# 铁路房建学员管理系统 · 外部实训系统数据接口设计 > 文档类型:接口与数据表设计(本阶段不含代码实现) > 依据文档:《铁路房建学员管理系统-外部实训系统数据接口规范》(文内版本 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`:`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 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{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 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` |