简介:一份面向高校计算机、电子信息工程等专业学习者的研究生管理系统Java源码包,基于SSM(Spring+SpringMVC+Mybatis)框架与Vue前端构建,采用B/S架构和MVC分层设计,适配JDK1.8、MySQL5.7、Maven3.6及Tomcat8/9环境,适合作为毕业设计、课程设计或期末大作业。压缩包共805个文件,大小约21.11MB,其中216个Java文件承载后端业务逻辑,154个Vue组件实现前端交互,33个XML配置负责SSM框架整合,另有SQL数据库脚本、JS/CSS静态资源、SVG图标及bat构建启动脚本,目录结构清晰,便于按模块理解和二次开发。当前已有54人学习下载,源码经过严格测试,可直接导入IDEA/Eclipse等开发工具运行。借助这份源码,读者能获得完整的研究生管理业务前后端实现、数据库初始化脚本与工程级配置,既适合在毕业答辩中演示核心功能,也能作为SSM+Vue实战项目进行功能扩展与重构,是快速上手企业级Java Web开发的实用参考。
1. 研究生管理系统代码:从一条学籍记录到一篇学位论文
研究生管理系统代码,在网上一搜就是一大把,但大多数打开之后会发现:权限写死、流程靠前端按钮硬撑、换了个学院就翻车。研究生管理这件事,本质上不是一套“学生信息增删改查”,而是一条从注册、选导师、开题、中期、答辩到学位申请的完整链路,每一环都牵扯学生、导师、教务三个角色。用 Java 技术栈落地这套系统,Spring Boot + MyBatis Plus 是当前最主流、也最适合拿来当毕设或课设的组合。这篇文章写给两类人:正在做 java 研究生毕设项目、需要交付一套能演示系统的在校生,以及想用真实业务把自己从 CRUD 里拽出来的初级 Java 工程师。读完你会有能建表的 SQL、能抄的鉴权与状态机代码,也知道哪些坑值得提前绕着走。
2. 技术选型与数据库设计:Spring Boot 三件套和六张核心表
研究生管理系统是一个典型的权限密集、流程密集、数据关系密集的业务系统。选型如果只在“能不能跑”上打转,后面改需求时会非常痛苦。这章先把组合怎么选、表怎么拆、表结构怎么落地讲清楚,这些是后面所有模块的地基。
2.1 为什么是 Spring Boot + MyBatis Plus + 轻量前端
先回答最现实的问题:为什么这套组合成了研究生管理系统的“默认答案”。Spring Boot 负责把配置收敛起来,内嵌 Tomcat、起步依赖、Actuator 健康检查,这些都让部署成本大幅降低,一个mvn spring-boot:run就能把后端拉起来。MyBatis Plus 的价值更直接:单表 CRUD 不需要手写 SQL,内置分页插件、代码生成器、逻辑删除、乐观锁,能把一个管理类项目里七成以上的样板代码省掉。剩下的精力全部投入到权限模型和流程状态这两块真正容易写崩的地方。
前端的选择我一般会给两条路。如果你是一个人做横评类项目,时间紧任务重,直接用 Thymeleaf 模板加 Bootstrap,后端渲染,部署时只有一个 Jar,不用处理跨域,不用起两个服务;如果你想把系统当作求职作品,那还是 Vue 3 + Element Plus 做前后端分离更体面,但代价是演示现场要多处理一层跨域和端口问题。我的建议是:答辩项目优先保稳定,前后端分离放到第二版再上。这个选择跟技术能力无关,跟演示失败的概率有关。
再说一句和学习路线的关系。如果你正照着 java 学习路线走到 Spring Boot 这一站,商城类项目练的是 CRUD 和缓存,而这个系统练的是权限、状态机、事务和并发控制——这些恰恰是工作里最容易出问题、面试里最常被追问的点。拿它当综合练习,性价比很高。
2.2 六张核心表与角色边界:不要再把一切塞进 user 表
很多半成品系统最大的问题是把所有信息堆在一张 user 表里,role 字段、学院、导师工号、班级全塞进去,最后查询靠like硬撑。研究生管理系统里,人和角色是多对多的:一个用户可以是学生,也可以是管理员,甚至可以是助教。常见做法是拆成“账号表 + 扩展表”,用user_id关联。
| 表名 | 职责 | 关键字段 | 说明 |
|---|---|---|---|
| user | 账号与角色 | id, username, password, role | role 取 admin/teacher/student |
| student | 学生扩展信息 | user_id, student_no, college, major, grade | 学号唯一索引 |
| teacher | 导师扩展信息 | user_id, teacher_no, title, research_area | 职称、研究方向用于双选展示 |
| teacher_quota | 导师名额 | teacher_id, total_quota, used_quota | 双选时的容量控制 |
| select_apply | 双选志愿 | student_id, teacher_id, round_no, priority, status | priority 表示第几志愿 |
| paper_process | 论文流程 | student_id, type, status, title, submit_time, audit_time | 开题、中期、答辩共用一张表 |
学生和导师的信息为什么拆出来单独建表?因为user表只负责鉴权,而 student 和 teacher 是业务实体,它们的字段变化频率不一样:学生要加一个“是否脱产”字段,导师要加一个“可带学生数”,如果都在 user 表里,改一次就要动鉴权表,风险很高。扩展表存在一定的冗余问题,例如一个用户如果既在 student 表又在 teacher 表,逻辑上会混乱,所以实际落地时我会加唯一约束:student.user_id和teacher.user_id都建唯一索引,从数据库层面拦住“一个人既是学生又是导师”的脏数据。
paper_process 这张表是整个系统的核心,后面第 4 章展开讲。这里先记住它的设计原则:不同类型流程共享同一张表,用type区分,而不是给开题、中期、答辩各建一张结构几乎一样的表,这是后续状态机可以复用的前提。
2.3 表结构落地:从实体类到建表 SQL 的正确顺序
很多人搜过“mybatisplus 根据 java 实体类生成创建表的 sql 语句”这种操作,确实有工具能把实体类反向生成建表 SQL。但我一般不建议靠它直接上生产:字段注释会丢、decimal 长度不准确、tinyint 的语义不明确,生成出来的东西还要人工再审一遍。更稳的做法是反过来——先写schema.sql定义表结构,再让代码生成器根据表生成实体,表是唯一事实来源。
下面这张paper_process的建表 SQL 可以直接抄,注意几个容易被忽略的点:version字段给乐观锁用;idx_student_type联合索引支撑“查某个学生的全部流程”这类高频查询;audit_comment允许为空,但业务层会校验。字符集统一用 utf8mb4,避免论文题目里出现生僻字或 Emoji 时乱码。
CREATE TABLE `paper_process` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `student_id` bigint(20) NOT NULL COMMENT '学生ID,关联 student 表', `type` varchar(20) NOT NULL COMMENT '流程类型:thesis_topic/opening/midterm/defense', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0草稿 1待审核 2已通过 3已驳回', `title` varchar(200) DEFAULT NULL COMMENT '论文题目或报告标题', `submit_time` datetime DEFAULT NULL COMMENT '学生提交时间', `audit_time` datetime DEFAULT NULL COMMENT '审核时间', `audit_comment` varchar(500) DEFAULT NULL COMMENT '审核意见', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`), KEY `idx_student_type` (`student_id`, `type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='论文流程表';表结构定好后,用 MyBatis Plus 的 FastAutoGenerator 根据表反向生成 entity、mapper、service、controller,这一步能省大量重复劳动。生成器代码如下:
FastAutoGenerator.create("jdbc:mysql://localhost:3306/graduate", "root", "your_password") .globalConfig(builder -> builder.author("demo").outputDir("src/main/java")) .packageConfig(builder -> builder.parent("com.example.graduate")) .strategyConfig(builder -> builder .addInclude("user", "student", "teacher", "teacher_quota", "select_apply", "paper_process") .entityBuilder().enableLombok() .controllerBuilder().enableRestStyle()) .execute();这段代码的意思是按你指定的表批量生成三层代码:entityBuilder().enableLombok()让实体类带上@Data注解,省去手写 getter/setter;controllerBuilder().enableRestStyle()生成 REST 风格的 Controller 骨架。要注意,生成器产出的 Service 只是空壳extends ServiceImpl,真正的双选校验、状态流转逻辑还是得手写。把生成器当成打字员,别把它当成架构师。
3. 登录鉴权与导师双选:最容易写崩的两个模块
登录鉴权和导师双选是所有研究生管理系统里出镜率最高、也是最容易翻车的两个模块。一个管“谁能用系统”,一个管“学生和导师怎么建立关系”,两者都牵涉多角色和多状态。这章给出可复用的写法,并把参数边界说透。
3.1 JWT 登录与三种身份的权限边界
管理员、导师、学生三种角色,权限差异非常明显:学生只能看自己的信息和流程,导师能看名下学生的流程,管理员能看全院数据。权限模型我一般不用 Spring Security 的全套过滤器链,而是用“拦截器 + 自定义注解 + JWT”的组合,原因很简单:这套系统角色就三种,用注解声明比写一堆 Security 配置更直观,团队新人也容易接手。
登录接口先做两件事:用 Bcrypt 校验密码,签发 JWT。密码不能是明文,这是底线,哪怕演示系统也一样。JWT 里只放 userId 和 role,不放姓名、学院这些可变信息,避免用户改名后 token 里的旧数据造成显示错乱。过期时间一般设 2 小时,如果要连续演示一整个上午,可以放宽到 12 小时。
拦截器的核心逻辑如下:
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 非控制器方法直接放行,比如静态资源和错误转发 if (!(handler instanceof HandlerMethod)) { return true; } // 接口方法上没有 RequireRole 注解,说明不需要登录 HandlerMethod handlerMethod = (HandlerMethod) handler; RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole == null) { return true; } // 取 token 并校验 String token = request.getHeader("Authorization"); if (token == null || !JwtUtil.verify(token)) { throw new BizException(401, "登录已过期,请重新登录"); } // 解析角色,判断是否在允许列表里 Claims claims = JwtUtil.parse(token); String role = claims.get("role", String.class); if (!Arrays.asList(requireRole.value()).contains(role)) { throw new BizException(403, "当前角色无权访问"); } // 请求期间保存用户信息,Controller 和 Service 直接取 UserContext.set(claims.get("userId", Long.class), role); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束必须清理 ThreadLocal,否则线程池复用会串数据 UserContext.clear(); } }代码逻辑不复杂,但有两个参数细节值得注意。第一,RequireRole注解加在方法上,@RequireRole({"teacher", "admin"})表示只有导师和管理员能访问,没加注解的接口默认放行——这意味着新增接口时必须刻意关注权限,漏加就会裸奔;如果团队担心漏加,可以把拦截器改成默认拒绝、显式@PermitAll才放行,这是更严格的策略。第二,UserContext.clear()必须放在afterCompletion,不在finally里,否则一次请求结束后没清理,下一次请求用同一个线程就会拿到上一个人的 userId,这种 bug 极其隐蔽,现场排查起来非常痛苦。
3.2 导师双选的数据模型:志愿制、名额锁定与事务边界
双选规则在不同学校差别很大,最常见的是志愿制:学生可以填若干个导师志愿,导师在待确认列表里选择通过或拒绝;也有学校是“先到先得”,导师名额满了就自动拒绝。这里以志愿制为主讲,因为它的状态更多,写出来的代码覆盖了大多数场景的难点。
双选涉及三张表:select_apply存志愿,teacher_quota存名额,relation存最终确认的师生关系。提交志愿的接口是并发重灾区,两个学生同时抢最后一个名额时,如果不用锁,数据库里会出现used_quota超出total_quota的脏数据。下面是提交志愿的完整代码:
@Transactional(rollbackFor = Exception.class) public void submitApply(StudentApplyDTO dto) { // 同一轮次只能提交一次志愿,数据库里对 (student_id, round_no) 建了唯一索引 Integer existed = applyMapper.countByStudentAndRound(dto.getStudentId(), dto.getRoundNo()); if (existed != null && existed > 0) { throw new BizException("本轮志愿已提交,不能重复提交"); } // 先锁住导师名额这一行,防止并发下两个学生同时抢最后一个名额 TeacherQuota quota = teacherQuotaMapper.selectByTeacherIdForUpdate(dto.getTeacherId()); if (quota == null || quota.getUsedQuota() >= quota.getTotalQuota()) { throw new BizException("该导师名额已满,请选择其他导师"); } // 学生当前不能已经存在“已确认”的导师关系 List<Relation> confirmed = relationMapper.findConfirmed(dto.getStudentId()); if (!confirmed.isEmpty()) { throw new BizException("你已经确认了导师,不能继续填报志愿"); } applyMapper.insert(new Apply() .setStudentId(dto.getStudentId()) .setTeacherId(dto.getTeacherId()) .setRoundNo(dto.getRoundNo()) .setPriority(dto.getPriority()) .setStatus(0)); // 0 待导师确认,1 通过,2 拒绝 }这段代码里最关键的是selectByTeacherIdForUpdate,它在事务里对teacher_quota这一行加了悲观锁,第二个事务执行到这里会被阻塞,直到第一个事务提交。代价是这个接口在高并发下会排队,但对研究生选导师这种场景,一天也就几千次提交,这个代价完全可接受。再用@Transactional(rollbackFor = Exception.class)把校验和插入放在同一个事务,任何一个环节抛异常,前面写进去的数据全部回滚,不会出现“名额扣了但志愿没写进去”的半截状态。
导师确认学生会走另一条路径:查出所有待确认志愿,按“志愿优先、时间优先”排序。SQL 如下:
SELECT ta.*, tq.total_quota, tq.used_quota FROM select_apply ta JOIN teacher_quota tq ON ta.teacher_id = tq.teacher_id WHERE ta.status = 0 -- 只取待确认 AND tq.used_quota < tq.total_quota ORDER BY ta.priority ASC, -- 学生填的第一志愿排在前面 ta.create_time ASC -- 同优先级下先提交的排在前面 LIMIT 50;这个查询的意思是:给导师展示一个“我还能确认谁”的列表,优先展示第一志愿学生,同志愿按提交时间排序。排序规则要和业务规则完全对齐,否则会出现“第二志愿学生先被确认,第一志愿学生反而被拒”的纠纷。确认动作是一个完整事务:更新relation、更新select_apply.status、给teacher_quota.used_quota加一,三步必须同时成功或同时失败。
3.3 分页与条件查询:LambdaQueryWrapper 的参数细节
双选列表、学生列表、流程列表,到处都是分页查询。MyBatis Plus 的分页插件配置很简单,但参数设计有很多细节。先看配置:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 数据库类型要指定,否则分页 count 语句可能用错方言 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }查询代码里,我用 LambdaQueryWrapper 构造条件,比写在 XML 里更安全——字段名写错在编译期就能发现:
@GetMapping("/student/list") public Result<IPage<StudentVO>> list(@RequestParam(defaultValue = "1") long current, @RequestParam(defaultValue = "10") long size, @RequestParam(required = false) String keyword, @RequestParam(required = false) String college) { Page<Student> page = new Page<>(current, size); LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<Student>() .like(StringUtils.hasText(keyword), Student::getName, keyword) .eq(StringUtils.hasText(college), Student::getCollege, college) .orderByDesc(Student::getCreateTime); IPage<Student> result = studentMapper.selectPage(page, wrapper); return Result.ok(convertToVO(result)); // 不要直接把实体吐给前端 }like和eq的第一个参数是布尔条件,StringUtils.hasText在 keyword 为空时会自动忽略该条件;orderByDesc按创建时间倒序,保证新提交的学生排在前面。分页请求里有三个参数容易被写错:current从 1 开始而不是 0,size要限制上限,比如size > 100时强制设为 100,否则有人传一个size=99999等于把全表拉出来;最后,Page 里的total是 long 类型,前端做表格分页时要留意精度问题,数据量大了之后可能溢出。
4. 论文全流程状态机:把开题、中期、答辩收进同一个审批服务
论文流程是研究生管理系统里最有业务深度的地方。学生提交开题报告,导师审;提交中期报告,导师审;提交答辩申请,导师审、教务再审。很多系统的做法是给每个类型写一套接口,流程之间互相独立,结果就出现“开题没通过也能提交答辩申请”这种漏洞。这章讲怎么用状态机把流程统一起来。
4.1 为什么用状态字段而不是一摞布尔值
最容易犯的错误是用布尔值表达流程状态:opening_passed、midterm_passed、defense_passed各占一列。表面看很直观,但实际用起来很难受:第一,你无法表达“提交了但还没审”这种中间态;第二,你想加一个“被驳回重新修改”的状态时,要么加一列,要么重新定义布尔含义,破坏历史数据;第三,也是最要命的,代码里没法做非法跳转校验,因为布尔值之间没有顺序关系。
所以这里用一个status字段表达单个流程的状态,再用type区分流程类型。常见状态定义如下:
| type | 状态流转 |
|---|---|
| thesis_topic(选题) | 0 草稿 → 1 待审核 → 2 通过 / 3 驳回 → 1 重新提交 |
| opening(开题) | 0 草稿 → 1 待审核 → 2 通过 / 3 驳回 → 1 重新提交 |
| midterm(中期) | 0 草稿 → 1 待审核 → 2 通过 / 3 驳回 → 1 重新提交 |
| defense(答辩) | 0 草稿 → 1 待审核 → 2 通过 / 3 驳回 → 1 重新提交 |
但在真正落地时,“选题通过”是“开题可提交”的前置条件,“开题通过”是“中期可提交”的前置条件。这套前置依赖不属于状态机本身,而是属于“流程编排”,需要在业务层单独校验。把这两件事分开想,代码就不会糊成一团。
4.2 可复用的流程审批服务:一个接口处理所有类型的审核
用一张表、一个状态机管四种流程,核心是建立一个“当前状态 → 允许流转到的状态”的映射表。审核接口拿到流程 ID 和目标状态,先判断这个跳转合不合法,再更新状态、写审核意见、记日志。代码如下:
@Component public class PaperProcessService extends ServiceImpl<PaperProcessMapper, PaperProcess> { /** key: 当前状态, value: 允许流转到的目标状态 */ private static final Map<Integer, Set<Integer>> TRANSITIONS = new HashMap<>(); static { // 0 草稿 -> 1 待审核(学生提交) TRANSITIONS.put(0, Set.of(1)); // 1 待审核 -> 2 通过 / 3 驳回 TRANSITIONS.put(1, Set.of(2, 3)); // 3 驳回 -> 1 待审核(修改后重新提交) TRANSITIONS.put(3, Set.of(1)); } @Transactional(rollbackFor = Exception.class) public void audit(Long processId, Integer targetStatus, String comment, String operatorRole) { PaperProcess process = getById(processId); if (process == null) { throw new BizException("流程记录不存在"); } if (process.getStatus() == 2) { throw new BizException("流程已通过,不能重复审核"); } // 状态机校验:当前状态不允许流向目标状态时直接拒绝 Set<Integer> allowed = TRANSITIONS.getOrDefault(process.getStatus(), Collections.emptySet()); if (!allowed.contains(targetStatus)) { throw new BizException("当前状态不允许流转到目标状态"); } // 角色校验:只有导师和管理员可以审核 if (!"admin".equals(operatorRole) && !"teacher".equals(operatorRole)) { throw new BizException("该角色无权审核"); } if (!StringUtils.hasText(comment)) { throw new BizException("审核意见不能为空"); } process.setStatus(targetStatus); process.setAuditComment(comment); process.setAuditTime(new Date()); updateById(process); // 操作留痕,答辩评审有争议时这是唯一的后悔药 auditLogMapper.insert(new AuditLog(processId, operatorRole, comment)); } }这段代码的逻辑分四层:状态机校验、角色校验、数据更新、日志记录。TRANSITIONS用静态 Map 定义,比 switch-case 好维护,以后要加“撤回”状态,只需要在对应的集合里加一个目标状态。updateById走 MyBatis Plus 的乐观锁,version字段会自动加一,两个审核人同时点“通过”时,后提交的人更新影响行数为 0,业务层再报“已被其他审核人处理”,避免覆盖。
这里还有一个隐藏设计:前置流程校验不放在audit里,而是放在“学生提交流程”的接口里。比如提交开题时检查选题流程状态为 2,提交中期时检查开题状态为 2。为什么拆开?因为审核动作只需要关心状态合不合法,而提交动作才需要关心“你够不够资格提交”。把职责拆开,后续新增“预答辩”流程时,只需要改提交校验,不需要动审核逻辑。
4.3 状态回退、撤回与超时提醒
毕业设计里学生提交错了文档、导师驳回了流程,这些场景都需要状态回退。回退不是改个数字那么简单:学生端要能退回草稿重新编辑,导师端要能撤回已通过的审核(有些学校允许在段时间内反悔),管理员要能强制纠正异常状态。我的做法是给这个入口单独开接口,并且只允许“高一级角色”操作:学生能撤回“待审核”的流程,导师能驳回“待审核”的流程,管理员能强制重置任何流程。这个分级杜绝了“学生把自己答辩状态改成已通过”这种事故。
超时提醒用 Spring 自带的@Scheduled就够:
@Scheduled(cron = "0 0 8 * * *") public void remindPendingProcess() { // 超过 48 小时还没审核的流程,汇总后发站内信 Date deadline = new Date(System.currentTimeMillis() - 48 * 3600 * 1000L); List<PaperProcess> pending = baseMapper.selectList(new LambdaQueryWrapper<PaperProcess>() .eq(PaperProcess::getStatus, 1) .lt(PaperProcess::getSubmitTime, deadline)); for (PaperProcess process : pending) { messageService.sendToStudent(process.getStudentId(), "你的" + process.getType() + "流程等待审核已超过48小时"); } }这个定时任务每天上午 8 点跑一次,把超过 48 小时没审核的流程找出来通知学生。注意一个问题:如果生产环境有多个后端实例,@Scheduled会在每个实例上都执行一遍,需要引入分布式锁(比如 Redisson)保证同一时刻只有一个实例在跑。毕设系统单机部署没这个问题,但你要知道边界在哪。
5. 避坑手册:研究生管理系统里最常见的五个翻车现场
这一章写的都是实际改代码时反复遇到过的坑。每条按“现象 → 原因 → 解决”的顺序讲,可以直接对应到你的报错日志和异常行为上。
5.1 登录后所有接口全部 401
现象:用户输入正确的账号密码,登录接口返回 token,但拿着 token 访问任何业务接口都提示 401,F12 里看到大量 OPTIONS 请求报错。
原因基本出在两个地方。第一是拦截器没有放行跨域预检请求:浏览器在发起 POST/PUT 前会先发一个 OPTIONS 请求,这个请求不带 token,如果拦截器直接拦截并返回 401,后续业务请求根本发不出去。第二是 token 解析时用的 key 不一致,比如登录时用secret-key签名,拦截器里用另一个字符串解析,能验签成功才见鬼。
解决:拦截器里对OPTIONS请求直接放行;Spring Boot 的 CORS 配置和拦截器配置要同时存在,缺一个都会有问题。token 的密钥统一放到application.yml的配置项里,不要散落在代码各处。配置如下:
jwt: secret: your-secret-key expire-hours: 2这样登录和拦截器都从同一个配置源读取,从根上消除不一致。
5.2 导师双选出现一个学生两个导师
现象:双选结束后导出的师生关系表里,同一个学生出现在两个导师名下,管理员手动删都删不干净。
原因:确认接口没有做“学生已存在导师关系”的并发防护。两个导师同时点“确认”按钮,两个事务都查了一遍relationMapper.findConfirmed(dto.getStudentId()),都发现为空,然后都插入成功。本质上是经典的“检查再插入”并发问题。
解决:第一道防线是数据库唯一索引,在relation表的student_id字段上加唯一约束,第二个插入直接被数据库拒绝,这是兜底;第二道防线是业务层校验,用SELECT ... FOR UPDATE锁住学生的关系记录再检查;第三道防线是提交前在select_apply表上把该学生所有已确认的志愿状态更新掉,只允许一条流转。并发扣名额、重复确认这类场景,现在几乎成了 java 八股文级别的经典考点,面试问到的概率不低。
5.3 论文流程能跳过开题直接到答辩
现象:学生没提交过开题报告,系统里却能建出一条状态为“待审核”的答辩申请记录;更离谱的是,开题被驳回后,中期记录照样能提交。
原因:每条流程都只校验自己的状态,没做流程间的前置依赖校验。学生提交中期报告时,代码只判断“中期流程当前是草稿”,没有去查开题流程是否为“已通过”。
解决:在“提交流程”的接口里,根据type做前置判断。提交 opening 时查 thesis_topic 状态,提交 midterm 时查 opening 状态,提交 defense 时查 midterm 状态。把这段判断抽成一个方法,单独写单元测试覆盖“前置未通过”“前置已通过”“前置被驳回”三种情况。流程之间的依赖关系是业务规则,不能指望前端按钮隐藏来保证安全。
5.4 答辩成绩计算出现空指针与精度丢失
现象:答辩录入分数时,某些学生成绩记不上;平均分算出来是整数,84.5 变成 84;评委只打了一组分数的学生会整条记录失败。
原因:一是数据库中成绩列允许为空,service 层直接用Integer累加,空值一参与计算就 NPE;二是用了int做除法,小数部分被截断;三是某个评委没打分时,List<Score>集合里存在 null 元素。
解决:成绩字段统一用BigDecimal,数据库列用DECIMAL(5,2)并在 DDL 里加NOT NULL DEFAULT 0,把空值挡在入口之外;计算平均分时用BigDecimal的divide方法指定保留两位小数;评委分数集合在遍历前过滤掉 null。核心原则是:分数计算不信任数据库的宽松,能加默认值就加默认值,能在 DDL 层卡住的事情不要让代码去兜底。
5.5 导入学生名单时中文乱码与学号变科学计数法
现象:用 EasyExcel 或 POI 导入学生 Excel,学号变成了1.23457E+10,中文姓名显示成乱码,日期字段读出序列号。
原因:Excel 里数字单元格默认被读成 double,15 位以上学号精度丢失;编码问题多发生在读取 xls 老格式时用了错误的字符集;日期列读出来是整数序号,需要手动转日期格式。
解决:用 POI 的DataFormatter读取单元格原始展示样式,不要直接用getNumericCellValue:
// 按单元格的展示格式读值,学号、日期都不会丢格式 DataFormatter formatter = new DataFormatter(); String cellValue = formatter.formatCellValue(cell);它的作用是“单元格在 Excel 里显示成什么样,就读成什么样”,学号不会变科学计数法,日期也不会变序列号。文件流读取时统一用xlsx格式保存,xlsx本身就是 UTF-8 编码的 zip,比老的xls少了大量编码坑。如果条件允许,直接让学生用系统提供的模板下载后填写再上传,把“列的标题和顺序”这个问题从源头解决。
6. 验证与进阶:用一条命令把系统跑起来并走通全流程
系统写完不是终点,能演示、能让人相信“这是能用的系统”才是终点。最常见的遗憾是:管理员、导师、学生三个账号都有,但演示时只展示学生端菜单,导师端和教务端的操作全靠口头描述。这里分享一个我一直在用的验证方法——写一段 bash 脚本,把完整业务链路串起来,演示的时候跑一遍,比切换页面点鼠标更有说服力,也比网上那些课程设计案例源码更进一步。
脚本的核心思路是模拟一条完整链路:学生登录 → 提交开题 → 导师登录 → 审核通过 → 学生提交中期 → 导师审核 → 学生提交答辩 → 管理员查看进度:
BASE=http://localhost:8080/api # 1. 学生登录,拿 token TOKEN=$(curl -s -X POST "$BASE/auth/login" \ -H "Content-Type: application/json" \ -d '{"username":"2023001","password":"stu123456"}' | jq -r '.data.token') # 2. 学生提交开题流程 curl -s -X POST "$BASE/paper/process" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"type":"opening","title":"基于Spring Boot的研究生管理系统设计与实现"}' # 3. 导师登录,审核通过 T_TOKEN=$(curl -s -X POST "$BASE/auth/login" \ -H "Content-Type: application/json" \ -d '{"username":"t2001","password":"tea123456"}' | jq -r '.data.token') curl -s -X POST "$BASE/paper/process/audit" \ -H "Authorization: Bearer $T_TOKEN" \ -H "Content-Type: application/json" \ -d '{"processId":1,"targetStatus":2,"comment":"同意开题"}'这段脚本依赖curl和jq,jq用从 JSON 里取 token,没有的话可以用 Python 的json.tool替代,或者直接打印返回体手抄 token。要注意的是,脚本里的 ID 和 username 是写死的,跑之前先确认数据库里的种子数据存在。建议在schema.sql里预置 5 个学生、3 个导师、2 个管理员,密码统一设为公开默认值,并在演示环境关闭密码复杂度校验。
验收时我还习惯对照一张清单:学生能否看到自己的全部审核进度;导师能否只看到名下学生;管理员能否强制撤回异常状态;两个浏览器同时操作同一流程会不会出现重复通过。这四件事都过了,系统的核心可信度就立住了。
最后说一个我自己的教训。最早做这类系统时,我把审核通过写成了直接改status,前端按钮一多,演示现场点错了把答辩状态从“待审核”点成了“已通过”,当场数据就乱了。后来全部改成后端状态机校验,前端按钮只是调接口,代码层面才真正兜住了底线。希望这篇笔记能让你少走这段弯路,也希望你做完之后,敢在任何人面前打开演示页面,跑完整条链路。
本文还有配套的精品资源,点击获取