简介:面向高校毕业设计管理场景的完整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 Wrapper(Windows) ├── 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 的一部分。正常情况下同目录还应该有mvnw(Linux/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 是平级文件,大概率是前后端整合模式生成的。常见的整合思路是:前端源码用 Vue,npm 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 Map<Integer, Set<Integer>> 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 List<Topic> recommendTopics(Long studentId, int topN) { // 1. 获取学生的兴趣标签,例如从选课历史中统计 List<String> studentTags = selectionHistoryService.aggregateStudentTags(studentId); // 2. 查询所有状态为「已开放」的课题 List<Topic> 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(List<String> tagsA, List<String> tagsB) { if (tagsA.isEmpty() || tagsB.isEmpty()) { return 0.0; } Set<String> setA = new HashSet<>(tagsA); Set<String> setB = new HashSet<>(tagsB); Set<String> intersection = new HashSet<>(setA); intersection.retainAll(setB); Set<String> 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 前缀,最省事的做法是在后端配置全局前缀,不用改每个 Controller:
server: 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?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 123456这里有两个细节容易被忽略:URL 参数里useUnicode=true是开启 Unicode 支持,characterEncoding=utf8mb4指定连接层字符集,两个同时存在才有效;serverTimezone=Asia/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.port=80或用 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 章检查配置,跑通选题流程不是难事。希望帮到你。
本文还有配套的精品资源,点击获取