1. 古典舞在线平台,为什么不能直接套一套通用论坛后台
古典舞这个圈子看似小众,真做平台的话需求非常立体。舞者和老师需要发教学视频、展示课堂片段,学员要按课程报名、提交作业,机构要发考级信息、比赛活动,平台运营方还得做内容审核和教师认证。把这么多需求压缩进一套通用社区系统里,往往上线一个月就开始到处补丁。这个项目之所以要单独做一套管理系统,核心诉求就一句话:把"内容展示、社区互动、线下业务"三类场景收进一套可维护的后台里,让运营不写SQL也能管内容,让用户不刷帖子也能找到课程。技术栈选型的理由我后面展开说,先给出最终落地的组合:SpringBoot 2.7.18 + JDK 1.8 + Vue 2 + Element UI + MyBatis 3.5 + MySQL 5.7,生产环境换成了 MySQL 8.0。这套组合不是性能天花板,但它有两个很现实的优势:团队成员基本都能接手,网上遇到的问题都有现成答案。对于中小垂直业务,稳定迭代比炫技重要得多。
这篇内容适合的人是:已经会 SpringBoot 和 Vue 的基本用法,但还没有独立做过完整全栈项目的开发同学;也适合准备做舞蹈、艺术类内容平台的初期团队参考。我不打算写那种从零搭环境的教程,而是把整个项目拆成数据模型、后端接口、前端页面、权限控制、部署实践几条线,把我在实际开发里觉得最值得讲的东西讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先想清楚数据长什么样,再写接口和页面
2.1 用户域:一套账号体系吃掉所有角色
做这类平台第一个容易踩的坑,是给每一种角色都建一张用户表。我在项目里一开始也差点这么干:教师一张表、学员一张表、管理员一张表。结果发现教师和学员都要看视频、都要发帖,用户信息字段大面积重叠,最后联查逻辑写得想骂人。
正确做法是统一一张用户主表 sys_user,角色关系用单独的 sys_role、sys_user_role 维护。教师认证信息(教学年限、擅长舞种、认证状态)拆到 teacher_profile 表,和 sys_user 一对一。这样设计的好处是:登录认证只认主表,业务流程按角色去扩展,不互相污染。下面是核心建表 SQL 的大致形态:
sql复制CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
`nickname` varchar(50) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`status` tinyint(4) DEFAULT '1',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意密码字段我从来不吃明文,用的是 Spring Security 里的 BCryptPasswordEncoder,每次都生成随机盐。这样即使数据库泄露,账号密码也不至于裸露出去。用户表状态字段 status 是给封号逻辑用的,运营后台的"禁用"按钮直接改这个字段,不需要删数据。
2.2 内容域:视频、课程、教师认证怎么关联
古典舞的内容形态主要两种:短视频/图文类教学帖,以及体系化的课程。我分别设计了 dance_video 和 dance_course 两张表,而不是把视频挂在帖子下面。原因是视频可能被多个场景复用:主页轮播、课程详情、教师主页展示。如果视频只属于某一篇帖子,后续做聚合查询就得写一堆奇怪的 JOIN。
dance_video 的字段大致是:标题、封面、视频地址、时长(单位秒)、舞种分类、上传用户、审核状态、播放量。舞种分类建议用单独的 category 表维护,因为中国古典舞的细分特别多:身韵、水袖、剑舞、团扇、汉唐舞,分类是可配置的,运营随时可能加。
课程表 dance_course 则记录课程名称、封面、授课教师、课程周期、购买/报名方式、适合人群、课程简介。注意这里我用了富文本字段,课程简介可能有图文混排。MySQL 里富文本内容我直接存 text 字段,配合自定义 TypeHandler 在 Java 端映射成对象,后面第 3 节会讲。
2.3 社区域:发帖、评论、点赞不要过度设计
论坛部分用的结构很常规:forum_post、forum_comment、post_like 三张表。帖子表包含标题、正文、作者、所属板块、置顶、精华等字段;评论表保存评论内容和回复的父评论 ID;点赞表用 post_id + user_id 加上唯一索引,防止一个人重复点赞。
这里想提醒一句:评论表里的 parent_id 一定要预留。古典舞教学视频下面的评论经常有"老师,这里提沉应该怎么练""回楼上,先练仰头再练含胸",这就是典型的楼中楼场景。如果一开始只做单层评论,后面加楼中楼等于把评论模块推倒重来。
2.4 活动域:报名表唯一索引很重要
平台还要承载线下工作坊、考级、比赛报名。活动主表 activity 存活动标题、封面、地点、开始时间、结束时间、名额上限、报名截止时间、状态。报名表 activity_signup 就三个核心字段:活动 ID、用户 ID、报名状态,再加一个活动 ID 和用户 ID 的联合唯一索引。
为什么要有唯一索引?用户连续点了两次报名,请求可能同时打到后端,光靠代码判断是否已报名会漏,数据库层面的唯一约束才是最后一道防线。报名成功后业务上要发站内信、要扣减剩余名额,这些放到事务里做,第 3 节会写。
2.5 数据库层面的几个通用设计细节
表引擎统一用 InnoDB,字符集统一 utf8mb4。utf8mb4 不是可选,是必须。用户昵称、帖子里插入一个表情符号,如果是老旧的 utf8 字符集,保存时直接报错,或者存进去变成问号。
所有表的常规索引按"高频查询的 WHERE 条件、排序字段"来建。比如帖子列表经常按 create_time 排序,那就会有一个 index_create_time;活动列表按 start_time 过滤,就建 index_start_time。不要给每个字段都建索引,更新成本和空间成本都不低,这个项目规模不需要走极端。
3. SpringBoot 后端分层与 MyBatis 的实战写法
3.1 目录分层:不是越高级越好,但基本职责要分明
后端我用标准三层结构,Controller -> Service -> Mapper。控制层只做参数接收和统一返回,业务判断放 Service,数据访问走 Mapper。很多人问我要不要上 DDD、要不要搞 CQRS,我的看法是这个体量的项目不要为了架构而架构,把分层边界划清楚,比引入一堆概念重要得多。
实际落地的目录结构大概是:
text复制com.dance.platform
├── controller # 接口层,只做参数校验和结果封装
├── service # 业务逻辑层
│ └── impl
├── mapper # MyBatis Mapper接口
├── entity # 数据库实体
├── dto # 入参出参对象
├── config # 配置类
├── common # 统一返回、异常、常量
└── interceptor # 拦截器(登录、日志)
3.2 一个完整接口链路:课程列表分页查询
课程列表是最典型的场景:前端要按分类筛选、按价格/热度排序、分页返回。我直接用 PageHelper 做分页插件,Controller 传入 pageNum 和 pageSize,Service 调 Mapper,返回 PageInfo。下面是 Controller 的写法:
java复制@RestController
@RequestMapping("/api/course")
public class CourseController {
@Autowired
private CourseService courseService;
@GetMapping("/list")
public Result<PageResult<CourseVO>> list(@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Integer sortType) {
return Result.success(courseService.pageList(pageNum, pageSize, categoryId, sortType));
}
}
Mapper 层对应的 XML 查询是带动态条件的。这里重点说一个容易忽略的点:如果列表页需要展示教师昵称、教师头衔、报名人数等跨表字段,不要用实体类接收,而是单独建一个 VO 类,SQL 直接 LEFT JOIN teacher_profile 和 activity_signup 统计子查询:
xml复制<select id="selectCoursePage" resultType="com.dance.platform.vo.CourseVO">
SELECT
c.id,
c.title,
c.cover_url,
c.price,
u.nickname AS teacherName,
tp.title AS teacherTitle,
(SELECT COUNT(*) FROM activity_signup s WHERE s.course_id = c.id) AS signupCount
FROM dance_course c
LEFT JOIN sys_user u ON c.teacher_id = u.id
LEFT JOIN teacher_profile tp ON tp.user_id = u.id
<where>
<if test="categoryId != null">
AND c.category_id = #{categoryId}
</if>
</where>
ORDER BY c.create_time DESC
</select>
这段 XML 里最容易出问题的是 LEFT JOIN 后分页的 COUNT 查询。PageHelper 会自己生成 count 语句,但如果 SQL 里的子查询、JOIN 太复杂,count 语句可能性能很差。我后来对课程列表这种高频接口做了单独优化,count 语句只统计主键 ID,用覆盖索引避免回表。
3.3 TypeHandler 实战:自定义映射处理 JSON 字段
刚才说课程简介是富文本,里面除了 HTML 还可能有视频链接列表、目录章节的 JSON 结构,实体里不能直接放 String 然后用 MyBatis 硬塞。我定义了一个 JSONTypeHandler,继承 MyBatis 的 BaseTypeHandler,在 setNonNullParameter 里把对象转成 JSON 字符串存入数据库,在 getNullableResult 里把数据库字符串解析成对象。
java复制@MappedTypes(List.class)
public class JsonListTypeHandler extends BaseTypeHandler<List<Object>> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
List<Object> parameter, JdbcType jdbcType) throws SQLException {
try {
ps.setString(i, MAPPER.writeValueAsString(parameter));
} catch (JsonProcessingException e) {
throw new SQLException("JSON序列化失败", e);
}
}
// getNullableResult 两个重载方法里做 JSON 反序列化
}
这样设计的好处是,课程表、视频表里的扩展字段都能以 JSON 形式灵活存储,不需要每加一个属性就改表结构。代价是查询这些字段时没法走索引、也没法直接在 where 里过滤,所以适合低频变更、高频整体读取的字段,不适合拿来当筛选条件。这个边界一定要想清楚。
3.4 事务设计:报名、扣减名额、发站内信必须绑在一起
活动报名的逻辑不是"插一条记录"这么简单:要查一次活动状态,检查名额是否满了,插入报名记录,扣减剩余名额,给用户发站内信。这个链路任何一个环节失败,都应该让数据库回到报名前的状态,否则会出现"用户显示报名成功但名额没扣"的脏数据。
我的做法是在 Service 方法上直接加 @Transactional(rollbackFor = Exception.class),同时把检查动作都放进同一个事务方法里:
java复制@Transactional(rollbackFor = Exception.class)
public void signUp(Long activityId, Long userId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null || activity.getStatus() != 1) {
throw new BizException("活动不存在或未开放报名");
}
if (activity.getRemainCount() <= 0) {
throw new BizException("活动名额已满");
}
int count = activitySignupMapper.countByActivityAndUser(activityId, userId);
if (count > 0) {
throw new BizException("请勿重复报名");
}
ActivitySignup signup = new ActivitySignup();
signup.setActivityId(activityId);
signup.setUserId(userId);
signup.setStatus(1);
activitySignupMapper.insert(signup);
// 扣减名额用乐观锁思路:UPDATE ... SET remain_count = remain_count - 1 WHERE id = ? AND remain_count > 0
int updated = activityMapper.decreaseRemainCount(activityId);
if (updated == 0) {
throw new BizException("名额不足");
}
messageService.sendSignUpNotice(activityId, userId);
}
扣减名额那一步是最容易产生并发问题的。我把 SQL 写成 UPDATE 表 SET remain_count = remain_count - 1 WHERE id = ? AND remain_count > 0,让数据库自己保证原子性,比先用 SELECT 查再 UPDATE 可靠得多。
3.5 统一异常与统一返回
前端要的不是一堆乱七八糟的 Map 或者 String,是一个稳定结构。我统一封装了 Result 对象,code 表示业务错误码,msg 表示提示信息,data 是业务数据。所有业务异常都抛 BizException,由全局 @RestControllerAdvice 捕获转成 Result 返回。这样前端 axios 拦截器里只需要判断 code 就能统一处理登录过期、参数错误、业务失败,页面代码清爽很多。
4. Vue 前端:从登录态到视频播放的完整串联
4.1 登录态与路由守卫
前端用的是 Vue 2 + Element UI,管理端和用户端是两套页面,但在同一个工程里通过路由区分。登录成功后后端返回 JWT token,前端存到 localStorage,axios 请求拦截器里读取 token 放进 Authorization 头。
路由守卫写在 router.beforeEach 里:没有 token 且去的页面没有在白名单(登录页、公开课程页)就重定向到登录页;有 token 但去管理端页面时,再向后端接口校验角色。这里额外的经验是:前端守卫只是提升体验,真正的权限校验一定要在后端做,因为前端的判断随时可以绕过。
4.2 axios 请求封装的实际写法
请求封装不复杂,但坑非常多。我在 src/utils/request.js 里创建 axios 实例,设置 baseURL 为 /api,超时时间 10 秒,然后加两个拦截器。响应拦截器里如果 code 表示登录失效,要先把 localStorage 里的 token 清掉,再跳登录页,并给出"登录已过期,请重新登录"的提示。竞态问题也要考虑:多个接口同时返回 401,不要跳转页面跳好几次,引入一个标志位控制。
javascript复制service.interceptors.response.use(
response => {
const res = response.data
if (res.code === 401) {
if (!isRedirecting) {
isRedirecting = true
localStorage.removeItem('token')
router.push('/login')
Message.error('登录已过期,请重新登录')
}
return Promise.reject(new Error('unauthorized'))
}
return res
},
error => Promise.reject(error)
)
4.3 课程列表页与 Element UI 的配合
管理端的课程管理页用到 Element UI 的 el-table、el-pagination、el-form。表格列里有封面缩略图、标题、价格、教师、状态。状态列我会用 el-tag 的 type 属性显示不同颜色,这个视觉反馈对运营非常友好。分页组件绑定 current-page 和 page-size,监听 current-change 重新拉数据,代码量不大但是用户体验差异明显。
用户端的课程列表则是卡片式布局,用 el-card 循环渲染,图片懒加载用 v-lazy,避免首屏加载几十张图把带宽吃满。
4.4 视频播放:mp4 直出和 hls 流媒体要兼容
这是古典舞平台最核心的体验。上传的视频如果是简单 MP4,前端用 video 标签直接播放,后端提供带签名的访问地址即可。但平台经常有几十上百分钟的完整课程,一个 MP4 动辄几个 G,直接传上来既占带宽又卡。我的方案是:长视频转码成 HLS 协议,也就是 m3u8 + ts 切片,前端用 video.js 或者 hls.js 播放,这样拖动进度条秒开,边下边播。
js复制methods: {
loadPlayer() {
if (this.videoUrl.endsWith('.m3u8')) {
const hls = new Hls();
hls.loadSource(this.videoUrl);
hls.attachMedia(this.$refs.video);
}
}
}
视频文件本身不要往 Tomcat 里塞,单独放到对象存储(我这边用的 MinIO)或者 CDN,数据库只存文件路径和时长。应用服务器只管数据接口,静态资源按字节流出服务器,反而拖垮接口性能。
4.5 富文本编辑器的选择
课程简介、活动详情都需要图文混排。我调研过 wangEditor、Quill、Tinymce,最后落地的是 wangEditor,因为接口风格和 Vue 配合顺,上传图片的逻辑也简单。富文本编辑器输出的 HTML 存到数据库后,展示端要注意 XSS 过滤,后端做一层白名单清洗,不能直接输出用户提交的原始 HTML。这一点对"企业级"尤为关键。
5. 权限、内容审核和企业级"该有的样子"
5.1 JWT 令牌怎么做得稳妥
JWT 的用法网上很多,我只说实际项目里几个容易踩的细节。第一,token 中只放 userId、角色编码,不放手机号、密码这类敏感信息。第二,token 要设置合理的过期时间,管理端建议 2 小时,用户端可以 7 天。第三,服务端要保有拦截器,每次请求检查 token 签名和过期时间,不要信任前端的任何传递。第四,用户被禁言或封号时,光删 token 没用,还要在拦截器里查一次用户状态,否则改数据库置为禁用之后,老 token 还是能访问。
5.2 拦截器实现角色权限控制
后端我用 HandlerInterceptor 写了一个 AuthInterceptor,里面用自定义的 @RequireRole 注解标注 Controller 方法需要的角色。拦截器中读取 token 里的角色编码,和注解要求做比对,不匹配直接抛 403。这样权限控制全部集中在注解上,写接口的人只需要声明"这个方法管理员才能调",可读性和维护性都很好。
需要说明的是,这是面向中小型业务最简单的方案。如果角色维度变得非常细,比如"运营只能管理内容、不能看财务",那再引入单独的权限表也不迟,但不要一开始就上 RBAC 全家桶,不然一个列表权限都要配半天。
5.3 内容审核状态机
舞蹈视频、帖子、课程都涉及内容审核,我统一用一个状态字段控制,取值如下:
- 0:待审核
- 1:已发布
- 2:已驳回
- 3:已下架
流程是:用户发布内容 → 状态为 0 待审核 → 管理员审核通过后变为 1,驳回则变为 2 → 已发布内容可以手动下架变为 3。运营后台的管理列表里,待审核内容单独开一个页签,避免和已发布内容混在一起。
这个状态机不要做复杂,不要引入工作流引擎。业务量没到那个程度,用枚举加 Service 方法维护状态流转,反而比引擎好改、好排查。
5.4 操作日志:管理员干了什么必须留痕
企业级平台有个默认要求:谁在后台改了什么内容,必须能追溯。我在后端做了个简单的 AOP 切面,拦截管理端 Controller,把请求路径、参数、操作人、操作时间、操作结果写入 sys_operation_log 表。运营改课程价格、驳回用户帖子、封禁账号这样的高危操作,全部记录在案。
切面实现本身不复杂,难在参数序列化:一个请求的 body 可能是大文本,序列化会撑爆日志表。我的处理是把超过 2000 字符的参数截断,只保留关键前段。
6. 部署上线与那些值得写进复盘里的坑
6.1 前后端分离的一次性部署
项目最终是前后端分离交付。后端用 mvn clean package -DskipTests 打成 jar 包,生产环境用 nohup 拉起。前端执行 npm run build 生成 dist 目录,交给 Nginx。Nginx 配置两个核心 location:/ 指向前端静态文件,/api/ 通过 proxy_pass 转发到 127.0.0.1:8080,同时把 WebSocket 需要的 upgrade 头也配置上,给论坛的实时消息预留空间。
nginx复制server {
listen 80;
server_name dance.example.com;
location / {
root /opt/dance-web/dist;
index index.html;
try_files $uri $uri/ /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;
}
}
try_files 那行千万别省略,这是 Vue Router history 模式打包后刷新 404 的官方解药。
6.2 数据库连接池与 MyBatis 缓存
SpringBoot 默认用 HikariCP,参数上我最常调的是 maximum-pool-size。一开始我用默认 10,结果高峰期活动报名接口出现连接等待,调大到 20 后明显缓解。但这不代表无限调大,连接数是和数据库 CPU 相关的,盲目调到 50 反而会拖垮 MySQL。
MyBatis 一级缓存默认开启,作用范围是同一个 SqlSession,Spring 集成后基本是每次请求开启关闭,所以一级缓存提升有限。二级缓存我个人在这个项目里没有全局开启,查询频率高且数据不敏感的分类表单独开了 cache 标签,其他业务表不碰,因为缓存脏数据问题比性能问题更头疼。
6.3 跨域问题:前后端联调的经典麻烦
联调阶段最常见的报错是前端控制台出现 CORS。我先在后端加了跨域配置类,允许本地开发端口访问,但上线时通过 Nginx 同域代理后,前端请求已经落在同一个域名下,理论上不需要跨域。麻烦的是配置顺序,CORS 配置如果被拦截器先处理掉,会导致 preflight 请求得不到响应。我的解决方式是把跨域配置交给 WebMvcConfigurer 里的 addCorsMappings 统一处理,不要在拦截器里重复设置头。
6.4 字符集和 emoji 的坑
数据库字符集这个问题,我在一个深夜被坑过。用户昵称带了一个表情符号,上线当天运营突然反应有个用户无法注册。排查发现建库时沿用了旧的 utf8 字符集,emoji 根本存不进去。处理办法是把涉及用户输入的库表全部 ALTER 成 utf8mb4,同时确认 MySQL 连接串带上 characterEncoding=utf8。教训是:新项目建库永远直接选 utf8mb4,不要等到出事再改。
6.5 上传大小限制:看似小事,影响体验
运营后台要上传课程封面、活动海报,大一点的视频如果走接口上传,会触发 SpringBoot 默认 1MB 限制。我在 application.yml 里显式配置了 multipart 上限,图片 5MB、临时视频 200MB,并且在前端上传组件里同步做类型和大小校验。特别注意:如果用的是 Nginx 转发,还要检查 client_max_body_size,否则 Nginx 会先拒绝请求,后端日志里根本看不到。
7. 复盘时最想保留的三条经验
整个项目做下来,我最深刻的感受是:古典舞在线交流平台这类垂直系统,难点不在技术多前沿,而在怎么把舞蹈内容、社区互动和线下报名这些需求理清,然后选择最稳的组合去实现。SpringBoot + Vue + MyBatis + MySQL 这套方案,确实能让一个三五人的团队快速跑起来,并且不会在维护期变成灾难。
如果让我给后来者一个可执行的建议,我会说:开工之前先把数据模型画在纸上,把每一张表和每一条关系讲给一个完全不懂这个项目的人听,讲不清楚的地方就是要重构的地方。代码是后面的事,数据清楚了,前后端的开发效率至少快一倍。
最后分享一个我一直在用的小技巧:后端每个接口写完,先不急着写前端页面,用 Postman 把正常流程、异常流程各打一遍,尤其是那个"报名名额已满"和"重复报名"的分支。接口层能挡住的问题,就尽量不要流到页面层。这样前端的同学会感谢你的。
