Files
xiaotutongxue/docs/03-接口设计.md
T
lovife 31e7483464 docs: 同步契约 §6.5~6.9 / §13.4、问题清单 F-112~F-124、交接摘要 §一·三十九
1) docs/03-接口设计.md:
   - §6.5 补 expireBefore **含当天**的语义与"格式不对返回 40001,不静默忽略筛选";补
     "列表 contactPhone 恒为 null"与"packageName 与内嵌 usage{} 不实现(200 行内嵌用量会变成
     N+1 统计)"。
   - §6.6 的 tenantType 由 3 值改回真实的 **4 值**(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)、
     packageId 由 ✅ 必填改为可选(DTO 里本来就是可选),并补副作用(发放 6 个内置角色 + 建管理员
     + 写 CREATE_TENANT 日志)。
   - §6.7 的响应示例原来写着 ocrCount=3105 / smsCount=420,**与实现恒 0 矛盾** —— 改成 0 并补齐
     契约没写的 notes 与 alerts.used/limit,再加三条口径:实时值 vs 周期、恒 0 的原因(编数字会让
     运营拿假数据去谈续费)、**只算这一个租户**(含"库里只有一所学校时看不出"这一点)。
   - **新增 §6.8 / §6.9**(PUT /tenants/{id} 与 GET /tenants/{id}):PUT 此前只在 §4.2 的总览表里
     出现过一次、全文**没有详情节**;GET 更是只登记在 §17.1 的"清单漏列"组里。§6.8 含字段表、
     "不传的字段不修改"、"tenantCode 不可修改"的理由,以及留痕对照表(status→0 是 DISABLE_TENANT,
     不是 UPDATE_TENANT)+ "这些日志行的 tenant_id 是 0,要用 target_type/target_id 查"的警告。
   - §13.4 的 Query 补 targetType(并说明为什么 targetType+targetId 才是最重要的查询方式:
     关键字全文匹配会把"改了它"和"顺带提到它"混在一起,而合规结论不能建立在这种匹配上);
     响应补 request_method / request_uri / extraJson;动作表由 9 行扩到**全集 27 个**并按来源分组
     (任务 / 名册 / 导出 / 角色 / 租户);写明 request_param / duration_ms 刻意不采集;
     并记下"一期只做查询、不做日志导出"这条**此前只写在 LogController 类注释里**的口径
     (导出等于把"谁在什么时候看了谁的身份证"这份名单落到本地磁盘,而导出行为本身也要留痕)。
   - 登录日志响应补 tenantId,并写明 userType 的权威枚举与 location 恒为 null。

2) docs/08-完成度盘点与问题清单.md:新增 **F-112~F-124** —— 用量越界(含"为什么本地完全看不出来")、
   PUT 明文写入加密列、两个枚举能建不能改、租户零留痕、expireBefore 差一天、AUTO_CLOSE 字面量、
   模块下拉里有死项、LogController 描述与事实相反、request_param/duration_ms 有列无写入点、
   schema-postgresql.sql 缺三张表、**"平台专属权限码 + 查带 tenant_id 的表"这个风险面**、
   登录日志缺租户列、userType 注释错位。F-112 标注为本轮最严重的一条。

3) docs/10-用户手册与客服话术.md:「三种角色,三个入口」→ **四种角色**(补平台运营,并写明它是
   唯一跨单位的角色、用"组织与账号/收集管理"这类单位内页面之前要先选"当前学校");
   §4 速查表的菜单名与路径同步(平台运维 → 日志,含两个页签;新增租户管理一行);
   附录的排查话术补 traceId("日志详情里能拿到,那是后端排查时最有用的一个线索")。

4) docs/交接摘要.md:新增 **§一·三十九**,九节 —— 一、口径变更(旧注释原文 + 推翻理由);
   二、用量越界那个 bug 的完整来龙去脉(权限码 → ignoreTable → 实测数据分布 → 修法与夹具);
   三、另外四条后端缺陷表;四、日志详情为什么不新增接口(轻字段 vs 全库 0 行有值的重字段);
   五、AUTO_CLOSE 与检查 ⑩ 的关系(**"这一族检查的价值取决于被检查的那一侧有没有被规范化"**);
   六、顺带修掉的五处;七、前端刻意用分段按钮而不是 NTabs 的两条原因;八、验证表 +
   "改了后端必须重新打包重启、判据是看返回体里有没有新字段"的提醒;九、约定同步。
   另把 E2E 断言总数 1595 → **1617**(四处)。

5) 根 AGENTS.md:接线检查说明补第 ⑩ 项,并写明做这一项时才发现 AUTO_CLOSE 是字符串字面量写的
   —— "提成常量"是让这个检查真正生效的前提。

