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