
简介《JavaSSM学生学籍管理系统》是一份面向计算机相关专业毕业生与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 全链路的开发者。反过来说如果目标只是三天交一个能点开的 demoSSM 那一堆 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 版本不一致会导致 NoSuchMethodErrorspring-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 监控监控页默认关闭需手动开 StatViewServletpagehelper5.x物理分页插件与 MyBatis 版本错配会拦截不到 Executorservlet-api / jsp-api容器提供编译期接口scope 必须写 provided!-- pom.xml 关键片段只列版本与作用域控制 -- properties spring.version5.3.39/spring.version mybatis.version3.5.16/mybatis.version /properties dependencies !-- SpringMVC提供 DispatcherServlet 与注解驱动 -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- 事务与数据源支持Transactional 依赖它 -- dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency !-- MyBatis 与 Spring 的桥接件版本必须与 Spring 5.x 对齐 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency !-- servlet-api 由 Tomcat 提供打包时必须排除 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/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-namecontextConfigLocation/param-name !-- 父容器service、mapper、事务、数据源 -- param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class !-- 子容器只负责 controller -- init-param param-namecontextConfigLocation/param-name param-valueclasspath:springmvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping !-- 编码过滤器要放在所有过滤器最前面否则中文姓名入库变问号 -- filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-paramparam-nameencoding/param-nameparam-valueUTF-8/param-value/init-param init-paramparam-nameforceEncoding/param-nameparam-valuetrue/param-value/init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mappingload-on-startup设为 1让 DispatcherServlet 在容器启动时就完成初始化第一次访问不用等。forceEncoding设 true 比只设 encoding 更稳妥它同时强制设置响应编码避免返回 JSON 时中文乱码。对应地springmvc.xml的扫描范围必须收窄到com.school.controllerapplicationContext.xml扫com.school.service和com.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 里不许出现SqlSessionservice 里不许出现HttpServletRequestentity 里不许写业务判断。这三条守住代码量再翻一倍也不会失控。DTO 和 VO 分开建是因为学籍新增时前端传的是班级 id返回时页面要显示班级名称用同一个类既要接收 id 又要返回名称字段就会互相污染。2.4 数据源与 SqlSessionFactoryDruid 和 PageHelper 的接入点!-- applicationContext.xml数据源 会话工厂 分页插件 -- bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/school_roll?useUnicodetrueamp;characterEncodingutf8amp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value123456/ !-- 初始连接数毕设环境 5 足够生产环境按 QPS 调 -- property nameinitialSize value5/ property namemaxActive value20/ !-- 空闲连接保活检测防止 MySQL 8 小时断连后报 Communications link failure -- property nametestWhileIdle valuetrue/ property namevalidationQuery valueSELECT 1/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ !-- 实体类批量起别名XML 里就能直接写 Student 而不是全限定名 -- property nametypeAliasesPackage valuecom.school.entity/ property namemapperLocations valueclasspath:mapper/*.xml/ property nameplugins array !-- PageHelper 作为 MyBatis 插件挂载顺序必须在其他插件之前 -- bean classcom.github.pagehelper.PageInterceptor property nameproperties value helperDialectmysql reasonabletrue supportMethodsArgumentstrue /value /property /bean /array /property /bean !-- Mapper 接口扫描生成代理对象交给 Spring 管理 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.school.mapper/ /bean !-- 事务管理器注解驱动开关 -- bean idtxManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertxManager/参数里有两个值得单独说。reasonabletrue让 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学籍异动记录表;设计上只追加、不修改历史记录。学生从计算机专业转到软件工程缓慢一条异动记录from_value和to_value记录专业名称快照。这样即使后来专业改名了翻历史记录仍然能看到当时转的是什么。用名称快照而不是外键 id是这一章里唯一一次故意冗余理由是可追溯性比存储空间重要得多。3.3 MyBatis 映射学籍详情用 association 而不是联表拼装学籍列表页要显示学号、姓名、班级名、专业名、院系名。如果手工写一个包含十几个字段的 DTO每次加字段都要改三处。用 resultMap 的 association 把班级对象嵌套进来结构清晰得多。!-- mapper/StudentMapper.xml 片段 -- resultMap idStudentDetailMap typecom.school.vo.StudentVO id propertyid columnid/ result propertystudentNo columnstudent_no/ result propertyname columnname/ result propertystatus columnstatus/ !-- association 处理一对一一个学生属于一个班级 -- association propertyclassInfo javaTypecom.school.entity.Class id propertyid columnc_id/ result propertyclassName columnclass_name/ !-- 班级再嵌套专业注意 column 不能重名统一加前缀 -- association propertymajor javaTypecom.school.entity.Major id propertyid columnm_id/ result propertymajorName columnmajor_name/ /association /association /resultMap select idselectDetailPage resultMapStudentDetailMap 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 testkeyword ! null and keyword ! AND (s.name LIKE CONCAT(%, #{keyword}, %) OR s.student_no LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND s.status #{status} /if /where ORDER BY s.student_no /selectassociation的嵌套列必须用别名区分三张表都有id字段不加重命名前缀会串行。这里用LEFT JOIN而不是INNER JOIN是为了让班级被误删的学生仍然能出现在列表里并标红提示方便修复脏数据而不是直接消失。where标签会自动处理第一个AND避免手写WHERE 11这种糊弄写法。当嵌套层级超过两层时更推荐拆成两次查询在 service 里组装否则一个学生的数据会被笛卡尔积放大性能问题在数据量上来之后才暴露。3.4 PageHelper 分页PageInfo 里 total 不对的三种原因分页几乎是每个列表页都要写的PageHelper 用法简单坑也集中。public PageInfoStudentVO pageQuery(StudentQuery query, int pageNum, int pageSize) { // 1. startPage 必须紧贴查询方法中间不能插入其他 SQL 调用 PageHelper.startPage(pageNum, pageSize); ListStudentVO list studentMapper.selectDetailPage(query); // 2. 用查询结果构造 PageInfo动态计算总页数 PageInfoStudentVO 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 ResultLong create(Valid RequestBody StudentCreateDTO dto) { // 学号唯一性等业务规则放在 servicecontroller 不做判断 Long id studentService.createStudent(dto); return Result.ok(id); } }Valid触发 DTO 上的约束校验失败抛MethodArgumentNotValidException交给全局异常处理器转成统一返回体。学号唯一性必须放在 service因为它是业务规则需要查库放在 controller 会让这一层重新变胖。RequestBody接收 JSON如果用表单提交则改成不加注解直接接收 DTO两种方式不要混用混用会让 Content-Type 和参数绑定方式对不上表现为字段全是 null。需要补充的是Result统一返回体里至少要含code、message、data三个字段前端拦截器靠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;charsetUTF-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 classcom.school.interceptor.AuthInterceptor/ /mvc:interceptor /mvc:interceptorspreHandle返回 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 idupdateClassAndStatus 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); ListString changed diffFields(before, after); if (!changed.isEmpty()) { logMapper.insert(new ChangeLog(studentId, String.join(,, changed), currentUserId(), new Date())); } return result; } }切点用自定义注解RecordChange而不是扫所有 service 方法是为了控制日志量学籍查询这种高频只读操作不该留痕。diffFields里只比对学籍相关字段把update_time、version这类技术字段排除掉否则每条日志都会显示「发生了修改」而看不出实质内容。日志表本身不要加外键约束学籍被删除时日志必须保留这是审计数据的基本要求。日志表按student_id加上普通索引查询某个学生的完整变更轨迹时才能走索引而不是全表扫描。本文还有配套的精品资源点击获取