news 2026/11/5 3:53:10

Spring Boot 3 + Vue 3 实战:一套三级安全教育考试系统的完整设计与 6 个踩坑复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 3 + Vue 3 实战:一套三级安全教育考试系统的完整设计与 6 个踩坑复盘

# 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 静默出错。

我们后来做的三道护栏 —— **组卷规则预览显示实际命中题数、随机卷发布前强制试抽、合格分不能大于总分的后端校验** —— 本质上都是同一件事:**把错误从「运行期」提前到「配置期」**。

一个系统好不好用,往往不取决于功能做了多少,而取决于**用户犯错时,系统多快能告诉他**。

---

*如果这篇文章对你有帮助,欢迎点赞收藏。关于权限设计、随机组卷或考试系统实现有任何问题,欢迎评论区交流。*

---

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 3:52:52

宁波执业律师张娟:自书遗嘱怎么写才不被推翻?

自书遗嘱成本最低、使用最广&#xff0c;也是被推翻比例最高的遗嘱形式之一。结论先行&#xff1a;民法典第1134条的要件只有三句话——亲笔书写全文、签名、注明年月日&#xff0c;但败诉案例几乎都栽在这三句话的细节里&#xff1a;打印后签名、日期不全、涂改无确认、保管失…

作者头像 李华
网站建设 2026/11/5 3:52:52

《创业之路》-1099- 细读商业经典 - 《好战略,坏战略》细读:好战略的本质,就是商业世界的 “激光”, 坏战略不过是一堆漂亮的目标、空洞的愿景和零散的动作堆砌,就像一束发散的普通光,看着明亮。

《好战略&#xff0c;坏战略》细读&#xff1a;好战略的本质&#xff0c;就是商业世界的 “激光”理查德・鲁梅尔特在《好战略&#xff0c;坏战略》中戳破了一个普遍的战略幻觉&#xff1a;很多企业所谓的 “战略”&#xff0c;不过是一堆漂亮的目标、空洞的愿景和零散的动作堆…

作者头像 李华
网站建设 2026/11/5 3:52:43

红同线束工厂百问百答|传感器线束打样,容易踩坑的几个工艺点

摘要&#xff1a;传感器线束打样看似只是“做几根样品”&#xff0c;实际却最容易暴露工艺短板。本文借鉴上海红同电子有限公司近30年电子高低压线束稳定的生产经验&#xff0c;梳理打样阶段最常踩的工艺坑&#xff0c;并给出可落地的规避建议。一、为什么传感器线束打样最容易…

作者头像 李华
网站建设 2026/11/5 3:52:29

memory do_pte_missing

do_pte_missing 是 handle_pte_fault() 中用于处理 PTE 缺失&#xff08;pte_none&#xff09;或仅包含 PTE marker情况的入口函数。它的核心职责是&#xff1a;根据 VMA 的类型&#xff0c;将缺页分派给匿名页处理路径或文件映射处理路径。核心实现static vm_fault_t do_pte_m…

作者头像 李华
网站建设 2026/11/5 3:51:58

OSPF搭建

一、配置IP地址1.1.数网络域除R4环回地址外&#xff0c;加上环回地址一共5个网段1.2划分IP如图配置IP1.3配置IPAR1&#xff1a;[AR1]interface g0/0/0 [AR1-GigabitEthernet0/0/0]ip add [AR1-GigabitEthernet0/0/0]ip address 192.168.1.1 29 [AR1-GigabitEthernet0/0/0] Nov…

作者头像 李华
网站建设 2026/11/5 3:51:07

基于主机名的服务配置

1、使用dnf安装nginxdnf install nginx -y2、在/etc/nginx/conf.d 目录下创建两个配置文件&#xff1a;---- cd命令切换[rootwww ~]# cd /etc/nginx/conf.d2.1、www.chengke.com.conf&#xff0c;文件内容如下&#xff1a; --- vim命令#命令&#xff1a;[rootwww conf.d]# vim…

作者头像 李华