简介《学生信息管理系统》配套程序与源码面向学校教务人员、教师及刚接触数据库编程的学习者围绕学生信息的录入、查找、删除、修改、排序、人数统计与全量显示等日常管理需求提供一套可直接运行的小型应用。压缩包共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能覆盖掉大部分动态条件查询不用为每个筛选组合写一段 XMLPaginationInnerInterceptor一个插件把分页的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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT成绩;两个设计点值得注意。一是软删除student.deleted字段配合 MyBatis-Plus 的TableLogic注解删除变成改标记历史成绩不会因为删学生而变成孤儿数据教务场景里这是刚需学生退学也得出成绩单。二是score上的联合唯一索引uk_stu_course_term它把「重复录入」这件事在数据库层面堵死了业务代码里可以少写一层判断但代价是批量插入时一旦有冲突整批回滚后面避坑章节会讲怎么处理。接口约定上后端统一前缀/api返回结构固定字段类型说明codeint200 成功401 未登录500 业务异常msgstring提示语前端直接弹dataobject业务数据分页时是{total, records}小程序端和后台端打的是同一批接口区别只在角色后台走sys_user表的账号小程序走学号。后端在登录接口里根据账号来源分配role塞进 JWT 的 payload拦截器按路径前缀做粗粒度判断——这套设计不精致但够用二次开发时想加细粒度权限瓶颈就在这里最后一章展开。3. 核心链路拆解登录鉴权、档案分页、成绩批量录入的真实代码3.1 登录从账号密码到一枚 JWT 令牌登录接口是整个系统的入口也是二次开发时改动最多的地方。这套代码的流程是接收账号密码 → 按账号类型查不同表 → 密码加盐比对 → 签发 JWT → 返回令牌和角色。PostMapping(/login) public ResultLoginVO 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 天 MapString, 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 拼 SQLMyBatis-Plus 的写法能干净很多Override public PageResultStudentVO page(StudentQuery query) { // 只把非空条件塞进去Wrapper 的链式调用天然支持动态拼接 LambdaQueryWrapperStudent 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 表示不分页 PageStudent page new Page(query.getPageNum(), query.getPageSize()); PageStudent result studentMapper.selectPage(page, wrapper); // 班级 id 转班级名避免前端多次请求 MapLong, String classMap classService.list().stream() .collect(Collectors.toMap(ClassInfo::getId, ClassInfo::getClassName)); ListStudentVO 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 处理防止有人传pageSize99999把内存打满。班级名转换这一步用的是「先查全量班级再做内存映射」班级表通常只有几十行这样写比给每个学生单独查一次班级省事得多真到了几百个班级的规模再改成 join 也不迟。3.3 成绩批量录入一个事务里发生的三件事成绩录入是这套系统里唯一需要事务的接口。老师选一门课、选一个班级页面渲染出全班学生填完分一次性提交。服务端要做三件事校验课程存在且未归档、把这批成绩按「学生课程学期」做存在则更新、返回成功条数。Override Transactional(rollbackFor Exception.class) // 别只写 Transactional检查异常不回滚 public int saveBatch(Long courseId, String term, ListScoreItem 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 LambdaQueryWrapperScore() .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 环境与版本对齐先把版本对齐这一步省下来后面要花三倍时间还债。我一般会按这个清单逐项核对组件建议版本说明JDK1.8项目pom.xml里java.version写的就是 1.8别上 11/17Maven3.6配个国内镜像首次拉依赖会快很多MySQL5.7 或 8.08.0 要换驱动版本并加时区参数Node.js14 / 16后台前端是 Vue2 Element UINode 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 8 用 cj5.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连接串里serverTimezoneAsia/Shanghai不加的话MySQL 8 驱动会按 UTC 解析存进去的时间差 8 小时characterEncodingutf8不加中文姓名可能变问号。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?pageNum1pageSize10 \ -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。解决连接串补齐characterEncodingutf8serverTimezoneAsia/Shanghai已经建好的库执行ALTER DATABASE sims CHARACTER SET utf8mb4;再逐表ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4;。改完重启后端旧数据里的问号是救不回来的得重新导一遍演示数据。5.2 分页失效total 恒为 0 或一次返回全表现象前端表格显示「共 0 条」但列表里明明有数据或者pageSize10却返回了几百条。原因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换成电脑的局域网 IPipconfigWindows/ifconfigMac能看到关闭防火墙或放行 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 还是环境本来就有问题。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?