简介:《学生信息管理系统》配套程序与源码面向学校教务人员、教师及刚接触数据库编程的学习者,围绕学生信息的录入、查找、删除、修改、排序、人数统计与全量显示等日常管理需求,提供一套可直接运行的小型应用。压缩包共4个文件,约5.96MB,包含2份Word文档、1个可执行程序与1个Python源文件,文档侧重使用说明与程序配置讲解,可执行程序便于快速体验功能,源码则保留二次开发与阅读空间。已有143人学习或下载。读者可借由可执行程序熟悉完整操作流程,再对照源码理解数据库增删改查、排序与计数等实现思路,并结合配置文档完成环境调整;对课程设计、小型教务工具实践或数据库入门练习而言,能减少从零搭建界面的成本,也便于拆解各功能模块并迁移到自己的项目中。
1. 从期末那张 Excel 成绩表说起:这套带源码的学生信息管理系统值不值得拆
每到学期末,辅导员群里就会流传若干个版本的「成绩汇总(最终版)(真的最终版).xlsx」,改错一行、发错人、覆盖别人的修改,几乎是固定节目。这套学生信息管理系统就是冲着这个场景来的:后台管学生档案、课程、成绩录入与统计,小程序端给学生自己查课表、查分数,源码整套都给全,前后端都在包里。它适合两类人:一类是课程设计要交差、想找个结构规整的底子往上改;另一类是刚接手一套「能跑但不敢动」的毕设代码,想知道每一层该从哪儿下手的开发者。下面按我给朋友部署这套东西的实际顺序拆,从目录结构一路讲到改哪里不翻车。
2. 技术栈与目录结构:SpringBoot 三层架构和小程序端怎么分工
2.1 后端为什么落在 SpringBoot + MyBatis-Plus 这套组合上
学生信息管理这类系统的业务形状非常固定:学生、班级、课程、成绩、用户五张主表,90% 的接口是单表增删改查加条件分页,剩下 10% 是「一个学生查他所有课的成绩」这种简单关联。这种形状下,选型的核心不是先进,而是改起来快、新人接手看得懂。
后端常见的做法是 SpringBoot 2.x + MyBatis-Plus + MySQL 5.7/8.0,单模块 Maven 工程,不拆微服务。理由很实在:MyBatis-Plus 的QueryWrapper能覆盖掉大部分动态条件查询,不用为每个筛选组合写一段 XML;PaginationInnerInterceptor一个插件把分页的LIMIT和COUNT都办了;代码生成器还能按表一把生成 Entity、Mapper、Service、Controller 四件套,五张表半小时出骨架。鉴权用 JWT,因为小程序端和后台管理端共用一套接口,会话状态放在服务端反而要额外处理跨端共享,签发一枚令牌塞进请求头是最省事的。
数据库层不搞读写分离、不加 Redis,成绩查询这种量级单表几万行,加索引就够了。ORM 之外还留了一个手写 SQL 的口子(mapper/*.xml),专门放统计类查询,比如「按班级算平均分、最高分、不及格人数」,这种用 Wrapper 拼出来可读性太差。
版本上有个血泪经验:JDK 别上 17,这套代码里的javax.*依赖和部分旧版工具类在 17 下会连环报NoClassDefFoundError,老老实实 JDK 8 最稳。MySQL 驱动用 8.x 的话,连接串必须带时区和编码参数,否则中文和日期都会出问题,后面避坑章节细说。
2.2 目录结构从根目录一层层拆开
拿到源码包,先别急着点运行,花十分钟把目录扫一遍,后面改代码时找文件能省一半时间。这套工程是典型的前后端分离三件套:一个 SpringBoot 后端、一个后台管理前端、一个小程序端。
student-info-system/ ├── server/ # SpringBoot 后端 │ ├── src/main/java/com/example/sims/ │ │ ├── SimsApplication.java # 启动类,@MapperScan 在这里 │ │ ├── common/ # 通用层 │ │ │ ├── Result.java # 统一返回体 {code,msg,data} │ │ │ ├── PageResult.java # 分页返回体 {total,records} │ │ │ └── BusinessException.java │ │ ├── config/ # 配置层 │ │ │ ├── MybatisPlusConfig.java # 分页插件注册,坑最多的地方 │ │ │ ├── WebMvcConfig.java # 跨域、拦截器注册 │ │ │ └── JwtInterceptor.java # 登录校验拦截器 │ │ ├── controller/ # 接口层,只做参数接收和结果包装 │ │ ├── service/ # 业务层,接口 + impl │ │ ├── mapper/ # DAO 层,继承 BaseMapper │ │ ├── entity/ # 与表一一对应的实体 │ │ └── utils/ # JwtUtil、MD5Util 等 │ ├── src/main/resources/ │ │ ├── application.yml # 数据库、端口、jwt 密钥 │ │ ├── mapper/ # 手写统计 SQL 的 XML │ │ └── sql/init.sql # 建库建表 + 演示数据,导入就靠它 │ └── pom.xml ├── admin-web/ # 后台管理前端(Vue + Element UI) │ ├── src/api/ # 接口定义,baseURL 在这里改 │ ├── src/views/ # 学生管理、课程管理、成绩录入页面 │ └── vue.config.js # 开发代理 └── miniprogram/ # 小程序端(学生查分用) ├── pages/login / index / score └── utils/request.js # 请求封装,token 注入和 401 处理分层的边界很清晰:Controller 里不写业务判断,只做@RequestBody接收和Result.success()包装;Service 里做校验和事务;Mapper 只管取数。看代码的时候顺着一条链路走,比如「录入成绩」,从ScoreController.saveBatch进,到ScoreServiceImpl里做课程归属校验,最后落到ScoreMapper的批量插入,三层走一遍就摸清套路了。
common/Result.java是全局统一返回体,前端所有接口回调都按code === 200判断成功,这个约定贯穿整个前端,改接口时千万别自己另起一套结构,否则前端要改一片。
config/MybatisPlusConfig.java只有几行但极其关键,分页插件就是在这里注册的,很多人把这个类删了或者注释掉了,然后抱怨分页不生效。
resources/sql/init.sql是整份源码里最容易被忽略又最重要的文件,建表和演示数据都在里面,导入方式后面单独讲。
2.3 数据库表设计与前后端接口约定
五张主表撑起全部业务,字段不花哨但够用。这里贴核心两张,其余结构类似:
-- 学生表:学号唯一,班级用 id 关联,不冗余班级名 CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL COMMENT '学号,登录账号也是它', name VARCHAR(50) NOT NULL COMMENT '姓名', gender TINYINT DEFAULT 1 COMMENT '1男 2女', class_id BIGINT NOT NULL COMMENT '所属班级', phone VARCHAR(20) DEFAULT NULL, enroll_year INT COMMENT '入学年份', deleted TINYINT DEFAULT 0 COMMENT '0正常 1已删除,软删除标记', UNIQUE KEY uk_student_no (student_no), KEY idx_class_id (class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生档案'; -- 成绩表:同一学生同一课程同一学期只能有一条记录 CREATE TABLE score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, term VARCHAR(20) NOT NULL COMMENT '学期,如 2024-2025-1', score DECIMAL(5,2) DEFAULT NULL COMMENT '百分制,NULL 表示未录入', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course_term (student_id, course_id, term), KEY idx_student_id (student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩';两个设计点值得注意。一是软删除:student.deleted字段配合 MyBatis-Plus 的@TableLogic注解,删除变成改标记,历史成绩不会因为删学生而变成孤儿数据,教务场景里这是刚需,学生退学也得出成绩单。二是score上的联合唯一索引uk_stu_course_term,它把「重复录入」这件事在数据库层面堵死了,业务代码里可以少写一层判断,但代价是批量插入时一旦有冲突整批回滚,后面避坑章节会讲怎么处理。
接口约定上,后端统一前缀/api,返回结构固定:
| 字段 | 类型 | 说明 |
|---|---|---|
| code | int | 200 成功,401 未登录,500 业务异常 |
| msg | string | 提示语,前端直接弹 |
| data | object | 业务数据,分页时是{total, records} |
小程序端和后台端打的是同一批接口,区别只在角色:后台走sys_user表的账号,小程序走学号。后端在登录接口里根据账号来源分配role,塞进 JWT 的 payload,拦截器按路径前缀做粗粒度判断——这套设计不精致但够用,二次开发时想加细粒度权限,瓶颈就在这里,最后一章展开。
3. 核心链路拆解:登录鉴权、档案分页、成绩批量录入的真实代码
3.1 登录:从账号密码到一枚 JWT 令牌
登录接口是整个系统的入口,也是二次开发时改动最多的地方。这套代码的流程是:接收账号密码 → 按账号类型查不同表 → 密码加盐比对 → 签发 JWT → 返回令牌和角色。
@PostMapping("/login") public Result<LoginVO> login(@RequestBody LoginDTO dto) { // userType: 1 后台管理员(查 sys_user) 2 学生(查 student,用学号登录) String rawPwd = dto.getPassword(); String md5 = MD5Util.encrypt(rawPwd, "sims"); // 固定盐值,跟着源码一起给的 Long id; String name; String role; if (dto.getUserType() == 1) { SysUser user = sysUserService.lambdaQuery() .eq(SysUser::getUsername, dto.getUsername()) .eq(SysUser::getStatus, 1) // 停用账号直接查不出来 .one(); if (user == null || !md5.equals(user.getPassword())) { return Result.fail("账号或密码错误"); } id = user.getId(); name = user.getUsername(); role = user.getRole(); } else { Student stu = studentService.lambdaQuery() .eq(Student::getStudentNo, dto.getUsername()) .one(); if (stu == null || !md5.equals(stu.getPassword())) { return Result.fail("账号或密码错误"); } id = stu.getId(); name = stu.getName(); role = "STUDENT"; } // 令牌里只放必要信息,过期时间从配置读,默认 7 天 Map<String, Object> claims = new HashMap<>(); claims.put("id", id); claims.put("name", name); claims.put("role", role); String token = JwtUtil.create(claims, jwtProperties.getExpire()); return Result.success(new LoginVO(token, name, role)); }逻辑上有两点值得说。密码比对放在业务层而不是用 Spring Security 的AuthenticationManager,是因为这套源码要照顾零基础读者,少一层框架心智负担;代价是密码强度、锁定策略都得自己补。Result.fail对「账号不存在」和「密码错误」返回同一句提示,这个小细节在真实系统里应该保留,避免被撞库枚举账号。
参数说明:userType决定了查哪张表,前端登录页有个「学生 / 管理员」的切换 tab,实际就是改这个值;jwtProperties.getExpire()读的是application.yml里的jwt.expire,单位毫秒,默认604800000。密钥jwt.secret一定要换掉,源码里给的是一串示例字符串,线上用这个等于把令牌签发权公开了。
拦截器那边对应的写法是:放行/api/login、静态资源,其余请求校验Authorization头里的令牌,解析失败就直接写 401 返回,不再进 Controller。
3.2 学生档案分页:一个 QueryWrapper 撑起多条件筛选
学生列表页面上有五个筛选框:姓名模糊、学号精确、班级下拉、入学年份、性别。很多人第一反应是写五段 if 拼 SQL,MyBatis-Plus 的写法能干净很多:
@Override public PageResult<StudentVO> page(StudentQuery query) { // 只把非空条件塞进去,Wrapper 的链式调用天然支持动态拼接 LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getName()), Student::getName, query.getName()) .eq(StringUtils.hasText(query.getStudentNo()), Student::getStudentNo, query.getStudentNo()) .eq(query.getClassId() != null, Student::getClassId, query.getClassId()) .eq(query.getEnrollYear() != null, Student::getEnrollYear, query.getEnrollYear()) .eq(query.getGender() != null, Student::getGender, query.getGender()) .orderByDesc(Student::getId); // 分页参数:pageNum 从 1 开始,pageSize 默认 10,前端传 -1 表示不分页 Page<Student> page = new Page<>(query.getPageNum(), query.getPageSize()); Page<Student> result = studentMapper.selectPage(page, wrapper); // 班级 id 转班级名,避免前端多次请求 Map<Long, String> classMap = classService.list().stream() .collect(Collectors.toMap(ClassInfo::getId, ClassInfo::getClassName)); List<StudentVO> records = result.getRecords().stream().map(s -> { StudentVO vo = BeanUtil.copyProperties(s, StudentVO.class); vo.setClassName(classMap.getOrDefault(s.getClassId(), "未分班")); return vo; }).collect(Collectors.toList()); return new PageResult<>(result.getTotal(), records); }LambdaQueryWrapper的第一个布尔参数是条件开关,这是它最好用的地方:条件为 false 时整段被跳过,不用写 if。selectPage会自动生成SELECT COUNT(*)和带LIMIT的查询两条 SQL,前提是分页插件已经注册,没注册的话total恒为 0 且返回全部数据,这是新手最常翻车的地方。
参数说明:pageNum和pageSize由前端表格组件传,pageSize建议在 Service 里加个上限(比如超过 200 就按 200 处理),防止有人传pageSize=99999把内存打满。班级名转换这一步用的是「先查全量班级再做内存映射」,班级表通常只有几十行,这样写比给每个学生单独查一次班级省事得多,真到了几百个班级的规模再改成 join 也不迟。
3.3 成绩批量录入:一个事务里发生的三件事
成绩录入是这套系统里唯一需要事务的接口。老师选一门课、选一个班级,页面渲染出全班学生,填完分一次性提交。服务端要做三件事:校验课程存在且未归档、把这批成绩按「学生+课程+学期」做存在则更新、返回成功条数。
@Override @Transactional(rollbackFor = Exception.class) // 别只写 @Transactional,检查异常不回滚 public int saveBatch(Long courseId, String term, List<ScoreItem> items) { Course course = courseService.getById(courseId); if (course == null) { throw new BusinessException("课程不存在"); } if (course.getArchived() == 1) { throw new BusinessException("该课程已归档,不允许改分"); } int count = 0; for (ScoreItem item : items) { // 分数区间校验,0~100,允许 null 表示缺考未录入 if (item.getScore() != null && (item.getScore().compareTo(BigDecimal.ZERO) < 0 || item.getScore().compareTo(new BigDecimal("100")) > 0)) { throw new BusinessException("学号 " + item.getStudentNo() + " 的分数超出 0-100"); } // 先按唯一索引三要素查,存在则更新,不存在则插入 Score exist = scoreMapper.selectOne(new LambdaQueryWrapper<Score>() .eq(Score::getStudentId, item.getStudentId()) .eq(Score::getCourseId, courseId) .eq(Score::getTerm, term)); if (exist == null) { Score s = new Score(); s.setStudentId(item.getStudentId()); s.setCourseId(courseId); s.setTerm(term); s.setScore(item.getScore()); scoreMapper.insert(s); } else { exist.setScore(item.getScore()); scoreMapper.updateById(exist); } count++; } // 课程表的「已录入人数」冗余字段同步,列表页不用再 count courseService.updateRecordedCount(courseId, term, items.size()); return count; }这里用的是「先查后写」而不是INSERT ... ON DUPLICATE KEY UPDATE,原因是业务上需要在更新时保留update_time自动刷新,而且查出来的老记录还能做「改分留痕」的扩展。代价是 N 次查询,一个班 50 人就是 50 次 select,本地跑没问题,真要优化就改成批量查一次再内存比对,最后一章会给改法。
rollbackFor = Exception.class这行必须写。默认只对RuntimeException回滚,BusinessException如果继承的是Exception而不是RuntimeException,出现「前 30 条插入成功、第 31 条校验失败」的半截数据,这坑我见过不止一次。参数里的items建议在前端就做去重,同一个学生传两遍会导致第二次走更新分支,虽然不报错但计数会错。
3.4 小程序端怎么接这套接口
小程序端做的事情很轻:登录、看课表、看成绩。三个页面共用一套请求封装,核心是把 token 注入和 401 处理收在一处,否则每个页面写一遍wx.request会散得到处都是。
// utils/request.js const BASE_URL = 'http://localhost:8080/api'; // 真机调试要换成局域网 IP function request(options) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', // 从本地缓存取令牌,登录成功后写入 'Authorization': wx.getStorageSync('token') || '' }, success(res) { const body = res.data; if (body.code === 200) { resolve(body.data); } else if (body.code === 401) { // 令牌过期:清缓存回登录页,加个锁防止多次跳转 wx.removeStorageSync('token'); wx.reLaunch({ url: '/pages/login/login' }); reject(new Error('未登录')); } else { wx.showToast({ title: body.msg || '请求失败', icon: 'none' }); reject(new Error(body.msg)); } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request, BASE_URL };BASE_URL是小程序联调的第一道坎:开发者工具里填localhost能通,因为工具跑在本机;真机预览时手机访问的localhost是手机自己,必须换成本机局域网 IP,且两台设备在同一网段。Authorization头直接放裸 token,没有加Bearer前缀,和后端拦截器的解析逻辑是对应的,改一边就得改另一边,这是最容易漏的联动。
成绩查询页的逻辑就是request({url: '/score/my'}),后端从 JWT 里取id当学生 id,不接受前端传学生 id,避免越权查别人分数。这个「身份从令牌来、不从参数来」的习惯,二次开发加任何新接口时都要保持。
4. 跑起来:从建库到页面亮起来的完整步骤
4.1 环境与版本对齐
先把版本对齐,这一步省下来,后面要花三倍时间还债。我一般会按这个清单逐项核对:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 项目pom.xml里java.version写的就是 1.8,别上 11/17 |
| Maven | 3.6+ | 配个国内镜像,首次拉依赖会快很多 |
| MySQL | 5.7 或 8.0 | 8.0 要换驱动版本并加时区参数 |
| Node.js | 14 / 16 | 后台前端是 Vue2 + Element UI,Node 18 以上容易在node-sass上卡住 |
| 微信开发者工具 | 最新稳定版 | 小程序端用,需要 AppID,测试号也行 |
JDK 版本这件事值得多说一句:源码里如果还引用了javax.servlet、javax.annotation这些包,在 JDK 9 以后的模块系统下会直接缺类。看到javax前缀就老实停在 8。
4.2 建库建表与初始化演示数据
打开 Navicat 或命令行,先建库再导脚本。库名默认是sims,字符集选utf8mb4,排序规则utf8mb4_general_ci。
# 1. 建库(字符集必须是 utf8mb4,否则学生姓名里的生僻字会存成问号) mysql -uroot -p -e "CREATE DATABASE sims DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入脚本,里面包含建表 + 演示数据 mysql -uroot -p sims < server/src/main/resources/sql/init.sql # 3. 验证:五张表都在,且 student 表有演示数据 mysql -uroot -p sims -e "SHOW TABLES; SELECT COUNT(*) FROM student;"导入完成后检查三件事:表数量对不对(通常是 sys_user、student、class_info、course、score 五张)、sys_user里有没有 admin 账号、score表有没有数据。演示数据里的账号密码是加密后的串,别去数据库里改明文,改完登录反而不通——要加账号就走注册接口或者自己用MD5Util.encrypt("密码","sims")算一遍再插。
如果脚本执行报Unknown collation: utf8mb4_0900_ai_ci,说明脚本是 MySQL 8 导出的,本地是 5.7。两种解法:升级本地 MySQL,或者全局替换脚本里的排序规则为utf8mb4_general_ci,后者更快。
4.3 后端启动与接口自测
改配置文件,四个地方必须动:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/sims?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 8 用 cj,5.x 用 com.mysql.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打开,能看到真实 SQL global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: change-me-to-a-long-random-string expire: 604800000连接串里serverTimezone=Asia/Shanghai不加的话,MySQL 8 驱动会按 UTC 解析,存进去的时间差 8 小时;characterEncoding=utf8不加,中文姓名可能变问号。log-impl那行打开之后控制台会打印实际执行的 SQL 和参数,调接口时对着看,比盲猜快得多。上线前记得关掉,日志量很大。
启动方式两种:IDEA 里直接跑SimsApplication,或者命令行mvn spring-boot:run。看到Started SimsApplication in x.xxx seconds就算起来了。接着用 curl 做冒烟测试,先登录拿令牌,再带着令牌调一个需要鉴权的接口:
# 登录,拿到 data.token curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456","userType":1}' # 用返回的 token 查学生分页,验证鉴权和分页插件都正常 curl "http://localhost:8080/api/student/page?pageNum=1&pageSize=10" \ -H "Authorization: 上一步返回的token"第二个请求的返回里data.total如果大于 0 且records只有 10 条,说明分页插件生效了;如果total是 0 但records有数据,或者records返回了全表,回头去看MybatisPlusConfig有没有被注释掉。
4.4 后台前端与小程序端的联调改动
后台前端三步:npm install、改接口地址、npm run serve。接口地址在src/api/下的请求实例里,或者vue.config.js的 proxy 里,两处改一处即可:
// vue.config.js —— 开发期走代理,前端代码里 baseURL 写 /api 就行 module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', // 后端地址,端口改了这里跟着改 changeOrigin: true // 后端接口本身带 /api 前缀,所以不写 pathRewrite } } } };小程序端只改utils/request.js里那一行BASE_URL,然后在小程序开发者工具里勾上「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」,否则本地 http 请求会被拦。真机预览时BASE_URL换局域网 IP,比如http://192.168.1.20:8080/api,手机和电脑连同一个 WiFi。
到这一步,后台登录页能进、学生列表有数据、小程序能查到演示学生的成绩,环境就算通了。接下来才是真正花时间的部分。
5. 踩坑排查:这套系统跑不通时最常见的五个问题
5.1 中文变问号、时间差 8 小时
现象:学生姓名入库后变成???,或者页面显示的创建时间比实际早 8 小时。
原因:MySQL 8 的驱动默认按 UTC 处理时间,连接串没指定时区就会偏移;字符集问题则多半出在建库时用了utf8或latin1,而不是utf8mb4。
解决:连接串补齐characterEncoding=utf8&serverTimezone=Asia/Shanghai;已经建好的库执行ALTER DATABASE sims CHARACTER SET utf8mb4;,再逐表ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4;。改完重启后端,旧数据里的问号是救不回来的,得重新导一遍演示数据。
5.2 分页失效,total 恒为 0 或一次返回全表
现象:前端表格显示「共 0 条」,但列表里明明有数据;或者pageSize=10却返回了几百条。
原因:MybatisPlusConfig里的PaginationInnerInterceptor没注册,或者注册了但@Bean所在的类没被扫描到。没有这个插件,selectPage会退化成普通查询,不拼LIMIT也不发COUNT。
解决:确认配置类上有@Configuration,内部方法返回MybatisPlusInterceptor并addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL))。另外注意 MyBatis-Plus 3.4 前后包名变过,PaginationInterceptor和PaginationInnerInterceptor是两个类,照抄网上代码很容易抄到旧版本的写法,编译能过但行为不对。
5.3 登录成功后疯狂跳回登录页
现象:刚登录进去,页面闪一下就回到登录页,控制台一片 401。
原因:前端响应拦截器对 401 的处理是「清 token + 跳登录」,但某个页面初始化时会并发发好几个请求,令牌其实只是刚好过期,几个请求同时 401,就触发了多次跳转;更常见的是后端令牌过期时间配得极短(比如测试时改成 60 秒忘了改回来)。
解决:拦截器里加一个跳转锁,第一次 401 时置标志位再跳,跳完重置;同时把jwt.expire改回 7 天。还有一种隐蔽情况:后端重启后密钥变了(如果密钥是随机生成的),旧 token 全部解析失败,表现也是全员 401,所以jwt.secret一定要写死在配置文件里。
5.4 小程序真机能登录,一调接口就 network error
现象:开发者工具里一切正常,扫码到手机上,登录页转圈然后提示网络异常。
原因:BASE_URL还是localhost;或者手机和电脑不在同一网段;或者电脑防火墙拦了 8080 端口。
解决:把BASE_URL换成电脑的局域网 IP,ipconfig(Windows)/ifconfig(Mac)能看到;关闭防火墙或放行 8080;确认后端server.port没被改过。真机调试阶段还必须勾上「不校验合法域名」,正式发布时则要求域名备案且走 https,这一步没法绕。
5.5 成绩批量录入报 Duplicate entry
现象:录第二个班的成绩时,接口直接 500,日志里是Duplicate entry '1001-3-2024-2025-1' for key 'uk_stu_course_term'。
原因:score表的联合唯一索引生效了,说明「学生+课程+学期」已经存在记录,但业务代码走的是 insert 分支而不是 update。常见于「先查后写」的查询条件漏了term,或者学期字段前后端传的格式不一致(2024-2025-1和2024-2025学年第一学期是两个值)。
解决:把唯一索引的三要素在查询条件里写全,学期格式在前后端统一成一种;批量的场景如果量大,改成先按courseId + term一次性查出所有已有记录做内存比对,减少查询次数也避免并发下的竞态。
排查这类问题有个通用顺序:先看浏览器 Network 里的返回体和状态码,再看后端控制台打印的 SQL(开了log-impl才有),最后才去翻数据库。跳过前两步直接改代码,多半是在猜。
6. 二次开发:把课程设计级别的代码改成能交付的东西
这套源码能跑,但离「能交给别人用」还差几处。我一般按影响面排序动手,先改权限,再补导出,最后动性能。
| 改造点 | 现状 | 改法 | 涉及文件 |
|---|---|---|---|
| 权限粒度 | 拦截器只判断「有没有登录」 | 加@RequireRole注解 + 拦截器校验 | JwtInterceptor、各 Controller |
| 数据导出 | 只能页面看,没法交表 | 引入 EasyExcel 做成绩单导出 | 新增ExportController |
| 批量录入性能 | 循环单条查、单条写 | 改成一次查询 + 批量插入 | ScoreServiceImpl |
| 改分留痕 | 直接覆盖,无记录 | 加score_log表,更新前落一条 | ScoreServiceImpl |
权限这块改动最小收益最大。加一个注解,拦截器里读方法上的注解和目标角色比对:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); // 允许的角色,如 {"ADMIN"} } // 拦截器 preHandle 里补一段 HandlerMethod hm = (HandlerMethod) handler; RequireRole rr = hm.getMethodAnnotation(RequireRole.class); if (rr != null && Arrays.stream(rr.value()).noneMatch(r -> r.equals(claims.get("role")))) { response.setStatus(200); response.getWriter().write(JSON.toJSONString(Result.fail(403, "无权限"))); return false; }注意别在拦截器里直接setStatus(403),前端统一按返回体的code判断,HTTP 状态码改了反而让 axios 走 catch 分支,错误提示就丢了。这个方法上没加注解的接口默认放行,所以新增接口时要养成加注解的习惯,不然权限形同虚设。
验证方式别靠手点。改完权限后我会跑一遍固定脚本:管理员令牌调增删改查应该全通,学生令牌调删学生应该返回 403,匿名调任何接口应该返回 401。三个断言都过,再去看页面。性能改造同理,改批量插入前后各跑一次「录入 50 人成绩」,比对控制台打印的 SQL 条数,从 100 条降到 2 条才算改对。
血泪经验是:这类别人写的源码包,最容易出问题的不是业务逻辑,而是版本和配置的隐性依赖。从那以后我每次接手一套源码,第一件事都是把 JDK、驱动、依赖版本和初始化脚本对齐,跑通冒烟用例再谈改功能,跳过这步直接改代码,最后往往分不清是新写的 bug 还是环境本来就有问题。希望帮到你。
本文还有配套的精品资源,点击获取