1. 这个平台到底在解决什么问题
先说个我实际遇到的场景。去年有个朋友找我帮看一个SpringBoot毕设项目,题目就是"特殊儿童家长教育能力提升平台"这类。一开始我以为又是那种纯CRUD的管理系统,结果看了需求和设计之后,才意识到这个领域的痛点非常真实:特殊儿童的康复和教育,靠的从来不只是医院或学校里的老师,家长的参与程度和干预能力,直接决定孩子的恢复天花板。但大多数家长既没专业背景,又没法随时找到医生或特教老师咨询,网上信息鱼龙混杂、东拼西凑,很难形成系统的学习路径。
这个平台的核心价值,就是把"碎片化的经验分享"升级成"体系化的能力提升方案"。它既要给家长提供结构化的课程资源(比如语言训练、感统训练、行为干预这些模块),又要提供专家问答和社区交流的出口,同时还要把孩子的学习进度、成长档案记录下来,让家长能看到一个学期前后的变化。对于用SpringBoot做毕业设计或者个人项目的开发者来说,这个题目的好处也很明显:业务上不是那种假大空的"论坛+后台",每一块功能都有实际的使用场景;技术上又能把权限管理、文件上传、拦截器、分页查询、异常处理这些SpringBoot的高频知识点全部串起来。
这篇文章我会先介绍整个项目的需求拆分和技术选型思路,再重点讲解开发过程中那些绕不开的实现细节。尤其会花比较大篇幅写实测中踩过的坑,比如分页插件的一个隐蔽配置问题、拦截器放行路径的坑、上传PDF做XSS过滤时遇到的问题,这些内容在大部分教程里根本找不到现成的答案。
这个平台适合谁来参考?如果你正在做同类型基于SpringBoot的JavaWeb毕设,或者你想快速掌握一套完整项目的开发流程而不只是照着现成代码抄一遍,那这篇文章应该能帮你少走不少弯路。
2. 从需求到功能模块:不是所有平台都需要"大而全"
2.1 核心角色的划分
做这种系统,第一步不是写代码,而是把角色和权限边界想清楚。平台面向的用户绝对不是单一群体,家长要学习课程、要提问;专家要上传资料、要回答问题;管理员要审核内容、要统计数据。
我在设计时把用户体系拆成三类:
- 家长用户:核心功能是浏览课程、加入学习、记录学习笔记、发布求助帖、参与社区交流、维护自家孩子的成长档案。
- 专家用户:具备比家长更高的权限,可以发布系统的视频课程和图文资料,可以在问答区回答家长提问,同时拥有对自己名下课程的管理权限。
- 管理员用户:负责用户审核、课程审核、问答区内容审核、数据统计看板。
这里的权限不是简单用一个role字段搞定的,因为专家上传课程后,课程与专家的绑定关系直接影响后续的数据查询。如果只用角色字段,课程表的作者ID还需要关联到用户表,在业务层再去判断当前用户是否有权编辑这条课程,逻辑会变得混乱。所以我的做法是:用户表只存角色枚举,通过Shiro或Spring Security的拦截配置控制接口级别的访问,而课程、帖子的操作权限,在Service层根据实体里的authorId做二次校验。
2.2 功能模块的取舍策略
很多初学者容易犯一个错,拿到题目后什么功能都往上堆,结果每个功能都只做了表面一层。这个项目里我刻意砍掉了几个看起来不错但实际性价比很低的功能:
- 在线直播课堂:技术上要对接WebRTC或第三方直播服务,部署成本高,而且毕设答辩时演示不稳定,风险很大。我把它替换成了视频课程+在线答疑的组合,既能体现"教育能力提升"的核心诉求,又不容易出事故。
- 在线测评打分:家长做心理量表后台自动算分,听起来很高级,但要保证量表本身的效果科学性和算法合理性,不是简单加减分能解决的。我最后做成了"测评问卷+专家人工解读"的简化版,反而更贴近真实场景。
保留下来的是四个核心模块:课程中心、专家问答、成长档案、社区交流。这四个模块之间信息上是贯通的:家长在课程中心学完某节课,可以在问答区向专家追问;专家在成长档案中看到孩子的基础信息后,给出的建议也会更具体。这种"学习—提问—记录—交流"的闭环,才是这个平台区别于普通学习网站的关键。
2.3 数据表设计时容易被忽略的几个细节
数据库表设计直接决定后面写查询的幸福感。我先把核心表结构列出来,再讲几个值得注意的点:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| t_user | 用户表 | id、name、phone、password、role、avatar |
| t_course | 课程表 | id、title、cover、type、content、author_id、status |
| t_course_chapter | 课程章节表 | id、course_id、title、video_url、content |
| t_learning_record | 学习记录表 | id、user_id、course_id、chapter_id、progress |
| t_question | 问答帖子表 | id、user_id、title、content、status |
| t_answer | 答疑回复表 | id、question_id、expert_id、content |
| t_growth_file | 成长档案表 | id、user_id、child_name、age、assessment、medical_history |
| t_resource | 资料下载表 | id、title、file_url、download_count |
有几个细节是很多教程不会提的:
视频课程我建议不要直接把视频大字段存在业务表里,而是存OSS或本地静态资源的URL。因为上传和播放是两个完全不同的问题,播放需要考虑流媒体协议,数据库仅存video_url字段就够了,真正做转码和CDN是部署阶段的事。
成长档案和用户表的关联,不要做成一对一,因为理论上一个家庭可能有多个孩子,而且档案信息的敏感度高,应该用独立的表保存,并在查询时带上用户ID条件,而不是直接挂到用户表的扩展字段里。
所有包含富文本内容的字段,建议一律使用TEXT类型,同时在前端做好XSS过滤。这个在第四章会专门展开讲。
3. 技术选型:为什么SpringBoot不是唯一答案但一定是最稳答案
3.1 后端框架的底层逻辑
对于这类平台,后端技术栈有多个选择:SpringMVC + JSP老一套、SpringBoot + Thymeleaf、SpringBoot + Vue前后端分离、甚至Node.js或Python Django都可以做。但我的选择是SpringBoot 2.7 + MyBatis-Plus + MySQL,原因有四点。
第一,SpringBoot的自动配置机制大幅减少了配置成本。以前SpringMVC项目要自己配一堆XML,学生项目一多半时间耗在配环境上,而SpringBoot能让你把时间花在业务代码上。第二,MyBatis-Plus提供了通用Mapper和条件构造器,对于课程查询、问答列表这类单表业务,完全不用手写SQL,能省下不少篇幅。第三,生态成熟,无论是做权限(Sa-Token/Spring Security)、接口文档(Knife4j),还是文件存储(本地/OSS/MinIO),都能找到现成的整合方案。第四,也是最实际的一点——网上教程多,遇到报错Ctrl+C搜索基本能解决,这在做作业和答辩准备阶段是巨大的优势。
3.2 前端方案的取舍
我实测过两种组合:一种是用Thymeleaf做服务端渲染,前后端耦合在一起;另一种是Vue + Element UI独立前端项目,通过接口交互。如果你是第一次完整做这个平台,我更推荐后者,理由是它能顺便锻炼接口设计能力,而且页面效果比模板渲染好看很多。
但要注意,选了前后端分离,后端必须把跨域问题提前处理好。我直接在配置类里添加了CorsFilter,指定允许的来源、请求头和方法,而不是依赖前端代理。因为部署后前端可能不在同一台机器上,如果跨域配置写死在代理里,换环境就崩。
3.3 环境和依赖版本建议
这里必须吐槽一下,SpringBoot版本选择是无数新手翻车的第一座山。如果你在开发这个项目,尽量不要用最新的SpringBoot 3.x,除非你已经做好踩坑Java 17和jakarta命名空间的心理准备。学校里给的JDK环境大多还是1.8,SpringBoot 2.7系列是最稳妥的选择。
我建议直接使用:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>这个版本适配JDK 8,同时兼容MyBatis-Plus 3.5.3、Sa-Token 1.37.0、Knife4j 4.1.0,这几个组合我实际上手跑过,目前没发现版本冲突问题。如果你用JDK 17或者JDK 21,再考虑升到SpringBoot 2.7更高的小版本。
4. 核心功能实现的四个关键代码环节
4.1 登录态校验:拦截器里做Token验证
这个平台的角色权限明确,所以我没上Spring Security那套重武器,而是在Sa-Token和手写拦截器之间选了手写拦截器加JWT方案。为什么这么做?因为题目本身不要求分布式会话,用JWT无状态校验更简洁,流程是:用户登录成功后后端生成Token返回前端,前端在后续请求的请求头Authorization中携带,后端拦截器统一解析并校验。
拦截器里最核心的一段逻辑:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); response.getWriter().write("未登录或登录已过期"); return false; } try { // 解析并校验JWT Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("userRole", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); response.getWriter().write("Token无效或已过期"); return false; } } }有个细节很多人会忽略——拦截器放行路径。注册接口、登录接口、首页课程列表和课程详情,这些是用户未登录时也可能访问的,所以必须放行。我建议用白名单常量数组维护,而不是在拦截器里写死一长串字符串,方便后期维护:
private static final String[] ALLOWED_PATHS = { "/api/user/login", "/api/user/register", "/api/course/list", "/api/course/detail/**", "/api/question/list", "/api/resource/list", "/doc.html", "/webjars/**", "/favicon.ico" };需要特别提醒的是/doc.html(Knife4j接口文档页面)的放行。如果你没放行它,调试接口时就会遇到前端能打开页面但接口401的问题,这个现象特别容易让人误判成项目没配置好,实际上只是拦截器把Knife4j的依赖路径拦截了。
4.2 统一返回值和异常处理
前后端分离开发时,接口返回格式不统一是我接手过最别扭的代码风格之一。有的接口返回{ "success": true, "data": ... },有的接口直接返回一个裸字符串,前端写起来非常痛苦。我在项目里定义了统一的Result<T>结构:
public class Result<T> implements Serializable { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> fail(Integer code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }配合@RestControllerAdvice做全局异常捕获,这样无论是业务异常、参数校验失败,还是数据库查询出现了空指针,前端拿到的都是结构一致的JSON,解析逻辑只需要写一次。
异常处理里有两个小坑。第一个是参数校验异常MethodArgumentNotValidException和字段类型转换异常HttpMessageNotReadableException是分开的异常类型,如果你只捕获了前一个,后一个就会以500状态返回给前端,排查时别死盯着业务代码,先看控制台异常类型再对症下药。第二个是不要在全局异常处理器里把Exception的堆栈直接返回给前端,应该记到日志文件里,返回给用户的是友好的提示信息,否则等于把服务端的敏感信息(如数据库连接串)暴露了出去。
4.3 MyBatis-Plus分页插件的正确用法
课程列表、问答列表、资源下载列表,这些查询都必须做分页。MyBatis-Plus的分页插件是我觉得整合起来最简单、但也是最容易配置错的地方。
正确配置方式是创建一个配置类,把MybatisPlusInterceptor注入容器,并添加分页拦截器:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }如果你在自定义的SQL里通过多个条件拼接查询,分页插件会自动在SQL外层包裹LIMIT,底层逻辑是把你的原始SQL作为子查询处理。所以我建议凡是需要分页的Mapper方法,都坚持写IPage<T> selectPage(Page<T> page, @Param("ew") Wrapper<T> wrapper)这类写法,不要自己手动在SQL末尾追加LIMIT,否则分页插件会识别不到你的意图,最终出现数据总量正确但当前页数据错位的诡异现象。
4.4 文件上传:PDF资料与图片的处理细节
平台支持家长上传孩子成长过程里的PDF评估报告,支持专家上传图文课件和视频封面。这个需求看着简单,实际上涉及三个问题。
第一个问题是文件存储位置。本地开发时,把文件保存到项目静态目录下比较省事,但一定要先判断目录是否存在,否则FileOutputStream会直接报目录不存在异常。我的做法是:
String uploadDir = System.getProperty("user.dir") + "/upload/"; File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); }第二个问题是文件类型限制。只有校验文件扩展名是不够的,因为攻击者完全可以把恶意文件改名成.pdf。我同时通过检测文件的Magic Number(PDF是%PDF开头、JPEG是FF D8 FF开头)来做二次确认,这一步能拦截掉大部分伪装文件的攻击。
第三个问题是XSS攻击。很多教程会告诉你后端要做XSS过滤,但处理上传的PDF时容易遗漏一个场景:如果这个PDF上传后被解析成文本展示或提供在线预览,恶意脚本可能会在PDF内容里触发DOM型XSS。我在全局做了一个XssFilter,对请求参数中的<script>标签做转义处理,同时把上传的PDF重命名为UUID文件名,去掉原始文件名里的所有特殊字符。这样即使前端预览组件出问题,恶意脚本也没有可执行的路径。
public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { chain.doFilter(new XssWrappedRequest((HttpServletRequest) request), response); } }XssWrappedRequest里重写getParameter、getHeader、getInputStream三个方法,对内容做转义。这里要注意处理JSON格式的POST请求体,如果只重写了getParameter,JSON里的脚本内容就过滤不到,需要额外处理getInputStream方法读取的请求体。我实测后把这两条路径都覆盖了,才敢说项目的XSS过滤是完整的。
5. 实测中踩过的坑:完整排查链路与优化方案
5.1 分页查询总数对不上?问题出在MyBatis-Plus的count优化
这是我开发这个平台时印象最深刻的一个坑。家长社区里的精选问答列表,筛选条件是"状态为已审核 + 回复数大于等于1",我调用了自定义SQL并传入Page对象,第一页数据正常,但点击第二页时发现总页数明显偏大,点进去还有大量空白行。
排查链路我建议你以后遇到也按这个顺序来:
- 先打印SQL日志,确认实际执行的count语句内容。
- 观察count语句是否把自定义的三表关联查询全部保留下来,导致count时出现重复行数。
- 如果count语句走的是嵌套子查询,检查子查询里是否存在一对多的数据关联。
我当时的问题出在自定义SQL中使用了一对多关联(一条帖子对应多条回复),而MyBatis-Plus自动生成的count SQL是基于主查询整体包装的,导致一条帖子因为关联了多条回复被重复计数。解决思路有两种:一是把关联查询改成子查询+EXISTS方式,二是手动指定count查询,用@Select注解单独写一个精确的count语句。我选的是第一个,因为改完之后的列表SQL更清晰,性能也更稳定。
5.2 拦截器404的迷惑行为:不是路径写错,而是资源放行顺序的问题
开发到课程视频播放时,前端播放器加载视频文件每次都返回404,但视频文件明明存在。我一开始以为是文件路径映射没配好,检查了WebMvcConfigurer里的静态资源映射,又检查了视频URL拼接代码,都没发现问题。
后来用浏览器直接访问视频文件的完整URL,发现返回的不是文件找不到,而是401状态码。这时候我才意识到,是拦截器把所有请求都拦截了,包括静态资源中的视频、图片。前面说的白名单路径覆盖了/doc.html和/webjars/**,但我没有把/upload/**文件存储目录加进去,导致视频文件在到达SpringMVC的静态资源处理器之前就被拦截了。
把/upload/**加入拦截器白名单,并在配置类中映射:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir); }这才解决。这个坑提醒我:拦截器不只是拦截接口,它拦截的是所有进入DispatcherServlet的请求,静态资源同样会经过它。以后设计白名单时,至少要包含三类:认证相关的开放接口、前端静态资源目录、接口文档相关的webjars资源。
5.3 富文本内容里的XSS漏网之鱼
前文提到了全局XSS过滤器,但我在测试专家发布图文课件时,发现一个漏网场景。专家在富文本编辑器里插入一张图片,用的是<img src="javascript:alert(1)">,这种写法并不包含<script>标签,单纯的字符串转义过滤不了。
排查这个问题时,我意识到前端编辑器的安全性同样重要,不能完全依赖后端过滤。最终的方案是双端配合:后端XSS过滤器继续保留,负责统一转义;前端在富文本编辑器组件上增加自定义过滤规则,删除javascript:协议开头的链接,并把onerror、onload这类事件属性剥离。
这里有一个实操建议:如果你用的是市面上主流的富文本编辑器,它在渲染内容时本身就有一些安全防御机制,但不同版本防御力度差异很大。上线前一定要自己构造几种常见的XSS Payload做测试,不要指望框架默认配置能帮你兜底。
5.4 登录后的会话状态丢失问题
家长用户登录成功后进入课程学习页面,学习进度能保存,但一旦刷新页面,登录状态就丢失了,需要重新登录。这个问题是前后端分离项目里最经典的坑:前端虽然把Token存到了localStorage,但页面刷新时没有重新把Token写回请求拦截器。
我排查的过程分三步:
- 第一步,在浏览器开发者工具里查看刷新后的请求,确认
Authorization请求头是否存在。 - 第二步,如果请求头存在,确认后端拦截器里解析Token的逻辑是否报异常。
- 第三步,如果请求头不存在,问题基本锁定在前端请求工具的全局配置里。
我的项目用的是Axios,因为刷新后前端代码重新执行,Axios实例里读取Token的逻辑在入口文件里只执行了一次,所以部分页面组件先于请求配置初始化时,所以出现Token丢失。修复方式是在Axios的请求拦截器里直接从localStorage读取Token,而不是在创建实例时读一次存到变量里。
service.interceptors.request.use(config => { const token = localStorage.getItem("token"); if (token) { config.headers["Authorization"] = token; } return config; });这个改动看起来特别不起眼,但影响面极大。做完之后,刷新页面、新开页面、长时间停留后再操作,登录状态都稳定了。
5.5 数据库连接池参数优化
课程列表和问答列表是平台访问频率最高的两个接口,我用JMeter做了简单的并发测试,50个并发用户连续访问时,出现了一个以前没注意到的现象:间隔大约10分钟后,第一次请求会莫名变慢,耗时从几十毫秒飙到几秒。
看报错日志发现是数据库连接超时后重建导致的。MySQL默认的wait_timeout是8小时,而系统里HikariCP连接池如果空闲连接超过maxLifetime没有活动,连接会被回收,但数据库端可能已经断开了这个连接,连接池再拿出来用时就需要重新握手,造成偶发延迟。
解决办法是在application.yml里针对连接池参数做调整:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 idle-timeout: 300000 max-lifetime: 1800000 connection-timeout: 10000max-lifetime设置为30分钟,小于MySQL的wait_timeout,确保连接池中留存连接不会被数据库主动断开。maximum-pool-size设为20是因为这个平台并发规模不大,20个连接足够,如果设太大反而浪费服务器资源。到这里我已经把开发过程中真正遇到过、且排查过程比较完整的几个实际问题讲完了,希望对准备做这类SpringBoot项目的朋友有点实际帮助。做这个平台最大的收获,不是把SpringBoot的注解背熟了,而是明白了每一个框架安全机制背后的边界在哪里,以及出了问题之后怎么一步步定位。碰到问题不要急着推翻重写,先打日志、再断点、最后再动代码,这个顺序能帮你省下至少一半的调试时间。