Files
railwaysystem/RuoYi-Vue/doc/外部实训系统数据接口设计(V1.0).md
T

919 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 铁路房建学员管理系统 · 外部实训系统数据接口设计
> 文档类型:接口与数据表设计(本阶段不含代码实现)
> 依据文档:《铁路房建学员管理系统-外部实训系统数据接口规范》(文内版本 V1.2,文件标题 V1.0,5 个接口)
> 适用系统:VR 实训 / 3D 巡检 / 实操实训 / 帽石考核 / 其他实训考核系统
> 设计范围:平台侧(学员管理系统)对外提供的 5 个接口所需的数据表、约束、接口契约与实现方案
> 技术基线:RuoYi-Vue(Spring Boot 4.1 / Java 17 / MyBatis / Redis / PageHelper),数据库 **MySQL 5.7**
> 审查状态:已完成框架与数据库兼容性审查,结论与修订建议见第 11 章
---
## 1. 业务场景与功能范围
### 1.1 业务场景
外部实训系统(VR、3D 巡检、实操、帽石考核等)在实训考核过程中产生成绩,需要:
1. 考前从平台拉取当期在训学员名单,作为本地考核名单(**不得自行新增学员**);
2. 考核结束后实时上报成绩,或按周期(默认 30 分钟)批量补报;
3. 上报失败时按 1/5/15 分钟重试,仍失败转本地缓存,恢复后以 `resend=true` 补传(窗口 90 天);
4. 通过对账接口查询逐条上报结果,处理"学员无法匹配(待认领)""已归档锁定"等异常。
平台侧承担:学员与班级主数据、考试科目字典、成绩接收与匹配、成绩归集与归档、应用授权与配额管理。
### 1.2 功能范围
| 编号 | 功能 | 对应接口 | 平台侧职责 |
|---|---|---|---|
| F1 | 应用登录换取令牌 | 3.1 `POST /api/open/v1/auth/token` | 校验 appId/appSecret,签发 7200s 令牌,旧令牌立即失效 |
| F2 | 在训学员查询 | 3.2 `GET /api/open/v1/student/list` | 按班级过滤、分页返回在训学员,敏感字段掩码 |
| F3 | 考试成绩批量上报 | 3.3 `POST /api/open/v1/exam/report` | 整批校验、整批写入,按 `batchId`/`reportId` 幂等,返回汇总 |
| F4 | 实时成绩上报 | 3.4 `POST /api/open/v1/exam/report/realtime` | 1–20 条即时接收,按 `reportId` 幂等,返回汇总 |
| F5 | 上报结果查询 | 3.5 `GET /api/open/v1/exam/result` | 按 batchId / reportId / 时间窗查询逐条状态,用于对账 |
**不在本期范围**:外部系统的账号体系与页面、考核过程数据(题目、操作轨迹)解析、成绩的人工评分、外部系统的本地缓存实现。平台仅原样存储 `content` JSON,不解析业务字段。
### 1.3 设计依据的业务约束(来自规范,必须满足)
| # | 约束 | 设计落点 |
|---|---|---|
| C1 | 平台为服务端,全部调用由外部系统发起 | 仅提供出站(平台对外)HTTP 接口,无回调 |
| C2 | 一个外部系统一个 `appId`;多实例共用一个 `appId`、一个令牌、一份配额 | 令牌以 `appId` 为粒度唯一(新令牌使旧令牌失效);限流按 `appId` 计数 |
| C3 | 学员身份以平台 `studentId` 为准 | 匹配键固定为 `train_student.s_id`;名单接口返回该值 |
| C4 | 报文 UTF-8、`application/json;charset=UTF-8`,单条 ≤ 1 MB | 请求体大小网关 + 应用双层校验 |
| C5 | 批量 ≤ 200 条/批,实时 ≤ 20 条/次 | 请求体数组长度校验 |
| C6 | 调用频率 ≤ 20 次/秒/appId,超限 3002 | Redis 滑动窗口限流 |
| C7 | 除 3.1 外均需 `Authorization: Bearer {accessToken}`,有效期 7200 秒,剩余 10 分钟重新登录 | 令牌 TTL 7200s;响应头回传剩余有效期 |
| C8 | 时间一律 int64 毫秒时间戳(东八区),禁止格式化字符串 | 入参校验为 long;内部换算为 `TIMESTAMP` 存储 |
| C9 | `reportId` 永久幂等,重复上报忽略、保留首次内容 | `report_id` 全局唯一 + 幂等处理 |
| C10 | 同一 `batchId` 重复提交返回首次结果 | `(app_id, batch_id)` 唯一 + 首次汇总回放 |
| C11 | 补传窗口 90 天,`resend=true` | 时间窗校验分支 |
| C12 | 实时上报时间窗:`examEndTime ≤ now+10min` 且 `≥ now-24h`;`resend=true` 放宽至 90 天 | 校验分支(码 3004) |
| C13 | 批量时间窗:每条 `examEndTime ∈ [periodStart, periodEnd)`,单次考试跨度 ≤ 24h | 校验(码 3004) |
| C14 | 分数 `0 ≤ score ≤ fullScore`,`fullScore` 默认 100,两位小数 | 校验 + `NUMERIC(7,2)` |
| C15 | `retake=true` 时 `retakeNo` 必填且从 1 递增 | 校验(码 3001) |
| C16 | `content` 必须为 JSON **对象**且 ≤ 32 KB,不得含身份证/手机号/人脸 | 对象校验 + 体积校验(3005)+ 敏感信息扫描 |
| C17 | 平台是成绩归集与归档方,成绩状态 RECEIVED → ARCHIVED,可 LOCKED | `score_status` 状态机 + 归档任务 |
| C18 | `matchStatus=UNMATCHED` 需平台管理员认领后转 MATCHED | 待认领池 + 认领操作(后台) |
| C19 | 业务结果用 `code` 表达,不依赖 HTTP 状态码 | 业务响应统一 HTTP 200 + code |
### 1.4 待评审确认的假设
| # | 事项 | 本设计取值 | 说明 |
|---|---|---|---|
| A1 | "在训"口径 | `train_enrollment.enrollment_status ∈ (1 已报到, 2 请假)` 且学员 `del_flag='0'`、`status=1` | 已结业(4)、退训(3)、待报到(0) 不返回;如需含待报到,仅调整白名单 |
| A2 | `studentNo` 来源 | `train_student.person_no`,为空回退 `s_id` | 平台无独立"学号"字段 |
| A3 | `idCard` 返回形式 | 默认掩码(保留前 6 后 4);可配置返回明文 | 依据规范"身份证号或掩码" |
| A4 | 科目字典形态 | 新建 `train_exam_subject` 表(而非复用 `sys_dict_data`) | 需承载"应用-科目授权""科目→考核项映射" |
| A5 | 归档映射 | `train_exam_subject.score_item_id` 优先,其次按 `train_score_item.plan_id + item_name` 匹配 | 无法映射则保持 RECEIVED,不阻塞上报 |
| A6 | 实时上报是否启用 | 按应用级开关 `realtime_enabled` | 规范:与平台约定开启 |
---
## 2. 核心实体与关系
### 2.1 实体清单
| 实体 | 说明 | 新增/复用 |
|---|---|---|
| 外部应用 | 接入方身份(appId/appSecret/类型/状态/配额) | 新增 |
| 应用授权范围 | 应用可见的班级 / 科目 | 新增 |
| 访问令牌 | 应用调用凭证(审计与吊销) | 新增 |
| 考试科目字典 | `subjectCode` / `subjectName`,归档映射 | 新增 |
| 上报批次 | 批量上报的批次头与汇总 | 新增 |
| 成绩上报记录 | 单条成绩(幂等键 `reportId`),含匹配/归档状态 | 新增 |
| 接口调用日志 | `reqId` 追踪与审计 | 新增 |
| 学员主档 | `studentId`、姓名、性别、单位、证件 | 复用 `train_student` |
| 参训登记 | 学员与班级关系、"在训"判定 | 复用 `train_enrollment` |
| 培训班 | `classId` / `className` | 复用 `train_class_plan` |
| 考核项 | 归档落库目标配置 | 复用 `train_score_item` |
| 学员得分 | 归档后的平台成绩 | 复用 `train_student_score` |
### 2.2 关系说明
```
train_open_app (PK app_id)
├─1:N─ train_open_app_scope (app_id)
├─1:N─ train_open_token (app_id)
├─1:N─ train_exam_report_batch (app_id)
│ └─1:N─ train_exam_report (batch_pk)
├─1:N─ train_open_api_log (app_id)
└─1:N─ train_exam_report (app_id)
train_exam_report
├─N:1─ train_student (student_id → s_id,匹配后)
├─N:1─ train_enrollment (matched_enrollment_id,匹配后)
├─N:1─ train_class_plan (matched_plan_id,匹配后)
├─N:1─ train_exam_subject (subject_code,可空)
└─1:1─ train_student_score (归档后:source_system=app_id AND source_event_id=report_id)
train_exam_subject
├─N:1─ train_class_plan (plan_id,可空表示全局科目)
└─N:1─ train_score_item (score_item_id,归档映射,可空)
```
### 2.3 关键约束汇总
| 约束 | 内容 | 目的 |
|---|---|---|
| U1 | `train_open_app.app_id` 唯一 | 应用身份唯一 |
| U2 | `train_open_app_scope (app_id, scope_type, scope_value)` 唯一 | 授权不重复 |
| U3 | `train_open_token.token_hash` 唯一 | 令牌可精确校验/吊销 |
| U4 | `train_exam_subject.subject_code` 唯一 | 科目编码唯一,避免同科目多名称 |
| U5 | `train_exam_report_batch (app_id, batch_id)` 唯一 | 整批幂等 |
| U6 | `train_exam_report.report_id` 唯一 | 单条永久幂等 |
| U7 | `train_student_score (enrollment_id, score_item_id)` 唯一(既有) | 归档不重复落分 |
| C1 | `train_exam_report`:`exam_end_time > exam_start_time`、`score >= 0`、`score <= full_score`、`retake_no >= 0` | 数据合法性兜底 |
| C2 | 状态枚举检查:`match_status ∈ (MATCHED, UNMATCHED)`、`score_status ∈ (RECEIVED, ARCHIVED, LOCKED)`、`report_channel ∈ (REALTIME, PERIODIC)` | 状态机可控 |
| C3 | 外键:`app_id → train_open_app.app_id`;`batch_pk → train_exam_report_batch.batch_pk`;`matched_enrollment_id → train_enrollment.enrollment_id` | 引用完整 |
> C1/C2 在 MySQL 5.7 中**无法由 `CHECK` 强制执行**(5.7 解析但忽略),必须在 DTO 校验 + Service 层兜底;U1–U7 由唯一索引保证,是并发幂等的最终防线。
---
## 3. 数据表设计清单
> 本项目主库为 **MySQL 5.7**,下表类型为方言中立写法,落地映射见 3.9:`BIGINT IDENTITY` → `BIGINT AUTO_INCREMENT`,`TIMESTAMP`(审计字段)→ `DATETIME`,`NUMERIC(7,2)` → `DECIMAL(7,2)`。业务时间统一 `BIGINT`(毫秒)以贴合报文,规避 5.7 `TIMESTAMP` 的 2038 上限与时区转换。
### 3.1 `train_open_app` 外部应用注册表(新增)
用途:登记接入方身份、类型、密钥散列、状态与配额。
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
| `app_id` | VARCHAR(64) | N | — | 应用标识,主键;报文 `appId` |
| `app_name` | VARCHAR(100) | N | — | 应用名称,如「VR 安全实训系统」 |
| `app_type` | VARCHAR(16) | N | — | `VR` / `INSPECT3D` / `PRACTICE` / `CAPSTONE` / `OTHER` |
| `org_name` | VARCHAR(100) | Y | NULL | 归属单位,登录响应回显 |
| `app_secret_hash` | VARCHAR(128) | N | — | `appSecret` 的 BCrypt 散列(明文不落库) |
| `app_secret_masked` | VARCHAR(16) | Y | NULL | 密钥掩码(展示后 4 位) |
| `contact_name` | VARCHAR(50) | Y | NULL | 联系人 |
| `contact_phone` | VARCHAR(20) | Y | NULL | 联系电话 |
| `rate_limit` | INT | N | 20 | 每秒请求上限(默认 20,规范值) |
| `realtime_enabled` | SMALLINT | N | 1 | 是否启用 3.4 实时上报(1 启用) |
| `status` | SMALLINT | N | 1 | 1 启用 / 0 停用(停用返回 1004) |
| `remark` | VARCHAR(500) | Y | NULL | 备注 |
| `create_by` | VARCHAR(64) | Y | NULL | 创建人 |
| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 创建时间 |
| `update_by` | VARCHAR(64) | Y | NULL | 更新人 |
| `update_time` | TIMESTAMP | Y | NULL | 更新时间 |
- 主键:`pk_train_open_app (app_id)`
- 索引建议:`idx_open_app_status (status)`
- 关联:1:N `train_open_app_scope`、`train_open_token`、`train_exam_report_batch`、`train_exam_report`
### 3.2 `train_open_app_scope` 应用授权范围表(新增)
用途:限定应用可访问的班级 / 科目(越权返回 2001)。
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
| `scope_id` | BIGINT IDENTITY | N | — | 主键 |
| `app_id` | VARCHAR(64) | N | — | 外键 → `train_open_app.app_id` |
| `scope_type` | VARCHAR(16) | N | — | `CLASS` 班级 / `SUBJECT` 科目 |
| `scope_value` | VARCHAR(64) | N | — | 班级 `plan_id` 或科目 `subject_code` |
| `create_by` | VARCHAR(64) | Y | NULL | 创建人 |
| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 创建时间 |
- 主键:`pk_open_app_scope (scope_id)`
- 唯一键:`uk_open_app_scope (app_id, scope_type, scope_value)`
- 外键:`fk_scope_app (app_id) → train_open_app(app_id)`
- 索引建议:`idx_scope_value (scope_type, scope_value)`(按班级/科目反查应用)
### 3.3 `train_open_token` 访问令牌表(新增)
用途:令牌审计、吊销与重启恢复。运行期以 Redis 为主存储(`train:open:token:{appId}`,TTL 7200s)。
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
| `token_id` | BIGINT IDENTITY | N | — | 主键 |
| `app_id` | VARCHAR(64) | N | — | 外键 → `train_open_app.app_id` |
| `token_hash` | VARCHAR(64) | N | — | 令牌的 SHA-256(不存明文),唯一 |
| `issued_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 签发时间 |
| `expire_time` | TIMESTAMP | N | — | 过期时间(签发 + 7200s) |
| `status` | SMALLINT | N | 1 | 1 有效 / 0 已失效(被新令牌替换或手动吊销) |
| `client_ip` | VARCHAR(64) | Y | NULL | 签发来源 IP(审计) |
| `user_agent` | VARCHAR(255) | Y | NULL | 客户端标识 |
| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 创建时间 |
- 主键:`pk_open_token (token_id)`
- 唯一键:`uk_open_token_hash (token_hash)`
- 外键:`fk_token_app (app_id) → train_open_app(app_id)`
- 索引建议:`idx_token_app_status (app_id, status)`、`idx_token_expire (expire_time)`(清理任务)
### 3.4 `train_exam_subject` 考试科目字典表(新增)
用途:统一 `subjectCode` / `subjectName`,承载科目授权与归档映射。
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
| `subject_id` | BIGINT IDENTITY | N | — | 主键 |
| `subject_code` | VARCHAR(32) | N | — | 科目编码(如 `VR-SAFE`),唯一 |
| `subject_name` | VARCHAR(100) | N | — | 科目名称(如「高处作业隐患排查」),唯一 |
| `plan_id` | VARCHAR(32) | Y | NULL | 外键 → `train_class_plan.plan_id`;空表示全局科目 |
| `score_item_id` | BIGINT | Y | NULL | 外键 → `train_score_item.score_item_id`;归档落分目标 |
| `status` | SMALLINT | N | 1 | 1 启用 / 0 停用 |
| `sort_no` | INT | N | 0 | 展示顺序 |
| `remark` | VARCHAR(200) | Y | NULL | 备注 |
| `create_by` / `create_time` / `update_by` / `update_time` | VARCHAR(64)/TIMESTAMP/… | Y | — | 审计字段 |
- 主键:`pk_exam_subject (subject_id)`
- 唯一键:`uk_exam_subject_code (subject_code)`、`uk_exam_subject_name (subject_name)`
- 外键:`fk_subject_plan (plan_id) → train_class_plan(plan_id)`、`fk_subject_score_item (score_item_id) → train_score_item(score_item_id)`
- 索引建议:`idx_subject_plan (plan_id, status)`
### 3.5 `train_exam_report_batch` 上报批次表(新增)
用途:批量上报的批次头、首个汇总结果回放、`batchId` 幂等。
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
| `batch_pk` | BIGINT IDENTITY | N | — | 主键(内部) |
| `app_id` | VARCHAR(64) | N | — | 外键 → `train_open_app.app_id` |
| `batch_id` | VARCHAR(64) | N | — | 报文 `batchId` |
| `report_time` | BIGINT | N | — | 报文 `reportTime`(毫秒) |
| `period_start` | BIGINT | N | — | 覆盖窗口开始(含),毫秒 |
| `period_end` | BIGINT | N | — | 覆盖窗口结束(不含),毫秒 |
| `resend` | SMALLINT | N | 0 | 补传标记 1/0 |
| `total` | INT | N | 0 | 本批记录数 |
| `accepted` | INT | N | 0 | 校验通过并入库 |
| `duplicated` | INT | N | 0 | `reportId` 已存在被忽略 |
| `unmatched` | INT | N | 0 | 学员无法匹配(待认领) |
| `received_time` | BIGINT | N | — | 平台接收时间(毫秒) |
| `req_id` | VARCHAR(64) | Y | NULL | 处理该批次的请求标识 |
| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 创建时间 |
- 主键:`pk_exam_report_batch (batch_pk)`
- 唯一键:`uk_batch_app_batch (app_id, batch_id)`
- 外键:`fk_batch_app (app_id) → train_open_app(app_id)`
- 索引建议:`idx_batch_received (app_id, received_time)`、`idx_batch_period (period_start, period_end)`
### 3.6 `train_exam_report` 成绩上报记录表(新增,核心)
用途:保存每条上报成绩原文、匹配结果与归档状态;`reportId` 为永久幂等键。
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
| `report_pk` | BIGINT IDENTITY | N | — | 主键(内部) |
| `report_id` | VARCHAR(64) | N | — | 报文 `reportId`,全局唯一幂等键 |
| `app_id` | VARCHAR(64) | N | — | 外键 → `train_open_app.app_id` |
| `batch_pk` | BIGINT | Y | NULL | 外键 → `train_exam_report_batch.batch_pk`;实时上报为 NULL |
| `batch_id` | VARCHAR(64) | Y | NULL | 批次号冗余(实时上报为 NULL),便于按批查询 |
| `report_channel` | VARCHAR(16) | N | — | `REALTIME` / `PERIODIC` |
| `biz_type` | VARCHAR(16) | N | 'PRACTICE' | 成绩类型,固定实训成绩 |
| `student_id` | VARCHAR(32) | N | — | 报文 `studentId`(匹配键,原文保留) |
| `student_name` | VARCHAR(50) | Y | NULL | 上报姓名,供人工认领核对 |
| `id_card_masked` | VARCHAR(32) | Y | NULL | 上报证件号掩码(明文不落库,仅存比对结果) |
| `id_card_md5` | CHAR(32) | Y | NULL | 上报证件号 MD5,用于与平台档案交叉校验 |
| `matched_enrollment_id` | BIGINT | Y | NULL | 外键 → `train_enrollment.enrollment_id`,匹配成功时写入 |
| `matched_plan_id` | VARCHAR(32) | Y | NULL | 匹配到的培训班 `plan_id`(冗余) |
| `match_status` | VARCHAR(16) | N | 'UNMATCHED' | `MATCHED` / `UNMATCHED` |
| `score_status` | VARCHAR(16) | N | 'RECEIVED' | `RECEIVED` / `ARCHIVED` / `LOCKED` |
| `subject_code` | VARCHAR(32) | Y | NULL | 科目编码,须与字典一致(可空) |
| `subject_name` | VARCHAR(100) | N | — | 科目名称(必填) |
| `exam_start_time` | BIGINT | N | — | 考试开始(毫秒) |
| `exam_end_time` | BIGINT | N | — | 考试结束(毫秒) |
| `duration_seconds` | INT | Y | NULL | 用时(`exam_end_time - exam_start_time` 换算秒,写入时计算) |
| `score` | NUMERIC(7,2) | N | — | 分数(两位小数) |
| `full_score` | NUMERIC(7,2) | N | 100.00 | 满分 |
| `pass_flag` | SMALLINT | N | — | 是否通过 1/0(报文 `pass`) |
| `retake` | SMALLINT | N | 0 | 是否重考 1/0 |
| `retake_no` | INT | N | 0 | 重考序号,`retake=0` 时为 0 |
| `station` | VARCHAR(64) | N | — | 考试工位 |
| `content` | TEXT | N | — | 考试内容原始 JSON(对象,≤32 KB,原样存储) |
| `content_size` | INT | Y | NULL | 内容 UTF-8 字节数 |
| `content_md5` | CHAR(32) | Y | NULL | 内容摘要,用于重复上报的内容一致性判断 |
| `remark` | VARCHAR(500) | Y | NULL | 备注 |
| `report_time` | BIGINT | Y | NULL | 报文 `reportTime`,实时上报缺省为接收时间 |
| `received_time` | BIGINT | N | — | 平台接收时间(毫秒) |
| `resend` | SMALLINT | N | 0 | 补传标记 1/0 |
| `claim_by` | VARCHAR(64) | Y | NULL | 认领操作人(UNMATCHED → MATCHED) |
| `claim_time` | TIMESTAMP | Y | NULL | 认领时间 |
| `archive_time` | TIMESTAMP | Y | NULL | 归档时间(写入 `train_student_score`) |
| `lock_time` | TIMESTAMP | Y | NULL | 锁定时间(结课/人工锁定) |
| `lock_reason` | VARCHAR(200) | Y | NULL | 锁定原因 |
| `data_version` | INT | N | 0 | 乐观锁版本(归档/认领并发保护) |
| `create_time` / `update_time` | TIMESTAMP | Y | CURRENT_TIMESTAMP | 审计字段 |
- 主键:`pk_exam_report (report_pk)`
- 唯一键:`uk_exam_report_id (report_id)`
- 外键:
- `fk_report_app (app_id) → train_open_app(app_id)`
- `fk_report_batch (batch_pk) → train_exam_report_batch(batch_pk)`
- `fk_report_enrollment (matched_enrollment_id) → train_enrollment(enrollment_id)`
- 检查约束(**MySQL 5.7 仅语法解析、不生效**,下列规则必须由应用层校验兜底,Kingbase 侧可按此 DDL 建约束):
- `chk_report_time_range CHECK (exam_end_time > exam_start_time)`
- `chk_report_score CHECK (score >= 0 AND score <= full_score)`
- `chk_report_retake_no CHECK (retake_no >= 0)`
- `chk_report_match_status CHECK (match_status IN ('MATCHED','UNMATCHED'))`
- `chk_report_score_status CHECK (score_status IN ('RECEIVED','ARCHIVED','LOCKED'))`
- `chk_report_channel CHECK (report_channel IN ('REALTIME','PERIODIC'))`
- 索引建议:
- `idx_report_app_time (app_id, received_time)`:按应用对账
- `idx_report_app_batch (app_id, batch_id)`:3.5 按批次查询
- `idx_report_student (app_id, student_id, exam_end_time)`:学员成绩回溯
- `idx_report_status (match_status, score_status)`:认领与归档任务扫描
- `idx_report_exam_end (exam_end_time)`:时间窗查询(3.5)
- 容量与归档:单应用日均 1 万条估算,`content` 平均 2–8 KB;**MySQL 5.7 不做分区**(唯一键 `report_id` 与分区列约束冲突),改为"热表保留 12 个月 + 按 `received_time` 归档到 `train_exam_report_his`(同结构)",列表查询不读取 `content` 列。
### 3.7 `train_open_api_log` 接口调用日志表(新增)
用途:`reqId` 排障、审计与配额统计(异步写入,失败不影响主流程)。
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
| `log_id` | BIGINT IDENTITY | N | — | 主键 |
| `req_id` | VARCHAR(64) | N | — | 请求标识(响应同值) |
| `app_id` | VARCHAR(64) | Y | NULL | 应用(登录失败时可能为空) |
| `api_path` | VARCHAR(128) | N | — | 请求路径 |
| `http_method` | VARCHAR(8) | N | — | GET / POST |
| `biz_key` | VARCHAR(128) | Y | NULL | 业务键:`batchId` / `reportId` / `classId` |
| `code` | INT | N | — | 业务返回码 |
| `msg` | VARCHAR(500) | Y | NULL | 返回描述(截断) |
| `cost_ms` | INT | Y | NULL | 耗时(毫秒) |
| `client_ip` | VARCHAR(64) | Y | NULL | 调用方 IP |
| `create_time` | TIMESTAMP | N | CURRENT_TIMESTAMP | 记录时间 |
- 主键:`pk_open_api_log (log_id)`
- 索引建议:`idx_open_log_req (req_id)`、`idx_open_log_app_time (app_id, create_time)`
- 保留策略:默认 90 天,按 `create_time` 定期清理。
### 3.8 复用的既有表(不新增字段,仅映射)
| 表 | 关键字段 | 在接口中的用途 | 映射/说明 |
|---|---|---|---|
| `train_student` | `s_id`、`s_name`、`s_gender`、`s_phone`、`id_card_ciphertext`/`id_card_hash`/`id_card_last4`、`dept_name`、`card_no`、`status`、`del_flag` | 3.2 名单来源;3.3/3.4 匹配 | `studentId ← s_id`;`gender`:男→`MALE`、女→`FEMALE`;`orgName ← dept_name`;`studentNo ← card_no`(空回退 `s_id`) |
| `train_enrollment` | `enrollment_id`、`plan_id`、`student_id`、`enrollment_status`、`join_time`、`complete_time` | 3.2 在训过滤;3.3/3.4 定位 `enrollment_id` | 在训口径见 A1 |
| `train_class_plan` | `plan_id`、`plan_name` | 3.2 班级过滤与 `className` | `classId ← plan_id` |
| `train_score_item` | `score_item_id`、`plan_id`、`item_name`、`full_score` | 归档时定位考核项 | 与 `train_exam_subject.score_item_id` 配合 |
| `train_student_score` | `enrollment_id`、`score_item_id`、`score`、`source_type`、`source_system`、`source_event_id` | 归档落分 | `source_type=4`(外部导入);`source_system=appId`;`source_event_id=reportId`;`(enrollment_id, score_item_id)` 唯一,归档用 UPSERT |
### 3.9 数据库方言与 MySQL 5.7 落地约定(本项目主库为 MySQL 5.7)
> 本节按「MySQL 5.7 为主、Kingbase 兼容为辅」修订;MySQL 5.7 的差异已计入字段类型与实现方式。
| 设计项 | MySQL 5.7 写法与约束 | 说明 |
|---|---|---|
| 自增主键 | `BIGINT NOT NULL AUTO_INCREMENT` | 不支持 `GENERATED ... AS IDENTITY`;MyBatis 需 `useGeneratedKeys="true"` |
| 审计时间 | `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` | 避免 `TIMESTAMP` 的 2038 上限与时区隐式转换;与 RuoYi 既有表一致 |
| 分数 | `DECIMAL(7,2)` | `NUMERIC` 仅为同义词,DDL 统一写 `DECIMAL` |
| 列注释 | 列定义内 `COMMENT '...'` | 不支持独立 `COMMENT ON COLUMN` |
| `content` 存储 | `TEXT NOT NULL`(**不用 `JSON`**) | MySQL 5.7 的 `JSON` 会重排键、去空白、强制校验,破坏"原样存储"与 `content_md5` 语义;`TEXT`(65,535 字节)足以容纳 32 KB |
| 枚举/状态 | `VARCHAR(16)` + 应用层枚举校验 | MySQL 5.7 **解析但不强制执行 `CHECK`**,唯一/非空之外只能靠应用校验 |
| 字符集 | `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci` | 需与 `train_student` 等既有表一致,避免 JOIN 隐式转换导致索引失效 |
| 长索引 | `VARCHAR(100)` 唯一键(utf8mb4 400 字节) | 5.7 默认 `innodb_large_prefix=ON`,3072 字节上限内安全 |
| 哈希列 | `CHAR(32) CHARACTER SET ascii` | 证件号/内容摘要仅 ASCII,节省空间并避免大小写/重音排序干扰 |
| 迁移脚本 | 无 `ADD COLUMN IF NOT EXISTS`、无 `CREATE INDEX IF NOT EXISTS` | 用 `information_schema` 判断后 `PREPARE/EXECUTE`,脚本须可重复执行 |
| SQL 能力 | 无窗口函数、无 CTE(`WITH`) | "取最新在训记录"用 `ORDER BY join_time DESC LIMIT 1`;统计用子查询,不写 `ROW_NUMBER()` |
| 分区 | **本期不分区** | 5.7 要求所有唯一键包含分区列,`report_id` 全局唯一与分区互斥;改用"热表 + 定期归档冷数据" |
| JDBC 连接 | 现有 URL 为 `characterEncoding=utf8` | 如需完整 4 字节字符支持,改为 `characterEncoding=utf8mb4` 并确认服务端 `utf8mb4`(现网中文场景可保持现状) |
---
## 4. 接口设计方案
### 4.1 分层与落点(仅设计说明)
| 层 | 职责 | 建议落点 |
|---|---|---|
| 接入层 | 令牌校验、限流、`reqId` 生成、请求体大小限制、应用上下文注入 | `ruoyi-admin`:`/api/open/v1/**` 过滤器 + 统一异常处理 |
| 控制层 | 5 个接口的入参/出参、参数格式校验 | `com.ruoyi.web.controller.open.OpenExamApiController`(按接口再拆分为 Auth/Student/Exam 三个 Controller) |
| 服务层 | 令牌签发、名单查询、上报校验与幂等、匹配、归档、认领 | `ruoyi-train`:`ITrainOpenAppService`、`IOpenExamReportService`、`IOpenStudentQueryService` |
| 持久层 | 上述新表 CRUD 与幂等插入 | MyBatis Mapper |
| 任务层 | 归档任务、令牌清理、日志清理 | 定时任务(参照现有 quartz 任务) |
| 存储层 | 令牌、限流计数、应用配置缓存 | Redis |
**统一响应模型** `OpenApiResponse<T>`:`code`、`msg`、`data`、`reqId`、`ts`;HTTP 恒为 200(业务错误也用 code 表达),网关层超限(如 Nginx 413)除外。
> **与 RuoYi 的兼容性说明(重要)**
> - 不能直接复用 `AjaxResult`:其成功码为 `HttpStatus.SUCCESS`(=200),`isSuccess()` 按 200 判定,而本规范要求 `code=0`;额外还需要 `reqId`/`ts`。故新建 `OpenApiResponse`(`code/msg/data/reqId/ts`)+ `OpenApiResponse.success(data)`/`error(code,msg)`,仅本包使用。
> - 不能复用 `TableDataInfo`(字段为 `total/rows/code/msg`)与 `BaseController.getDataTable()`,分页数据按规范输出 `{total,pageNo,pageSize,list}`。
> - 不能复用全局 `GlobalExceptionHandler`:其对 `ServiceException`/校验异常统一返回 `AjaxResult.error(...)`(code=500,无 `reqId`)。需新增 `@RestControllerAdvice`(`basePackages` 限定 open 包、`@Order` 高于全局)把异常映射到 1001/1002/3003/3005/4000。
> - 鉴权放行用框架既有机制:Controller 标注 `@Anonymous`(`PermitAllUrlProperties` 自动收集并 permitAll),再由自有 Servlet Filter(参照 `OpenApiAuthFilter`)做令牌校验;不改动 `SecurityConfig` 中既有 matcher 列表。
> - 必须把 `/api/open/**` 加入 `xss.excludes`:RuoYi `XssFilter` 对 POST/PUT 的 JSON 会逐字符串转义,会破坏 `content` 的"原样存储"与 `content_md5`。
> - 令牌读写统一用 `RedisCache`(`setCacheObject(key, val, 7200, TimeUnit.SECONDS)`),键前缀 `open:token:{appId}`,与框架登录令牌 `login_tokens:*` 隔离。
> - 外部调用无登录上下文:禁止调用 `SecurityUtils.getUsername()`(会抛异常),审计操作人固定为 `openapi`。
### 4.2 接口一:登录获取访问令牌
| 项 | 内容 |
|---|---|
| 名称 | 登录获取访问令牌 |
| 路径 / 方法 | `POST /api/open/v1/auth/token` |
| 鉴权 | 无(白名单),仅校验 appId + appSecret |
| 对应表 | `train_open_app`、`train_open_token`、`train_open_api_log` |
**入参**
| 字段 | 类型 | 必填 | 校验 |
|---|---|---|---|
| `appId` | string(64) | 是 | 非空;长度 ≤ 64 |
| `appSecret` | string(128) | 是 | 非空;长度 ≤ 128 |
**出参 `data`**
| 字段 | 类型 | 说明 |
|---|---|---|
| `accessToken` | string | 不透明令牌(256 位随机),Redis 与 `train_open_token.token_hash` 存散列 |
| `tokenType` | string | 固定 `Bearer` |
| `expiresIn` | int | 固定 `7200` |
| `app.appId` | string | 应用标识 |
| `app.appName` | string | 应用名称 |
| `app.appType` | string | 应用类型枚举 |
| `app.orgName` | string | 归属单位 |
> 规范示例中的令牌形如 JWT 仅为示意;平台采用不透明随机串(调用方无需解析),如需与既有系统统一,也可改为 HS256 JWT,载荷仅含 `appId`、`iat`、`exp`,并同样在 Redis 维护"单应用单令牌"。
**业务逻辑**
1. 参数非空校验,失败 → `1001`;
2. 按 `app_id` 查应用:不存在 → `1003`;`status=0` → `1004`;
3. BCrypt 校验 `appSecret`,失败 → `1005`(同一应用连续失败 5 次锁定 10 分钟,防爆破);
4. 生成令牌(SecureRandom 32 字节,Base64URL),计算 SHA-256;
5. 旧令牌失效:将 Redis `train:open:token:{appId}` 指向新散列并重置 TTL;`train_open_token` 中该应用 `status=1` 记录置 0;
6. 写入新令牌记录(`issued_time`、`expire_time=now+7200s`、`client_ip`),Redis 写入 `appId → tokenHash`,TTL 7200s;
7. 记录调用日志;返回令牌与应用信息。
**边界与错误码**:`1001`/`1003`/`1004`/`1005`/`4000`。
### 4.3 接口二:在训学员查询
| 项 | 内容 |
|---|---|
| 名称 | 在训学员查询 |
| 路径 / 方法 | `GET /api/open/v1/student/list` |
| 鉴权 | Bearer;需 `CLASS` 范围(传 `classId` 时校验) |
| 对应表 | `train_student`、`train_enrollment`、`train_class_plan`、`train_open_app_scope`、`train_open_api_log` |
**入参(Query)**
| 字段 | 类型 | 必填 | 校验 |
|---|---|---|---|
| `classId` | string(32) | 否 | 传则须存在且在该应用 `CLASS` 授权内 |
| `pageNo` | int | 否 | ≥ 1,默认 1 |
| `pageSize` | int | 否 | 1–200,默认 50 |
**出参 `data`**:`{ total, pageNo, pageSize, list[] }`
`list[]` 字段:`studentId`、`studentName`、`gender`(MALE/FEMALE)、`phone`(掩码)、`idCard`(掩码)、`classId`、`className`、`studentNo`、`orgName`。
**业务逻辑**
1. 令牌校验通过,`appId` 由过滤器注入;
2. `classId` 存在性校验 → 不存在 `2002`;`CLASS` 授权校验 → 未授权 `2001`;
3. 组装查询:`train_enrollment e JOIN train_student s JOIN train_class_plan p`;
- `s.del_flag='0' AND s.status=1`;
- `e.enrollment_status IN (1,2)`(在训口径 A1);
- 传 `classId` 时 `e.plan_id = #{classId}`;
- 同一学员多班时按班展开(每人每班一行)。
4. 固定排序:`p.plan_name ASC, e.student_id ASC`(保证翻页稳定);
5. 分页返回;手机号中间 4 位掩码,证件号按 A3 掩码;`gender` 枚举转换;
6. 记录调用日志(`biz_key=classId`)。
**边界与错误码**:`1002`(分页参数非法)、`2001`、`2002`、`1006`、`3002`、`4000`。
### 4.4 接口三:考试成绩批量上报
| 项 | 内容 |
|---|---|
| 名称 | 考试成绩批量上报 |
| 路径 / 方法 | `POST /api/open/v1/exam/report` |
| 鉴权 | Bearer;`SUBJECT` 范围校验(带 `subjectCode` 时);`realtime_enabled` 无关 |
| 对应表 | `train_exam_report_batch`、`train_exam_report`、`train_open_app_scope`、`train_exam_subject`、`train_student`、`train_enrollment`、`train_open_api_log` |
**入参(Body)**
批次级:`batchId`(string64,必填)、`reportTime`(int64,必填)、`periodStart`(int64,必填)、`periodEnd`(int64,必填)、`resend`(boolean,可选,默认 false)、`reports`(object[],必填,1–200)。
`reports[]`:`reportId`(string64,必填)、`studentId`(string32,必填)、`idCard`(string64,可选)、`studentName`(string50,可选)、`subjectCode`(string32,可选)、`subjectName`(string100,必填)、`examStartTime`(int64,必填)、`examEndTime`(int64,必填)、`score`(decimal,必填)、`fullScore`(decimal,可选,默认 100)、`pass`(boolean,必填)、`retake`(boolean,必填)、`retakeNo`(int,条件)、`station`(string64,必填)、`content`(object,必填)、`remark`(string500,可选)。
**出参 `data`**:`batchId`、`total`、`accepted`、`duplicated`、`unmatched`、`receivedTime`(满足 `accepted + duplicated + unmatched = total`)。
**业务逻辑**
1. **请求级校验**:`Content-Length ≤ 1 MB` → 否则 `3003`;JSON 结构/类型 → `1002`;必填缺失 → `1001`;
2. **批次校验**:`batchId` ≤ 64;`periodEnd > periodStart` → 否则 `3004`;`reports` 1–200 → 否则 `3003`;批内 `reportId` 重复 → `3001`;
3. **批次幂等**:查 `(app_id, batch_id)`:
- 已存在且批次元数据(`reportTime`/`periodStart`/`periodEnd`/`resend`/记录数)一致 → 直接回放首次汇总,不重复入库;
- 已存在但元数据不一致 → `2003`;
4. **逐条字段校验**(整批校验,任一不合法整批拒绝 `3001`):
- `reportId` ≤ 64 且非空;`studentId` 非空 ≤ 32;`subjectName` 非空;
- `examEndTime > examStartTime` 且跨度 ≤ 24h,`examEndTime ∈ [periodStart, periodEnd)` → 否则 `3004`;
- `0 ≤ score ≤ fullScore`,`fullScore` 缺省 100,小数位 ≤ 2 → 否则 `3001`;
- `retake=true` 时 `retakeNo ≥ 1`,`retake=false` 时 `retakeNo` 缺省 0;
- `content` 为 JSON 对象且序列化 ≤ 32 KB → 否则 `3005`;命中身份证/手机号模式 → `3005`;
- `subjectCode` 传值时须存在于 `train_exam_subject`(启用)→ 否则 `3001`;不在应用 `SUBJECT` 授权内 → `2001`;
5. **写入批次头**(`total` 先落,汇总置 0,事务内更新);
6. **逐条处理**(`reportId` 分块预查 + 唯一键冲突兜底):
- `report_id` 已存在 → `duplicated++`(保持首次内容;若 `content_md5` 不同仅记差异日志,不覆盖);
- 学员匹配(见 4.7):成功 → `match_status=MATCHED` 并写 `matched_enrollment_id`/`matched_plan_id`;失败 → `UNMATCHED`、`unmatched++`;
- 入库 `train_exam_report`(`report_channel='PERIODIC'`,`resend`、`report_time`、`received_time` 落库);
- 成功入库 → `accepted++`;
7. 汇总更新批次头,返回汇总;记录调用日志(`biz_key=batchId`)。
8. 事务与并发:批次头 + 明细在同一事务,每 100 条一批 `INSERT`;唯一键冲突捕获后按 `duplicated` 处理;跨实例并发同 `batchId` 由唯一键保证只成功一次,另一请求回放首次结果。
**边界与错误码**:`1001`、`1002`、`2001`、`2003`、`3001`、`3002`、`3003`、`3004`、`3005`、`4000`。
### 4.5 接口四:实时成绩上报
| 项 | 内容 |
|---|---|
| 名称 | 实时成绩上报 |
| 路径 / 方法 | `POST /api/open/v1/exam/report/realtime` |
| 鉴权 | Bearer;应用需 `realtime_enabled=1` |
| 对应表 | `train_exam_report`、`train_exam_subject`、`train_student`、`train_enrollment`、`train_open_app_scope`、`train_open_api_log` |
**入参**:`reports`(object[], 必填, 1–20,字段同 3.3 的 `reports[]`)、`reportTime`(int64, 可选,缺省取平台接收时间)。不带 `batchId`/`periodStart`/`periodEnd`。
**出参**:`{ total, accepted, duplicated, unmatched, receivedTime }`(无 `batchId`)。
**业务逻辑**
1. 请求体 ≤ 1 MB → 否则 `3003`;`reports` 1–20 → 否则 `3003`;同一请求内 `reportId` 重复 → `3001`;
2. 逐条字段校验(同 3.3 第 4 步),时间窗改为:`examEndTime ≤ now + 10min` 且 `≥ now - 24h`;`resend=true` 时下限放宽至 90 天 → 违反 `3004`;
3. 幂等:`report_id` 唯一键冲突 → `duplicated++`;
4. 匹配与入库:`report_channel='REALTIME'`、`batch_pk=NULL`、`batch_id=NULL`;匹配失败同样入待认领池并计入 `unmatched`;
5. **即时可见**:匹配成功的记录同步触发归档(见 4.8),使成绩查询与班牌即时可见;归档失败不回滚上报,仅置 `RECEIVED` 由定时任务补归档;
6. 返回汇总;记录日志。
**边界与错误码**:`1001`、`1002`(含未开启实时上报时按 `2001` 返回)、`2001`、`3001`、`3002`、`3003`、`3004`、`3005`、`4000`。
> 失败兜底由调用方完成:超时(5s)或非 0 → 本地持久化,并入下一周期 3.3(`resend=true`),同一 `reportId` 不会重复入库。
### 4.6 接口五:上报结果查询
| 项 | 内容 |
|---|---|
| 名称 | 上报结果查询 |
| 路径 / 方法 | `GET /api/open/v1/exam/result` |
| 鉴权 | Bearer |
| 对应表 | `train_exam_report_batch`、`train_exam_report`、`train_open_api_log` |
**入参(Query)**
| 字段 | 类型 | 必填 | 校验 |
|---|---|---|---|
| `batchId` | string(64) | 条件 | 与 `reportId` 二选一;同时传 → `1002` |
| `reportId` | string(64) | 条件 | 与 `batchId` 二选一 |
| `periodStart` / `periodEnd` | int64 | 否 | 两者均未传 `batchId`/`reportId` 时必填;跨度 ≤ 31 天 |
| `pageNo` / `pageSize` | int | 否 | 默认 1 / 50,`pageSize ≤ 200` |
**出参 `data`**:`batchId`、`reportTime`、`periodStart`、`periodEnd`、`total`、`accepted`、`duplicated`、`unmatched`、`list[]`
`list[]`:`reportId`、`subjectName`、`studentId`、`matchStatus`、`scoreStatus`、`score`、`pass`、`retake`、`retakeNo`、`examStartTime`、`examEndTime`、`receivedTime`。
**业务逻辑**
1. 参数互斥/必填校验 → `1001`/`1002`;
2. 按 `batchId`:查批次头(不存在 → `2002`),分页查明细(`app_id + batch_id`);
3. 按 `reportId`:查单条(不存在或不属于本应用 → `2002`),返回 `total=1`;
4. 按时间窗:`received_time ∈ [periodStart, periodEnd)`,分页查明细;汇总按窗口内 `total/accepted/duplicated/unmatched` 统计;
5. 固定排序:`exam_end_time ASC, report_id ASC`(规范:平台按考试结束时间排序);
6. 只返回必要字段,不返回 `content`、`idCard`、`station` 等敏感/大字段;
7. 记录调用日志。
**边界与错误码**:`1001`、`1002`、`1006`、`2002`、`3002`、`4000`。
### 4.7 学员匹配与待认领规则
| 场景 | 判定 | 结果 |
|---|---|---|
| `studentId` 存在、在训 | `train_student.s_id` 命中 + `train_enrollment` 在训(A1) | `MATCHED`,写 `matched_enrollment_id/plan_id` |
| `studentId` 不存在 | 主档无记录 | `UNMATCHED`(待认领池) |
| `studentId` 存在但无在训记录 | 已结业/退训/未入班 | `UNMATCHED` |
| 传 `idCard` 且与平台档案不一致 | MD5 比对不一致 | `UNMATCHED`(交叉校验不通过) |
| 多班在训 | 取 `join_time` 最新且 `enrollment_status=1` 的一条 | `MATCHED` |
| 认领 | 管理员指定学员 + 参训记录 | `UNMATCHED → MATCHED`,记 `claim_by/claim_time`,随后进入归档 |
> 批量/实时接口对 `UNMATCHED` **不返回逐条错误**,只计入 `unmatched`;单条状态通过 3.5 查询(`matchStatus`)。
### 4.8 归档与锁定规则
| 环节 | 规则 |
|---|---|
| 归档对象 | `match_status=MATCHED AND score_status=RECEIVED` |
| 归档映射 | `train_exam_subject.score_item_id` 优先;否则按 `train_score_item.plan_id = matched_plan_id AND item_name = subject_name` 匹配 |
| 落库 | UPSERT `train_student_score`:`enrollment_id`、`score_item_id`、`score`、`source_type=4`、`source_system=app_id`、`source_event_id=report_id` |
| 状态迁移 | 落库成功 → `ARCHIVED` + `archive_time`;映射缺失 → 保持 `RECEIVED` 并告警,待配置后重跑 |
| 锁定 | 培训班结课(`plan_status=2`)或管理员手工锁定 → `LOCKED` + `lock_time`;锁定后同一 `reportId` 内容变更重传 → `2005` |
| 幂等 | 归档以 `(enrollment_id, score_item_id)` 唯一键 UPSERT,重复执行不重复计分 |
| 执行方式 | 实时上报同步归档;周期批次由定时任务(建议 1 分钟)批量归档 |
### 4.9 考试内容(content)约定
| 项目 | 平台侧设计与校验 |
|---|---|
| 类型 | 必须是 JSON 对象(`{}`),数组/字符串/null → `3005`(解析后用 `JsonNode.isObject()` 判断) |
| 大小 | UTF-8 序列化后 ≤ 32 KB → 否则 `3005`;同时记录 `content_size` |
| 结构 | 平台不解析业务字段,原样入库(`content` TEXT);增删字段无需平台改造 |
| 敏感信息 | 扫描身份证(18 位)、手机号(11 位)等模式 → `3005`,提示改用 `studentId` 关联 |
| 变更 | 结构变更走线下报备;平台按上报时间保留历史内容,`content_md5` 用于识别同 `reportId` 内容差异 |
| 展示 | 详情页转义输出,禁止 `innerHTML` 直出,防 XSS |
### 4.10 接口间依赖关系
```
3.1 登录 ──签发令牌──► 3.2 / 3.3 / 3.4 / 3.5(均需 Bearer)
3.2 在训名单 ──提供 studentId──► 3.3 / 3.4(成绩匹配键)
3.3 批量上报 ─┐
3.4 实时上报 ─┴─写──► train_exam_report ──归档──► train_student_score
3.3 / 3.4 ──失败兜底──► 3.3(resend=true,同一 reportId 幂等)
3.5 结果查询 ──读──► train_exam_report(_batch)(对账 / 问题定位)
科目字典 train_exam_subject ──约束──► 3.3 / 3.4(subjectCode)──映射──► 归档
应用授权 train_open_app_scope ──约束──► 3.2(CLASS)/ 3.3、3.4(SUBJECT)
```
### 4.11 表与接口对应关系
| 数据表 | 3.1 令牌 | 3.2 名单 | 3.3 批量上报 | 3.4 实时上报 | 3.5 结果查询 |
|---|---|---|---|---|---|
| `train_open_app` | ● 读 | ○ 鉴权 | ○ 鉴权 | ○ 鉴权 | ○ 鉴权 |
| `train_open_app_scope` | — | ● 读 | ● 读 | ● 读 | — |
| `train_open_token` | ● 写 | ● 读 | ● 读 | ● 读 | ● 读 |
| `train_exam_subject` | — | — | ● 读 | ● 读 | ○ 展示 |
| `train_exam_report_batch` | — | — | ● 写 | — | ● 读 |
| `train_exam_report` | — | — | ● 写 | ● 写 | ● 读 |
| `train_open_api_log` | ● 写 | ● 写 | ● 写 | ● 写 | ● 写 |
| `train_student` | — | ● 读 | ● 读 | ● 读 | — |
| `train_enrollment` | — | ● 读 | ● 读 | ● 读 | — |
| `train_class_plan` | — | ● 读 | ○ 归档 | ○ 归档 | — |
| `train_score_item` | — | — | ○ 归档 | ○ 归档 | — |
| `train_student_score` | — | — | ○ 归档写 | ○ 归档写 | — |
> ● 直接读写;○ 间接依赖(鉴权、归档、展示)。
---
## 5. 通用规范
### 5.1 分页
- 入参 `pageNo ≥ 1`(默认 1)、`pageSize ∈ [1,200]`(默认 50);超范围 → `1002`;
- 响应统一 `{ total, pageNo, pageSize, list }`;`total` 为满足过滤条件的总条数;
- 深分页(`pageNo` 过大)由索引 + `ORDER BY` 保证稳定;导出类需求不在本期。
- **与 RuoYi 分页机制的对接方式**(不使用 `BaseController.startPage()`):
- 框架的 `startPage()` 读取 `pageNum`/`pageSize`/`orderByColumn`/`isAsc` 并交给 PageHelper,而本规范是 `pageNo` 且**不开放自定义排序**,直接复用会读不到页码、并引入 `orderBy` 注入面;
- 改为在 Service/Mapper 查询前显式 `PageHelper.startPage(pageNo, pageSize)`(不传 orderBy),查询后用 `new PageInfo<>(list)` 取 `total`,再映射为 `{total, pageNo, pageSize, list}`;
- PageHelper 为 ThreadLocal,必须在 `finally` 中 `PageUtils.clearPage()`(或 `PageHelper.clearPage()`),避免线程复用时污染后续查询;
- 3.5 按时间窗统计(`total/accepted/duplicated/unmatched`)需单独 `COUNT` 查询,不能依赖分页拦截器生成。
### 5.2 排序
| 接口 | 固定排序 |
|---|---|
| 3.2 学员 | `plan_name ASC, student_id ASC` |
| 3.5 结果查询 | `exam_end_time ASC, report_id ASC`(符合规范"按考试结束时间排序") |
> 本期不开放自定义排序字段,避免索引失控与注入风险。
### 5.3 过滤
- 3.2 仅支持 `classId` 精确过滤;
- 3.5 支持 `batchId`、`reportId`、时间窗三类,且互斥/组合规则如 4.6;
- 时间窗跨度上限:3.5 为 31 天;3.3 批次窗口由调用方定义(单次考试 ≤ 24h)。
### 5.4 时间与数值
- 入参/出参时间统一 `int64` 毫秒(东八区);业务时间库内 `BIGINT` 毫秒,审计字段用 `DATETIME`(MySQL 5.7 的 `TIMESTAMP` 有 2038 上限且受时区影响);
- 分数 `DECIMAL(7,2)`(Java `BigDecimal`,统一 `setScale(2, RoundingMode.HALF_UP)`);JSON 中 `86.50` 与 `86.5` 数值等价,尾随 0 不作为契约要求,展示层格式化;
- 布尔字段报文用 `true/false`,库内 `SMALLINT 1/0`(MySQL 用 `TINYINT(1)` 会被驱动映射为 Boolean,故统一 `SMALLINT`,避免实体类型不一致);
- 文本列统一 `utf8mb4`;如需支持 4 字节字符(emoji),JDBC 需 `characterEncoding=utf8mb4`(现网配置为 `utf8`)。
### 5.5 幂等与重试
| 场景 | 幂等键 | 处理 |
|---|---|---|
| 单条成绩 | `reportId` | 唯一键 + 冲突按 `duplicated`,保留首次内容 |
| 整批 | `(appId, batchId)` | 唯一键;同批重提回放首次汇总;元数据不一致 `2003` |
| 令牌 | `appId` | 新令牌覆盖旧令牌,旧令牌立即失效 |
| 归档 | `(enrollment_id, score_item_id)` | UPSERT,不重复计分 |
调用方重试策略:3 次,间隔 1/5/15 分钟;仍失败转本地缓存,恢复后 `resend=true` 补传(90 天)。
### 5.6 限流
- 维度:`appId`,默认 20 次/秒(`train_open_app.rate_limit` 可调);
- 实现:Redis 滑动窗口(Lua 原子计数),超限 → `3002`;
- **不能直接复用框架 `@RateLimiter`**:其 `getCombineKey()` 只支持 `LimitType.IP`(追加客户端 IP)或全局,无 `appId` 维度;且超限抛 `ServiceException`,经全局异常处理返回 `code=500`,与 `3002` 契约不符。本设计自建 `OpenApiRateLimiter`(Servlet Filter 或 AOP),复用框架已有的 `RedisScript<Long> limitScript` bean,key 为 `open:rate:{appId}`,超限直接写 `OpenApiResponse.error(3002, ...)`;
- 登录接口额外独立限流(同 IP 10 次/分钟)防爆破(可用框架 `@RateLimiter(limitType=IP)` 实现,但需单独映射错误码)。
---
## 6. 鉴权与权限控制
| 项 | 设计 |
|---|---|
| 认证方式 | `appId` + `appSecret` 换取出站令牌,后续请求 `Authorization: Bearer {accessToken}` |
| 密钥存储 | 仅存 BCrypt 散列 + 掩码;页面只在新增/重置时展示一次明文 |
| 令牌 | 256 位随机不透明串;Redis 存 `appId → sha256(token)`,TTL 7200s;DB 冗余审计 |
| 令牌失效 | 同 `appId` 重新登录 → 旧令牌立即失效;管理员停用应用 → 该应用全部令牌失效 |
| 校验流程 | 过滤器解析 Bearer → 查 Redis → 比对散列 → 校验应用状态 → 注入 `appId` 到请求上下文 |
| 令牌提醒 | 响应头 `X-Token-Remaining`(剩余秒数),剩余 ≤ 600s 时提示重新登录 |
| 权限范围 | `train_open_app_scope`:`CLASS` 限制 3.2 可查班级;`SUBJECT` 限制 3.3/3.4 可报科目;越权 `2001` |
| 数据隔离 | 所有查询/写入强制带 `app_id` 条件;`reportId` 跨应用不可见(`2002`) |
| 免鉴权白名单 | 仅 `POST /api/open/v1/auth/token` |
| 停用应用 | `status=0` → `1004`(登录)或 `1006`(存量令牌失效) |
| 框架放行方式 | Controller 标注 `@Anonymous`(`PermitAllUrlProperties` 自动收集 → `permitAll`),无需改动 `SecurityConfig`;`JwtAuthenticationTokenFilter` 按 `login_tokens:{token}` 查 Redis,未命中时 `loginUser` 为 null 且不抛异常,请求继续下传,由自有过滤器接管鉴权 |
| 过滤器实现 | 参照 `ruoyi-iot` 的 `OpenApiAuthFilter`:`implements Filter` + `@Component`,按路径前缀 `/api/open/` 判定,非本前缀直接放行;校验失败按规范返回 HTTP 200 + `code`(与既有 `OpenApiAuthFilter` 的 401 风格不同,需在文档中显式声明) |
| 上下文传递 | 校验通过后将 `appId`、`tokenHash` 写入 `request.setAttribute`,Controller 通过参数或 `RequestContextHolder` 读取;不写入 Spring Security 上下文 |
---
## 7. 错误码与异常处理
### 7.1 错误码(与规范 2.1 一致)
| code | 含义 | 触发场景 | 返回位置 |
|---|---|---|---|
| 0 | 成功 | 正常处理(含 `unmatched>0` 的汇总) | 全部 |
| 1001 | 缺少必填参数 | 必填字段缺失 | 全部 |
| 1002 | 参数格式错误 | 类型/长度/枚举/分页/互斥参数错误 | 全部 |
| 1003 | 应用未注册 | `appId` 不存在 | 3.1 |
| 1004 | 应用已停用 | `status=0` | 3.1 |
| 1005 | 应用密钥错误 | BCrypt 校验失败 | 3.1 |
| 1006 | 令牌无效或已过期 | 缺失/不匹配/过期 | 3.2–3.5 |
| 2001 | 无权限访问该资源 | 班级/科目不在授权范围;实时上报未开启 | 3.2/3.3/3.4 |
| 2002 | 数据不存在 | `batchId`/`reportId` 无记录 | 3.5 |
| 2003 | 数据冲突 | 同 `batchId` 元数据不一致 | 3.3 |
| 2004 | 学员不存在或不在训 | 单条查询/认领场景提示 | 3.5(单条语义) |
| 2005 | 成绩已归档锁定 | 锁定后内容变更重传 | 3.3/3.4 |
| 3001 | 上报数据校验失败 | 字段业务规则不满足(含批内 `reportId` 重复) | 3.3/3.4 |
| 3002 | 调用频率超限 | 超过 `rate_limit` | 全部 |
| 3003 | 消息体超过大小限制 | > 1 MB;批量 > 200;实时 > 20 | 3.3/3.4 |
| 3004 | 考试时间超出允许范围 | 时间窗/跨度/先后关系不合法 | 3.3/3.4 |
| 3005 | 考试内容 JSON 不合法 | 非对象 / > 32 KB / 含敏感信息 | 3.3/3.4 |
| 4000 | 服务端内部错误 | 未预期异常、DB 异常 | 全部 |
| 4001 | 下游系统不可达 | 平台依赖服务不可用(预留,本期无下游依赖) | 全部 |
### 7.2 HTTP 语义
- 业务错误一律 HTTP 200 + `code`;`reqId` 同时返回并落日志;
- 仅网关/容器层限制返回非 200(如 Nginx 413 请求体过大),此时调用方按 `3003` 处理;
- 禁止用 HTTP 4xx/5xx 表达业务结果(避免调用方误判重试策略)。
### 7.3 异常处理
| 异常类型 | 处理 |
|---|---|
| JSON 解析失败 / 类型不符 | `1002`,不回显原始报文 |
| 参数校验(Bean Validation + 业务校验) | 首个错误返回对应 code,`msg` 指明字段与原因 |
| 唯一键冲突 | 3.3/3.4 按 `duplicated` 计入;批次头冲突按首次结果回放 |
| 数据库异常/超时 | 事务回滚,返回 `4000`,日志带 `reqId`、`appId`、`bizKey` |
| 归档失败 | 不影响上报结果,保留 `RECEIVED`,定时任务重试并告警 |
| 幂等重放 | 不重复入库,返回首次汇总(`duplicated` 口径一致) |
### 7.4 事务边界
- 3.3:批次头 + 明细同一事务;分块插入(每 100 条);
- 3.4:明细批量插入同一事务;归档在事务外异步/同步独立处理;
- 3.1:令牌写入(Redis + DB)先 DB 后 Redis,Redis 失败则整体失败返回 `4000`。
---
## 8. 性能与容量
| 项 | 设计 |
|---|---|
| 索引 | 见第 3 章;覆盖 `(app_id, received_time)`、`(app_id, batch_id)`、`report_id` 唯一索引、状态扫描索引 |
| 大字段 | `content` 不参与列表查询;详情单独查询 |
| 批量写入 | 明细 `INSERT` 每 100 条一批;批次汇总最后一次性更新 |
| 幂等预查 | 按 `reportId` 分块 `IN` 预查,减少逐条 SELECT |
| 缓存 | 应用配置(含限流值、回调开关)Redis 缓存 60s;令牌 O(1) 校验 |
| 分页 | 限制 `pageSize ≤ 200`,避免深分页与全表扫描 |
| 异步化 | 调用日志、归档、告警异步执行,不阻塞主链路 |
| 估算 | 单应用日均 1 万条、峰值 20 次/秒;`train_exam_report` 年增量约 365 万行(含 content),按月分区可控制单表规模 |
| 归档任务 | 1 分钟扫描 `MATCHED + RECEIVED`,每次 ≤ 500 条,失败重试 3 次并告警 |
| 慢查询 | 开启慢 SQL 日志(> 500ms),对 `3.5` 时间窗查询重点监控 |
---
## 9. 安全设计
| 项 | 措施 |
|---|---|
| 传输 | 仅 HTTPS;禁用 HTTP 明文入口 |
| 密钥 | `appSecret` 仅存 BCrypt 散列;新增/重置仅展示一次;支持轮换;连续失败锁定 |
| 令牌 | 不透明随机串(非可预测 JWT 载荷)、2h 过期、单应用单令牌、可即时吊销 |
| 越权 | 全链路 `app_id` 过滤;班级/科目授权校验;跨应用 `reportId` 一律 `2002` |
| 注入 | MyBatis `#{}` 参数绑定;排序/过滤字段白名单,不拼接用户输入 |
| 请求体 | 1 MB 上限(Nginx + 应用);`content` 32 KB;批量条数上限 |
| 敏感数据 | 名单接口手机号掩码、证件号掩码;证件号只存 MD5/Hash 与掩码;日志不记录证件号、手机号、`content` |
| `content` 合规 | 强制 JSON 对象;扫描身份证/手机号模式拒绝(3005);页面展示转义,防 XSS |
| 限流与防刷 | 应用级 20 次/秒;登录接口 IP 级 10 次/分钟 |
| 重放 | `reportId`/`batchId` 幂等键天然防重放;令牌有效期短 |
| 审计 | `train_open_api_log` 记录 `reqId`、`appId`、路径、业务键、返回码、耗时、IP;保留 90 天 |
| 权限分离 | 平台后台"应用授权"仅管理员可见;密钥重置留操作日志 |
---
## 10. 评审确认清单
| # | 待确认项 | 影响 |
|---|---|---|
| 1 | "在训"是否包含待报到(`enrollment_status=0`) | 3.2 名单范围 |
| 2 | `studentNo` 的权威字段(`card_no` 还是 `s_id`) | 3.2 输出 |
| 3 | `idCard` 是否允许返回明文(当前默认掩码) | 3.2 输出与合规 |
| 4 | 科目字典由平台后台维护还是沿用 `sys_dict_data` | 表结构与页面 |
| 5 | 归档映射规则(科目 → 考核项)与未映射时的处理 | 归档覆盖率 |
| 6 | 成绩锁定触发点(结课自动锁定 or 人工) | 状态机与 2005 |
| 7 | 是否需要 IP 白名单(规范未要求) | 安全加固项 |
| 8 | `train_exam_report` 是否按月分区/冷热分离 | 容量与运维 |
---
## 11. 兼容性审查与修改建议(RuoYi-Vue 3.9.2 + MySQL 5.7)
> 审查基准:本仓库实际代码与配置(Spring Boot 4.1.0 / Java 17 / MyBatis / Druid / Redis / PageHelper;数据库按 MySQL 5.7),以及规范《外部实训系统数据接口规范》。
> 结论:**接口契约(路径、字段、错误码)无需变更**;但返回结构、分页、鉴权放行、异常映射 4 处必须按框架实际能力调整,数据层有 6 处需按 MySQL 5.7 修正(已在第 2/3/4/5/6 章同步修订)。
### 11.1 框架兼容性问题(R1–R10)
| 编号 | 问题 | 证据(仓库实际) | 影响 | 修改建议 | 优先级 | 文档落点 |
|---|---|---|---|---|---|---|
| R1 | 直接复用 `AjaxResult` 会返回 `code=200` 而非 `0`,且缺少 `reqId`/`ts` | `AjaxResult` 成功码取 `HttpStatus.SUCCESS`(=200),`isSuccess()` 按 200 判定 | 调用方按 `code!=0` 判失败,所有成功请求被误判 | 新增 `OpenApiResponse<T>{code,msg,data,reqId,ts}`,`code=0` 表成功;不复用 `AjaxResult` | P0 | 4.1(已修订) |
| R2 | 复用 `TableDataInfo`/`getDataTable()` 会输出 `rows` 而非 `list`,且无 `pageNo/pageSize` | `TableDataInfo` 字段为 `total/rows/code/msg` | 3.2、3.5 响应结构不符合规范 | 自定义 `OpenApiPage{total,pageNo,pageSize,list}`,由 `PageInfo` 映射 | P0 | 3.x/4.3/4.6/5.1(已修订) |
| R3 | `BaseController.startPage()` 读 `pageNum`/`orderByColumn`/`isAsc`,与规范 `pageNo` 且固定排序冲突 | `PageUtils`/`TableSupport` 按 `pageNum` 解析,`orderBy` 由请求参数控制 | 页码取不到(永远第 1 页);排序参数形成注入面 | 显式 `PageHelper.startPage(pageNo, pageSize)`(不传 orderBy)+ `new PageInfo<>(list)`;`finally` 中 `PageUtils.clearPage()` | P0 | 5.1(已修订) |
| R4 | 全局异常处理器把校验错误映射为 `code=500`,且无 `reqId`;不含 `HttpMessageNotReadableException` 处理 | `GlobalExceptionHandler`:`ServiceException`/`BindException`/`MethodArgumentNotValidException` 均返回 `AjaxResult.error(...)` | 参数错误返回 500,调用方按"服务端错误"重试 | 新增 openapi 专用 `@RestControllerAdvice(basePackages="...open")` + `@Order`:`MethodArgumentNotValid/Bind/HttpMessageNotReadable/TypeMismatch` → 1001/1002,`MaxUploadSizeExceeded` → 3003,`DuplicateKeyException` → 按幂等处理,其它 → 4000 | P0 | 4.1/7.3(已修订) |
| R5 | `/api/open/v1/**` 未放行时会被 Spring Security 拦截并返回框架 401 结构 | `SecurityConfig` 逐个 matcher 放行(`/api/iot/v1/**`、`/api/lock/v1/**` 等) | 未放行 → 直接 401,且响应体不是规范结构 | Controller 标注 `@Anonymous`(`PermitAllUrlProperties` 自动收集),或显式追加 matcher;**禁止**改造 `JwtAuthenticationTokenFilter` | P0 | 6(已修订) |
| R6 | 限流注解不支持 `appId` 维度,且超限码不符合规范 | `RateLimiterAspect.getCombineKey()` 仅 `LimitType.IP`/全局;超限抛 `ServiceException` | 多实例共用 appId 时按 IP 计数不准确;返回 code=500 | 自建 `OpenApiRateLimiter`(Filter/AOP),复用 `RedisScript<Long> limitScript`,key `open:rate:{appId}`,超限返回 3002 | P1 | 5.6(已修订) |
| R7 | XSS 过滤器会改写 JSON 请求体,破坏 `content` 原样存储与 `content_md5` | `XssFilter` 对 POST/PUT 包装 `XssHttpServletRequestWrapper`,逐字符串转义 | `content` 入库内容与外部系统原文不一致,幂等内容比对失效 | `application.yml` 的 `xss.excludes` 增加 `/api/open/**` | P0 | 4.1(已修订) |
| R8 | 无登录上下文,`SecurityUtils.getUsername()`/`@Log` 不可用 | `@Log` 依赖登录用户写 `sys_oper_log`;`SecurityUtils` 无认证时抛异常 | 审计字段写入失败或抛异常 | 操作人固定 `openapi`;审计落 `train_open_api_log`(异步),不使用 `@Log` | P1 | 4.1/3.7(已修订) |
| R9 | 实体与后台管理页需遵循框架约定 | 现有实体继承 `BaseEntity`(`createBy/createTime/updateBy/updateTime/remark/params`);列表页用 `@PreAuthorize("@ss.hasPermi(...)")` + `@Excel` | 与既有后台风格不一致,导出/权限缺失 | 实体继承 `BaseEntity`;后台菜单权限键 `train:openApp:*`、`train:examSubject:*`、`train:examReport:*`;字段加 `@Excel`;枚举用 `sys_dict_data` 供 `dict-tag` 渲染 | P1 | 3.x、10(待补充评审) |
| R10 | Redis 访问方式不统一 | 框架提供 `RedisCache`(序列化、TTL 封装);登录令牌键前缀 `login_tokens:` | 直接使用 `RedisTemplate` 易出现序列化/键冲突 | 令牌、限流、应用缓存统一走 `RedisCache`,键前缀 `open:` | P1 | 6(已修订) |
### 11.2 MySQL 5.7 兼容性问题(D1–D10)
| 编号 | 问题 | MySQL 5.7 事实 | 影响 | 修改建议 | 优先级 | 文档落点 |
|---|---|---|---|---|---|---|
| D1 | `CHECK` 约束不生效 | 5.7 解析但忽略 `CHECK` | 时间/分数/枚举非法值可直接写入 | 应用层(DTO 校验 + Service 兜底)实现全部检查;DDL 保留仅作其他方言兼容 | P0 | 2.3/3.6(已修订) |
| D2 | `content` 使用 `JSON` 会破坏"原样存储" | `JSON` 列会规范化键序、空白并强制合法性 | 与外部原文不一致,`content_md5` 比对失真 | 统一 `TEXT NOT NULL`(32 KB < 65,535 字节),应用层 `JsonNode.isObject()` 校验 | P0 | 3.6/3.9(已修订) |
| D3 | `TIMESTAMP` 2038 上限与时区隐式转换 | `TIMESTAMP` 范围 1970–2038,受 `time_zone` 影响 | 审计/过期时间长期不可用,跨时区显示偏差 | 审计字段 `DATETIME DEFAULT CURRENT_TIMESTAMP`;业务时间用 `BIGINT` 毫秒 | P1 | 3.x/5.4(已修订) |
| D4 | `ADD COLUMN IF NOT EXISTS`、`CREATE INDEX IF NOT EXISTS` 不支持 | 5.7 无该语法 | 迁移脚本重复执行报错 | `information_schema` 判断 + `PREPARE/EXECUTE`(参照 `sql/train/v2/022b_*.sql`) | P1 | 3.9(已修订) |
| D5 | 无窗口函数、无 CTE | 5.7 不支持 `ROW_NUMBER()`、`WITH` | "取最新在训记录"等实现方式不可用 | `ORDER BY join_time DESC LIMIT 1`;统计用子查询/两次聚合 | P1 | 4.7/3.9(已修订) |
| D6 | 分区与唯一键冲突 | 5.7 要求唯一键包含分区列 | 按 `received_time` 分区将无法保留 `report_id` 全局唯一 | **本期不分区**;热表 12 个月 + 归档表 `train_exam_report_his` | P1 | 3.6(已修订) |
| D7 | 字符集/排序规则需与既有表一致 | 既有表 `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;连接串 `characterEncoding=utf8` | JOIN 隐式转换导致索引失效;emoji 写入失败 | 新表显式 `CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`;跨表 `student_id/plan_id` 同字符集;需要 4 字节时调整连接串 | P1 | 3.9/5.4(已修订) |
| D8 | `TINYINT(1)` 被 JDBC 映射为 Boolean | `tinyInt1isBit` 默认 true | `pass`/`retake`/`status` 实体类型随驱动变化 | 统一 `SMALLINT` + Java `Integer`,不使用 `TINYINT(1)` | P2 | 3.5/3.6/5.4(已修订) |
| D9 | 索引长度与哈希列膨胀 | utf8mb4 单字符 4 字节;`CHAR(32)` 哈希列占 128 字节 | 空间浪费、唯一索引偏大 | 哈希列 `CHAR(32) CHARACTER SET ascii`;`VARCHAR` 长度按业务收紧(已 ≤100) | P2 | 3.6/3.9(已修订) |
| D10 | `JSON` 函数/类型别名与 `NUMERIC` 写法 | `NUMERIC` 为 `DECIMAL` 同义词;`JSON_TABLE` 不存在 | DDL 风格不统一 | DDL 统一 `DECIMAL`;不使用 `JSON_TABLE` | P2 | 3.9(已修订) |
### 11.3 参数校验、异常与安全性完整性(V1–V8)
| 编号 | 问题 | 影响 | 修改建议 | 优先级 |
|---|---|---|---|---|
| V1 | 3.3/3.4 未定义"校验失败是否部分入库"的明确边界(文档仅写整批校验整批写入) | 歧义:实现可能边校验边插入 | 明确:**格式/业务校验失败 → 整批回滚(3001/3004/3005)**;`duplicated`/`unmatched` 属正常计数,不算失败 | P0 |
| V2 | 未约定请求体超限在网关被拦截时的响应形态 | 调用方收到 413 HTML,无法解析 | 网关 `client_max_body_size 2m` + 应用层 `Content-Length` 预检返回 3003;文档注明 413 按 3003 处理 | P1 |
| V3 | `idCard` 明文传输与落库风险 | 违反"平台不存明文"的最小化原则 | 只用于交叉校验:入参即算 `MD5`,仅存 `id_card_md5` + 掩码;日志与 `content` 中禁止出现 | P1 |
| V4 | 缺少请求链路标识的统一规范 | 排障困难 | `reqId` 由过滤器生成(`RQ-yyyyMMdd-8位`),响应体、响应头 `X-Request-Id`、`train_open_api_log` 三处一致;下游调用透传 | P1 |
| V5 | 令牌校验对"已停用应用/已轮换密钥"的存量令牌失效时机未定义 | 停用后仍可调用 2 小时内有效 | 过滤器每次校验都读应用状态(Redis 缓存 60s);停用/重置密钥时主动删除 `open:token:{appId}` | P1 |
| V6 | 3.5 未限制 `reportId` 跨应用访问的响应差异 | 可能探测他人 `reportId` 是否存在 | 统一返回 2002(不区分"不存在"与"不属于本应用"),并记录审计日志 | P1 |
| V7 | 未定义重复 `batchId` 但 `reports` 内容不同的判定口径 | 可能重复入库或静默丢弃 | 明确:仅比对批次元数据(`reportTime/periodStart/periodEnd/resend/reports.length`);一致→回放首次汇总,不一致→2003;明细差异由 `reportId` 幂等兜底 | P0 |
| V8 | 归档与锁定缺少并发保护说明 | 定时任务与实时归档并发重复落分 | 归档使用 `data_version` 乐观锁 + `train_student_score` 唯一键 UPSERT;锁定时校验 `score_status!=LOCKED` | P1 |
### 11.4 本仓库证据索引(便于评审核对)
| 主题 | 文件 |
|---|---|
| 成功码/响应结构 | `ruoyi-common/src/main/java/com/ruoyi/common/core/domain/AjaxResult.java`、`.../core/page/TableDataInfo.java` |
| 分页 | `ruoyi-common/src/main/java/com/ruoyi/common/core/controller/BaseController.java`、`.../utils/PageUtils.java` |
| 全局异常 | `ruoyi-framework/src/main/java/com/ruoyi/framework/web/exception/GlobalExceptionHandler.java` |
| 安全放行 | `ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java`(100–118 行)、`.../config/properties/PermitAllUrlProperties.java` |
| 限流 | `ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/RateLimiterAspect.java`(76–88 行) |
| 开放接口鉴权先例 | `ruoyi-iot/src/main/java/com/ruoyi/iot/openapi/filter/OpenApiAuthFilter.java` |
| XSS | `ruoyi-common/src/main/java/com/ruoyi/common/filter/XssFilter.java` 及 `application.yml` 的 `xss.excludes` |
| 数据源/字符集 | `ruoyi-admin/src/main/resources/application-druid.yml`(`characterEncoding=utf8`) |
| MySQL DDL 风格 | `sql/train/v2/019_signage.sql`(`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)、`sql/train/v2/022b_add_accommodation_lock_result_mysql.sql`(幂等 DDL) |
### 11.5 修订实施顺序(建议)
| 阶段 | 内容 | 关联项 |
|---|---|---|
| 第一步(P0,编码前必须定稿) | 响应模型、分页映射、异常映射、鉴权放行、XSS 排除、CHECK 兜底、`content` 改 `TEXT` | R1–R5、R7、D1、D2、V1、V7 |
| 第二步(P1) | 限流自建、审计与操作人、归档/锁定并发、令牌失效策略、时间类型、迁移脚本、字符集 | R6、R8、R10、D3–D7、V2–V6、V8 |
| 第三步(P2) | 哈希列 ascii、`SMALLINT` 统一、后台菜单与字典、导出注解 | R9、D8–D10 |
---
## 附录 A:接口清单
| 序号 | 接口名称 | 方法 | 路径 | 鉴权 | 主要表 |
|---|---|---|---|---|---|
| 3.1 | 登录获取访问令牌 | POST | `/api/open/v1/auth/token` | 无 | `train_open_app`、`train_open_token` |
| 3.2 | 在训学员查询 | GET | `/api/open/v1/student/list` | Bearer | `train_student`、`train_enrollment`、`train_class_plan` |
| 3.3 | 考试成绩批量上报 | POST | `/api/open/v1/exam/report` | Bearer | `train_exam_report_batch`、`train_exam_report` |
| 3.4 | 实时成绩上报 | POST | `/api/open/v1/exam/report/realtime` | Bearer | `train_exam_report` |
| 3.5 | 上报结果查询 | GET | `/api/open/v1/exam/result` | Bearer | `train_exam_report_batch`、`train_exam_report` |
## 附录 B:数据字典
| 字段 | 取值 |
|---|---|
| `appType` | `VR` / `INSPECT3D` / `PRACTICE` / `CAPSTONE` / `OTHER` |
| `gender` | `MALE` / `FEMALE` |
| `matchStatus` | `MATCHED` / `UNMATCHED` |
| `scoreStatus` | `RECEIVED` / `ARCHIVED` / `LOCKED` |
| `bizType` | `PRACTICE` |
| `reportChannel` | `REALTIME` / `PERIODIC` |
| `retake` / `resend` | `true` / `false`(库内 1/0) |
| `scopeType` | `CLASS` / `SUBJECT` |