news 2026/9/18 1:41:34

SSM 学籍管理系统:数据流、事务与异动审批实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSM 学籍管理系统:数据流、事务与异动审批实践

简介:《Java+SSM学生学籍管理系统》是一份面向计算机相关专业毕业生与Java Web初学者的毕业设计论文文档,围绕高校学籍管理场景,给出从选题背景、研究现状到系统分析、设计、实现与测试的完整论述。文档共1个doc文件,压缩包约1.5MB,结构完整,含摘要、ABSTRACT、绪论、系统开发环境、系统分析等章节,目录清晰便于按章节查阅。内容重点拆解SSM框架(Spring、SpringMVC、MyBatis)的整合应用,兼顾Vue前端、Java技术、MySQL数据库与B/S架构的选型说明,并覆盖可行性分析、性能与功能需求分析、数据库设计、学生信息管理、成绩管理、课程管理等模块的实现思路。已有69人学习,适合需要搭建学籍管理系统、撰写毕业论文或准备答辩的学生参考,可用于梳理需求分析、系统架构、功能测试与部署流程,快速形成可落地的设计与实现方案。

1. 学籍管理系统为什么还在用 SSM,毕设答辩前要想明白的数据流

每年三四月,实验室里都会重演同一批问题:一个学生转了专业,班级、院系、专业三张关联表的字段没跟着改,页面上还显示旧数据;答辩老师追问一句「这条 update 放在哪个事务里」,人就卡住了。学生学籍管理系统看上去简单,真正把它做成能演示、能被追问、能临时改需求的毕业设计,难点全在数据流上。用 Java 加 SSM 这套组合,Spring 负责对象的创建与依赖注入,SpringMVC 负责把一次 HTTP 请求落到具体方法上,MyBatis 负责 SQL 怎么写、结果怎么映射回对象,三件事拆开之后,每个环节都能单独说清楚为什么这么做。

这套方案适合正在做毕设的计算机专业学生,也适合写了两年业务代码、想回头补一遍 JavaWeb 全链路的开发者。反过来说,如果目标只是三天交一个能点开的 demo,SSM 那一堆 XML 配置确实是负担,直接上 SpringBoot 会更快。但只要答辩 PPT 里要出现「架构分层」「事务边界」「权限控制」这几个词,SSM 的显式配置反而是优势:它把每一步都摊在明面上,逼着你想明白一个学籍异动从页面提交到落库,中间到底经过了谁。

2. 搭 SSM 工程骨架:依赖版本、双 Spring 容器与分包边界

2.1 Maven 依赖怎么锁:SSM 最容易冲突的四个坐标

第一次跑不起来的 SSM 项目,八成是版本打架。Spring 与 MyBatis 之间的连接件 mybatis-spring 必须和 Spring 大版本对齐,mysql 驱动 8.x 与 5.x 的驱动类名、连接串时区参数写法完全不同,servlet-api 忘了写provided会被打进 war 包,和容器自带的实现撞上,Tomcat 启动直接抛 ClassCastException。我一般会把版本集中锁在 Spring 5.3.x 这一档,这是目前资料最多、踩坑记录最全的区间。

坐标锁定档位承担的职责高频坑
spring-webmvc5.3.x请求分发、参数绑定、视图解析与 context 版本不一致会导致 NoSuchMethodError
spring-jdbc5.3.x事务管理器、数据源支持漏引会让 DataSourceTransactionManager 找不到
mybatis3.5.xSQL 映射、结果集封装与 mybatis-spring 跨大版本会报 SqlSession 工厂异常
mybatis-spring2.0.x把 Mapper 接口注册成 Bean1.x 版本不兼容 Spring 5 的事务回调
mysql-connector-java8.0.xJDBC 驱动连接串必须带 serverTimezone,否则时间字段差 8 小时
druid1.2.x连接池、慢 SQL 监控监控页默认关闭,需手动开 StatViewServlet
pagehelper5.x物理分页插件与 MyBatis 版本错配会拦截不到 Executor
servlet-api / jsp-api容器提供编译期接口scope 必须写 provided
<!-- pom.xml 关键片段:只列版本与作用域控制 --> <properties> <spring.version>5.3.39</spring.version> <mybatis.version>3.5.16</mybatis.version> </properties> <dependencies> <!-- SpringMVC:提供 DispatcherServlet 与注解驱动 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- 事务与数据源支持,@Transactional 依赖它 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 与 Spring 的桥接件,版本必须与 Spring 5.x 对齐 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <!-- servlet-api 由 Tomcat 提供,打包时必须排除 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> </dependencies>

