简介:一套基于SSM框架的研究生管理系统Java源码,专为计算机、电子信息工程等专业学生毕业设计、课程设计或期末大作业而准备。项目采用B/S架构与MVC模式,整合Spring、SpringMVC、MyBatis与MySQL,包含研究生信息管理、后台管理、数据交互等典型模块,有助于理解企业级Java Web项目结构。项目基于JDK1.8与Maven构建,可在Tomcat 8/9中运行,支持IDEA、Eclipse等主流开发工具导入。压缩包共805个文件,约21.11MB,216个Java源码负责后端逻辑,154个Vue页面构建前端界面,另有XML配置、SQL脚本、构建运行脚本及说明文档,便于快速部署与二次开发;文件按功能组织,适合边学边练。目前已有53人学习下载。代码经过严格测试,导入IDEA/Eclipse即可运行,既可直接用于答辩展示,也可作为学习SSM与前后端分离开发的完整范例;使用中遇到问题可与博主沟通,第一时间获得解答。
1. 研究生管理系统代码,Java 版最容易踩的坑是什么
研究生管理系统代码,Java 版最难处理的部分不是“学生管理”,而是研究生培养过程里的状态流转。研究生从入学开始就要选导师,导师确认以后才能定课题,课题过了开题,后面还有中期和答辩。很多课程设计和外包项目把这一切简化成一张 user 表加一个 status 字段,结果前端界面做出来了,后端一旦并发提交,就会同时出现“一个学生有两个导师”“导师名额超收”“开题没通过但已经答辩”这类数据问题。这篇文章按 Java 项目最常见的 Spring Boot + MyBatis-Plus + MySQL 组合,把数据库表设计、登录权限、导师双选、选题答辩和排错方法串起来讲。新手可以照着搭,有经验的工程师也能拿去对照自己的实现是不是在关键地方少做了约束。
2. Java 研究生管理系统代码的数据库表与实体映射
2.1 先用“培养流程”而不是“用户类型”来建表
研究生管理系统里能登录的人有三种:学生、导师、管理员。但“研究生”不等于“学生用户”,论文选题也不是挂在 sys_user 表上的一个字符串。我一般会把“账号”和“业务身份”拆开:sys_user 只负责登录认证,学生和导师各自用扩展表存业务字段。这样以后系统要加一个外部评审专家,只需要给 sys_user 增加一个 REVIEWER 角色,再单独建一张扩展表,而不用去动学生的表结构。
培养流程相关的表至少要有:导师选择申请表 t_selection、论文过程表 t_paper、答辩记录表 t_defense。其中 t_paper 需要用一个类型字段区分选题、开题、中期和答辩阶段,而不是每阶段建一张表,否则查询历史链路会非常痛苦。下面这张表比较能说明问题。
| 表名 | 角色 | 核心字段 | 为什么需要独立 |
|---|---|---|---|
| sys_user | 登录 | username, password, role, status | 账号与业务解耦 |
| t_student | 学生扩展 | user_id, student_no, major, mentor_id | 学生基础属性独立 |
| t_teacher | 导师扩展 | user_id, teacher_no, max_count | 区分身份与账号 |
| t_selection | 双选 | student_id, teacher_id, status | 状态可追溯 |
| t_paper | 课题过程 | student_id, teacher_id, type, status | 覆盖选题到中期 |
| t_defense | 答辩 | paper_id, defense_time, score | 成绩独立存储 |
2.2 建表 SQL:把约束放在数据库而不是只写在 Service
很多初学者只会在 Service 层写 if 判断,数据库表结构却很松。导师双选这个场景里,数据库层面至少要有一个“学生同一时刻只存在一条未结束申请”的约束;论文流程里,状态字段要有明确的取值范围。下面这份 SQL 是一个可用的最小结构。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL COMMENT 'STUDENT/TEACHER/ADMIN', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_student ( id BIGINT PRIMARY KEY COMMENT '与sys_user.id一致', user_id BIGINT NOT NULL, student_no VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL, major VARCHAR(64), mentor_id BIGINT NULL COMMENT '最终确认导师', CONSTRAINT fk_stu_user FOREIGN KEY (user_id) REFERENCES sys_user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_teacher ( id BIGINT PRIMARY KEY COMMENT '与sys_user.id一致', user_id BIGINT NOT NULL, teacher_no VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL, title VARCHAR(32), max_count INT NOT NULL DEFAULT 4, CONSTRAINT fk_tea_user FOREIGN KEY (user_id) REFERENCES sys_user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已接受 2已拒绝', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_teacher (teacher_id), CONSTRAINT fk_sel_stu FOREIGN KEY (student_id) REFERENCES t_student(id), CONSTRAINT fk_sel_tea FOREIGN KEY (teacher_id) REFERENCES t_teacher(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这份 SQL 里的外键建议保留。在毕业设计或者中小型项目中,外键的作用不是限制性能,而是防止程序 Bug 直接写坏数据。mentor_id放在 t_student 中看起来冗余,但它能避免每次查学生信息都要关联 t_selection 才能知道导师是谁。双选确认后把mentor_id更新掉,同时把 t_selection.status 改成 1,这两个操作要在同一个事务里完成,后面双选那章再展开。
注意t_selection里没有把唯一键加在 student_id + status 上,因为这种写法会让同一学生同时存在一条 status=1 和一条 status=0 的记录。正确做法是给双选增加一个batch_code字段,代表“第几轮双选”,然后对 (student_id, batch_code) 建唯一索引。你可以先用普通索引配合业务判断,但这属于“指标不治本”。
2.3 Java 实体与 MyBatis-Plus 的映射写法
对应到 Java 代码,实体类尽量不要和前端 VO 混用。MyBatis-Plus 推荐用@TableName注解映射数据库表,主键策略要根据实际表结构来选。如果 t_student.id 与 sys_user.id 一一对应,就用IdType.INPUT,不要在应用里再生成一次 ID。
@Data @TableName("t_student") public class Student { @TableId(type = IdType.INPUT) private Long id; private Long userId; private String studentNo; private String name; private String major; private Long mentorId; }这段代码中的@TableId(type = IdType.INPUT)表示 id 由外部传入,而不是数据库自增。如果创建学生账号时先插入 sys_user 拿到自增 id,再把这同一个 id 插入 t_student,就能保证两个表主键一致。@TableName里的指定值要和表名完全一致,MySQL 在 Linux 下区分大小写,写错会直接报“表不存在”。mentorId在数据库里是mentor_id,MyBatis-Plus 默认开启驼峰映射,但如果 Spring Boot 配置文件里map-underscore-to-camel-case没打开,查询结果里这个字段就一直是 null。
2.4 MyBatis 和 JPA 怎么选
每次讨论 Java 研究生管理系统代码,都会有人问该用 MyBatis 还是 JPA。我的真实建议是:如果系统里报表查询和动态条件比较多,用 MyBatis 或 MyBatis-Plus;如果主要诉求是对象关系管理和自动建表,用 JPA。研究生管理系统很少需要复杂的继承关系,反而不断有“按学院统计”“按导师统计”“按答辩年份统计”这类带条件聚合的 SQL,MyBatis 在这条路上更顺手。
| 维度 | MyBatis-Plus | Spring Data JPA |
|---|---|---|
| 动态查询 | QueryWrapper 直接拼 | Specifications 或 QueryDSL |
| 复杂 SQL | 写 XML 可控性强 | JPQL 表达统计略绕 |
| 自动建表 | 基本靠脚本 | ddl-auto 方便 |
| 学习门槛 | 低,SQL 看得懂即可 | 需要理解持久化上下文 |
我见过不少用 JPA 做研究生管理系统的项目,最后答辩统计和报表查询都忍不住写了@Query(nativeQuery = true),既然如此,不如一开始就用 MyBatis-Plus。
3. Java 研究生管理系统代码的登录权限和角色校验
3.1 密码加密:MD5 直接否掉,BCrypt 是最低要求
研究生管理系统代码里最容易被忽略的安全点是密码存储。很多课程设计直接用 MD5 加密,这在 2025 年已经完全没有防御力,彩虹表一查就还原。更稳妥的做法是使用 Spring Security 自带的 BCryptPasswordEncoder,它每次生成的哈希值都不一样,因为内部会自动混入随机盐。
BCrypt 的强度可以通过 strength 参数调整,默认 10,数值越大计算越慢。如果机器性能一般,保持 10 即可;如果表单登录并发很高,10 的 BCrypt 会在压力测试时消耗不少 CPU。管理员密码重置功能里常见的错误是再次加密randomPassword而不是加密用户输入的密码,这个细节在联调时最容易被发现。
3.2 登录接口代码:返回 Token 而不是 Session
如果系统前后端不分离,Session 方案没问题。但现在常见做法是 Vue + Spring Boot,后端更推荐签发 JWT。登录接口拿到用户名和密码,校验通过后生成带角色信息的 Token,后续请求只要携带Authorization: <token>即可。
@Service public class AuthService { @Resource private UserMapper userMapper; @Resource private PasswordEncoder passwordEncoder; public LoginResult login(LoginDTO dto) { SysUser user = userMapper.findByUsername(dto.getUsername()); if (user == null || user.getStatus() != 1) { throw new BizException("用户名不存在或已被禁用"); } if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) { throw new BizException("密码错误"); } String token = JWT.create() .withClaim("uid", user.getId()) .withClaim("role", user.getRole()) .withExpiresAt(DateUtil.offsetDay(new Date(), 7)) .sign(Algorithm.HMAC256("你的签名密钥")); return LoginResult.of(token, user.getRole()); } }这段代码的逻辑是:先查用户,再比对密码,最后生成有效期为 7 天的 JWT。passwordEncoder.matches()方法会从库里的 BCrypt 哈希中提取盐并重新计算,所以不要尝试手动截取 BCrypt 字符串中的盐来做二次校验。Algorithm.HMAC256的密钥必须放到配置文件中,不要硬编码在源码里;密钥长度最好 32 字节以上,且每隔一段时间轮换。withClaim里只放用户 id 和角色,不要放姓名、手机号这类敏感信息。
3.3 用拦截器校验角色:理解 HandlerInterceptor 的执行时机
JWT 只负责身份认证,角色鉴权还需要一个拦截器。Spring Boot 中实现 HandlerInterceptor 是常见解法。拦截器在 Controller 方法调用前执行,如果校验不通过直接抛出异常,后续处理器就不会再执行。
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token)) { throw new BizException("未登录"); } DecodedJWT jwt = JWT.require(Algorithm.HMAC256(secretKey)) .build().verify(token); Long uid = jwt.getClaim("uid").asLong(); String role = jwt.getClaim("role").asString(); if (handler instanceof HandlerMethod handlerMethod) { RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole != null && !Arrays.asList(requireRole.value()).contains(role)) { throw new BizException("无权访问该接口"); } } request.setAttribute("loginUid", uid); request.setAttribute("loginRole", role); return true; } }这段拦截器有两个关键参数:handler可能是静态资源处理器,所以要先判断handler instanceof HandlerMethod,否则一个静态图片请求可能因为没有 Token 被拒绝。@RequireRole是自定义注解,定义成@Target(ElementType.METHOD),作用在具体接口上。一个小技巧是:把登录用户的 id 和角色写入 request attribute,Controller 中直接用(Long) request.getAttribute("loginUid")获取,而不是在每个接口里重复解析 Token。
3.4 各角色接口划分表
当系统有学生、导师、管理员三种角色时,接口权限最容易混乱。下面这张表可以作为初始的分权依据。
| 功能 | 学生 | 导师 | 管理员 |
|---|---|---|---|
| 个人信息维护 | 查看和修改 | 查看和修改 | 查看禁用 |
| 导师双选 | 发起/撤销申请 | 确认/拒绝 | 配置批次 |
| 选题申报 | 提交/撤回 | 审核通过/退回 | 查看全院进度 |
| 答辩成绩管理 | 查看成绩 | 录入/修改 | 导出统计 |
| 用户管理 | 无 | 无 | 新增/禁用账号 |
这张表不是一成不变的,比如答辩成绩可以由导师录入,也可以由管理员备份录入。每写一个加密接口,先想清楚最终操作者是谁,再决定要不要在方法上写@RequireRole。只依赖前端按钮隐藏权限等于没有权限。
4. Java 研究生管理系统代码的导师双选状态机与并发控制
4.1 双选不是“改一个字段”,是状态迁移
导师双选是所有研究生管理系统里最核心的部分。它的天然难点是双方都会操作,而且同一个学生的申请可能同时被多个导师看到。双选状态可以抽象为四态:待确认、已接受、已拒绝、已失效。已失效可以对应导师超过确认时限或学生被管理员取消的情况。
| 状态码 | 含义 | 可转换方向 |
|---|---|---|
| 0 | 待导师确认 | 1 已接受 / 2 已拒绝 |
| 1 | 已接受,成为最终导师 | 无,需要管理员撤销 |
| 2 | 已拒绝 | 学生重新发起 |
| 3 | 已失效 | 学生重新发起 |
如果所有业务都围绕这个状态迁移展开,就不会出现“导师已经接受了 A,B 还看到该导师可申请”的问题。每次修改状态之前,都要重新从数据库读取当前状态,而不是直接更新前端传来的对象。
4.2 学生发起选择的 Service 代码
学生端发起申请时,需要校验三个条件:自己是否已有导师、当前是否有待确认申请、导师是否开放招生。这段逻辑不要拆到 Controller 里,写进 Service 并且加上事务。
@Transactional(rollbackFor = Exception.class) public void studentChoose(Long studentId, Long teacherId) { Student student = studentMapper.selectById(studentId); if (student.getMentorId() != null) { throw new BizException("已有导师,不能重复申请"); } Selection pending = selectionMapper.selectOne( new LambdaQueryWrapper<Selection>() .eq(Selection::getStudentId, studentId) .eq(Selection::getStatus, 0)); if (pending != null) { throw new BizException("已有一条待确认申请"); } Teacher teacher = teacherMapper.selectById(teacherId); if (teacher.getMaxCount() == null || teacher.getMaxCount() <= 0) { throw new BizException("该导师未开放招生"); } Selection selection = new Selection(); selection.setStudentId(studentId); selection.setTeacherId(teacherId); selection.setStatus(0); selectionMapper.insert(selection); }这个方法虽然加了@Transactional,但实际只做了一次插入操作,事务的作用更多是保证后续扩展时一致。LambdaQueryWrapper里的eq(Selection::getStatus, 0)对应数据库字段 status,不要写成setStatus。如果查询的学生或导师不存在,selectById会返回 null,代码里没有判空,可以在实际项目中补上。
4.3 导师确认:先检查名额,再更新双方数据
导师确认申请是由导师端触发的一个写操作,它必须同时更新 t_selection 状态和 t_student.mentor_id。如果学生在同一时间被两位导师分别确认,就会出现 t_student 表里导师被覆盖的情况。
@Transactional(rollbackFor = Exception.class) public void teacherConfirm(Long teacherId, Long selectionId, boolean accept) { Selection sel = selectionMapper.selectById(selectionId); if (sel == null || !sel.getTeacherId().equals(teacherId)) { throw new BizException("申请不存在或不属于当前导师"); } if (sel.getStatus() != 0) { throw new BizException("该申请已处理"); } if (!accept) { sel.setStatus(2); selectionMapper.updateById(sel); return; } Teacher teacher = teacherMapper.selectById(teacherId); if (teacher.getMaxCount() <= studentMapper.countTeacherAccepted(teacherId)) { throw new BizException("导师名额已满"); } sel.setStatus(1); selectionMapper.updateById(sel); studentMapper.updateMentor(sel.getStudentId(), teacherId); }这段逻辑的执行顺序是先读 selection,再判断名额,再更新状态和 mentor 字段。studentMapper.updateMentor是自定义 SQL,执行类似update t_student set mentor_id = #{mentorId} where id = #{studentId} and mentor_id is null的语句。在 where 条件里加mentor_id is null,是防止并发下学生已经有导师但事务读到了旧数据。
4.4 并发问题:行锁、唯一索引和业务判断缺一不可
常见误解是“加了@Transactional就能避免并发问题”,实际上事务默认的隔离级别只能解决事务隔离,不能防止两个事务同时读到同一条状态为 0 的记录。更可靠的方案是引入乐观锁,在 t_teacher 表中增加version字段,MyBatis-Plus 用它实现乐观锁插件。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }配置完插件后,在 t_teacher 实体中对应 version 字段上加上@Version。更新导师名额时 MyBatis-Plus 会生成update t_teacher set max_count = ?, version = version + 1 where id = ? and version = ?这样的 SQL。当两个导师操作不同学生时,后 commit 的那个事务会因为版本号不匹配而更新 0 行,代码里要判断 update 返回值,为 0 则说明有人先改过。
5. Java 研究生管理系统代码的选题、开题与答辩统计
5.1 用一张 t_paper 表承载全过程
很多研究生管理系统把开题报告、中期检查、论文定稿拆成多张表,导致统计成绩时要用 union 拼接。一种更省心的做法是统一用 t_paper 表,通过 type 区分阶段。这样从选题到答辩的数据血缘完整,也方便按学生分组看全程时间线。
CREATE TABLE t_paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, title VARCHAR(200) NOT NULL, type TINYINT NOT NULL COMMENT '1选题 2开题 3中期 4答辩', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1提交 2审核通过 3退回', score DECIMAL(5,2) NULL COMMENT '答辩/中期评分', comment VARCHAR(500), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_student_type (student_id, type), KEY idx_teacher_status (teacher_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_student_type是常用查询路径:查某个学生的所有论文进展。idx_teacher_status用来支撑导师端“待审核列表”。status=2 代表审核通过,type=4 且 status=2 说明答辩评分为最终成绩。成绩字段放在这张表上而不是单独建表,是为了简化统计。
5.2 Service 层查询:关联学生和导师,避免 N+1
前端需要展示论文列表,包括标题、学生姓名、导师姓名、当前状态。最直观的写法是在 for 循环里查学生和导师,但每页 20 条记录就会产生 40 条额外 SQL,这种 N+1 查询在学校管理这种低频访问场景下也能跑,只是代码会越来越慢。常用做法是先批量查出 id,再用 Map 一步完成关联。
public List<PaperVO> listPapers(String keyword, Integer status) { List<Paper> papers = paperMapper.selectList( new LambdaQueryWrapper<Paper>() .like(StringUtils.hasText(keyword), Paper::getTitle, keyword) .eq(status != null, Paper::getStatus, status) .orderByDesc(Paper::getCreateTime)); List<Long> studentIds = papers.stream() .map(Paper::getStudentId).distinct().toList(); Map<Long, Student> studentMap = studentMapper.selectBatchIds(studentIds) .stream().collect(Collectors.toMap(Student::getId, s -> s)); List<Long> teacherIds = papers.stream() .map(Paper::getTeacherId).distinct().toList(); Map<Long, Teacher> teacherMap = teacherMapper.selectBatchIds(teacherIds) .stream().collect(Collectors.toMap(Teacher::getId, t -> t)); return papers.stream().map(p -> { PaperVO vo = new PaperVO(); BeanUtils.copyProperties(p, vo); Student s = studentMap.get(p.getStudentId()); Teacher t = teacherMap.get(p.getTeacherId()); vo.setStudentName(s == null ? "" : s.getName()); vo.setTeacherName(t == null ? "" : t.getName()); return vo; }).toList(); }这段代码暴露了一个容易忽略的细节:papers.stream().distinct().toList()是 Java 16 之后才有的方法,如果项目使用的是 Java 8,需要改成.collect(Collectors.toList())。selectBatchIds内部会用WHERE id IN (...),如果studentIds为空,MyBatis-Plus 默认会生成WHERE id IN ()这种非法 SQL,所以要先判断if (studentIds.isEmpty()) return List.of();。关联查询本质上是把 SQL 里的 join 搬到了应用层,在数据量小于一万条时性能影响不大,代码却好维护很多。
5.3 答辩统计 SQL:JOIN + GROUP BY + HAVING
导师带研究生最多的学院会要求统计“每位教师指导的答辩成绩平均分”和“指导人数”。这条 SQL 看起来简单,但 HAVING 和 WHERE 的先后顺序经常被搞混。
SELECT t.teacher_name, COUNT(DISTINCT p.student_id) AS student_cnt, ROUND(AVG(p.score), 2) AS avg_score FROM t_paper p JOIN t_teacher t ON p.teacher_id = t.id WHERE p.type = 4 AND p.status = 2 AND p.score IS NOT NULL GROUP BY t.teacher_name, t.id HAVING COUNT(DISTINCT p.student_id) >= 3 ORDER BY avg_score DESC;WHERE是在分组前过滤掉无效记录,HAVING是在分组后过滤。所以“指导人数大于等于 3”必须放在 HAVING 里,“只统计答辩成绩”放在 WHERE 里。GROUP BY t.teacher_name, t.id里的 t.id 可以防止两个老师同名导致被错误合并。如果后续还要统计当年数据,可以在 WHERE 中加p.update_time >= '2025-01-01'或者建立一张年度归档表。
5.4 答辩成绩用没历史的统计字段
答辩成绩一旦由导师录入并确认,尽量不要允许直接修改。如果需要纠错,管理员应该走“修正记录”而不是把 score 字段覆盖掉。可以用一张t_score_audit表记录变更前分数、变更后分数、操作人 id、操作时间,防止答辩结束以后的数据被偷偷改动。这也是研究生管理系统和普通管理系统不太一样的地方:数据合规性比操作便捷更重要。
6. 跑 Java 研究生管理系统代码时,SQL 日志和事务回滚这样查
研究生管理系统中报错最多的地方往往不是业务逻辑,而是 SQL 拼错或者事务根本没生效。这里分享一个立刻就能用的排查方法:把 MyBatis 的 SQL 日志打到控制台,再配合事务回滚检查。
在application.yml里加这样一段:
logging: level: com.your.mapper: debug mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImplcom.your.mapper要替换成 Mapper 接口所在的包路径。这样每个方法执行时,控制台会打印 Preparing 和 Parameters 两行,前者是最终发给 MySQL 的 SQL,后者是绑定的参数。如果发现update t_student set mentor_id = ?但没有 where 条件,问题一定出在 MyBatis-Plus 的updateById之前把主键 id 置空了。
事务回滚失效的常见原因有四个:第一,类内部自调用this.method(),导致 @Transactional 代理拦截不到,必须注入自身或者拆到另一个 Service;第二,catch 了异常并且 return 正常值,事务会提交而非回滚;第三,抛出的不是 RuntimeException,且rollbackFor没有指定Exception.class;第四,同一个事务里操作了多数据源,但没有配置对应的事务管理器。
@Transactional(rollbackFor = Exception.class) public void savePaperAndAudit(Paper paper) { try { paperMapper.updateById(paper); } catch (Exception e) { // 这里如果把异常吞掉,事务就永远不会回滚 log.error("保存失败", e); throw new BizException("保存失败"); } }上面如果 catch 里只打了日志不重新抛出,就算paperMapper.updateById失败,事务也会正常提交,学生看到的评审结果就是旧数据。正确做法是把可预期异常统一转换成 BizException 抛出,让事务代理感知到异常并执行回滚。把日志级别调成 debug 跑一次双选流程,看到Rolling back JDBC transaction这行日志,才能确定事务是真的生效了。
本文还有配套的精品资源,点击获取