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

66 KiB
Raw Blame History

铁路房建学员管理系统 · 外部实训系统数据接口设计

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