这段依赖声明里最需要解释的是scope参数。provided表示编译和测试时可见、打包时剔除,凡是容器已经提供的接口(servlet-api、jsp-api)都必须这么写,否则 war 包里带着一份,容器里还有一份,类加载器挑哪份全看运气。mybatis-spring 单独列出来而不是跟着 mybatis 走,是因为 MyBatis 本身不感知 Spring 的事务,只有桥接件才能让 Mapper 方法参与DataSourceTransactionManager管理的事务。Druid 和 PageHelper 我一般放在最后加,先把核心链路跑通再叠功能,出问题时排查范围小。

2.2 web.xml 与两个 Spring 容器:谁加载什么决定了事务生不生效

这是 SSM 里最反直觉的一处设计。项目里同时存在两个 Spring 容器:父容器由ContextLoaderListener启动,读applicationContext.xml,管 service、mapper、数据源和事务;子容器由DispatcherServlet启动,读springmvc.xml,只管 controller 和视图解析。子容器能看见父容器的 Bean,父容器看不见子容器。

问题就出在这里。如果springmvc.xml里的组件扫描写成com.school这种大包名,service 会被子容器再扫一遍,生成一份不带事务代理的实例,Controller 注入到的恰好是这一份。表现就是异动审批里两条 update 一成功一失败,数据脏了,日志里却什么异常都没有。

<!-- web.xml:容器启动顺序与配置文件绑定 --> <context-param> <param-name>contextConfigLocation</param-name> <!-- 父容器:service、mapper、事务、数据源 --> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <!-- 子容器:只负责 controller --> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:springmvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <!-- 编码过滤器要放在所有过滤器最前面,否则中文姓名入库变问号 --> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param><param-name>encoding</param-name><param-value>UTF-8</param-value></init-param> <init-param><param-name>forceEncoding</param-name><param-value>true</param-value></init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

load-on-startup设为 1,让 DispatcherServlet 在容器启动时就完成初始化,第一次访问不用等。forceEncoding设 true 比只设 encoding 更稳妥,它同时强制设置响应编码,避免返回 JSON 时中文乱码。对应地,springmvc.xml的扫描范围必须收窄到com.school.controllerapplicationContext.xmlcom.school.servicecom.school.mapper,两个容器各管一段,谁也不越界。

2.3 分包结构:每个包里禁止出现的东西

分层不只是为了好看,它是排错的坐标系。异动审批出问题,先看 service 里事务有没有开;SQL 结果不对,直接翻 mapper 的 XML;页面收到的字段少了,看 controller 有没有把 DTO 转成 VO。包名一旦乱,这个坐标系就失效了。

src/main/java/com/school ├── controller # 只做参数接收、调用 service、组装返回体,禁止写 SQL ├── service # 业务规则与事务边界,禁止出现 HttpServletRequest ├── service/impl # 实现类,@Transactional 加在这一层 ├── mapper # 只有接口,方法名与 XML 的 id 一一对应 ├── entity # 与数据库表字段对应的实体,禁止加业务方法 ├── dto # 前端提交的参数对象,承载校验注解 ├── vo # 返回给前端的展示对象,字段可以比 entity 少 └── common # 统一返回体、异常、常量、工具类

约束要写死:controller 里不许出现SqlSession,service 里不许出现HttpServletRequest,entity 里不许写业务判断。这三条守住,代码量再翻一倍也不会失控。DTO 和 VO 分开建,是因为学籍新增时前端传的是班级 id,返回时页面要显示班级名称,用同一个类既要接收 id 又要返回名称,字段就会互相污染。

2.4 数据源与 SqlSessionFactory:Druid 和 PageHelper 的接入点

