Files
xiaotutongxue/docs/03-接口设计.md
T
lovife 0bbac7d228 feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)
客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤,
  于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所
  学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。
  现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户:
  只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。
- OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对
  平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() ——
  isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通
  用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补
  TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。
- 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。
  按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校;
  学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。
- 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn
  吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。
  E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。

二、PC 端走查体验
- 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口,
  菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。
- 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。
  403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。
- 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在
  卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据
  铃铛;配色按客户要求还原。

顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于
PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false,
而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义
竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。

验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过;
e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、
e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。
详见 docs/交接摘要.md §一·三十三 与 §一·三十四。
2026-10-09 17:29:20 +08:00

3055 lines
131 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.0 | 日期:2026-09-28
> 对应主文档:《数据信息填报系统PRD.md》§8 接口清单(V2.3)
> 读者:前后端开发、测试
> **本文档是 PRD §8 的展开实现层**——§8 定义了「有哪些接口」,本文档定义「每个接口收发什么字段」。
---
## 目录
| 节 | 内容 |
|---|---|
| §1 | 通用约定(域名、鉴权、响应、分页、幂等、限流、脱敏) |
| §2 | 枚举字典全集 |
| §3 | 通用数据结构(Schema 定义) |
| §4 | 接口总览(**121 个**) |
| §5 | 认证与账号(13) |
| §6 | 组织与租户(15) |
| §7 | 任务与配置(30) |
| §8 | 名册(12) |
| §9 | 填报端 · 小程序(16) |
| §10 | 班主任移动端(10) |
| §11 | 审核(9) |
| §12 | 校验 / 导出 / 通知(12) |
| §13 | **本次补齐的缺口接口(21)** |
| §14 | 开放接口 · 供认定机构对接(4) |
| §15 | 错误码全集 |
---
## §1 通用约定
### 1.1 域名与环境
| 环境 | 域名 | 用途 |
|---|---|---|
| 生产 | `https://api.tianbaotong.cn/api/v1/**` | 正式 |
| 开放接口 | `https://api.tianbaotong.cn/open/v1/**` | 认定机构对接 |
| 测试 | `https://api-test.tianbaotong.cn/api/v1/**` | 联调 |
### 1.2 鉴权
| 端 | 方式 | Header |
|---|---|---|
| PC 管理端 / 班主任 PC | 账号密码 → JWT | `Authorization: Bearer <token>` |
| 微信小程序(学生 / 班主任) | `code2session` → JWT | `Authorization: Bearer <token>` |
| 开放接口(机构) | appKey + HMAC-SHA256 | `X-App-Key` / `X-Timestamp` / `X-Nonce` / `X-Sign` |
**JWT Payload**
```json
{
"uid": 10086,
"tenantId": 1001,
"orgIds": [2001, 2002],
"roles": ["SCHOOL_ADMIN"],
"userType": 2,
"exp": 1790000000
}
```
| 字段 | 说明 |
|---|---|
| `uid` | 用户 ID |
| `tenantId` | **租户 ID,服务端强制校验,防越租户** |
| `orgIds` | 数据权限范围(本人 / 本班 / 本院系 / 全租户) |
| `roles` | 角色码数组 |
| `userType` | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生 |
**Token 有效期**:PC 端 8 小时(可续期)/ 小程序端 7 天(静默续期)
#### `X-Tenant-Id`:平台运营的「当前学校」(可选,仅平台运营)
平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户过滤对它整体关闭。于是
「组织架构」「账号管理」这类**本身就是租户内**的接口,平台不指明学校时会拿到
**所有租户混在一起**的数据 —— 而这看不出错(只有一所学校有数据时,它与学校管理员看到的完全一样)。
| 项 | 约定 |
|---|---|
| Header | `X-Tenant-Id: <tenantId>` |
| 谁能用 | **只认平台运营**。其它身份带这个头一律忽略(改了也不生效,不是"报错") |
| 作用 | 本次请求的租户上下文换成该租户:`/orgs/**`、`/users/**`、`/tasks/**` 等按它过滤与写入 |
| 不带 / 传 `0` | 不切换(`0` 视为"平台自己")。平台的租户列表、全量日志等跨租户能力照旧 |
| 非法值 | 非数字、或租户不存在/已停用 → `40001` |
| 与 token 的关系 | 只对平台生效,**不能**用来访问别的租户(学校管理员带它不会越权) |
| 权限怎么判 | 切换之后**不再享受"平台超管"捷径**,一律按 `PLATFORM_ADMIN` 声明的权限码判 —— 所以那些码必须真的够用(例如「查看填报进度看板」要 `task:view` + `progress:viewAll`) |
> 例:`GET /api/v1/orgs/tree` + `X-Tenant-Id: 1` → 返回租户 1 的组织树;
> 不带该头 → 返回平台自己租户的组织(空树),**不再**把各租户的组织混在一起。
### 1.3 统一响应
```json
{ "code": 0, "msg": "success", "data": { } }
```
| 情况 | code | HTTP |
|---|---|---|
| 成功 | `0` | 200 |
| 业务错误(客户端) | `4xxxx` | 200(**业务错误不占用 HTTP 状态码**) |
| 服务端错误 | `5xxxx` | 200 |
| 未登录 | `40101` | 401 |
| 无权限 | `40301` | 403 |
| 限流 | `42901` | 429 |
> **实现约定**:HTTP 状态码只表达传输层语义(401/403/429/500),**业务判定一律看 `code`**。这样前端拦截器只需处理两类分支,减少漏判。
**校验失败的标准结构**(`40001`)
```json
{
"code": 40001,
"msg": "存在 2 项必须处理的材料问题",
"data": {
"canSubmit": false,
"blocking": [ { "materialCode": "SCHOOL_ROLL", "ruleCode": "VAL-05", "title": "缺少材料:学籍表" } ],
"warnings": [ { "materialCode": "PHOTO1", "ruleCode": "VAL-08", "title": "照片分辨率偏低" } ]
}
}
```
### 1.4 分页
**请求**:`?pageNum=1&pageSize=20&sortBy=create_time&sortOrder=desc`
| 参数 | 类型 | 默认 | 约束 |
|---|---|---|---|
| `pageNum` | int | 1 | ≥1 |
| `pageSize` | int | 20 | 1–200,超出按 200 |
| `sortBy` | string | 见各接口 | 白名单字段,非法值忽略 |
| `sortOrder` | string | `desc` | `asc` / `desc` |
**响应**
```json
{ "list": [ ], "total": 128, "pageNum": 1, "pageSize": 20 }
```
> **注意**:分页响应直接放在 `data` 里(不是 `data.page`),与 §8.1 约定一致。
### 1.5 幂等
需带 `Idempotency-Key: <uuid>` 请求头的接口:
| 接口 | 说明 |
|---|---|
| `POST /mp/submissions/{id}/submit` | 防重复提交 |
| `POST /mp/files/upload-token` | 防重复建文件记录 |
| `POST /exports` | 防重复打包(**避免重复计费**) |
| `POST /mp/roster/match` | 防重放 |
| 所有 `POST` 审核动作 | 防重复审批 |
**幂等窗口**:24 小时。重复请求返回 **首次结果 + `code: 40009`**,`data.idempotentReplay: true`。
### 1.6 限流
| 场景 | 阈值 | 超限响应 |
|---|---|---|
| 登录 | 5 次/分钟/IP + 账号锁定 5 次 | `42901` |
| **门禁核验** | **同 openid 10 次/小时;同 IP 20 次/分钟** | `40071` |
| 上传凭证 | 200 次/分钟/用户 | `42901` |
| OCR | 30 次/分钟/用户 | `42901` |
| 催报 | 10 次/分钟/任务 | `42901` |
| 导出创建 | 5 次/分钟/租户 | `42901` |
> **门禁限流是安全设计的一部分**(防身份证号枚举探测),阈值调低是刻意的。上线后按真实人数标定。
### 1.7 脱敏规则
| 字段 | 明文传入 | 响应返回 |
|---|---|---|
| 身份证号 | ✅ 允许(HTTPS,仅门禁/导入场景) | ❌ **一律返回脱敏值** `idCardMask`,形如 `3610292009******10` |
| 手机号 | ✅ 允许 | ❌ 返回脱敏值 `138****8888` |
| 姓名 | ✅ | ✅ 明文(业务必需) |
**例外**:`GET /audit/submissions/{id}` 等审核场景,管理员/班主任可见**完整身份证号**(审核必需),但仍**不返回明文**——返回 `idCardFull` 需通过 `GET /audit/submissions/{id}/idcard` 单独获取,该接口**单独记录审计日志**。
> 这条设计的意义:审核人员看材料图片时不需要抄身份证号,真需要核验时走单独接口、留痕可追溯。
### 1.8 其他约定
| 项 | 约定 |
|---|---|
| ID 类型 | `int64`,JSON 中以 number 传输(生成规则保证 < 2^53) |
| 时间 | ISO 8601 带时区,`2026-09-28T14:30:00+08:00`;日期型 `2026-09-28` |
| 体积 | 统一 **KB**(`finalSizeKb`),原始字节用 `rawSizeBytes` |
| 比例 | 字符串百分比 `"97.1%"`(避免前端再乘 100) |
| 空值 | 统一 `null`,不用空字符串 `""` |
| 枚举 | **数字码**(与 DDL `smallint` 对齐),文案由前端映射;**唯一例外:`materialCode` 用字符串码**(可读性优先,见 §2.1) |
| 排序 | 数组顺序即业务顺序,不额外传 `sortNo` 以外的字段 |
| 批量上限 | 单次批量操作 ≤ 200 条;超限返回 `40011` |
**布尔字段命名**:统一 `isXxx` / `hasXxx` / `needXxx`,**不用 0/1**(JSON 原生 true/false)。
---
## §2 枚举字典全集
> 与数据库 `smallint` 列严格对齐。**「无」是合法业务值**,见 §2.6。
### 2.1 材料类型 `materialCode`(字符串码)
| 编码 | 名称 | 别名(识别用) | 默认压缩档 |
|---|---|---|---|
| `PHOTO1` | 一寸照片 | 一寸电子照片 / 证件照 / 免冠照 / 1寸照片 | P1 |
| `IDCARD` | 身份证 | 身份证正面 / 身份证反面 / 身份证人像面 | P2 |
| `SCHOOL_ROLL` | 学籍表 | 学籍证明 / 在读证明 / 在校证明 | P4 |
| `XJW_BA` | 学信网备案表 | 学信网 / 学历备案表 / 电子注册备案表 | P4 |
| `WORK_PROOF` | **工作证明** | 个人申报表 / 职业技能等级个人申报表 | P3 |
| `OTHER_PROOF` | 其它佐证材料 | 其他 / 补充材料 | P3 |
> ⚠️ **`WORK_PROOF` 的名称陷阱**:按机构模板,**「职业技能等级个人申报表」的规范文件名是「工作证明」**。这是机构既有习惯,不要"修正"。
### 2.2 收集模式 `collectMode`
| 值 | 含义 | 审核流 |
|:--:|---|---|
| `1` | 学校收集 | 学生 → 班主任初审 → 管理员终审 |
| `2` | 社会收集 | 提交后**直接进管理员终审队列**(跳过初审)。⚠️ **一期不启用** |
### 2.3 认证类型 `certType`
| 值 | 含义 | 面向谁 | 特有约束 |
|:--:|---|---|---|
| `1` | 自主认定 | 本单位职工 / 本校学生 | 要**学籍表** |
| `2` | **社会评价** | **社会人员** | 材料为**学信网备案表** + 其它佐证(**无学籍表**)。⚠️ **一期不启用**(零样本材料) |
| `3` | **专项职业能力** | 专项能力考核 | ⚠️ **无工种 / 无等级 / 无补考科目(0 科)**。⚠️ **一期不启用** |
> ⚠️ **`社会评价`(认定类型)≠ `社会收集`(`collectMode`)**:
> 前者决定**填什么材料**,后者决定**谁审核**(有无班主任初审层)。二者可任意组合。
### 2.4 填报单状态 `submissionStatus`
| 值 | 含义 | 可流转到 |
|:--:|---|---|
| `0` | 草稿 | 1 |
| `1` | 待初审 | 2 / 4 |
| `2` | 待终审 | 3 / 4 |
| `3` | 已通过 | 5 |
| `4` | 已驳回 | 1(学生重提交) |
| `5` | 已归档 | — |
### 2.5 问题级别 `severity`
| 值 | 含义 | UI 色 | 阻断提交 |
|:--:|:--:|:--:|:--:|
| `1` | 信息 | 蓝 | 否 |
| `2` | 警告 | 黄 | 否(可 `confirmWarning` 强提) |
| `3` | **错误** | 红 | **是** |
### 2.6 「无」的语义(三条铁律)
> 这是最容易实现错的地方,单列说明。
| 场景 | 错误做法 | 正确做法 |
|---|---|---|
| 学历为"无" | 存 `null` | 存字符串 `"无"`——**它是合法业务值** |
| 专项职业能力无工种 | 存 `null` | 存 `null` + `certType=3`,**由 `certType` 决定字段是否参与校验** |
| 补考科目为"无" | 存 `null` | 存 `[]` 空数组(**0 科是合法值**) |
**判定原则**:**「用户选择了无」与「用户没填」必须能区分**。前者是有效数据,后者是缺失。
### 2.7 其他枚举
| 枚举 | 取值 |
|---|---|
| `userType` | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生 |
| `roleCode` | `PLATFORM_ADMIN` / `SCHOOL_ADMIN` / `HEAD_TEACHER` / `DEPUTY_TEACHER` / `STUDENT` / `AUDITOR` |
| `taskStatus` | `0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` **已暂停** (与后端 `CollectTask.STATUS_*` 及 DDL 列注释一致)|
| `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 |
| `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 |
| `auditNode` | `1` 初审 / `2` 终审 |
| `auditAction` | `1` 通过 / `2` 驳回 / `3` 批量通过 / `4` 批量驳回 / `5` 转交 / `6` 代传 / `7` **撤回** |
| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 |
| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 |
| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 |
| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` **催报** / `6` **导出完成** / `7` 加入名单审批 |
| `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 |
| `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
| `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 |
> ✅ **`notifyBizType` 的 `5`/`6` 曾经在本文件内部对调过(已修正,2026-09-30 客户确认)**:
> 本表旧版写「`5` 导出完成 / `6` 催报」,与 §13.5 消息中心示例、落库值
> `collect_notify_record.biz_type`(`5` 催报 / `6` 导出完成)不一致 ——
> 若按旧表实现,催报会被写成"导出完成",**催报去重会静默失效**(老师点两下学生收到两条),
> 而排查时从数据上完全看不出是码对不上。现全文档统一为「`5` 催报 / `6` 导出完成」。
> 记录见《08-完成度盘点与问题清单》**F-30**。
---
## §3 通用数据结构
> 下列对象在多个接口中复用。**字段名与数据库列名一一对应**(下划线转驼峰),便于后端直接映射,避免二次改名。
> 表格中「↔ 列」列标注该字段对应的 DDL 列,`—` 表示计算字段。
### 3.1 `TaskVO` 任务
| 字段 | 类型 | ↔ 列 | 说明 |
|---|---|---|---|
| `id` | int64 | `id` | 任务 ID |
| `taskCode` | string | `task_code` | 任务编码,如 `2026090001` |
| `taskName` | string | `task_name` | 任务名称 |
| `collectMode` | int | `collect_mode` | `1` 学校 / `2` 社会 |
| `batchNo` | string | `batch_no` | 批次号,如 `202609` |
| `majorName` | string | `major_name` | 专业名称 |
| `jobName` | string | `job_name` | 职业(工种)名称 |
| `jobLevel` | string | `job_level` | 等级,如 `四级` |
| `certType` | int | `cert_type` | 见 §2.3 |
| `startTime` / `endTime` | string | `start_time` / `end_time` | 填报起止 |
| `auditConfig` | object | `audit_config` | 见 3.2 |
| `namingTemplate` | string | `naming_template` | 命名模板 |
| `compressConfig` | object | `compress_config` | 见 3.11 |
| `rosterRequired` | bool | `roster_required` | 是否强制门禁(**当前恒为 true**) |
| `status` | int | `status` | 见 §2.7 `taskStatus` |
| `publishedTime` | string | `published_time` | 发布时间 |
| `stat` | object | — | 统计快照,见 3.3 |
| `createTime` | string | `create_time` | — |
### 3.2 `AuditConfigVO` 审核配置
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `needClassAudit` | bool | `true` | 是否需班主任初审(**社会收集恒为 false**)。一期不启用社会收集 |
| `needAdminAudit` | bool | `true` | 是否需管理员终审 |
| `classAuditTimeoutHours` | int | `48` | 初审超时提醒 |
| `adminAuditTimeoutHours` | int | `72` | 终审超时提醒 |
| `maxRejectCount` | int | `3` | 驳回次数上限,超限升级管理员 |
| `allowProxyUpload` | bool | `true` | 允许班主任代传 |
| `batchApproveOnlyGreen` | bool | `true` | **批量通过只带无问题项(不可关闭)** |
### 3.3 `TaskStatVO` 任务统计
| 字段 | 类型 | 说明 |
|---|---|---|
| `rosterTotal` | int | 应填人数(名册行数) |
| `submittedCount` | int | 已提交数 |
| `approvedCount` | int | 已通过数 |
| `pendingCount` | int | 待审数 |
| `rejectedCount` | int | 已驳回数 |
| `unsubmittedCount` | int | 未提交数 |
| **`submitRate`** | string | **已提交率**(`submittedCount / rosterTotal`) |
| `passRate` | string | 通过率(`approvedCount / rosterTotal`) |
| `abnormalCount` | int | 有红/黄问题的单数 |
| `avgAuditHours` | number | 平均处理时长(小时) |
> ⚠️ **`submitRate` 与 `passRate` 必须分开报**。原型定稿的口径是「**完成率 82% 是已提交率 23/28**」,不要合并成一个数字——已提交≠已通过。
### 3.4 `MaterialDefVO` 材料项定义
| 字段 | 类型 | ↔ 列 | 说明 |
|---|---|---|---|
| `id` | int64 | `id` | — |
| `materialCode` | string | `material_code` | 见 §2.1 |
| `materialName` | string | `material_name` | 展示名 |
| `aliases` | string[] | `aliases` | 别名集合(识别用) |
| `namePattern` | string | `name_pattern` | 命名模板,如 `{姓名}-{证件号}-{材料名}{后缀}` |
| `required` | bool | `required` | 是否必传 |
| `visibleWhen` | object | `visible_when` | 条件显示,如 `{ "field": "is_student", "op": "eq", "value": true }` |
| `fileTypes` | string[] | `file_types` | 格式白名单 `["jpg","jpeg","png","pdf","heic"]` |
| `maxSizeKb` | int | `max_size_kb` | 体积上限(**默认 1024**) |
| `minSizeKb` | int | — | **体积下限保护**,低于此值提示"可能不是原图" |
| `compressProfile` | string | `compress_profile` | `P1`~`P5` |
| `minLongSide` | int | `min_long_side` | 分辨率下限(长边 px) |
| `minQuality` | int | `min_quality` | 压缩质量下限(默认 70) |
| `targetDpi` | int | `target_dpi` | 目标 DPI(默认 300) |
| `fixedSize` | object | `fixed_size` | 固定尺寸,如 `{ "width": 295, "height": 413 }` |
| `outputFormat` | string | `output_format` | 输出格式;`null` = 保持原格式 |
| `maxCount` | int | `max_count` | 最多可传份数 |
| `mergeEnabled` | bool | `merge_enabled` | 是否允许多图合并为一份(如身份证正反面) |
| `ocrType` | string | `ocr_type` | `IDCARD` / `NONE` |
| `sampleUrl` | string | `sample_url` | 示例图 |
| `tipText` | string | `tip_text` | **来源引导语**(显示在学生端) |
| `sortNo` | int | `sort_no` | 排序 |
### 3.5 `FormSchemaVO` 表单定义
| 字段 | 类型 | 说明 |
|---|---|---|
| `version` | int | 版本号,**已发布任务改表单必须递增** |
| `isCurrent` | bool | 是否当前生效版本 |
| `fields` | array | 字段数组,见下 |
| `fields[].key` | string | 字段键,内置字段与 `collect_submission` 列名一致 |
| `fields[].label` | string | 展示标签 |
| `fields[].type` | string | `text` / `number` / `select` / `cascade` / `date` / `idcard` / `phone` / `bool` |
| `fields[].builtin` | bool | **是否内置字段(内置不可删除)** |
| `fields[].locked` | bool | **是否锁定(学生不可改)** |
| `fields[].required` | bool | 必填 |
| `fields[].dictCode` | string | 选项来源字典码 |
| `fields[].options` | array | 手填选项 |
| `fields[].validate` | object | `{ "regex": "...", "min": 0, "max": 0, "message": "..." }` |
| `fields[].visibleWhen` | object | 条件显示规则 |
| `fields[].tip` | string | 字段提示 |
| `fields[].sortNo` | int | 排序 |
**内置字段清单(21 个,`builtin: true`)**
| 分类 | 字段 key | 学生端权限 |
|---|---|---|
| 身份 | `real_name`、`id_card_no` | 🔒 锁定 |
| 报考 | `occupation`、`worktype`、`job_level` | 🔒 锁定 |
| 学历 | `education`、`is_student`、`school`、`major`、`enroll_date` | ✏️ 可核对修改 |
| 工作 | `work_unit`、`work_years` | ✏️ 可核对修改 |
| 证书 | `cert_occupation`、`cert_level`、`cert_no` | ✏️ 可核对修改 |
| 补考 | `is_retake`、`retake_theory`、`retake_practice`、`retake_overall` | ✏️ 可核对修改 |
| 联系 | `phone` | ✏️ 可核对修改 |
> **锁定三项的理由**:姓名、身份证号、报考三项一旦改动,会与名册、报考范围、导出模板全部失配,**必须由老师改**。
> **改动留痕**:学生修改任一预填字段,写入 `collect_submission.changed_fields`(JSON 数组),审核时高亮显示。
### 3.6 `RosterVO` 名册项
| 字段 | 类型 | ↔ 列 | 说明 |
|---|---|---|---|
| `id` | int64 | `id` | 名册项 ID |
| `seqNo` | int | `seq_no` | 序号 |
| `studentNo` | string | `student_no` | 学号 |
| `realName` | string | `real_name` | 姓名 |
| `namePinyin` | string | `name_pinyin` | 姓名拼音(形近字纠错用) |
| `idCardNo` | string | — | ⚠️ **仅导入时可传,响应一律返回 `idCardMask`** |
| `idCardMask` | string | `id_card_mask` | 脱敏证件号 |
| `idCardHash` | string | `id_card_hash` | 哈希(核验用,不可逆) |
| `phone` | string | — | 手机号(响应脱敏) |
| `phoneMask` | string | — | 脱敏手机号 |
| `orgId` / `orgName` | int64 / string | `org_id` | 所属班级 |
| `seqNo` | int | `seq_no` | — |
| `occupation` / `worktype` / `jobLevel` | string | `ext_json` | 报考三项(**来自导入,非独立字段**) |
| `extJson` | object | `ext_json` | 其它列 |
| `bindStatus` | int | `bind_user_id` | `0` 未绑定 / `1` 已绑定 |
| `bindTime` | string | `bind_time` | 绑定时间 |
| `bindType` | int | `bind_type` | `1` 本人 / `2` 代填 |
| `submitStatus` | int | `submit_status` | 见 §2.7 |
### 3.7 `SubmissionVO` 填报单
| 字段 | 类型 | ↔ 列 | 说明 |
|---|---|---|---|
| `id` | int64 | `id` | 填报单 ID |
| `taskId` | int64 | `task_id` | — |
| `rosterId` | int64 | `roster_id` | 名册项 |
| `formVersion` | int | `form_version` | 填报时的表单版本 |
| `formData` | object | `form_data` | 表单值(内置字段在此冗余一份快照) |
| `extrasJson` | object | `extras_json` | 扩展字段值 |
| `changedFields` | string[] | `changed_fields` | **学生改过的预填字段列表**(审核高亮用) |
| `status` | int | `status` | 见 §2.4 |
| `rejectCount` | int | `reject_count` | 累计驳回次数 |
| `lastRejectReason` | object | `last_reject_reason` | `{ reasonCode, reasonText }` |
| `redCount` / `yellowCount` | int | `red_count` / `yellow_count` | 红/黄问题数 |
| `materials` | array | — | `MaterialFileVO[]`(见 3.8),**仅详情接口返回** |
| `issues` | array | — | `ValidationIssueVO[]`(见 3.9),**仅详情接口返回** |
| `submitTime` | string | `submit_time` | 提交时间 |
| `classAudit` | object | — | 初审信息 `{ auditorName, auditTime, action }` |
| `adminAudit` | object | — | 终审信息 |
| `createTime` | string | `create_time` | — |
**展开字段(`formData` 内的扁平字段,与 DDL 列同名)**
`real_name` · `id_card_no` · `occupation` · `worktype` · `job_level` · `education` · `is_student` · `school` · `major` · `enroll_date` · `work_unit` · `is_retake` · `retake_theory` · `retake_practice` · `retake_overall` · `work_years` · `cert_occupation` · `cert_level` · `cert_no`
> 这些字段**在 `collect_submission` 里是正式列**(不是 JSON),所以可以直接 `WHERE education = '无'` 做统计。`extras_json` 只存扩展字段。
### 3.8 `MaterialFileVO` 材料文件
| 字段 | 类型 | ↔ 列 | 说明 |
|---|---|---|---|
| `id` | int64 | `id` | 文件 ID |
| `materialCode` | string | `material_code` | 所属槽位 |
| `version` | int | `version` | 版本号 |
| `isCurrent` | bool | `is_current` | 是否当前版本 |
| `originalName` | string | `original_name` | 学生原始文件名 |
| `standardName` | string | `standard_name` | **规范文件名** |
| `fileExt` | string | `file_ext` | 扩展名 |
| `mimeType` | string | `mime_type` | — |
| `rawSizeKb` | number | `raw_size` | 原始体积(KB) |
| `finalSizeKb` | number | `final_size` | 处理后体积(KB) |
| `compressRatio` | string | — | 压缩率,如 `"97.1%"` |
| `previewUrl` | string | — | 预览签名 URL(有效期 30 分钟) |
| `width` / `height` | int | `width` | 像素尺寸 |
| `dpi` | int | `dpi` | 实际 DPI |
| `ocrStatus` | int | `ocr_status` | `0` 未跑 / `1` 成功 / `2` 失败 / `3` 跳过 |
| `ocrConfidence` | number | `ocr_confidence` | 置信度 0–1 |
| `detectedMaterialCode` | string | `detected_material_code` | **系统识别结果** |
| `detectConfidence` | number | `detect_confidence` | 识别置信度 |
| `detectSource` | int | `detect_source` | 见 §2.7 `detectSource` |
| `detectConflict` | bool | `detect_conflict` | **识别结果与所在槽位冲突** |
| `needManualPlace` | bool | `need_manual_place` | 需人工分拣 |
| `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) |
| `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` |
| `issues` | array | — | 该文件的 `ValidationIssueVO[]` |
| `source` | int | `source` | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入 |
| `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** |
> ✅ **`collect_file.source` 的口径曾经有两套,已确认(2026-09-30 客户确认)**:本表旧版写
> "从哪个入口选的"(`1` 聊天记录 / `2` 相册 / `3` 手机文件 / `4` 代传),与建表注释
> (`V1__business_tables.sql`)冲突。**以建表注释为准("谁传的")**:§10.6 要求代传可追溯,
> 要回答的是"**谁**替他传的、**为什么**",而"从哪个入口选的"会随同一个学生的操作路径变化,
> 统计上没有意义。实现见 `CollectFile.SOURCE_*` 常量。记录见《08》**F-38 / F-39**。
**`ownership.verdict` 取值**(对应 PRD §6.1 归属裁决矩阵)
| verdict | 含义 | blockSubmit | 学生端表现 |
|---|---|:--:|---|
| `NO_IDCARD_IN_NAME` | 文件名无证件号(正常) | false | 无提示 |
| `CONFIRMED_SAME` | 证件号 = 名册值 | false | 无提示 |
| **`BELONGS_TO_OTHER`** | **证件号指向名册中的另一个人** | **true** | **阻断**,提示文件名对应的是谁 |
| `UNKNOWN_IDCARD` | 证件号合法但名册查不到 | 视 OCR 而定 | 无 OCR 佐证时阻断 |
| `CHECKSUM_INVALID` | 校验位失败(只是写错了) | false | 信息级"已按名册修正" |
| `NO_ROSTER` | 本次无名单可依 | false | 标黄 |
> ⚠️ **`canForce: false` 是硬约定**:归属问题**不提供"仍然继续"**。
> 与 `VAL-08`(分辨率偏低)这类警告不同——那种可以"仍然使用",这个不行。
**`detectSource` 的展示约定**:审核辅助显示「系统判定:身份证 92%」,其中档位由 `detectSource` 决定可信度标注:
| `detectSource` | 展示文案 |
|---|---|
| `1` 文件名 | 文件名匹配 |
| `2` 形态 | 形态识别 |
| `3` OCR 内容 | 内容识别 |
| `4` VLM | **智能识别**(置信度 < 0.8 时不显示,避免误导) |
### 3.9 `ValidationIssueVO` 校验问题
| 字段 | 类型 | ↔ 列 | 说明 |
|---|---|---|---|
| `id` | int64 | `id` | — |
| `materialCode` | string | — | 关联材料(可为空) |
| `fileId` | int64 | `file_id` | 关联文件 |
| `ruleCode` | string | `rule_code` | 如 `VAL-05` |
| `severity` | int | `severity` | 见 §2.5 |
| `fieldKey` | string | `field_key` | 关联字段(表单类问题) |
| `title` | string | `title` | 标题 |
| `detail` | string | `detail` | 详情 |
| `expectedVal` | string | `expected_val` | 期望值(**脱敏**) |
| `actualVal` | string | `actual_val` | 实际值(**脱敏**) |
| `autoFixed` | bool | `auto_fixed` | 是否已自动修复 |
| `resolved` | bool | `resolved` | 是否已解决 |
| `resolvedBy` | string | `resolved_by` | 处理人 |
### 3.10 `AuditRecordVO` 审核记录
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int64 | — |
| `auditNode` | int | `1` 初审 / `2` 终审 |
| `auditorId` / `auditorName` | int64 / string | 审核人 |
| `action` | int | 见 §2.7 `auditAction`(**撤回是 `7`**;`3` 是批量通过 —— 旧版此表把两者写混过) |
| `reasonCode` | string | 驳回原因码(见 PRD 附录 C) |
| `reasonText` | string | 手填原因 |
| `materialCodes` | string[] | 关联材料 |
| `durationSec` | int | 从提交到本次操作的耗时 |
| `createTime` | string | — |
### 3.11 `CompressConfigVO` 压缩配置
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `defaultMaxSizeKb` | int | `1024` | 全局体积上限(**D1 决策:无例外**) |
| `defaultTargetDpi` | int | `300` | A4 扫描件目标 DPI |
| `defaultQuality` | int | `85` | 默认质量 |
| `minQuality` | int | `70` | 质量下限(低于此值标记"强制压缩") |
| `hardFloorDpi` | int | `200` | DPI 硬下限 |
| `maxPixels` | int | `50000000` | 超大图熔断阈值(5000 万像素) |
| `keepPngForScreenshot` | bool | `true` | **截图类保持 PNG 不转 JPG** |
| `markWhenForced` | bool | `true` | 强制压缩时打标记进抽检清单 |
### 3.12 其余对象(简表)
| 对象 | 字段 |
|---|---|
| `OrgVO` | `id` `parentId` `orgName` `orgType` `orgCode` `ancestorPath` `leaderUserId` `leaderName` `sortNo` `status` `children[]` `studentCount` |
| `TenantVO` | `id` `tenantCode` `tenantName` `tenantType` `packageId` `packageName` `expireTime` `status` `logoUrl` `configJson` `usage{ studentCount, storageMb, ocrCount, smsCount }` |
| `UserVO` | `id` `username` `realName` `phone` `orgId` `orgName` `roleCodes[]` `userType` `status` `lastLoginTime` |
| `ExportTaskVO` | `id` `taskId` `exportName` `scopeJson` `splitSizeMb` `desensitize` `status` `progress` `fileCount` `fileSizeMb` `resultFiles[]{ name, sizeMb, url }` `errorMsg` `expireTime` `finishTime` |
| `NotifyRecordVO` | `id` `taskId` `channel` `bizType` `receiverId` `receiverName` `receiverRole` `templateCode` `content` `status` `failReason` `sendTime` |
| `CleanupJobVO` | `id` `jobName` `sourceType` `sourcePath` `targetTaskId` `status` `progress` `totalFiles` `resultPath` `reportJson` `finishTime` |
| `DictItemVO` | `id` `dictCode` `dictName` `itemCode` `itemName` `sortNo` `parentItemCode` `status` `builtin` |
| `MessageVO` | `id` `bizType` `title` `content` `read` `linkType` `linkId` `createTime` |
---
## §4 接口总览
> 合计 **121 个**:PRD §8 已定义 96 个 + 追溯矩阵补齐 **21 个** + 客户确认新增 **5 个**,减去客户要求移除的联系方式接口 1 个(标 ⭐)。补齐项见 §4.9 索引,客户确认新增见 §8.7。
> 「权限」列:`公开` / `学生` / `教师` / `管理` / `运营`(平台方)。
> 「修补」列指该接口是补齐追溯矩阵审计发现的缺口。
| 分类 | 接口数 |
|---|:--:|
| 认证与账号 | 13 |
| 组织与租户 | 15 |
| 任务与配置 | 30 |
| 名册 | 12 |
| 填报端 · 小程序 | 16 |
| 班主任移动端 | 10 |
| 审核 | 9 |
| 校验 / 导出 / 通知 | 12 |
| 开放接口 | 4 |
| **合计** | **121** |
### 4.1 认证与账号(13)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| POST | `/auth/login` | PC 账号密码登录 | 公开 |
| POST | `/auth/logout` | 退出 | 全部 |
| GET | `/auth/me` | 当前用户 + 权限菜单 | 全部 |
| PUT | `/auth/password` | 修改密码 | 全部 |
| POST | `/mp/login` | 小程序 code 换 session | 公开 |
| POST | `/mp/bind-phone` | 微信手机号快捷绑定 | 学生/教师 |
| POST | `/mp/roster/match` | **门禁核验** | 学生 |
| GET | `/users` | 用户列表 | 管理 |
| POST | `/users` | 新增用户 | 管理 |
| PUT | `/users/{id}` | 修改用户 | 管理 |
| DELETE | `/users/{id}` | 停用用户 | 管理 |
| POST | `/users/import` | 批量导入 | 管理 |
| POST | `/users/{id}/reset-password` | 重置密码 | 管理 |
### 4.2 组织与租户(15)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | `/orgs/tree` | 组织树 | 管理 |
| POST | `/orgs` | 新增组织 | 管理 |
| PUT | `/orgs/{id}` | 修改 | 管理 |
| DELETE | `/orgs/{id}` | 删除 | 管理 |
| POST | `/orgs/import` | 批量导入 | 管理 |
| GET | `/tenants` | 租户列表 | 运营 |
| POST | `/tenants` | 开通租户 | 运营 |
| PUT | `/tenants/{id}` | 修改租户/套餐 | 运营 |
| GET | `/tenants/{id}/usage` | 用量统计 | 运营 |
| ⭐ GET | `/dicts` | **字典列表** | 管理 |
| ⭐ GET | `/dicts/{code}/items` | **字典项** | 管理 |
| ⭐ POST | `/dicts/{code}/items` | **新增字典项** | 管理 |
| ⭐ PUT | `/dicts/items/{id}` | **修改字典项** | 管理 |
| ⭐ GET | `/logs/operations` | **操作日志** | 管理 |
| ⭐ GET | `/logs/logins` | **登录日志** | 管理 |
### 4.3 任务与配置(30)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | `/tasks` | 任务列表 | 管理 |
| POST | `/tasks` | 创建任务 | 管理 |
| GET | `/tasks/{id}` | 任务详情 | 管理/教师 |
| PUT | `/tasks/{id}` | 修改任务 | 管理 |
| DELETE | `/tasks/{id}` | 删除(仅草稿) | 管理 |
| POST | `/tasks/{id}/publish` | 发布 | 管理 |
| POST | `/tasks/{id}/close` | 截止 | 管理 |
| POST | `/tasks/{id}/archive` | 归档 | 管理 |
| ⭐ POST | `/tasks/{id}/pause` | **暂停** | 管理 |
| ⭐ POST | `/tasks/{id}/resume` | **恢复** | 管理 |
| POST | `/tasks/{id}/clone` | 克隆 | 管理 |
| GET | `/tasks/{id}/orgs` | 覆盖班级与班主任 | 管理 |
| PUT | `/tasks/{id}/orgs` | 设置覆盖班级/班主任 | 管理 |
| ⭐ GET | `/tasks/{id}/plan-scope` | **报考范围读取** | 管理 |
| ⭐ PUT | `/tasks/{id}/plan-scope` | **报考范围确认** | 管理 |
| GET | `/tasks/{id}/form-schema` | 表单定义 | 管理 |
| PUT | `/tasks/{id}/form-schema` | 保存表单(版本化) | 管理 |
| GET | `/tasks/{id}/materials` | 材料项列表 | 管理 |
| POST | `/tasks/{id}/materials` | 新增材料项 | 管理 |
| PUT | `/materials/{id}` | 修改材料项 | 管理 |
| DELETE | `/materials/{id}` | 删除材料项 | 管理 |
| GET | `/tasks/{id}/statistics` | 任务统计 | 管理/教师 |
| GET | `/tasks/{id}/qrcode` | 填报二维码 | 管理 |
| GET | `/rule-templates` | 规则模板列表 | 管理 |
| POST | `/rule-templates/{id}/apply` | 应用模板 | 管理 |
| ⭐ POST | `/cleanup/jobs` | **创建整理任务** | 管理 |
| ⭐ GET | `/cleanup/jobs` | **整理任务列表** | 管理 |
| ⭐ GET | `/cleanup/jobs/{id}` | **整理进度** | 管理 |
| ⭐ GET | `/cleanup/jobs/{id}/report` | **体检报告** | 管理 |
| ⭐ POST | `/cleanup/jobs/{id}/import` | **结果导入为正式任务** | 管理 |
### 4.4 名册(12)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| POST | `/tasks/{id}/roster/import` | 导入(返回体检报告) | 管理 |
| GET | `/tasks/{id}/roster` | 名册分页 | 管理/教师 |
| PUT | `/roster/{rid}` | 修改名册项 | 管理 |
| DELETE | `/roster/{rid}` | 删除名册项 | 管理 |
| GET | `/tasks/{id}/roster/template` | 下载导入模板 | 管理 |
| POST | `/roster/{rid}/notify` | 定向催报 | 管理/教师 |
| POST | `/tasks/{id}/roster/notify-unsubmitted` | 一键催报未提交者 | 管理/教师 |
| ⭐ POST | `/mp/roster/join-request` | **提交加入申请** | 学生 |
| ⭐ GET | `/mp/roster/join-request` | **我的申请状态** | 学生 |
| ⭐ GET | `/roster/join-requests` | **申请待办列表** | 管理 |
| ⭐ POST | `/roster/join-requests/{id}/approve` | **审批通过** | 管理 |
| ⭐ POST | `/roster/join-requests/{id}/reject` | **审批驳回** | 管理 |
### 4.5 填报端 · 小程序(16)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | `/mp/tasks` | 我的任务列表 | 学生 |
| GET | `/mp/tasks/{id}/form` | 表单结构 + 已填数据 | 学生 |
| POST | `/mp/submissions` | 创建/获取草稿 | 学生 |
| PUT | `/mp/submissions/{id}` | 保存草稿 | 学生 |
| POST | `/mp/submissions/{id}/submit` | 提交 | 学生 |
| POST | `/mp/submissions/{id}/withdraw` | 撤回(未审核前) | 学生 |
| GET | `/mp/submissions/{id}/progress` | 进度与问题清单 | 学生 |
| POST | `/mp/files/upload-token` | OSS 直传凭证 | 学生 |
| ⭐ POST | `/mp/files/{fileId}/process` | **文件规范化(命名+压缩+识别)** | 学生 |
| POST | `/mp/files/batch-process` | 批量处理(多材料) | 学生 |
| GET | `/mp/files/{fileId}` | 预览签名 URL | 学生/教师 |
| DELETE | `/mp/files/{fileId}` | 删除材料(软删) | 学生 |
| POST | `/mp/ocr/idcard` | 身份证 OCR | 学生 |
| ⭐ GET | `/mp/messages` | **消息中心** | 学生 |
| ⭐ POST | `/mp/messages/read` | **标记已读** | 学生 |
| ⭐ POST | `/mp/subscribe` | **订阅消息额度登记** | 学生 |
> 注:本表 16 行,其中 `/mp/files/{fileId}/process` 与 `/mp/files/batch-process` 在 PRD §8.6 中分别登记于 §8.6 与 §8.9,此处合并列出。
### 4.6 班主任移动端(10)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | `/mp/teacher/dashboard` | 班级看板 | 教师 |
| GET | `/mp/teacher/audit/todos` | 待审列表(卡片流) | 教师 |
| GET | `/mp/teacher/audit/{submissionId}` | 学生详情 | 教师 |
| POST | `/mp/teacher/audit/{submissionId}/approve` | 通过 | 教师 |
| POST | `/mp/teacher/audit/{submissionId}/reject` | 驳回 | 教师 |
| POST | `/mp/teacher/audit/batch-approve` | **一键通过全部绿色项** | 教师 |
| POST | `/mp/teacher/proxy-upload` | 代传材料 | 教师 |
| POST | `/mp/teacher/notify/unsubmitted` | 一键催报 | 教师 |
| GET | `/mp/teacher/notify/records` | 催报历史 | 教师 |
| ⭐ GET | `/mp/teacher/messages` | **教师消息中心** | 教师 |
### 4.7 审核(9)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | `/audit/todos` | 待办列表(红黄绿) | 教师/管理 |
| GET | `/audit/submissions/{id}` | 填报单详情 | 教师/管理 |
| POST | `/audit/submissions/{id}/approve` | 通过 | 教师/管理 |
| POST | `/audit/submissions/{id}/reject` | 驳回 | 教师/管理 |
| ⭐ POST | `/audit/submissions/{id}/revoke` | **撤回已审** | 教师/管理 |
| GET | `/audit/submissions/{id}/timeline` | 审核时间轴 | 教师/管理 |
| GET | `/audit/statistics` | 审核统计 | 教师/管理 |
| GET | `/audit/reason-codes` | 驳回原因字典 | 教师/管理 |
| POST | `/audit/submissions/{id}/proxy-upload` | 代传材料 | 教师/管理 |
### 4.8 校验 / 导出 / 通知(12)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| POST | `/validation/run` | 触发任务级全量校验 | 管理 |
| GET | `/validation/issues` | 问题清单 | 教师/管理 |
| GET | `/validation/issues/export` | 导出异常清单 Excel | 管理 |
| POST | `/exports` | 创建导出任务 | 管理 |
| GET | `/exports` | 导出历史 | 管理 |
| GET | `/exports/{id}` | 导出进度 | 管理 |
| GET | `/exports/{id}/download` | 下载签名 URL | 管理 |
| GET | `/notify/templates` | 通知模板 | 管理 |
| PUT | `/notify/templates/{id}` | 修改模板 | 管理 |
| POST | `/notify/send` | 手动发送通知 | 管理/教师 |
| GET | `/notify/records` | 通知记录 | 管理 |
| POST | `/callback/wechat/msg` | 微信订阅消息回调 | 公开 |
### 4.9 ⭐ 本次新增接口索引(21)
> 本表是**索引**,不是额外接口——其中 5 个已在上方 §4.3 / §4.7 列出,**不计入总数**。
> 表内 18 行覆盖 **21 个接口**(含 GET/PUT、GET/POST 合并行)。
| | 路径 | 说明 | 详情节 |
|---|---|---|---|
| ⭐ | `/tasks/{id}/plan-scope` | 报考范围读取 / 确认(GET / PUT) | §7.7 / §7.8 |
| ⭐ | `/tasks/{id}/pause` `/resume` | 暂停 / 恢复任务 | §7.4 |
| ⭐ | `/audit/submissions/{id}/revoke` | 撤回已审 | §11.4 |
| ⭐ | `/mp/files/{fileId}/process` | **文件规范化总入口**(原仅在 §8.9 详述,未登记进清单) | §9.6 |
| ⭐ | `/cleanup/jobs` | 创建整理任务 | §13.2 |
| ⭐ | `/cleanup/jobs` | 整理任务列表 | §13.2 |
| ⭐ | `/cleanup/jobs/{id}` | 整理进度 | §13.2 |
| ⭐ | `/cleanup/jobs/{id}/report` | 体检报告 | §13.2 |
| ⭐ | `/cleanup/jobs/{id}/import` | 结果导入为正式任务 | §13.2 |
| ⭐ | `/dicts` | 字典列表 | §13.3 |
| ⭐ | `/dicts/{code}/items` | 字典项(GET / POST) | §13.3 |
| ⭐ | `/dicts/items/{id}` | 修改字典项 | §13.3 |
| ⭐ | `/logs/operations` | 操作日志 | §13.4 |
| ⭐ | `/logs/logins` | 登录日志 | §13.4 |
| ⭐ | `/mp/messages` | 学生消息中心 | §13.5 |
| ⭐ | `/mp/messages/read` | 标记已读 | §13.5 |
| ⭐ | `/mp/teacher/messages` | 教师消息中心 | §13.5 |
| ⭐ | `/mp/subscribe` | 订阅消息额度登记 | §13.6 |
**新增 21 个的分布**
| 归属 | 数量 | 明细 |
|---|:--:|---|
| 报考范围 | 2 | plan-scope GET / PUT |
| 任务状态 | 2 | pause / resume |
| 审核 | 1 | revoke |
| 文件处理 | 1 | process(登记遗漏) |
| 历史整理器 | 5 | cleanup 全套 |
| 字典 | 4 | dicts |
| 日志 | 2 | operations / logins |
| 消息中心 | 3 | 学生 2 + 教师 1 |
| 订阅额度 | 1 | subscribe |
### 4.10 开放接口(4)
| | 路径 | 说明 |
|---|---|---|
| POST | `/open/v1/auth/token` | appKey/secret 换 token |
| GET | `/open/v1/tasks/{id}/submissions` | 拉取已终审通过的数据 |
| GET | `/open/v1/tasks/{id}/files` | 批量获取文件签名 URL |
| POST | `/open/v1/callback/push` | 推送审核结果到机构系统 |
### 4.11 ⭐ 客户确认后新增(5)
> 来源:确认函 **B1**(客户确认采用"申请加入名单 → 管理员审批"通道)。这是本次唯一的功能增量。
> 详述见 **§8.7**。
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| ⭐ | `/mp/roster/join-request` | 提交加入申请 / 查看我的申请(GET / POST) | 学生 |
| ⭐ | `/roster/join-requests` | 申请待办列表 | 管理 |
| ⭐ | `/roster/join-requests/{id}/approve` | 审批通过(自动建名册项 + 自动绑定) | 管理 |
| ⭐ | `/roster/join-requests/{id}/reject` | 审批驳回(原因必填) | 管理 |
**设计边界(三条)**
| # | 边界 | 理由 |
|:--:|---|---|
| 1 | **只解决"进名单",不绕过门禁** | 申请期间仍不能填报;审批通过后学生**仍需重新核验**(或凭已绑定 openid 直接放行) |
| 2 | **证件号仅存 HMAC,不落明文** | 与门禁口径一致;申请记录保留 2 年 |
| 3 | **驳回必须逐条填原因;批量通过不提供"全选"** | 身份认定环节误操作代价极高,与"批量通过只带绿色项"同源 |
**与现有机制的关系**
| 机制 | 区别 |
|---|---|
| `FR-508` 名册追加导入 | 老师批量加人;申请通道是**学生自助 + 逐条审批** |
| `FR-504` 统一失败提示 | 申请通道**不破坏**统一提示——`40039`(已在名单中)不泄露新信息 |
| `FR-504d` 已被他人绑定 | 申请通过后若该证件号已被占用 → `40041`,不覆盖 |
---
## §5 认证与账号
### 5.1 `POST /api/v1/auth/login`
**权限**:公开 **限流**:5 次/分钟/IP
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `username` | string | ✅ | 账号 |
| `password` | string | ✅ | 密码(前端 RSA 加密后传输) |
| `captchaKey` | string | 条件 | 图形验证码 key(连续失败 2 次后必填) |
| `captchaCode` | string | 条件 | 验证码 |
**响应**
```json
{
"code": 0,
"data": {
"token": "eyJhbGciOi...",
"expireSeconds": 28800,
"user": { "id": 10086, "realName": "王老师", "userType": 2, "avatarUrl": null },
"tenant": { "id": 1001, "tenantName": "江西机电职业技术学院", "logoUrl": "..." },
"roleCodes": ["SCHOOL_ADMIN"],
"menus": ["dashboard", "task", "roster", "audit", "export"]
}
}
```
**主要错误**:`40012` 账号或密码错误 / `40013` 账号锁定 / `42901` 限流
### 5.2 `GET /api/v1/auth/me`
**响应**:同 5.1 的 `user` + `tenant` + `roleCodes` + `menus` + `permissions[]`(细粒度权限码)。
### 5.3 `POST /api/v1/mp/login`
**权限**:公开
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `code` | string | ✅ | `wx.login` 返回的 code |
| `scene` | string | — | 扫码进入时的小程序码参数(**含租户与任务标识**) |
**响应**
```json
{
"code": 0,
"data": {
"token": "eyJ...",
"expireSeconds": 604800,
"needBindPhone": false,
"user": { "id": 20001, "realName": "曾伊豪", "userType": 4 },
"boundRoster": { "taskId": 202609, "taskName": "2026年9月机电认定", "rosterId": 9001 }
}
}
```
> `boundRoster` 用于**已绑定学生的免门禁直入**:门禁只需过一次,绑定关系长期有效。
### 5.4 `POST /api/v1/mp/roster/match` ⭐ 门禁核验(核心接口)
**权限**:学生 **限流**:同 openid 10 次/小时;同 IP 20 次/分钟 **幂等**:是
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `realName` | string | ✅ | 姓名 |
| `idCardNo` | string | ✅ | 身份证号 |
| `taskId` | int64 | ✅ | 任务 ID |
**匹配逻辑**(服务端)
```
1. 限流检查 → 超限返回 40071
2. 规范化:姓名去空格、全角转半角;证件号转大写
3. 按 (task_id, id_card_hash) 定位名册项
├─ 未命中 → 记录失败尝试(不记明文) → 40007 统一提示
└─ 命中 → 比对姓名
├─ 完全一致 → 通过
├─ 形近字/同音字 → 返回候选让用户确认(FR-504c)
└─ 不一致 → 40007 统一提示
4. 检查绑定状态
├─ 已被他人绑定 → 40072
└─ 未绑定 → 写入 bind_user_id / bind_time / bind_type
```
**成功响应**
```json
{
"code": 0,
"data": {
"matched": true,
"rosterId": 9001,
"realName": "曾伊豪",
"idCardMask": "3610292009******10",
"orgName": "24中技数控1班",
"prefill": {
"education": "中等职业技术学校",
"isStudent": true,
"school": "江西机电职业技术学院",
"major": "数控加工",
"enrollDate": "2024-09-01",
"phone": "138****8888",
"occupation": "车工",
"worktype": "数控车工",
"jobLevel": "四级"
},
"needConfirmName": false
}
}
```
**姓名形近字确认响应**(FR-504c)
```json
{
"code": 0,
"data": {
"matched": false,
"needConfirmName": true,
"candidates": [ { "rosterId": 9001, "realName": "曾伊豪", "similarity": 0.92 } ],
"confirmToken": "tmp_8f3a...",
"tip": "你是不是「曾伊豪」?"
}
}
```
**失败统一响应**(`40007`)——**不区分是姓名错还是号码错**
```json
{
"code": 40007,
"msg": "没有找到你的信息,请核对后重试",
"data": { "matched": false, "remainAttempts": 7, "contactTeacher": true }
}
```
> **安全要求**:`remainAttempts` 只报剩余次数,**不透露任何关于名单的信息**;失败尝试**不落明文证件号**(只落 hash 与时间)。
### 5.5 ~~`GET /api/v1/mp/roster/contact`~~ 【已移除】
> ❌ **本接口已按客户要求移除**(2026-09-28)。
>
> **原设计**:门禁失败页展示班主任/管理员的脱敏手机号,学生可一键拨打。
>
> **移除理由**:学生在班级群里**本来就有班主任的联系方式**,系统内再放一份:
> ① 不必要——用户已经知道怎么找人;
> ② 有维护成本——班主任换人、号码变更都要同步;
> ③ 多一处隐私暴露面——即使是脱敏号,也意味着系统里要多存一份联系方式数据。
>
> **替代**:门禁失败页只保留两条**系统内的动作**——「重新填写」与「申请加入名单」(FR-509)。
> 纯文案提示(如"如有疑问请联系老师")保留,因为**不涉及任何联系方式数据**。
### 5.6 `GET /api/v1/users`
**Query**:`keyword`(姓名/账号/手机号模糊)、`orgId`、`roleCode`、`userType`、`status`、分页参数
**响应**:`{ list: UserVO[], total }`
### 5.7 `POST /api/v1/users`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `username` | string | ✅ | 登录账号,租户内唯一 |
| `realName` | string | ✅ | 姓名 |
| `phone` | string | ✅ | 手机号 |
| `orgId` | int64 | — | 所属组织 |
| `roleCodes` | string[] | ✅ | 至少 1 个 |
| `userType` | int | ✅ | 见 §2.7 |
**响应**:`{ "id": 10087, "initialPassword": "Ab3#k9mP" }`(初始密码仅此一次返回)
### 5.8 `POST /api/v1/users/import`
**Content-Type**:`multipart/form-data` **字段**:`file`(Excel,≤10MB)
**响应**(导入体检报告,与名册导入同构)
```json
{
"code": 0,
"data": {
"total": 42, "success": 40, "failed": 2,
"headerRow": 1,
"errors": [
{ "row": 17, "field": "phone", "value": "1380013", "reason": "手机号格式不正确" },
{ "row": 33, "field": "username", "value": "lisi", "reason": "账号已存在" }
],
"initialPasswords": [
{ "username": "zhangsan", "password": "Xy7$mQ2p" }
]
}
}
```
> ⚠️ **导入类接口一律返回「体检报告」结构**(`total/success/failed/headerRow/errors[]`)。`headerRow` 必须回传——实测两份真实文件的表头分别在**第 1 行和第 6 行**,界面要把它可视化出来让用户确认。
### 5.9 其余账号接口
| 接口 | 关键字段 | 备注 |
|---|---|---|
| `PUT /users/{id}` | 同 5.7,`username` 不可改 | — |
| `DELETE /users/{id}` | — | **软删**(`deleted=1`);有在办任务时返回 `40014` 并提示先移交 |
| `POST /users/{id}/reset-password` | `{ "mode": "GENERATE" }` 或 `{ "newPassword": "..." }` | 返回 `{ newPassword }` |
| `PUT /auth/password` | `{ oldPassword, newPassword }` | 强制改密场景 `oldPassword` 可空 |
| `POST /auth/logout` | — | 服务端把 token 加入黑名单,TTL = 剩余有效期 |
| `POST /mp/bind-phone` | `{ code }`(`getPhoneNumber` 返回) | 写入 `phone_hash`,用于门禁二次校验 |
---
## §6 组织与租户
### 6.1 `GET /api/v1/orgs/tree`
**Query**:`withCount`(bool,是否带学生数)、`rootId`
**响应**
```json
{
"code": 0,
"data": [
{
"id": 2000, "orgName": "江西机电职业技术学院", "orgType": 1, "orgCode": "JXJD",
"leaderUserId": null, "sortNo": 1, "status": 1, "studentCount": 1240,
"children": [
{ "id": 2001, "orgName": "24中技数控1班", "orgType": 3, "orgCode": "24ZK1",
"leaderUserId": 10087, "leaderName": "李老师", "sortNo": 1, "status": 1,
"studentCount": 28, "children": [] }
]
}
]
}
```
| `orgType` | 含义 |
|:--:|---|
| `1` | 学校 |
| `2` | 院系 / 专业 |
| `3` | 班级 |
### 6.2 `POST /api/v1/orgs` / `PUT /api/v1/orgs/{id}`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `parentId` | int64 | — | 顶级为空 |
| `orgName` | string | ✅ | — |
| `orgType` | int | ✅ | `1`/`2`/`3` |
| `orgCode` | string | ✅ | 租户内唯一 |
| `leaderUserId` | int64 | — | 负责人 |
| `sortNo` | int | — | 默认 0 |
> `ancestor_path` 由服务端维护(如 `/2000/2010/2001/`),用于**按院系统计**,客户端不要传。
### 6.3 `DELETE /api/v1/orgs/{id}`
**约束**:有下级组织、有名册项、有进行中任务时**拒绝**,返回 `40015` 并给出占用清单。
### 6.4 `POST /api/v1/orgs/import`
**Content-Type**:`multipart/form-data` **字段**:`file`
Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手机号`。返回同 5.8 的体检报告结构。
### 6.5 `GET /api/v1/tenants`
**权限**:运营 **Query**:`keyword`、`status`、`expireBefore`、分页
**响应**:`{ list: TenantVO[], total }`
### 6.6 `POST /api/v1/tenants`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantName` | string | ✅ | — |
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd` |
| `tenantType` | int | ✅ | `1` 学校 / `2` 机构 / `3` 企业 |
| `contactName` / `contactPhone` | string | ✅ | 联系人 |
| `packageId` | int64 | ✅ | 套餐 |
| `expireTime` | string | ✅ | 到期时间 |
| `adminUsername` / `adminRealName` / `adminPhone` | string | ✅ | **同时创建首个管理员** |
**响应**:`{ "id": 1002, "adminInitialPassword": "Kp9#vR4t", "miniProgramScene": "t1002" }`
### 6.7 `GET /api/v1/tenants/{id}/usage`
**Query**:`period`(`2026-09` 或 `2026Q3`)
```json
{
"code": 0,
"data": {
"studentCount": 1240, "storageMb": 8420, "ocrCount": 3105, "smsCount": 420,
"quota": { "studentLimit": 2000, "storageLimitMb": 20480, "ocrLimit": 10000, "smsLimit": 2000 },
"alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%" } ]
}
}
```
---
## §7 任务与配置
### 7.1 `GET /api/v1/tasks`
**Query**:`keyword`、`status`、`collectMode`、`batchNo`、`orgId`、分页
**权限过滤**:管理员看全租户;班主任**只看自己带的班级的任务**(由 `collect_task_org.head_user_id` 决定)。
**响应**:`{ list: TaskVO[], total }`(含 `stat` 快照)
### 7.2 `POST /api/v1/tasks`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskName` | string | ✅ | — |
| `collectMode` | int | ✅ | `1` 学校 / `2` 社会(**决定审核流,发布后不可改**) |
| `batchNo` | string | ✅ | 如 `202609` |
| `majorName` | string | ✅ | — |
| `certType` | int | ✅ | 见 §2.3 |
| `startTime` / `endTime` | string | ✅ | — |
| `auditConfig` | object | — | 缺省按 `collectMode` 取默认值 |
| `namingTemplate` | string | — | 缺省 `{姓名}-{证件号}-{材料名}{后缀}` |
| `compressConfig` | object | — | 缺省见 3.11 |
| `formTemplateId` | int64 | — | 从表单模板库初始化 |
| `materialTemplateId` | int64 | — | 从材料模板初始化 |
**响应**:`{ "id": 2026090001, "taskCode": "2026090001", "status": 0 }`
> **社会收集的审核配置默认值**(二期):`needClassAudit: false`(跳过初审),由服务端强制设置,客户端传 true 会被忽略并返回 `warnings`。
> ⚠️ **一期只支持 `collectMode=1`(学校收集)**。传 `2` 会被拒绝并返回 `40044`。
### 7.3 `POST /api/v1/tasks/{id}/publish`
**前置校验**(FR-507)——**任一不通过则阻断发布**
| 检查 | 不通过时的响应 |
|---|---|
| 名册人数 > 0 | `40016` 名册为空 |
| 名册含「姓名」「身份证号」列 | `40017` |
| 至少 1 项必传材料 | `40018` |
| 已设置覆盖班级与班主任 | `40019` |
| **命名规则试跑通过** | `40020` 附试跑失败样例 |
**请求**:`{ "force": false, "confirmWarnings": [] }`
**响应**:`{ "status": 1, "publishedTime": "2026-09-28T10:00:00+08:00", "qrcodeUrl": "..." }`
> **`force` 只对 P1 级警告有效**(如"某班的班主任未指定");上述 5 项**全部为阻断级,`force` 无法绕过**。
### 7.4 ⭐ `POST /api/v1/tasks/{id}/pause` · `/resume`
**权限**:管理 **幂等**:是
**暂停语义**(与"截止"的区别):
| 操作 | 学生可提交 | 已提交的审核 | 恢复 | 典型场景 |
|---|---|---|---|---|
| **暂停** | ❌(提示"任务已暂停") | ✅ **继续可审** | ✅ 可恢复 | 名单要改、规则要调,先冻住入口 |
| **截止** | ❌ | ✅ 可审 | ❌ 不可逆 | 时间到了 |
| **归档** | ❌ | ❌ 只读 | ❌ 不可逆 | 结束,进历史 |
**请求**:`{ "reason": "名册需补充 3 人" }`(可选)
**响应**:`{ "status": 2, "pausedTime": "..." }`
**错误**:`40021` 仅已发布任务可暂停 / `40022` 仅已暂停任务可恢复
### 7.5 `POST /api/v1/tasks/{id}/close` · `/archive` · `/clone`
| 接口 | 请求 | 响应 | 约束 |
|---|---|---|---|
| `/close` | `{ "force": false }` | `{ status: 3 }` | 未提交者仍可被催报;`force` 可跳过"有未审单"警告 |
| `/archive` | `{ "archiveRemark": "..." }` | `{ status: 4 }` | **要求无待审单**,否则 `40023` |
| `/clone` | `{ "newTaskName": "...", "newBatchNo": "202703", "copyRoster": true, "copyFormSchema": true, "copyMaterials": true, "copyOrgs": true }` | `{ newTaskId, newTaskCode }` | 名册复制时**清空绑定与提交状态** |
### 7.6 `GET/PUT /api/v1/tasks/{id}/orgs`
**GET 响应**
```json
{
"code": 0,
"data": [
{ "orgId": 2001, "orgName": "24中技数控1班", "headUserId": 10087, "headUserName": "李老师",
"deputyUserIds": [10088], "deputyUserNames": ["张老师"], "expectedCount": 28, "status": 1 }
]
}
```
**PUT 请求**:`{ "orgs": [ { "orgId": 2001, "headUserId": 10087, "deputyUserIds": [10088], "expectedCount": 28 } ] }`
> 一个班主任可带多个班;一个班可配**主/副**班主任(FR-204)。`expectedCount` 用于与名册实际人数比对,不一致时在任务详情告警。
### 7.7 ⭐ `GET /api/v1/tasks/{id}/plan-scope` 报考范围
**背景**:**不存在独立的"计划清单"文件**。报考范围由**名册聚合**得出。
**响应**
```json
{
"code": 0,
"data": {
"source": "ROSTER_AGGREGATED",
"updatedAt": "2026-09-28T10:00:00+08:00",
"rosterCount": 28,
"groups": [
{
"occupation": "车工", "worktype": "数控车工", "jobLevel": "四级",
"certType": 1, "count": 26,
"rosterIds": [9001, 9002, "..."],
"status": "CONFIRMED"
},
{
"occupation": "铣工", "worktype": "数控铣工", "jobLevel": "四级",
"certType": 1, "count": 1, "rosterIds": [9027], "status": "CONFIRMED"
},
{
"occupation": null, "worktype": null, "jobLevel": null,
"certType": 3, "count": 1, "rosterIds": [9028],
"status": "NEED_REVIEW",
"issue": "专项职业能力无工种/无等级,已自动识别"
}
],
"healthCheck": {
"passed": false,
"issues": [
{ "level": 2, "code": "SCOPE_LEVEL_MIXED",
"title": "同一职业出现多个等级",
"detail": "「车工」下同时存在 四级(26人) 与 三级(1人),请确认是否应为两个任务",
"rosterIds": [9012] },
{ "level": 1, "code": "SCOPE_UNFILLED",
"title": "1 人未填报考项",
"detail": "序号 28 曾某某 的报考职业为空,将无法导出",
"rosterIds": [9028] }
]
}
}
}
```
**`groups[].status`**
| 值 | 含义 |
|---|---|
| `CONFIRMED` | 已确认 |
| `NEED_REVIEW` | 需人工确认(有异常) |
| `EXCLUDED` | 已排除(不参与本次认定) |
**`healthCheck.issues[].code` 清单**
| code | 级别 | 含义 |
|---|:--:|---|
| `SCOPE_LEVEL_MIXED` | 2 | 同职业多等级 |
| `SCOPE_UNFILLED` | 1 | 报考项为空 |
| `SCOPE_FREE_TEXT` | 1 | 报考职业/工种为自由填写,与字典不完全一致(**Excel 模板本来就没有下拉**) |
| `SCOPE_TYPEO_MISMATCH` | 2 | `certType` 与是否有工种/等级矛盾 |
| `SCOPE_OUT_OF_TASK` | 3 | 出现任务专业范围外的职业 |
### 7.8 ⭐ `PUT /api/v1/tasks/{id}/plan-scope` 报考范围确认
**请求**
```json
{
"groups": [
{ "occupation": "车工", "worktype": "数控车工", "jobLevel": "四级",
"certType": 1, "rosterIds": [9001, 9002], "status": "CONFIRMED" },
{ "occupation": null, "worktype": null, "jobLevel": null,
"certType": 3, "rosterIds": [9028], "status": "EXCLUDED" }
],
"ignoredIssueCodes": ["SCOPE_FREE_TEXT"],
"remark": "9028 待补报考信息,本轮先排除"
}
```
**响应**:`{ "updatedAt": "...", "healthCheck": { "passed": true, "issues": [] } }`
**校验**:`rosterIds` 的并集必须**恰好覆盖**名册全员,否则 `40024` 并返回差集。
> **为什么强制全覆盖**:遗漏任何一人,导出包就会缺人;而漏人在人工核对时极难发现。**用接口约束代替人眼核对**。
### 7.9 `GET/PUT /api/v1/tasks/{id}/form-schema`
**GET 响应**:`FormSchemaVO`(见 3.5)
**PUT 请求**
```json
{
"version": 3,
"baseVersion": 2,
"fields": [
{ "key": "real_name", "label": "姓名", "type": "text", "builtin": true, "locked": true, "required": true, "sortNo": 1 },
{ "key": "education", "label": "学历", "type": "select", "builtin": true, "locked": false,
"required": true, "dictCode": "EDU_LEVEL", "sortNo": 4,
"tip": "老师已填,请核对" },
{ "key": "is_retake", "label": "是否补考", "type": "bool", "builtin": true, "sortNo": 7 },
{ "key": "retake_theory", "label": "补考理论", "type": "text", "builtin": true, "sortNo": 8,
"visibleWhen": { "field": "is_retake", "op": "eq", "value": true } }
],
"extras": [
{ "key": "extra_skill_cert", "label": "其它技能证书编号", "type": "text", "required": false, "sortNo": 100 }
]
}
```
**版本冲突**:`baseVersion` 与当前版本不符 → `40025` 并返回最新 schema,前端做差异合并。
**内置字段不可删**:删除任一 `builtin: true` 的字段 → `40026`。
> **`extras` 存入 `extras_json`,不参与统计**。凡需要统计的字段必须落在内置列上——这是架构约束,在设计器里以「扩展字段不参与核心统计」明示。
### 7.10 `GET/POST /api/v1/tasks/{id}/materials` · `PUT/DELETE /api/v1/materials/{id}`
**POST/PUT 请求**:`MaterialDefVO`(见 3.4)
**删除约束**:已有文件上传的材料项**不允许删**(`40027`),只能设 `visibleWhen` 隐藏。
**材料项模板**(`materialTemplateId` 可引用)
| 模板 | 材料项 |
|---|---|
| `TPL_SCHOOL_ROLL` | 一寸照片 + 身份证 + 学籍表 + 工作证明 |
| `TPL_SOCIAL` | 一寸照片 + 身份证 + 工作证明 + 学信网备案表 |
| `TPL_ZHUANXIANG` | 一寸照片 + 身份证 + 工作证明 |
### 7.11 `GET /api/v1/tasks/{id}/statistics`
**响应**:`TaskStatVO`(见 3.3)+ 分班明细
```json
{
"code": 0,
"data": {
"rosterTotal": 28, "submittedCount": 23, "approvedCount": 15,
"pendingCount": 4, "rejectedCount": 1, "unsubmittedCount": 5,
"submitRate": "82.1%", "passRate": "53.6%",
"abnormalCount": 4, "avgAuditHours": 6.4,
"byOrg": [ { "orgId": 2001, "orgName": "24中技数控1班", "rosterTotal": 28,
"submittedCount": 23, "approvedCount": 15, "submitRate": "82.1%" } ],
"byOccupation": [ { "occupation": "车工", "worktype": "数控车工", "jobLevel": "四级",
"count": 26, "approvedCount": 14 } ]
}
}
```
> **`byOccupation` 走的是正式列**(`collect_submission.occupation`),实时且走索引——这正是当初坚持"18 个正式列 + extras_json"的收益。
### 7.12 `GET /api/v1/tasks/{id}/qrcode`
**Query**:`type`(`MINI` 小程序码 / `URL` 短链)、`orgId`(可选,带班级参数)
**响应**:`{ "qrcodeUrl": "https://oss.../qr.png", "shortUrl": "https://t.xx/ab3K9", "scene": "t1001-t202609-o2001", "expireTime": null }`
> 短链永久有效,**任务截止后扫码进入提示"已截止"**,不做失效。
### 7.13 `GET /api/v1/rule-templates` · `POST /api/v1/rule-templates/{id}/apply`
| 接口 | 关键字段 | 说明 |
|---|---|---|
| GET | 返回 `{ id, templateName, certType, isPublic, useCount }` 列表 | 含公共模板与租户自建 |
| apply | 请求 `{ "targetTaskId": 2026090001, "parts": ["FORM","MATERIALS","NAMING"] }` | **可选择只应用其中一部分** |
---
## §8 名册
### 8.1 `POST /api/v1/tasks/{id}/roster/import` ⭐ 核心
**Content-Type**:`multipart/form-data`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file` | file | ✅ | Excel(≤10MB,≤5000 行) |
| `headerRow` | int | — | 手动指定表头行;**不传则自动探测** |
| `sheetName` | string | — | 手动指定工作表;不传则取第一个**有数据的**表 |
| `mode` | string | — | `APPEND`(默认,按证件号去重合并)/ `REPLACE` |
| `dryRun` | bool | — | 只体检不落库 |
**关键设计**:**表头行与工作表名都不固定**(实测一份在第 1 行、一份在第 6 行),因此:
```
表头探测算法:
1. 若传 headerRow → 用它
2. 否则扫前 10 行,选「命中已知列名 ≥ 3 个」的行作为表头
已知列名 = 官方模板 21 列 + 常见别名(姓名/名字/学生姓名…)
3. 若有多个候选 → 选命中最多者;并列 → 取最靠上者,并在响应中回传 candidates 让用户确认
4. 全部不命中 → 40028,返回前 10 行预览,请用户手动指定
```
**响应(导入体检报告)**
```json
{
"code": 0,
"data": {
"total": 28, "success": 28, "failed": 0,
"headerRow": 6,
"sheetName": "24中技数控1班",
"headerCandidates": [ { "row": 6, "score": 18 }, { "row": 1, "score": 2 } ],
"headerConfirmed": true,
"columns": [
{ "col": "A", "title": "序号", "mappedTo": null, "matched": false },
{ "col": "B", "title": "姓名", "mappedTo": "real_name", "matched": true },
{ "col": "C", "title": "身份证号", "mappedTo": "id_card_no", "matched": true },
{ "col": "D", "title": "报考职业", "mappedTo": "occupation", "matched": true },
{ "col": "U", "title": "备注", "mappedTo": "ext_json.remark", "matched": false }
],
"errors": [
{ "row": 12, "col": "C", "field": "id_card_no", "value": "3601052009081712**",
"reason": "身份证号校验位不通过", "code": "IDCARD_CHECKSUM" },
{ "row": 19, "col": "C", "field": "id_card_no", "value": "36102920090729**10",
"reason": "证件号与第 8 行重复", "code": "DUP_IDCARD" }
],
"warnings": [
{ "row": 28, "col": "D", "field": "occupation",
"reason": "报考职业为空(专项职业能力可留空)", "code": "EMPTY_OCCUPATION" }
],
"summary": { "uniquePersons": 28, "dupRows": 2, "idcardInvalid": 1, "nameSuspect": 0, "phoneInvalid": 3 }
}
}
```
**体检项清单**
| code | 说明 | 处理 |
|---|---|---|
| `IDCARD_CHECKSUM` | 校验位不通过(ISO 7064) | **阻断该行** |
| `DUP_IDCARD` | 证件号重复 | **阻断该行** |
| `IDCARD_FORMAT` | 格式非法(长度/字符) | 阻断该行 |
| `EMPTY_NAME` | 姓名为空 | 阻断该行 |
| `EMPTY_IDCARD` | 证件号为空 | 阻断该行 |
| `EMPTY_OCCUPATION` | 报考职业为空 | 警告 |
| `NAME_SUSPECT` | 姓名含罕见字/形近字 | 警告 |
| `PHONE_INVALID` | 手机号格式异常 | 警告(不阻断,可后续补) |
> **`dryRun` 模式**:先体检、给用户确认表头与错行,确认无误后再正式导入。**推荐前端默认走 dryRun**,避免"导进去才发现表头选错"。
### 8.2 `GET /api/v1/tasks/{id}/roster`
**Query**:`keyword`、`orgId`、`submitStatus`、`bindStatus`、`hasIssue`、分页
**响应**:`{ list: RosterVO[], total }`
### 8.3 `PUT /api/v1/roster/{rid}`
| 字段 | 类型 | 说明 |
|---|---|---|
| `realName` | string | 改名会**重置绑定**(需二次确认 `confirmRebind: true`) |
| `idCardNo` | string | **改证件号必留痕**(FR-506) |
| `phone` | string | — |
| `extJson` | object | 报考三项等 |
**留痕**:写入 `collect_roster` 变更前值快照 + 操作日志,`GET /logs/operations` 可查。
### 8.4 `DELETE /api/v1/roster/{rid}`
**约束**:已绑定或已提交的名册项**不允许删除**(`40029`),只能标记为"不参与本次认定"。
### 8.5 `GET /api/v1/tasks/{id}/roster/template`
**Query**:`format`(`xlsx`)、`withSample`(bool,是否带示例行)
**响应**:文件流。表头**固定在第 1 行**,必填列标红,附 9 条填表规则说明页。
### 8.6 `POST /api/v1/roster/{rid}/notify` · `POST /api/v1/tasks/{id}/roster/notify-unsubmitted`
| 接口 | 请求 | 说明 |
|---|---|---|
| 定向催报 | `{ "channel": 1, "templateCode": "NOTIFY_UNSUBMITTED", "extraText": "..." }` | 单人或多人(`rosterIds[]`) |
| 一键催报 | `{ "orgIds": [2001], "channel": 1, "excludeRecentHours": 24 }` | **仅催未提交者**;`excludeRecentHours` 防重复骚扰 |
**响应**:`{ "sent": 5, "failed": 0, "skipped": 1, "details": [ { "rosterId": 9012, "skipped": true, "reason": "24 小时内已催过" } ] }`
**限流**:10 次/分钟/任务。
---
### 8.7 名单加入申请(FR-509 · 客户确认后新增)
> **背景**:门禁不通过的人原先只能"联系老师",老师再手工加名单。几百人的社会收集场景下,老师负担极重。
> 本组接口提供一条**受控通道**:学生自助申请 → 管理员审批 → 自动入册。
>
> ⚠️ **关键边界**:这条通道**只解决"进名单",不绕过门禁**——申请期间学生仍不能填报。
#### 8.7.1 `POST /api/v1/mp/roster/join-request`
**权限**:学生(**须已过门禁失败流程**) **限流**:同 openid 每任务 ≤2 次
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | int64 | ✅ | — |
| `realName` | string | ✅ | 姓名(明文存储,业务必需) |
| `idCardNo` | string | ✅ | 身份证号(**服务端转 HMAC,不落明文**) |
| `phone` | string | ✅ | 手机号(**仅存 HMAC + 脱敏值**) |
| `orgHint` | string | — | 自述班级 |
| `reason` | string | — | 申请说明,如"我是转班过来的" |
**响应**
```json
{
"code": 0,
"data": {
"requestId": 7001,
"status": 0,
"statusText": "已提交,等待学校审核",
"estimatedHours": 24,
"remainAttempts": 1
}
}
```
**主要错误**
| 码 | 场景 | 提示 |
|---|---|---|
| ⭐ `40037` | 同任务申请次数超限(>2) | "该任务申请次数已达上限,请联系老师" |
| ⭐ `40038` | 该证件号已有待审申请 | "你已提交过申请,请耐心等待" |
| ⭐ `40039` | 该证件号已在名册中 | "你已在名单中,请直接返回重新核验" |
| `40008` | 任务已截止/暂停 | — |
> **`40039` 是必要的**:若证件号已在名册,说明学生**只是输错了姓名**,该引导他回去重填门禁,而不是提交申请。
> 这一条不违反"统一失败提示"原则——因为学生已经主动提供了正确证件号,此时告知"你已在名单中"**不泄露任何新信息**。
#### 8.7.2 `GET /api/v1/mp/roster/join-request`
**Query**:`taskId`
**响应**
```json
{
"code": 0,
"data": {
"list": [
{ "requestId": 7001, "taskId": 2026090001, "taskName": "2026年9月职业技能认定",
"status": 0, "statusText": "待审核",
"realName": "李四", "idCardMask": "3610**********10",
"orgHint": "24中技数控2班",
"createTime": "2026-09-28T18:00:00+08:00",
"auditTime": null, "rejectReason": null,
"rosterId": null }
],
"total": 1
}
}
```
**驳回态**:`status: 2` + `rejectReason` 明文返回(学生需要知道原因)。
**通过态**:`status: 1` + `rosterId`,并提示"已加入名单,请返回重新核验进入填报"。
#### 8.7.3 `GET /api/v1/roster/join-requests`
**权限**:管理 **Query**:`taskId`、`status`(默认 `0` 待审)、`orgId`、`keyword`、分页
**响应**
```json
{
"code": 0,
"data": {
"list": [
{ "requestId": 7001, "taskId": 2026090001,
"realName": "李四", "idCardMask": "3610**********10",
"phoneMask": "138****8888",
"orgHint": "24中技数控2班",
"reason": "我是转班过来的",
"status": 0,
"createTime": "2026-09-28T18:00:00+08:00",
"waitHours": 2.5,
"duplicateHint": null }
],
"total": 1,
"counts": { "pending": 1, "approved": 12, "rejected": 3 },
"alert": null
}
}
```
**`duplicateHint`**:若该证件号与**其他任务的名册**匹配上,返回提示(帮管理员判断是否本校学生):
```json
"duplicateHint": { "type": "IN_OTHER_TASK", "taskName": "2026年3月认定", "realName": "李四" }
```
**`alert`**:单任务待审 > 50 条时返回 `{ "level": 2, "text": "待审申请已达 53 条,建议集中处理" }`(FR-509c)。
#### 8.7.4 `POST /api/v1/roster/join-requests/{id}/approve`
**权限**:管理 **幂等**:是
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `orgId` | int64 | ✅ | **指定班级**(默认取自述班级,无则必填) |
| `realName` | string | — | 修正姓名(管理员核对后可改) |
| `idCardNo` | string | — | 修正证件号 |
| `seqNo` | int | — | 序号(默认追加到末尾) |
**服务端动作(事务内)**
```
1. 校验申请状态 = 待审,否则 40040
2. 校验该 task 下证件号未被占用,否则 40041
3. 在 collect_roster 新建名册项:
real_name / id_card_no / id_card_hash / id_card_mask ← 申请值(或管理员修正值)
org_id ← 指定班级
bind_user_id ← 申请人 uid(FR-509g:自动绑定,免重输)
bind_type ← 3(来自申请)
ext_json.source ← 'JOIN_REQUEST'
4. 回写 collect_join_request:status=1, audit_by, audit_time, roster_id
5. 写操作日志 JOIN_APPROVE
6. 推送通知 bizType=7
```
**响应**
```json
{
"code": 0,
"data": {
"requestId": 7001, "status": 1,
"rosterId": 9029,
"rosterCreated": true,
"notifySent": true,
"canFillNow": true
}
}
```
> **`canFillNow: true`** 表示该 openid 已绑定,学生再次进入**直接放行**。
#### 8.7.5 `POST /api/v1/roster/join-requests/{id}/reject`
**权限**:管理 **幂等**:是
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `reason` | string | ✅ | **驳回原因(必填)**,会原样推送给学生 |
| `reasonCode` | string | — | 快捷原因码(`NOT_THIS_SCHOOL` / `INFO_MISMATCH` / `NOT_IN_SCOPE` / `OTHER`) |
**响应**:`{ "requestId": 7001, "status": 2, "notifySent": true }`
**约束**
| 条件 | 结果 |
|---|---|
| 已通过后再驳回 | `40042`(如需撤销请走名册删除) |
| 任务已归档 | `40034` |
| 批量审批 | **支持**,但**必须逐条确认**(不可一键全通过——涉及身份认定) |
> ⚠️ **批量审批的设计取舍**:批量**通过**允许(管理员已看过列表),但**驳回必须逐条填原因**;且批量通过时**不提供"全选"快捷键**。
> 理由与"批量通过只带绿色项"同源:**身份认定环节的误操作代价极高**。
---
## §9 填报端 · 小程序
### 9.1 `GET /api/v1/mp/tasks`
**权限**:学生 **说明**:只返回**该学生被绑定/在名册中**的任务。
**响应**
```json
{
"code": 0,
"data": [
{
"taskId": 2026090001, "taskName": "2026年9月职业技能等级认定",
"orgName": "24中技数控1班", "endTime": "2026-10-10T23:59:59+08:00",
"status": 1, "submissionStatus": 0,
"progress": { "total": 4, "done": 2, "percent": "50%" },
"issueCount": { "red": 1, "yellow": 2 },
"hasDraft": true,
"materials": [
{ "materialCode": "PHOTO1", "materialName": "一寸照片", "required": true,
"done": true, "visible": true },
{ "materialCode": "SCHOOL_ROLL", "materialName": "学籍表", "required": true,
"done": false, "visible": true }
]
}
]
}
```
> **`materials[].visible`**:条件显示的材料(如"是否在校生=否"时学籍表隐藏、学信网备案表显示)由服务端算好,**前端不做条件判断**——避免两端逻辑不一致。
### 9.2 `GET /api/v1/mp/tasks/{id}/form`
**响应**
```json
{
"code": 0,
"data": {
"taskId": 2026090001, "formVersion": 3,
"sections": [
{
"title": "基本信息", "fields": [
{ "key": "real_name", "label": "姓名", "type": "text", "required": true,
"locked": true, "value": "曾伊豪", "prefilled": true },
{ "key": "education", "label": "学历", "type": "select", "required": true,
"locked": false, "value": "中等职业技术学校", "prefilled": true,
"tip": "老师已填,请核对",
"options": [ { "value": "中等职业技术学校", "label": "中等职业技术学校" } ] }
]
}
],
"materials": [ { "materialCode": "PHOTO1", "...": "见 3.4 MaterialDefVO" } ],
"submitPolicy": {
"blockOnRed": true,
"allowForceOnYellow": true,
"deadlineTip": "请在 10 月 10 日前提交"
}
}
}
```
**字段三态**
| `locked` | `prefilled` | 学生端表现 |
|:--:|:--:|---|
| `true` | — | 只读,样式灰底,右侧提示"如有误请联系老师" |
| `false` | `true` | 可编辑,标注"**老师已填,请核对**",修改后留痕 |
| `false` | `false` | 可编辑,正常样式 |
### 9.3 `POST /api/v1/mp/submissions`
**幂等**:是 **说明**:取草稿(存在则返回既有,不存在则创建)
| 字段 | 类型 | 必填 |
|---|---|---|
| `taskId` | int64 | ✅ |
**响应**:`{ "id": 5001, "formVersion": 3, "created": true }`
### 9.4 `PUT /api/v1/mp/submissions/{id}` 保存草稿
| 字段 | 类型 | 说明 |
|---|---|---|
| `formData` | object | 表单值(**只传变化的字段**,服务端做合并) |
| `extrasJson` | object | 扩展字段 |
| `clientTime` | string | 客户端时间,用于冲突提示 |
**响应**
```json
{
"code": 0,
"data": {
"savedAt": "2026-09-28T14:30:00+08:00",
"changedFields": ["phone"],
"conflict": null
}
}
```
**并发冲突**:同一份草稿在多端同时编辑,后写者返回 `conflict`(含对方已改字段),由前端提示。
**自动保存**:前端每 15 秒或失焦时调一次;服务端**不限流**(草稿合并是轻量操作)。
### 9.5 ⭐ `POST /api/v1/mp/files/upload-token` 上传前置检查(预检 / 秒传 / 压缩建议)
> ✅ **定位已确认(2026-09-30 客户确认,原「OSS 直传凭证」改为此口径)**:
> 一期实现是**服务端一步上传**(`POST /api/v1/mp/files/upload`,multipart,
> 收上来即压缩 + 按模板改名,见 §17.2 D-3),**不走对象存储直传** ——
> 直传要把上传凭证交给前端、且七牛直传必须配回调才能让服务端知道"传完了"。
> 本接口因此**不是安全边界**(客户端可以跳过它直接上传,服务端上传本身做全部校验),
> 它的价值是**在上传一个字节之前**完成三件事:
> ① 预检(格式 / 体积 / 份数,传 8MB 才发现格式不对就晚了);
> ② **秒传**(同 `fileHash` 已存在 → `existed: true`,网络断开重试时不用重传);
> ③ 端侧压缩建议(能大幅降低上传流量)。
> 大文件 / 弱网场景要恢复直传时再单独立项(含回调闭环),见《08》**F-3 / F-71 / F-72**。
**幂等**:是
**请求**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | int64 | ✅ | — |
| `materialCode` | string | ✅ | 目标槽位 |
| `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) |
| `fileSize` | int64 | ✅ | 字节 |
| `contentType` | string | ✅ | MIME |
| `source` | int | — | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入(口径见 §3.6 注) |
| `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) |
**响应**
```json
{
"code": 0,
"data": {
"fileId": 987654321,
"direct": false,
"uploadUrl": "/api/v1/mp/files/upload",
"expireSeconds": 900,
"existed": false,
"clientCompressHint": {
"enabled": true, "maxLongSide": 1600, "quality": 80, "targetSizeKb": 1024
}
}
}
```
| 字段 | 说明 |
|---|---|
| `direct` | 恒为 `false`(一期不做对象存储直传)。`uploadUrl` 就是本项目自己的接收地址,学生端 `wx.uploadFile` 直接 POST 它,与 H5 路径完全一样 |
| `existed` | `true` = **秒传命中**(同 hash 已存在),前端跳过上传直接进入处理 |
| `clientCompressHint` | **端侧预压缩建议**。这是第一道防线,能大幅降低上传流量与处理时长;**服务端仍会按规则重压**,端侧压缩只是加速 |
> **端侧压缩与「一次编码到位」的关系**:实测 300dpi/q85 即可达标,**端侧压缩只是减少上传量的优化,不是质量决定环节**。服务端始终以原图(或端侧压缩后的图)为输入重新规范,保证输出一致。
### 9.6 ⭐ `POST /api/v1/mp/files/{fileId}/process` 文件规范化(有原始件时的重新处理)
> ✅ **语义已确认(2026-09-30,原「命名+压缩+识别+校验总入口」改为此口径)**:
> 一期上传是**一步**的 —— `POST /mp/files/upload` 收上来**当场**完成压缩、按模板改名、校验、
> 记录问题(含 VAL-06 自动修复),默认**只存规范件**(`fill.storage.keep-raw=false`)。
> 所以本接口**只在「保留了原始件」时有用**:对原始件重新跑一遍规范化
> (典型场景:改了材料规则后重压)。默认配置下多数文件没有原始件,调用会明确说明这一点,
> 而不是假装处理了一遍。识别 / OCR 的四层递进是二期(M3/M4),见 §17.2 D-12。
> 下面的响应结构保留作为「规范化产物」的字段参考(上传接口的产物与之同构)。
**说明**:对**有原始件**的文件重新执行规范化(压缩 + 改名 + 校验)。
**请求**
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `async` | bool | `false` | 大文件建议异步 |
| `runOcr` | bool | `true` | 是否跑 OCR |
| `dryRun` | bool | `false` | 只试跑不落库 |
| `confirmMove` | bool | `false` | 识别到槽位不符时是否执行移动 |
**响应(同步模式)**
```json
{
"code": 0,
"data": {
"fileId": 987654321,
"originalName": "曾伊豪-361029200907295110身份证.jpg.jpg",
"standardName": "曾伊豪-361029200907295110-身份证.jpg",
"rawSizeKb": 17541, "finalSizeKb": 512,
"compressRatio": "97.1%",
"width": 1600, "height": 1200, "dpi": 300, "quality": 74,
"compressProfile": "P2",
"forced": false,
"steps": [
{ "step": "NAME_NORMALIZE", "before": "曾伊豪-361029200907295110身份证.jpg.jpg",
"after": "曾伊豪-361029200907295110-身份证.jpg", "success": true, "costMs": 12 },
{ "step": "FORMAT_CONVERT", "before": "jpg.jpg", "after": "jpg", "success": true, "costMs": 3 },
{ "step": "RESIZE", "before": "1600x1200", "after": "1600x1200", "success": true, "costMs": 210 },
{ "step": "ENCODE", "before": "17541KB", "after": "512KB", "success": true, "costMs": 840 }
],
"issues": [
{ "ruleCode": "VAL-06", "severity": 1, "title": "格式已转换",
"detail": "jpg.jpg → jpg", "autoFixed": true },
{ "ruleCode": "VAL-02", "severity": 3, "title": "身份证号与名册不一致",
"expectedVal": "3610292009******10", "actualVal": "3610292009******10",
"detail": "OCR 识别值与名册一致,文件名号码有误,已按名册修正", "autoFixed": true }
],
"ocr": { "status": 1, "confidence": 0.973, "fieldsRedacted": { "name": "曾*豪" } },
"detect": {
"materialCode": "IDCARD", "confidence": 0.98, "source": 1,
"conflict": false, "needManualPlace": false, "vlmCalled": false
},
"ownership": {
"verdict": "BELONGS_TO_OTHER",
"blockSubmit": true,
"otherRosterName": "李四",
"message": "这份材料可能不是你的(文件名对应的是「李四」)",
"action": "REUPLOAD",
"canForce": false
}
}
}
```
**异步模式**:返回 `{ "taskToken": "..." }`,通过 `GET /api/v1/mp/files/{fileId}/process-status` 轮询(间隔 500ms,最多 30 次)或 SSE 订阅。
**`process-status` 响应**:`{ "status": 1, "progress": 60, "currentStep": "ENCODE", "result": { ...同同步响应... } }`
> ⚠️ **`issues[].severity = 1`(信息级)不阻断提交**。学生看到的应该是"已自动修正",不是"你有问题"——这个区分直接影响填报完成率。
### 9.7 `POST /api/v1/mp/files/batch-process`
**说明**:多材料一次提交(学生端"一次传 4 个文件"场景)。
**请求**:`{ "submissionId": 5001, "fileIds": [987654321, 987654322, 987654323], "async": true }`
**响应**
```json
{
"code": 0,
"data": {
"batchToken": "b_7f2a...",
"total": 3,
"results": [
{ "fileId": 987654321, "status": 2, "materialCode": "PHOTO1", "detectMaterialCode": "PHOTO1", "conflict": false },
{ "fileId": 987654322, "status": 2, "materialCode": "IDCARD", "detectMaterialCode": "IDCARD", "conflict": false },
{ "fileId": 987654323, "status": 2, "materialCode": "SCHOOL_ROLL", "detectMaterialCode": "WORK_PROOF",
"conflict": true, "detectConfidence": 0.71, "needManualPlace": true }
],
"summary": { "ok": 2, "conflict": 1, "failed": 0 }
}
}
```
**冲突处理**(`conflict: true`):**只提示、不自动改位置**(FR-614a)。前端展示:
> 这张图看起来是「工作证明」,你放在了「学籍表」。要移过去吗?
>  [移到工作证明] [就放这里]
### 9.8 `GET /api/v1/mp/files/{fileId}` · `DELETE /api/v1/mp/files/{fileId}`
| 接口 | Query / 说明 | 响应 |
|---|---|---|
| GET | `prefer=preview` / `prefer=original`;返回**签名 URL**(30 分钟) | `{ previewUrl, thumbUrl, standardName, finalSizeKb, width, height }` |
| DELETE | **软删**,保留版本历史 | `{ deleted: true, remainVersions: 1 }` |
**删除约束**:已提交并进入审核的文件删除需先**撤回**(`40030`)。
### 9.9 `POST /api/v1/mp/ocr/idcard`(一期缓做)
> ⚠️ **一期未实现(等 OCR 供应商选型,《01-客户与机构确认函》D1)**。
> 这是 121 个契约接口里**唯一**没有实现的一个;表单预填与校验位校验在学生端走人工填写。
> 选型要同时评估**数据出境合规**。选型定了之后本节按原设计实现,以下为设计参考。
**说明**:身份证 OCR,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。
**请求**:`{ "fileId": 987654321, "side": "FRONT" }`
**响应**
```json
{
"code": 0,
"data": {
"name": "曾伊豪",
"idCardNo": "361029200907295110",
"idCardMask": "3610292009******10",
"gender": "男", "birthDate": "2009-07-29", "address": "江西省...",
"confidence": 0.973,
"checksumValid": true,
"crossCheck": { "rosterMatch": true, "consistentWithOtherFiles": true }
}
}
```
> **错误 `40004`**:校验位不通过时**高亮错误位**提示核对原件,而不是简单说"格式错误"。
> **错误 `50002`**:OCR 服务异常 → **降级为人工填写**,绝不阻断。
### 9.10 `POST /api/v1/mp/submissions/{id}/submit`
**幂等**:是 **错误码**:`40001` 阻断 / `40008` 已截止或已暂停
**请求**
| 字段 | 类型 | 说明 |
|---|---|---|
| `confirmWarning` | bool | 知悉黄色警告后继续 |
| `ackIssueIds` | int64[] | 已确认知悉的警告 ID |
**校验失败响应**(`40001`)
```json
{
"code": 40001,
"msg": "存在 2 项必须处理的材料问题",
"data": {
"canSubmit": false,
"blocking": [
{ "materialCode": "SCHOOL_ROLL", "ruleCode": "VAL-05", "title": "缺少材料:学籍表",
"action": "UPLOAD", "actionText": "去上传" },
{ "materialCode": "IDCARD", "ruleCode": "VAL-04", "title": "本材料证件号与其他材料不一致",
"detail": "一寸照片为 360122200902044232,身份证为 36010520090817121X",
"action": "REPLACE", "actionText": "重新上传" }
],
"warnings": [
{ "materialCode": "PHOTO1", "ruleCode": "VAL-08", "title": "照片分辨率偏低,建议让对方发一张原图",
"action": "KEEP", "actionText": "仍然使用" }
],
"canForceSubmit": true
}
}
```
**成功响应**
```json
{
"code": 0,
"data": {
"submissionId": 5001, "status": 1,
"submitTime": "2026-09-28T15:00:00+08:00",
"nextNode": "CLASS_AUDIT",
"nextNodeText": "等待李老师初审",
"estimatedAuditHours": 48
}
}
```
**社会收集**(二期):`nextNode` 直接返回 `ADMIN_AUDIT`(跳过初审)。
### 9.11 `POST /api/v1/mp/submissions/{id}/withdraw`
**约束**:`status ∈ {1, 2}`(未终审)可撤回;`status = 3` 需管理员操作(`40031`)。
**响应**:`{ "status": 0, "withdrawnAt": "..." }`
### 9.12 `GET /api/v1/mp/submissions/{id}/progress`
**响应**
```json
{
"code": 0,
"data": {
"status": 1, "progressPercent": "75%",
"materials": [
{ "materialCode": "PHOTO1", "materialName": "一寸照片", "required": true, "done": true,
"fileCount": 1, "issueLevel": 0 },
{ "materialCode": "SCHOOL_ROLL", "materialName": "学籍表", "required": true, "done": false,
"fileCount": 0, "issueLevel": 3, "issueText": "缺少材料" }
],
"timeline": [
{ "time": "2026-09-28T14:00:00+08:00", "title": "创建草稿" },
{ "time": "2026-09-28T15:00:00+08:00", "title": "已提交", "desc": "等待初审" }
]
}
}
```
---
## §10 班主任移动端
### 10.1 `GET /api/v1/mp/teacher/dashboard`
**响应**
```json
{
"code": 0,
"data": {
"summary": {
"rosterTotal": 28, "submittedCount": 23, "approvedCount": 15,
"pendingCount": 4, "unsubmittedCount": 5, "abnormalCount": 4,
"submitRate": "82.1%"
},
"myOrgs": [
{ "orgId": 2001, "orgName": "24中技数控1班", "rosterTotal": 28,
"submittedCount": 23, "approvedCount": 15, "submitRate": "82.1%",
"isHead": true }
],
"todos": { "auditCount": 4, "abnormalCount": 4, "rejectedAwaitingCount": 1 },
"deadline": { "taskId": 2026090001, "endTime": "2026-10-10T23:59:59+08:00", "remainDays": 12 }
}
}
```
> **口径铁律**:`submitRate` 是**已提交率 23/28 = 82.1%**,与 `approvedCount 15` 分开呈现。原型定稿如此,接口不许合并。
### 10.2 `GET /api/v1/mp/teacher/audit/todos`
**Query**:`orgId`、`level`(`0` 全部 / `1` 仅无问题 / `2` 有问题)、`keyword`、分页(**卡片流默认 pageSize=10**)
**响应**
```json
{
"code": 0,
"data": {
"list": [
{
"submissionId": 5001, "rosterId": 9001,
"realName": "曾伊豪", "avatarUrl": null,
"orgName": "24中技数控1班",
"submitTime": "2026-09-28T15:00:00+08:00",
"waitHours": 3.2,
"level": 0,
"redCount": 0, "yellowCount": 0,
"materialThumbs": [ "url1", "url2", "url3", "url4" ],
"materialCount": 4,
"changedFields": ["phone"],
"rejectCount": 0,
"batchApprovable": true
},
{
"submissionId": 5002, "realName": "张三", "level": 3,
"redCount": 1, "yellowCount": 1,
"redSummary": "身份证号与名册不一致",
"batchApprovable": false
}
],
"total": 4,
"counts": { "all": 4, "clean": 3, "abnormal": 1 }
}
}
```
| `level` | 含义 | 卡片表现 |
|:--:|---|---|
| `0` | 无问题 | 绿色,**可右滑通过** |
| `2` | 仅黄色 | 黄色 |
| `3` | 有红色 | 红色,**不可批量、不可滑动** |
> **`batchApprovable`**:服务端算好,前端据此决定是否让该卡片参与"一键通过"。**规则不落地到前端**。
### 10.3 `POST /api/v1/mp/teacher/audit/{submissionId}/approve`
**幂等**:是
| 字段 | 类型 | 说明 |
|---|---|---|
| `remark` | string | 可选备注 |
| `actionSource` | string | `SWIPE`(右滑)/ `BUTTON`(按钮)——**用于埋点分析误触率** |
**响应**:`{ "status": 2, "nextNode": "ADMIN_AUDIT", "nextNodeText": "等待管理员终审" }`
**校验**:`redCount > 0` 时返回 `40032` —— **有红色问题的单不允许通过**,必须先驳回让学生改。
### 10.4 `POST /api/v1/mp/teacher/audit/{submissionId}/reject`
> ⚠️ **班主任端不做左滑驳回**。驳回必须点按钮 + 二次确认(误触后果不对称)。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `reasonCode` | string | ✅ | 见 PRD 附录 C |
| `reasonText` | string | 条件 | `R999` 时必填 |
| `materialCodes` | string[] | — | 指出是哪几项材料的问题 |
| `voiceText` | string | — | 语音转文字结果(可选输入方式) |
| `suggestReupload` | bool | — | 是否建议重新扫描 |
**响应**:`{ "status": 4, "rejectCount": 1, "notifySent": true, "escalated": false }`
**升级规则**:`rejectCount >= maxRejectCount(3)` 时 `escalated: true`,同时通知管理员。
### 10.5 `POST /api/v1/mp/teacher/audit/batch-approve`
**请求**
| 字段 | 类型 | 说明 |
|---|---|---|
| `submissionIds` | int64[] | ≤200 |
| `onlyClean` | bool | **默认 true 且服务端强制 true** |
**响应**
```json
{
"code": 0,
"data": {
"approved": 3, "skipped": 1,
"details": [
{ "submissionId": 5002, "skipped": true,
"reason": "存在红色问题,需单独审核", "redCount": 1 }
]
}
}
```
> **铁律**:**批量通过只带无问题的单,红黄一律排除**。即使前端传了红单,服务端也跳过并回报原因。这是防止"手快全选"放行异常的最后一道闸。
### 10.6 `POST /api/v1/mp/teacher/proxy-upload`
**说明**:班主任代传材料(学生不会弄 / 人不在)。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `submissionId` | int64 | ✅ | — |
| `materialCode` | string | ✅ | — |
| `fileId` | int64 | ✅ | 已上传的文件 |
| `proxyReason` | string | — | 代传原因 |
**响应**:`{ "fileId": 987654322, "proxyBy": 10087, "proxyByName": "李老师" }`
> **代传必须留痕**:文件记录 `source = 4`(代传),审核时间轴显示"由李老师代传"。学生端可见。
### 10.7 `POST /api/v1/mp/teacher/notify/unsubmitted` · `GET /mp/teacher/notify/records`
| 接口 | 说明 |
|---|---|
| `notify/unsubmitted` | 请求 `{ orgIds[], channel, excludeRecentHours }`,**仅催未提交者** |
| `notify/records` | 崔报历史:`{ list: NotifyRecordVO[], total }`,可按 `bizType` / `status` 筛 |
---
## §11 审核(PC 端为主)
### 11.1 `GET /api/v1/audit/todos`
**Query**:`taskId`、`orgId`、`level`、`node`(`1` 初审 / `2` 终审)、`waitingOverHours`、分页
**响应**:`{ list: SubmissionVO[](精简), total, counts: { all, clean, abnormal, overTime } }`
### 11.2 `GET /api/v1/audit/submissions/{id}` ⭐ 审核详情
**响应**:完整 `SubmissionVO` + 材料 + 问题 + **识别结果**
```json
{
"code": 0,
"data": {
"id": 5001, "status": 1,
"realName": "曾伊豪", "idCardMask": "3610292009******10",
"orgName": "24中技数控1班", "submitTime": "2026-09-28T15:00:00+08:00",
"changedFields": ["phone"],
"changedDetail": [
{ "field": "phone", "label": "联系电话",
"rosterValue": "138****8888", "studentValue": "139****9999",
"changedAt": "2026-09-28T14:50:00+08:00" }
],
"materials": [
{
"materialCode": "IDCARD", "materialName": "身份证", "done": true,
"files": [
{
"id": 987654321,
"originalName": "曾伊豪-361029200907295110身份证.jpg.jpg",
"standardName": "曾伊豪-361029200907295110-身份证.jpg",
"previewUrl": "https://...", "thumbUrl": "https://...",
"finalSizeKb": 512, "maxSizeKb": 1024,
"width": 1600, "height": 1200, "dpi": 300,
"validateStatus": 2,
"detect": { "materialCode": "IDCARD", "confidence": 0.98, "source": 1,
"sourceText": "文件名匹配", "conflict": false },
"issues": [
{ "ruleCode": "VAL-02", "severity": 1, "title": "证件号已按名册修正",
"expectedVal": "3610292009******10", "actualVal": "3610292009******10",
"autoFixed": true }
]
}
]
}
],
"redCount": 0, "yellowCount": 1,
"approvePolicy": { "canApprove": true, "canBatch": true, "reason": null },
"timeline": [ { "node": 0, "title": "学生提交", "time": "..." } ]
}
}
```
> **识别结果 (`detect`) 在此一并返回**——这就是追溯矩阵里 I10 缺口的落点,不需要独立接口。
### 11.3 `POST /api/v1/audit/submissions/{id}/approve` · `/reject`
**与班主任端同构**,差异:
| 项 | 初审(班主任) | 终审(管理员) |
|---|---|---|
| 通过后状态 | `1 → 2`(待终审) | `2 → 3`(已通过) |
| 驳回后状态 | `→ 4` | `→ 4` |
| 驳回是否可越过初审 | — | **可以**(终审驳回直接生效) |
| `redCount > 0` | 禁止通过 | 禁止通过 |
**终审请求额外字段**:`{ "finalOpinion": "同意认定", "needArchive": false }`
### 11.4 ⭐ `POST /api/v1/audit/submissions/{id}/revoke`
**说明**:撤回已作出的审核结论(**这是原设计缺失的接口**)。
**请求**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `auditRecordId` | int64 | ✅ | 要撤回的审核记录 |
| `reason` | string | ✅ | 撤回原因 |
**响应**:`{ "status": 1, "revokedRecordId": 5501 }`
**约束**
| 条件 | 结果 |
|---|---|
| 当前状态 `3`(已通过)且未被导出 | ✅ 允许,回退到 `2` 或 `1` |
| 当前状态 `2`(待终审) | ✅ 允许,回退到 `1`(撤回的是**初审**结论 —— 初审通过后发现填错,正是最需要撤的时刻) |
| **已被导出打包** | ❌ `40033`——**已交付的数据不可追溯修改** |
| 已归档 | ❌ `40034` |
| 撤回者非原审核人且非管理员 | ❌ `40301` |
**留痕**:写入一条 `action = 7`(撤回)的 `collect_audit_record`,时间轴显示"李老师撤回了通过结论"。
(旧版写 `action = 3` —— `3` 实际是**批量通过**,撤回恒为 `7`,与代码常量 `ACTION_REVOKE` 一致。)
### 11.5 `GET /api/v1/audit/submissions/{id}/timeline`
**响应**:`AuditRecordVO[]`,倒序。
### 11.6 `GET /api/v1/audit/statistics`
**Query**:`taskId`、`orgId`、`from` / `to`
**响应**:`{ pendingCount, approvedCount, rejectedCount, avgAuditHours, timeoutCount, byAuditor: [{ auditorName, handled, avgHours }], byReason: [{ reasonCode, reasonText, count }] }`
> **`byReason`** 是给管理员看"哪种问题最多"的,用于反过来优化材料引导语。
### 11.7 `GET /api/v1/audit/reason-codes` · `POST /api/v1/audit/submissions/{id}/proxy-upload`
| 接口 | 说明 |
|---|---|
| `reason-codes` | 返回 PRD 附录 C 的 10 条,仅返回启用的;可按 `taskId` 定制 |
| `proxy-upload` | 同 10.6,PC 端入口 |
---
## §12 校验 / 导出 / 通知
### 12.1 `POST /api/v1/validation/run`
**说明**:任务级全量校验,产出问题清单。
> ✅ **改为同步(2026-09-30 客户确认)**:旧版声明 `async` 默认 `true` 并返回 `jobId`,
> 但全契约**没有"查 jobId"的接口** —— 那个 jobId 查不到任何东西。
> 实测一份填报单的校验 <100ms、一个任务的量级秒级,**同步跑完直接回结果**
> 比返回一个查不到状态的作业号有用。保留 `jobId` 仅作本次运行的标识(幂等对账用)。
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `taskId` | int64 | ✅ | — |
| `scope` | string | `ALL` | `ALL` / `ISSUES_ONLY` / `APPOINTED` |
| `submissionIds` | int64[] | — | `scope=APPOINTED` 时必填 |
| `rules` | string[] | 全部 | 只跑指定规则(如 `["VAL-02","VAL-04"]`) |
**响应**:`{ "jobId": "v_3f2a", "checkedSubmissions": 320, "issuesFound": 47, "unresolvedLeft": 31, "elapsedMs": 812 }`
### 12.2 `GET /api/v1/validation/issues`
**Query**:`taskId`、`severity`、`ruleCode`、`orgId`、`resolved`、分页
**响应**
```json
{
"code": 0,
"data": {
"list": [ { "...": "ValidationIssueVO" } ],
"total": 47,
"counts": { "severity1": 12, "severity2": 30, "severity3": 5 },
"byRule": [ { "ruleCode": "VAL-06", "title": "双扩展名", "count": 56, "autoFixed": 56 } ]
}
}
```
### 12.3 `GET /api/v1/validation/issues/export`
**响应**:Excel 文件流。列:`姓名` `班级` `材料` `规则码` `级别` `问题` `期望值` `实际值` `是否自动修复` `处理人`。
### 12.4 `POST /api/v1/exports` ⭐ 导出(前置检查是铁律)
**幂等**:是 **限流**:5 次/分钟/租户
**请求**
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `taskId` | int64 | ✅ | — |
| `exportName` | string | ✅ | 如 `202609机电认定-24中技数控1班` |
| `scope` | object | — | `{ orgIds, onlyApproved, includeRaw }` |
| `splitSizeMb` | int | `50` | 分卷大小 |
| `desensitize` | bool | `false` | 是否脱敏导出 |
| `format` | string | `ZIP` | `ZIP` / `FOLDER` |
| `generateSummaryExcel` | bool | `true` | 生成汇总 Excel |
| `notifyOnFinish` | bool | `true` | — |
**⚠️ 前置检查失败响应(`40035`)——导出按钮必须被禁用**
```json
{
"code": 40035,
"msg": "存在 3 项不合格文件,无法导出",
"data": {
"canExport": false,
"blocking": [
{ "type": "FILE_SIZE", "rosterId": 9012, "realName": "张三",
"materialCode": "WORK_PROOF", "fileId": 987654400,
"title": "工作证明 1.8M 超过 1M 上限", "action": "重新压缩或重传" },
{ "type": "NAME_INVALID", "rosterId": 9014, "realName": "李四",
"materialCode": "IDCARD", "fileId": 987654410,
"title": "文件名未规范化", "action": "触发重新处理" },
{ "type": "MISSING_MATERIAL", "rosterId": 9020, "realName": "王五",
"materialCode": "SCHOOL_ROLL",
"title": "缺少必传材料:学籍表", "action": "催报" }
],
"summary": { "sizeExceeded": 1, "nameInvalid": 1, "missing": 1, "unapproved": 0 },
"quickActions": ["RETRY_COMPRESS", "REPROCESS_NAMES", "NOTIFY_UNSUBMITTED"]
}
}
```
> **铁律 2**:`canExport = false` 时,前端**直接禁用「开始导出」按钮**,并指出是哪一个人的哪一项卡住了。**不允许"警告后放行"**。
**成功响应**:`{ "exportId": 555, "status": 0, "estimatedSeconds": 45 }`
### 12.5 `GET /api/v1/exports` · `GET /api/v1/exports/{id}`
**列表响应**:`{ list: ExportTaskVO[], total }`
**进度响应**
```json
{
"code": 0,
"data": {
"id": 555, "status": 1, "progress": 62,
"currentStep": "COMPRESS",
"stepText": "正在压缩 24中技数控1班/工作证明",
"stats": { "fileCount": 112, "processedCount": 70, "rawSizeMb": 475, "finalSizeMb": 22.4,
"compressRatio": "95.3%" },
"resultFiles": null, "errorMsg": null
}
}
```
**完成响应**:`resultFiles: [{ "name": "202609机电认定-24中技数控1班.zip", "sizeMb": 22.4, "url": "..." }]`
### 12.6 `GET /api/v1/exports/{id}/download`
**Query**:`fileIndex`(分卷序号,默认 0)
**响应**
```json
{
"code": 0,
"data": {
"url": "https://oss.../export/555/part1.zip?Signature=...",
"expireSeconds": 1800,
"fileName": "202609机电认定-24中技数控1班.zip",
"sizeMb": 22.4,
"checksum": "sha256:abc123...",
"remainsCachedDays": 7
}
}
```
**错误 `40302`**:签名过期 → 重新获取(不重复计费)。
### 12.7 通知
| 接口 | 请求要点 | 响应 |
|---|---|---|
| `GET /notify/templates` | `taskId`(可选,租户级/任务级) | `[{ id, templateCode, bizType, channel, title, content, enabled }]` |
| `PUT /notify/templates/{id}` | `{ title, content, enabled }`;`content` 支持变量 `{姓名}` `{任务名}` `{截止日}` `{驳回原因}` | `{ updated: true }` |
| `POST /notify/send` | `{ bizType, receiverIds[], channel, templateCode, extraText }` | `{ sent, failed, skipped }` |
| `GET /notify/records` | `taskId`、`bizType`、`status`、分页 | `{ list: NotifyRecordVO[], total, deliveryRate: "97.2%" }` |
| `POST /callback/wechat/msg` | 微信回调原始报文 | `{ errcode: 0 }` |
**模板变量白名单**(越界变量渲染为空并告警):
| 变量 | 适用 |
|---|---|
| `{姓名}` `{班级}` | 全部 |
| `{任务名}` `{截止日}` `{剩余天数}` | 全部 |
| `{驳回原因}` `{材料名}` | 驳回/催报 |
| `{提交进度}` | 催报 |
| `{导出包名}` `{下载链接}` | 导出完成 |
**订阅消息限制**:微信订阅消息为**一次性订阅**,每次发送消耗 1 次额度(`collect_subscribe_quota`)。额度不足时**降级为公众号模板消息**,再不足降级短信(按条计费,见 §13.6)。
---
## §13 本次补齐的缺口接口
### 13.1 说明
> 来源:《需求追溯矩阵》§3.1 审计发现的 10 个缺口。**这些接口在原 PRD §8 中被需求提及但未定义**,若不补齐,对应页面将无数据来源。
| 缺口 | 补在哪 | 状态 |
|---|---|---|
| I1 报考范围读取/确认 | **§7.7 / §7.8**(已定义) | ✅ |
| I8 审核撤回 | **§11.4**(已定义) | ✅ |
| I9 任务暂停/恢复 | **§7.4**(已定义) | ✅ |
| I10 材料识别结果读取 | **并入 §11.2 详情响应**(不新增接口) | ✅ |
| I2 历史数据整理器 | **§13.2** | ✅ |
| I3 字典管理 | **§13.3** | ✅ |
| I4 / I5 日志 | **§13.4** | ✅ |
| I6 消息中心 | **§13.5** | ✅ |
| I7 订阅额度登记 | **§13.6** | ✅ |
### 13.2 历史数据整理器(5 个)· 差异化获客功能
#### `POST /api/v1/cleanup/jobs`
**说明**:上传已有压缩包/文件夹,用**与正式收集完全相同的规则**做规范化。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `jobName` | string | ✅ | 如 `24中技数控1班-历史整理` |
| `sourceType` | int | ✅ | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
| `uploadToken` / `objectKey` | string | 条件 | `sourceType=1/2` 时必传(先走通用上传) |
| `sourceUrl` | string | 条件 | `sourceType=3` 时必传 |
| `namingTemplate` | string | — | 缺省用系统默认 |
| `compressConfig` | object | — | 缺省见 3.11 |
| `rosterTaskId` | int64 | — | **关联到已有任务的名册**(用于证件号纠错与串档检测) |
**响应**:`{ "jobId": 301, "status": 0, "estimatedSeconds": 120 }`
> `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化,**有名单才能做证件号校验、疑似串档检测**——而"疑似串档 1 例"正是报告里最有价值的一条。
#### `GET /api/v1/cleanup/jobs`
**Query**:`status`、`keyword`、分页 **响应**:`{ list: CleanupJobVO[], total }`
#### `GET /api/v1/cleanup/jobs/{id}`
**响应**
```json
{
"code": 0,
"data": {
"id": 301, "jobName": "24中技数控1班-历史整理", "status": 3, "progress": 100,
"totalFiles": 112, "processedFiles": 112,
"parsedStudents": 28, "parsedMaterials": 4,
"rawSizeMb": 436, "finalSizeMb": 37, "compressRatio": "91.5%",
"resultPath": "/cleanup/301/result.zip",
"finishTime": "2026-09-28T16:00:00+08:00"
}
}
```
#### `GET /api/v1/cleanup/jobs/{id}/report` ⭐ 体检报告
**Query**:`format`(`json` / `xlsx`)
**响应(json)**
```json
{
"code": 0,
"data": {
"jobId": 301,
"summary": {
"totalFiles": 112,
"nameInvalid": 59, "sizeExceeded": 31, "formatConverted": 56,
"idcardMismatch": 5, "idcardAutoFixed": 3, "idcardNeedConfirm": 2,
"crossFileSuspected": 1, "lowResolution": 1, "orphanImages": 0
},
"autoHandled": 90,
"needManual": 1,
"highlights": [
{ "type": "CROSS_FILE", "level": 3, "rosterId": 9012, "realName": "张三",
"title": "疑似串档", "detail": "该生 4 项材料的证件号与名册均不同,且 4 项彼此一致",
"suggestion": "可能整个文件夹被换过,请核对学生本人" },
{ "type": "LOW_RESOLUTION", "level": 2, "rosterId": 9008, "realName": "李四",
"title": "分辨率偏低", "materialCode": "IDCARD",
"detail": "558×745,同批其他人为 1279×1706", "suggestion": "建议索取原图" }
],
"nameFixedSamples": [
{ "original": "身份证.jpg.jpg", "fixed": "曾伊豪-361029200907295110-身份证.jpg", "rule": "VAL-06" }
],
"idcardConflicts": [
{ "rosterId": 9012, "realName": "张三",
"rosterValue": "3610292009******10", "fileNameValue": "3610292009******99",
"ocrValue": "3610292009******10",
"verdict": "AUTO_FIXED", "verdictText": "OCR 与名册一致,按名册修正文件名" }
]
}
}
```
> **报告里最重要的一句话是 `autoHandled: 90 / needManual: 1`**——「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。这是最有说服力的销售话术,也是接口必须算出来的数字。
#### `POST /api/v1/cleanup/jobs/{id}/import`
**说明**:把整理结果**导入为正式任务**(名册 + 材料一次成型)。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskName` | string | ✅ | 新任务名 |
| `collectMode` | int | ✅ | `1` / `2` |
| `batchNo` | string | ✅ | — |
| `importRoster` | bool | — | 是否把解析出的学员建成名册(默认 true) |
| `importFiles` | bool | — | 是否把文件挂到填报单上(默认 true) |
| `markAsSubmitted` | bool | — | 是否直接置为"已提交待审"(默认 true) |
**响应**:`{ "taskId": 2026090002, "rosterCount": 28, "fileCount": 112 }`
### 13.3 字典管理(4 个)
| 接口 | 说明 |
|---|---|
| `GET /api/v1/dicts` | 字典列表 `[{ dictCode, dictName, itemCount, builtin }]` |
| `GET /api/v1/dicts/{code}/items` | 字典项 `[{ id, itemCode, itemName, parentItemCode, sortNo, status }]`,**含层级**(如职业-工种) |
| `POST /api/v1/dicts/{code}/items` | 新增项:`{ itemCode, itemName, parentItemCode, sortNo }` |
| `PUT /api/v1/dicts/items/{id}` | 修改:`{ itemName, sortNo, status }` |
**内置字典(`builtin: true`,不可删,只可停用单项)**
| `dictCode` | 名称 | 项数 |
|---|---|---|
| `EDU_LEVEL` | 学历 | 11 |
| `JOB_LEVEL` | 职业技能等级 | 5(一~五级) |
| `CERT_TYPE` | 认定类型 | 3 |
| `RETARGET_SUBJECT` | 补考科目 | 3 |
| `OCCUPATION` | 职业(国标,可选) | 由 `sys_occupation_ref` 提供 |
| `REJECT_REASON` | 驳回原因 | 10 |
**约束**:有下级项的字典项不可停用(`40036`)。修改 `itemCode` 不允许(会破坏历史数据)。
> **`OCCUPATION` 字典的特殊性**:官方 Excel 模板里「报考职业」「报考工种」**是自由填写、没有下拉**。所以字典在这里**只做拼写校验与联想建议(P2 级),不做强约束**。凡发现自由填写值不在字典中,记 `SCOPE_FREE_TEXT` 信息级提示,**不阻断**。
### 13.4 日志(2 个)
#### `GET /api/v1/logs/operations`
**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页
**范围**(权限二选一,`@RequirePerm({log:viewTenant, log:viewAll})`):
| 调用者 | 权限 | 看到什么 |
|---|---|---|
| 平台运营 | `log:viewAll` | **默认全量**(跨租户);传 `tenantId` 则只看该单位 |
| 学校管理员 / 教务处 | `log:viewTenant` | **恒为本租户**;传了别的 `tenantId` → `40301`(**不静默忽略**:静默会让人以为"筛过了") |
**响应**
```json
{
"code": 0,
"data": {
"list": [
{ "id": 70001, "operatorId": 10086, "operatorName": "王老师", "module": "ROSTER",
"action": "UPDATE_IDCARD", "targetType": "ROSTER", "targetId": 9012,
"beforeVal": "3610292009******10", "afterVal": "3610292009******99",
"ip": "10.1.2.3", "userAgent": "Chrome/120", "createTime": "2026-09-28T16:20:00+08:00" }
],
"total": 1
}
}
```
**必记的敏感操作**(PRD FR-506 / FR-1202)
| `action` | 场景 |
|---|---|
| `UPDATE_IDCARD` | **改名册证件号**(留痕强制) |
| `UPDATE_ROSTER_NAME` | 改名 |
| `DELETE_ROSTER` | 删名册项 |
| `PUBLISH_TASK` / `PAUSE_TASK` / `ARCHIVE_TASK` | 任务状态流转 |
| `REVOKE_AUDIT` | **撤回审核** |
| `EXPORT_CREATE` | 创建导出 |
| `PROXY_UPLOAD` | 代传材料 |
| `VIEW_IDCARD_FULL` | **查看完整身份证号**(单独留痕) |
| `CONFIG_COMPRESS` | 改压缩配置 |
**脱敏**:`beforeVal` / `afterVal` 中的证件号**一律脱敏存储**,即使日志泄露也不暴露明文。
#### `GET /api/v1/logs/logins`
**Query**:`tenantId`、`userId`、`userType`、`result`、`from` / `to`、分页
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
| `loginType` | 含义 |
|---|---|
| `PASSWORD` | PC 账号密码 |
| `WECHAT` | 小程序微信授权 |
| `PHONE` | 手机号快捷登录 |
> **保留期**:操作日志 3 年,登录日志 1 年(可在租户配置中调整)。
### 13.5 消息中心(3 个)
#### `GET /api/v1/mp/messages`(学生端)
**Query**:`bizType`、`read`、分页
**响应**
```json
{
"code": 0,
"data": {
"list": [
{ "id": 80001, "bizType": 3, "title": "你的材料被退回",
"content": "「工作证明」未盖公章,请重新提供",
"read": false, "linkType": "SUBMISSION", "linkId": 5001,
"createTime": "2026-09-28T16:30:00+08:00" },
{ "id": 80002, "bizType": 2, "title": "距截止还有 3 天",
"content": "请在 10 月 10 日前完成填报", "read": true,
"linkType": "TASK", "linkId": 2026090001, "createTime": "2026-10-07T09:00:00+08:00" }
],
"total": 2,
"unreadCount": 1
}
}
```
#### `POST /api/v1/mp/messages/read`
`{ "messageIds": [80001], "all": false }` → `{ "unreadCount": 0 }`
#### `GET /api/v1/mp/teacher/messages`(教师端)
同构,`bizType` 增加 `7`(加入名单审批)等教师专属类型。
> **消息中心 vs 通知记录的区别**:`collect_notify_record` 记的是**发送通道结果**(发出去没有、到没到),消息中心记的是**站内信内容**(用户看到什么)。二者通过 `bizType` 关联。
### 13.6 订阅消息额度登记(1 个)
#### `POST /api/v1/mp/subscribe`
**说明**:微信订阅消息为**一次性授权**,用户每点一次"允许"只能发一条。小程序端在关键节点**主动索取授权**并登记额度。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `templateCode` | string | ✅ | 模板码,见下表 |
| `subscribeCount` | int | — | 本次授权次数(微信返回,默认 1) |
**响应**:`{ "templateCode": "AUDIT_RESULT", "total": 5, "used": 2, "remain": 3 }`
**模板码与索取时机**
| `templateCode` | 索取时机 | 用途 |
|---|---|---|
| `SUBMIT_SUCCESS` | 提交成功后 | 通知审核进展 |
| `AUDIT_RESULT` | 提交成功后 | 通过 / 驳回通知 |
| `DEADLINE_REMIND` | 进入填报页时 | 临期提醒 |
| `EXPORT_READY` | 管理端创建导出时 | 导出完成 |
| `JOIN_APPROVE` | 申请加入名单时 | 审批结果 |
**额度不足的降级链**
```
微信订阅消息(免费,额度受限)
↓ 额度耗尽
公众号模板消息(需关注公众号)
↓ 未关注
短信(按条计费,走 collect_subscribe_quota 计费口径)
↓ 超出套餐
仅站内信(不推送,用户主动进小程序可见)
```
> **降级必须静默**:学生端不显示"你的额度不够了"。运营侧在 `GET /tenants/{id}/usage` 里看到 `smsCount` 上涨与告警。
---
## §14 开放接口(供认定机构对接)
**鉴权**:`appKey + timestamp + nonce + HMAC-SHA256`
```
X-App-Key: jxjd_2026
X-Timestamp: 1790000000
X-Nonce: 8f3a9c2e
X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
```
**签名规则**:`body` 为**原始 JSON 字符串**(未格式化、未排序);`timestamp` 与服务器时间偏差 > 5 分钟拒绝;`nonce` 5 分钟内不可重复。
### 14.1 `POST /open/v1/auth/token`
**请求**:`{ "appKey": "jxjd_2026", "appSecret": "..." }`
**响应**:`{ "accessToken": "...", "expireSeconds": 7200, "scope": ["submission:read","file:read"] }`
### 14.2 `GET /open/v1/tasks/{id}/submissions`
**Query**:`since`(增量拉取时间)、`status`(默认 `3` 已通过)、分页
**响应**:`SubmissionVO` 数组 + `idCardMask`(**开放接口一律不返回明文证件号**)
### 14.3 `GET /open/v1/tasks/{id}/files`
**Query**:`submissionIds`(逗号分隔,≤200)
**响应**
```json
{
"code": 0,
"data": {
"files": [
{ "fileId": 987654321, "submissionId": 5001, "realName": "曾伊豪",
"materialCode": "IDCARD", "standardName": "曾伊豪-3610292009******10-身份证.jpg",
"url": "https://oss.../sign?expire=1800", "sizeKb": 512, "expireSeconds": 1800 }
]
}
}
```
> 签名 URL **有效期 30 分钟**,过期需重新获取。
### 14.4 `POST /open/v1/callback/push`
**说明**:认定机构提供回调地址,系统在终审通过后主动推送。
**请求(我方 → 机构)**
```json
{
"eventType": "SUBMISSION_APPROVED",
"occurTime": "2026-09-28T17:00:00+08:00",
"taskId": 2026090001, "taskCode": "2026090001",
"submissionId": 5001,
"data": { "realName": "曾伊豪", "idCardMask": "3610292009******10",
"occupation": "车工", "worktype": "数控车工", "jobLevel": "四级" },
"files": [ { "materialCode": "IDCARD", "url": "https://...", "expireSeconds": 1800 } ]
}
```
**重试策略**:失败重试 5 次,间隔 1/5/30/120/600 秒;全部失败后标记并告警,支持人工重推。
---
## §15 错误码全集
> 在 PRD 附录 D(20 个码)基础上**新增 38 个**(标 ⭐),本节共 **58 个**(含 `0` 成功码)。所有码在 `code` 字段返回,HTTP 状态码见 §1.3。
### 15.1 客户端错误(4xxxx)
| 码 | 含义 | 端处理 |
|---|---|---|
| `40001` | 存在阻断性校验问题,无法提交 | 展示 blocking 清单,引导逐项处理 |
| `40002` | 文件格式不在白名单 | 提示支持的格式 |
| `40003` | 文件体积超上限且压缩受限 | 提示重新提供 |
| `40004` | 身份证号校验位不通过 | **高亮错误位**,提示核对原件 |
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
| `40006` | 疑似串档 | 转人工,**不自动修正** |
| `40007` | 门禁未匹配 | **统一提示**"没有找到你的信息",不透露哪项错 |
| `40071` | 门禁尝试过于频繁 | 提示"操作过于频繁,请稍后再试" |
| `40072` | 名单项已被他人绑定 | 提示"该信息已被登记" |
| `40008` | 任务已截止 / 已归档 / **已暂停** | 禁止提交 |
| `40009` | 重复提交(幂等键冲突) | 返回既有结果 |
| `40010` | 驳回次数超上限 | 提示联系班主任 |
| ⭐ `40011` | 批量操作超过 200 条上限 | 分批提交 |
| ⭐ `40012` | 账号或密码错误 | 重新输入(不透露账号是否存在) |
| ⭐ `40013` | 账号已锁定 | 提示剩余锁定时间 |
| ⭐ `40014` | 用户有在办任务,不可停用 | 提示先移交任务 |
| ⭐ `40015` | 组织被占用,不可删除 | 展示占用清单 |
| ⭐ `40016` | 名册为空,不可发布 | 引导导入名册 |
| ⭐ `40017` | 名册缺少「姓名」或「身份证号」列 | 引导重新导入 |
| ⭐ `40018` | 未配置必传材料项 | 引导配置材料 |
| ⭐ `40019` | 未设置覆盖班级或班主任 | 引导配置 |
| ⭐ `40020` | 命名规则试跑未通过 | 展示失败样例,引导修规则 |
| ⭐ `40021` | 仅已发布任务可暂停 | 刷新状态 |
| ⭐ `40022` | 仅已暂停任务可恢复 | 刷新状态 |
| ⭐ `40023` | 存在待审单,不可归档 | 展示待审单 |
| ⭐ `40024` | 报考范围未覆盖全部名册 | 返回差集(**漏人查验**) |
| ⭐ `40025` | 表单版本冲突 | 返回最新 schema,前端差异合并 |
| ⭐ `40026` | 内置字段不可删除 | 提示可改标签/顺序 |
| ⭐ `40027` | 材料项已有文件,不可删除 | 提示改为隐藏 |
| ⭐ `40028` | 无法自动定位表头行 | 返回前 10 行预览,要求手动指定 |
| ⭐ `40029` | 名册项已绑定/已提交,不可删除 | 提示改为"不参与本次认定" |
| ⭐ `40030` | 已进入审核,需先撤回才能删材料 | 引导撤回 |
| ⭐ `40031` | 已通过终审,学生不可撤回 | 提示联系管理员 |
| ⭐ `40032` | **存在红色问题,不允许通过** | 必须先驳回让学生修正 |
| ⭐ `40033` | **已导出打包,不可撤回审核** | 已交付数据不可追溯修改 |
| ⭐ `40034` | 已归档,不可撤回 | — |
| ⭐ `40035` | **存在不合格文件,禁止导出** | 禁用导出按钮 + 展示 blocking 清单 |
| ⭐ `40036` | 字典项有下级,不可停用 | 先处理下级 |
| ⭐ `40037` | **加入申请次数超限**(同 openid 每任务 >2) | 提示联系老师 |
| ⭐ `40038` | **该证件号已有待审申请** | "你已提交过申请,请耐心等待" |
| ⭐ `40039` | **该证件号已在名册中** | "你已在名单中,请返回重新核验"(引导回门禁,**不泄露新信息**) |
| ⭐ `40040` | 申请状态已变更(非待审) | 刷新列表 |
| ⭐ `40041` | **名册中该证件号已被占用** | 不覆盖,提示管理员核查 |
| ⭐ `40042` | 申请已通过,不可再驳回 | 如需撤销请走名册删除 |
| ⭐ `40043` | **文件归属存疑**(文件名指向名册中的另一个人,或归属无法确证) | **阻断提交**,提示"这份材料可能不是你的",引导重新上传(**无"仍然继续"选项**) |
| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) |
| `40101` | 未登录 / token 失效 | 静默重新登录 |
| `40301` | 无权限(含跨租户) | 跳转 403 |
| `40302` | 签名 URL 已过期 | 重新获取 |
| `42901` | 触发限流 | 退避重试 |
### 15.2 服务端错误(5xxxx)
| 码 | 含义 | 端处理 |
|---|---|---|
| `50001` | 文件处理失败 | 保留原图,允许重试 |
| `50002` | OCR 服务异常 | **降级为人工填写** |
| `50003` | 图片过大被熔断 | 转异步处理 |
| ⭐ `50004` | VLM 服务异常 | **降级为规则结果或不判**(识别失败一律放行) |
| ⭐ `50005` | 导出打包失败 | 保留中间态,允许重试(不重复计费) |
| ⭐ `50006` | 通知发送失败 | 记录 `fail_reason`,走降级链 |
| ⭐ `50007` | 存储写入失败 | 重试 3 次后提示用户重传 |
> **降级原则**(贯穿全部 5xxxx):**任何服务端能力失效都不得阻断学生提交**。OCR 挂了就手填,VLM 挂了就不判,识别失败就放行。这是填报完成率的底线保障。
---
## §16 变更记录
| 版本 | 日期 | 变更 |
|---|---|---|
| **V2.6** | **2026-09-30** | **六处口径修正 + 新增 §17(全部经客户确认)**:① §2.7 `notifyBizType` 5/6 对调修正(5 催报 / 6 导出完成,F-30);② §2.7 `auditAction` 补全为 7 个动作、撤回=7(F-7);③ §3.6/§9.5 `source` 改为建表口径「谁传的」(F-38/39);④ §9.5 改为「上传前置检查」定位 + §9.6 `process` 语义改为「有原始件时的重新规范化」(F-3/71/72);⑤ §9.9 OCR 标注一期缓做;⑥ §11.4 撤回约束补「状态 2 可撤(撤初审)」、留痕 `action=7`;⑦ §12.1 `validation/run` 改同步并返回统计(F-51);⑧ **新增 §17 实现补充与偏差记录**(41 个 §4 未列接口按六类分组 + 12 条已确认偏离 D-1~D-12)。同步记录见《08-完成度盘点与问题清单》 |
| **V2.5** | **2026-09-30** | **两处"文档内部口径冲突"加注(不改口径,只把冲突摆到明处)**:① §2 枚举 `notifyBizType` 的 `5`/`6`(本文件写「5 导出完成 / 6 催报」)与 §13.5 消息中心示例、落库值 `collect_notify_record.biz_type`(「**5 催报 / 6 导出完成**」)不一致 —— **实现以 §13.5 为准**,需要客户确认以哪边为准(若以 §2 为准,要同时改两个常量与已落库数据);② §3.6 与 §9.5 的 `collect_file.source` 字段表写的是"入口来源"(1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传),而建表注释与实现(`CollectFile.SOURCE_*`)是"**谁传的**"(1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入)—— **实现以建表口径为准**(§10.6 要求代传可追溯,要回答的是"谁替他传的")。两处均已在原文加 ⚠️ 注。记录见《08-完成度盘点与问题清单》**F-30 / F-38 / F-39**。 |
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 |
| **V1.2** | **2026-09-28** | **错误码规范化**:`40007b` / `40007c` 带字母后缀,与 §1.3「`code` 为数字」的约定冲突(JSON 里会变成字符串,前端 `code === 0` 判断失效)。改为纯数字:**`40007b` → `40071`**(门禁限流)、**`40007c` → `40072`**(名单项已被绑定)。总码数不变,仍 **58 个**。 |
| V1.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义;**补齐追溯矩阵审计发现的 10 个缺口(新增 21 个接口,含 1 个登记遗漏)**;错误码由 20 个扩至 **50 个**;新增 §2 枚举全集、§3 通用数据结构、§15 错误码全集 |
| **V1.1** | **2026-09-28** | **客户要求移除「联系方式展示」**:删除 `GET /api/v1/mp/roster/contact`(原用于门禁失败页展示班主任脱敏手机号)。<br>**理由**:学生在班级群里本来就有班主任联系方式,系统内再放一份既不必要(用户已知怎么找人)、有维护成本(换人换号要同步)、又多一处隐私暴露面。<br>门禁失败页改为只保留两条**系统内动作**:重新填写 / 申请加入名单(FR-509)。纯文案提示("如有疑问请联系老师")保留,因**不涉及任何联系方式数据**。<br>**接口总数 122 → 121** |
| V1.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义
### 自校验记录
| 检查项 | 结果 |
|---|---|
| §4 各模块表行数合计 | **121** ✓ 与声称一致 |
| §4 内路径重复 | 无 ✓ |
| Markdown 表格列数一致性 | 0 行不符 ✓ |
| 文档引用的字段名 | 116 个唯一值,**全部存在于 DDL** ✓ |
| 错误码数量 | 58 个(新增 38 个标 ⭐)✓ |
| 文件规模 | 约 100 KB / 2570 行 |
---
## §17 实现补充与偏差记录
> **§4 的 121 个是设计口径**。本节回答两件事:① 代码里还有哪些 §4 没列的接口;
> ② 哪些地方**有意偏离**了本文档的原始写法、为什么、谁确认的。
>
> ⚠️ 17.1 的清单由工具生成:`python scripts/check-api-parity.py --extra`
> (当前 **41 个**,生成于 2026-09-30)。**手抄清单会过期,以命令输出为准**;
> 下表按"为什么不在 §4"分组,便于决定"补进 §4 还是留在本节"。
### 17.1 代码有、§4 清单没有的接口(41 个,按偏离原因分组)
**A · 材料访问通道(7 个)** —— 审核端与学生端各一条通道(审核端要看防伪纹,学生端只看自己的);上传是一步 multipart
| 接口 | 说明 |
|---|---|
| `POST /api/v1/mp/files/upload` | 实际的上传入口(一步 multipart,收上来即压缩+改名) |
| `GET /api/v1/mp/files/{id}/url` | 学生端取签名预览链接 |
| `GET /api/v1/mp/files/{id}/content` | 学生端直出内容 |
| `GET /api/v1/mp/files/submissions/{submissionId}` | 按提交单列出材料 |
| `GET /api/v1/audit/files/{id}/url` | 审核端签名链接(独立通道;支持 `?thumb=true` 取缩略图) |
| `GET /api/v1/audit/files/{id}/content` | 审核端直出内容 |
| `GET /api/v1/mp/teacher/files/{fileId}/preview` | 教师端(代传场景)预览学生材料 |
**B · 微信授权链路代替 `POST /mp/login`(4 个)**
| 接口 | 说明 |
|---|---|
| `GET /api/v1/mp/h5/authorize` | H5 微信网页授权跳转 |
| `GET /api/v1/mp/h5/callback` | 授权回调(token 放 URL fragment) |
| `GET /api/v1/mp/h5/stub-login` | **仅非生产**打桩登录(`stub-enabled` + profile 双重保护) |
| `GET /api/v1/mp/scene-context` | 匿名扫码落地页查询(scene 三段式,只返回最少信息 + 限流) |
**C · 签名 URL 下载(1 个)**
| 接口 | 说明 |
|---|---|
| `GET /api/v1/exports/{id}/file` | 导出包的流式下载。§12.6 只定义了"取下载信息";真正下文件走这条,**URL 有有效期、不能入库/外发** |
**D · 界面与接口必需、清单漏列(17 个)**
| 接口 | 说明 |
|---|---|
| `GET /api/v1/auth/captcha` | 图形验证码(连续失败 2 次后必填) |
| `GET /api/v1/auth/public-key` | 密码 RSA 加密传输用的公钥 |
| `GET /api/v1/material-catalog` | 材料目录(码/档位/格式/模板/体积范围) |
| `POST /api/v1/tasks/{id}/materials/apply-template` | 应用内置材料模板(幂等) |
| `POST /api/v1/tasks/{id}/save-as-template` | 保存为模板(§7.13 的缺口,F-62) |
| `POST /api/v1/audit/submissions/batch-approve` | 批量通过(§11.3 标题带过、清单未单列) |
| `GET /api/v1/mp/teacher/audit/reason-codes` | 教师端驳回原因字典(§11.7 有 PC 版) |
| `GET /api/v1/mp/teacher/submissions/{id}/proxy-context` | 代传前取上下文(哪个班、哪份草稿) |
| `GET /api/v1/qrcodes/{scene}` | 匿名出图 |
| `POST /api/v1/roster/{rid}/deactivate` | 名册项"标记不参与"(删除的可逆替代,§8.3/8.4) |
| `GET /api/v1/tenants/{id}` | 租户详情 |
| `PUT /api/v1/users/{id}/status` | 启用/停用(清单只有 DELETE 停用) |
| `DELETE /api/v1/dicts/items/{id}` | 删除自定义字典项(§13.3) |
| `POST /api/v1/dicts/{code}/items/{itemCode}/toggle` | 内置字典项停用/恢复(§13.3) |
| `GET /api/v1/exports/precheck` | 导出前置检查(只检查不创建;界面靠它禁用按钮) |
| `GET /api/v1/exports/preview` | 导出目录树预览(与真正打包共用同一份构建代码) |
| `GET /api/v1/cleanup/jobs/{id}/result` | 整理器报告(§13.2 界面轮询用) |
**E · 契约链条原本断在第一步(8 个,已实现)**
| 接口 | 说明 |
|---|---|
| `POST /api/v1/notify/templates/{code}/customize` | 把内置模板复制成可编辑的库行(F-52:内置模板没有 id,`PUT /{id}` 拿不出 id) |
| `DELETE /api/v1/notify/templates/{code}` | 恢复默认模板 |
| `GET /api/v1/notify/templates/variables` | 变量白名单(前端抄一份迟早不一致) |
| `POST /api/v1/open-apps` · `GET /api/v1/open-apps` | §14 的接入方管理(F-67:没有它,appSecret 只能改库产生) |
| `POST /api/v1/open-apps/{id}/reset-secret` | 重置密钥(显式动作,会让对方现有签名立即失效) |
| `POST /api/v1/open-apps/{id}/toggle` | 启停接入方 |
| `GET /api/v1/open-apps/push-logs` | 推送记录(人工重推的依据) |
**F · 运维与调试动作(4 个)** —— 定时任务在测试里"等不起",所以都有手工入口(权限走运营侧 `log:view:all`)
| 接口 | 说明 |
|---|---|
| `POST /api/v1/schedules/run` | 手工触发定时任务(`job` 取值 close/urge/timeout/raw/usage/retry/expire) |
| `POST /api/v1/exports/cleanup-run` | 手工触发导出包过期清理(可重复执行) |
| `POST /api/v1/cleanup/jobs/json` | 整理器:直接提交 JSON(不走上传) |
| `POST /api/v1/cleanup/jobs/parse-preview` | 整理器:解析预览(不落库) |
### 17.2 有意偏离契约的地方(均已确认,2026-09-30)
| # | 偏离点 | 契约原口径 | 实现口径 | 依据 |
|---|---|---|---|---|
| D-1 | `notifyBizType` 5/6 | §2.7 旧表「5 导出完成 / 6 催报」 | 「**5 催报 / 6 导出完成**」(与 §13.5、落库值一致) | F-30;按旧表实现催报去重会静默失效 |
| D-2 | `collect_file.source` | §3.6 旧表「入口来源」(聊天记录/相册/手机文件/代传) | 「**谁传的**」:1 本人 / 2 班主任代传 / 3 管理员代传 / 4 整理器(按建表注释) | F-38/F-39;§10.6 代传可追溯 |
| D-3 | 上传链路 | OSS 直传 + `upload-token` 凭证 | **服务端一步上传**;`upload-token` 定位为**预检+秒传+压缩建议**(非安全边界) | F-3/F-71/F-72;直传需配回调且把凭证交前端 |
| D-4 | `process` 语义 | 「命名+压缩+识别+校验总入口」 | 上传当场完成这些;`process` = **有原始件时的重新规范化** | F-3;默认 `keep-raw=false` |
| D-5 | `POST /validation/run` | `async=true` + jobId | **同步跑完**并返回 `checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs` | F-51;全契约没有查 jobId 的接口 |
| D-6 | 撤回动作码 | §11.4 旧文 `action=3` | **`action=7`(撤回)**;`3` 是批量通过;状态 2/3 均可撤(2 撤的是初审) | F-7;时间轴不能混淆 |
| D-7 | §14 路径前缀 | §14 详情写 `POST /open/v1/...`(无 `/api/v1`) | 统一 `/api/v1/open/v1/...`(与 §1 及其余接口一致,网关一条规则) | F-65 |
| D-8 | `biz_type` 落库类型 | §6 字段表按数字码定义 | `collect_notify_record.biz_type` 是 `varchar(32)`,存**数字码的字符串形式**("3");接口层仍是数字 | F-26;DDL 不可改 |
| D-9 | 任务删除 | 已发布任务不可删(`40001`) | **已归档(status=3)的任务可删**:管理员显式动作 + 操作留痕(`DELETE_TASK`,记行数/文件数);**子表级联物理删**(名册/材料/审核记录等 14 张表)、**任务行本身软删**(占住 `task_code` 不复用 —— 编码是文件目录名) | F-14;否则任务列表只增不减 |
| D-10 | 租户域名 | 未写 | 能力已实现(Host 定位 + token 授权),**默认关闭**;启用四步见《交接摘要》§一·二十六 | F-10;客户决定先不做 |
| D-11 | 通知降级链 | 「订阅消息→公众号模板→短信→站内信」四级 | 一期**两级**(订阅消息→站内信);公众号模板需已认证服务号、短信需采购通道(商务动作),不假装可发 | F-28 |
| D-12 | OCR(§9.9) | 已定义 | **一期缓做**(等供应商选型,见《01》D1,需评估数据出境合规) | F-3;121 个接口里唯一未实现 |