1. 项目定位:我要解决的三个核心痛点
先聊点背景。古典舞这个圈子很有意思,线上内容不少,但真正能"交流"的地方太少了。B站的弹幕和评论区更像是一群人在围观,而不是舞者在对话;培训机构自己做的小程序又只服务自己学员;抖音、小红书上偶尔刷到好看的成品舞,想跟这位舞者请教一个动作细节,私信大概率石沉大海。更别提那些刚入门的爱好者,连"踢后腿到底该用哪块肌肉发力"这种问题都不知道去哪里问。
所以我打算做的这个古典舞在线交流平台,核心不是"视频点播",而是"以舞会友"——让舞蹈爱好者发布自己的练习视频、记录练功笔记、互相点评动作、约课约练。技术栈选定为 Java SpringBoot + Vue3 + MyBatis + MySQL,前后端完全分离。项目目标很具体:用户注册登录后能上传自己翻跳或原创的古典舞视频,按舞种、难度、风格分类浏览,可以对别人的作品点赞、收藏、评论,还能发图文形式的练功日记,关注喜欢的舞者后动态会推送到首页。管理员端则负责视频审核、用户管理、分类管理、数据统计。
说白了,这就是一个"古典舞垂直领域的轻社区"。之所以不做成大而全的社交平台,是因为垂直领域的内容调性差异极大——古典舞用户需要的是动作拆解、身韵讲解、练功打卡这类深度内容,而不是泛娱乐的流量逻辑。
整个项目从立项到跑通主流程,我一共花了三周左右。下面把完整的设计思路、核心代码和踩坑记录都写出来,给正在做类似前后端分离项目的朋友一个参考,尤其是那些"想做点有垂直领域特色的毕设或实战项目"的开发者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型逻辑:为什么是这套组合
2.1 后端选型:SpringBoot不是唯一答案,但最稳
我在选型时其实纠结过一段时间。做社区类平台,后端可选项很多:Node.js的Express/NestJS、Python的Django/Flask、Go的Gin,甚至PHP的Laravel都有各自优势。但最后我选了SpringBoot,理由很朴素——生态成熟、资料多、招聘市场认可度高。
SpringBoot 2.7.x是我当前用的版本。之所以没用3.x,是因为3.x基于Jakarta EE规范,很多老MyBatis版本和第三方组件的兼容性还有坑,而且2.7.x的安全更新还在持续。对于这种偏实战教学的项目,稳妥大于追新。
MyBatis选它的原因更直接:我对SQL的控制欲比较强。相比JPA/Hibernate那种"帮你把SQL也生成了"的方式,MyBatis让我能亲手写每一条SQL,这在做复杂联表查询、统计报表时优势非常明显。后面做用户活跃度统计时,一条join加group by的SQL写下去,心里特别踏实。
MySQL不用多说,社区类项目最成熟的持久化方案,InnoDB引擎、事务支持、全文索引都能满足需求。版本我用的8.0.32,注意8.0以上默认的认证插件是caching_sha2_password,如果连数据库时报错,要在JDBC URL上加allowPublicKeyRetrieval=true。
2.2 前端选型:Vue3 + Vite,为什么弃用Vue2
前端用Vue3,组合式API(Composition API)写业务逻辑确实清爽。以前Vue2用Options API,一个组件里data、methods、computed、watch分开写,关联逻辑被拆得七零八落;用Vue3的setup函数,一个功能的响应式数据、方法、计算属性可以聚在一起,维护成本低了一个量级。
构建工具选了Vite而不是Webpack。Vite基于ES Module,开发环境冷启动速度极快,改代码热更新几乎是秒级。Webpack在做大型复杂应用时功能更全,但古典舞平台这种中小型项目,Vite的开发体验简直是一种享受。
UI组件库用的Element Plus,Vue3生态里最成熟的方案。表格、表单、弹窗、分页这些后台管理常用组件开箱即用。前台C端页面我则是手写样式加部分组件混用,通用组件用现成的,个性化展示区自己动手,保证舞蹈视频列表的卡片风格足够灵活。
2.3 前后端分离的架构边界
前后端分离这个决策,说起来容易,真正落地时得划清边界。我的原则是:后端只提供RESTful API,不返回任何视图层内容;前端所有页面路由由Vue Router控制,所有数据获取通过Axios调用API完成。两者通过JSON交换数据。
具体到项目里,后端接口统一定义在/api前缀下,按资源划分:/api/user、/api/video、/api/comment、/api/favorite、/api/follow。前端通过环境变量配置API网关地址,开发环境用代理解决跨域,生产环境用Nginx反向代理。
权限控制采用Spring Security + JWT的方案。用户登录后拿到token,后续每次请求在Header里携带Authorization: Bearer
3. 数据库设计:一张ER图说不完的那些细节
数据库是整个项目的根基,表结构设计得好,后面写SQL能省一半力气。我这个项目一共设计了10张核心表,这里挑有代表性的几张详细说说。
3.1 用户表:不止是账号密码
用户表(t_user)是最基础的,但也是最容易被低估的。除了常规的id、username、password、nickname、avatar、email、phone、create_time这些字段外,我还加了几个针对舞蹈社区场景的字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| dance_level | varchar(20) | 舞龄等级:入门/初级/中级/高级 |
| style_preference | varchar(100) | 偏好舞种:古典舞/汉唐舞/敦煌舞/水袖舞等 |
| bio | varchar(500) | 个人简介,展示在个人主页 |
| status | tinyint | 账号状态:0禁用 1正常 |
dance_level字段在后续做内容推荐时会很有用,可以按用户等级推送合适的教学内容。style_preference用逗号分隔存多个值,查询时用FIND_IN_SET,简单高效。
3.2 视频表与分类表:别把分类做成枚举
视频表(t_video)我一开始把分类直接设计成枚举字段,后来意识到这是个错误——古典舞分类体系是动态演进的。今天你只分"古典舞、民族舞",明天可能会增加"汉唐舞、敦煌舞、水袖舞、剑舞"等细分类别,用枚举就得改代码。改成独立的分类表(t_category)后,前端分类导航、后台分类管理、视频归类都灵活了。
视频表核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 发布者ID |
| category_id | bigint | 分类ID |
| title | varchar(200) | 视频标题 |
| description | text | 视频描述(舞蹈心得、动作要点) |
| video_url | varchar(500) | 视频文件地址 |
| cover_url | varchar(500) | 封面图地址 |
| duration | int | 视频时长(秒) |
| play_count | bigint | 播放量 |
| like_count | int | 点赞数 |
| favorite_count | int | 收藏数 |
| comment_count | int | 评论数 |
| status | tinyint | 审核状态:0待审 1通过 2驳回 |
| create_time | datetime | 发布时间 |
这里有个设计取舍:play_count、like_count这些计数,到底是在视频表里冗余存储,还是每次用count()实时统计?我的建议是冗余存储。社区平台的高频操作是浏览视频列表,如果列表页每展示一个视频都要去关联查询点赞表做count(*),数据量上来后数据库压力会非常大。用冗余字段配合定时任务或异步消息同步,是更工程化的做法。后面我会专门讲点赞这种高频操作的计数一致性处理。
3.3 点赞、收藏、关注:三张关系表的设计共性
点赞表(t_video_like)、收藏表(t_favorite)、关注表(t_follow)这三张表结构上非常相似,核心都是"谁对什么做了什么"。设计时我统一遵循了"唯一约束+联合索引"的原则。
比如点赞表:
sql复制CREATE TABLE `t_video_like` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '点赞用户ID',
`video_id` bigint NOT NULL COMMENT '视频ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_video` (`user_id`,`video_id`) USING BTREE,
KEY `idx_video_id` (`video_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频点赞表';
唯一约束uk_user_video保证了同一个用户对同一个视频只能点赞一次,这比在代码里先查询再判断是否已点赞更加可靠——数据库层面直接防住了重复数据。联合索引则是为了让"查询某个视频下的所有点赞用户"和"查询某个用户点赞过的所有视频"两个方向都能命中索引。
评论表(t_comment)单独说一下。评论支持多级回复,但我没有用传统的parent_id无限层级设计——那种设计在删除父评论时非常麻烦(要递归删子评论)。我采用了最简单的方案:只支持一级评论和针对一级评论的回复,回复时记录parent_id和reply_to_user_id。虽然牺牲了一点灵活性,但在小型社区里体验完全够用,且实现和维护成本极低。
3.4 练功日记表:图文混排的存储方案
舞蹈社区除了视频交流,练功日记也是核心内容形态。用户每天练了什么动作、哪里进步了、哪里还不到位,用图文形式记录下来。日记表(t_diary)设计时,正文我用的是TEXT类型存Markdown格式内容,配图则单独存一张子表。
为什么不把图片直接存到正文里?因为后期的内容审核、图片分离管理、CDN加速都会更灵活。正文里图片的位置用相对路径占位,渲染时再替换为完整URL。这是博客系统惯用做法,社区平台同样适用。
4. 后端核心功能实现:从登录到视频发布的完整链路
4.1 Spring Security + JWT:无状态认证的落地细节
认证是前后端分离项目的第一个拦路虎。我用的方案是Spring Security + JWT,核心思路:登录接口放行,其余接口校验token。SecurityConfig里最关键的是自定义过滤器JwtAuthenticationTokenFilter,它可以理解成一个"守门员"——每个请求进来,先看Header里有没有合法token,有就放行并往SecurityContext里塞用户信息,没有就按匿名用户处理。
JWT生成时,我存储的claims包括:userId、username、loginTime。token有效期设置为2小时。这里要提醒一个容易踩的坑:JWT一旦签发,服务端无法主动让它失效。所以如果需要"退出登录立即失效"或者"修改密码后旧token作废"这种需求,光靠JWT本身是做不到的。我的解决方法是在Redis里存一份token黑名单:退出登录时把token的jti(唯一标识)存进Redis,过期时间设成和token一致;校验时先查黑名单,命中就拒绝。
密码加密用的是BCryptPasswordEncoder。这个不是简单的哈希,它内置了盐值,同样的密码每次加密结果都不同,但校验时能正确匹配。千万不能用MD5直接存密码,哪怕加盐也容易出问题。
4.2 MyBatis使用细节:XML文件的参数传递与结果映射
MyBatis的使用中,最容易翻车的就是参数传递和结果映射。
参数传递方面,我习惯用@Param注解明确标记参数名。比如分页查询视频列表时:
java复制List<VideoVO> selectVideoPage(@Param("categoryId") Long categoryId,
@Param("offset") int offset,
@Param("size") int size);
对应的XML里,用#{categoryId}、#{offset}、#{size}引用。这里必须强调:如果参数是对象,直接用#{属性名};如果是多个参数,不加@Param的话,MyBatis会按arg0、arg1或param1、param2来引用,很容易写错。
结果映射方面,由于视频列表页需要同时展示发布者昵称和头像,我设计了一个VideoVO类,在SQL里直接join用户表查出来,而不是先查视频再循环查用户(避免N+1查询问题):
xml复制<select id="selectVideoPage" resultType="com.example.vo.VideoVO">
SELECT v.id, v.title, v.cover_url, v.video_url, v.duration,
v.play_count, v.like_count, v.favorite_count,
u.nickname, u.avatar
FROM t_video v
LEFT JOIN t_user u ON v.user_id = u.id
WHERE v.status = 1
<if test="categoryId != null">
AND v.category_id = #{categoryId}
</if>
ORDER BY v.create_time DESC
LIMIT #{offset}, #{size}
</select>
这里有个值得说的细节:动态SQL里的
4.3 视频上传:本地存储实现与静态资源映射
视频上传是视频平台的核心功能。我采用的是前后端分离模式下最常见的方案:前端用Element Plus的Upload组件选文件,FormData方式POST到后端接口,后端把文件存到服务器指定目录,返回可访问的URL。
后端接收文件的代码核心就这段:
java复制@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.error("上传文件不能为空");
}
// 原始文件名
String originalFilename = file.getOriginalFilename();
// 后缀名,如 .mp4
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
// 生成新文件名
String newFileName = UUID.randomUUID().toString().replace("-", "") + suffix;
// 年月子目录,避免单目录文件过多
String dateDir = new SimpleDateFormat("yyyyMMdd").format(new Date());
String filePath = uploadDir + "/" + dateDir + "/" + newFileName;
File dest = new File(filePath);
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
file.transferTo(dest);
String url = "/files/" + dateDir + "/" + newFileName;
return Result.success(url);
}
关于上传,有几个经验:
- 文件名一定不要用用户上传的原始名。为了避免重名,也为了避免中文文件名带来的URL编码问题,统一用UUID重命名。
- 目录按日期分文件夹,防止单目录下文件数量过多导致文件系统性能下降。
- 静态资源映射需要在SpringBoot里配置。在application.yml中加了spring.mvc.static-path-pattern=/files/**,然后把本地上传目录映射到静态资源路径,这样视频URL才能被直接访问。这个配置忘了写的话,前端拿到URL会404,非常容易踩。
4.4 点赞操作的并发安全与计数一致性
点赞这个功能,看起来简单,里面坑不少。核心问题是:用户点赞时,既要往点赞表插入记录,又要让视频表的like_count加1,两个操作必须保证原子性。
我的实现分为两步。第一步,插入点赞记录,利用刚说的唯一约束来防止重复点赞:
java复制@Override
@Transactional(rollbackFor = Exception.class)
public Result likeVideo(Long userId, Long videoId) {
VideoLike unlike = videoLikeMapper.selectByUserIdAndVideoId(userId, videoId);
if (unlike != null) {
return Result.error("您已点赞过该作品");
}
VideoLike videoLike = new VideoLike();
videoLike.setUserId(userId);
videoLike.setVideoId(videoId);
videoLikeMapper.insert(videoLike);
videoLikeMapper.countIncrement(videoId);
return Result.success();
}
视频表like_count字段用update语句实现自增,update t_video set like_count = like_count + 1 where id = #{videoId}。这条SQL在MySQL里是原子操作,多个用户同时点赞同一个视频时不会出现覆盖问题。
事务上用了@Transactional,点赞表插入和视频表计数更新在同一个事务里,要么都成功要么都失败。这在高并发场景未必是最优方案(可能用消息队列削峰),但对于古典舞交流平台这个量级,事务保证一致性完全够用。
取消点赞则是反向操作:删点赞记录,like_count递减1。同样用唯一约束定位记录,delete from t_video_like where user_id = #{userId} and video_id = #{videoId},然后执行countDecrement。
5. 前端实现:从组件化设计到交互细节
5.1 Vue3项目搭建与目录结构
用Vite命令行工具创建项目:npm create vite@latest front-end -- --template vue,然后按需安装vue-router、pinia(状态管理)、axios、element-plus。
目录结构我习惯按业务模块划分,而不是按技术类型划分:
code复制src/
├── api/ # 接口请求函数
├── assets/ # 静态资源
├── components/ # 通用组件
├── router/ # 路由配置
├── stores/ # Pinia状态
├── utils/ # 工具函数
└── views/ # 页面组件
├── Home.vue
├── VideoList.vue
├── VideoDetail.vue
├── Upload.vue
├── Login.vue
├── Register.vue
├── UserCenter.vue
└── admin/ # 后台管理页面
相比按文件类型划分(views、components按类型各扔一个目录),按业务模块划分的直观好处是:改某个功能时,相关文件在同一个目录下就能找到。
5.2 路由懒加载与组件通信
路由配置里,我用了Vue Router的懒加载写法:
javascript复制const routes = [
{ path: '/', component: () => import('../views/Home.vue') },
{ path: '/video/:id', component: () => import('../views/VideoDetail.vue') },
{ path: '/upload', component: () => import('../views/Upload.vue'), meta: { requiresAuth: true } }
];
按需加载让首屏包体积小了很多。上传页、个人中心这些不常访问的页面,用户真正点进去时才加载对应JS。路由守卫中加了一个全局前置守卫,检查to.meta.requiresAuth为true时,如果localStorage没有token,就跳转登录页。
组件通信这块,我用Pinia管理全局状态。最典型的是用户信息:登录成功后把用户信息存到Pinia的user store里,这样导航栏头像、昵称、发视频按钮的权限控制都能直接取用。Pinia相对Vuex的优点是API更简洁,没有mutations和actions的区分,这对简化代码是好消息。
5.3 Axios封装与拦截器:一处统一处理,处处清爽
Axios如果不做封装,每个页面都在重复写错误处理逻辑,代码很丑。我封装了一个request.js,核心功能:
- 请求拦截器:从localStorage取token,放到Authorization请求头。
- 响应拦截器:统一处理业务状态码。后端返回格式固定为{ code, message, data },code为200表示成功。非200时,如果是401(token过期或无效),清除本地登录态并跳转登录页;其他错误码用ElMessage提示。
封装的好处非常明显。项目后期几乎不用在每个请求里单独写token塞入逻辑和401处理,一次封装,全局生效。这里给个建议:**后端的code用业务码,不要依赖HTTP状态码来判断业务成功失败。**HTTP状态码只管传输层(200表示请求到了服务器并返回了响应),业务失败可能HTTP状态码也是200,但code是50001。前后端统一以业务code为准,逻辑才清晰。
5.4 视频列表页:瀑布流布局与播放页设计
视频列表页是C端用户的首页。我没有用传统的Row/Col网格布局,而是用CSS的columns属性做瀑布流效果,高度不一的视频卡片自动排列,视觉上更像b站那种浏览体验。每张卡片显示封面、时长、标题、作者昵称、点赞数,鼠标悬停时封面播放预览动图。
视频播放页用了原生HTML5的video标签,没有上视频播放器SDK。对于古典舞教学这种场景,用户可能反复回看某个动作,我给video标签加了controls、playsinline属性,并设置preload="metadata"(只预加载元数据,不预加载整个视频,节省流量)。播放页下方是评论区,通过videoId加载评论列表,支持发表评论和回复评论。
这里有个小交互设计:点赞按钮节流。用户快速连续点击时,前端以最后一次点击为准再发请求,避免同一用户短时间内对同一视频发出多次请求。我在utils里写了个简单的节流工具,某些需要高频操作的按钮都接入。
6. 部署与踩坑:数据库时区、端口占用、跨域问题全记录
6.1 后端打包与启动:jar包部署的玄学
SpringBoot项目打包直接用Maven插件,mvn clean package -DskipTests,生成fat jar(包含所有依赖的jar包),然后用java -jar命令直接启动。部署到服务器上,我习惯用nohup命令后台运行:
bash复制nohup java -jar dance-platform-server-1.0.0.jar --spring.profiles.active=prod > server.log 2>&1 &
--spring.profiles.active=prod指定生产环境配置。我没有把数据库密码等敏感信息写死在application.yml里,而是按环境拆了dev和prod两份配置,生产配置通过环境变量注入数据库连接信息,避免配置文件泄露带来的安全问题。
6.2 MySQL时区问题:8小时时差的经典坑
这个坑我敢说90%的Java后端开发都踩过。连接MySQL时,如果JDBC URL里没指定serverTimezone,或者指定得不正确,查询时间字段时会出现"下午6点存进去,查出来变成凌晨2点"的情况。
我的解决方法是在JDBC URL上显式指定:
code复制jdbc:mysql://localhost:3306/dance_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
serverTimezone=Asia/Shanghai把时区对齐到北京时间。另外MySQL服务端本身的时区设置也要检查,登录MySQL执行select now(),如果返回的时间和系统时间不一致,就在my.cnf里加上default-time-zone = '+08:00'再重启服务。
6.3 前端跨域:开发环境代理与生产环境Nginx转发
前后端分离项目必有跨域问题。开发环境我用Vite的代理解决:
javascript复制// vite.config.js
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
这样前端请求/api/user/login,开发服务器会把它转发到http://localhost:8080/api/user/login,浏览器看起来是同源的,不会触发跨域。
生产环境则用Nginx做反向代理。前端构建后生成dist目录,配好Nginx静态资源服务,再把/api路径代理到后端服务端口:
nginx复制server {
listen 80;
server_name your-domain.com;
root /var/www/dance-platform/dist;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /files/ {
proxy_pass http://127.0.0.1:8080;
}
location / {
try_files $uri $uri/ /index.html;
}
}
关键点是最后那个try_files配置。Vue Router使用history模式时,如果用户直接访问某个子路由路径(比如/video/123),Nginx找不到对应物理文件会返回404。try_files $uri $uri/ /index.html让所有找不到的路径都回退到index.html,由前端路由接管。
6.4 缓存策略与性能优化
视频首页的动态推荐列表,我加了Redis缓存。key为video:recommend:page:{page},value为JSON序列化后的列表数据,过期时间5分钟。这能极大降低数据库压力——如果是热帖被疯狂刷,每次都查MySQL大概率会拖垮数据库。
MyBatis层面也做了一级缓存和二级缓存的配置。一级缓存默认开启,同一个SqlSession内重复查询会直接返回缓存结果;二级缓存需要手动开启,在Mapper.xml里加
点赞、评论这类写多读少的数据,更新时采用缓存延迟双删的策略:先更新数据库,再删除缓存中对应数据,让下次读请求重新加载。这个策略能有效避免缓存与数据库不一致的问题,具体实现是在Service层更新操作后,执行一次redisTemplate.delete。
这个项目后续还可以怎么扩展
整个开发过程走下来,我的体会是:古典舞在线交流平台这种垂直领域项目,技术难度适中,但领域特征的把握很考验产品思维——你得真的理解舞者之间的交流需求,懂他们需要什么,然后在功能设计上去呼应这些需求。
这里再分享一个测试技巧:视频上传功能调试时,千万别每次都传大视频文件,很浪费时间。我通常是先用手机随便录一个3秒的小视频,或者找一个几MB的短视频反复测,等整套上传、审核、展示流程跑通了,再拿真实舞蹈视频做最终验收。这能帮你节省大量开发时间。
视频审核功能我目前做的是管理员手动审核,真正的生产环境可以考虑引入机器初审(画面模糊、时长过短自动标记),但第一步让人工把流程跑规范和顺滑,远比一上来就追求全自动化实在。
这个项目的数据库表结构、核心API、前端组件化方案,理论上也能直接迁移到其他垂直兴趣社区(比如汉服展示、摄影作品交流、手工艺教学分享),只需要替换内容分类和社区调性就行。如果你正好在纠结要不要自己动手做一个类似项目,我的建议是:别等满腔热情褪去,先把用户表建出来,把登录跑通,后面每一步都是水到渠成的事。