<!-- applicationContext.xml:数据源 + 会话工厂 + 分页插件 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/school_roll?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> <!-- 初始连接数,毕设环境 5 足够,生产环境按 QPS 调 --> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> <!-- 空闲连接保活检测,防止 MySQL 8 小时断连后报 Communications link failure --> <property name="testWhileIdle" value="true"/> <property name="validationQuery" value="SELECT 1"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- 实体类批量起别名,XML 里就能直接写 Student 而不是全限定名 --> <property name="typeAliasesPackage" value="com.school.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="plugins"> <array> <!-- PageHelper 作为 MyBatis 插件挂载,顺序必须在其他插件之前 --> <bean class="com.github.pagehelper.PageInterceptor"> <property name="properties"> <value> helperDialect=mysql reasonable=true supportMethodsArguments=true </value> </property> </bean> </array> </property> </bean> <!-- Mapper 接口扫描,生成代理对象交给 Spring 管理 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.school.mapper"/> </bean> <!-- 事务管理器,注解驱动开关 --> <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="txManager"/>

参数里有两个值得单独说。reasonable=true让 PageHelper 在页码小于 1 时查第一页、超过总页数时查最后一页,学生名单的翻页控件就不会因为手输页码而返回空列表。testWhileIdle配合validationQuery是长期运行必备,很多毕设系统演示时好好的,隔夜再打开就报连接失效,就是因为连接池里的空闲连接被数据库单方面掐断了。typeAliasesPackage省掉 XML 里的全限定类名,但要注意别名冲突,如果两个包下有同名类,起别名会直接启动失败。

3. 学籍与异动表设计:从 E-R 到 MyBatis 映射文件

3.1 学籍主表:学生、班级、专业、院系拆到什么粒度

学籍管理最容易做错的一件事,是把院系、专业、班级都塞进 student 表当字符串字段。这样建表快,但统计「计算机学院有多少在籍学生」时只能靠LIKE '%计算机%',改一次学院名称要全表更新。常见做法是拆成四张表,用外键 id 关联。