验证:python scripts/verify-docs.py **22/22**;python scripts/check-wiring.py 未接线 **0 条**
(⑦ 11 个权限码 / ⑧ 0 处漏 import / ⑨ 3 个 SVG 全合法 / ⑩ 27 个动作码两端逐字一致);
python scripts/check-api-parity.py 契约 **119** / 已实现 118 / 缺口 1 —— **没变**,符合本轮
"不新增后端接口"的决定。
2026-10-13 14:04:59 +08:00

2989 lines
138 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 | 接口总览(**119 个**) |
| §5 | 认证与账号(13) |
| §6 | 组织与租户(15) |
| §7 | 任务与配置(25) |
| §8 | 名册(7) |
| §9 | 填报端 · 小程序(16) |
| §10 | 班主任移动端(10) |
| §11 | 审核(9) |
| §12 | 校验 / 导出 / 通知(12) |
| §13 | **本次补齐的缺口接口(16)** |
| §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` 的名称陷阱**:按机构模板,**「职业技能等级个人申报表」的规范文件名是「工作证明」**。这是机构既有习惯,不要"修正"。
> ⚠️ **一期实际只有 4 类材料**:`PHOTO1` / `IDCARD` / `SCHOOL_ROLL` / `WORK_PROOF`。
> `XJW_BA` 与 `OTHER_PROOF` 是需求收敛(2026-10)**之前**留下的编码 ——
> **历史数据读得到、但"新增材料"选不到**(`GET /api/v1/material-catalog` 的可选项不含它们),
> 内置材料模板也只剩 `TPL_SCHOOL_ROLL` 一个(原 `TPL_SOCIAL` / `TPL_ZHUANXIANG` 已删)。
> 这样处理是为了**不让历史任务里的材料项悬空**:删编码会让已配的任务读不出材料名。
### 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` / `STUDENT` |
| `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` **导出完成** |
| `exportStatus` | `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` |
| `DictItemVO` | `id` `dictCode` `dictName` `itemCode` `itemName` `sortNo` `parentItemCode` `status` `builtin` |
| `MessageVO` | `id` `bizType` `title` `content` `read` `linkType` `linkId` `createTime` |
---
## §4 接口总览
> 合计 **119 个**:PRD §8 已定义 96 个 + 追溯矩阵补齐 **16 个** + **「角色与授权」8 个**,减去客户要求移除的联系方式接口 1 个(标 ⭐)。
> 补齐项见 §4.9 索引,角色与授权见 §4.12 与 §5.10。
> ⚠️ **需求收敛(2026-10)的减法**:原「客户确认新增 5 个」(加入申请,确认函 B1)**整条通道取消**,
> 原「补齐 21 个」中的「历史整理器 5 个」**随模块删除** → 21 − 5 = 16,故 96 + 16 + 8 − 1 = **119**。
> 「权限」列:`公开` / `学生` / `教师` / `管理` / `运营`(平台方)。
> 「修补」列指该接口是补齐追溯矩阵审计发现的缺口。
| 分类 | 接口数 |
|---|:--:|
| 认证与账号 | 13 |
| 组织与租户 | 15 |
| 任务与配置 | 25 |
| 名册 | 7 |
| 填报端 · 小程序 | 16 |
| 班主任移动端 | 10 |
| 审核 | 9 |
| 校验 / 导出 / 通知 | 12 |
| 开放接口 | 4 |
| ⭐ 角色与授权 | 8 |
| **合计** | **119** |
### 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 任务与配置(25)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| 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` | 应用模板 | 管理 |
### 4.4 名册(7)
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| 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` | 一键催报未提交者 | 管理/教师 |
### 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 ⭐ 本次新增接口索引(16)
> 本表是**索引**,不是额外接口——其中 5 个已在上方 §4.3 / §4.7 列出,**不计入总数**。
> 表内 13 行覆盖 **16 个接口**(含 GET/PUT、GET/POST 合并行)。
> ⚠️ 需求收敛(2026-10)后,原表里的**历史整理器 5 个**随模块删除,故 21 − 5 = **16**。
| | 路径 | 说明 | 详情节 |
|---|---|---|---|
| ⭐ | `/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 |
| ⭐ | `/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 |
**新增 16 个的分布**
| 归属 | 数量 | 明细 |
|---|:--:|---|
| 报考范围 | 2 | plan-scope GET / PUT |
| 任务状态 | 2 | pause / resume |
| 审核 | 1 | revoke |
| 文件处理 | 1 | process(登记遗漏) |
| 字典 | 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 ⭐ 加入申请(已撤销 · 0 个)
> ⚠️ **本节原为「客户确认后新增 5 个」**(确认函 **B1**:申请加入名单 → 管理员审批)。
> **需求收敛(2026-10)后整条通道取消**:模块 `module/join`、5 个接口、`join:audit` 权限码、
> `collect_join_request` 表、学生端「申请加入名单」页与 5 个专用错误码
> (`40037` / `40038` / `40039` / `40040` / `40042`)**已全部删除**。
>
> **门禁失败后的口径**:只保留「**重新填写**」一个系统内动作 + 一句纯文案提示
> "如确认已报名,请联系老师把你加入名单"。名单外的人由老师在任务里**手工加人**(`FR-508` 追加导入),
> **不展示任何联系方式**(铁律:联系方式不进系统)。
>
> ⚠️ **治理提示**:B1 是确认函里**唯一的功能增量**,本次属**需求回退**,建议补一张变更确认单。
### 4.12 ⭐ 角色与授权(8)
> 来源:**FR-205「角色自定义与数据权限」**。内置角色 **4 个**(平台运营 / 学校管理员 / 班主任 / 学生,
> 权限由代码 `BuiltinRole` 决定、界面只读),租户可另建**自定义角色**并配置权限码与可见菜单。
> 详述见 **§5.10**。
| | 路径 | 说明 | 权限 |
|---|---|---|---|
| ⭐ GET | `/roles` | 角色列表(内置 + 自定义) | 管理 |
| ⭐ GET | `/permissions` | 权限码目录(按分组) | 管理(`role:manage`) |
| ⭐ POST | `/roles` | 新增自定义角色 | 管理(`role:manage`) |
| ⭐ PUT | `/roles/{id}` | 修改自定义角色(名称 / 用户类型 / 备注) | 管理(`role:manage`) |
| ⭐ DELETE | `/roles/{id}` | 删除自定义角色(软删) | 管理(`role:manage`) |
| ⭐ PUT | `/roles/{id}/permissions` | 设置权限码(覆盖式) | 管理(`role:manage`) |
| ⭐ PUT | `/roles/{id}/menus` | 设置可见菜单(覆盖式) | 管理(`role:manage`) |
| ⭐ PUT | `/roles/{id}/status` | 启用 / 停用角色 | 管理(`role:manage`) |
---
## §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)。
>
> **原设计**:门禁失败页展示班主任/管理员的脱敏手机号,学生可一键拨打。
>
> **移除理由**:学生在班级群里**本来就有班主任的联系方式**,系统内再放一份:
> ① 不必要——用户已经知道怎么找人;
> ② 有维护成本——班主任换人、号码变更都要同步;
> ③ 多一处隐私暴露面——即使是脱敏号,也意味着系统里要多存一份联系方式数据。
>
> **替代**:门禁失败页只保留「**重新填写**」一个**系统内动作**(原「申请加入名单」通道已于 2026-10 需求收敛时取消)。
> 纯文案提示(如"如确认已报名,请联系老师把你加入名单")保留,因为**不涉及任何联系方式数据**;
> 名单外的人由老师在任务里手工加人(`FR-508` 追加导入)。
### 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`,用于门禁二次校验 |
---
### 5.10 角色与授权(§4.12 的 8 个接口)
> 对应 **FR-205**。内置角色 **4 个**(`PLATFORM_ADMIN` / `SCHOOL_ADMIN` / `HEAD_TEACHER` / `STUDENT`),
> 权限由代码 `BuiltinRole` 决定、界面**只读**(能看、能当模板,改不了);
> 租户可另建**自定义角色**,于是"角色 → 权限码"与"角色 → 可见菜单"两件事可以由租户自己配。
> 改授权**立即生效**(服务端写授权后清缓存),不需要重新登录。
>
> 权限码本身**仍然定义在代码里**(`PermissionDef`,**28 个**),没有 `sys_permission` 定义表 ——
> 它是前后端契约:后端用它校验、前端用它控制按钮显隐,存 DB 就会漂移。
>
> ⚠️ **需求收敛(2026-10)**:本条不再是"二期功能",它就是一期能力;同时
> **数据范围(本人/本班/本院系/全租户)只展示、不提供修改入口** ——
> 它目前由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,做成可配要先把判据收口(**本次不做**)。
**五条口径(实现与文档同源,改一处必须改另一处)**
| # | 口径 | 为什么 |
|:--:|---|---|
| 1 | **内置角色只读** | `roleType=1` 的角色可以看权限矩阵,但改 / 删 / 授权 / 停用一律 `40001`。一次误操作就能把管理员锁在系统外,而这类错误不可逆 |
| 2 | **内置角色的权限以代码为准** | 列表里内置角色的 `permCodes` / `menus` 由代码返回,**不读库**;库里的行只是"看得见、可审计"(每日定时任务把两者对齐,见 §17.2 D-13) |
| 3 | **平台级能力与入口不给学校** | 学校租户授予 `tenant:manage` / `tenant:view` / `log:viewAll` → `40301`;配置平台专用菜单 `tenant` → `40301`;建"平台运营"类角色(`userType=1`)→ `40301`;发 `PLATFORM_ADMIN` 给账号 → `40301` |
| 4 | **防自锁** | 不允许去掉"自己进入本页所依赖的最后一条 `role:manage` 权限 / `role` 菜单",也不允许停用或删除那个角色;改账号角色时同理保护 `user:manage` |
| 5 | **改完立即生效** | 每次写授权都清该角色的缓存,所以**不需要重新登录**;停用角色立刻不再给权限(不是等 token 过期)。⚠️ 改**账号的角色归属**仍是"下次登录生效"(角色码在 token 里) |
**`GET /api/v1/roles`** 权限:`role:manage` 或 `user:view` / `user:manage`(账号页的角色下拉要用它)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | long | 角色 ID |
| `roleCode` | string | 角色码(建后不可改) |
| `roleName` | string | 角色名 |
| `roleType` | int | `1` 内置(只读)/ `2` 自定义 |
| `userType` | int | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生(账号的 `userType` 由它推导) |
| `dataScope` | int | `1` 全平台 / `2` 本租户 / `3` 本组织及下级 / `4` 仅本人(**本期只展示**,见下) |
| `status` | int | `0` 停用 / `1` 正常 |
| `userCount` | int | 有多少账号在用(> 0 时不可删除) |
| `editable` | bool | 能否编辑 / 删除 / 停用(内置恒 `false`) |
| `assignable` | bool | 能否发给账号(内置的「平台运营」在学校租户里 `false`:**看得见、发不出去**) |
| `permCodes` | string[] | 权限码(内置取代码,自定义取库) |
| `menus` | string[] | 可见菜单键(与前端 `XtMenu.key` 一致) |
> ⚠️ **`dataScope` 本期只展示、不提供修改入口**:数据范围(本人 / 本班 / 本院系 / 全租户)目前由
> "拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,散在审核 / 催报 / 代传 / 看板各处;
> 做成可配要先把那些判据收口(单独一批)。让界面出现一个"改了不生效"的开关,比没有它更糟。
**`GET /api/v1/permissions`** 权限:`role:manage`
返回按分组排列的 **28** 个权限码:`[{ group, items: [{ code, label, platform, platformOnly }] }]`。
`platform` 为 `PC` / `MP` / `BOTH`;`platformOnly=true` 的码学校租户不可授予(界面禁用勾选,服务端 `40301`)。
**`POST /api/v1/roles`** 权限:`role:manage`
| 字段 | 必填 | 说明 |
|---|:--:|---|
| `roleCode` | ✅ | 大写字母 / 数字 / 下划线,以字母开头,租户内唯一,**不得占用内置角色码** |
| `roleName` | ✅ | 显示名(可改) |
| `userType` | ✅ | 该角色对应的用户类型(学校租户不能选 `1`) |
| `remark` | — | 备注 |
| `permCodes` | ✅ | 至少一项(服务端校验存在性与平台级白名单) |
| `menus` | ✅ | 至少一个(服务端校验是否为现存菜单键、是否平台专用) |
返回新角色 `id`。建后**不能改 `roleCode`**(它在 JWT 与操作日志里,改了历史留痕就对不上)。
**`PUT /api/v1/roles/{id}`** 权限:`role:manage` 只改 `roleName` / `userType` / `remark`。
⚠️ 角色还有账号在用(`userCount > 0`)时改 `userType` → `40001`:那会让账号的 `user_type` 与角色不一致,
而 `user_type` 决定"他算学生还是老师"。
**`DELETE /api/v1/roles/{id}`** 权限:`role:manage` 软删(含授权与菜单行)。
还有账号在用 → **`40045`**;命中防自锁 → `40001`。
**`PUT /api/v1/roles/{id}/permissions`** / **`.../menus`** 权限:`role:manage`
请求体 `{ permCodes: [] }` / `{ menus: [] }`,**覆盖式**;返回 `void`。写后立即失效该角色的授权缓存。
**`PUT /api/v1/roles/{id}/status?status=0|1`** 权限:`role:manage`
停用 = 该角色立刻不再给权限(**不必等 token 过期**);内置角色不可停用。
**错误码**
| 码 | 场景 |
|---|---|
| `40001` | 角色码不合法 / 重复 / 占用内置码;权限码或菜单键不存在;改内置角色;`userType` 与在用的账号冲突;命中防自锁 |
| `40301` | 无 `role:manage`;授予平台级权限码;配置平台专用菜单;建平台运营类角色;跨租户(平台未选定学校) |
| `40045` | 角色还有账号在用,不可删除 |
---
## §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`、分页
`expireBefore`(`yyyy-MM-dd`)**含当天** —— `expire_time` 存的是当天 `23:59:59`,
所以比较要补到当天末尾,否则"今天到期"的租户查不出来。格式不对返回 `40001`
(**不静默忽略筛选**:这个值只由界面按当前日期算出来,非法值只可能来自手搓请求)。
**响应**:`{ list: TenantVO[], total }`
⚠️ 列表里的 `contactPhone` **恒为 `null`** —— 联系电话以密文入库,列表接口不能成为
批量拉取联系方式的通道。脱敏值只有 6.9 给。
`packageName` 与内嵌 `usage{}`(§3.12 的简表里画了)**不实现**:200 行内嵌用量会变成 N+1 统计。
### 6.6 `POST /api/v1/tenants`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantName` | string | ✅ | — |
| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd`;**入库时小写化** |
| `tenantType` | int | ✅ | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
| `contactName` / `contactPhone` | string | ✅ | 联系人;电话**加密入库** |
| `packageId` | int64 | | 套餐(可选,无外键) |
| `expireTime` | string | ✅ | 到期时间;只给日期时补成当天 `23:59:59` |
| `adminUsername` / `adminRealName` / `adminPhone` | string | ✅ | **同时创建首个管理员** |
**响应**:`{ "id": 1002, "adminInitialPassword": "Kp9#vR4t", "miniProgramScene": "t1002" }`
**副作用**:切到新租户的作用域发放 6 个内置角色、建管理员并绑定 `SCHOOL_ADMIN`;
写一条操作日志(动作 `CREATE_TENANT`,`target_type=TENANT`)。
### 6.7 `GET /api/v1/tenants/{id}/usage`
**Query**:`period`(`2026-09` 或 `2026Q3`;不传 = 当月)
```json
{
"code": 0,
"data": {
"tenantId": 1, "tenantName": "示范职业学校", "period": "2026-09",
"studentCount": 1240, "storageMb": 8420, "ocrCount": 0, "smsCount": 0,
"quota": { "studentLimit": 2000, "storageLimitMb": 20480, "ocrLimit": 10000, "smsLimit": 2000 },
"alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%", "used": 8420, "limit": 20480 } ],
"notes": [
"ocrCount 恒为 0:M4 的识别通道还没接(等 OCR 选型),不是「没用量」",
"smsCount 恒为 0:短信通道还没有商务结论(降级链只实现到站内信)"
]
}
}
```
⚠️ 三条口径(都不是"实现细节",是**看数的人必须知道的**):
- `studentCount` / `storageMb` 是**实时**值(它们决定"还能不能再收人"),
`period` **只影响计费项**(`ocrCount` / `smsCount`)—— 界面必须写明,
否则会以为"2026Q3 的学生数"是历史值;
- `ocrCount` / `smsCount` 一期**恒为 0 并带 `notes` 说明**:
两条通道都还没接,编一个数字会让运营拿着假数据去谈续费;
- 统计**只算这一个租户**。这条不是废话:调用方一定是平台运营(`tenant:view` 是平台专属码),
而平台身份的 `tenantId` 是 `0`,租户拦截器对它**整张表关掉过滤** ——
实现里必须显式切到目标租户的上下文再统计,否则返回的是**全平台**的数
(库里只有一所学校有数据时,它与正确值完全相等,看不出来)。
### 6.8 `PUT /api/v1/tenants/{id}`
**权限**:运营
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantName` | string | | 不能是空白串 |
| `tenantType` | int | | `1` 学校 / `2` 认定机构 / `3` 报名点 / `4` 企业 |
| `contactName` | string | | — |
| `contactPhone` | string | | **留空 = 不修改**;非空才写,且写入的是**密文** |
| `packageId` | int64 | | — |
| `expireTime` | string | | `2027-12-31` 或 `2027-12-31T23:59:59` |
| `status` | int | | `0` 停用 / `1` 正常 / `2` 试用 |
**不传的字段不修改**(不是"清空")。**`tenantCode` 不可修改**:它出现在小程序扫码
落地页、域名解析与对接文档里,改了会让已经发出去的东西失效,而我们通知不到客户那边。
**留痕**(本接口必须留痕,因为它是这套系统里影响面最大的一类动作):
| 变更 | 动作码 |
|---|---|
| `status` → `0` | `DISABLE_TENANT`(该校所有学生与老师**立刻登不进来**,单独一个码) |
| `status` → `1` | `ENABLE_TENANT` |
| `status` → `2` | `UPDATE_TENANT` |
| 其它字段有变化 | `UPDATE_TENANT`(`before` / `after` 是 `k=v; k=v` 摘要) |
⚠️ 这些日志行的 `tenant_id` 是 **`0`**(写日志用的是**操作者**的上下文,平台运营的租户号是 0),
不是被操作的那所学校。要查"这所学校被谁停过",用 `target_type=TENANT` + `target_id=<租户 id>`,
**不能用 `tenantId` 筛**。
**响应**:`TenantVO`(同 6.9)
### 6.9 `GET /api/v1/tenants/{id}`
**权限**:运营 **响应**:`TenantVO`,其中 `contactPhone` 是**脱敏值**(形如 `139****1111`)
这是**唯一**能拿到联系电话的接口。为什么不给明文:这是全系统唯一一处"加密存储的联系方式",
若在详情里可读且不留痕,就与「看证件号明文必须留痕」(`VIEW_IDCARD_FULL`)这条既有规矩
自相矛盾;而脱敏值足够确认"档里有号",要改直接填新号(6.8 留空 = 不修改)。
⚠️ 库里存在**历史明文行**(早期版本的写入路径没有加密)。服务端对解密失败
按明文处理并记一条 WARN —— 否则一所学校的详情页会**永远打不开**,
而那种故障看起来像"系统坏了",不像"数据里有一行是旧的"。
---
## §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` | 一寸照片 + 身份证 + 学籍表 + 工作证明 |
> ⚠️ **需求收敛(2026-10)后模板只剩这一个**:原 `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 次/分钟/任务。
---
> ⚠️ **本节已撤销(需求收敛 2026-10)**:原「名单加入申请」通道(`FR-509`,确认函 **B1**)
> 连同 5 个接口(`POST/GET /mp/roster/join-request`、`GET /roster/join-requests`、
> `POST /roster/join-requests/{id}/approve|reject`)、`join:audit` 权限码、`collect_join_request` 表
> 与错误码 `40037` / `40038` / `40039` / `40040` / `40042` **一并删除**。
>
> **现在的口径**:门禁失败页只给「**重新填写**」+ 一句"如确认已报名,请联系老师把你加入名单";
> 名单外的人由老师在任务里**手工加人**(§8.1 追加导入,`FR-508`)。**系统内不展示任何联系方式。**
>
> **治理提示**:B1 是客户确认函里**唯一的功能增量**,本次属**需求回退**,建议补一张变更确认单。
## §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 的四层递进**缓做**(等 OCR 供应商选型,见 §17.2 D-12 与《01》D1)——**不再作为二期功能设计**。
> 下面的响应结构保留作为「规范化产物」的字段参考(上传接口的产物与之同构)。
**说明**:对**有原始件**的文件重新执行规范化(压缩 + 改名 + 校验)。
**请求**
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `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)**。
> 这是 119 个契约接口里**唯一**没有实现的一个;表单预填与校验位校验在学生端走人工填写。
> 选型要同时评估**数据出境合规**。选型定了之后本节按原设计实现,以下为设计参考。
**说明**:身份证 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
}
}
```
**社会收集**(本次不做,传 `collectMode=2` 直接 `40044`):`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 详情响应**(不新增接口) | ✅ |
| I3 字典管理 | **§13.3** | ✅ |
| I4 / I5 日志 | **§13.4** | ✅ |
| I6 消息中心 | **§13.5** | ✅ |
| I7 订阅额度登记 | **§13.6** | ✅ |
### 13.2 历史数据整理器(已取消 · 0 个)
> ⚠️ **本节已撤销(需求收敛 2026-10)**:历史整理器整块能力(`module/cleanup`)连同
> 5 个接口(`POST/GET /cleanup/jobs`、`GET /cleanup/jobs/{id}`、`.../report`、`.../import`)、
> `cleanup:run` 权限码、`collect_cleanup_job` 表、PC 端「历史整理器」菜单与「整理器」原型屏
> **一并删除**,不再设计。
>
> 保留这一节标题只是为了让旧的交叉引用(`§13.2`)仍能落到一个说清"为什么没有了"的地方 ——
> 节号不重排,`§13.3` 起的编号与代码注释里的引用都不受影响。
>
> ⚠️ **别误伤**:`POST /api/v1/exports/cleanup-run` 是**导出包的过期清理**,属导出中心,**仍在**。
### 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`、`targetType`、`targetId`、`from` / `to`、分页
> `targetType` + `targetId` 是**最重要的查询方式**:靠关键字全文匹配会把
> 「改了它」和「顺带提到它」混在一起,而合规结论不能建立在这种匹配上。
>
> `from` / `to` 只给 10 位日期时,`to` 会补成当天 `23:59:59` —— 不补的话
> 「查 9 月 30 日」查不到当天的记录。
**范围**(权限二选一,`@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",
"requestMethod": "PUT", "requestUri": "/api/v1/roster/9012",
"extraJson": "{\"orgId\":43,\"taskId\":487}",
"bizCode": 0, "traceId": "3f9c…", "createTime": "2026-09-28T16:20:00+08:00" }
],
"total": 1
}
}
```
**动作码全集**(`action`,权威来源是后端 `OperationLogService` 的 `ACTION_*` 常量,
PC 端 `apis/xt/log.ts` 的 `OPERATION_ACTIONS` 是它的文案表 —— 两端由
`scripts/check-wiring.py` 第 ⑩ 项**逐字核对**,缺一个界面上就会原样回显英文码)
| `action` | 场景 |
|---|---|
| `UPDATE_IDCARD` | **改名册证件号**(留痕强制) |
| `UPDATE_ROSTER_NAME` | 改名 |
| `DELETE_ROSTER` | 删名册项 |
| `PUBLISH_TASK` | 发布任务 |
| `PAUSE_TASK` / `RESUME_TASK` | 暂停 / 恢复("恢复"意味着曾经暂停过,必须与"发布"分开记) |
| `CLOSE_TASK` / `AUTO_CLOSE` | 手动截止 / **定时任务自动截止**(出争议时第一个要看的就是这个区别) |
| `STATUS_CHANGE` | 任务状态变更(兜底名:调用方说不出是哪个动作时才用) |
| `ARCHIVE_TASK` / `DELETE_TASK` | 归档 / 删除已归档任务 |
| `REVOKE_AUDIT` | **撤回审核** |
| `EXPORT_CREATE` / `EXPORT_DOWNLOAD` | 创建导出 / 下载导出文件 |
| `PROXY_UPLOAD` | 代传材料 |
| `VIEW_IDCARD_FULL` | **查看完整身份证号**(单独留痕) |
| `CONFIG_COMPRESS` | 改压缩配置 |
| `CREATE_ROLE` / `UPDATE_ROLE` / `DELETE_ROLE` | 角色的增删改 |
| `GRANT_ROLE` / `UPDATE_ROLE_MENU` / `TOGGLE_ROLE` | **改授权** / 改可见菜单 / 停用启用角色 |
| `CREATE_TENANT` / `UPDATE_TENANT` | 开通租户 / 改租户信息·套餐 |
| `DISABLE_TENANT` / `ENABLE_TENANT` | **停用租户**(该校所有人立刻登不进来)/ 恢复启用 |
**模块 `module`**:`TASK` / `ROSTER` / `FILE` / `ROLE` / `TENANT`。
(`AUDIT` / `EXPORT` / `USER` / `JOIN` 四个常量**当前没有写入点** —— 界面上的模块下拉
刻意不列它们:选中只会得到一张空表,让人以为"这段时间没有审核动作"。)
**脱敏**:`beforeVal` / `afterVal` 中的证件号**一律脱敏存储**,即使日志泄露也不暴露明文。
服务层还会再兜底脱敏一次并截断到 512 字符(两层都做,是因为"忘记脱敏"的后果不可逆)。
**两列刻意不采集**:`request_param`(请求参数)与 `duration_ms`(耗时)**没有任何写入点**
(全库 0 行有值)。要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,
而合规留痕本来就不需要它们 —— 界面上不展示、也不做筛选。
> **一期只做查询,不做日志导出**(`backend/.../module/log/controller/LogController.java` 的类注释):
> 导出等于把「谁在什么时候看了谁的身份证」这份名单落到本地磁盘上,
> 而"有人导出过这份日志"本身也要留痕 —— 一个追着自己尾巴的循环。
> 这条口径**只写在代码注释里**过一段时间,所以在这里也记一笔。
#### `GET /api/v1/logs/logins`
**Query**:`tenantId`、`userId`、`userType`、`result`、`from` / `to`、分页
**范围**:与操作日志同一口径(`log:viewAll` 可跨租户,学校管理员恒为本租户)。
**响应**:`{ list: [{ id, tenantId, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }`
⚠️ 三条口径:
- `tenantId`(`0` = 平台)**必须有**:平台运营看到的是跨单位的登录日志,
没有这一列就分不清一行是哪所学校的;
- `userType` 是 **`1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生**
(权威来源是 `BuiltinRole.UserType` 与 `UserService.USER_TYPE_NAMES`,库列注释也一致)
—— **不要**按某些模板里那套「1 教师 / 2 学生」去映射;
- `location`(IP 归属地)**一期恒为 `null`**:没接 IP 库,留 null 而不是编一个。
界面**不要显示这一列**(否则整列空白,看起来像功能坏了)。
| `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` | 管理端创建导出时 | 导出完成 |
**额度不足的降级链**
```
微信订阅消息(免费,额度受限)
↓ 额度耗尽
公众号模板消息(需关注公众号)
↓ 未关注
短信(按条计费,走 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 个码)基础上**新增 34 个**(标 ⭐),本节共 **54 个**(含 `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` | 字典项有下级,不可停用 | 先处理下级 |
| ⭐ `40041` | **名册中该证件号已被占用** | 不覆盖,提示管理员核查 |
| ⭐ `40043` | **文件归属存疑**(文件名指向名册中的另一个人,或归属无法确证) | **阻断提交**,提示"这份材料可能不是你的",引导重新上传(**无"仍然继续"选项**) |
| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) |
| ⭐ `40045` | **角色还有账号在用,不可删除** | 先给这些账号换一个角色(界面会说明还有几个账号) |
| `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.7** | **2026-10** | **需求收敛:删角色 / 删两模块 / 取消二期**。① 内置角色 **6 → 4**(删 `DEPUTY_TEACHER` 副班主任、`AUDITOR` 审核员;挂这两个角色的存量账号由 **V19 迁移**直接删除);② 删「**历史整理器**」(5 个接口 + `cleanup:run` 权限码 + `collect_cleanup_job` 表 + §13.2 整节)与「**加入申请**」(5 个接口 + `join:audit` 权限码 + `collect_join_request` 表 + §8.7 整节 + 错误码 `40037`/`40038`/`40039`/`40040`/`40042`);③ `collect_task_org.deputy_user_ids`(协办人)**整列删除**,审核范围由三级回退收窄为**两级**(`head_user_id` → `sys_org.leader_user_id`);④ **不再有任何二期功能设计** —— 社会收集 / 社会评价 / 专项职业能力 / 数据范围可配 / 角色变更即时失效 / 平台角色页一律**不做**,OCR 与材料识别改为「**缓做,等供应商选型**」;⑤ 内置材料模板 **3 → 1**(仅 `TPL_SCHOOL_ROLL`),`XJW_BA` / `OTHER_PROOF` **读得到、选不到**。**数字**:契约 **129 → 119**、错误码 **59 → 54**、权限码 **30 → 28**、PC 菜单 **12 → 10**、小程序页面 **10 → 9**、原型 **32 → 30 屏**。⚠️ **加入申请(确认函 B1)是客户唯一确认的功能增量,本次属需求回退,建议补变更确认单**。详见《交接摘要》§一·三十六 |
| **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 各模块表行数合计 | **119** ✓ 与声称一致 |
| §4 内路径重复 | 无 ✓ |
| Markdown 表格列数一致性 | 0 行不符 ✓ |
| 文档引用的字段名 | 116 个唯一值,**全部存在于 DDL** ✓ |
| 错误码数量 | 54 个(新增 34 个标 ⭐)✓ |
| 文件规模 | 约 89 KB / 2870 行 |
---
## §17 实现补充与偏差记录
> **§4 的 119 个是设计口径**。本节回答两件事:① 代码里还有哪些 §4 没列的接口;
> ② 哪些地方**有意偏离**了本文档的原始写法、为什么、谁确认的。
>
> ⚠️ 17.1 的清单由工具生成:`python scripts/check-api-parity.py --extra`
> (当前 **38 个**,2026-10 复核)。**手抄清单会过期,以命令输出为准**;
> 下表按"为什么不在 §4"分组,便于决定"补进 §4 还是留在本节"。
### 17.1 代码有、§4 清单没有的接口(38 个,按偏离原因分组)
**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 · 界面与接口必需、清单漏列(16 个)**
| 接口 | 说明 |
|---|---|
| `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` | 导出目录树预览(与真正打包共用同一份构建代码) |
**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 · 运维与调试动作(2 个)** —— 定时任务在测试里"等不起",所以都有手工入口(权限走运营侧 `log:view:all`)
| 接口 | 说明 |
|---|---|
| `POST /api/v1/schedules/run` | 手工触发定时任务(`job` 取值 close/urge/timeout/raw/usage/retry/expire) |
| `POST /api/v1/exports/cleanup-run` | 手工触发导出包过期清理(可重复执行) |
### 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;119 个接口里唯一未实现 |