简介:这份《学生成绩信息管理系统的设计与实现(论文+源码)》面向高校计算机相关专业课程设计、毕业设计及.NET Web开发初学者,提供学生成绩管理项目源码与配套论文参考,围绕学生信息、课程信息、成绩录入查询、统计分析与报表输出等教学管理场景组织功能。压缩包共311个文件,约3.48MB,以86个cs源码、29个aspx页面、27个dll程序集及mdf/ldf数据库文件为核心,辅以css、js、config配置和gif、jpg界面素材,另含doc论文、xls数据表与sln/csproj工程文件,便于按表现层、业务层和数据层梳理项目结构。已有60人学习,适合需要参考完整实现思路、数据库设计、页面交互与部署配置的读者,也可用于对照论文理解需求分析、系统设计与测试流程。
1. 一个成绩管理系统的真实定位:谁在用、卡在哪
每到期末周,做毕业设计的同学里有相当一部分会把题目落到同一个方向——学生成绩信息管理系统。它看起来是最没有悬念的增删改查,但真把成绩录进去、算出来、锁得住之后,难点全在细节里:平时分和考试分按什么比例合、一门课结课之后成绩还能不能改、同一个学生重修两次怎么区分。这份资源给的就是一套带论文的完整实现,后端接口、前端页面、建表脚本和设计文档都在里面,适合拿来做课程设计交付,也适合当成一个能真跑起来的小型教务系统练手。它真正解决的问题不是“把分数存进数据库”,而是把录入、核算、排名、留痕这四件事串成一条谁都不敢乱改的链路。
2. 技术栈与工程结构:从 pom.xml 到 views 目录全部摊开看
2.1 技术选型:为什么这套组合最适合成绩系统
这类项目的技术栈基本定型了,跑不出Spring Boot + MyBatis-Plus + MySQL + Vue2 + Element UI这个组合。原因不是它多先进,而是它在这个场景下每一层都能对上一个具体需求。
后端要处理的是“多表关联 + 条件分页 + 按角色裁剪字段”,MyBatis-Plus 的QueryWrapper和IPage正好能把这部分写得极短;前端要的是“表格、表单、弹窗、批量导入”,Element UI 的el-table和el-form拿来就能用,不用自己造轮子。数据库选 MySQL 是因为窗口函数RANK()在 8.0 里能一行算出课程排名,这条特性后面会重点讲,它也是我见过的翻车高发区。
整套系统分三个角色:管理员维护学生、教师、课程基础数据并且管账号;教师负责自己所教课程的成绩录入和修改;学生只能查自己的成绩和绩点。这套权限模型简单,但在论文里是标准的用例图素材,答辩时也好讲。
依赖清单精简下来大概是这样:
<dependencies> <!-- Web 层,提供 REST 接口和内置 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 持久层,省掉大量单表 CRUD 的 XML --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <!-- 驱动,8.x 版本对应 com.mysql.cj.jdbc.Driver --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 登录令牌,前后端分离后 session 不好用,走 JWT 更省事 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>scope为runtime的驱动不需要在代码里显式引用,只在启动时由DriverManager加载;Lombok 标optional是为了不打进最终 jar,只参与编译期的 getter/setter 生成。这两处细节很多同学照抄依赖时会漏,导致打出来的包莫名变大。
配置文件里最容易踩的是数据源 URL,四个参数一个都不能少:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/score_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0characterEncoding=utf8决定中文姓名字段会不会变成问号,serverTimezone=Asia/Shanghai决定录成绩的时间会不会差八小时,useSSL=false是本地开发免掉证书握手,allowPublicKeyRetrieval=true是 MySQL 8 默认加密方式下不加会直接连不上。map-underscore-to-camel-case开着,数据库的student_id才能自动映射到实体的studentId,关掉的话查出来全是 null。
2.2 后端工程结构:分层是怎么切的
后端目录按职责切分,这套切法在小型教务系统里已经够用:
score-backend/src/main/java/com/example/score/ ├── ScoreApplication.java 启动类,@MapperScan 挂在这里 ├── common/ │ ├── Result.java 统一返回 {code, msg, data} │ ├── BizException.java 业务异常,带上可读的错误文案 │ ├── GlobalExceptionHandler.java @RestControllerAdvice 全局兜底 │ └── UserContext.java ThreadLocal 存当前登录用户 ├── config/ │ ├── WebConfig.java 注册拦截器、CORS、静态资源 │ └── MybatisPlusConfig.java 分页插件 ├── interceptor/AuthInterceptor.java ├── controller/ 只做参数接收和返回包装 ├── service/ + service/impl/ 业务规则全在这层 ├── mapper/ Mapper 接口 ├── entity/ 和表一一对应 ├── dto/ 入参对象,带校验注解 └── vo/ 出参对象,按角色裁剪字段关键是entity和vo要分开。学生查成绩时不该看到teacher_id和内部主键,教师看班级名单时不该看到别人的工号。用同一套实体从头传到尾,接口一多就会泄露字段,这个毛病在答辩现场被问到的概率很高。
ScoreApplication上有个容易漏的注解:
@SpringBootApplication @MapperScan("com.example.score.mapper") // 不写这行,Mapper 接口注入不进来 public class ScoreApplication { public static void main(String[] args) { SpringApplication.run(ScoreApplication.class, args); } }@MapperScan的包路径必须精确到mapper这一层,写成com.example.score会让 MyBatis 扫描全包,启动变慢还可能有冲突。
2.3 前端工程结构与接口约定
前端是标准的 vue-cli 结构,改造点集中在三个文件:
score-frontend/src/ ├── api/ │ ├── score.js 成绩相关接口,一个函数对应一个后端路径 │ └── user.js 登录、登出、当前用户信息 ├── utils/request.js axios 实例 + 拦截器 ├── router/index.js 路由表,meta 里带角色要求 ├── store/ Vuex,存 token 和用户信息 └── views/ ├── login/ 登录页 ├── score/ 成绩录入、成绩查询 ├── student/ 学生档案维护 └── stat/ 班级排名、及格率统计request.js是前后端契约的落点,统一返回{code, msg, data},只有code === 0才当成功处理:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 开发环境走 /dev-api,由 devServer 转发到 8080 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 // code 非 0 统一弹提示,业务层不用每次都写 Message if (body.code !== 0) { Message.error(body.msg || '请求失败') return Promise.reject(new Error(body.msg)) } return body.data // 直接解包,页面里拿到的是 data 而不是整个响应 }, err => { // 401 单独处理:清 token 回登录页,避免页面停在空白状态 if (err.response && err.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(err) } ) export default service拦截器里return body.data这一行看着小,但决定了页面代码要不要写res.data.data。统一在这里解包,后面所有页面都少一层取值,改接口格式时只动这一处。
2.4 本地启动顺序与最小验证
跑起来的顺序不能反:先建库导数据,再起后端,最后起前端。反过来会出现前端一直转圈、排查半天才发现是数据库没连上。
# 1. 建库并导入初始化脚本,脚本在 score-backend/src/main/resources/sql/ 下 mysql -uroot -p123456 -e "CREATE DATABASE score_db DEFAULT CHARSET utf8mb4;" mysql -uroot -p123456 score_db < score-backend/src/main/resources/sql/score_db.sql # 2. 起后端,看到 Started ScoreApplication 才算成功 cd score-backend && mvn spring-boot:run # 3. 起前端,默认 8081 端口,避免和别的项目撞 cd score-frontend && npm install --registry=https://registry.npmmirror.com && npm run serve验证是不是真跑通了,别急着点页面,先用 curl 打一下登录接口:
curl -X POST http://localhost:8080/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'返回体里code为 0 且data里有 token,说明后端、数据库、密码加密这三段都通了。如果这里就报错,问题一定在后端或数据库,跟前端无关,能省掉一半排查时间。
3. 数据库设计:七张表撑起一个能算排名、能锁成绩的教务骨架
3.1 七张核心表与它们的关系
表不用多,够用就行。这套设计里七张表能覆盖全部业务流程:
| 表名 | 作用 | 关键字段 |
|---|---|---|
t_user | 登录账号与角色 | username、password、role |
t_student | 学生档案 | stu_no、stu_name、class_id |
t_teacher | 教师档案 | tea_no、tea_name、dept_id |
t_class | 班级 | class_name、grade、major |
t_course | 课程 | course_no、course_name、credit、usual_ratio |
t_score | 成绩明细 | student_id、course_id、term、total_score |
t_term | 学期字典 | term_code、start_date、is_current |
关系是典型的星型:t_score是事实表,其他六张围在周围。学生和课程是多对多,中间靠t_score拆开。教师和课程也是一对多,一门课由一位教师主讲。
这里有个设计选择值得说:t_course上放了usual_ratio字段,表示平时分占比。很多实现直接把 0.3 写死在 Java 代码里,等到要改成 4:6 就得重新打包发版。放到表里,管理员在页面上改一行就生效,这是后面二次开发那一章的基础。
3.2 成绩表字段设计:为什么不能只存一个总分
最省事的建法是t_score(id, student_id, course_id, score)。但这套实现存了四个分数:usual_score、exam_score、total_score、grade_point。多出来的三个字段不是冗余,是留证据。
学生查完成绩觉得不对来问,教师能立刻说清楚“平时分 85、考试分 72,按 3:7 合出来 75.9”。只存总分的系统,这时候只能翻纸质记录。grade_point单独存也是同理,绩点换算规则不同学院不一样,存下来是为了避免规则改了之后历史数据全变。
建表语句里有两个约束是重点:
CREATE TABLE `t_score` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `student_id` BIGINT NOT NULL COMMENT '学生主键', `course_id` BIGINT NOT NULL COMMENT '课程主键', `teacher_id` BIGINT NOT NULL COMMENT '录入教师,用于追溯', `term` VARCHAR(20) NOT NULL COMMENT '学期,如 2024-2025-1', `usual_score` DECIMAL(4,1) DEFAULT NULL COMMENT '平时分,0-100', `exam_score` DECIMAL(4,1) DEFAULT NULL COMMENT '考试分,0-100', `total_score` DECIMAL(4,1) DEFAULT NULL COMMENT '按权重合出的总评', `grade_point` DECIMAL(3,2) DEFAULT NULL COMMENT '绩点,规则见文档', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_stu_course_term` (`student_id`,`course_id`,`term`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩表';DECIMAL(4,1)而不是FLOAT,是因为浮点数存 75.9 会变成 75.899999,页面上显示出来就是一串小数,四舍五入的位置还会因为计算顺序不同而飘。分数这种要参与计算又必须精确的值,用定点数。
UNIQUE KEY uk_stu_course_term是防重复录入的最后一道闸。代码里当然也会先查一遍,但两个教师同时点保存时,查和插之间有窗口期,只有数据库唯一索引能兜住。这条索引的存在意味着业务上要接受“一个学生一门课一学期只能有一条记录”,重修必须换个学期,这个前提在需求阶段就得定死。
3.3 实体类与 MyBatis-Plus 的映射细节
实体类不长,但几个注解都省不掉:
@Data @TableName("t_score") public class Score { @TableId(type = IdType.AUTO) // 告诉 MP 主键由数据库自增,不是雪花 ID private Long id; private Long studentId; // 对应 student_id,靠驼峰映射 private Long courseId; private Long teacherId; private String term; private BigDecimal usualScore; // 金额/分数类统一 BigDecimal,别用 Double private BigDecimal examScore; private BigDecimal totalScore; private BigDecimal gradePoint; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableLogic // 标记逻辑删除字段,delete 语句会自动改成 update private Integer deleted; }@TableId(type = IdType.AUTO)不写的话,MyBatis-Plus 默认走雪花算法生成 19 位 ID,插入时会把自增主键覆盖掉,然后自增列的值和实际 id 对不上,后面按 id 查数据全是乱序。
@TableLogic让所有deleteById变成update t_score set deleted = 1。好处是成绩数据不会真丢,坏处是配合唯一索引会出现冲突——这个坑留到第五章细说。
字段类型用BigDecimal而不是Double,是因为总评分要做乘法和加法,Double的精度误差会导致 75.95 变成 75.94,四舍五入后差 0.1 分,学生真的会来找。
3.4 建表脚本与初始化数据
初始化脚本里除了建表,还要塞三条账号数据和一批测试成绩,否则一登录进去全是空表,没法演示。
-- 三个角色的初始账号,密码统一是 123456 的 BCrypt 密文 INSERT INTO `t_user` (`username`, `password`, `role`, `real_name`) VALUES ('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'ADMIN', '系统管理员'), ('teacher01','$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi','TEACHER', '某教师'), ('2021001', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', 'STUDENT', 'A同学');密码用 BCrypt 而不是 MD5,是因为 MD5 可以直接撞库查出明文,答辩时被问到“密码怎么存的”会很难看。BCrypt 每次加密带随机盐,同样的密码存两次密文也不同,校验时用matches()比较。
导入完成后先跑一条自检 SQL,确认表和关联都对:
SELECT s.stu_no, s.stu_name, c.course_name, sc.total_score FROM t_score sc JOIN t_student s ON s.id = sc.student_id JOIN t_course c ON c.id = sc.course_id WHERE sc.term = '2024-2025-1' ORDER BY sc.total_score DESC LIMIT 10;这条语句能出结果,说明三张表的主外键关系、字符集、关联字段类型全部对得上,比在页面上一个个点要快得多。
4. 核心链路拆解:一次成绩录入从 Controller 到 Mapper 的完整走查
4.1 成绩录入的完整调用链
从教师点下“保存”到数据落库,中间经过五层:页面表单校验 → axios 拦截器加 token → Controller 参数校验 → Service 业务规则 → Mapper 落库。每一层各管一件事,混在一起就会出问题。
Controller 只做三件事:接参数、触发校验、包装返回。
@RestController @RequestMapping("/api/score") @RequiredArgsConstructor public class ScoreController { private final IScoreService scoreService; // 只有管理员和教师能调,学生调直接 403 @PostMapping("/save") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") public Result<Void> save(@RequestBody @Validated ScoreSaveDTO dto) { scoreService.saveScore(dto); return Result.ok(); } // 分页查询,按角色在 Service 里自动追加数据范围条件 @GetMapping("/page") public Result<PageResult<ScoreVO>> page(ScoreQuery query) { return Result.ok(scoreService.pageQuery(query)); } }@Validated配合 DTO 上的@NotNull、@DecimalMin、@DecimalMax,能在进业务层之前就把“分数没填”“分数超过 100”这类问题挡掉。@PreAuthorize依赖配置类上的@EnableGlobalMethodSecurity(prePostEnabled = true),漏了这行注解不报错,但权限校验完全不生效——这种静默失效最难查。
4.2 Service 层:权重计算与边界校验
业务规则全在 Service,这是整套系统最该看懂的一段:
@Service @RequiredArgsConstructor public class ScoreServiceImpl extends ServiceImpl<ScoreMapper, Score> implements IScoreService { private final ICourseService courseService; @Override @Transactional(rollbackFor = Exception.class) public void saveScore(ScoreSaveDTO dto) { // 1. 课程存在性 + 归档状态校验,归档后成绩锁定不允许再改 Course course = courseService.getById(dto.getCourseId()); if (course == null) { throw new BizException("课程不存在"); } if (Integer.valueOf(1).equals(course.getStatus())) { throw new BizException("该课程已归档,成绩不可修改"); } // 2. 分数边界校验,防止页面绕过前端校验直接调接口 checkRange(dto.getUsualScore(), "平时分"); checkRange(dto.getExamScore(), "考试分"); // 3. 权重从课程表取,缺省按 3:7,这是后面做二次开发的入口 BigDecimal usualRatio = course.getUsualRatio() == null ? new BigDecimal("0.3") : course.getUsualRatio(); BigDecimal examRatio = BigDecimal.ONE.subtract(usualRatio); BigDecimal total = dto.getUsualScore().multiply(usualRatio) .add(dto.getExamScore().multiply(examRatio)) .setScale(1, RoundingMode.HALF_UP); Score score = new Score(); score.setStudentId(dto.getStudentId()); score.setCourseId(dto.getCourseId()); score.setTerm(dto.getTerm()); score.setTeacherId(UserContext.get().getUserId()); score.setUsualScore(dto.getUsualScore()); score.setExamScore(dto.getExamScore()); score.setTotalScore(total); score.setGradePoint(toGradePoint(total)); try { saveOrUpdate(score); } catch (DuplicateKeyException e) { // 唯一索引兜底,并发下重复提交会走到这里 throw new BizException("该学生本学期已存在这门课的成绩"); } } private void checkRange(BigDecimal v, String field) { if (v == null || v.compareTo(BigDecimal.ZERO) < 0 || v.compareTo(new BigDecimal("100")) > 0) { throw new BizException(field + "必须在 0 到 100 之间"); } } }@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省。Spring 默认只对RuntimeException回滚,自定义的BizException如果继承的是受检异常,抛出去事务照常提交,成绩就写进去了但业务上算失败,这种“日志里报错但数据已落库”的情况最难排查。
saveOrUpdate是先查再插还是直接插,取决于实体上有没有主键。这里score.getId()为空,走的是 insert 分支。捕获DuplicateKeyException是把数据库约束当作业务规则的一部分来用,比在代码里先 select 一遍更可靠,因为 select 和 insert 之间还有时间窗口。
4.3 Mapper 层:用窗口函数一次查出班级排名
排名是最容易写歪的地方。很多人会用 Java 查出全部记录再排序、再循环赋名次,数据量一大就慢得不能看。一条 SQL 就够了:
<select id="selectCourseRank" resultType="com.example.score.vo.RankVO"> SELECT s.student_id AS studentId, st.stu_name AS studentName, s.total_score AS totalScore, RANK() OVER ( PARTITION BY s.course_id, s.term ORDER BY s.total_score DESC ) AS rankNo FROM t_score s JOIN t_student st ON st.id = s.student_id WHERE s.deleted = 0 AND s.course_id = #{courseId} AND s.term = #{term} ORDER BY s.total_score DESC </select>PARTITION BY s.course_id, s.term是按课程和学期分组,每组内部按总分倒序排,RANK()给每组独立编号。这样一次查询就能同时拿到多门课的排名,不用在 Java 里套两层循环。
RANK()和ROW_NUMBER()的区别很关键:并列第一时,RANK()给出 1、1、3,ROW_NUMBER()给出 1、2、3。成绩排名必须用RANK(),两个 95 分的学生都该是第一,用ROW_NUMBER()会因为插入顺序随机决定谁是第一,学生真的会投诉。
WHERE s.deleted = 0这行不能靠@TableLogic自动加,因为这是手写 XML,MP 的逻辑删除拦截只对它的内置方法生效。手写 SQL 漏了这个条件,删掉的成绩还会出现在排名里。
4.4 登录与角色权限:三种身份共用一套菜单
登录走 JWT,验证走拦截器,两者配合就能把三种角色管住。
@Component @RequiredArgsConstructor public class AuthInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { // 预检请求直接放行,否则跨域时 OPTIONS 会被拦成 401 if (HttpMethod.OPTIONS.matches(req.getMethod())) { return true; } String token = req.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { writeUnauthorized(resp); return false; } Claims claims = jwtUtil.parse(token.substring(7)); // 用户信息放进 ThreadLocal,Service 层可直接取,不用一层层传参 UserContext.set(new LoginUser(claims)); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { // Tomcat 线程会复用,不清会串号,学生能看到别人的数据 UserContext.clear(); } }UserContext.clear()放在afterCompletion里,是这套设计里最容易被忽略、后果最严重的一行。线程池复用线程时,ThreadLocal 不会自动清,上一个请求的学生 id 会留在里面,下一个请求如果没覆盖,就会拿着别人的身份查数据。这种 bug 在测试环境很难复现,上了生产才炸。
前端菜单按角色过滤,路由表里用meta.roles标记:
const routes = [ { path: '/score/input', component: () => import('@/views/score/Input'), meta: { roles: ['ADMIN', 'TEACHER'], title: '成绩录入' } }, { path: '/score/my', component: () => import('@/views/score/My'), meta: { roles: ['STUDENT'], title: '我的成绩' } } ]前端过滤只是体验优化,真正的权限边界在后端@PreAuthorize和 Service 里的数据范围拼接。前端隐藏了按钮不等于接口调不到,用 Postman 直接打接口一样能过,所以后端必须自己再判一次。
5. 避坑与排查:五个让系统跑不起来的真实翻车现场
5.1 环境与编码类的三个坑
现象:登录进去学生姓名全是“???”
原因基本锁定在字符集。三层里任何一层是latin1都会这样:数据库连接串没写characterEncoding=utf8、建库时没指定DEFAULT CHARSET utf8mb4、或者表已经建错了再改连接串也没用。解决方式是从底往上改一遍,先ALTER DATABASE score_db CHARACTER SET utf8mb4;,再对每张表ALTER TABLE t_student CONVERT TO CHARACTER SET utf8mb4;,最后确认连接串里有useUnicode=true&characterEncoding=utf8。注意utf8在 MySQL 里是 3 字节的别名,存不了 emoji 和部分生僻字,建库时直接写utf8mb4更省事。
现象:Mapper 里写了RANK() OVER (...),一执行报 SQL 语法错误
报错信息大概是You have an error in your SQL syntax ... near 'OVER (PARTITION BY ...',看着像 SQL 写错了,实际是数据库版本不对。窗口函数从 MySQL 8.0 才开始支持,5.7 上必然报这个错。两条路:升到 8.0,或者改成用户变量模拟排名。
-- MySQL 5.7 的替代写法,用变量在排序过程中累加序号 SELECT t.* , @rownum := @rownum + 1 AS rankNo FROM ( SELECT s.student_id, st.stu_name, s.total_score FROM t_score s JOIN t_student st ON st.id = s.student_id WHERE s.course_id = #{courseId} AND s.term = #{term} AND s.deleted = 0 ORDER BY s.total_score DESC ) t, (SELECT @rownum := 0) r;变量写法不支持并列名次,两个同分学生会拿到不同编号,业务上要不要接受得提前和需求方确认。我的习惯是直接在 README 里写死“需要 MySQL 8.0+”,比事后兼容两套 SQL 省事得多。
现象:前端所有请求 401,登录接口本身也是 401
先看浏览器 Network 里有没有一条OPTIONS请求返回 401。跨域时浏览器会先发预检请求,这个请求不带Authorization头,拦截器一看没 token 就直接拒了,真正的 POST 根本发不出去。解决是在preHandle最前面放行OPTIONS,就是上一章代码里那三行。另一种情况是baseURL配错,开发环境应该指向/dev-api再由 devServer 转发到 8080,直接写http://localhost:8080会因为没有转发配置而触发跨域。
5.2 数据一致性相关的两个坑
现象:删掉一条成绩想重新录入,报“Duplicate entry for key uk_stu_course_term”
这是逻辑删除和唯一索引打架的经典场景。@TableLogic删除时只把deleted改成 1,物理行还在;唯一索引(student_id, course_id, term)不认deleted字段,所以再插一条同样的组合就冲突了。
三种解法,按推荐度排:一是在唯一索引里加上deleted字段,但这样只能删一次,删第二次还是冲突;二是把deleted设计成时间戳或 id,每次删除写不同的值,索引天然不冲突;三是成绩这种数据干脆不做逻辑删除,直接物理删,靠操作日志表留痕。这套项目里用的是第二种,deleted字段类型改成BIGINT,删除时写入当前时间戳。
现象:教师在页面上改完成绩,刷新又变回旧值,日志里两条 update 都成功
八成是saveOrUpdate的判断逻辑出了问题。MP 的saveOrUpdate看主键是否为空来决定 insert 还是 update,如果前端传的 DTO 里带了 id 但实际是新增场景,就会走 update 分支更新到别的记录上;反之如果更新时 id 丢了,就会插一条新记录。解决是在 DTO 里明确区分新增和修改两个接口,新增接口忽略 id 字段,修改接口强制校验 id 必填,不要图省事共用一个方法。
6. 二次开发:把固定 3:7 权重改成按课程可配置的改造思路
这套系统最值得动手改的地方,是成绩权重。原始实现里权重写死在代码里,改一次要发一次版,而实际情况是不同课程权重差异很大:实验课可能 5:5,理论课 3:7,体育课甚至只算平时分。改造分三步走。
第一步,表结构已经支持了。t_course上有usual_ratio字段,管理员的课程维护页面加一个输入框就能改,取值 0 到 1 之间。同时给这门课加一个status字段,1 表示归档,归档后 Service 层直接抛异常拒绝修改,这就是成绩锁定的实现方式。
第二步,把计算逻辑抽出来。直接在ScoreServiceImpl里读课程字段也能跑,但课程一多、规则一复杂(比如某些课程还要加期中分),那段代码会膨胀成一坨。我会抽一个策略接口:
public interface ScoreCalculator { // 不同课程类型走不同实现,新增规则时只加类不改老代码 BigDecimal calc(ScoreSaveDTO dto, Course course); BigDecimal toGradePoint(BigDecimal total); }默认实现按course.getUsualRatio()算,实验课实现按 5:5 算,用@Component("labScoreCalculator")起名,Service 里按课程类型从 Spring 容器取对应 bean。这样加规则是新增文件,不是改老文件,回归测试的范围小很多。
第三步,想清楚历史数据怎么办。改了权重之后,之前按 3:7 算出来的总评分要不要重算?我的做法是只对新录入和未归档课程生效,已归档课程的成绩原样保留,同时在成绩详情页展示“计算规则快照”,把当时的平时分权重一起存进t_score。多存一个calc_rule字段,代价是一列,收益是半年后有人质疑分数时能拿出依据。
验证方式很直接:改一门课的权重为 0.5,录入一条平时 80、考试 90 的成绩,期望总评 85.0;再把权重改成 0.7,重新录入另一条同样分数的记录,期望总评 83.0。两条都对,说明配置读取和计算链路通了。再把这门课状态改成归档,尝试修改成绩,应该返回“课程已归档”,说明锁定逻辑也生效了。
这套流程我是踩过坑之后固化下来的:凡是涉及“算出来的值”,一律把计算参数一起存下来,别指望以后能复现。从那以后我每次做类似改造,都会先问一句“三个月后有人拿着这条数据来对账,我能不能解释清楚”,答不上来就先加字段再写代码。希望帮到你。
本文还有配套的精品资源,点击获取