☰
Spring Boot学籍管理系统:架构、JWT鉴权与异动状态机
2026/10/31 12:31:43 网站建设 项目流程

简介:面向高校计算机相关专业毕业设计阶段的学籍管理系统学习资料,适合需要完成论文撰写与系统实现的本科生参考。资源围绕学籍信息、课程成绩、学籍变动等教务管理需求展开,覆盖需求分析、数据库与功能模块设计、开发测试及部署维护等环节,并配有Java实现代码与Access数据库文件,可帮助理解从选题到落地的完整思路。压缩包共24个文件,约323KB,包含Java源码与class编译文件、bak备份、doc论文文档、mdb数据库及ldb临时文件等,便于对照阅读代码结构、数据库表设计与论文正文。已有348人学习,适合作为毕业设计参考样本,可从中梳理学生信息管理、成绩管理、主界面交互与增删改查逻辑,并借鉴模块划分、测试流程和部署维护的写法。

1. 从一份毕设包说起:学籍管理系统到底能跑出什么

学籍管理系统是毕设选题里的高频词,高到很多人拿到题目第一反应是找个模板改改交差。真动手才会发现,学生表、班级表、学籍异动这三块只要有一处理不顺,答辩时被问一句「学生休学之后班级在册人数怎么算」就答不上来。这套包给的是能直接跑的东西:后端 Spring Boot 配 MyBatis-Plus 和 MySQL,前端 Vue 加 Element UI,附建表脚本、论文文档和启动说明。

它适合两类人——选题已经定了、想先把系统跑通再回头补文档的,以及想把毕设当成真正的练手项目、顺着真实业务把状态流转和权限做扎实的。下面从目录结构一路拆到能改的代码。

2. 架构与技术栈:一次学籍查询请求要穿过哪几层

毕设项目最容易出问题的地方,是项目一开始跑得通,但稍微改动一个字段就出错。学籍管理系统这个题目有个特点:数据之间的关系比业务逻辑复杂,院系、专业、班级、学生、异动记录层层往下挂,分层一旦模糊,改一处就会牵连一片。所以拆这套包,先看它怎么切目录,再看一次查询是怎么从浏览器走到 SQL 的。

2.1 目录结构:后端按职责切,前端按页面切

后端是标准的三层加 VO,没有为了显得高级而堆设计模式;前端按业务模块拆 api 和 views。这套结构的好处是,加一个「学籍预警」模块时只要新增一组 Controller/Service/Mapper 和对应页面,不用动老代码。

xueji/ ├── xueji-server/ │ ├── src/main/java/com/example/xueji/ │ │ ├── XuejiApplication.java 启动类 │ │ ├── common/ R 统一返回、BizException、全局异常处理 │ │ ├── config/ MyBatis-Plus 分页插件、跨域、JWT 拦截器 │ │ ├── controller/ StudentController、ClassController、AuthController │ │ ├── service/ service/impl/ 业务层 │ │ ├── mapper/ 继承 BaseMapper 的接口 │ │ ├── entity/ 与表一一对应 │ │ └── vo/ StudentVO、StudentExportVO │ └── src/main/resources/ │ ├── mapper/StudentMapper.xml 手写 SQL 放这里 │ ├── application.yml │ └── db/init.sql 建库建表 + 初始账号 ├── xueji-web/ │ ├── src/api/student.js 一个模块一个文件 │ ├── src/views/student/ 列表页、异动页、导入导出 │ ├── src/router/index.js │ └── src/utils/request.js axios 封装 ├── doc/ 论文、开题报告、任务书、答辩 PPT └── README.md

entity和vo分开这件事值得说一句。entity 是数据库的镜像,字段跟表一一对应,包含deleted、update_time这类前端根本用不上的东西;vo 是给前端看的,比如StudentVO里把classId换成了className和majorName。我见过不少人图省事直接把 entity 返回给前端,结果前端列表里得自己拿 classId 再发一次请求换名字,十行数据十个请求,翻页卡成幻灯片。

2.2 一次学籍查询请求的完整链路

前端点「查询」,GET /api/student/page?pageNum=1&pageSize=10&keyword=2023。先落到 Controller:

@RestController @RequestMapping("/api/student") public class StudentController { @Resource private StudentService studentService; /** 分页查询:keyword 同时匹配学号和姓名 */ @GetMapping("/page") public R<Page<StudentVO>> page(StudentQuery query) { return R.ok(studentService.pageQuery(query)); } /** 学籍异动提交,operatorId 由 JWT 拦截器塞进 request */ @PostMapping("/change") public R<Void> change(@RequestBody @Valid StudentChangeDTO dto, @RequestAttribute("userId") Long userId) { studentService.submitChange(dto, userId); return R.ok(); } }

R是全项目统一的返回壳,code=0表示业务成功,非 0 是业务失败,HTTP 状态码统一 200。这套约定在前端拦截器里只用写一次判断,比每接一个接口猜一次返回格式舒服得多:

@Data public class R<T> { private int code; // 0 成功;1001 参数错误;1002 未登录;1003 无权限 private String msg; private T data; public static <T> R<T> ok(T data) { R<T> r = new R<>(); r.code = 0; r.msg = "success"; r.data = data; return r; } public static <T> R<T> fail(int code, String msg) { R<T> r = new R<>(); r.code = code; r.msg = msg; return r; } }

业务层拿到参数后,第一件事是拼查询条件:

@Override public Page<StudentVO> pageQuery(StudentQuery q) { Page<Student> page = new Page<>(q.getPageNum(), q.getPageSize()); LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>(); // 关键:keyword 的两个 like 必须用 and() 包起来, // 否则会跟后面的 eq 串成 a like ? or b like ? and class_id = ?,条件优先级全乱 if (StringUtils.hasText(q.getKeyword())) { w.and(x -> x.like(Student::getStudentNo, q.getKeyword()) .or() .like(Student::getName, q.getKeyword())); } w.eq(q.getClassId() != null, Student::getClassId, q.getClassId()) .eq(q.getStatus() != null, Student::getStatus, q.getStatus()) .orderByDesc(Student::getCreateTime); Page<Student> raw = this.page(page, w); // convert 用的是同一个分页对象,总条数不会丢 return raw.convert(this::toVO); } private StudentVO toVO(Student s) { StudentVO vo = new StudentVO(); BeanUtils.copyProperties(s, vo); vo.setClassName(classCache.getName(s.getClassId())); // 本地缓存,别在循环里查库 vo.setMajorName(majorCache.getName(s.getMajorId())); return vo; }

几个参数的含义得说清楚:pageNum从 1 开始,pageSize前端固定传 10 或 20;keyword空字符串要走StringUtils.hasText判空而不是!= null,否则页面上清空搜索框会传一个空串过来,变成like '%%'全表扫;classId和status用 MyBatis-Plus 的三参数重载,第一个布尔值决定这个条件拼不拼进 SQL,省掉一堆 if。

另外两个细节:分页插件要在MybatisPlusConfig里注册PaginationInnerInterceptor,不注册的话Page参数会被无视,永远返回全表;deleted字段在application.yml里配了逻辑删除之后,框架会自动在每条 SQL 后面补AND deleted = 0,不用手写。

提示:toVO里如果直接classMapper.selectById(),一页 10 条就是 10 次数据库往返,数据量上来后接口耗时从 30ms 涨到 300ms 很正常。班级和专业数据变化极少,启动时全量加载进本地 Map 是最省事的做法。

2.3 前端接口层与错误约定

前端不直接写 axios,统一收在request.js里,业务代码只import { pageStudent } from '@/api/student':

import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 开发环境指到 http://localhost:8081 timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers['Authorization'] = 'Bearer ' + token return config }) service.interceptors.response.use( res => { const body = res.data if (body.code !== 0) { // 业务失败:弹一次提示就够,别让页面自己判断 Message.error(body.msg || '请求失败') return Promise.reject(new Error(body.msg)) } return body.data // 这里直接脱壳,页面拿到的就是纯数据 }, err => { // 401 是 token 过期或没带,直接踢回登录页,别让用户在页面上反复点 if (err.response && err.response.status === 401) { localStorage.removeItem('token') router.replace('/login') } return Promise.reject(err) } ) export default service

这里把body.data直接返回,页面里就写成const data = await pageStudent(query),data.records是列表、data.total是总数,跟 Element UI 的el-pagination对得上。新人常犯的错是拦截器里返回整个res,结果每个页面都要写res.data.data.records,一层套一层,改起来很难受。

3. 数据库设计:学生表、异动表和唯一索引的取舍

学籍管理系统的难点不在增删改查,在于「一个学生的状态会变,而且每次变化都得留痕」。表设计没把这条想清楚,后面做统计、做异动审批全是补丁。

3.1 表关系与冗余字段的取舍

主干是四条线:院系 1:N 专业 1:N 班级 1:N 学生,学生 1:N 异动记录。学生表里除了class_id,还冗余了major_id和grade(入学年份)。这么设计是有代价的,但收益更大:

按专业统计人数是院系最常要的报表,如果只存class_id,每次统计都得student join class join major,两个 join 写起来烦,索引也不好走。冗余一份major_id并建索引,统计就是一句GROUP BY major_id。grade同理——班级名称里虽然带年份(比如「2023级软件工程1班」),但班级名是会改的,改完历史数据就对不上了,单独存一个整型年份最稳。

代价是转专业时major_id和class_id必须一起改,绝不能分两次更新。这一点在异动模块里用同一个事务兜住。

3.2 建表脚本:把约束写进 DDL

init.sql里六张表,挑两张核心的看:

-- 学生学籍主表 CREATE TABLE `student` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `student_no` VARCHAR(20) NOT NULL COMMENT '学号,入学时分配', `name` VARCHAR(50) NOT NULL COMMENT '姓名', `gender` TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1男 2女', `id_card` VARCHAR(32) DEFAULT NULL COMMENT '证件号,列表脱敏展示', `class_id` BIGINT UNSIGNED NOT NULL COMMENT '所属班级', `major_id` BIGINT UNSIGNED NOT NULL COMMENT '所属专业,冗余字段', `grade` SMALLINT NOT NULL COMMENT '入学年份,如 2023', `enroll_date` DATE NOT NULL COMMENT '入学日期', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1在读 2休学 3退学 4毕业 5转出', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删除', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_no` (`student_no`, `deleted`), KEY `idx_class_status` (`class_id`, `status`), KEY `idx_major_grade` (`major_id`, `grade`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生学籍主表'; -- 学籍异动流水表 CREATE TABLE `student_change` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `student_id` BIGINT UNSIGNED NOT NULL, `change_type` TINYINT NOT NULL COMMENT '1休学 2复学 3转专业 4退学 5转学', `from_value` VARCHAR(64) DEFAULT NULL COMMENT '变更前的值,如状态码或班级ID', `to_value` VARCHAR(64) DEFAULT NULL COMMENT '变更后的值', `effect_date` DATE NOT NULL COMMENT '生效日期', `reason` VARCHAR(255) DEFAULT NULL COMMENT '异动原因', `audit_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1通过 2驳回', `operator_id` BIGINT UNSIGNED NOT NULL COMMENT '提交人', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_student_time` (`student_id`, `create_time`), KEY `idx_audit` (`audit_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学籍异动记录';

三处值得展开。第一,uk_student_no是(student_no, deleted)联合唯一索引,而不是只对student_no做唯一——原因下一节细说。第二,idx_class_status是联合索引,顺序不能反,因为「查某个班在读的学生」这个查询里class_id是等值、status也是等值,两个都走索引;但如果只建status单列索引,一个学校几千个在读学生,基本等于全表扫。第三,student_change里from_value和to_value用的是 VARCHAR 而不是 TINYINT,因为它要同时装状态码(1→2)和班级 ID(转专业时),用一个字段装两种语义确实不优雅,但毕设项目里这样最省事,真要严谨就拆成from_status/to_status/from_class_id/to_class_id四个字段。

3.3 逻辑删除撞上唯一索引,怎么选

这是我在学籍系统里踩得最深的一个坑。项目用了逻辑删除(deleted=1),某天要把一个退学学生的学号重新分配给复学回来的同一个学生,直接报Duplicate entry '20230101' for key 'uk_student_no'——因为老记录还在表里,唯一索引认为这个学号被占了。

三种处理方式,我列个表对比:

方案做法优点代价
联合唯一索引UNIQUE(student_no, deleted)改动最小,一条 DDL 搞定同一个学号被删两次就冲突了(两条 deleted=1 的记录),只能扛一次
删除时改写学号deleted=1时把学号改成20230101_del_1712...彻底解决,能删无数次学号字段被污染,老数据查起来费劲
归档表删除时整行搬到student_archive主表干净,唯一索引正常多一张表多一套代码,毕设体量下偏重

毕设场景我一般选第一种,因为学生退学再复学、学号还重复的极端情况基本不会出现,一条 DDL 换来的是代码零改动。真要上生产,我会用第二种,在 Service 的删除方法里加一个后缀,代价只是历史数据查询时要记得LIKE '20230101%'。

注意:改唯一索引之前先把存量脏数据清一遍,SELECT student_no, COUNT(*) FROM student GROUP BY student_no HAVING COUNT(*) > 1,不然加索引那一步会直接失败,报错信息还只告诉你哪一行冲突。

4. 核心模块拆解:登录鉴权与学籍异动状态机

前面两章是地基,这一章是真正会被人翻看的代码。学籍管理系统里有两个模块决定了项目质量:登录鉴权决定「谁能看什么」,学籍异动决定「数据对不对」。

4.1 登录与 JWT:拦截器只做两件事

登录接口校验账号密码,签发一个 JWT,里面塞userId和role。之后的每个请求由拦截器解析:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { // 预检请求必须放行,否则浏览器拿不到 CORS 响应头,控制台只显示一个 401 if (HttpMethod.OPTIONS.matches(req.getMethod())) { return true; } String token = req.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException(1002, "未登录"); } Claims claims = JwtUtil.parse(token.substring(7)); // 签名不对或过期会抛异常 req.setAttribute("userId", Long.valueOf(claims.getSubject())); req.setAttribute("role", claims.get("role", String.class)); return true; } }

拦截器的职责就两条:验签、把用户信息塞进 request。权限判断不放在这里,否则每加一个接口都要回来改拦截器的 if-else。角色校验用注解加切面更合适:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); // 允许访问的角色,如 {"ADMIN", "TEACHER"} } // 切面里读 request 上的 role,不在允许列表里就抛 1003 @Around("@annotation(requireRole)") public Object check(ProceedingJoinPoint pjp, RequireRole requireRole) throws Throwable { String role = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()) .getRequest().getAttribute("role").toString(); if (!Arrays.asList(requireRole.value()).contains(role)) { throw new BizException(1003, "无权限"); } return pjp.proceed(); }

这样在StudentController.delete()上标一个@RequireRole("ADMIN")就完事。拦截器的注册别忘了排除登录接口和验证码接口,addPathPatterns("/api/**").excludePathPatterns("/api/login", "/api/captcha", "/error"),/error一定要排,否则异常转发到错误页时又被拦一次,日志里会出现一堆莫名其妙的 401。

4.2 学籍异动:把状态流转写成规则表

这是整个系统里唯一有「业务规则」的地方。休学只能在读状态申请,复学只能休学状态申请,退学只能从在读走。如果用 if-else 写,五个异动类型乘五个状态就是一堆嵌套,加一种异动类型要回头翻代码。改成规则表,逻辑一眼能看完:

@Service public class StudentChangeServiceImpl extends ServiceImpl<StudentChangeMapper, StudentChange> implements StudentChangeService { @Resource private StudentMapper studentMapper; /** 异动类型 -> (允许的原状态 -> 目标状态),转专业状态不变但要换班级 */ private static final Map<Integer, Map<Integer, Integer>> RULES = new HashMap<>(); static { RULES.put(ChangeType.SUSPEND, Collections.singletonMap(Status.NORMAL, Status.SUSPENDED)); RULES.put(ChangeType.RESUME, Collections.singletonMap(Status.SUSPENDED, Status.NORMAL)); RULES.put(ChangeType.TRANSFER, Collections.singletonMap(Status.NORMAL, Status.NORMAL)); RULES.put(ChangeType.QUIT, Collections.singletonMap(Status.NORMAL, Status.QUIT)); } @Override @Transactional(rollbackFor = Exception.class) public void submitChange(StudentChangeDTO dto, Long operatorId) { Student stu = studentMapper.selectById(dto.getStudentId()); if (stu == null) { throw new BizException("学生不存在或已删除"); } Map<Integer, Integer> rule = RULES.get(dto.getChangeType()); if (rule == null) { throw new BizException("异动类型不合法"); } Integer target = rule.get(stu.getStatus()); if (target == null) { throw new BizException("当前学籍状态不允许该操作"); } // 1. 先落流水,audit_status 默认 0 待审核 StudentChange record = new StudentChange(); record.setStudentId(stu.getId()); record.setChangeType(dto.getChangeType()); record.setFromValue(String.valueOf(stu.getStatus())); record.setToValue(String.valueOf(target)); record.setEffectDate(dto.getEffectDate()); record.setReason(dto.getReason()); record.setOperatorId(operatorId); this.save(record); // 2. 再改主表,带 expectStatus 做乐观锁 Long newClassId = ChangeType.TRANSFER == dto.getChangeType() ? dto.getNewClassId() : null; int rows = studentMapper.updateStatus(stu.getId(), stu.getStatus(), target, newClassId); if (rows == 0) { throw new BizException("该学生学籍状态已变更,请刷新后重试"); } } }

对应的 XML 是这段逻辑的核心:

<update id="updateStatus"> UPDATE student SET status = #{target}, class_id = IFNULL(#{newClassId}, class_id), update_time = NOW() WHERE id = #{id} AND status = #{expectStatus} AND deleted = 0 </update>

WHERE status = #{expectStatus}是乐观锁的实现:两个辅导员同时打开同一个学生的页面,一个先提交休学把状态改成 2,另一个的 UPDATE 就匹配不到status = 1的行,rows返回 0,直接抛提示。这比SELECT ... FOR UPDATE轻得多,也不用担心锁表。IFNULL(#{newClassId}, class_id)处理的是转专业场景——传了新区 ID 就改,传 null 就保持原值,一条 SQL 覆盖两种异动。

@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省。Spring 默认只回滚RuntimeException,如果哪天代码里抛了个受检异常,流水已经写进去了、主表没改,数据就永久不一致,而且日志里干干净净什么都看不出来。这个坑我在别的项目上吃过一次,排查了半天。

4.3 分页查询与 Excel 导出的两个细节

导出学籍名册是院系的高频需求,用的 EasyExcel:

@GetMapping("/export") public void export(StudentQuery query, HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); // 文件名必须 URL 编码,否则中文名在部分浏览器里变成一串下划线 String fileName = URLEncoder.encode("学生学籍名册", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); List<StudentExportVO> list = studentService.listForExport(query); // 不分页,走同一套条件 EasyExcel.write(response.getOutputStream(), StudentExportVO.class) .sheet("学籍") .doWrite(list); }

StudentExportVO上用@ExcelProperty("学号")标注表头,字段顺序就是列顺序。两个细节:一是导出接口和列表接口必须复用同一个查询条件对象,否则用户在页面上筛了「2023级软件工程」,导出的是全校数据,这种 bug 用户一眼就能发现,但开发时特别容易漏;二是listForExport里要显式加LIMIT 50000之类的上限,学校几万学生全量导一次是能撑住,但如果筛选条件写错导致全表导,内存会直接爆。

再补一个列表页的常见配置——id_card这类敏感字段在列表里脱敏,在导出里保留完整:

private String maskIdCard(String idCard) { if (idCard == null || idCard.length() < 8) return idCard; return idCard.substring(0, 4) + "**********" + idCard.substring(idCard.length() - 4); }

脱敏放在toVO里做,导出走单独的StudentExportVO,两边互不影响。别在 entity 上做,否则导出的时候也拿不到原值了。

5. 避坑与排查:五个让我返工的现场

这一章按「现象 → 原因 → 解决」写,全部是这套包里真实会遇到的问题,新人跑不起来九成卡在前三条。

5.1 启动与环境阶段

现象一:启动报Unknown database 'xueji',或者Access denied for user 'root'@'localhost'。

原因:init.sql里只写了CREATE TABLE,没写CREATE DATABASE,直接source会提示 no database selected;账号密码则要跟本机 MySQL 一致,毕设包里默认写的root/123456,本机改过密码就会连不上。

解决:先手工建库再导表,两条命令分开执行更清楚——

mysql -uroot -p -e "CREATE DATABASE xueji DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p xueji < xueji-server/src/main/resources/db/init.sql

MySQL 8 还要注意驱动:driver-class-name必须是com.mysql.cj.jdbc.Driver,URL 里带上serverTimezone=Asia/Shanghai,否则时间字段会差 8 小时,create_time显示成昨天。

现象二:前端登录一直转圈,Network 里显示 401,但用 Postman 调同一个接口是通的。

原因:浏览器发 POST 之前会先发一个 OPTIONS 预检请求,拦截器把它当成未登录请求拦掉了,浏览器拿不到 CORS 响应头,于是后续请求根本不发。

解决:拦截器开头放行 OPTIONS,同时跨域配置里allowedOrigins("*")不能和allowCredentials(true)同时用,会直接抛异常,改成allowedOriginPatterns("*")。这两个改动缺一个都不行。

现象三:本地起的后端 8080 端口被占,Web server failed to start. Port 8080 was already in use。

原因:八成是上一次的进程没退干净,或者本机装了别的东西占着 8080。毕设包默认端口写在application.yml里。

解决:改端口最快,改成 8081,前端vue.config.js里的 proxy target 和.env.development的VUE_APP_BASE_API一起改,三处漏一处就是 404。想看是谁占的,netstat -ano | findstr :8080找到 PID 再决定杀不杀。

5.2 数据一致性阶段

现象四:休学审批通过了,学生列表里的状态还是「在读」,但异动记录里明明有一条。

原因:两个可能。一是submitChange上忘了rollbackFor = Exception.class,中间抛了受检异常,流水回滚了主表没回滚,或者反过来;二是把submitChange写在了 Controller 里直接调 Service 的私有方法,Spring 的 AOP 代理没生效,@Transactional形同虚设。

解决:先看日志里有没有异常堆栈,再看方法调用是不是跨了 Bean。同一个类里this.xxx()调用不走代理,这是 Spring 事务失效最经典的姿势,把方法拆到另一个 Service 里就正常了。

现象五:两个辅导员同时给同一个学生办休学,两条异动记录都成功了,主表状态被改了两次。

原因:只做了业务校验(当前状态是不是在读),没做并发校验。两个请求都读到status=1,都通过了校验,然后都执行了 UPDATE。

解决:UPDATE 的 WHERE 里带上期望状态,判断影响行数,就是 4.2 节里那段 XML。这个改法的好处是不用引入分布式锁,也不用担心死锁,代价极小。如果业务上还要求同一天同一学生不能重复提交同类异动,那就在student_change上加一条UNIQUE(student_id, change_type, effect_date),双保险。

提示:这五个问题里,前三个属于环境,删库重来十分钟能修好;后两个属于逻辑,一旦上线才发现,补数据的时间够你把整个模块重写一遍。所以本地跑通之后,先花半小时手动造二十条异动数据,把「休学 → 复学 → 转专业 → 退学」这条链完整走一遍,比什么都管用。

6. 二次开发:把毕设改成能交付的小系统

答辩完这套代码大概率会被搁置,但如果想拿它当简历项目或者交给某个小单位试用,有四个地方值得动,按投入产出比排序:

改造点动手位置难度验证方法
角色权限从写死改 RBAC新增sys_role/sys_menu,拦截器查缓存中用学生账号登录,菜单只剩「我的学籍」,直接访问/api/student/page返回 1003
异动加审批流student_change.audit_status+ 状态机中造三条异动,一条通过、一条驳回、一条撤回,看主表状态是否只随通过的变
学生批量导入EasyExcel 读 + 事务 + 学号查重低传一份含重复学号的表,确认整批回滚、不出现半截导入
操作日志AOP 切面 +operation_log表低改一条学生信息,日志表里要有操作前后的值

RBAC 那块最容易做歪。很多人一上手就把role字符串塞进 JWT,然后每个接口if (role.equals("ADMIN")),改到第十个接口就疯了。正确做法是user → role → menu三张表,登录时把权限码列表缓存起来(放 Redis 或者本地 Caffeine 都行),切面只判断「当前请求的权限码在不在列表里」。这样加一个角色只需要插数据,不用改代码。

导入这块的坑在于「整批回滚」。我见过不少实现是 for 循环里逐条 insert,中间有一条学号重复就抛异常,结果前 300 条进去了后 200 条没进,用户重传一次又全撞唯一索引。正确姿势是先把整个文件读进内存,用一次SELECT student_no FROM student WHERE student_no IN (...)查出已存在的学号,在内存里比对完,确认没问题再统一入库,整个过程包在一个事务里。

验证方法我一般用最

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询