# Spring Boot 3 + Vue 3 实战:一套三级安全教育考试系统的完整设计与 6 个踩坑复盘
> 从需求拆解、数据库设计、双维度权限,到随机组卷、在线答题、自动判分,再到 6 个真实踩过的坑。全文基于实际落地的生产代码,不是 Demo。
## 一、前言
去年接到一个需求:给一家xx做一套「三级安全教育」在线考试系统。
听起来像是「又一个考试系统」,但真正做完之后我发现,这个领域的业务复杂度远超预期 —— 它不是简单地把试卷搬上网,而是要把**国家安全生产法规定的三级安全教育体系**数字化,涉及厂级、车间级、班组级三层教育档案的联动、有效期管理、合格判定与过期提醒。
这篇文章我会完整讲清楚:
- 三级安全教育的业务模型怎么抽象成数据库表
- 权限体系为什么要做成「菜单权限 + 数据权限」双维度
- 随机组卷怎么保证不会给学员发一张空卷
- 在线答题、断点续答、自动交卷怎么落地
- **以及 6 个真实踩过的坑**(这部分是本文最有价值的部分,每一个都曾经真实影响过线上功能)
技术栈是 Spring Boot 3 + Vue 3,前后端分离,两套前端(管理端 + 学员端)共用一个后端。
---
## 二、项目背景:三级安全教育到底在考什么
先说清业务,否则后面的表结构会看不懂。
《生产经营单位安全培训规定》要求:新入职从业人员必须接受**三级安全教育培训**,且必须**逐级考核合格**:
| 层级 | 教育主体 | 内容侧重 |
| --- | --- | --- |
| 厂级(公司级) | 厂安全管理部门 | 国家法律法规、厂级安全制度、事故案例 |
| 车间级(部门级) | 车间/部门 | 车间工艺特点、危险源辨识、车间安全规程 |
| 班组级 | 所在班组 | 岗位操作规程、设备使用、应急处置 |
关键业务约束有几个,它们直接决定了系统设计:
1. **逐级合格、三层联动**:一个人必须厂级合格、车间级合格、班组级合格,才算「三级教育完成」。
2. **证书有有效期**:各级合格证有有效期(如 12 个月),到期需重新考核,所以要记录「通过时间」和「有效期至」。
3. **考题按层级区分**:厂级考题和班组级考题完全不同,题库要能按层级筛选。
4. **组织架构强相关**:员工归属「厂 → 车间 → 班组」,管理员只能看自己管辖范围内的数据。
第 4 条就是「数据权限」的来源 —— 这是本项目权限设计的核心难点。
---
## 三、技术选型
### 后端
| 组件 | 版本 | 用途 |
| --- | --- | --- |
| Spring Boot | 3.2.5 | 基础框架 |
| Java | 17 | 必须 17,SB3 最低要求 |
| MyBatis-Plus | 3.5.7 | ORM + 分页 + 逻辑删除 + 数据权限拦截 |
| jjwt | 0.12.5 | JWT 签发与校验 |
| Spring Security | 6.x(BOM 管理) | 认证授权,无状态模式 |
| Hutool | 5.8.27 | 工具集 |
| Apache POI | 5.2.5 | 试题 Excel 导入导出 |
| MinIO SDK | 8.5.12 | 对象存储(头像、附件) |
| MySQL | 8.0 | 主库 |
### 前端(两套独立工程)
| 组件 | 版本 |
| --- | --- |
| Vue | 3.4.27 |
| Element Plus | 2.7.3 |
| Pinia | 2.1.7 |
| Vue Router | 4.3.2 |
| Vite | 5.2.11 |
| TypeScript | 5.4.5 |
| ECharts | 6.1.0(仅管理端,用于统计图表) |
两个前端构建脚本都是 `vue-tsc --noEmit && vite build`,也就是说**类型检查不通过直接构建失败**,这一条在后面救过我好几次。
---
## 四、整体架构
### 4.1 部署拓扑
```
浏览器
│
┌─────────┴──────────┐
│ │
:81 管理端 :82 学员端
│ │
┌───▼────────────────────▼───┐
│ Nginx(双 server) │
│ / → admin-web/dist │
│ / → student-web/dist │
│ /api/ → 127.0.0.1:8080 │
└────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ Spring Boot hpc-exam │
│ /api/auth/** 公共鉴权 │
│ /api/admin/** 管理端 │
│ /api/student/** 学员端 │
└──┬──────────┬───────────┬────┘
▼ ▼ ▼
MySQL Redis MinIO
(业务库) (缓存/黑名单) (文件)
```
两套前端**共用同一个后端、同一套 `/api` 前缀**,靠 `/api/admin/**` 和 `/api/student/**` 区分。好处是权限模型、异常处理、返回体规范只需写一份;坏处是必须严格约束两者的接口边界,不能互相串用。
### 4.2 Maven 模块划分
后端是一个 7 模块的聚合工程:
```
hpc-exam-server
├── hpc-common 通用层:统一响应体、异常、枚举、常量、工具
├── hpc-system 系统域:组织/用户/角色/菜单/字典
├── hpc-exam 业务域:分类/题库/试卷/考试计划/答卷/错题本/教育档案
├── hpc-framework 框架层:Spring Security + JWT + MyBatis-Plus 配置 + 全局异常
├── hpc-admin-api 管理端接口 /api/admin/** + /api/auth/**
├── hpc-student-api 学员端接口 /api/student/**
└── hpc-boot 启动聚合模块(打成一个可执行 jar)
```
依赖方向是单向的:`boot → admin-api/student-api → framework → system → common`。
这里有个我比较满意的设计取舍:**把「接口层」按使用者拆成两个模块,而不是按业务域拆**。因为是 B 端(管理员)和 C 端(学员)两套完全不同的使用者,它们的鉴权规则、数据可见范围、甚至返回字段都不同。按使用者拆模块,可以很自然地在模块级别约束权限边界 —— `student-api` 里根本不允许出现管理端的接口。
### 4.3 统一约定
- **返回体**:`Result<T>{ code, message, data }`,成功 `code=200`;业务错误码 1001 登录失败、1002 账号禁用、1003 令牌过期、1004 令牌非法、1005 数据不存在、1006 参数错误等。
- **分页**:`PageResult<T>{ list, total, pageNum, pageSize }`,底层走 MyBatis-Plus 分页插件,`maxLimit=500` 防止前端传超大 pageSize 拖垮数据库。
- **全局异常**:`@RestControllerAdvice` 统一兜底。业务异常返回 HTTP 200 + 业务码;参数校验 400;无权限 403;兜底 500。
- **逻辑删除**:全表统一 `deleted` 字段,1 已删 / 0 未删,由 MyBatis-Plus 全局配置接管,业务代码不用手动拼条件。
---
## 五、数据库设计:25 张表与 4 个关键决策
### 5.1 表全景(按业务域分组)
```
系统权限域 (6)
sys_org 组织机构(厂 → 车间 → 班组,含 ancestors 祖级路径)
sys_user 用户/员工
sys_role 角色(含数据范围 data_scope)
sys_user_role 用户-角色
sys_menu 菜单/按钮权限(含 perms 权限标识)
sys_role_menu 角色-菜单
系统配置域 (6)
sys_dict_type / sys_dict_data 字典
sys_config 系统参数(初始密码、默认合格分等)
sys_notice / sys_notice_read 通知公告 + 已读记录
sys_file 文件记录(对应 MinIO 对象)
sys_login_log / sys_oper_log 登录日志 / 操作日志
题库域 (2)
exam_category 安全知识分类(树形,含 edu_level 适用层级)
exam_question 题库
考试域 (5)
exam_paper 试卷(固定/随机/时段三类)
exam_paper_question 试卷-题目关联(固定卷)
exam_plan 考试计划(下发对象、时长、次数、有效期、是否需报名)
exam_room 考场
exam_registration 报名记录(含审核状态、分配考场)
成绩档案域 (4)
exam_record 考试记录/答卷(正式与练习共用,靠 biz_type 区分)
exam_record_detail 答卷明细(题干快照 + 作答)
exam_wrong_book 错题本
edu_record 三级教育档案
```
### 5.2 决策一:登录标识用身份证号,而不是用户名
`sys_user` 有两套唯一键:`username`(内部标识)和 `id_card`(18 位身份证号)。**登录时按 `id_card` 查询**,JWT 里也带 `idCard`。
为什么?因为这是传统制造业现场场景:
- 一线工人可能记不住系统给的账号,但一定记得自己的身份证号;
- 现场核验身份时,身份证号是唯一可信的自然人标识;
- 避免同一个姓名重名造成的账号混乱。
代价是所有涉及用户展示的地方都得注意脱敏,不能把完整身份证号暴露在列表页上。这是个「业务优先」的取舍。
### 5.3 决策二:分数用「整数 ×10」存储
所有分数字段在数据库里都是**整数**,实际值 = 存储值 ÷ 10。
比如 85.5 分,库里存 `855`。
为什么不用 `DECIMAL` 或 `FLOAT`?
- `FLOAT`/`DOUBLE` 有二进制浮点误差,`0.1 + 0.2 != 0.3`,在「总分累加」这种场景下会出现「明明每道题都对,总分却差 0.1」的诡异 bug;
- `DECIMAL` 精度够,但 Java 侧要用 `BigDecimal`,四则运算写法繁琐、容易漏掉 `setScale`。
用整数存 ×10 后,全链路都是 `int` 运算,**没有任何精度风险**。接口层和前端展示时再除以 10。这个约定由一个 `ScoreUtil` 工具类统一收口,DB 字段注释里也写明了。
### 5.4 决策三:数据权限放在「角色」维度,而不是用户维度
`sys_role` 上有个 `data_scope` 字段:
```
1 = 全部数据
2 = 本厂
3 = 本部门及以下
4 = 本部门
5 = 本班组
6 = 仅本人(默认)
```
一个人可能有多个角色,最终数据范围取**数值最小者**(1 最宽)—— 也就是「权限叠加取最宽」,符合直觉。
配合 `sys_org.ancestors`(祖级路径,形如 `0,1,5`)就能用一条 `IN` 查询把「本部门及以下」的所有人捞出来,而不需要递归。
### 5.5 决策四:三级教育用「枚举贯穿 + 三组状态字段」
`edu_level` 这个枚举贯穿了 7 张表:
```
0 通用 / 1 厂级 / 2 车间级 / 3 班组级
```
`sys_org`、`sys_user`、`exam_category`、`exam_question`、`exam_paper`、`exam_plan`、`exam_record` 上都有这个字段。这样「按层级筛题」「按层级下发计划」都是一句 SQL 的事。
而**「三层联动」这个特性,用另一张表 `edu_record` 承载** —— 它不是用一条记录 + 一个状态表示,而是用三组字段:
```sql
plant_status / plant_pass_time / plant_expire_time -- 厂级
dept_status / dept_pass_time / dept_expire_time -- 车间级
team_status / team_pass_time / team_expire_time -- 班组级
overall_status -- 三级全通过才置 1
overall_expire_time -- 取三级中最早到期时间
```
之所以不用「一条记录一个层级」的从表设计,是因为这三级的**读取模式永远是「一次性把某人的三级状态全查出来」**(教育档案页就是三行并排展示)。用三组字段做宽表,查档一次查询搞定,不用 join,也不用在应用层做聚合。
---
## 六、核心模块实现
### 6.1 双维度权限体系
这是整个系统最核心的设计。权限被拆成两个完全独立的维度:
| 维度 | 解决的问题 | 生效层次 | 数据来源 |
| --- | --- | --- | --- |
| **功能权限**(菜单权限) | 你能不能点这个按钮 / 调这个接口 | 方法级 | `sys_menu.perms` |
| **数据权限** | 你能看到哪些人的数据 | SQL 层 | `sys_role.data_scope` |
**功能权限**用 Spring Security 6 的 `@PreAuthorize` 实现:
```java
@PreAuthorize("@ss.hasPermi('exam:record:judge')")
@PostMapping("/judge")
public Result<Void> judge(@RequestBody JudgeDTO dto) { ... }
```
`@ss` 是一个注册为 `ss` 的 SpEL bean,内部校验当前登录用户的权限集合。超管角色持有通配权限 `*:*:*`,直接放行。权限标识由 `sys_menu.perms` 维护,配合菜单表实现「接口权限」和「前端按钮显隐」的同源控制。
**数据权限**用 MyBatis-Plus 的 `DataPermissionInterceptor` 在 SQL 层拦截:
```java
// 注册时注意:数据权限拦截器必须装在分页拦截器之前
interceptor.addInnerInterceptor(new DataPermissionInterceptor(new HpcDataPermissionHandler()));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
```
`HpcDataPermissionHandler` 里用一张「受控表 → 过滤列」的映射表集中声明:
```java
// 受控表 + 组织列 + 本人列
Map.of(
"sys_user", new TableScope("org_id", "id"),
"exam_record", new TableScope("org_id", "user_id"),
"edu_record", new TableScope("org_id", "user_id"),
"exam_registration",new TableScope("org_id", "user_id")
)
```
只有这 4 张表会被注入数据范围条件,其余表不受影响。注入规则:
- 超管 / `data_scope=1`(全部)/ 未登录 → 不加任何条件
- `data_scope=6`(仅本人)→ 按 `user_id = 当前用户`
- 其余(本厂/本部门/本班组)→ 按组织 ID 集合 `org_id IN (...)`;若该表没有组织列,则退化成子查询
这里有个必须留的口子:像「登录时按身份证查用户」这种**必须跨组织查询**的场景,如果被数据权限拦掉就登录不了了。所以做了一个基于 `ThreadLocal` 的显式忽略开关:
```java
DataScopeHelper.runIgnore(() -> sysUserService.getByIdCard(idCard));
```
**为什么不用注解式的数据权限**(很多开源框架用 `@DataScope` 注解)?因为注解式的表达能力有限,遇到「按组织集合过滤」时还是得在 XML 里手写一坨 SQL。而拦截器方案是「表级声明 + 统一注入」,加一张受控表只需要改一行配置。
### 6.2 题库与试题导入
题库核心表 `exam_question` 的字段:题型、分类、适用层级、难度、分值、题干、选项(JSON)、答案、解析、出处。
**题型枚举**:1 单选 / 2 多选 / 3 判断 / 4 填空 / 5 简答。其中 4、5 为主观题,需要人工阅卷。
选项不建从表,而是序列化成 JSON 存一列。原因是选项的读取模式永远是「跟着题目一起读出来」,从不单独查询或统计,独立成表反而增加无效 join。
**Excel 导入**(POI)规定了 15 列固定顺序:
```
1 题型 2 分类名称 3 适用层级 4 难度 5 分值
6-11 选项A~选项F(6 列)
12 正确答案 13 解析 14 出处
```
几个工程上的细节:
- 只读**第一个工作表**(`workbook.getSheetAt(0)`),避免多 sheet 带来的歧义;
- **分类名称必须命中库中已有分类**,否则该行导入失败 —— 不做「自动建分类」,防止脏数据;
- 解析尽量宽容:题型既接受「单选」「单选题」也接受数字码 `1`;难度留空默认 3;层级留空默认 0(通用);
- 逐行解析、逐行报错,返回 `{successCount, failCount, errors}`,**不让一行错误导致整批失败**。
这种「宽容输入、严格校验关键字段」的策略,是在实际导入现场数据后调出来的 —— 一线管理员填的 Excel 千奇百怪,如果严格匹配字符串,基本没法用。
### 6.3 组卷:固定卷与随机卷
试卷分两类,实现差异很大:
**固定卷**:组卷时把题目 ID 落库到 `exam_paper_question`,考试时直接按关联表取题。
**随机卷**:试卷只存一份**抽题规则 JSON**(存在 `exam_paper.random_rule`),规则由若干条规则项组成:
```json
[
{ "categoryId": 3, "questionType": 1, "eduLevel": 1, "difficulty": 3, "count": 20, "score": 2 },
{ "categoryId": 3, "questionType": 2, "eduLevel": 1, "difficulty": 4, "count": 10, "score": 4 }
]
```
每条规则项支持 4 个筛选维度(分类 / 题型 / 适用层级 / 难度)+ 题量 + 分值。**考试开始时才实时抽题**,所以每个考生拿到的卷子都不一样。
抽题直接用 MySQL 随机:
```java
@Select("""
<script>
SELECT * FROM exam_question
WHERE deleted = 0 AND status = 1
<if test="categoryId != null"> AND category_id = #{categoryId} </if>
<if test="questionType != null"> AND question_type = #{questionType} </if>
<if test="eduLevel != null"> AND edu_level = #{eduLevel} </if>
<if test="difficulty != null"> AND difficulty = #{difficulty} </if>
ORDER BY RAND() LIMIT #{count}
</script>
""")
List<ExamQuestion> randomSelect(@Param("categoryId") Long categoryId,
@Param("questionType") Integer questionType,
@Param("eduLevel") Integer eduLevel,
@Param("difficulty") Integer difficulty,
@Param("count") Integer count);
```
`ORDER BY RAND()` 在大表上有性能问题(MySQL 会对全表生成随机值再排序)。本项目题库量级在万级以内,实测可接受,所以选了最简单可靠的方案。如果题库涨到百万级,就需要改成「先随机取 ID 区间再查询」的两段式。这是**一个明确的、可接受的取舍**:不过早优化,但记录清楚瓶颈在哪。
### 6.4 考试流程与状态机
`exam_record` 一张表承载正式考试和练习,靠 `biz_type` 区分(1 正式 / 2 练习)。状态字段有四个:
```
biz_type 1 正式考试 / 2 模拟练习
status 1 待批改 / 2 已完成
pass_status 0 不合格 / 1 合格
valid_status 1 有效 / 0 已过期
```
`exam_plan` 侧还有 `publish_status`:`0 草稿 / 1 已发布 / 2 已结束`。
**开考**(`startExam`)做的事情比想象中多:
1. 校验计划已发布、当前时间在考试时间窗内;
2. 校验该学员是否「已合格且证书仍在有效期内」—— 有效期内不允许重复考;
3. 校验剩余考试次数;
4. 生成 `exam_record`;
5. **落题目快照**:`exam_record_detail` 里存的不是「题目 ID 引用」,而是**题干、选项、正确答案的完整快照**。
第 5 点是一个刻意的设计。原因是:考试过程中管理员可能修改题库(比如修正一个错别字、甚至改答案)。如果答卷只存题目 ID,那么改题会**污染历史答卷**——学员昨天答的卷子,今天看到题干变了甚至判分变了。存快照后,答卷与题库解耦,历史数据永远反映当时的真实情况。
**交卷**(`submitExam`)流程:
```
写回作答 → 逐题 autoJudge 自动判分 → aggregate 汇总
├─ 含主观题(填空/简答)→ status 保持 1「待批改」
└─ 全客观题 → 直接 settle 结算
```
**练习模式**和正式考试**共用同一张表、共用大部分判分逻辑**,但有关键差异:
| 项 | 正式考试 | 模拟练习 |
| --- | --- | --- |
| `biz_type` | 1 | 2 |
| 关联考试计划 | 有(`plan_id`) | 无(`plan_id = null`) |
| 次数/有效期校验 | 有 | 无 |
| 倒计时依据 | 计划时长 + 开考时间 | 试卷建议时长(写入 `expire_time`) |
| 是否回填错题本 | 是 | 否 |
| 是否写教育档案 | 是 | 否 |
| 是否计有效期 | 是 | 否 |
用代码分支而不是两张表,好处是判分、汇总、明细快照这些复杂逻辑只写一遍;代价是每个方法里都要留意 `practice` 这个布尔分支 —— 后面第 7.6 个坑就出在这里。
### 6.5 判分、阅卷与成绩回填
**客观题自动判分**(`autoJudge`):
- 单选、判断:归一化后比较。判断题做了容错,`对/√/true/T` 都算对,`错/×/false/F` 都算错;
- 多选:按选项字母集合**全等**才得分(不支持「少选得部分分」,这是产品明确要求);
- 填空、简答:`doRight` 置 `null`,标记为待批改。
**主观题人工阅卷**:
```java
@PreAuthorize("@ss.hasPermi('exam:record:judge')")
@PostMapping("/api/admin/record/judge")
public Result<Void> judge(@RequestBody JudgeDTO dto)
```
管理端有「待批改」列表,过滤条件是 `status = 1`(待批改)。阅卷时逐题给分,给完最后一题系统调用 `settleAndBackfill` 结算。
**成绩结算与回填**(`settleAndBackfill`)是整个流程的收口,做四件事:
1. 判合格:`user_score >= paper.pass_score`;
2. 算有效期:`expire_time = 合格时间 + valid_months`;
3. 回填**错题本**:把答错的题写入 `exam_wrong_book`;
4. 回填**三级教育档案**:按 `edu_level` 把通过状态写到 `edu_record` 对应的 plant/dept/team 字段,并重算 `overall_status` 和 `overall_expire_time`。
第 4 步就是三级教育「逐级联动」的落地点 —— 学员考过厂级,`plant_status` 置 2;三级全过,`overall_status` 才置 1。
### 6.6 文件存储:MinIO + 匿名只读策略
文件(头像、附件)上传到 MinIO,对象 key 规则是 `yyyy/MM/dd/{uuid}.{ext}`。
这里有个实用的小设计:**bucket 自动创建并设置为匿名只读**。
```java
// 上传前确保 bucket 存在,并策略允许 s3:GetObject
ensureBucket(); // 双重检查锁 + bucketReady 标志,避免并发重复创建
setBucketPolicy(publicReadPolicy());
```
普通 MinIO 用法是「私有桶 + 每次访问签临时 URL」,但那样前端 `<img src>` 就得每次调接口换签名 URL,体验和实现都复杂。因为本项目存的是**非敏感文件**(头像、公告附件),所以直接开匿名只读,上传后返回的 URL 前端可以直接用。
配置上有个容易踩的点:`endpoint` 是**后端访问 MinIO 的地址**(可以是内网/127.0.0.1),而 `public-endpoint` 是**浏览器访问的地址**,返回给前端的 URL 必须用后者。生产环境这两个地址通常不同(后端走内网,浏览器走公网 IP 或域名),配错就会导致「上传成功但图片显示不出来」。
---
## 七、6 个真实踩坑复盘
这一章是全文最有价值的部分。以下每一个都是真实上线后遇到的问题,我会按「现象 → 排查 → 根因 → 修复 → 沉淀」讲清楚。
### 坑 1:全局序列化策略改了,前端判空逻辑集体失效
**现象**:学员端「我的考试」列表里,该显示「报名」按钮的地方,按钮**完全不渲染**。但后端接口返回的数据看着是对的。
**排查**:前端这个按钮的显示条件是:
```ts
// 直觉上没问题,实际是错的
v-if="row.registerStatus === null"
```
打断点发现 `row.registerStatus` 是 `undefined`,不是 `null`。继续追,发现后端**根本没返回这个字段**。
**根因**:项目早期把全局 Jackson 配置设成了「只输出非空字段」:
```yaml
spring:
jackson:
default-property-inclusion: non_null # 值为 null 的字段直接不输出
```
于是当 `registerStatus` 为 `null` 时,这个 key 在 JSON 里**完全消失**。Jackson 反序列化到 JS 对象后,访问不存在的属性得到的是 `undefined`。
而 JS 里 `undefined === null` 是 **`false`** —— 所以判断永远不成立,按钮永远不渲染。
**修复**:两处一起改。
1. 全局配置改为输出全部字段:
```yaml
spring:
jackson:
default-property-inclusion: always
```
2. 前端统一改用**宽松相等**,兼容 `null` 和 `undefined` 两种情况:
```ts
// ✅ 宽松相等同时匹配 null 和 undefined
v-if="row.registerStatus == null"
```
如果必须用严格相等,就得写 `=== null || === undefined`,冗长且容易漏。
**沉淀**:「接口字段的『缺失』和『为 null』在 JS 里是两种不同的东西」这件事,必须全组形成肌肉记忆。任何「某个 key 可能不存在」的场景,前端一律用宽松相等。同时约定:**业务字段一律输出,不做非空裁剪** —— 省那点流量换来的是一堆难查的 bug。
### 坑 2:JWT 没有 jti,导致同秒重新登录被自己拉黑
**现象**:用户登出后立刻重新登录(1 秒内),新登录被系统拒绝,提示「令牌已失效」。
**排查**:这个 bug 复现条件很苛刻 —— 必须**同一个人、在同一秒内、先登出再登录**。日志里看到新签发的 token 被判定为黑名单中的 token。
**根因**:原来的 JWT builder 没设置 `jti`(JWT ID):
```java
// 有问题的写法:没有 jti
return Jwts.builder()
.issuer(JWT_ISSUER)
.subject(String.valueOf(userId))
.claim(CLAIM_USER_ID, userId)
.issuedAt(new Date(now)) // 精度到秒
.expiration(expiration)
.signWith(secretKey)
.compact();
```
问题在于 JWT 的 `iat` / `exp` 精度只到**秒**。同一个用户、同一秒内签发两次,Payload 里的所有 claim **完全一样**,用同一个密钥签名后,**生成的 token 字符串逐字节相同**。
于是:登出时把这个 token 字符串写进 Redis 黑名单 → 同秒重新登录签发的 token 字符串**一模一样** → 过滤器一查黑名单,命中,直接判定为已失效令牌。
**修复**:给 token 加唯一 ID:
```java
return Jwts.builder()
.id(IdUtil.fastSimpleUUID()) // ✅ jti:保证每次签发都唯一
.issuer(SecurityConstants.JWT_ISSUER)
.subject(String.valueOf(loginUser.getUserId()))
.claim(SecurityConstants.CLAIM_TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_ACCESS)
.claim(SecurityConstants.CLAIM_USER_ID, loginUser.getUserId())
.claim(SecurityConstants.CLAIM_USERNAME, loginUser.getUsername())
.claim(SecurityConstants.CLAIM_ID_CARD, loginUser.getIdCard())
.claim(SecurityConstants.CLAIM_REAL_NAME, loginUser.getRealName())
.issuedAt(new Date(now))
.expiration(expiration)
.signWith(secretKey)
.compact();
```
`jti` 是 JWT 标准里的「令牌唯一标识」,用处之一就是**让每个 token 可被单独标识和吊销**。这里正好用上。
**沉淀**:凡是「用 token 字符串本身作为唯一标识」的场景(黑名单、单点登录、令牌轮换),都必须先保证 token 本身唯一 —— 也就是必须有 `jti`。这个坑非常隐蔽,因为它在 99% 的情况下都不会触发,只在时间精度边界上偶发。
### 坑 3:路由守卫只校验 token,硬刷新后顶栏显示「未登录」
**现象**:学员登录后,在非首页(比如「错题本」)按下 F5 刷新,页面能正常打开,但顶栏的用户名变成了「未登录」。
**排查**:从首页点进「错题本」是一切正常的,只有**刷新**才出问题。所以问题一定出在「刷新」这个动作的差异上。
**根因**:前端路由守卫里只做了「有没有 token」的校验:
```ts
// 有问题的写法:只判断 token 存在
router.beforeEach((to, from, next) => {
const token = getToken()
if (token) {
next()
} else {
next('/login')
}
})
```
而用户信息(`userStore.userInfo`)是存在 **Pinia 内存**里的,刷新页面等于重置 JS 运行时,内存里的用户信息就没了。守卫看到 token 存在就放行,页面渲染时读 `userInfo` 拿到空值,顶栏自然显示「未登录」。
从首页进入不出问题,是因为那时候 `userInfo` 还在内存里。
**修复**:在守卫里补上「token 有但用户信息为空 → 先拉取用户信息」:
```ts
router.beforeEach(async (to, from, next) => {
const token = getToken()
if (!token) {
return next('/login')
}
const userStore = useUserStore()
// ✅ 刷新后 store 被重置,需要重新拉取用户信息
if (!userStore.userInfo) {
try {
await userStore.fetchUserInfo()
} catch (e) {
await userStore.logout()
return next('/login')
}
}
next()
})
```
**沉淀**:「状态放在内存里,就会在刷新时消失」是 SPA 的基本常识,但很容易被忽略,因为开发时通常是从首页一路点进去的。**测试用例里必须包含「在非首页硬刷新」这一条**。
### 坑 4:随机组卷条件过严,生成了 0 道题的空卷
**现象**:某张试卷,学员点「开始考试」后进入答题页,**一道题都没有**,直接就是一张空白卷。
**排查**:查这张试卷的抽题规则:
```json
{ "categoryId": 5, "questionType": 1, "eduLevel": 3, "difficulty": 4, "count": 20 }
```
这四个维度同时生效。然后去题库里按这四个条件查:
```sql
SELECT COUNT(*) FROM exam_question
WHERE deleted = 0 AND status = 1
AND category_id = 5 AND question_type = 1
AND edu_level = 3 AND difficulty = 4;
-- 结果:0
```
**根因**:抽题规则的 4 个维度是** AND 关系**。管理端配置时同时勾了「分类 + 层级 + 难度」三个限制,而题库里恰好没有**同时满足这三个条件**的题。
但问题不只是「配错了规则」—— 更深层的问题是:**系统在配置阶段完全没有给管理员任何反馈**。管理员配完规则、发布试卷、下发计划,一路畅通,直到学员点开考试才发现是空卷。这个反馈链条太长了。
**修复**:分三层治理。
1. **考试开始时兜底**:抽到的题目数 < 规则要求数,直接报错并给出明确提示,而不是静默生成空卷。
2. **试卷预览时展示实际命中数**:管理端配置完规则后,预览按钮会**真实执行一次抽题**,把「每个规则项命中多少题」显示出来。管理员当场就能看到「这条规则只命中 3 题,但你要求 20 题」。
3. **随机卷发布前强制试抽校验**(最关键的一层护栏):
```java
// 发布考试计划前,先按规则试抽一次,命中不足直接拒绝发布
private void validateRandomPaperAvailable(ExamPlan plan) {
if (isRandomPaper(plan)) {
List<ExamQuestion> picked = examPaperService.randomPickQuestions(plan.getRule());
if (picked.size() < plan.getRequiredCount()) {
throw new BusinessException("抽题规则命中题目不足,无法发布:" +
"需要 " + plan.getRequiredCount() + " 题,实际命中 " + picked.size() + " 题");
}
}
}
```
这样错误会在**配置阶段**就被拦住,而不是等到学员端爆出来。
**沉淀**:凡是「A 阶段配置、B 阶段才暴露问题」的链路,都要在 A 阶段加**前置校验**。尤其是「配置自由度高的规则引擎」,用户的组合方式千变万化,系统必须主动告诉他「你这么配,结果是什么」。**把错误提前到配置期**,是这类系统可用性的关键。
### 坑 5:合格分大于总分,配置出一个永远不及格的考试
**现象**:某次考试,全班学员考完,**没有一个人合格**。而且分数看上去都正常。
**排查**:查这张试卷的配置:
```
总分:60
合格分:100
```
**根因**:管理员在配置试卷时,把合格分填成了 100,而这张卷子的总分只有 60。系统保存时**没有任何校验**,直接把配置落库了。结果就是 `user_score >= pass_score` 这个判断永远不成立 —— 一张永远不可能及格的试卷。
**修复**:在后端保存试卷时增加校验:
```java
if (passScore > totalScore) {
throw new BusinessException("合格分不能大于总分(总分 " + totalScore + ",合格分 " + passScore + ")");
}
```
注意校验要放在**后端**,而不是只做前端表单校验。前端校验只是体验优化,后端的**领域约束必须由后端自己守住** —— 因为接口是可以被直接调用的。
**沉淀**:这是典型的「**语义非法但语法合法**」的配置。`passScore = 100, totalScore = 60` 在类型上完全合法,两个都是正整数,数据库也不会报错,但它在业务上是荒谬的。这类约束必须在领域层显式表达。**凡是「两个字段之间存在业务关系」的地方,都要问一句:要不要加个校验?**
### 坑 6:练习模式没下发剩余时长,前端按 0 处理导致「一进去就自动交卷」
**现象**:模拟练习功能,学员点「开始练习」,页面刚打开就**立刻自动交卷**了。
**排查**:前端倒计时逻辑是「剩余秒数 ≤ 0 就自动提交」:
```ts
// 有问题的写法:把 undefined / null 当 0 处理
const remainSeconds = res.data.remainingSeconds
startCountdown(remainSeconds ?? 0) // undefined → 0 → 立即交卷
```
而练习模式下,后端返回的 `remainingSeconds` 是 **`undefined`(字段缺失或为 null)**,因为练习没有关联考试计划,后端没算这个值。
被 `?? 0` 一兜底,就变成了「剩余 0 秒」,倒计时立刻归零,触发交卷。
正式考试不受影响,因为正式考试一定会算剩余时长。
**根因**:两层问题叠加:
1. **后端**:练习模式没有写入「截止时间」,所以算不出剩余时长,返回空值;
2. **前端**:把「没有限制」**错误地**当成了「限制为 0」。这两者语义完全相反,但都用「空值」表示了。
**修复**:
1. **后端**:练习开始时按试卷的建议时长写入 `expire_time`(截止时间),并提供一个「按截止时间计算剩余秒数」的方法:
```java
// 练习模式:按试卷建议时长计算截止时间
record.setExpireTime(LocalDateTime.now().plusMinutes(paper.getSuggestTime()));
```
2. **前端**:把「空值」和「0」区分开 —— **只有后端明确返回 0 才启动倒计时并自动交卷,返回空值视为不限时**:
```ts
// ✅ 类型改为可为空,语义明确
const remainSeconds = res.data.remainingSeconds as number | null
if (remainSeconds === 0) {
// 明确为 0:立即交卷
handleSubmit(true)
} else if (remainSeconds != null && remainSeconds > 0) {
// 正常倒计时
startCountdown(remainSeconds)
} else {
// 空值:不限时,不启动倒计时
}
```
**沉淀**:这是「**用同一个值表达两种语义**」造成的经典问题。`null` / `undefined` 到底表示「未知」「没有限制」还是「零」,必须在接口契约里写清楚。**默认值兜底(`?? 0`)是个危险动作** —— 它会把「缺失」悄悄变成「具体值」,从而把一个「显示问题」升级成「功能错误」。
---
## 八、部署形态
说明一下部署方式(不涉及具体环境信息):
- **不使用 Docker**,裸机部署 + systemd 托管,便于现场运维人员排查;
- 中间件(JDK、MySQL、Redis、MinIO、Nginx)**全部自包含在一个部署目录下**,程序、数据、日志、socket 都不散落到系统目录 —— 好处是迁移和清理都只需操作一个目录,也不会和机器上已有的服务冲突;
- **后端用外置配置 + profile 覆盖**:打包内的 `application.yml` 激活的是 `dev`,生产启动时用 `--spring.profiles.active=prod` 覆盖,并把 `application-prod.yml` 放在 jar 同级目录。外置配置优先级高于 classpath,所以**不改源码、不重新打包**就能换环境;
- **前端双站点**:Nginx 两个 `server` 块,分别指向 `admin-web/dist` 和 `student-web/dist`,两者都把 `/api/` 反代到后端,SPA 路由用 `try_files ... /index.html` 兜底。
这里有个细节值得单独提:**后端端口不要用 8080**。很多环境里 8080 会被别的进程占用(我们这次部署就撞上了),换一个不常用的端口能省掉一堆「服务起不来但日志没报错」的排查时间。
---
## 九、不足之处与改进方向
做完之后复盘,有几个明确的不足:
**1. 超时交卷由前端驱动,没有后端兜底**
当前实现是前端倒计时归零后调交卷接口,**后端没有定时任务**。这意味着:
- 学员关掉页面不交卷 → 答卷一直挂着;
- 学员改系统时间 → 倒计时可能被绕过。
更稳的做法是加一个后端定时任务,扫描已过截止时间且未提交的答卷,强制结算。当时的取舍是「现场考试有监考老师,且不希望引入额外的调度复杂度」,但这确实是**一个应该补上的短板**。
**2. `ORDER BY RAND()` 的性能上限**
如 6.3 所述,题库量级上万之后抽题会明显变慢。改进方向是「按 ID 区间随机取再筛选」或维护一张随机序列表。目前量级够用,但需要记录在案。
**3. 主观题判分完全依赖人工,缺少辅助**
填空、简答现在只能人工逐题给分。可以引入「关键词匹配自动预判 + 人工复核」,减轻阅卷压力。
**4. 数据权限的受控表需要手工登记**
新增一张需要数据隔离的表时,必须记得去 `HpcDataPermissionHandler` 的映射表里加一行。漏加就是**越权**。这个「必须记得」是个隐患,更好的做法是在表上加标记、由代码自动识别。
---
## 十、总结
回头看不难发现,这个项目的技术难点**不在技术栈本身**,而在于三件事:
**第一,业务模型要抽象准。**
三级安全教育的「逐级合格 + 三层联动 + 有效期」如果用传统的主从表设计,读取时到处 join;用宽表 + 三组状态字段,一次查询搞定。**表结构的设计应该跟着「读取模式」走,而不是跟着「实体关系」走。**
**第二,权限要拆成两个维度。**
「你能干什么」和「你能看谁的数据」是两件完全不同的事。前者用 `@PreAuthorize` 做方法级校验,后者用 MyBatis 拦截器做 SQL 层过滤。混在一起做,最后一定是一团乱麻。
**第三,也是最重要的:错误要尽量提前暴露。**
复盘那 6 个坑,有 3 个本质上都是同一个问题 —— **问题暴露得太晚**:
- 空卷(坑 4):配置时不说,学员开考才知道;
- 合格分大于总分(坑 5):保存时不校验,考完才发现全班不及格;
- 判空失效(坑 1):接口契约不清,UI 静默出错。
我们后来做的三道护栏 —— **组卷规则预览显示实际命中题数、随机卷发布前强制试抽、合格分不能大于总分的后端校验** —— 本质上都是同一件事:**把错误从「运行期」提前到「配置期」**。
一个系统好不好用,往往不取决于功能做了多少,而取决于**用户犯错时,系统多快能告诉他**。
---
*如果这篇文章对你有帮助,欢迎点赞收藏。关于权限设计、随机组卷或考试系统实现有任何问题,欢迎评论区交流。*
---