# 接口设计说明书 > 版本: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 ` | | 微信小程序(学生 / 班主任) | `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` 的名称陷阱**:按机构模板,**「职业技能等级个人申报表」的规范文件名是「工作证明」**。这是机构既有习惯,不要"修正"。 > ⚠️ **一期实际只有 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`(原用于门禁失败页展示班主任脱敏手机号)。
**理由**:学生在班级群里本来就有班主任联系方式,系统内再放一份既不必要(用户已知怎么找人)、有维护成本(换人换号要同步)、又多一处隐私暴露面。
门禁失败页改为只保留两条**系统内动作**:重新填写 / 申请加入名单(FR-509)。纯文案提示("如有疑问请联系老师")保留,因**不涉及任何联系方式数据**。
**接口总数 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 个接口里唯一未实现 |