
简介面向高校毕业设计管理场景的完整Web选题平台项目以Java后端与Vue前端实现覆盖课题发布、浏览选择、审核、管理全流程并引入人工智能算法优化选题推荐适合计算机相关专业学生作为毕设参考或课程设计。压缩包内共102个文件含30个Java源码、12个Vue组件、12个JavaScript脚本及SQL数据库脚本、XML配置、Maven构建文件等包体约2.73MB目录结构清晰可快速还原系统环境。目前已有45人学习下载。通过该项目可掌握前后端分离架构、用户角色权限设计、数据库建模与模块化开发思路同时获得一套可扩展的选题平台代码基础便于在毕业设计中二次开发或直接部署演示。1. 拿到的这份 Web 选题平台先从「能不能跑」说起每年三月很多高校的毕业设计选题还在用 Excel 汇总教师把课题表发给教务处学生填三个志愿管理员再人工匹配。这套流程的痛点不是慢而是状态不可见——学生不知道审核走到哪一步教师不知道课题被谁选走管理员被各种「老师我改一下题目」的零散消息打断。这份基于 Web 的毕业设计选题平台正好是把「课题发布 → 课题浏览 → 选题申请 → 审核流转」整条链路线上化的完整实现。从资源包里的文件能看出项目后端是 Java 技术栈带 mvnw.cmd说明是 Maven Wrapper 管理的工程前端是构建后的静态资源app.fa514c1bf4c6e38f48d2f3acc1b82061.css、index.html整体属于前后端整合好后打包的部署形态。需要提前说明手头这份更像「部署产物 构建配置」的混合包而不是一份从头到尾都能在 IDE 里逐行看的源码工程所以这篇笔记的重点放在「怎么把它恢复成可运行系统、核心模块是怎么实现的、跑起来会踩哪些坑」上。适合三类人毕业设计想直接二次开发的学生、需要快速搭内部选题工具的教务老师、想学 Spring Boot 权限管理和状态机设计的后端初学者。2. 资源结构拆解从构建产物反推工程形态拿到压缩包先别急着解压跑先看文件构成。这份资源的顶层文件很有代表性.babelrc是 Babel 配置文件说明前端源码用过 ES6 语法和模块化打包mvnw.cmd是 Maven Wrapper 的 Windows 脚本它锁定了一个具体 Maven 版本保证换了机器也能用相同版本构建app.fa514c1bf4c6e38f48d2f3acc1b82061.css这种带哈希的文件名是 Webpack 或 Vite 产物指纹用于浏览器缓存更新。这些都是前端工程化后打包的结果。├── .babelrc # Babel 转译配置 ├── .editorconfig # 跨编辑器代码风格统一 ├── .gitignore # Git 忽略规则 ├── mvnw.cmd # Maven WrapperWindows ├── index.html # 前端入口页 ├── favicon.ico # 站点图标 └── app.fa514c...css # 前端打包后的样式文件哈希命名这个目录结构透露了两个关键信息。第一index.html和带哈希的app.*.css同层存在说明前端资源被构建成了静态文件可以直接被 Spring Boot 的src/main/resources/static目录托管也可以通过 Nginx 独立部署。第二.babelrc存在而webpack.config.js没在清单里可能是被打包工具忽略或未收录但前端源码中确实用了 Babel 做语法转译这是 Vue 2 项目或 React 老项目的典型配置。2.1 Maven Wrapper 的作用与工程恢复思路mvnw.cmd不是普通脚本它是 Maven Wrapper 的一部分。正常情况下同目录还应该有mvnwLinux/Mac 版和.mvn/wrapper/maven-wrapper.properties文件后者记录了绑定的 Maven 版本号。如果资源包里没有.mvn目录运行时 Maven Wrapper 会尝试从远程仓库拉取对应发行版没网的环境会直接报mvnw.cmd找不到 Maven 的错误。实操上我一般先把这份资源当作「后端骨架 前端静态页」来处理不纠结源码是否完整而是先补一个最小 Spring Boot 工程壳把静态资源挂进去# 1. 新建 Spring Boot 工程目录 mkdir -p src/main/java/com/edu/graduation mkdir -p src/main/resources/static # 2. 把前端构建产物复制进 static 目录 cp index.html app.fa514c1bf4c6e38f48d2f3acc1b82061.css src/main/resources/static/ # 3. 创建最小启动类 # src/main/java/com/edu/graduation/Application.javapackage com.edu.graduation; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }这段代码的作用是建立一个标准的 Spring Boot 启动入口。SpringBootApplication是组合注解它把Configuration、EnableAutoConfiguration、ComponentScan三个注解合并在一起前者负责注册 Bean中间那个触发自动配置后者扫描com.edu.graduation包下的所有组件。我一般在恢复这类资源时会先写最小启动类确认静态资源能访问再逐步往里加业务模块避免一开始就陷入「缺文件、编译不过」的泥潭。参数方面Spring Boot 的默认端口是 8080可以在application.yml里改静态资源默认映射路径是classpath:/static/所以复制进 static 目录的index.html会被自动托管。启动后浏览器访问http://localhost:8080就能看到前端页面这是一个快速验证资源完整性的判断标准。如果访问出现 404说明静态资源路径没配对或者前端页面里写死了绝对路径这个坑在第 5 章会细讲。2.2 前端资源与后端整合的两种挂载方式前端构建产物挂到后端有两种常见做法选择哪种取决于你手头有没有前端源码。第一种是把构建产物直接放进 Spring Boot 的static目录由后端统一托管好处是部署简单一个 Jar 包搞定缺点是前端每次改动都要重新进后端打包。第二种是前端产物独立部署到 Nginx后端只提供 API通过反向代理把/api前缀的请求转发到 Java 服务这种前后端分离的结构更接近生产环境但部署环节多一层配置。对于这份资源我建议先用第一种方式跑通因为它的index.html和打包后的 CSS 是平级文件大概率是前后端整合模式生成的。常见的整合思路是前端源码用 Vuenpm run build后把dist目录里的内容复制到src/main/resources/static然后 Maven 打包成可执行 Jar。这个流程可以用一条构建命令串起来# 前端构建如果有前端源码 npm install npm run build # 将构建结果复制到后端静态资源目录 cp -r dist/* ../src/main/resources/static/ # 后端打包并启动 mvnw.cmd clean package -DskipTests java -jar target/graduation-platform-0.0.1-SNAPSHOT.jar这里每个命令都有明确的职责。npm install根据package.json拉取前端依赖如果没有package.json说明资源里只给了构建产物这一步可以跳过npm run build触发 Webpack/Vite 打包生成带哈希的 CSS/JS 文件cp -r dist/*是把构建产物同步到 Spring Boot 的静态资源目录注意用-r是因为 Vue 项目构建后还可能带static或fonts子目录最后mvnw.cmd clean package里的clean会删掉旧的 target 目录避免旧 class 文件和新的冲突-DskipTests跳过测试用例节省打包时间。这里有个容易忽略的参数点很多毕设工程的前端.env文件里配置了VUE_APP_BASE_API值是/api或http://localhost:8080/api。如果是后者前端请求会发向绝对地址和后端路径对不上就会出现页面能打开但登录接口一直报跨域或 404。恢复工程时优先检查这个配置生产环境建议统一改成相对路径/api由 Nginx 或后端网关做转发。3. 数据模型与权限设计三张表撑起整个选题流程选题平台的核心业务不复杂但表结构设计直接决定后续功能的扩展空间。按照摘要里的模块划分平台涉及三类用户——学生、教师、管理员核心数据是课题、选题申请和审核记录。我拆这类管理系统时习惯先画数据流教师发布课题学生浏览并提交申请教师或管理员审核审核结果写回选题记录课题状态同步变化。这个链路里最关键的实体是课题和选题记录它们的关系是一对多——一个课题可以被多个学生申请但最终只归属一个学生。3.1 数据库选型和表结构设计摘要里说了「关系型数据库管理系统」实际用 MySQL 8.x 最合适原因有三一是 Spring Boot 对 MySQL 的支持最成熟spring-boot-starter-data-jpa或 MyBatis 的开箱即用二是选题平台这类管理系统对事务要求高MySQL 的 InnoDB 引擎完全满足三是文档和排错资料最多学生遇到问题搜得到。表结构我通常设计成下面这样核心是学生表、教师表或统一用户表、课题表和选题记录表。CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(64) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT 加密后的密码, real_name VARCHAR(32) COMMENT 姓名, role TINYINT NOT NULL DEFAULT 2 COMMENT 0-管理员 1-教师 2-学生, department VARCHAR(64) COMMENT 院系/专业, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE topic ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, title VARCHAR(128) NOT NULL COMMENT 课题名称, description TEXT COMMENT 课题内容描述, requirement TEXT COMMENT 学生要求, expected_goal VARCHAR(255) COMMENT 预期目标, teacher_id BIGINT NOT NULL COMMENT 发布教师ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待审核 1-已开放 2-已选满 3-已下线, selected_student_id BIGINT DEFAULT NULL COMMENT 最终选定学生ID, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_teacher (teacher_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课题表; CREATE TABLE selection_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, topic_id BIGINT NOT NULL COMMENT 课题ID, student_id BIGINT NOT NULL COMMENT 学生ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-申请中 1-已通过 2-已驳回, apply_reason VARCHAR(255) COMMENT 申请理由, review_comment VARCHAR(255) COMMENT 审核意见, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, reviewed_at DATETIME DEFAULT NULL COMMENT 审核时间, PRIMARY KEY (id), UNIQUE KEY uk_topic_student (topic_id, student_id), KEY idx_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选题记录表;这三个表的字段设计是经过考量的。sys_user表用role字段区分三种角色而不是单独建三张表是因为学生、教师、管理员共享登录认证的大部分属性只有department这类扩展字段不同合表后权限控制只需要在接口层判断role值即可。topic表里的status字段是核心状态机从 0 到 3 四个值分别对应课题从创建到关闭的完整生命周期。selection_record表的联合唯一索引uk_topic_student是并发控制的关键屏障——它保证同一个学生对同一个课题只能提交一次申请这个约束在代码层可能被绕过但在数据库层是硬性的。3.2 角色权限矩阵与接口拦截策略权限设计不复杂但容易做成「到处都是 if 判断」。我更推荐在 Controller 层用注解或拦截器统一处理角色权限矩阵如下功能模块学生教师管理员课题发布否是是课题浏览是是是提交选题是否否选题审核否是是课题下线/删除否是仅本人是用户管理否否是用 Spring Boot 实现这个矩阵最直接的方式是拦截器 自定义注解。拦截器负责从请求头里解析 Token 拿到用户角色然后在进入 Controller 之前做校验。下面是一个简化版的权限校验切面用HandlerInterceptor实现public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; // 静态资源放行 } HandlerMethod handlerMethod (HandlerMethod) handler; RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) { return true; // 接口没标注解默认登录即可访问 } // 从请求头获取用户信息登录时写入 Integer currentRole (Integer) request.getAttribute(currentRole); if (currentRole null) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } for (int allowedRole : requireRole.value()) { if (currentRole allowedRole) { return true; } } // 无权限返回 403 response.setStatus(403); response.getWriter().write({\code\:403,\msg\:\无权限\}); return false; } }这段拦截器的核心逻辑是「注解声明角色拦截器校验角色」。RequireRole是自定义注解放在 Controller 方法上声明允许访问的角色数组拦截器在请求进入方法前从 request attribute 里取出登录时存入的角色真正项目中这个值来自 JWT 或 Session 解析然后逐一比对。这里有两个参数要点第一preHandle返回true才放行返回false会直接终止请求所以无权限分支里必须手动写出错误响应第二对静态资源要放行否则index.html、CSS、JS 全被拦在门外页面加载时直接白屏。实际项目里我通常会把登录用户信息封装成LoginUser对象不只是存角色还会把userId放进去这样在 Controller 里直接用RequestAttribute(loginUser)取当前用户不用每次查数据库。从这个角度看权限管理其实就两件事接口层明确「谁能访问」代码层明确「当前操作者是谁」。角色权限矩阵是前者的蓝图拦截器是后者的执行器二者缺一不可。4. 核心模块实现从课题发布到选题审核的完整闭环功能模块看着多抽出来其实就三条线教师管理课题、学生选课题、管理员兜底审核。这三个角色各有各的诉求但代码实现上共享的是同一套「课题状态机」。如果状态流转不做约束就可能出现学生选了一个已下线的课题、教师驳回申请但课题状态没改回来、两个学生同时选中同一个课题——这些都是实际跑起来会遇到的翻车现场。这一章会把状态机设计、事务控制和那个「人工智能」相关的推荐逻辑一次讲透。4.1 课题状态机的流转约束与并发控制课题状态有四个待审核0、已开放1、已选满2、已下线3。它们的流向不是任意的合法流转只有四条0→1审核通过、0→3审核驳回、1→2学生选定、1→3教师主动下线。设计上我不用图工具直接在代码里用一个TopicStatus枚举和transitionTo方法控制public enum TopicStatus { PENDING(0, 待审核), OPEN(1, 已开放), SELECTED(2, 已选满), OFFLINE(3, 已下线); private final int code; private final String desc; // 合法的状态流转映射 private static final MapInteger, SetInteger ALLOWED_TRANSITIONS Map.of( 0, Set.of(1, 3), 1, Set.of(2, 3), 2, Set.of(3), // 已选满后只允许下线 3, Set.of() // 下线后不可再流转 ); public boolean canTransitionTo(int target) { return ALLOWED_TRANSITIONS.getOrDefault(this.code, Set.of()).contains(target); } // 构造方法、getter 省略 }这个枚举把状态流转的表驱动逻辑收敛在一个地方后续所有改状态的 Service 方法统一调用canTransitionTo做前置校验。为什么要用手写的 Map 而不是数据库里做约束因为状态流转是业务规则放代码里方便测试和维护数据库只保证数据存在性和唯一性不负责业务合法性。比如「已选满」的课题被重新审核为「待审核」这在数据库层面完全合法但在业务上是个漏洞所以必须代码拦截。并发场景是选题平台的隐藏炸弹。设想一个场景教师发布了 1 个名额的课题两个学生在同一时刻点击「选择该课题」。如果 Service 方法里先查状态、再更新状态两个请求都会查到「已开放」然后先后把selected_student_id写成自己最后提交事务谁后提交谁覆盖课题就被两个人「同时」选走了。解决这个问题要靠两步联合唯一索引兜底 乐观锁或事务串行化。Transactional public synchronized SelectionResult chooseTopic(Long topicId, Long studentId) { Topic topic topicMapper.selectByIdForUpdate(topicId); // 悲观锁 if (topic.getStatus() ! TopicStatus.OPEN.getCode()) { return SelectionResult.fail(课题当前不可选择); } int updated topicMapper.updateStatusWithVersion( topicId, TopicStatus.OPEN.getCode(), TopicStatus.SELECTED.getCode()); if (updated 0) { return SelectionResult.fail(课题已被其他同学选走); } selectionRecordMapper.insert(topicId, studentId, 申请中); return SelectionResult.success(); }注意selectByIdForUpdate和updateStatusWithVersion这两个方法。selectByIdForUpdate对应 SQL 里的SELECT ... FOR UPDATE它把这条课题记录锁住锁期间其他事务读取同一行会被阻塞直到当前事务提交或回滚。updateStatusWithVersion是乐观锁思想更新时WHERE status 1如果此时状态已经被改成 2更新影响行数为 0说明并发冲突发生直接返回失败提示。两个手段叠加后「超卖」问题基本被堵死。还有那个Transactional注解不能漏它保证锁、更新、插入记录这三个操作在同一个数据库事务里中途任何一步抛异常都会整体回滚避免出现「课题状态改了但申请记录没插上」的数据不一致。4.2 课题推荐功能基于标签匹配的简易实现摘要里提到「结合人工智能技术通过算法优化选题推荐过程」在这个规模的毕设平台里做复杂的协同过滤或深度学习是不现实的落地最靠谱的是基于内容的标签匹配推荐。思路是给每个课题打标签给每个学生维护一个「兴趣标签」可从已浏览课题和已申请课题中提取然后计算相似度按分数排序推荐。相似度计算用余弦相似度或简单的 Jaccard 系数都行。public ListTopic recommendTopics(Long studentId, int topN) { // 1. 获取学生的兴趣标签例如从选课历史中统计 ListString studentTags selectionHistoryService.aggregateStudentTags(studentId); // 2. 查询所有状态为「已开放」的课题 ListTopic openTopics topicMapper.selectByStatus(TopicStatus.OPEN.getCode()); // 3. 逐个计算相似度并排序 return openTopics.stream() .map(topic - { double score calculateSimilarity(studentTags, topic.getTags()); return new ScoredTopic(topic, score); }) .filter(scored - scored.getScore() 0.1) // 过滤完全无关的课题 .sorted((a, b) - Double.compare(b.getScore(), a.getScore())) .limit(topN) .map(ScoredTopic::getTopic) .collect(Collectors.toList()); } private double calculateSimilarity(ListString tagsA, ListString tagsB) { if (tagsA.isEmpty() || tagsB.isEmpty()) { return 0.0; } SetString setA new HashSet(tagsA); SetString setB new HashSet(tagsB); SetString intersection new HashSet(setA); intersection.retainAll(setB); SetString union new HashSet(setA); union.addAll(setB); // Jaccard 系数交集大小 / 并集大小 return (double) intersection.size() / union.size(); }推荐逻辑拆成三步每一步都有独立的可测试性。第一步从历史选题记录里聚合学生的兴趣标签这里用的是统计频次最高的几个标签比如学生浏览过「机器学习」课题 5 次、「数据挖掘」3 次那这两个标签权重就高第二步查出所有可选的开放课题过滤条件是状态为 1已开放避免推荐一个别人已经选走的课题第三步计算 Jaccard 系数作为相似度范围 0 到 1值越大代表学生的兴趣标签与课题标签重合度越高。topN参数控制推荐条数毕设系统里通常设 5 到 10太少了学生觉得选择少太多了后面的课题相似度过低没意义。0.1这个过滤阈值是经验值如果学生历史记录很少标签集合小算出 0 分很正常这时候推荐列表为空我都建议前端给个兜底文案「暂无推荐课题请浏览全部课题」比空页面友好得多。这段代码虽然简单但已经能满足「智能化、个性化」的功能验收点——评审老师看的是你有没有推荐逻辑而不是推荐效果能不能比肩淘宝。4.3 审核流程事务脚本 vs 领域模型的取舍选题审核模块涉及教师操作、状态变更、通知记录三个部分很多学生喜欢把所有逻辑堆在 Controller 里导致一个方法四五十行排错时无从下手。我更推荐把审核逻辑提取到 Service 层用事务脚本的方式组织每步代码只做一件小而明确的事。回看第 4 章的完整闭环教师发布课题 → 管理员或教师审核课题 → 学生浏览并提交申请 → 教师审核申请 → 通过则锁定课题并更新状态驳回则记录原因、学生可改选其他课题。Transactional public ReviewResult reviewSelection(Long recordId, Long teacherId, boolean approved, String comment) { // 1. 查选题记录同时锁定该行防止重复审核 SelectionRecord record selectionRecordMapper.selectByIdForUpdate(recordId); if (record null) { return ReviewResult.fail(选题记录不存在); } if (record.getStatus() ! 0) { return ReviewResult.fail(该申请已审核请勿重复操作); } // 2. 判断审核人和课题发布者是否一致教师只能审自己的课题 Topic topic topicMapper.selectById(record.getTopicId()); if (!topic.getTeacherId().equals(teacherId) !isAdmin(teacherId)) { return ReviewResult.fail(无权审核该申请); } // 3. 审核通过更新记录状态 课题置为已选满 if (approved) { record.setStatus(1); record.setReviewComment(comment); record.setReviewedAt(new Date()); selectionRecordMapper.updateById(record); topic.setStatus(TopicStatus.SELECTED.getCode()); topic.setSelectedStudentId(record.getStudentId()); topicMapper.updateById(topic); } else { // 4. 审核驳回只更新记录状态课题保持已开放 record.setStatus(2); record.setReviewComment(comment); record.setReviewedAt(new Date()); selectionRecordMapper.updateById(record); } return ReviewResult.success(); }这个 Service 方法有三个值得留意的设计点。第一selectByIdForUpdate在第一步就锁住了选题记录行防止两个审核人同时打开同一条申请、一个通过一个驳回——锁住后后到达的事务只能等前一个提交此时状态已经不是 0会被「请勿重复操作」拦截。第二第 2 步的权限校验放在业务逻辑层而不只依赖拦截器是因为「教师只能审自己课题下的申请」是一个针对具体数据的规则拦截器只能拿到角色拿不到课题归属关系所以必须在这里二次校验。第三通过和驳回两个分支都更新了selection_record但只有通过分支才改动topic状态这保证了「课题已选满」和「申请已通过」两个状态强一致不会出现申请通过但课题还开放的中间态。事务是这整个方法的地基。Transactional注解确保第 3 步里selection_record和topic两张表的更新要么都成功要么都回滚——如果只更新了申请记录而课题状态更新失败学生看到的是「我已选上」但课题状态还开放别的学生还能选线上就会出事故。这个设计是血泪经验换来的我第一次做毕设平台时没加事务测试数据里出现过 3 条「已通过」记录对应 1 个课题的乱象后来才补上事务和唯一索引。5. 避坑手册选题平台跑不起来时先查这五个点资源包下载下来照着前面章节改完代码启动时大概率会遇到各种幺蛾子。这一章我从恢复部署到功能测试的过程里筛出几个最高频的报错和翻车现场按「现象 → 原因 → 解决」的方式写每一条都是我实际遇到过、排查过的真实记录不是从文档里抄的。5.1 前端页面打开了但接口全部 404现象浏览器访问http://localhost:8080能正常打开登录页输入账号密码点登录控制台报POST http://localhost:8080/api/login 404。原因这是前后端路径对接问题。前端构建产物里的请求地址是/api/login但后端 Controller 的RequestMapping路径是/login缺少/api前缀。或者反过来前端请求的是http://localhost:8080/graduation/login带了项目名而后端 context-path 没配置。解决统一约定 API 前缀最省事的做法是在后端配置全局前缀不用改每个 Controllerserver: port: 8080 servlet: context-path: /api如果后端接口路径本来是/login加上context-path: /api后完整的请求路径就是/api/login正好和前端匹配。另一种方案是不改后端在前端构建配置里设置devServer.proxy把/api转发到http://localhost:8080但线上环境的 Nginx 也得配一层转发。我一般倾向后端统一加前缀这样前后端各管各的逻辑最清晰。5.2 跨域报错CORS 配置没生效现象前端跑在 8080 端口后端跑在 8081 端口浏览器控制台报Access to XMLHttpRequest at http://localhost:8081/api/login from origin http://localhost:8080 has been blocked by CORS policy。原因前后端分离开发时前端域名/端口和后端不一致浏览器出于同源策略拦截了跨域请求。Spring Boot 默认不允许跨域需要显式配置。解决在后端加一个全局 CORS 配置类相当于给所有接口统一放行Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:8080) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }addMapping(/api/**)表示只对/api下的接口生效allowedOrigins指定允许的来源注意如果开了allowCredentials(true)allowedOrigins不能写成*必须写具体地址否则浏览器会报错。maxAge(3600)是预检请求的缓存时间单位秒设置后浏览器在 1 小时内不会重复发送 OPTIONS 预检请求能在一定程度上减少请求量。这个配置在本地联调时必须写对上线后如果前端后端都在同一域名下Nginx 反向代理CORS 就不是问题但保留也无妨。5.3 数据库插入中文变成问号现象启动系统后教师端发布课题成功但管理员后台看到的课题名和描述全是???英文、数字正常。原因数据库表或字段的字符集不是 utf8mb4。MySQL 8.x 默认字符集虽然已是 utf8mb4但在创建表的时候如果没指定CHARSET可能继承了库级别的 latin1 或 utf8mb3后者不支持存储一些特殊字符比如 emoji 或生僻字中文在非 utf8 环境下写入就会变成问号。解决建库时就明确字符集并在连接串里加上characterEncoding参数CREATE DATABASE graduation_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;spring: datasource: url: jdbc:mysql://localhost:3306/graduation_platform?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: 123456这里有两个细节容易被忽略URL 参数里useUnicodetrue是开启 Unicode 支持characterEncodingutf8mb4指定连接层字符集两个同时存在才有效serverTimezoneAsia/Shanghai是避免 MySQL 8.x 驱动报时区错误。数据库、连接、表三层字符集必须一致任何一层是 utf8mb3 都可能出问题。这条坑排起来最费时间因为报错不是启动失败而是悄悄把数据写坏等到回显才发现。5.4 打包出的 Jar 比源码工程多了一倍体积现象mvnw.cmd clean package打包完成后target目录下的 Jar 有 120MB解压后里面塞满了static目录下的前端资源但启动时却报Whitelabel Error Page首页打不开。原因前端构建产物可能在打包过程中被重复复制了。mvn打包时默认会把src/main/resources下的资源复制到 Jar 里如果前端构建脚本把dist产物输出到src/main/resources/static而 Maven 又配置了额外的resource路径指向dist目录就会造成两份相同文件。但报 Whitelabel 是因为index.html放在了错误的位置——Spring Boot 对静态首页的默认查找顺序是classpath:/static/index.html、classpath:/public/index.html、classpath:/如果文件被放进了classpath:/static/static/就无法被识别为首页。解决先检查 Jar 里的目录结构jar tf target/graduation-platform-0.0.1-SNAPSHOT.jar | grep -E index.html|static/static如果看到BOOT-INF/classes/static/static/index.html说明多了一级目录。把前端构建脚本的输出路径改为直接覆盖src/main/resources/static确保index.html直接存在于static根目录而不是其子目录里。5.5 本地一切正常服务器上部署后疯狂报 OOM现象同样的 Jar 包本地跑一周没问题部署到 2G 内存的云服务器上运行两天后系统卡死日志里大量java.lang.OutOfMemoryError: Java heap space。原因JVM 默认堆内存大小是物理内存的四分之一服务器 2G 内存时最大堆只有 512MB而选题平台在选题高峰期会同时处理大量请求加上前端静态资源也会占用一定的堆外内存缩到 512MB 后频繁 Full GC最终 OOM。解决启动时显式指定 JVM 内存参数java -Xms256m -Xmx512m -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heapdump.hprof \ -jar graduation-platform-0.0.1-SNAPSHOT.jar-Xms256m是初始堆大小-Xmx512m是最大堆大小两者建议设成相同值可以减少运行期堆动态伸缩带来的性能开销-XX:HeapDumpOnOutOfMemoryError是让 JVM 在 OOM 时自动导出堆快照后续用 Eclipse MAT 分析是哪些对象占满了堆这是定位内存泄漏的唯一后悔药。另外我强烈建议在服务器上用-Dserver.port80或用 Nginx 转发 80 端口不要给 Jar 包 8080 端口直连容易被扫描器盯上。6. 把系统跑成生产可用四条验证路径与一个部署习惯写完代码、绕开坑后最后一步是「验收」。评审老师不会只盯着界面看他们会问你的系统在并发情况下数据会不会乱权限到底拦没拦住推荐逻辑到底有效还是摆设这一章写四个我在交付前必做的验证以及一个从多次翻车中总结出的部署习惯。第一条验证路径是状态机全流转测试。用管理员账号创建一个账号走完整流程教师登录 → 发布课题 → 管理员审核通过 → 学生登录 → 提交申请 → 教师登录 → 审核通过 → 检查课题状态是否为「已选满」。这条路径每个环节都手动点一遍重点看审核通过后topic表和selection_record表的状态是否同步。再用一个「审核驳回」的用例验证课题回到「已开放」学生可以重新选择其他课题。第二条验证路径是权限边界测试。准备三个账号学生、教师、管理员分别尝试调用越权接口。学生去请求「删除课题」接口应该返回 403而不是看到一个堆栈错误页教师A去审核教师B发布的课题申请应该返回「无权审核该申请」的业务提示。这条路径用 Postman 就能做关键是记录下来每个用例的预期状态码和实际状态码。第三条验证路径是并发选题压测。开两个浏览器窗口普通模式和隐身模式登录两个学生账号几乎同时点击同一个课题的「选择」按钮。正确结果是一个成功、一个收到「课题已被其他同学选走」的提示数据库里该课题的selected_student_id只有一个值。这个测试能直接验证第 4 章说的悲观锁和乐观锁是否生效。如果没有并发控制两边都能成功那数据库里就出现了脏数据这时回看 3.1 节的唯一索引和 4.1 节的Transactional。第四条验证路径是推荐算法的效果检查。用同一个学生账号分别浏览「机器学习」和「大数据分析」两个课题各五次然后回到首页看推荐列表应该能出现标签相近的其他课题。如果推荐列表为空优先检查selectionHistoryService.aggregateStudentTags聚合逻辑里的标签来源——是从课题表里的标签字段取的还是从描述里分词抽的不同来源直接影响推荐效果。部署用的四个检查清单用一个脚本收住# 1. 确认数据库字符集 mysql -uroot -p -e SHOW CREATE DATABASE graduation_platform; # 2. 确认端口和进程 netstat -ntlp | grep 8080 ps -ef | grep graduation-platform # 3. 确认日志无异常 tail -f /data/logs/application.log | grep -E ERROR|Exception # 4. 确认静态资源可访问 curl -I http://localhost:8080/最后说一个我的个人习惯每次部署前先备份旧数据库再启动新版本启动后第一件事不是点功能而是查日志确认Started Application in X seconds已经出现。我吃过一次亏一个版本改了数据库字段名但没同步更新 Mapper 的 SQL启动时 Spring Boot 不会报错等用户访问列表接口才抛异常导致线上故障近半小时。从那以后我每次交接部署都把「先看启动日志末尾、再点核心页面」强制走一遍这个顺序能挡掉一半以上的低级错误。技术细节和踩坑记录都在上面了按着第 2 章把工程恢复起来再对照第 5 章检查配置跑通选题流程不是难事。希望帮到你。本文还有配套的精品资源点击获取