-- 学籍主表:只留与学生本人强相关的字段 CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL COMMENT '学号,业务主键,需唯一', name VARCHAR(30) NOT NULL COMMENT '姓名', gender TINYINT NOT NULL DEFAULT 1 COMMENT '1男 2女', id_card VARCHAR(18) COMMENT '身份证号,用于异动核验', class_id BIGINT NOT NULL COMMENT '所属班级', enroll_year SMALLINT NOT NULL COMMENT '入学年份', status TINYINT NOT NULL DEFAULT 1 COMMENT '1在籍 2休学 3复学 4退学 5毕业', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_student_no (student_no), KEY idx_class (class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生学籍主表'; -- 班级表向上关联专业,专业关联院系,三层各司其职 CREATE TABLE class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(50) NOT NULL, major_id BIGINT NOT NULL, grade SMALLINT NOT NULL COMMENT '年级,如 2024', UNIQUE KEY uk_name_grade (class_name, grade) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级表';

status字段是整张表的核心。在籍、休学、退学这几种状态决定了学生能不能选课、能不能录入成绩,用数字枚举而不是中文存库,前端展示时再翻译。version字段现在看起来多余,等到异动审批要防止两个人同时改同一条学籍时就会用上。student_no上加唯一索引而不是主键,是因为学号有可能因为录入错误被修正,改主键的代价太高。

3.2 学籍异动表:一次转专业应该留几条记录

异动是学籍系统里唯一带流程的业务。转专业、休学、复学、退学,动作不同,但都可以抽象成一张表:谁、什么类型、从什么状态变到什么状态、审批到哪一步了。

CREATE TABLE student_change ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT '关联 student.id', change_type TINYINT NOT NULL COMMENT '1转专业 2休学 3复学 4退学', from_value VARCHAR(100) COMMENT '变更前的值,如原班级名称', to_value VARCHAR(100) COMMENT '变更后的值', reason VARCHAR(255) COMMENT '申请理由', audit_status TINYINT NOT NULL DEFAULT 1 COMMENT '1待审 2通过 3驳回', apply_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, auditor_id BIGINT COMMENT '审批人', audit_time DATETIME, KEY idx_student (student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学籍异动记录表';

设计上只追加、不修改历史记录。学生从计算机专业转到软件工程,缓慢一条异动记录,from_valueto_value记录专业名称快照。这样即使后来专业改名了,翻历史记录仍然能看到当时转的是什么。用名称快照而不是外键 id,是这一章里唯一一次故意冗余,理由是可追溯性比存储空间重要得多。

3.3 MyBatis 映射:学籍详情用 association 而不是联表拼装

学籍列表页要显示学号、姓名、班级名、专业名、院系名。如果手工写一个包含十几个字段的 DTO,每次加字段都要改三处。用 resultMap 的 association 把班级对象嵌套进来,结构清晰得多。

<!-- mapper/StudentMapper.xml 片段 --> <resultMap id="StudentDetailMap" type="com.school.vo.StudentVO"> <id property="id" column="id"/> <result property="studentNo" column="student_no"/> <result property="name" column="name"/> <result property="status" column="status"/> <!-- association 处理一对一:一个学生属于一个班级 --> <association property="classInfo" javaType="com.school.entity.Class"> <id property="id" column="c_id"/> <result property="className" column="class_name"/> <!-- 班级再嵌套专业,注意 column 不能重名,统一加前缀 --> <association property="major" javaType="com.school.entity.Major"> <id property="id" column="m_id"/> <result property="majorName" column="major_name"/> </association> </association> </resultMap> <select id="selectDetailPage" resultMap="StudentDetailMap"> SELECT s.id, s.student_no, s.name, s.status, c.id AS c_id, c.class_name, m.id AS m_id, m.major_name FROM student s LEFT JOIN class c ON s.class_id = c.id LEFT JOIN major m ON c.major_id = m.id <where> <if test="keyword != null and keyword != ''"> AND (s.name LIKE CONCAT('%', #{keyword}, '%') OR s.student_no LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND s.status = #{status} </if> </where> ORDER BY s.student_no </select>

association的嵌套列必须用别名区分,三张表都有id字段,不加重命名前缀会串行。这里用LEFT JOIN而不是INNER JOIN,是为了让班级被误删的学生仍然能出现在列表里并标红提示,方便修复脏数据而不是直接消失。<where>标签会自动处理第一个AND,避免手写WHERE 1=1这种糊弄写法。当嵌套层级超过两层时,更推荐拆成两次查询在 service 里组装,否则一个学生的数据会被笛卡尔积放大,性能问题在数据量上来之后才暴露。

3.4 PageHelper 分页:PageInfo 里 total 不对的三种原因

分页几乎是每个列表页都要写的,PageHelper 用法简单,坑也集中。

public PageInfo<StudentVO> pageQuery(StudentQuery query, int pageNum, int pageSize) { // 1. startPage 必须紧贴查询方法,中间不能插入其他 SQL 调用 PageHelper.startPage(pageNum, pageSize); List<StudentVO> list = studentMapper.selectDetailPage(query); // 2. 用查询结果构造 PageInfo,动态计算总页数 PageInfo<StudentVO> info = new PageInfo<>(list); // 3. 手动兜底:如果业务只需要总数不需要明细,用 count 查询代替 return info; }

第一个坑是startPage与查询之间隔了别的 Mapper 调用,分页参数会被下一个 SQL 消费掉,返回的数据条数对但 total 是错的。第二个坑是 count 语句由插件自动生成,遇到LEFT JOIN时它会包一层SELECT COUNT(*) FROM (原SQL),数据量大时慢得离谱,可以在 Mapper 里单独写一个selectDetailCount并配置countSql或改用PageHelper.startPage(pageNum, pageSize, false)关闭自动 count。第三个坑是分页参数没做边界校验,pageSize传进来 10000 就真查一万条,service 层必须加一层限制,例如pageSize = Math.min(pageSize, 100)

4. 学籍录入与异动审批:SpringMVC 接口与登录拦截落地

4.1 学籍新增接口:用 DTO 接收参数并做服务端校验

前端提交的学号格式、身份证号、班级 id,任何一项都不能只靠页面校验。用 DTO 承接参数,把校验规则写在字段上,Controller 保持干净。

// dto/StudentCreateDTO.java public class StudentCreateDTO { @NotBlank(message = "学号不能为空") @Pattern(regexp = "^\\d{8,12}$", message = "学号格式不正确") private String studentNo; @NotBlank(message = "姓名不能为空") @Size(max = 30, message = "姓名过长") private String name; @NotNull(message = "班级不能为空") private Long classId; @NotNull(message = "入学年份不能为空") private Integer enrollYear; // 省略 getter/setter } // controller/StudentController.java @RestController @RequestMapping("/api/student") public class StudentController { @Autowired private StudentService studentService; @PostMapping("/create") public Result<Long> create(@Valid @RequestBody StudentCreateDTO dto) { // 学号唯一性等业务规则放在 service,controller 不做判断 Long id = studentService.createStudent(dto); return Result.ok(id); } }

@Valid触发 DTO 上的约束,校验失败抛MethodArgumentNotValidException,交给全局异常处理器转成统一返回体。学号唯一性必须放在 service,因为它是业务规则,需要查库,放在 controller 会让这一层重新变胖。@RequestBody接收 JSON,如果用表单提交则改成不加注解直接接收 DTO,两种方式不要混用:混用会让 Content-Type 和参数绑定方式对不上,表现为字段全是 null。

需要补充的是,Result统一返回体里至少要含codemessagedata三个字段,前端拦截器靠code判断是否跳登录页。校验失败时的code建议单独一档(比如 400),和业务失败(500)区分开,前端就能对表单字段做红色提示而不是弹一个笼统的错误框。

4.2 登录拦截器:学生只能查自己的学籍

权限控制是学籍系统区别于普通增删改查的地方。管理员能看全部,教师能看自己所带班级,学生只能看自己。用HandlerInterceptor在进入 Controller 前做判断,比在每个方法里写 if 干净。

// interceptor/AuthInterceptor.java public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行静态资源和登录接口,避免登录页也被拦 if (handler instanceof HandlerMethod) { HandlerMethod hm = (HandlerMethod) handler; if (hm.hasMethodAnnotation(IgnoreAuth.class)) { return true; } } HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return false; // 返回 false 中断请求,不再进入 Controller } return true; } }
<!-- springmvc.xml:挂载拦截器并指定路径规则 --> <mvc:interceptors> <mvc:interceptor> <!-- 拦截所有学籍相关接口 --> <mvc:mapping path="/api/**"/> <!-- 登录、验证码接口排除在外 --> <mvc:exclude-mapping path="/api/login"/> <mvc:exclude-mapping path="/api/captcha"/> <bean class="com.school.interceptor.AuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>

preHandle返回 false 是中断,返回 true 是放行,这一点决定了代码写在拦截器还是 AOP 里更合适。拦截器拿得到HttpServletRequest,适合做登录态和角色判断;如果要做的是记录操作日志,用 AOP 切 service 方法更合适,因为日志关心的是业务参数而不是 HTTP 上下文。学生查自己学籍的场景,建议在 service 方法签名里显式传入currentUserId,而不是从ThreadLocal里悄悄取,后者在异步调用和定时任务里会失效,埋得很深。

4.3 异动审批的状态流转:一张表说清谁在什么条件下能改

审批是学籍系统里唯一有多步状态的流程。状态定义不清,代码里就会出现status == 2 || status == 3这种难以维护的判断。把流转规则固化成表,service 里按表实现。

当前状态允许操作操作者角色目标状态附加动作
1 待审通过管理员2 通过更新 student.status 与班级关联
1 待审驳回管理员3 驳回仅记录驳回意见,不动学籍
2 通过撤销管理员3 驳回学籍回滚到变更前的值
3 驳回重新提交学生本人1 待审清空上一轮审批人信息
@Transactional(rollbackFor = Exception.class) public void audit(Long changeId, Integer targetStatus, Long auditorId) { StudentChange change = changeMapper.selectById(changeId); // 状态机校验:只有待审状态才能被审批,防止重复点击 if (change == null || change.getAuditStatus() != 1) { throw new BizException("该申请已被处理,请刷新后重试"); } // 先更新异动记录,更新行数为 0 说明被并发改过 int rows = changeMapper.updateAudit(changeId, targetStatus, auditorId); if (rows == 0) { throw new BizException("审批冲突,请重试"); } // 审批通过才真正改动学籍主表 if (targetStatus == 2) { studentMapper.updateClassAndStatus(change.getStudentId(), change.getToValue(), resolveStatus(change.getChangeType())); } }

rollbackFor显式写成Exception.class是必要的,默认只在运行时异常时回滚,业务里抛的受检异常会让前一条 update 生效而后一条不生效。updateAudit里带WHERE audit_status = 1条件,靠数据库的行锁保证只有第一次点击能成功,这是最省事的防重复提交手段,比前端按钮置灰可靠得多。审批通过后再去改学籍主表,顺序不能反,否则学籍已经改了但审批记录还没落库,事务回滚也救不回来。

5. 事务、并发与审计:学籍变更里最容易翻车的三处细节

5.1 @Transactional 自调用失效:为什么两条 update 只成了一条

这是最经典的一处。service 内部方法 A 调用同类的方法 B,即使 B 上标了@Transactional,事务也不会生效,因为调用走的是this引用而不是 Spring 生成的代理对象。表现就是 B 里抛异常,A 里已经执行的 update 却没有回滚。

// 错误写法:内部自调用,事务注解形同虚设 public void handleChange(Long studentId) { updateStudentStatus(studentId); // 走 this,不经过代理 insertChangeLog(studentId); } @Transactional(rollbackFor = Exception.class) public void insertChangeLog(Long studentId) { /* ... */ }

修正方式有两种。把 B 抽到另一个 Spring Bean 里注入进来,或者注入自身代理@Autowired private StudentService self;然后self.insertChangeLog(...)。我一般选前者,因为跨类调用语义更清楚,也顺便把「日志写入」这件事的职责分离出去了。第二种在循环依赖场景下有额外风险,能不用就不用。

5.2 异动生效的乐观锁:两个人同时点通过怎么办

审批接口的并发窗口很小,但一旦撞上就是脏数据。第 3 章建表时留的version字段在这里派上用场。

<update id="updateClassAndStatus"> UPDATE student SET class_id = #{classId}, status = #{status}, version = version + 1 WHERE id = #{id} AND version = #{version} </update>

传入的version是查询时读到的值,更新影响行数为 0 就说明这条记录在读取之后被别人改过,service 里据此抛异常让前端提示重试。这套机制比SELECT ... FOR UPDATE温和,不会在审批这种低并发场景里白白占住行锁,代价是调用方必须处理失败重试。要注意的是,乐观锁只在「读-改-写」有间隔时才有意义,如果是一条 SQL 直接更新,行锁本身就够了,加 version 只是徒增字段。

5.3 用 AOP 记录学籍变更日志:字段级 diff 才查得动

学籍系统的日志不能只记「谁在什么时候改了谁」,答辩或者排查问题时真正需要的是「改了什么字段、从什么变成什么」。用环绕通知拿到方法入参和返回值,在返回后对比新旧对象。

@Aspect @Component public class ChangeLogAspect { @Around("@annotation(com.school.annotation.RecordChange)") public Object around(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); Long studentId = (Long) args[0]; // 1. 执行前查旧对象,作为 diff 的基线 Student before = studentMapper.selectById(studentId); Object result = pjp.proceed(); // 2. 执行后查新对象,逐字段比对,只写有变化的 Student after = studentMapper.selectById(studentId); List<String> changed = diffFields(before, after); if (!changed.isEmpty()) { logMapper.insert(new ChangeLog(studentId, String.join(",", changed), currentUserId(), new Date())); } return result; } }

切点用自定义注解@RecordChange而不是扫所有 service 方法,是为了控制日志量,学籍查询这种高频只读操作不该留痕。diffFields里只比对学籍相关字段,把update_timeversion这类技术字段排除掉,否则每条日志都会显示「发生了修改」而看不出实质内容。日志表本身不要加外键约束,学籍被删除时日志必须保留,这是审计数据的基本要求。日志表按student_id加上普通索引,查询某个学生的完整变更轨迹时才能走索引而不是全表扫描。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 1:40:52

Windows/Linux跨平台采集Mac地址与CPU序列号生成机器码

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 1:40:46

Windows Server 2016/Win10忘记密码离线重置与域控RDS

凌晨两点接到电话&#xff0c;说机房那台跑业务系统的 Windows Server 2016 本地管理员密码没人记得了&#xff0c;早上八点要开机。这种场面我遇到过不止一次&#xff0c;也帮同事远程处理过 Win10 笔记本忘记密码的情况。重置密码这件事本身技术难度不高&#xff0c;但真正让…

作者头像 李华
网站建设 2026/9/18 1:39:15

给需求环节生成工单的 Agent,TaoToken 的 Key 从官网领

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 1:36:55

数据库三级模式:外模式、模式与内模式的工程落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 1:36:40

DX12调试实战:Device创建、SwapChain与Descriptor Heap避坑指南

1. 这不是又一套“理论正确但跑不起来”的DX12教程&#xff1a;它是一份带血丝的调试日志你搜过“DirectX 12 教程”&#xff0c;点开前三个&#xff0c;十有八九是2017年写的&#xff0c;配图还是VS2015界面&#xff0c;代码里还带着D3D12CreateDevice裸调用、没封装、没错误检…

作者头像 李华