commit 63aec553298fa94038930082448f2a76ce17ba3c Author: lovife <474239041@qq.com> Date: Mon Sep 28 16:19:19 2026 +0800 docs: 产品文档与交互原型(P1-P7 交付物) 一期范围:学校收集 + 自主认定(4 类材料全部有实测样本) - 主 PRD 190KB:125 条 FR / 21 条 VAL / 20 张表 DDL / 121 个接口 - 功能逻辑卷 88KB:不含代码模块 - 接口设计 116KB:58 个错误码 / 12 个 VO - 核心模块设计:M1 命名 / M2 压缩 / M3 识别 / M4 门禁 / M5 校验 / M6 导出 - 开发任务拆解:100 任务 / 387 人日 / 15 周 - 验收测试清单:36 条 Golden 断言 / 111 条功能验收 - PostgreSQL DDL:已在 PG 16.15 实测零错误 - 交互原型 31 屏:学生端 11 / 班主任端 9 / 管理端 11 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea936b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# ── IDE ────────────────────────────── +.idea/ +*.iml +.vscode/ +*.swp + +# ── Java / Maven ───────────────────── +target/ +*.class +*.jar +!**/gradle/wrapper/*.jar +.mvn/wrapper/maven-wrapper.jar + +# ── Node / 前端 ─────────────────────── +node_modules/ +dist/ +dist-ssr/ +unpackage/ +.pnpm-store/ +*.tsbuildinfo + +# ── 环境与密钥(禁止入库)──────────── +.env +.env.local +.env.*.local +*.pem +*.key +*.p12 +keystore/ + +# ── 日志与运行时 ────────────────────── +logs/ +*.log +npm-debug.log* +yarn-error.log* + +# ── 构建产物 ────────────────────────── +build/ +out/ +*.tmp +*.bak + +# ── 上传/导出的本地测试数据 ─────────── +/data/ +/upload/ +/export/ +*.zip + +# ── OS ─────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f8d77f --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# 数据信息填报系统 · 文档中心 + +> 职业技能等级认定**报名材料收集 SaaS** +> **一期范围**:学校收集 + 自主认定(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力后置二期 +> 学生在微信小程序填报 · 班主任在手机初审 · 管理员在 PC 终审与导出 +> 最后更新:2026-09-28 | **状态:需求已冻结(客户全部确认,可开工)** + +--- + +## 一、这是什么 + +客户(职业院校 / 认定机构)每年要收集几百名学生的认定报名材料(一寸照片、身份证、学籍表、工作证明)。现状是靠班主任在微信群里收、手工改名、手工压缩、手工核对——**一个班 28 人就有 112 个文件、59 个命名违规、31 个超体积**,全靠人眼。 + +本系统把这件事做成流程:**门禁核验 → 按规则自动命名与压缩 → 材料识别 → 校验 → 分级审核 → 一键导出规范包**。 + +**核心增量**:官方 Excel 模板里「报考职业」「报考工种」是自由填写、没有下拉——我们把它变成**从名单聚合 + 一致性体检**。 + +--- + +## 二、文档地图 + +### 按角色读 + +| 你是 | 从这几份开始 | 顺序 | +|---|---|---| +| **客户 / 业务方** | `01-客户与机构确认函.md` → `prototype/index.html` | 先看确认函(有 23 项待拍板),再点开原型 | +| **项目经理** | `00-交付计划.md` → `05-开发任务拆解.md` → `交接摘要.md` | 先看进度,再看 94 个任务与 14 周排期 | +| **产品 / 需求** | `数据信息填报系统PRD.md` → `数据信息填报系统PRD-功能逻辑卷.md` | 前者含库表接口,后者是纯功能逻辑 | +| **后端开发** | `03-接口设计.md` → `04-核心模块设计说明.md` → `db/schema-postgresql.sql` | 接口收发 → 引擎怎么做 → 建表 | +| **前端开发** | `prototype/` → `03-接口设计.md` | 原型是视觉与交互的**唯一基准** | +| **测试** | `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 29 条 Golden 断言 + 85 条功能验收 | +| **新加入的人** | `交接摘要.md` → 本文件 | 一份文档看清全部状态 | + +### 全部文档 + +| # | 文档 | 规模 | 内容 | 状态 | +|:--:|---|:--:|---|:--:| +| 00 | `00-交付计划.md` | 8 KB | 7 项计划与进度追踪、风险跟踪 | ✅ | +| 01 | `01-客户与机构确认函.md` | 18 KB | **23 项已全部确认** + 客户回执 + 落地动作清单 | ✅ **需求冻结** | +| 02 | `02-需求追溯矩阵.md` | 10 KB | 115 条 FR ↔ 原型/库表/接口 的自审报告 | ✅ | +| 03 | `03-接口设计.md` | 112 KB | **121 个接口**字段级定义、枚举全集、12 个 VO、**57 个错误码** | ✅ | +| 04 | `04-核心模块设计说明.md` | 56 KB | **6 个模块**伪代码级说明 + 依赖图 + 26 项回归基线 | ✅ | +| 05 | `05-开发任务拆解.md` | 31 KB | **100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与风险 | ✅ | +| 06 | `06-验收测试清单.md` | 29 KB | **36 条 Golden 断言** + 111 条功能验收 + 签署表 | ✅ | +| — | `交接摘要.md` | 11 KB | **状态源**:客户回执、13 条铁律、已完成、待办、环境、教训 | ✅ | +| — | `数据信息填报系统PRD.md` | 190 KB | 主 PRD V2.3+:**125 条 FR**、**21 条 VAL**、**20 张表 DDL**、验收标准 | ✅ | +| — | `数据信息填报系统PRD-功能逻辑卷.md` | 88 KB | 纯功能运转逻辑(**不含库表/接口/代码**) | ✅ | +| — | `db/schema-postgresql.sql` | 36 KB | 可执行 PG DDL(20 表),**已在 PostgreSQL 16 实测零错误** | ✅ | +| — | `prototype/`(4 份) | 240 KB | **31 屏**交互原型:学生端 11 / 班主任端 9 / 管理端 11 | ✅ | + +--- + +## 三、三分钟看懂产品 + +### 一条主线 + +``` + ┌─ 学生端(小程序)─┐ ┌─ 班主任端(手机)─┐ ┌─ 管理端(PC)─┐ +名单 ──→ 门禁核验 ──→ 填报 ──→ 卡片流初审 ──→ 终审 ──→ 导出规范包 + └─✗ 失败 → 申请加入名单 → 管理员审批 → 入册放行 + ▲ ▲ ▲ ▲ + │ │ │ │ + 报考范围 识别 命名+压缩 汇总 Excel + 从名单聚合 校验 (≤1M 无例外) + 目录树 +``` + +### 两个必须知道的数字 + +| 数字 | 含义 | +|---|---| +| **59 / 112** | 真实批次里的命名违规率(52.7%)。系统自动修 | +| **475 MB → 37 MB** | 原始总量 → 导出包,压缩率 92%。**300dpi 清晰度零损失** | + +### 三条铁律(不要试图绕过) + +| # | 铁律 | 理由 | +|:--:|---|---| +| 1 | **必须先导名单**,姓名+身份证匹配不通过不放行 | 门禁是产品根基 | +| 2 | **有不合格文件时直接禁用导出按钮** | 带问题打包出去,返工成本远高于当场修 | +| 3 | **识别失败一律放行** | 不阻断提交是完成率的底线 | +| 4 | **归属存疑一律阻断,且无"仍然继续"** | 文件名的姓/证号指向别人 = 传错材料。**自动改名会把错传洗白**,审核看不出来、事后无从追溯 | + +--- + +## 四、关键决策速查 + +> 完整 13 条见 `交接摘要.md` 第二节。这里只列最容易被反复讨论的。 + +| 决策 | 内容 | +|---|---| +| 压缩 | **强制 ≤1M,无例外**;默认 300dpi/q85(实测 28/28 达标,均值 806KB) | +| 材料来源 | 只有**聊天记录 / 相册 / 手机文件**,「直接拍照」默认关闭 | +| 报考范围 | **从名单聚合**,不存在独立的"计划清单"文件 | +| 数据库 | **PostgreSQL**(纯 PG);软删除字段名 `deleted` | +| 滑动交互 | 班主任端**只做右滑通过**,驳回必须点按钮 | +| 批量通过 | **只带无问题项**,红黄一律排除 | +| 动态表单 | 内置字段(固定列可统计)+ 扩展字段(`extras_json`) | +| 名单外的人 | **申请加入名单 → 管理员逐条审批**;通道只解决"进名单",**不绕过门禁** | +| 归属判定优先级 | **证件号反查名册 > OCR 内容 > 姓名(仅辅助,不做独立规则)** | +| 联系方式 | **系统内不展示任何班主任/管理员联系方式**——学生本来就有;门禁失败只给「重新填写 / 申请加入名单」两条系统内出路 | +| 校验位的作用 | **失败 = 不是真实号码 → 可自动改**;**通过 = 真人 → 改了就是改身份 → 必须阻断** | +| 审批粒度 | 批量通过**不提供"全选"**,驳回**必须逐条填原因**(身份认定误操作代价极高) | + +--- + +## 五、目录结构 + +``` +xiaotutongxue/ +├── README.md ← 本文件 +└── docs/ + ├── 00-交付计划.md + ├── 01-客户与机构确认函.md + ├── 02-需求追溯矩阵.md + ├── 03-接口设计.md + ├── 04-核心模块设计说明.md + ├── 05-开发任务拆解.md + ├── 06-验收测试清单.md + ├── 交接摘要.md + ├── 数据信息填报系统PRD.md + ├── 数据信息填报系统PRD-功能逻辑卷.md + ├── db/ + │ └── schema-postgresql.sql + └── prototype/ + ├── index.html ← 三端入口,从这里打开 + ├── student-app.html + ├── teacher-app.html + └── admin-web.html +``` + +--- + +## 六、快速上手 + +### 看原型 + +直接双击 `docs/prototype/index.html`。**单文件自包含、零外部依赖**,不需要服务器。 +管理端原型支持 100% / 82% / 66% 缩放查看。 + +### 验证数据库 + +```bash +# 起一个 PG 容器 +docker run -d --name pg-verify -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=filldb -p 5432:5432 postgres:16 + +# 执行 DDL +docker exec -i pg-verify psql -U postgres -d filldb < docs/db/schema-postgresql.sql + +# 验证结果:19 张表 / 167 列注释 / 61 索引,零错误 +docker exec -it pg-verify psql -U postgres -d filldb +``` + +> 容器仍在本机运行时可 `docker exec -it pg-verify psql -U postgres -d filldb` 直接进入; +> 不需要了用 `docker rm -f pg-verify` 清理。 + +### 跑原型结构校验 + +```bash +python check2.py docs/prototype/admin-web.html +# 输出:标签配对 / CSS 变量 / SVG 图标 / emoji / 外部依赖 五项检查 +``` + +--- + +## 七、交付状态 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| **需求** | 主 PRD(**125 FR** / 20 VAL / **20 张表**)+ 功能逻辑卷 | ✅ | +| **设计** | 数据库 DDL(PG 16 实测)+ 接口设计(**121**)+ 模块设计(6) | ✅ | +| **原型** | **31 屏**(学生 **11** / 班主任 9 / 管理 **11**)= 31 | ✅ | +| **计划** | **100 任务 / 387 人日 / 15 周**(一期范围已收窄) | ✅ | +| **测试** | **36** 条 Golden 断言 + **111** 条功能验收 | ✅ | +| **待外部** | ~~客户确认函(23 项)~~ | ✅ **已确认,按建议执行** | + +### 客户已确认(2026-09-28 · 可开工) + +| 项 | 确认结论 | +|---|---| +| **A1** | ✅ 认定机构接受 300dpi 输出 | +| **B1** | ✅ 增加「申请加入名单 → 管理员审批」通道(**唯一功能增量,已落地**) | +| **B3** | ✅ 字段三分类:姓名/证件号/报考三项锁定,其余可核对修改并留痕 | + +**其余 20 项均与现有设计一致,零改动。** 需求已冻结,可按 `05-开发任务拆解.md` 的 **M0** 开工。 + +--- + +## 八、数据与测试基线 + +| 项 | 值 | +|---|---| +| **Golden Test Set** | `D:\项目\202609机电填报系统\24中技数控1班\` | +| 规模 | 28 人 × 4 类材料 = **112 个文件**,原始 **475 MB** | +| 命名违规 | **59**(52.7%) | +| 双扩展名 | **56** | +| 体积超标 | **31** | +| 证件号异常 | **5**(自动修 3 + 待人工 2) | +| 疑似串档 | **1**(唯一必须人工判断的) | +| 压缩后 | 均值 **806 KB**,最大 **831 KB**,**0 张超标** | +| 导出包 | **≈37 MB**,压缩率 **92%** | + +> ⚠️ **这个数据集不可替代**。所有"实测"结论都来自它;换成构造数据,双扩展名 56/112 这种数字就复现不出来。 +> 必须纳入版本管理或保留只读副本。 + +--- + +## 九、开发环境 + +| 项 | 说明 | +|---|---| +| 数据库 | PostgreSQL 16(DDL 已实测通过) | +| 开发顺序 | **M1 命名引擎 → M4 门禁 → M2 压缩 → M5 校验 → M3 识别 → M6 导出** | +| 客户回执 | 全部 23 项按建议执行;唯一功能增量 B1(申请加入名单)已落地 | +| 原型约束 | 单文件自包含、零外部依赖、内联 SVG 图标、**无 emoji** | +| 最长的两个单点 | 压缩引擎 12 人日、打包引擎 10 人日(**加人无效**) | +| 回归门禁 | 改动后必须复现 **15 项基线指标**(对应 29 条断言),偏差即回归 | + +--- + +## 十、两条踩过的坑 + +| # | 教训 | +|:--:|---| +| 1 | **脚本审计前先验证脚本本身**。追溯矩阵审计时脚本漏匹配加粗编号,误报 3 个"需求问题",若直接采信会引入 3 处错误 | +| 2 | **引用已有编号前先脚本核对,不凭记忆**。模块设计里把 VAL 编号整体写错位(VAL-01 是姓名 vs 证件号格式),若不查会与 PRD 互相矛盾 | + +> 详见 `交接摘要.md` 第七节。 + +--- + +**文档中心维护约定**:任何改动后,请更新 `00-交付计划.md` 的执行记录与 `交接摘要.md` 的状态;改动接口/库表/FR 编号时,**必须重跑跨文档一致性核对**。 diff --git a/docs/00-交付计划.md b/docs/00-交付计划.md new file mode 100644 index 0000000..c419855 --- /dev/null +++ b/docs/00-交付计划.md @@ -0,0 +1,112 @@ +# 交付计划与进度 + +> 项目:数据信息填报系统(填报通 / CollectFlow) +> 起始:2026-09-28 +> 状态:**进行中** + +--- + +## 一、当前已交付 + +| # | 交付物 | 版本 | 状态 | +|---|---|---|---| +| — | 主 PRD(含库表设计、接口清单、15 条校验规则、18 张表 DDL) | V2.3 | ✅ | +| — | 功能逻辑卷(纯功能运转逻辑,不含库表/接口/代码) | V1.9 | ✅ | +| — | PostgreSQL 建表脚本 | — | ✅ **已在 PG 16 实测通过** | +| — | 学生端原型(**11 屏**) | V1.2 | ✅ 含「申请加入名单」 | +| — | 班主任端原型(9 屏) | V1.1 | ✅ | +| — | 管理端 PC 原型(**11 屏**) | V1.2 | ✅ 含表单设计器/材料规则/整理器/申请审批 | + +--- + +## 二、待执行计划 + +> 排序原则:**先解阻塞,再补设计,最后备交付**。 +> 客户确认链是最长的外部依赖,必须最先发出去。 + +### 阶段一 · 打通确认链 + +| # | 任务 | 产出 | 为什么这个顺序 | 状态 | +|---|---|---|---|---| +| **P1** | **客户与认定机构确认函** | `01-客户与机构确认函.md` | 外部依赖最长。**5 项高优先级阻塞开发**(300dpi 能否接受、名单外的人怎么办、社会收集名单从哪来…),越早发出越早回来 | ✅ | +| **P2** | **需求追溯矩阵** | `02-需求追溯矩阵.md` | 自审:把 PRD 需求 ↔ 原型屏 ↔ 库表 ↔ 接口 逐条对应,找出**说了没做/做了没说**的缺口。放在接口文档之前,避免接口漏做 | ✅ | +| **P2.5** | **管理端补充原型**(矩阵审计追加) | 追加到 `admin-web.html` | 矩阵发现 3 个 P0/P1 复杂交互页面未出原型:**表单设计器**、**材料规则配置**、**历史整理器**。不画出来开发必理解偏 | ✅ | + +### 阶段二 · 补齐技术设计 + +| # | 任务 | 产出 | 说明 | 状态 | +|---|---|---|---|---| +| **P3** | **接口设计(OpenAPI)** | `03-接口设计.md` | PRD §8 已有 96 个接口清单,但缺**完整请求/响应字段定义**。补齐后前端可 mock、后端可并行开工。**含补追溯矩阵发现的 21 个缺口** | ✅ | +| **P4** | **核心模块设计说明** | `04-核心模块设计说明.md` | 6 个模块的**伪代码级**说明:M1 命名 / M2 压缩 / M3 识别 / M4 门禁 / M5 校验 / M6 导出;含依赖图、开发顺序、Golden 基线 | ✅ | + +### 阶段三 · 交付准备 + +| # | 任务 | 产出 | 说明 | 状态 | +|---|---|---|---|---| +| **P5** | **开发任务拆解(WBS)** | `05-开发任务拆解.md` | 8 个里程碑拆到可派工粒度(94 个任务 / 380 人日 / 工期 14 周),含关键路径、8 项风险、团队配置、3 个客户演示点 | ✅ | +| **P6** | **验收测试清单** | `06-验收测试清单.md` | 29 条 Golden 断言(可直接自动化)+ 85 条功能验收 + 20 条异常边界 + 9 项性能 + 12 项安全 + 签署表 | ✅ | +| **P7** | **文档导航(README)** | `../README.md` | 统一入口:按角色读法、三分钟看懂产品、关键决策速查、快速上手 | ✅ | + +--- + +## 三、执行记录 + +| 时间 | 任务 | 结果 | +|---|---|---| +| 2026-09-28 | 立计划 | 7 项,分三阶段 | +| 2026-09-28 | **P1 确认函** | 完成 `01-客户与机构确认函.md`:23 项待确认按**拍板方**分 4 组(认定机构 6 / 客户业务 9 / 商务法务 5 / 内部技术 7);**每项给出建议默认值**,客户不回复也能推进;附 9 条已闭环决策表防止反复讨论 | +| 2026-09-28 | **P2 追溯矩阵** | 完成 `02-需求追溯矩阵.md`。**审计发现**:① 修正统计口径——实际 **115 条 FR**(83 P0/29 P1/3 P2)+ 17 条 VAL,此前漏算加粗编号;② **10 个接口缺口**(报考范围、整理器、字典、日志、消息中心、订阅额度、审核撤回、任务暂停…);③ **管理端 10 个原型页面缺口**,其中表单设计器为 P0;④ 4 个疑似需求问题中 **3 个是脚本正则缺陷、1 个为真**(已补 FR-616);⑤ **追加 P2.5** 补 3 个复杂交互页面原型 | +| 2026-09-28 | **P2.5 补充原型** | 管理端 `admin-web.html` 由 7 屏扩至 **10 屏**:⑧**表单设计器**(字段库/画布/属性三栏 + 条件规则行,内置字段带锁不可删)⑨**材料规则配置**(材料清单侧栏 + 命名模板 + 档位选择 + 来源引导语)⑩**历史数据整理器**(用真实实测数字:475MB→37MB、59 项命名违规、仅 1 项需人工)。四份原型全部通过结构校验 | +| 2026-09-28 | **P6 验收测试清单** | 完成 `06-验收测试清单.md`(25KB / 518 行)。把 P4 的 15 项基线转成 **29 条可自动化断言 G-01~G-29**;另含 3 条端到端主流程(32 步)、20 条异常边界、85 条功能验收、6 条视觉、9 项性能、12 项安全;含里程碑签署表与 **7 条 Go/No-Go 条件**;点出 3 个易漏盲区(Windows 解压中文名 / 并发绑定临界窗口 / 「无」是合法值) | +| 2026-09-28 | **客户回执** | 客户确认全部 23 项按建议执行,**需求冻结**。识别出唯一功能增量 **B1(申请加入名单)**,其余 22 项零改动 | +| 2026-09-28 | **D7 降级** | 重新评估「文件名识别真实准确率」这个待办:因**输出命名取自名册**、**归属靠证件号反查名册**、**识别有槽位兜底**、**失败一律放行**,该指标**不影响任何交付项**。降级为「上传页引导文案校准」——班主任在群里问两句即可,**取消样本索取**。至此**全部外部数据依赖清零** | +| 2026-09-28 | **一期范围收窄** | 客户确认「**先做学校内的填报流程**」→ 一期只做**学校收集 + 自主认定**(4 类材料全部有实测样本);社会收集 / 社会评价 / 专项职业能力后置二期,**数据结构与枚举保留不启用**。**D4 因此不再是待办**。工期 −5 人日(392→387),关键路径不变仍 15 周 | +| 2026-09-28 | **移除联系方式展示** | 客户指出"学生本来就有班主任联系方式,不需要系统里面体现"。删除 `GET /mp/roster/contact` 接口 + 门禁失败页「联系老师」按钮,改为只保留两条系统内动作(重新填写 / 申请加入名单)。**接口总数 122 → 121**。纯文案提示保留(不涉及联系方式数据) | +| 2026-09-28 | **归属校验修正** | 客户指出"系统不存在名称错误"——纠正原设计中「把文件名不符自动按名册修正」的缺陷(会把传错的文件静默洗白)。改为 **VAL-21 文件归属校验 + 阻断提交**;判定优先级重排为 证件号反查名册 > OCR > 姓名(仅辅助);姓名不再作为独立规则。另补全「社会评价」定义与 `cert_type` 第三取值 | +| 2026-09-28 | **B1 落地** | 新增 FR-509 系列 10 条 / `collect_join_request` 表(**PG 16 实测通过 + 部分唯一索引业务规则验证**)/ 5 个接口 / 错误码 40037~40042 / 学生端 STEP 03B + 管理端 STEP 11 两屏原型 / F-509 验收项 19 条 / WBS 新增 6 个任务 | +| 2026-09-28 | **P7 文档导航** | 完成 ../README.md(10KB)。按角色给出读法、三分钟看懂产品、13 条关键决策速查、目录结构与快速上手、数据基线、两条踩坑教训 | +| 2026-09-28 | **P5 开发任务拆解** | 完成 `05-开发任务拆解.md`(27.6KB / 468 行)。**94 个任务 / 380 人日 / 含缓冲 456**,工期 **14 周**。关键结论:**工期由关键路径(67 人日串行)决定,不能按人日总数摊人**;压缩周期只能砍范围不能加人。含 3 个客户演示点(A 第 5 周 / B 第 10 周 / C 第 14 周)与 §9 可复现核算口径 | +| 2026-09-28 | **P4 核心模块设计** | 完成 `04-核心模块设计说明.md`(54KB / 1218 行)。六个模块全部伪代码级:M1 命名(含证件号三源裁决表)、M2 压缩(五档+二分降质+二次有损防护)、M3 识别(四层递进+成本闸)、M4 门禁(**并发绑定必须 UPDATE...WHERE bind_user_id IS NULL**)、M5 校验、M6 导出(快照先固化+中文文件名编码)。**校验中发现并修正三处真问题**,见下条教训 | +| 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL | + +--- + +## 三、客户回执(2026-09-28) + +> **客户全部 23 项按建议方案确认。** 详见 `01-客户与机构确认函.md` 的客户回执章节。 + +| 组 | 项数 | 需开发 | 结论 | +|---|:--:|:--:|---| +| A 认定机构 | 6 | **0** | 全部与现有设计一致(当初就按实测数据设计) | +| B 校方业务 | 9 | **1** | 仅 **B1(申请加入名单通道)** 是功能增量 | +| C 商务法务 | 5 | 0 | C1 小程序资质按建议推进 + H5 保底 | +| D 内部技术 | 7 | 0 | **D4 已后置二期**、**D7 已降级清除**;仅 D6(微信三条来源通道体积上限)需真机自测,**无外部依赖** | + +**一期范围(客户确认后收窄)**:只做 **学校收集 + 自主认定**(4 类材料全部有实测样本)。 +社会收集 / 社会评价 / 专项职业能力后置二期,**数据结构与枚举保留不启用**。 +⇒ **D4(社会评价样本)因此不再是待办**;工期 **392 → 387 人日**,关键路径不变仍 **15 周**。 + +**本次变更(V1)**:新增 **FR-509 系列**(10 条)+ **1 张表**(`collect_join_request`)+ **5 个接口** + **2 屏原型**。 + +| 指标 | 变更前 | 变更后 | +|---|:--:|:--:| +| FR 总数 | 115 | **125** | +| 接口总数 | 117 | **121** | +| 库表 | 19 | **20** | +| VAL 规则 | 20 | **21**(新增 VAL-21 归属校验) | +| 错误码 | 50 | **57** | +| 原型屏数 | 29 | **31** | +| 任务数 | 94 | **100** | +| 人日 | 380 | **392** → **387**(一期收窄 −5) | +| 工期 | 14 周 | **15 周** | + +--- + +## 四、已知风险(跟踪中) + +| # | 风险 | 影响 | 对策 | +|---|---|---|---| +| R1 | ~~客户迟迟不确认 P1 的 5 项高优先级问题~~ | — | ✅ **已解除**:客户确认全部 23 项按建议执行,需求已冻结 | +| R2 | ~~强特征词表未在"社会评价"批次数据上标定~~ | — | ✅ **已消除**:一期范围收窄为「学校收集 + 自主认定」,**不涉及**那两类零样本材料(D4 已后置二期) | +| R3 | VLM 选型未定 | 中 | P4 中把 VLM 抽成**可替换接口**,不绑定供应商 | +| R4 | **二期启动时缺少社会评价样本** | 中(二期) | 已在 PRD「一期范围声明」列出二期启动前必做 6 项;**不阻塞一期** | diff --git a/docs/01-客户与机构确认函.md b/docs/01-客户与机构确认函.md new file mode 100644 index 0000000..8b5c1a5 --- /dev/null +++ b/docs/01-客户与机构确认函.md @@ -0,0 +1,376 @@ +# 确认函 · 数据信息填报系统 + +> 文档用途:汇总需各方拍板的事项,**可直接转发** +> 发出日期:2026-09-28 +> 回复期限:建议 **5 个工作日内** +> 版本:V1.0 + +--- + +## 致各位 + +我们已完成《数据信息填报系统》的需求与设计,进入开发前需确认若干关键事项。本文档把需确认的问题按**拍板方**分为四组: + +| 分组 | 拍板方 | 项数 | 为什么需要确认 | +|---|---|:--:|---| +| **A** | 认定机构 | 6 | 直接决定**验收标准**——压缩到什么程度算合格、什么格式算合格 | +| **B** | 校方 / 客户业务 | 9 | 决定**流程规则**——名单外的人怎么处理、预填字段能不能改 | +| **C** | 商务 / 法务 | 5 | 决定**资质与商业模式**——小程序类目、定价、合规 | +| **D** | 我方内部技术 | 7 | 不涉及客户,仅内部拍板 | + +**每项都给出了「建议方案」,如无异议可直接回复"全部同意建议方案"。** +若 5 个工作日内未收到回复,我方将按建议方案推进——**以便不阻塞开发排期**,后续如与预期不符可再调整(涉及外部技术依赖的除外,会单独再次确认)。 + +**如何回复**:只需按编号回答即可,例如—— +``` +A1 同意 +A2 选 B +B1 选 A,但希望增加审批环节 +``` + +--- + +## A 组 · 请认定机构确认(技术规格) + +> 这些直接决定**交付物是否合格**。我们已用真实批次(28 名学生 / 112 个文件)实测过,结论都有数据支撑。 + +### A1. 「300dpi 扫描件」是否可接受? + +| | | +|---|---| +| **背景** | 认定机构要求单文件 ≤1M。工作证明等 A4 材料原始为 1272dpi、16MB。我们实测将 A4 等比缩至 **300dpi / 质量 85**,**28 张全部达标,平均 806KB,最大 831KB**——即**无需牺牲清晰度**。 | +| **问题** | 输出为标准 300dpi 扫描件,是否可接受? | +| **建议方案** | ✅ **接受 300dpi 输出**(这是文档扫描的行业标准分辨率,清晰度零损失) | +| **需谁确认** | 认定机构 | +| **回复** | ☐ 同意 ☐ 需更高分辨率(请注明期望值) | + +### A2. 若某张扫描件内容特别复杂,压缩参数的下限怎么定? + +| | | +|---|---| +| **背景** | 绝大多数材料 300dpi/q85 即可达标。但极端情况(满页手写、彩色底纹)可能需要降质。需要事先约定"最低可接受到什么程度"。 | +| **问题** | 允许系统自动降到的最低档是多少? | +| **建议方案** | ✅ **建议:允许自动降至 200dpi / 质量 70;低于此值则标记"强制压缩"并需人工确认后才提交** | +| **回复** | ☐ 同意 ☐ 不允许降质,超限就驳回重传 | + +### A3. 学籍表等截图类材料,保留 PNG 格式是否会被拒收? + +| | | +|---|---| +| **背景** | 实测学籍表为教务系统的**屏幕截图**(约 1025×590,PNG 格式,40KB)。若强行转 JPG,文字边缘会发虚——**而截图本身已经很小,转换没有收益、只有损失**。 | +| **问题** | 保留 PNG 是否可以? | +| **建议方案** | ✅ **建议:截图类保留 PNG 原样输出**(命名仍按规范) | +| **回复** | ☐ 可以保留 PNG ☐ 必须统一 JPG | + +### A4. 分辨率下限:什么样的图判定为"不合格、需重传"? + +| | | +|---|---| +| **背景** | 实测发现有一张身份证翻拍件仅 **558×745 / 0.08MB**,明显低于同批其他人(1279×1706)。**体积小不等于合格**,需要设下限。 | +| **问题** | 长边低于多少像素应判定为不合格? | +| **建议方案** | ✅ **建议:身份证等证件翻拍长边 ≥1400px;低于则提示重传(不阻断提交,由审核人决定)** | +| **回复** | ☐ 同意 1400px ☐ 其他阈值:______ | + +### A5. 材料「工作证明」的规范文件名,与实际内容不符,是否保留? + +| | | +|---|---| +| **背景** | 按机构模板,**「职业技能等级个人申报表」的规范文件名却是「工作证明」**(例:`张三-身份证号-工作证明.jpg`)。这是机构的既有习惯。 | +| **问题** | 是否按机构习惯保留这个命名? | +| **建议方案** | ✅ **建议:保留机构习惯**(系统支持别名映射,不影响内部识别) | +| **回复** | ☐ 保留 ☐ 改为"个人申报表" | + +### A6. 导出包的目录结构与命名是否符合接收要求? + +| | | +|---|---| +| **背景** | 系统默认按下述结构打包: | + +``` +{批次号}-{任务名}/ +├── 24中技数控1班/ +│ ├── 一寸照片/ 张三-360121200010101234-一寸照片.jpg +│ ├── 身份证/ 张三-360121200010101234-身份证.jpg +│ ├── 学籍表/ 张三-360121200010101234-学籍表.png +│ └── 工作证明/ 张三-360121200010101234-工作证明.jpg +└── 考生线下报名信息导入模板(汇总).xlsx +``` + +| | | +|---|---| +| **问题** | 这个结构是否满足接收要求? | +| **建议方案** | ✅ **建议:按上述结构**(如需调整,请提供一份理想结构的样例) | +| **回复** | ☐ 符合要求 ☐ 需调整为:______ | + +--- + +## B 组 · 请校方 / 客户业务确认(流程规则) + +### B1. 不在名单上的人想填报,怎么处理? ⚠️ **优先级最高** + +| | | +|---|---| +| **背景** | 按现有需求,系统采用"**先导名单、姓名+身份证核验通过才能填报**"的门禁机制。名单外的人**一律不给填、只提示联系老师**。
但这会带来运营问题:**老师需要反复去后台手工加人**。在几百人的社会收集中会很痛苦。 | +| **问题** | 是否给名单外的人一条"申请加入"通道? | +| **建议方案** | ✅ **建议选 A** —— 既不破门禁,又免去老师手工加人 | + +| 选项 | 方案 | 代价 | +|---|---|---| +| **A(建议)** | 失败页增加「**申请加入名单**」按钮 → 生成管理员待办 → **管理员批准后自动放行** | 需开发一个审批小流程 | +| B | 维持现状:只提示,老师去后台加人 | 零开发,但老师负担重 | +| C | 给每个任务配"白名单开关",允许自由填报 | 门禁形同虚设,**不推荐** | + +**回复**:☐ 选 A ☐ 选 B ☐ 选 C + +### B2. 社会收集的名单从哪里来? + +| | | +|---|---| +| **背景** | 学校收集的名单可从教务系统导出,很稳定。但社会收集**同样要求"先导名单"**,如果名单来源不稳定,这套机制在社会侧很难跑起来。 | +| **问题** | 社会收集的名单由谁提供、怎么来? | +| **建议方案** | ✅ **建议:由报名点/机构维护预报名名单,导入后开放填报**;同时允许在填报期内**随时追加**(系统会告警范围外的报考项) | +| **回复** | 名单来源:__________ 维护责任人:__________ | + +### B3. 学生能不能修改老师预填的字段? + +| | | +|---|---| +| **背景** | 实测老师交上来的名单里,21 个字段**已填满 18 个**(学历、在校生、学校、专业、入学时间、联系电话、报考三项等)。所以学生端主要是"核对 + 传材料"。
但老师填的东西可能出错——比如电话写错、入学时间记错。 | +| **问题** | 哪些字段允许学生改? | +| **建议方案** | ✅ **建议按下表三分类** | + +| 类别 | 字段 | 学生端 | +|---|---|---| +| 🔒 **锁定** | 姓名、身份证号、**报考职业/工种/等级** | **只读不可改**(要改找老师) | +| ✏️ **可核对修改** | 其余全部(学历、在校生、学校、专业、入学时间、电话…) | 预填并标注"老师已填,请核对",**可改且留痕** | +| 📄 **学生负责** | 各类材料文件 | 上传 | + +**回复**:☐ 同意该分类 ☐ 要求全部只读(学生只能改电话) ☐ 其他:______ + +### B4. 要不要再加一层手机号验证码? + +| | | +|---|---| +| **背景** | 目前门禁是"姓名 + 身份证号"。理论上,**只要有人拿到这两项信息,就能代填**。 | +| **问题** | 是否需要加手机号验证码? | +| **建议方案** | ⚠️ **建议不加**。理由:① 名单里已有手机号,加验证等于要求一致;② 学生群体换号频繁,会制造大量"收不到验证码"的运营问题;③ 有人代填(如家长帮填)在本场景是可接受的。
**若机构有实名硬要求,则必须加。** | +| **回复** | ☐ 不加 ☐ 必须加(请说明是哪个环节要求) | + +### B5. 学生被驳回几次后应升级提醒管理员? + +| | | +|---|---| +| **问题** | 驳回次数上限设为几次? | +| **建议方案** | ✅ **默认 3 次**,达到后自动提醒管理员介入 | +| **回复** | ☐ 同意 3 次 ☐ 改为 ____ 次 | + +### B6. 导出包在服务器保留几天? + +| | | +|---|---| +| **问题** | 导出包保留时长(超期自动清理,释放存储) | +| **建议方案** | ✅ **默认 7 天**,管理员可随时重新导出(不重复计费) | +| **回复** | ☐ 同意 7 天 ☐ 改为 ____ 天 | + +### B7. 压缩质量的抽检由谁做? + +| | | +|---|---| +| **背景** | 系统对"清晰度可能受损"的文件会打标记,需要人工抽检确认。**但实测 28 张扫描件全部达标,不产生任何待抽检文件**——这个机制可能用不上。 | +| **问题** | 是否需要保留抽检环节?由谁执行? | +| **建议方案** | ✅ **建议保留机制但默认不启用**(仅在出现"强制压缩"标记时才触发),由**班主任在审核时顺带确认** | +| **回复** | ☐ 同意 ☐ 不需要该机制 | + +### B8. 一期是否需要与认定机构的业务系统对接? + +| | | +|---|---| +| **问题** | 是否需要把数据直接推送到机构系统,而不是导出文件包? | +| **建议方案** | ✅ **建议一期不做**(导出标准包已可满足),**但预留开放接口**,二期按需启用 | +| **回复** | ☐ 一期不做 ☐ 一期就必须对接(请提供对方接口文档) | + +### B9. 社会收集是否需公安实名核验? + +| | | +|---|---| +| **问题** | 是否调用公安实名核验接口(涉及按次收费) | +| **建议方案** | ✅ **建议不做**。当前"名单核验 + 材料人工审核"已足够;如机构有硬要求再启用 | +| **回复** | ☐ 不做 ☐ 必须做 | + +--- + +## C 组 · 请商务 / 法务确认 + +| # | 事项 | 建议方案 | 回复 | +|---|---|---|---| +| **C1** | **微信小程序主体资质**是否齐备?教育类目可能需要办学许可或合作方资质,**这会影响上线时间** | 请法务提前确认类目与所需资质;**若小程序受阻,备选 H5 扫码填报可保底** | ☐ 资质齐备 ☐ 有问题,预计 ____ 解决 | +| **C2** | **定价策略** | 建议:按**在册人数**计费(如 3–5 元/人/学期),免费版限 1 任务 / 60 人 | ☐ 同意 ☐ 改为 ______ | +| **C3** | 是否向学生收取费用(如报名服务费)? | 建议**一期不做**(涉及支付资质与资金合规),仅收 B 端服务费 | ☐ 不做 ☐ 需要 | +| **C4** | 私有化部署版本是否同期开发? | 建议 **SaaS 优先、私有化后置**(同代码,按需打包) | ☐ 同意 ☐ 同期开发 | +| **C5** | **微信隐私保护指引**需更新(需声明收身份证号、相册权限) | 我方负责配置,**但需法务确认文案** | ☐ 由法务审文案 ☐ 我方自行处理 | + +--- + +## D 组 · 我方内部技术决策(仅备案,不需客户回复) + +| # | 决策项 | 建议 | 备注 | +|---|---|---|---| +| D1 | OCR 引擎选型 | 待比价(腾讯云 / 百度 / 本地模型) | 需同时评估**数据出境合规** | +| D2 | 视觉大模型(VLM)选型 | 抽成**可替换接口**,不绑定供应商 | 实测触发率仅 2%~10%,成本可控 | +| D3 | 门禁限流阈值 | 同 openid 每小时 ≤10 次 | 上线后按真实数据标定 | +| D4 | 材料识别强特征词表 | 平台预置 + 租户可自维护 | **需在"社会评价"批次数据上补充标定** | +| D5 | 一寸照片形态判定阈值 | 用真实样本标定(宽高比容差、背景纯净度) | — | +| D6 | 微信三条来源通道的体积上限 | 需真机实测 | 决定超限提示与降级路径 | +| ~~D7~~ | ~~文件名规则的真实准确率~~ | ✅ **已降级**(见下) | 客户确认后重新评估:该指标**不再影响任何交付项** | + +--- + +## 附:已闭环的决策(无需再讨论) + +| 决策 | 内容 | 依据 | +|---|---|---| +| **D1 强制 1M** | A4 扫描件**强制压到 1M,无例外、不设放宽** | 客户已确认 | +| **压缩参数** | 默认 **300dpi / q85**,实测 28/28 达标,平均 806KB | 用真实扫描件实测 | +| **无"拍照"入口** | 材料来源只有**聊天记录 / 相册 / 手机文件**三种 | 实测 4 类材料**无一来自现场拍照** | +| **报考范围来源** | **从名单聚合**,不存在独立的"计划清单"文件 | 真实名单验证(三列已填 28/28) | +| **数据库** | **PostgreSQL**(纯 PG,不依赖 Vastbase 专有语法) | 已确认 + DDL 实测通过 | +| **软删除字段** | `deleted` | 已确认 | +| **滑动交互** | **只做「右滑通过」**,驳回必须点按钮 | 误触后果不对称 | +| **批量操作范围** | 批量通过**只带无问题的单**,红黄一律排除 | 防止"手快全选"放行异常 | +| **导出前置检查** | 有不合格文件时**直接禁用导出按钮**,不允许带问题打包 | 铁律 | + +--- + +## 客户回执(2026-09-28) + +> **回执结论:全部 23 项按我方建议方案执行。** +> 本文档转为**需求冻结基线**——以下各项视为已确认,不再重新讨论。 + +### A 组 · 认定机构(6 项) + +| # | 确认结论 | 落地动作 | 状态 | +|:--:|---|---|:--:| +| A1 | **接受 300dpi 输出** | 无需改动。实测 28/28 达标,均值 806KB | ✅ 已一致 | +| A2 | **允许自动降至 200dpi / q70**;低于此值标记"强制压缩"需人工确认 | 已一致:`min_quality=70`、`hardFloorDpi=200`、`markWhenForced=true` | ✅ 已一致 | +| A3 | **截图类保留 PNG** | 已一致:`keepPngForScreenshot=true`;学籍表 1025×590 PNG 不缩放 | ✅ 已一致 | +| A4 | **证件翻拍长边 ≥1400px** | 已一致:`min_long_side DEFAULT 1400`(仅 P2 档生效) | ✅ 已一致 | +| A5 | **保留「工作证明」命名**(即使内容是个人申报表) | 已一致:材料别名映射,规范名不"纠正" | ✅ 已一致 | +| A6 | **导出包结构按建议** | 已一致:`{批次号}-{任务名}/{班级}/{材料}/姓名-证件号-材料名.ext` | ✅ 已一致 | + +> **结论:A 组 6 项零改动**——因为当初就是按实测数据设计的,确认只是把假设变成了事实。 + +### B 组 · 校方业务(9 项) + +| # | 确认结论 | 落地动作 | 状态 | +|:--:|---|---|:--:| +| **B1** | **选 A:增加「申请加入名单 → 管理员审批」通道** | ⚠️ **需开发**:新增 FR-509 系列 + 1 张表 + 4 个接口 + 2 屏原型 | 🔨 **待实施** | +| B2 | 社会收集名单**由报名点/机构维护预报名名单**,导入后开放;**填报期内可随时追加** | 已一致:FR-508 追加导入;补充说明写入 PRD §5.5 | ✅ 已一致 | +| B3 | **字段三分类**:姓名/证件号/报考三项锁定,其余可核对修改并留痕 | 已一致:FR-502b + `changed_fields` 机制 | ✅ 已一致 | +| B4 | **不加手机号验证码** | 无需改动(当前门禁即姓名+证件号) | ✅ 已一致 | +| B5 | **驳回上限 3 次** | 已一致:`maxRejectCount=3`,超限升级管理员 | ✅ 已一致 | +| B6 | **导出包保留 7 天** | 已一致:`expire_time = NOW() + 7 days` | ✅ 已一致 | +| B7 | **保留抽检机制但默认不启用** | 已一致:FR-616,仅"强制压缩"标记触发 | ✅ 已一致 | +| B8 | **一期不对接机构系统,预留开放接口** | 已一致:§14 开放接口已设计但不启用 | ✅ 已一致 | +| B9 | **不做公安实名核验** | 无需改动 | ✅ 已一致 | + +> **结论:B 组 9 项中 8 项零改动,仅 B1 需开发。** +> B1 的建议是我方主动提出的(原需求只有"只提示"),客户采纳后**这是一次功能增量**,已按 §变更记录 追加设计与排期。 + +### C 组 · 商务法务(5 项) + +| # | 确认结论 | 落地动作 | +|:--:|---|---| +| C1 | 小程序教育类目资质:**按建议准备,若受阻走 H5 扫码填报保底** | 商务/法务启动;**H5 保底方案需在 M0 期间做技术预研**(约 2 人日) | +| C2 | 定价:**按在册人数计费**(3–5 元/人/学期),免费版 1 任务/60 人 | 已一致:`sys_tenant_package` 表已在 DDL 中 | +| C3 | **一期不向学生收费** | 无需改动 | +| C4 | **SaaS 优先、私有化后置**(同代码按需打包) | 已在架构中体现 | +| C5 | 隐私保护指引:**我方配置、法务审文案** | ⏳ 待法务出文案 | + +### D 组 · 我方内部(7 项) + +| # | 决策 | 状态 | +|:--:|---|---| +| D1 | OCR 引擎选型(腾讯云/百度/本地) | ⏳ M2 结束前定 | +| D2 | VLM 抽成可替换接口,不绑定供应商 | ✅ 已按此设计(04 文档 M3 §3.6) | +| D3 | 门禁限流:openid 10 次/小时、IP 20 次/分钟 | ✅ 已实现口径 | +| D4 | **需索取「社会评价」批次的原始材料样本** | ✅ **已后置为二期**(一期只做学校收集 + 自主认定,不涉及社会评价材料) | + +#### D4 补充说明:为什么需要「社会评价」的样本 + +> 我们的实测数据集(`24中技数控1班`,28 人 112 文件)**全部是自主认定批次**,只覆盖 4 类材料。 +> 而 **`cert_type=2 社会评价`** 批次的材料清单**有两类是完全不同的**,目前**一份样本都没有**。 + +| 材料编码 | 名称 | 出现在 | 强特征词现状 | 样本 | +|---|---|---|---|---| +| `PHOTO1` | 一寸照片 | 两者都有 | 一寸、证件照、免冠 | ✅ 112 个实测 | +| `IDCARD` | 身份证 | 两者都有 | 身份证、公民身份、人像面 | ✅ 实测 | +| `WORK_PROOF` | 工作证明 | 两者都有 | 申报表、工作证明、公章 | ✅ 实测 | +| `SCHOOL_ROLL` | 学籍表 | 仅自主认定 | 学籍 | ✅ 实测 | +| **`XJW_BA`** | **学信网备案表** | **仅社会评价** | 学信网、备案表、电子注册 | ❌ **零样本** | +| **`OTHER_PROOF`** | **其它佐证材料** | **仅社会评价 / 专项** | **写不出词** | ❌ **零样本** | + +**需要样本解决的三件事** + +| # | 要解决什么 | 现状的猜测成分 | +|:--:|---|---| +| 1 | **标定 `XJW_BA` 的强特征词与形态阈值** | 现有词是从**官方模板的文字要求**里抄的,不是从真实文件观察来的。备案表可能是网页截图(P4)、打印扫描(P3)、或拍屏(P2),**形态不同则识别与压缩策略完全不同** | +| 2 | **决定 `OTHER_PROOF` 要不要做自动识别** | 它含"毕业证 / 社保权益单 / 职称证明 / 技能等级证书"四类**版式完全不同**的东西。用一个编码承载,识别引擎**无法从内容判断它"是不是其它佐证材料"**。可能应改为:**不做识别、默认识别失败放行、由学生自选槽位** | +| 3 | **校准 P2/P3/P4 的自动判定阈值** | 现有阈值是用院校批次(学籍表截图 1025×590)标定的 | + +**样本要求** + +| 项 | 说明 | +|---|---| +| 理想 | **一个完整的社会评价批次**(1 个任务、全部人的全部材料) | +| 退而求其次 | **10~20 份**,但**必须覆盖「学信网备案表」和「各类佐证材料」**(这两类现在零样本) | +| 特别注意 | 记录**材料来源与格式**——社会人员可能直接从学信网下载 **PDF**(而非拍照/扫描)。若是,`file_types` 白名单、预览、压缩策略都要跟着调整 | + +> ✅ **已后置为二期**(2026-09-28 客户确认):一期范围收窄为「**学校收集 + 自主认定**」,材料只有 4 类(一寸照片 / 身份证 / 学籍表 / 工作证明),**全部有 112 个真实文件实测**。 +> 因此 D4 **不再是待办、也不阻塞任何里程碑**。待二期做「社会评价」时再索取即可。 +| D5 | 一寸照片形态阈值用真实样本标定 | ✅ 已有实测(984×1378,5:7) | +| D6 | **微信三条来源通道体积上限需真机实测** | ⏳ 待测 | +| D7 | ~~文件名规则在"学生原始乱名"上测真实准确率~~ | ✅ **已降级为文案校准**(不需样本) | + +#### D7 补充说明:为什么这个指标不再重要 + +> **原担忧**:文件名层识别实测 112/112 命中,但**这批文件是老师已经命名过的**,属有偏样本。 +> 学生的原始文件名可能是 `微信图片_20260928103015.jpg`、`IMG_20260928_103015.jpg` 这种,**完全没有姓名和证件号**。 + +**重新评估后结论:这个指标的"有偏性"不影响任何交付项。** + +| # | 为什么不影响 | 依据 | +|:--:|---|---| +| 1 | **输出命名不依赖文件名** | 规范名一律取自名册(`{姓名}-{证件号}-{材料名}`),文件名只用于**判断归属**,不用于命名 | +| 2 | **归属校验不依赖姓名** | 判据是 **证件号反查名册 > OCR > 姓名(仅辅助)**;姓名单独不构成规则 | +| 3 | **材料类型识别有槽位兜底** | 学生在**哪个槽位**上传就默认是哪类(`confidence 0.60`),文件名只是**交叉验证**——用于发现传错槽位 | +| 4 | **识别失败一律放行** | 兜底策略已定:识别不出来不阻断提交 | + +> ⇒ 即使学生的文件**全是乱名**(层 1 完全失效),系统行为也只是: +> **少了一层"传错槽位"的交叉验证**,而槽位本身的准确性由学生自己保证,且形态层(层 2)与内容层(层 3)仍能发现明显错位。 +> **不会出错,只会少一层提示。** + +**降级后唯一残留的需求(文案问题,非算法问题)** + +上传页的「**去哪里找**」引导语(`collect_material_def.tip_text`)要贴合学生真实看到的文件名。例如: + +| 如果学生文件名普遍是 | 引导语应该说 | +|---|---| +| `微信图片_20260928103015.jpg` | "在聊天记录里往上翻,找**你发出去的那张图**" | +| `IMG_20260928_103015.jpg` | "在相册里**按拍摄日期**找" | +| `Screenshot_2026-09-28.png` | "在相册的**截图**分类里找" | + +**怎么获取**:**不需要专门准备样本**——班主任在班级群里问两句,或者让学生随手截几个文件名发过来即可。 + +> ⚠️ **原样本需求已取消**,不再向客户索取"学生原始乱名样本"。 + +### 本次回执带来的变更汇总 + +| # | 变更 | 影响面 | 工作量 | +|:--:|---|---|:--:| +| **V1** | **新增 FR-509 系列:名单加入申请与审批** | PRD / DDL / 接口 / 模块设计 / 原型 / WBS / 测试 | **+9 人日** | +| V2 | 社会收集名单来源补充说明(B2) | PRD §5.5 | 0.5 人日 | +| V3 | H5 保底方案技术预研(C1) | M0 追加 | 2 人日 | +| | | **合计** | **+11.5 人日** | + +**修订后排期**:380 → **391.5 人日**;关键路径 **67 → 76 人日**;工期 **14 周 → 15 周**。 +(B1 落在 M2,而 M2 在关键路径上,故工期顺延 1 周。) diff --git a/docs/02-需求追溯矩阵.md b/docs/02-需求追溯矩阵.md new file mode 100644 index 0000000..987f98a --- /dev/null +++ b/docs/02-需求追溯矩阵.md @@ -0,0 +1,164 @@ +# 需求追溯矩阵(自审报告) + +> 用途:把 PRD 的每条需求对上「**原型 / 库表 / 接口 / 验收**」四个交付面,找出**说了没做**和**做了没说**。 +> 生成日期:2026-09-28 +> 审计范围:**125 条 FR**(92 P0 / 30 P1 / 3 P2)+ **21 条 VAL 校验规则** + **121 个接口** + **20 张表** +> 结论:**主流程全覆盖。第三节的 10 个接口缺口已于 P3 补齐(共新增 21 个接口);配置类页面缺口见 3.2** + +--- + +## 一、总览 + +| 模块 | 条数 | P0 | 库表 | 接口 | 原型 | 判定 | +|---|:--:|:--:|:--:|:--:|:--:|:--:| +| FR-100 租户与组织 | 5 | 5 | ✅ | ✅ | ❌ | ⚠️ 缺平台运营端原型 | +| FR-200 账号与角色 | 6 | 5 | ✅ | ✅ | ❌ | ⚠️ 缺登录页与用户管理页 | +| FR-300 收集任务 | 12 | 9 | ✅ | ✅ | ⚠️ | ✅ 暂停接口已补(P3) | +| FR-400 动态表单 | 8 | 6 | ✅ | ✅ | ❌ | ⚠️ **缺表单设计器原型** | +| FR-500 名单管理 | 24 | 21 | ✅ | ✅ | ✅ | ✅ **全覆盖**(含客户确认新增的 FR-509 系列 10 条) | +| FR-600 材料与规则 | 23 | 18 | ✅ | ✅ | ⚠️ | ✅ 识别结果已并入审核详情(P3) | +| FR-800 审核工作台 | 10 | 7 | ✅ | ✅ | ✅ | ✅ 撤回接口已补(P3) | +| FR-900 汇总导出 | 10 | 8 | ✅ | ✅ | ✅ | ✅ **全覆盖** | +| FR-1000 通知催报 | 6 | 4 | ✅ | ✅ | ⚠️ | ✅ 消息中心与额度已补(P3) | +| FR-1100 数据看板 | 5 | 2 | ✅ | ✅ | ✅ | ✅ **全覆盖** | +| FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 | +| FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 | +| FR-1400 历史整理器 | 6 | 0 | ✅ | ❌ | ❌ | ❌ **三面全缺**(P1,本轮未做) | +| **FR-700 校验引擎** | — | — | ✅ | ✅ | ✅ | ✅ 以 VAL-01~21 实现,三端均有体现 | + +**图例**:✅ 完整覆盖 ⚠️ 部分覆盖 ❌ 未覆盖 + +--- + +## 二、覆盖亮点(这些是逐条核对过的) + +| 能力 | 库表 | 接口 | 原型 | 备注 | +|---|:--:|:--:|:--:|---| +| **名单即预填报名表(18 业务列)** | ✅ | ✅ | ✅ | 已在 PG 16 实测;管理端③可视化呈现 | +| **门禁核验(含限流防枚举)** | ✅ | ✅ | ✅ | 学生端①②③;统一失败提示已实现 | +| **命名规范化引擎** | ✅ | ✅ | ✅ | 管理端②**试跑闸门**;学生端⑦结果卡 | +| **压缩引擎(五档 + 内存保护)** | ✅ | ✅ | ✅ | 实测 28/28 达标;管理端⑦报压缩率 | +| **材料识别(四层递进)** | ✅ | ✅ | ✅ | 学生端⑧;识别结果已并入审核详情响应 | +| **批量通过只带绿色项** | ✅ | ✅ | ✅ | 班主任端④ + ⑨(滑动层灰化) | +| **导出前置检查(禁止带问题打包)** | ✅ | ✅ | ✅ | 管理端⑦**禁用导出按钮** | +| **报考范围聚合与体检** | ✅ | ✅ | ✅ | 管理端④;plan-scope 接口已补 | +| **汇总 Excel 与目录树** | ✅ | ✅ | ✅ | 管理端⑦目录树预览 | + +--- + +## 三、发现的缺口 + +### 3.1 接口缺口(**已在 P3 全部补齐**,共新增 21 个) + +> 这些是"原型/PRD 里承诺了,但接口表里没有"的。放在 P3 接口设计里一并补齐。 + +| # | 缺口 | 对应需求 | 影响 | 建议接口 | +|:--:|---|---|---|---| +| I1 | **报考范围读取与确认** | FR-307~311 | 管理端④整屏无数据来源 | `GET/PUT /api/v1/tasks/{id}/plan-scope` | +| I2 | **历史数据整理器** | FR-1401~1406 | §5.14 六条需求**完全无接口** | `POST /api/v1/cleanup/jobs` 等 5 个 | +| I3 | **字典管理** | FR-1201 | 学历/等级/补考科目等下拉项无法维护 | `GET/POST/PUT /api/v1/dicts` | +| I4 | **操作日志** | FR-1202 | 审计留痕无法查询 | `GET /api/v1/logs/operations` | +| I5 | **登录日志** | FR-1203 | 同上 | `GET /api/v1/logs/logins` | +| I6 | **消息中心** | FR-1004~1005 | 学生端/教师端"消息"屏无数据来源 | `GET /api/v1/mp/messages`、`/mp/teacher/messages` | +| I7 | **订阅额度登记** | R12 对策 | 功能逻辑卷写 `/mp/subscribe`,**主 PRD 接口表漏登记** | `POST /api/v1/mp/subscribe` | +| I8 | **审核撤回** | FR-808、原型 | 原型承诺"终审前可撤回",无接口 | `POST /api/v1/audit/submissions/{id}/revoke` | +| I9 | **任务暂停/恢复** | FR-301 | FR 要求"发布、**暂停**、截止、归档",只有 publish/close/archive | `POST /api/v1/tasks/{id}/pause`、`/resume` | +| I10 | **材料识别结果读取** | FR-614d | 审核辅助要显示"系统判定:身份证 92%",无处取 | 并入 `GET /api/v1/audit/submissions/{id}` 响应 | + +> **合计 10 项,已在 P3 全部补齐**:I10 并入审核详情响应,其余新增 20 个接口(含 1 项登记遗漏 `/mp/files/{fileId}/process`) + +### 3.2 原型缺口(管理端 10 个页面;**其中 3 个已在 P2.5 补齐**) + +> 本轮原型聚焦"**高频主流程 + 设计难点**",配置类页面未出。这不是遗漏,是优先级取舍,但需要明确记录,避免开发以为"不用做"。 + +| # | 页面 | 对应需求 | 复杂度 | 建议 | +|:--:|---|---|:--:|---| +| P1 | **表单设计器** | FR-401~408 | 高 | ⚠️ **必须补**——拖拽式字段编排是 P0,且交互复杂,光靠文字描述开发必理解偏 | +| P2 | **材料规则配置**(命名模板编辑器) | FR-601~609 | 高 | ⚠️ **建议补**——管理端②只做了"试跑",配置界面本身没画 | +| P3 | 历史数据整理器 | FR-1401~1406 | 中 | 建议补(差异化获客功能,客户演示时最吸睛) | +| P4 | 登录页 | FR-201 | 低 | 可略 | +| P5 | 组织与用户管理 | FR-103、203~206 | 中 | 可略(标准 CRUD) | +| P6 | 任务列表与详情 | FR-301~306 | 中 | 建议补简版(克隆、二维码、暂停) | +| P7 | 通知模板管理 | FR-1001~1002 | 低 | 可略 | +| P8 | 字典管理 | FR-1201 | 低 | 可略 | +| P9 | 操作日志查询 | FR-1202~1203 | 低 | 可略 | +| P10 | 平台运营端(租户/套餐/计费) | FR-101~102、1301~1304 | 中 | 建议二期做(一期客户是学校,不关心平台运营) | + +### 3.3 需求条目本身的核查 + +> 审计过程中发现 4 个疑似问题,逐条核实后 **3 个是统计脚本的正则缺陷(不是文档缺陷)**,仅 1 个为真实待办。 + +| # | 疑似问题 | 核实结论 | 处理 | +|:--:|---|---|---| +| D1 | FR-614 主条目编号缺位 | ❌ **假警报**——FR-614 在 §5.6 存在(脚本未匹配**加粗编号**写法) | 无需处理 | +| D2 | FR-608 未出现在抽取结果 | ❌ **假警报**——同上,且其优先级格含补充说明文字 | 无需处理 | +| D3 | FR-1000 模块分级计数与条数不符 | ❌ **假警报**——1001~1004 为 P0、1005 为 P1、1006 为 P2,相加正确 | 无需处理 | +| **D4** | **「压缩质量抽检机制」在功能逻辑卷有详述,主 PRD 无独立 FR** | ✅ **真实**——PRD 仅在 §6.3 与验收标准中提及,未立为需求条目 | 🟡 **补 FR-616**:清晰度分级标记与抽检机制(P1) | + +> **教训**:脚本审计前必须先验证脚本本身。本次若直接采信 D1~D3,会在文档里"修"三个不存在的问题,反而引入错误。 + +### 3.4 已闭环(无需处理) + +| 项 | 状态 | +|---|---| +| 报考范围来自名单(不存在计划文件) | ✅ 已澄清并改设计 | +| DDL 在 PG 16 实测 | ✅ 零错误 | +| 压缩参数实测标定 | ✅ 300dpi/q85,28/28 达标 | +| 材料来源三通道(无拍照) | ✅ 已实现并出原型 | +| 滑动交互规则 | ✅ 只右滑通过 | + +--- + +## 四、结论与行动 + +### 覆盖度小结 + +``` +主流程(填报→审核→导出) ████████████████████ 100% +规则引擎(命名/压缩/识别) ████████████████████ 100% +配置类页面 ██████░░░░░░░░░░░░░░ 30% +平台运营 ░░░░░░░░░░░░░░░░░░░░ 0% +历史整理器 ░░░░░░░░░░░░░░░░░░░░ 0%(仅库表) +``` + +### 必须行动的 3 项 + +| 优先级 | 行动 | 归属 | +|:--:|---|---| +| ✅ 已完成 | ~~补 10 个缺失接口(3.1)~~ | → **P3 已完成(新增 21 个)** | +| ✅ 已完成 | ~~补**表单设计器**原型(3.2 P1)~~ | → **P2.5 已完成** | +| ✅ 已完成 | ~~补**材料规则配置**原型(3.2 P2)~~ | → **P2.5 已完成** | +| ✅ 已完成 | ~~补 **FR-616**「清晰度分级标记与抽检机制」~~ 及 **VAL-18~20** 三条校验规则 | → 已补入 PRD | + +### 对计划的调整 + +发现了一个原计划未覆盖的缺口,**追加为 P2.5**: + +| # | 任务 | 产出 | 理由 | +|---|---|---|---| +| **P2.5** | **管理端补充原型**:表单设计器 + 材料规则配置 + 整理器 | 补充到 `admin-web.html` | 追溯矩阵发现的缺口,三者都是**复杂交互、不画会理解偏**的页面 | + +--- + +## 附录 · 完整需求索引(115 条) + +| 模块 | 编号范围 | 条数 | 备注 | +|---|---|:--:|---| +| FR-100 | FR-101 ~ 105 | 5 | 全 P0 | +| FR-200 | FR-201 ~ 206 | 6 | 含 FR-202 小程序登录 | +| FR-300 | FR-301 ~ 312 | 12 | 含 FR-307~311 报考范围 | +| FR-400 | FR-401 ~ 408 | 8 | 全为表单引擎 | +| FR-500 | FR-501 ~ 509 | **24** | 含 502a/b、504a~d、**509a~i(客户确认新增 10 条)** | +| FR-600 | FR-601 ~ 616 | 23 | 含 601a/b、614a~e(**编号连续,无跳号**) | +| FR-800 | FR-801 ~ 810 | 10 | 审核 | +| FR-900 | FR-901 ~ 910 | 10 | 导出 | +| FR-1000 | FR-1001 ~ 1006 | 6 | 通知 | +| FR-1100 | FR-1101 ~ 1105 | 5 | 看板 | +| FR-1200 | FR-1201 ~ 1206 | 6 | 系统管理 | +| FR-1300 | FR-1301 ~ 1304 | 4 | 计费 | +| FR-1400 | FR-1401 ~ 1406 | 6 | 整理器 | +| **合计** | | **125** | **92 P0 / 30 P1 / 3 P2** | + +**校验规则**:VAL-01 ~ VAL-21(21 条,编号连续;18~20 为 P4 补充,**21 为客户确认后补充的归属校验**) +**接口**:**121 个已定义**(原 96 + 补齐 21 + 客户确认新增 5 − 客户要求移除 1,见 `03-接口设计.md`) +**库表**:**20 张**完整 DDL + 4 张"从略"标准表 diff --git a/docs/03-接口设计.md b/docs/03-接口设计.md new file mode 100644 index 0000000..eb4ce7a --- /dev/null +++ b/docs/03-接口设计.md @@ -0,0 +1,2880 @@ +# 接口设计说明书 + +> 版本:V1.0 | 日期:2026-09-28 +> 对应主文档:《数据信息填报系统PRD.md》§8 接口清单(V2.3) +> 读者:前后端开发、测试 +> **本文档是 PRD §8 的展开实现层**——§8 定义了「有哪些接口」,本文档定义「每个接口收发什么字段」。 + +--- + +## 目录 + +| 节 | 内容 | +|---|---| +| §1 | 通用约定(域名、鉴权、响应、分页、幂等、限流、脱敏) | +| §2 | 枚举字典全集 | +| §3 | 通用数据结构(Schema 定义) | +| §4 | 接口总览(**121 个**) | +| §5 | 认证与账号(13) | +| §6 | 组织与租户(15) | +| §7 | 任务与配置(30) | +| §8 | 名册(12) | +| §9 | 填报端 · 小程序(16) | +| §10 | 班主任移动端(10) | +| §11 | 审核(9) | +| §12 | 校验 / 导出 / 通知(12) | +| §13 | **本次补齐的缺口接口(21)** | +| §14 | 开放接口 · 供认定机构对接(4) | +| §15 | 错误码全集 | + +--- + +## §1 通用约定 + +### 1.1 域名与环境 + +| 环境 | 域名 | 用途 | +|---|---|---| +| 生产 | `https://api.tianbaotong.cn/api/v1/**` | 正式 | +| 开放接口 | `https://api.tianbaotong.cn/open/v1/**` | 认定机构对接 | +| 测试 | `https://api-test.tianbaotong.cn/api/v1/**` | 联调 | + +### 1.2 鉴权 + +| 端 | 方式 | Header | +|---|---|---| +| PC 管理端 / 班主任 PC | 账号密码 → JWT | `Authorization: Bearer ` | +| 微信小程序(学生 / 班主任) | `code2session` → JWT | `Authorization: Bearer ` | +| 开放接口(机构) | appKey + HMAC-SHA256 | `X-App-Key` / `X-Timestamp` / `X-Nonce` / `X-Sign` | + +**JWT Payload** + +```json +{ + "uid": 10086, + "tenantId": 1001, + "orgIds": [2001, 2002], + "roles": ["SCHOOL_ADMIN"], + "userType": 2, + "exp": 1790000000 +} +``` + +| 字段 | 说明 | +|---|---| +| `uid` | 用户 ID | +| `tenantId` | **租户 ID,服务端强制校验,防越租户** | +| `orgIds` | 数据权限范围(本人 / 本班 / 本院系 / 全租户) | +| `roles` | 角色码数组 | +| `userType` | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生 | + +**Token 有效期**:PC 端 8 小时(可续期)/ 小程序端 7 天(静默续期) + +### 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 次/分钟** | `40007b` | +| 上传凭证 | 200 次/分钟/用户 | `42901` | +| OCR | 30 次/分钟/用户 | `42901` | +| 催报 | 10 次/分钟/任务 | `42901` | +| 导出创建 | 5 次/分钟/租户 | `42901` | + +> **门禁限流是安全设计的一部分**(防身份证号枚举探测),阈值调低是刻意的。上线后按真实人数标定。 + +### 1.7 脱敏规则 + +| 字段 | 明文传入 | 响应返回 | +|---|---|---| +| 身份证号 | ✅ 允许(HTTPS,仅门禁/导入场景) | ❌ **一律返回脱敏值** `idCardMask`,形如 `3610292009******10` | +| 手机号 | ✅ 允许 | ❌ 返回脱敏值 `138****8888` | +| 姓名 | ✅ | ✅ 明文(业务必需) | + +**例外**:`GET /audit/submissions/{id}` 等审核场景,管理员/班主任可见**完整身份证号**(审核必需),但仍**不返回明文**——返回 `idCardFull` 需通过 `GET /audit/submissions/{id}/idcard` 单独获取,该接口**单独记录审计日志**。 + +> 这条设计的意义:审核人员看材料图片时不需要抄身份证号,真需要核验时走单独接口、留痕可追溯。 + +### 1.8 其他约定 + +| 项 | 约定 | +|---|---| +| ID 类型 | `int64`,JSON 中以 number 传输(生成规则保证 < 2^53) | +| 时间 | ISO 8601 带时区,`2026-09-28T14:30:00+08:00`;日期型 `2026-09-28` | +| 体积 | 统一 **KB**(`finalSizeKb`),原始字节用 `rawSizeBytes` | +| 比例 | 字符串百分比 `"97.1%"`(避免前端再乘 100) | +| 空值 | 统一 `null`,不用空字符串 `""` | +| 枚举 | **数字码**(与 DDL `smallint` 对齐),文案由前端映射;**唯一例外:`materialCode` 用字符串码**(可读性优先,见 §2.1) | +| 排序 | 数组顺序即业务顺序,不额外传 `sortNo` 以外的字段 | +| 批量上限 | 单次批量操作 ≤ 200 条;超限返回 `40011` | + +**布尔字段命名**:统一 `isXxx` / `hasXxx` / `needXxx`,**不用 0/1**(JSON 原生 true/false)。 + +--- + +## §2 枚举字典全集 + +> 与数据库 `smallint` 列严格对齐。**「无」是合法业务值**,见 §2.6。 + +### 2.1 材料类型 `materialCode`(字符串码) + +| 编码 | 名称 | 别名(识别用) | 默认压缩档 | +|---|---|---|---| +| `PHOTO1` | 一寸照片 | 一寸电子照片 / 证件照 / 免冠照 / 1寸照片 | P1 | +| `IDCARD` | 身份证 | 身份证正面 / 身份证反面 / 身份证人像面 | P2 | +| `SCHOOL_ROLL` | 学籍表 | 学籍证明 / 在读证明 / 在校证明 | P4 | +| `XJW_BA` | 学信网备案表 | 学信网 / 学历备案表 / 电子注册备案表 | P4 | +| `WORK_PROOF` | **工作证明** | 个人申报表 / 职业技能等级个人申报表 | P3 | +| `OTHER_PROOF` | 其它佐证材料 | 其他 / 补充材料 | P3 | + +> ⚠️ **`WORK_PROOF` 的名称陷阱**:按机构模板,**「职业技能等级个人申报表」的规范文件名是「工作证明」**。这是机构既有习惯,不要"修正"。 + +### 2.2 收集模式 `collectMode` + +| 值 | 含义 | 审核流 | +|:--:|---|---| +| `1` | 学校收集 | 学生 → 班主任初审 → 管理员终审 | +| `2` | 社会收集 | 提交后**直接进管理员终审队列**(跳过初审)。⚠️ **一期不启用** | + +### 2.3 认证类型 `certType` + +| 值 | 含义 | 面向谁 | 特有约束 | +|:--:|---|---|---| +| `1` | 自主认定 | 本单位职工 / 本校学生 | 要**学籍表** | +| `2` | **社会评价** | **社会人员** | 材料为**学信网备案表** + 其它佐证(**无学籍表**)。⚠️ **一期不启用**(零样本材料) | +| `3` | **专项职业能力** | 专项能力考核 | ⚠️ **无工种 / 无等级 / 无补考科目(0 科)**。⚠️ **一期不启用** | + +> ⚠️ **`社会评价`(认定类型)≠ `社会收集`(`collectMode`)**: +> 前者决定**填什么材料**,后者决定**谁审核**(有无班主任初审层)。二者可任意组合。 + +### 2.4 填报单状态 `submissionStatus` + +| 值 | 含义 | 可流转到 | +|:--:|---|---| +| `0` | 草稿 | 1 | +| `1` | 待初审 | 2 / 4 | +| `2` | 待终审 | 3 / 4 | +| `3` | 已通过 | 5 | +| `4` | 已驳回 | 1(学生重提交) | +| `5` | 已归档 | — | + +### 2.5 问题级别 `severity` + +| 值 | 含义 | UI 色 | 阻断提交 | +|:--:|:--:|:--:|:--:| +| `1` | 信息 | 蓝 | 否 | +| `2` | 警告 | 黄 | 否(可 `confirmWarning` 强提) | +| `3` | **错误** | 红 | **是** | + +### 2.6 「无」的语义(三条铁律) + +> 这是最容易实现错的地方,单列说明。 + +| 场景 | 错误做法 | 正确做法 | +|---|---|---| +| 学历为"无" | 存 `null` | 存字符串 `"无"`——**它是合法业务值** | +| 专项职业能力无工种 | 存 `null` | 存 `null` + `certType=3`,**由 `certType` 决定字段是否参与校验** | +| 补考科目为"无" | 存 `null` | 存 `[]` 空数组(**0 科是合法值**) | + +**判定原则**:**「用户选择了无」与「用户没填」必须能区分**。前者是有效数据,后者是缺失。 + +### 2.7 其他枚举 + +| 枚举 | 取值 | +|---|---| +| `userType` | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生 | +| `roleCode` | `PLATFORM_ADMIN` / `SCHOOL_ADMIN` / `HEAD_TEACHER` / `DEPUTY_TEACHER` / `STUDENT` / `AUDITOR` | +| `taskStatus` | `0` 草稿 / `1` 已发布 / `2` **已暂停** / `3` 已截止 / `4` 已归档 | +| `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 | +| `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 | +| `auditNode` | `1` 初审 / `2` 终审 | +| `auditAction` | `1` 通过 / `2` 驳回 / `3` **撤回** / `4` 代传 | +| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 | +| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 | +| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 | +| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` 导出完成 / `6` 催报 / `7` 加入名单审批 | +| `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 | +| `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 | +| `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 | + +--- + +## §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 天)** | + +**`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`(含 `3` **撤回**) | +| `reasonCode` | string | 驳回原因码(见 PRD 附录 C) | +| `reasonText` | string | 手填原因 | +| `materialCodes` | string[] | 关联材料 | +| `durationSec` | int | 从提交到本次操作的耗时 | +| `createTime` | string | — | + +### 3.11 `CompressConfigVO` 压缩配置 + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `defaultMaxSizeKb` | int | `1024` | 全局体积上限(**D1 决策:无例外**) | +| `defaultTargetDpi` | int | `300` | A4 扫描件目标 DPI | +| `defaultQuality` | int | `85` | 默认质量 | +| `minQuality` | int | `70` | 质量下限(低于此值标记"强制压缩") | +| `hardFloorDpi` | int | `200` | DPI 硬下限 | +| `maxPixels` | int | `50000000` | 超大图熔断阈值(5000 万像素) | +| `keepPngForScreenshot` | bool | `true` | **截图类保持 PNG 不转 JPG** | +| `markWhenForced` | bool | `true` | 强制压缩时打标记进抽检清单 | + +### 3.12 其余对象(简表) + +| 对象 | 字段 | +|---|---| +| `OrgVO` | `id` `parentId` `orgName` `orgType` `orgCode` `ancestorPath` `leaderUserId` `leaderName` `sortNo` `status` `children[]` `studentCount` | +| `TenantVO` | `id` `tenantCode` `tenantName` `tenantType` `packageId` `packageName` `expireTime` `status` `logoUrl` `configJson` `usage{ studentCount, storageMb, ocrCount, smsCount }` | +| `UserVO` | `id` `username` `realName` `phone` `orgId` `orgName` `roleCodes[]` `userType` `status` `lastLoginTime` | +| `ExportTaskVO` | `id` `taskId` `exportName` `scopeJson` `splitSizeMb` `desensitize` `status` `progress` `fileCount` `fileSizeMb` `resultFiles[]{ name, sizeMb, url }` `errorMsg` `expireTime` `finishTime` | +| `NotifyRecordVO` | `id` `taskId` `channel` `bizType` `receiverId` `receiverName` `receiverRole` `templateCode` `content` `status` `failReason` `sendTime` | +| `CleanupJobVO` | `id` `jobName` `sourceType` `sourcePath` `targetTaskId` `status` `progress` `totalFiles` `resultPath` `reportJson` `finishTime` | +| `DictItemVO` | `id` `dictCode` `dictName` `itemCode` `itemName` `sortNo` `parentItemCode` `status` `builtin` | +| `MessageVO` | `id` `bizType` `title` `content` `read` `linkType` `linkId` `createTime` | + +--- + +## §4 接口总览 + +> 合计 **121 个**:PRD §8 已定义 96 个 + 追溯矩阵补齐 **21 个** + 客户确认新增 **5 个**,减去客户要求移除的联系方式接口 1 个(标 ⭐)。补齐项见 §4.9 索引,客户确认新增见 §8.7。 +> 「权限」列:`公开` / `学生` / `教师` / `管理` / `运营`(平台方)。 +> 「修补」列指该接口是补齐追溯矩阵审计发现的缺口。 + +| 分类 | 接口数 | +|---|:--:| +| 认证与账号 | 13 | +| 组织与租户 | 15 | +| 任务与配置 | 30 | +| 名册 | 12 | +| 填报端 · 小程序 | 16 | +| 班主任移动端 | 10 | +| 审核 | 9 | +| 校验 / 导出 / 通知 | 12 | +| 开放接口 | 4 | +| **合计** | **121** | + +### 4.1 认证与账号(13) + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| POST | `/auth/login` | PC 账号密码登录 | 公开 | +| POST | `/auth/logout` | 退出 | 全部 | +| GET | `/auth/me` | 当前用户 + 权限菜单 | 全部 | +| PUT | `/auth/password` | 修改密码 | 全部 | +| POST | `/mp/login` | 小程序 code 换 session | 公开 | +| POST | `/mp/bind-phone` | 微信手机号快捷绑定 | 学生/教师 | +| POST | `/mp/roster/match` | **门禁核验** | 学生 | +| GET | `/users` | 用户列表 | 管理 | +| POST | `/users` | 新增用户 | 管理 | +| PUT | `/users/{id}` | 修改用户 | 管理 | +| DELETE | `/users/{id}` | 停用用户 | 管理 | +| POST | `/users/import` | 批量导入 | 管理 | +| POST | `/users/{id}/reset-password` | 重置密码 | 管理 | + +### 4.2 组织与租户(15) + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/orgs/tree` | 组织树 | 管理 | +| POST | `/orgs` | 新增组织 | 管理 | +| PUT | `/orgs/{id}` | 修改 | 管理 | +| DELETE | `/orgs/{id}` | 删除 | 管理 | +| POST | `/orgs/import` | 批量导入 | 管理 | +| GET | `/tenants` | 租户列表 | 运营 | +| POST | `/tenants` | 开通租户 | 运营 | +| PUT | `/tenants/{id}` | 修改租户/套餐 | 运营 | +| GET | `/tenants/{id}/usage` | 用量统计 | 运营 | +| ⭐ GET | `/dicts` | **字典列表** | 管理 | +| ⭐ GET | `/dicts/{code}/items` | **字典项** | 管理 | +| ⭐ POST | `/dicts/{code}/items` | **新增字典项** | 管理 | +| ⭐ PUT | `/dicts/items/{id}` | **修改字典项** | 管理 | +| ⭐ GET | `/logs/operations` | **操作日志** | 管理 | +| ⭐ GET | `/logs/logins` | **登录日志** | 管理 | + +### 4.3 任务与配置(30) + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/tasks` | 任务列表 | 管理 | +| POST | `/tasks` | 创建任务 | 管理 | +| GET | `/tasks/{id}` | 任务详情 | 管理/教师 | +| PUT | `/tasks/{id}` | 修改任务 | 管理 | +| DELETE | `/tasks/{id}` | 删除(仅草稿) | 管理 | +| POST | `/tasks/{id}/publish` | 发布 | 管理 | +| POST | `/tasks/{id}/close` | 截止 | 管理 | +| POST | `/tasks/{id}/archive` | 归档 | 管理 | +| ⭐ POST | `/tasks/{id}/pause` | **暂停** | 管理 | +| ⭐ POST | `/tasks/{id}/resume` | **恢复** | 管理 | +| POST | `/tasks/{id}/clone` | 克隆 | 管理 | +| GET | `/tasks/{id}/orgs` | 覆盖班级与班主任 | 管理 | +| PUT | `/tasks/{id}/orgs` | 设置覆盖班级/班主任 | 管理 | +| ⭐ GET | `/tasks/{id}/plan-scope` | **报考范围读取** | 管理 | +| ⭐ PUT | `/tasks/{id}/plan-scope` | **报考范围确认** | 管理 | +| GET | `/tasks/{id}/form-schema` | 表单定义 | 管理 | +| PUT | `/tasks/{id}/form-schema` | 保存表单(版本化) | 管理 | +| GET | `/tasks/{id}/materials` | 材料项列表 | 管理 | +| POST | `/tasks/{id}/materials` | 新增材料项 | 管理 | +| PUT | `/materials/{id}` | 修改材料项 | 管理 | +| DELETE | `/materials/{id}` | 删除材料项 | 管理 | +| GET | `/tasks/{id}/statistics` | 任务统计 | 管理/教师 | +| GET | `/tasks/{id}/qrcode` | 填报二维码 | 管理 | +| GET | `/rule-templates` | 规则模板列表 | 管理 | +| POST | `/rule-templates/{id}/apply` | 应用模板 | 管理 | +| ⭐ POST | `/cleanup/jobs` | **创建整理任务** | 管理 | +| ⭐ GET | `/cleanup/jobs` | **整理任务列表** | 管理 | +| ⭐ GET | `/cleanup/jobs/{id}` | **整理进度** | 管理 | +| ⭐ GET | `/cleanup/jobs/{id}/report` | **体检报告** | 管理 | +| ⭐ POST | `/cleanup/jobs/{id}/import` | **结果导入为正式任务** | 管理 | + +### 4.4 名册(12) + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| POST | `/tasks/{id}/roster/import` | 导入(返回体检报告) | 管理 | +| GET | `/tasks/{id}/roster` | 名册分页 | 管理/教师 | +| PUT | `/roster/{rid}` | 修改名册项 | 管理 | +| DELETE | `/roster/{rid}` | 删除名册项 | 管理 | +| GET | `/tasks/{id}/roster/template` | 下载导入模板 | 管理 | +| POST | `/roster/{rid}/notify` | 定向催报 | 管理/教师 | +| POST | `/tasks/{id}/roster/notify-unsubmitted` | 一键催报未提交者 | 管理/教师 | +| ⭐ POST | `/mp/roster/join-request` | **提交加入申请** | 学生 | +| ⭐ GET | `/mp/roster/join-request` | **我的申请状态** | 学生 | +| ⭐ GET | `/roster/join-requests` | **申请待办列表** | 管理 | +| ⭐ POST | `/roster/join-requests/{id}/approve` | **审批通过** | 管理 | +| ⭐ POST | `/roster/join-requests/{id}/reject` | **审批驳回** | 管理 | + +### 4.5 填报端 · 小程序(16) + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/mp/tasks` | 我的任务列表 | 学生 | +| GET | `/mp/tasks/{id}/form` | 表单结构 + 已填数据 | 学生 | +| POST | `/mp/submissions` | 创建/获取草稿 | 学生 | +| PUT | `/mp/submissions/{id}` | 保存草稿 | 学生 | +| POST | `/mp/submissions/{id}/submit` | 提交 | 学生 | +| POST | `/mp/submissions/{id}/withdraw` | 撤回(未审核前) | 学生 | +| GET | `/mp/submissions/{id}/progress` | 进度与问题清单 | 学生 | +| POST | `/mp/files/upload-token` | OSS 直传凭证 | 学生 | +| ⭐ POST | `/mp/files/{fileId}/process` | **文件规范化(命名+压缩+识别)** | 学生 | +| POST | `/mp/files/batch-process` | 批量处理(多材料) | 学生 | +| GET | `/mp/files/{fileId}` | 预览签名 URL | 学生/教师 | +| DELETE | `/mp/files/{fileId}` | 删除材料(软删) | 学生 | +| POST | `/mp/ocr/idcard` | 身份证 OCR | 学生 | +| ⭐ GET | `/mp/messages` | **消息中心** | 学生 | +| ⭐ POST | `/mp/messages/read` | **标记已读** | 学生 | +| ⭐ POST | `/mp/subscribe` | **订阅消息额度登记** | 学生 | + +> 注:本表 16 行,其中 `/mp/files/{fileId}/process` 与 `/mp/files/batch-process` 在 PRD §8.6 中分别登记于 §8.6 与 §8.9,此处合并列出。 + +### 4.6 班主任移动端(10) + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/mp/teacher/dashboard` | 班级看板 | 教师 | +| GET | `/mp/teacher/audit/todos` | 待审列表(卡片流) | 教师 | +| GET | `/mp/teacher/audit/{submissionId}` | 学生详情 | 教师 | +| POST | `/mp/teacher/audit/{submissionId}/approve` | 通过 | 教师 | +| POST | `/mp/teacher/audit/{submissionId}/reject` | 驳回 | 教师 | +| POST | `/mp/teacher/audit/batch-approve` | **一键通过全部绿色项** | 教师 | +| POST | `/mp/teacher/proxy-upload` | 代传材料 | 教师 | +| POST | `/mp/teacher/notify/unsubmitted` | 一键催报 | 教师 | +| GET | `/mp/teacher/notify/records` | 催报历史 | 教师 | +| ⭐ GET | `/mp/teacher/messages` | **教师消息中心** | 教师 | + +### 4.7 审核(9) + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/audit/todos` | 待办列表(红黄绿) | 教师/管理 | +| GET | `/audit/submissions/{id}` | 填报单详情 | 教师/管理 | +| POST | `/audit/submissions/{id}/approve` | 通过 | 教师/管理 | +| POST | `/audit/submissions/{id}/reject` | 驳回 | 教师/管理 | +| ⭐ POST | `/audit/submissions/{id}/revoke` | **撤回已审** | 教师/管理 | +| GET | `/audit/submissions/{id}/timeline` | 审核时间轴 | 教师/管理 | +| GET | `/audit/statistics` | 审核统计 | 教师/管理 | +| GET | `/audit/reason-codes` | 驳回原因字典 | 教师/管理 | +| POST | `/audit/submissions/{id}/proxy-upload` | 代传材料 | 教师/管理 | + +### 4.8 校验 / 导出 / 通知(12) + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| POST | `/validation/run` | 触发任务级全量校验 | 管理 | +| GET | `/validation/issues` | 问题清单 | 教师/管理 | +| GET | `/validation/issues/export` | 导出异常清单 Excel | 管理 | +| POST | `/exports` | 创建导出任务 | 管理 | +| GET | `/exports` | 导出历史 | 管理 | +| GET | `/exports/{id}` | 导出进度 | 管理 | +| GET | `/exports/{id}/download` | 下载签名 URL | 管理 | +| GET | `/notify/templates` | 通知模板 | 管理 | +| PUT | `/notify/templates/{id}` | 修改模板 | 管理 | +| POST | `/notify/send` | 手动发送通知 | 管理/教师 | +| GET | `/notify/records` | 通知记录 | 管理 | +| POST | `/callback/wechat/msg` | 微信订阅消息回调 | 公开 | + +### 4.9 ⭐ 本次新增接口索引(21) + +> 本表是**索引**,不是额外接口——其中 5 个已在上方 §4.3 / §4.7 列出,**不计入总数**。 +> 表内 18 行覆盖 **21 个接口**(含 GET/PUT、GET/POST 合并行)。 + +| | 路径 | 说明 | 详情节 | +|---|---|---|---| +| ⭐ | `/tasks/{id}/plan-scope` | 报考范围读取 / 确认(GET / PUT) | §7.7 / §7.8 | +| ⭐ | `/tasks/{id}/pause` `/resume` | 暂停 / 恢复任务 | §7.4 | +| ⭐ | `/audit/submissions/{id}/revoke` | 撤回已审 | §11.4 | +| ⭐ | `/mp/files/{fileId}/process` | **文件规范化总入口**(原仅在 §8.9 详述,未登记进清单) | §9.6 | +| ⭐ | `/cleanup/jobs` | 创建整理任务 | §13.2 | +| ⭐ | `/cleanup/jobs` | 整理任务列表 | §13.2 | +| ⭐ | `/cleanup/jobs/{id}` | 整理进度 | §13.2 | +| ⭐ | `/cleanup/jobs/{id}/report` | 体检报告 | §13.2 | +| ⭐ | `/cleanup/jobs/{id}/import` | 结果导入为正式任务 | §13.2 | +| ⭐ | `/dicts` | 字典列表 | §13.3 | +| ⭐ | `/dicts/{code}/items` | 字典项(GET / POST) | §13.3 | +| ⭐ | `/dicts/items/{id}` | 修改字典项 | §13.3 | +| ⭐ | `/logs/operations` | 操作日志 | §13.4 | +| ⭐ | `/logs/logins` | 登录日志 | §13.4 | +| ⭐ | `/mp/messages` | 学生消息中心 | §13.5 | +| ⭐ | `/mp/messages/read` | 标记已读 | §13.5 | +| ⭐ | `/mp/teacher/messages` | 教师消息中心 | §13.5 | +| ⭐ | `/mp/subscribe` | 订阅消息额度登记 | §13.6 | + +**新增 21 个的分布** + +| 归属 | 数量 | 明细 | +|---|:--:|---| +| 报考范围 | 2 | plan-scope GET / PUT | +| 任务状态 | 2 | pause / resume | +| 审核 | 1 | revoke | +| 文件处理 | 1 | process(登记遗漏) | +| 历史整理器 | 5 | cleanup 全套 | +| 字典 | 4 | dicts | +| 日志 | 2 | operations / logins | +| 消息中心 | 3 | 学生 2 + 教师 1 | +| 订阅额度 | 1 | subscribe | + +### 4.10 开放接口(4) + +| | 路径 | 说明 | +|---|---|---| +| POST | `/open/v1/auth/token` | appKey/secret 换 token | +| GET | `/open/v1/tasks/{id}/submissions` | 拉取已终审通过的数据 | +| GET | `/open/v1/tasks/{id}/files` | 批量获取文件签名 URL | +| POST | `/open/v1/callback/push` | 推送审核结果到机构系统 | + +### 4.11 ⭐ 客户确认后新增(5) + +> 来源:确认函 **B1**(客户确认采用"申请加入名单 → 管理员审批"通道)。这是本次唯一的功能增量。 +> 详述见 **§8.7**。 + +| | 路径 | 说明 | 权限 | +|---|---|---|---| +| ⭐ | `/mp/roster/join-request` | 提交加入申请 / 查看我的申请(GET / POST) | 学生 | +| ⭐ | `/roster/join-requests` | 申请待办列表 | 管理 | +| ⭐ | `/roster/join-requests/{id}/approve` | 审批通过(自动建名册项 + 自动绑定) | 管理 | +| ⭐ | `/roster/join-requests/{id}/reject` | 审批驳回(原因必填) | 管理 | + +**设计边界(三条)** + +| # | 边界 | 理由 | +|:--:|---|---| +| 1 | **只解决"进名单",不绕过门禁** | 申请期间仍不能填报;审批通过后学生**仍需重新核验**(或凭已绑定 openid 直接放行) | +| 2 | **证件号仅存 HMAC,不落明文** | 与门禁口径一致;申请记录保留 2 年 | +| 3 | **驳回必须逐条填原因;批量通过不提供"全选"** | 身份认定环节误操作代价极高,与"批量通过只带绿色项"同源 | + +**与现有机制的关系** + +| 机制 | 区别 | +|---|---| +| `FR-508` 名册追加导入 | 老师批量加人;申请通道是**学生自助 + 逐条审批** | +| `FR-504` 统一失败提示 | 申请通道**不破坏**统一提示——`40039`(已在名单中)不泄露新信息 | +| `FR-504d` 已被他人绑定 | 申请通过后若该证件号已被占用 → `40041`,不覆盖 | + +--- + +## §5 认证与账号 + +### 5.1 `POST /api/v1/auth/login` + +**权限**:公开 **限流**:5 次/分钟/IP + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `username` | string | ✅ | 账号 | +| `password` | string | ✅ | 密码(前端 RSA 加密后传输) | +| `captchaKey` | string | 条件 | 图形验证码 key(连续失败 2 次后必填) | +| `captchaCode` | string | 条件 | 验证码 | + +**响应** + +```json +{ + "code": 0, + "data": { + "token": "eyJhbGciOi...", + "expireSeconds": 28800, + "user": { "id": 10086, "realName": "王老师", "userType": 2, "avatarUrl": null }, + "tenant": { "id": 1001, "tenantName": "江西机电职业技术学院", "logoUrl": "..." }, + "roleCodes": ["SCHOOL_ADMIN"], + "menus": ["dashboard", "task", "roster", "audit", "export"] + } +} +``` + +**主要错误**:`40012` 账号或密码错误 / `40013` 账号锁定 / `42901` 限流 + +### 5.2 `GET /api/v1/auth/me` + +**响应**:同 5.1 的 `user` + `tenant` + `roleCodes` + `menus` + `permissions[]`(细粒度权限码)。 + +### 5.3 `POST /api/v1/mp/login` + +**权限**:公开 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `code` | string | ✅ | `wx.login` 返回的 code | +| `scene` | string | — | 扫码进入时的小程序码参数(**含租户与任务标识**) | + +**响应** + +```json +{ + "code": 0, + "data": { + "token": "eyJ...", + "expireSeconds": 604800, + "needBindPhone": false, + "user": { "id": 20001, "realName": "曾伊豪", "userType": 4 }, + "boundRoster": { "taskId": 202609, "taskName": "2026年9月机电认定", "rosterId": 9001 } + } +} +``` + +> `boundRoster` 用于**已绑定学生的免门禁直入**:门禁只需过一次,绑定关系长期有效。 + +### 5.4 `POST /api/v1/mp/roster/match` ⭐ 门禁核验(核心接口) + +**权限**:学生 **限流**:同 openid 10 次/小时;同 IP 20 次/分钟 **幂等**:是 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `realName` | string | ✅ | 姓名 | +| `idCardNo` | string | ✅ | 身份证号 | +| `taskId` | int64 | ✅ | 任务 ID | + +**匹配逻辑**(服务端) + +``` +1. 限流检查 → 超限返回 40007b +2. 规范化:姓名去空格、全角转半角;证件号转大写 +3. 按 (task_id, id_card_hash) 定位名册项 + ├─ 未命中 → 记录失败尝试(不记明文) → 40007 统一提示 + └─ 命中 → 比对姓名 + ├─ 完全一致 → 通过 + ├─ 形近字/同音字 → 返回候选让用户确认(FR-504c) + └─ 不一致 → 40007 统一提示 +4. 检查绑定状态 + ├─ 已被他人绑定 → 40007c + └─ 未绑定 → 写入 bind_user_id / bind_time / bind_type +``` + +**成功响应** + +```json +{ + "code": 0, + "data": { + "matched": true, + "rosterId": 9001, + "realName": "曾伊豪", + "idCardMask": "3610292009******10", + "orgName": "24中技数控1班", + "prefill": { + "education": "中等职业技术学校", + "isStudent": true, + "school": "江西机电职业技术学院", + "major": "数控加工", + "enrollDate": "2024-09-01", + "phone": "138****8888", + "occupation": "车工", + "worktype": "数控车工", + "jobLevel": "四级" + }, + "needConfirmName": false + } +} +``` + +**姓名形近字确认响应**(FR-504c) + +```json +{ + "code": 0, + "data": { + "matched": false, + "needConfirmName": true, + "candidates": [ { "rosterId": 9001, "realName": "曾伊豪", "similarity": 0.92 } ], + "confirmToken": "tmp_8f3a...", + "tip": "你是不是「曾伊豪」?" + } +} +``` + +**失败统一响应**(`40007`)——**不区分是姓名错还是号码错** + +```json +{ + "code": 40007, + "msg": "没有找到你的信息,请核对后重试", + "data": { "matched": false, "remainAttempts": 7, "contactTeacher": true } +} +``` + +> **安全要求**:`remainAttempts` 只报剩余次数,**不透露任何关于名单的信息**;失败尝试**不落明文证件号**(只落 hash 与时间)。 + +### 5.5 ~~`GET /api/v1/mp/roster/contact`~~ 【已移除】 + +> ❌ **本接口已按客户要求移除**(2026-09-28)。 +> +> **原设计**:门禁失败页展示班主任/管理员的脱敏手机号,学生可一键拨打。 +> +> **移除理由**:学生在班级群里**本来就有班主任的联系方式**,系统内再放一份: +> ① 不必要——用户已经知道怎么找人; +> ② 有维护成本——班主任换人、号码变更都要同步; +> ③ 多一处隐私暴露面——即使是脱敏号,也意味着系统里要多存一份联系方式数据。 +> +> **替代**:门禁失败页只保留两条**系统内的动作**——「重新填写」与「申请加入名单」(FR-509)。 +> 纯文案提示(如"如有疑问请联系老师")保留,因为**不涉及任何联系方式数据**。 + +### 5.6 `GET /api/v1/users` + +**Query**:`keyword`(姓名/账号/手机号模糊)、`orgId`、`roleCode`、`userType`、`status`、分页参数 + +**响应**:`{ list: UserVO[], total }` + +### 5.7 `POST /api/v1/users` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `username` | string | ✅ | 登录账号,租户内唯一 | +| `realName` | string | ✅ | 姓名 | +| `phone` | string | ✅ | 手机号 | +| `orgId` | int64 | — | 所属组织 | +| `roleCodes` | string[] | ✅ | 至少 1 个 | +| `userType` | int | ✅ | 见 §2.7 | + +**响应**:`{ "id": 10087, "initialPassword": "Ab3#k9mP" }`(初始密码仅此一次返回) + +### 5.8 `POST /api/v1/users/import` + +**Content-Type**:`multipart/form-data` **字段**:`file`(Excel,≤10MB) + +**响应**(导入体检报告,与名册导入同构) + +```json +{ + "code": 0, + "data": { + "total": 42, "success": 40, "failed": 2, + "headerRow": 1, + "errors": [ + { "row": 17, "field": "phone", "value": "1380013", "reason": "手机号格式不正确" }, + { "row": 33, "field": "username", "value": "lisi", "reason": "账号已存在" } + ], + "initialPasswords": [ + { "username": "zhangsan", "password": "Xy7$mQ2p" } + ] + } +} +``` + +> ⚠️ **导入类接口一律返回「体检报告」结构**(`total/success/failed/headerRow/errors[]`)。`headerRow` 必须回传——实测两份真实文件的表头分别在**第 1 行和第 6 行**,界面要把它可视化出来让用户确认。 + +### 5.9 其余账号接口 + +| 接口 | 关键字段 | 备注 | +|---|---|---| +| `PUT /users/{id}` | 同 5.7,`username` 不可改 | — | +| `DELETE /users/{id}` | — | **软删**(`deleted=1`);有在办任务时返回 `40014` 并提示先移交 | +| `POST /users/{id}/reset-password` | `{ "mode": "GENERATE" }` 或 `{ "newPassword": "..." }` | 返回 `{ newPassword }` | +| `PUT /auth/password` | `{ oldPassword, newPassword }` | 强制改密场景 `oldPassword` 可空 | +| `POST /auth/logout` | — | 服务端把 token 加入黑名单,TTL = 剩余有效期 | +| `POST /mp/bind-phone` | `{ code }`(`getPhoneNumber` 返回) | 写入 `phone_hash`,用于门禁二次校验 | + +--- + +## §6 组织与租户 + +### 6.1 `GET /api/v1/orgs/tree` + +**Query**:`withCount`(bool,是否带学生数)、`rootId` + +**响应** + +```json +{ + "code": 0, + "data": [ + { + "id": 2000, "orgName": "江西机电职业技术学院", "orgType": 1, "orgCode": "JXJD", + "leaderUserId": null, "sortNo": 1, "status": 1, "studentCount": 1240, + "children": [ + { "id": 2001, "orgName": "24中技数控1班", "orgType": 3, "orgCode": "24ZK1", + "leaderUserId": 10087, "leaderName": "李老师", "sortNo": 1, "status": 1, + "studentCount": 28, "children": [] } + ] + } + ] +} +``` + +| `orgType` | 含义 | +|:--:|---| +| `1` | 学校 | +| `2` | 院系 / 专业 | +| `3` | 班级 | + +### 6.2 `POST /api/v1/orgs` / `PUT /api/v1/orgs/{id}` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `parentId` | int64 | — | 顶级为空 | +| `orgName` | string | ✅ | — | +| `orgType` | int | ✅ | `1`/`2`/`3` | +| `orgCode` | string | ✅ | 租户内唯一 | +| `leaderUserId` | int64 | — | 负责人 | +| `sortNo` | int | — | 默认 0 | + +> `ancestor_path` 由服务端维护(如 `/2000/2010/2001/`),用于**按院系统计**,客户端不要传。 + +### 6.3 `DELETE /api/v1/orgs/{id}` + +**约束**:有下级组织、有名册项、有进行中任务时**拒绝**,返回 `40015` 并给出占用清单。 + +### 6.4 `POST /api/v1/orgs/import` + +**Content-Type**:`multipart/form-data` **字段**:`file` + +Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手机号`。返回同 5.8 的体检报告结构。 + +### 6.5 `GET /api/v1/tenants` + +**权限**:运营 **Query**:`keyword`、`status`、`expireBefore`、分页 + +**响应**:`{ list: TenantVO[], total }` + +### 6.6 `POST /api/v1/tenants` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `tenantName` | string | ✅ | — | +| `tenantCode` | string | ✅ | 全局唯一,如 `jxjd` | +| `tenantType` | int | ✅ | `1` 学校 / `2` 机构 / `3` 企业 | +| `contactName` / `contactPhone` | string | ✅ | 联系人 | +| `packageId` | int64 | ✅ | 套餐 | +| `expireTime` | string | ✅ | 到期时间 | +| `adminUsername` / `adminRealName` / `adminPhone` | string | ✅ | **同时创建首个管理员** | + +**响应**:`{ "id": 1002, "adminInitialPassword": "Kp9#vR4t", "miniProgramScene": "t1002" }` + +### 6.7 `GET /api/v1/tenants/{id}/usage` + +**Query**:`period`(`2026-09` 或 `2026Q3`) + +```json +{ + "code": 0, + "data": { + "studentCount": 1240, "storageMb": 8420, "ocrCount": 3105, "smsCount": 420, + "quota": { "studentLimit": 2000, "storageLimitMb": 20480, "ocrLimit": 10000, "smsLimit": 2000 }, + "alerts": [ { "type": "STORAGE", "level": 1, "ratio": "41.1%" } ] + } +} +``` + +--- + +## §7 任务与配置 + +### 7.1 `GET /api/v1/tasks` + +**Query**:`keyword`、`status`、`collectMode`、`batchNo`、`orgId`、分页 + +**权限过滤**:管理员看全租户;班主任**只看自己带的班级的任务**(由 `collect_task_org.head_user_id` 决定)。 + +**响应**:`{ list: TaskVO[], total }`(含 `stat` 快照) + +### 7.2 `POST /api/v1/tasks` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `taskName` | string | ✅ | — | +| `collectMode` | int | ✅ | `1` 学校 / `2` 社会(**决定审核流,发布后不可改**) | +| `batchNo` | string | ✅ | 如 `202609` | +| `majorName` | string | ✅ | — | +| `certType` | int | ✅ | 见 §2.3 | +| `startTime` / `endTime` | string | ✅ | — | +| `auditConfig` | object | — | 缺省按 `collectMode` 取默认值 | +| `namingTemplate` | string | — | 缺省 `{姓名}-{证件号}-{材料名}{后缀}` | +| `compressConfig` | object | — | 缺省见 3.11 | +| `formTemplateId` | int64 | — | 从表单模板库初始化 | +| `materialTemplateId` | int64 | — | 从材料模板初始化 | + +**响应**:`{ "id": 2026090001, "taskCode": "2026090001", "status": 0 }` + +> **社会收集的审核配置默认值**(二期):`needClassAudit: false`(跳过初审),由服务端强制设置,客户端传 true 会被忽略并返回 `warnings`。 +> ⚠️ **一期只支持 `collectMode=1`(学校收集)**。传 `2` 会被拒绝并返回 `40044`。 + +### 7.3 `POST /api/v1/tasks/{id}/publish` + +**前置校验**(FR-507)——**任一不通过则阻断发布** + +| 检查 | 不通过时的响应 | +|---|---| +| 名册人数 > 0 | `40016` 名册为空 | +| 名册含「姓名」「身份证号」列 | `40017` | +| 至少 1 项必传材料 | `40018` | +| 已设置覆盖班级与班主任 | `40019` | +| **命名规则试跑通过** | `40020` 附试跑失败样例 | + +**请求**:`{ "force": false, "confirmWarnings": [] }` + +**响应**:`{ "status": 1, "publishedTime": "2026-09-28T10:00:00+08:00", "qrcodeUrl": "..." }` + +> **`force` 只对 P1 级警告有效**(如"某班的班主任未指定");上述 5 项**全部为阻断级,`force` 无法绕过**。 + +### 7.4 ⭐ `POST /api/v1/tasks/{id}/pause` · `/resume` + +**权限**:管理 **幂等**:是 + +**暂停语义**(与"截止"的区别): + +| 操作 | 学生可提交 | 已提交的审核 | 恢复 | 典型场景 | +|---|---|---|---|---| +| **暂停** | ❌(提示"任务已暂停") | ✅ **继续可审** | ✅ 可恢复 | 名单要改、规则要调,先冻住入口 | +| **截止** | ❌ | ✅ 可审 | ❌ 不可逆 | 时间到了 | +| **归档** | ❌ | ❌ 只读 | ❌ 不可逆 | 结束,进历史 | + +**请求**:`{ "reason": "名册需补充 3 人" }`(可选) + +**响应**:`{ "status": 2, "pausedTime": "..." }` + +**错误**:`40021` 仅已发布任务可暂停 / `40022` 仅已暂停任务可恢复 + +### 7.5 `POST /api/v1/tasks/{id}/close` · `/archive` · `/clone` + +| 接口 | 请求 | 响应 | 约束 | +|---|---|---|---| +| `/close` | `{ "force": false }` | `{ status: 3 }` | 未提交者仍可被催报;`force` 可跳过"有未审单"警告 | +| `/archive` | `{ "archiveRemark": "..." }` | `{ status: 4 }` | **要求无待审单**,否则 `40023` | +| `/clone` | `{ "newTaskName": "...", "newBatchNo": "202703", "copyRoster": true, "copyFormSchema": true, "copyMaterials": true, "copyOrgs": true }` | `{ newTaskId, newTaskCode }` | 名册复制时**清空绑定与提交状态** | + +### 7.6 `GET/PUT /api/v1/tasks/{id}/orgs` + +**GET 响应** + +```json +{ + "code": 0, + "data": [ + { "orgId": 2001, "orgName": "24中技数控1班", "headUserId": 10087, "headUserName": "李老师", + "deputyUserIds": [10088], "deputyUserNames": ["张老师"], "expectedCount": 28, "status": 1 } + ] +} +``` + +**PUT 请求**:`{ "orgs": [ { "orgId": 2001, "headUserId": 10087, "deputyUserIds": [10088], "expectedCount": 28 } ] }` + +> 一个班主任可带多个班;一个班可配**主/副**班主任(FR-204)。`expectedCount` 用于与名册实际人数比对,不一致时在任务详情告警。 + +### 7.7 ⭐ `GET /api/v1/tasks/{id}/plan-scope` 报考范围 + +**背景**:**不存在独立的"计划清单"文件**。报考范围由**名册聚合**得出。 + +**响应** + +```json +{ + "code": 0, + "data": { + "source": "ROSTER_AGGREGATED", + "updatedAt": "2026-09-28T10:00:00+08:00", + "rosterCount": 28, + "groups": [ + { + "occupation": "车工", "worktype": "数控车工", "jobLevel": "四级", + "certType": 1, "count": 26, + "rosterIds": [9001, 9002, "..."], + "status": "CONFIRMED" + }, + { + "occupation": "铣工", "worktype": "数控铣工", "jobLevel": "四级", + "certType": 1, "count": 1, "rosterIds": [9027], "status": "CONFIRMED" + }, + { + "occupation": null, "worktype": null, "jobLevel": null, + "certType": 3, "count": 1, "rosterIds": [9028], + "status": "NEED_REVIEW", + "issue": "专项职业能力无工种/无等级,已自动识别" + } + ], + "healthCheck": { + "passed": false, + "issues": [ + { "level": 2, "code": "SCOPE_LEVEL_MIXED", + "title": "同一职业出现多个等级", + "detail": "「车工」下同时存在 四级(26人) 与 三级(1人),请确认是否应为两个任务", + "rosterIds": [9012] }, + { "level": 1, "code": "SCOPE_UNFILLED", + "title": "1 人未填报考项", + "detail": "序号 28 曾某某 的报考职业为空,将无法导出", + "rosterIds": [9028] } + ] + } + } +} +``` + +**`groups[].status`** + +| 值 | 含义 | +|---|---| +| `CONFIRMED` | 已确认 | +| `NEED_REVIEW` | 需人工确认(有异常) | +| `EXCLUDED` | 已排除(不参与本次认定) | + +**`healthCheck.issues[].code` 清单** + +| code | 级别 | 含义 | +|---|:--:|---| +| `SCOPE_LEVEL_MIXED` | 2 | 同职业多等级 | +| `SCOPE_UNFILLED` | 1 | 报考项为空 | +| `SCOPE_FREE_TEXT` | 1 | 报考职业/工种为自由填写,与字典不完全一致(**Excel 模板本来就没有下拉**) | +| `SCOPE_TYPEO_MISMATCH` | 2 | `certType` 与是否有工种/等级矛盾 | +| `SCOPE_OUT_OF_TASK` | 3 | 出现任务专业范围外的职业 | + +### 7.8 ⭐ `PUT /api/v1/tasks/{id}/plan-scope` 报考范围确认 + +**请求** + +```json +{ + "groups": [ + { "occupation": "车工", "worktype": "数控车工", "jobLevel": "四级", + "certType": 1, "rosterIds": [9001, 9002], "status": "CONFIRMED" }, + { "occupation": null, "worktype": null, "jobLevel": null, + "certType": 3, "rosterIds": [9028], "status": "EXCLUDED" } + ], + "ignoredIssueCodes": ["SCOPE_FREE_TEXT"], + "remark": "9028 待补报考信息,本轮先排除" +} +``` + +**响应**:`{ "updatedAt": "...", "healthCheck": { "passed": true, "issues": [] } }` + +**校验**:`rosterIds` 的并集必须**恰好覆盖**名册全员,否则 `40024` 并返回差集。 + +> **为什么强制全覆盖**:遗漏任何一人,导出包就会缺人;而漏人在人工核对时极难发现。**用接口约束代替人眼核对**。 + +### 7.9 `GET/PUT /api/v1/tasks/{id}/form-schema` + +**GET 响应**:`FormSchemaVO`(见 3.5) + +**PUT 请求** + +```json +{ + "version": 3, + "baseVersion": 2, + "fields": [ + { "key": "real_name", "label": "姓名", "type": "text", "builtin": true, "locked": true, "required": true, "sortNo": 1 }, + { "key": "education", "label": "学历", "type": "select", "builtin": true, "locked": false, + "required": true, "dictCode": "EDU_LEVEL", "sortNo": 4, + "tip": "老师已填,请核对" }, + { "key": "is_retake", "label": "是否补考", "type": "bool", "builtin": true, "sortNo": 7 }, + { "key": "retake_theory", "label": "补考理论", "type": "text", "builtin": true, "sortNo": 8, + "visibleWhen": { "field": "is_retake", "op": "eq", "value": true } } + ], + "extras": [ + { "key": "extra_skill_cert", "label": "其它技能证书编号", "type": "text", "required": false, "sortNo": 100 } + ] +} +``` + +**版本冲突**:`baseVersion` 与当前版本不符 → `40025` 并返回最新 schema,前端做差异合并。 + +**内置字段不可删**:删除任一 `builtin: true` 的字段 → `40026`。 + +> **`extras` 存入 `extras_json`,不参与统计**。凡需要统计的字段必须落在内置列上——这是架构约束,在设计器里以「扩展字段不参与核心统计」明示。 + +### 7.10 `GET/POST /api/v1/tasks/{id}/materials` · `PUT/DELETE /api/v1/materials/{id}` + +**POST/PUT 请求**:`MaterialDefVO`(见 3.4) + +**删除约束**:已有文件上传的材料项**不允许删**(`40027`),只能设 `visibleWhen` 隐藏。 + +**材料项模板**(`materialTemplateId` 可引用) + +| 模板 | 材料项 | +|---|---| +| `TPL_SCHOOL_ROLL` | 一寸照片 + 身份证 + 学籍表 + 工作证明 | +| `TPL_SOCIAL` | 一寸照片 + 身份证 + 工作证明 + 学信网备案表 | +| `TPL_ZHUANXIANG` | 一寸照片 + 身份证 + 工作证明 | + +### 7.11 `GET /api/v1/tasks/{id}/statistics` + +**响应**:`TaskStatVO`(见 3.3)+ 分班明细 + +```json +{ + "code": 0, + "data": { + "rosterTotal": 28, "submittedCount": 23, "approvedCount": 15, + "pendingCount": 4, "rejectedCount": 1, "unsubmittedCount": 5, + "submitRate": "82.1%", "passRate": "53.6%", + "abnormalCount": 4, "avgAuditHours": 6.4, + "byOrg": [ { "orgId": 2001, "orgName": "24中技数控1班", "rosterTotal": 28, + "submittedCount": 23, "approvedCount": 15, "submitRate": "82.1%" } ], + "byOccupation": [ { "occupation": "车工", "worktype": "数控车工", "jobLevel": "四级", + "count": 26, "approvedCount": 14 } ] + } +} +``` + +> **`byOccupation` 走的是正式列**(`collect_submission.occupation`),实时且走索引——这正是当初坚持"18 个正式列 + extras_json"的收益。 + +### 7.12 `GET /api/v1/tasks/{id}/qrcode` + +**Query**:`type`(`MINI` 小程序码 / `URL` 短链)、`orgId`(可选,带班级参数) + +**响应**:`{ "qrcodeUrl": "https://oss.../qr.png", "shortUrl": "https://t.xx/ab3K9", "scene": "t1001-t202609-o2001", "expireTime": null }` + +> 短链永久有效,**任务截止后扫码进入提示"已截止"**,不做失效。 + +### 7.13 `GET /api/v1/rule-templates` · `POST /api/v1/rule-templates/{id}/apply` + +| 接口 | 关键字段 | 说明 | +|---|---|---| +| GET | 返回 `{ id, templateName, certType, isPublic, useCount }` 列表 | 含公共模板与租户自建 | +| apply | 请求 `{ "targetTaskId": 2026090001, "parts": ["FORM","MATERIALS","NAMING"] }` | **可选择只应用其中一部分** | + +--- + +## §8 名册 + +### 8.1 `POST /api/v1/tasks/{id}/roster/import` ⭐ 核心 + +**Content-Type**:`multipart/form-data` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `file` | file | ✅ | Excel(≤10MB,≤5000 行) | +| `headerRow` | int | — | 手动指定表头行;**不传则自动探测** | +| `sheetName` | string | — | 手动指定工作表;不传则取第一个**有数据的**表 | +| `mode` | string | — | `APPEND`(默认,按证件号去重合并)/ `REPLACE` | +| `dryRun` | bool | — | 只体检不落库 | + +**关键设计**:**表头行与工作表名都不固定**(实测一份在第 1 行、一份在第 6 行),因此: + +``` +表头探测算法: +1. 若传 headerRow → 用它 +2. 否则扫前 10 行,选「命中已知列名 ≥ 3 个」的行作为表头 + 已知列名 = 官方模板 21 列 + 常见别名(姓名/名字/学生姓名…) +3. 若有多个候选 → 选命中最多者;并列 → 取最靠上者,并在响应中回传 candidates 让用户确认 +4. 全部不命中 → 40028,返回前 10 行预览,请用户手动指定 +``` + +**响应(导入体检报告)** + +```json +{ + "code": 0, + "data": { + "total": 28, "success": 28, "failed": 0, + "headerRow": 6, + "sheetName": "24中技数控1班", + "headerCandidates": [ { "row": 6, "score": 18 }, { "row": 1, "score": 2 } ], + "headerConfirmed": true, + "columns": [ + { "col": "A", "title": "序号", "mappedTo": null, "matched": false }, + { "col": "B", "title": "姓名", "mappedTo": "real_name", "matched": true }, + { "col": "C", "title": "身份证号", "mappedTo": "id_card_no", "matched": true }, + { "col": "D", "title": "报考职业", "mappedTo": "occupation", "matched": true }, + { "col": "U", "title": "备注", "mappedTo": "ext_json.remark", "matched": false } + ], + "errors": [ + { "row": 12, "col": "C", "field": "id_card_no", "value": "3601052009081712**", + "reason": "身份证号校验位不通过", "code": "IDCARD_CHECKSUM" }, + { "row": 19, "col": "C", "field": "id_card_no", "value": "36102920090729**10", + "reason": "证件号与第 8 行重复", "code": "DUP_IDCARD" } + ], + "warnings": [ + { "row": 28, "col": "D", "field": "occupation", + "reason": "报考职业为空(专项职业能力可留空)", "code": "EMPTY_OCCUPATION" } + ], + "summary": { "uniquePersons": 28, "dupRows": 2, "idcardInvalid": 1, "nameSuspect": 0, "phoneInvalid": 3 } + } +} +``` + +**体检项清单** + +| code | 说明 | 处理 | +|---|---|---| +| `IDCARD_CHECKSUM` | 校验位不通过(ISO 7064) | **阻断该行** | +| `DUP_IDCARD` | 证件号重复 | **阻断该行** | +| `IDCARD_FORMAT` | 格式非法(长度/字符) | 阻断该行 | +| `EMPTY_NAME` | 姓名为空 | 阻断该行 | +| `EMPTY_IDCARD` | 证件号为空 | 阻断该行 | +| `EMPTY_OCCUPATION` | 报考职业为空 | 警告 | +| `NAME_SUSPECT` | 姓名含罕见字/形近字 | 警告 | +| `PHONE_INVALID` | 手机号格式异常 | 警告(不阻断,可后续补) | + +> **`dryRun` 模式**:先体检、给用户确认表头与错行,确认无误后再正式导入。**推荐前端默认走 dryRun**,避免"导进去才发现表头选错"。 + +### 8.2 `GET /api/v1/tasks/{id}/roster` + +**Query**:`keyword`、`orgId`、`submitStatus`、`bindStatus`、`hasIssue`、分页 + +**响应**:`{ list: RosterVO[], total }` + +### 8.3 `PUT /api/v1/roster/{rid}` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `realName` | string | 改名会**重置绑定**(需二次确认 `confirmRebind: true`) | +| `idCardNo` | string | **改证件号必留痕**(FR-506) | +| `phone` | string | — | +| `extJson` | object | 报考三项等 | + +**留痕**:写入 `collect_roster` 变更前值快照 + 操作日志,`GET /logs/operations` 可查。 + +### 8.4 `DELETE /api/v1/roster/{rid}` + +**约束**:已绑定或已提交的名册项**不允许删除**(`40029`),只能标记为"不参与本次认定"。 + +### 8.5 `GET /api/v1/tasks/{id}/roster/template` + +**Query**:`format`(`xlsx`)、`withSample`(bool,是否带示例行) + +**响应**:文件流。表头**固定在第 1 行**,必填列标红,附 9 条填表规则说明页。 + +### 8.6 `POST /api/v1/roster/{rid}/notify` · `POST /api/v1/tasks/{id}/roster/notify-unsubmitted` + +| 接口 | 请求 | 说明 | +|---|---|---| +| 定向催报 | `{ "channel": 1, "templateCode": "NOTIFY_UNSUBMITTED", "extraText": "..." }` | 单人或多人(`rosterIds[]`) | +| 一键催报 | `{ "orgIds": [2001], "channel": 1, "excludeRecentHours": 24 }` | **仅催未提交者**;`excludeRecentHours` 防重复骚扰 | + +**响应**:`{ "sent": 5, "failed": 0, "skipped": 1, "details": [ { "rosterId": 9012, "skipped": true, "reason": "24 小时内已催过" } ] }` + +**限流**:10 次/分钟/任务。 + +--- + +### 8.7 名单加入申请(FR-509 · 客户确认后新增) + +> **背景**:门禁不通过的人原先只能"联系老师",老师再手工加名单。几百人的社会收集场景下,老师负担极重。 +> 本组接口提供一条**受控通道**:学生自助申请 → 管理员审批 → 自动入册。 +> +> ⚠️ **关键边界**:这条通道**只解决"进名单",不绕过门禁**——申请期间学生仍不能填报。 + +#### 8.7.1 `POST /api/v1/mp/roster/join-request` + +**权限**:学生(**须已过门禁失败流程**) **限流**:同 openid 每任务 ≤2 次 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `taskId` | int64 | ✅ | — | +| `realName` | string | ✅ | 姓名(明文存储,业务必需) | +| `idCardNo` | string | ✅ | 身份证号(**服务端转 HMAC,不落明文**) | +| `phone` | string | ✅ | 手机号(**仅存 HMAC + 脱敏值**) | +| `orgHint` | string | — | 自述班级 | +| `reason` | string | — | 申请说明,如"我是转班过来的" | + +**响应** + +```json +{ + "code": 0, + "data": { + "requestId": 7001, + "status": 0, + "statusText": "已提交,等待学校审核", + "estimatedHours": 24, + "remainAttempts": 1 + } +} +``` + +**主要错误** + +| 码 | 场景 | 提示 | +|---|---|---| +| ⭐ `40037` | 同任务申请次数超限(>2) | "该任务申请次数已达上限,请联系老师" | +| ⭐ `40038` | 该证件号已有待审申请 | "你已提交过申请,请耐心等待" | +| ⭐ `40039` | 该证件号已在名册中 | "你已在名单中,请直接返回重新核验" | +| `40008` | 任务已截止/暂停 | — | + +> **`40039` 是必要的**:若证件号已在名册,说明学生**只是输错了姓名**,该引导他回去重填门禁,而不是提交申请。 +> 这一条不违反"统一失败提示"原则——因为学生已经主动提供了正确证件号,此时告知"你已在名单中"**不泄露任何新信息**。 + +#### 8.7.2 `GET /api/v1/mp/roster/join-request` + +**Query**:`taskId` + +**响应** + +```json +{ + "code": 0, + "data": { + "list": [ + { "requestId": 7001, "taskId": 2026090001, "taskName": "2026年9月职业技能认定", + "status": 0, "statusText": "待审核", + "realName": "李四", "idCardMask": "3610**********10", + "orgHint": "24中技数控2班", + "createTime": "2026-09-28T18:00:00+08:00", + "auditTime": null, "rejectReason": null, + "rosterId": null } + ], + "total": 1 + } +} +``` + +**驳回态**:`status: 2` + `rejectReason` 明文返回(学生需要知道原因)。 + +**通过态**:`status: 1` + `rosterId`,并提示"已加入名单,请返回重新核验进入填报"。 + +#### 8.7.3 `GET /api/v1/roster/join-requests` + +**权限**:管理 **Query**:`taskId`、`status`(默认 `0` 待审)、`orgId`、`keyword`、分页 + +**响应** + +```json +{ + "code": 0, + "data": { + "list": [ + { "requestId": 7001, "taskId": 2026090001, + "realName": "李四", "idCardMask": "3610**********10", + "phoneMask": "138****8888", + "orgHint": "24中技数控2班", + "reason": "我是转班过来的", + "status": 0, + "createTime": "2026-09-28T18:00:00+08:00", + "waitHours": 2.5, + "duplicateHint": null } + ], + "total": 1, + "counts": { "pending": 1, "approved": 12, "rejected": 3 }, + "alert": null + } +} +``` + +**`duplicateHint`**:若该证件号与**其他任务的名册**匹配上,返回提示(帮管理员判断是否本校学生): + +```json +"duplicateHint": { "type": "IN_OTHER_TASK", "taskName": "2026年3月认定", "realName": "李四" } +``` + +**`alert`**:单任务待审 > 50 条时返回 `{ "level": 2, "text": "待审申请已达 53 条,建议集中处理" }`(FR-509c)。 + +#### 8.7.4 `POST /api/v1/roster/join-requests/{id}/approve` + +**权限**:管理 **幂等**:是 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `orgId` | int64 | ✅ | **指定班级**(默认取自述班级,无则必填) | +| `realName` | string | — | 修正姓名(管理员核对后可改) | +| `idCardNo` | string | — | 修正证件号 | +| `seqNo` | int | — | 序号(默认追加到末尾) | + +**服务端动作(事务内)** + +``` +1. 校验申请状态 = 待审,否则 40040 +2. 校验该 task 下证件号未被占用,否则 40041 +3. 在 collect_roster 新建名册项: + real_name / id_card_no / id_card_hash / id_card_mask ← 申请值(或管理员修正值) + org_id ← 指定班级 + bind_user_id ← 申请人 uid(FR-509g:自动绑定,免重输) + bind_type ← 3(来自申请) + ext_json.source ← 'JOIN_REQUEST' +4. 回写 collect_join_request:status=1, audit_by, audit_time, roster_id +5. 写操作日志 JOIN_APPROVE +6. 推送通知 bizType=7 +``` + +**响应** + +```json +{ + "code": 0, + "data": { + "requestId": 7001, "status": 1, + "rosterId": 9029, + "rosterCreated": true, + "notifySent": true, + "canFillNow": true + } +} +``` + +> **`canFillNow: true`** 表示该 openid 已绑定,学生再次进入**直接放行**。 + +#### 8.7.5 `POST /api/v1/roster/join-requests/{id}/reject` + +**权限**:管理 **幂等**:是 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `reason` | string | ✅ | **驳回原因(必填)**,会原样推送给学生 | +| `reasonCode` | string | — | 快捷原因码(`NOT_THIS_SCHOOL` / `INFO_MISMATCH` / `NOT_IN_SCOPE` / `OTHER`) | + +**响应**:`{ "requestId": 7001, "status": 2, "notifySent": true }` + +**约束** + +| 条件 | 结果 | +|---|---| +| 已通过后再驳回 | `40042`(如需撤销请走名册删除) | +| 任务已归档 | `40034` | +| 批量审批 | **支持**,但**必须逐条确认**(不可一键全通过——涉及身份认定) | + +> ⚠️ **批量审批的设计取舍**:批量**通过**允许(管理员已看过列表),但**驳回必须逐条填原因**;且批量通过时**不提供"全选"快捷键**。 +> 理由与"批量通过只带绿色项"同源:**身份认定环节的误操作代价极高**。 + +--- + +## §9 填报端 · 小程序 + +### 9.1 `GET /api/v1/mp/tasks` + +**权限**:学生 **说明**:只返回**该学生被绑定/在名册中**的任务。 + +**响应** + +```json +{ + "code": 0, + "data": [ + { + "taskId": 2026090001, "taskName": "2026年9月职业技能等级认定", + "orgName": "24中技数控1班", "endTime": "2026-10-10T23:59:59+08:00", + "status": 1, "submissionStatus": 0, + "progress": { "total": 4, "done": 2, "percent": "50%" }, + "issueCount": { "red": 1, "yellow": 2 }, + "hasDraft": true, + "materials": [ + { "materialCode": "PHOTO1", "materialName": "一寸照片", "required": true, + "done": true, "visible": true }, + { "materialCode": "SCHOOL_ROLL", "materialName": "学籍表", "required": true, + "done": false, "visible": true } + ] + } + ] +} +``` + +> **`materials[].visible`**:条件显示的材料(如"是否在校生=否"时学籍表隐藏、学信网备案表显示)由服务端算好,**前端不做条件判断**——避免两端逻辑不一致。 + +### 9.2 `GET /api/v1/mp/tasks/{id}/form` + +**响应** + +```json +{ + "code": 0, + "data": { + "taskId": 2026090001, "formVersion": 3, + "sections": [ + { + "title": "基本信息", "fields": [ + { "key": "real_name", "label": "姓名", "type": "text", "required": true, + "locked": true, "value": "曾伊豪", "prefilled": true }, + { "key": "education", "label": "学历", "type": "select", "required": true, + "locked": false, "value": "中等职业技术学校", "prefilled": true, + "tip": "老师已填,请核对", + "options": [ { "value": "中等职业技术学校", "label": "中等职业技术学校" } ] } + ] + } + ], + "materials": [ { "materialCode": "PHOTO1", "...": "见 3.4 MaterialDefVO" } ], + "submitPolicy": { + "blockOnRed": true, + "allowForceOnYellow": true, + "deadlineTip": "请在 10 月 10 日前提交" + } + } +} +``` + +**字段三态** + +| `locked` | `prefilled` | 学生端表现 | +|:--:|:--:|---| +| `true` | — | 只读,样式灰底,右侧提示"如有误请联系老师" | +| `false` | `true` | 可编辑,标注"**老师已填,请核对**",修改后留痕 | +| `false` | `false` | 可编辑,正常样式 | + +### 9.3 `POST /api/v1/mp/submissions` + +**幂等**:是 **说明**:取草稿(存在则返回既有,不存在则创建) + +| 字段 | 类型 | 必填 | +|---|---|---| +| `taskId` | int64 | ✅ | + +**响应**:`{ "id": 5001, "formVersion": 3, "created": true }` + +### 9.4 `PUT /api/v1/mp/submissions/{id}` 保存草稿 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `formData` | object | 表单值(**只传变化的字段**,服务端做合并) | +| `extrasJson` | object | 扩展字段 | +| `clientTime` | string | 客户端时间,用于冲突提示 | + +**响应** + +```json +{ + "code": 0, + "data": { + "savedAt": "2026-09-28T14:30:00+08:00", + "changedFields": ["phone"], + "conflict": null + } +} +``` + +**并发冲突**:同一份草稿在多端同时编辑,后写者返回 `conflict`(含对方已改字段),由前端提示。 + +**自动保存**:前端每 15 秒或失焦时调一次;服务端**不限流**(草稿合并是轻量操作)。 + +### 9.5 ⭐ `POST /api/v1/mp/files/upload-token` 获取直传凭证 + +**幂等**:是 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `taskId` | int64 | ✅ | — | +| `materialCode` | string | ✅ | 目标槽位 | +| `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) | +| `fileSize` | int64 | ✅ | 字节 | +| `contentType` | string | ✅ | MIME | +| `source` | int | — | `1` 聊天记录 / `2` 相册 / `3` 手机文件 | +| `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) | + +**响应** + +```json +{ + "code": 0, + "data": { + "fileId": 987654321, + "uploadUrl": "https://oss.xxx.com/raw/1001/2026090001/202609/9f3a....jpg?Signature=...", + "objectKey": "raw/1001/2026090001/202609/9f3a....jpg", + "expireSeconds": 900, + "headers": { "Content-Type": "image/jpeg" }, + "existed": false, + "clientCompressHint": { + "enabled": true, "maxLongSide": 1600, "quality": 80, "targetSizeKb": 1024 + } + } +} +``` + +| 字段 | 说明 | +|---|---| +| `existed` | `true` = **秒传命中**(同 hash 已存在),前端跳过上传直接进入处理 | +| `clientCompressHint` | **端侧预压缩建议**。这是第一道防线,能大幅降低上传流量与处理时长;**服务端仍会按规则重压**,端侧压缩只是加速 | + +> **端侧压缩与「一次编码到位」的关系**:实测 300dpi/q85 即可达标,**端侧压缩只是减少上传量的优化,不是质量决定环节**。服务端始终以原图(或端侧压缩后的图)为输入重新规范,保证输出一致。 + +### 9.6 ⭐ `POST /api/v1/mp/files/{fileId}/process` 文件规范化(核心) + +**说明**:这是**命名 + 压缩 + 识别 + 校验**的总入口。 + +**请求** + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `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,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。 + +**请求**:`{ "fileId": 987654321, "side": "FRONT" }` + +**响应** + +```json +{ + "code": 0, + "data": { + "name": "曾伊豪", + "idCardNo": "361029200907295110", + "idCardMask": "3610292009******10", + "gender": "男", "birthDate": "2009-07-29", "address": "江西省...", + "confidence": 0.973, + "checksumValid": true, + "crossCheck": { "rosterMatch": true, "consistentWithOtherFiles": true } + } +} +``` + +> **错误 `40004`**:校验位不通过时**高亮错误位**提示核对原件,而不是简单说"格式错误"。 +> **错误 `50002`**:OCR 服务异常 → **降级为人工填写**,绝不阻断。 + +### 9.10 `POST /api/v1/mp/submissions/{id}/submit` + +**幂等**:是 **错误码**:`40001` 阻断 / `40008` 已截止或已暂停 + +**请求** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `confirmWarning` | bool | 知悉黄色警告后继续 | +| `ackIssueIds` | int64[] | 已确认知悉的警告 ID | + +**校验失败响应**(`40001`) + +```json +{ + "code": 40001, + "msg": "存在 2 项必须处理的材料问题", + "data": { + "canSubmit": false, + "blocking": [ + { "materialCode": "SCHOOL_ROLL", "ruleCode": "VAL-05", "title": "缺少材料:学籍表", + "action": "UPLOAD", "actionText": "去上传" }, + { "materialCode": "IDCARD", "ruleCode": "VAL-04", "title": "本材料证件号与其他材料不一致", + "detail": "一寸照片为 360122200902044232,身份证为 36010520090817121X", + "action": "REPLACE", "actionText": "重新上传" } + ], + "warnings": [ + { "materialCode": "PHOTO1", "ruleCode": "VAL-08", "title": "照片分辨率偏低,建议让对方发一张原图", + "action": "KEEP", "actionText": "仍然使用" } + ], + "canForceSubmit": true + } +} +``` + +**成功响应** + +```json +{ + "code": 0, + "data": { + "submissionId": 5001, "status": 1, + "submitTime": "2026-09-28T15:00:00+08:00", + "nextNode": "CLASS_AUDIT", + "nextNodeText": "等待李老师初审", + "estimatedAuditHours": 48 + } +} +``` + +**社会收集**(二期):`nextNode` 直接返回 `ADMIN_AUDIT`(跳过初审)。 + +### 9.11 `POST /api/v1/mp/submissions/{id}/withdraw` + +**约束**:`status ∈ {1, 2}`(未终审)可撤回;`status = 3` 需管理员操作(`40031`)。 + +**响应**:`{ "status": 0, "withdrawnAt": "..." }` + +### 9.12 `GET /api/v1/mp/submissions/{id}/progress` + +**响应** + +```json +{ + "code": 0, + "data": { + "status": 1, "progressPercent": "75%", + "materials": [ + { "materialCode": "PHOTO1", "materialName": "一寸照片", "required": true, "done": true, + "fileCount": 1, "issueLevel": 0 }, + { "materialCode": "SCHOOL_ROLL", "materialName": "学籍表", "required": true, "done": false, + "fileCount": 0, "issueLevel": 3, "issueText": "缺少材料" } + ], + "timeline": [ + { "time": "2026-09-28T14:00:00+08:00", "title": "创建草稿" }, + { "time": "2026-09-28T15:00:00+08:00", "title": "已提交", "desc": "等待初审" } + ] + } +} +``` + +--- + +## §10 班主任移动端 + +### 10.1 `GET /api/v1/mp/teacher/dashboard` + +**响应** + +```json +{ + "code": 0, + "data": { + "summary": { + "rosterTotal": 28, "submittedCount": 23, "approvedCount": 15, + "pendingCount": 4, "unsubmittedCount": 5, "abnormalCount": 4, + "submitRate": "82.1%" + }, + "myOrgs": [ + { "orgId": 2001, "orgName": "24中技数控1班", "rosterTotal": 28, + "submittedCount": 23, "approvedCount": 15, "submitRate": "82.1%", + "isHead": true } + ], + "todos": { "auditCount": 4, "abnormalCount": 4, "rejectedAwaitingCount": 1 }, + "deadline": { "taskId": 2026090001, "endTime": "2026-10-10T23:59:59+08:00", "remainDays": 12 } + } +} +``` + +> **口径铁律**:`submitRate` 是**已提交率 23/28 = 82.1%**,与 `approvedCount 15` 分开呈现。原型定稿如此,接口不许合并。 + +### 10.2 `GET /api/v1/mp/teacher/audit/todos` + +**Query**:`orgId`、`level`(`0` 全部 / `1` 仅无问题 / `2` 有问题)、`keyword`、分页(**卡片流默认 pageSize=10**) + +**响应** + +```json +{ + "code": 0, + "data": { + "list": [ + { + "submissionId": 5001, "rosterId": 9001, + "realName": "曾伊豪", "avatarUrl": null, + "orgName": "24中技数控1班", + "submitTime": "2026-09-28T15:00:00+08:00", + "waitHours": 3.2, + "level": 0, + "redCount": 0, "yellowCount": 0, + "materialThumbs": [ "url1", "url2", "url3", "url4" ], + "materialCount": 4, + "changedFields": ["phone"], + "rejectCount": 0, + "batchApprovable": true + }, + { + "submissionId": 5002, "realName": "张三", "level": 3, + "redCount": 1, "yellowCount": 1, + "redSummary": "身份证号与名册不一致", + "batchApprovable": false + } + ], + "total": 4, + "counts": { "all": 4, "clean": 3, "abnormal": 1 } + } +} +``` + +| `level` | 含义 | 卡片表现 | +|:--:|---|---| +| `0` | 无问题 | 绿色,**可右滑通过** | +| `2` | 仅黄色 | 黄色 | +| `3` | 有红色 | 红色,**不可批量、不可滑动** | + +> **`batchApprovable`**:服务端算好,前端据此决定是否让该卡片参与"一键通过"。**规则不落地到前端**。 + +### 10.3 `POST /api/v1/mp/teacher/audit/{submissionId}/approve` + +**幂等**:是 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `remark` | string | 可选备注 | +| `actionSource` | string | `SWIPE`(右滑)/ `BUTTON`(按钮)——**用于埋点分析误触率** | + +**响应**:`{ "status": 2, "nextNode": "ADMIN_AUDIT", "nextNodeText": "等待管理员终审" }` + +**校验**:`redCount > 0` 时返回 `40032` —— **有红色问题的单不允许通过**,必须先驳回让学生改。 + +### 10.4 `POST /api/v1/mp/teacher/audit/{submissionId}/reject` + +> ⚠️ **班主任端不做左滑驳回**。驳回必须点按钮 + 二次确认(误触后果不对称)。 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `reasonCode` | string | ✅ | 见 PRD 附录 C | +| `reasonText` | string | 条件 | `R999` 时必填 | +| `materialCodes` | string[] | — | 指出是哪几项材料的问题 | +| `voiceText` | string | — | 语音转文字结果(可选输入方式) | +| `suggestReupload` | bool | — | 是否建议重新扫描 | + +**响应**:`{ "status": 4, "rejectCount": 1, "notifySent": true, "escalated": false }` + +**升级规则**:`rejectCount >= maxRejectCount(3)` 时 `escalated: true`,同时通知管理员。 + +### 10.5 `POST /api/v1/mp/teacher/audit/batch-approve` + +**请求** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `submissionIds` | int64[] | ≤200 | +| `onlyClean` | bool | **默认 true 且服务端强制 true** | + +**响应** + +```json +{ + "code": 0, + "data": { + "approved": 3, "skipped": 1, + "details": [ + { "submissionId": 5002, "skipped": true, + "reason": "存在红色问题,需单独审核", "redCount": 1 } + ] + } +} +``` + +> **铁律**:**批量通过只带无问题的单,红黄一律排除**。即使前端传了红单,服务端也跳过并回报原因。这是防止"手快全选"放行异常的最后一道闸。 + +### 10.6 `POST /api/v1/mp/teacher/proxy-upload` + +**说明**:班主任代传材料(学生不会弄 / 人不在)。 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `submissionId` | int64 | ✅ | — | +| `materialCode` | string | ✅ | — | +| `fileId` | int64 | ✅ | 已上传的文件 | +| `proxyReason` | string | — | 代传原因 | + +**响应**:`{ "fileId": 987654322, "proxyBy": 10087, "proxyByName": "李老师" }` + +> **代传必须留痕**:文件记录 `source = 4`(代传),审核时间轴显示"由李老师代传"。学生端可见。 + +### 10.7 `POST /api/v1/mp/teacher/notify/unsubmitted` · `GET /mp/teacher/notify/records` + +| 接口 | 说明 | +|---|---| +| `notify/unsubmitted` | 请求 `{ orgIds[], channel, excludeRecentHours }`,**仅催未提交者** | +| `notify/records` | 崔报历史:`{ list: NotifyRecordVO[], total }`,可按 `bizType` / `status` 筛 | + +--- + +## §11 审核(PC 端为主) + +### 11.1 `GET /api/v1/audit/todos` + +**Query**:`taskId`、`orgId`、`level`、`node`(`1` 初审 / `2` 终审)、`waitingOverHours`、分页 + +**响应**:`{ list: SubmissionVO[](精简), total, counts: { all, clean, abnormal, overTime } }` + +### 11.2 `GET /api/v1/audit/submissions/{id}` ⭐ 审核详情 + +**响应**:完整 `SubmissionVO` + 材料 + 问题 + **识别结果** + +```json +{ + "code": 0, + "data": { + "id": 5001, "status": 1, + "realName": "曾伊豪", "idCardMask": "3610292009******10", + "orgName": "24中技数控1班", "submitTime": "2026-09-28T15:00:00+08:00", + "changedFields": ["phone"], + "changedDetail": [ + { "field": "phone", "label": "联系电话", + "rosterValue": "138****8888", "studentValue": "139****9999", + "changedAt": "2026-09-28T14:50:00+08:00" } + ], + "materials": [ + { + "materialCode": "IDCARD", "materialName": "身份证", "done": true, + "files": [ + { + "id": 987654321, + "originalName": "曾伊豪-361029200907295110身份证.jpg.jpg", + "standardName": "曾伊豪-361029200907295110-身份证.jpg", + "previewUrl": "https://...", "thumbUrl": "https://...", + "finalSizeKb": 512, "maxSizeKb": 1024, + "width": 1600, "height": 1200, "dpi": 300, + "validateStatus": 2, + "detect": { "materialCode": "IDCARD", "confidence": 0.98, "source": 1, + "sourceText": "文件名匹配", "conflict": false }, + "issues": [ + { "ruleCode": "VAL-02", "severity": 1, "title": "证件号已按名册修正", + "expectedVal": "3610292009******10", "actualVal": "3610292009******10", + "autoFixed": true } + ] + } + ] + } + ], + "redCount": 0, "yellowCount": 1, + "approvePolicy": { "canApprove": true, "canBatch": true, "reason": null }, + "timeline": [ { "node": 0, "title": "学生提交", "time": "..." } ] + } +} +``` + +> **识别结果 (`detect`) 在此一并返回**——这就是追溯矩阵里 I10 缺口的落点,不需要独立接口。 + +### 11.3 `POST /api/v1/audit/submissions/{id}/approve` · `/reject` + +**与班主任端同构**,差异: + +| 项 | 初审(班主任) | 终审(管理员) | +|---|---|---| +| 通过后状态 | `1 → 2`(待终审) | `2 → 3`(已通过) | +| 驳回后状态 | `→ 4` | `→ 4` | +| 驳回是否可越过初审 | — | **可以**(终审驳回直接生效) | +| `redCount > 0` | 禁止通过 | 禁止通过 | + +**终审请求额外字段**:`{ "finalOpinion": "同意认定", "needArchive": false }` + +### 11.4 ⭐ `POST /api/v1/audit/submissions/{id}/revoke` + +**说明**:撤回已作出的审核结论(**这是原设计缺失的接口**)。 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `auditRecordId` | int64 | ✅ | 要撤回的审核记录 | +| `reason` | string | ✅ | 撤回原因 | + +**响应**:`{ "status": 1, "revokedRecordId": 5501 }` + +**约束** + +| 条件 | 结果 | +|---|---| +| 当前状态 `3`(已通过)且未被导出 | ✅ 允许,回退到 `2` 或 `1` | +| **已被导出打包** | ❌ `40033`——**已交付的数据不可追溯修改** | +| 已归档 | ❌ `40034` | +| 撤回者非原审核人且非管理员 | ❌ `40301` | + +**留痕**:写入一条 `action = 3`(撤回)的 `collect_audit_record`,时间轴显示"李老师撤回了通过结论"。 + +### 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` + +**说明**:任务级全量校验,产出问题清单。 + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `taskId` | int64 | ✅ | — | +| `scope` | string | `ALL` | `ALL` / `ISSUES_ONLY` / `APPOINTED` | +| `submissionIds` | int64[] | — | `scope=APPOINTED` 时必填 | +| `rules` | string[] | 全部 | 只跑指定规则(如 `["VAL-02","VAL-04"]`) | +| `async` | bool | `true` | — | + +**响应**:`{ "jobId": "v_3f2a", "estimatedSeconds": 30 }` + +### 12.2 `GET /api/v1/validation/issues` + +**Query**:`taskId`、`severity`、`ruleCode`、`orgId`、`resolved`、分页 + +**响应** + +```json +{ + "code": 0, + "data": { + "list": [ { "...": "ValidationIssueVO" } ], + "total": 47, + "counts": { "severity1": 12, "severity2": 30, "severity3": 5 }, + "byRule": [ { "ruleCode": "VAL-06", "title": "双扩展名", "count": 56, "autoFixed": 56 } ] + } +} +``` + +### 12.3 `GET /api/v1/validation/issues/export` + +**响应**:Excel 文件流。列:`姓名` `班级` `材料` `规则码` `级别` `问题` `期望值` `实际值` `是否自动修复` `处理人`。 + +### 12.4 `POST /api/v1/exports` ⭐ 导出(前置检查是铁律) + +**幂等**:是 **限流**:5 次/分钟/租户 + +**请求** + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `taskId` | int64 | ✅ | — | +| `exportName` | string | ✅ | 如 `202609机电认定-24中技数控1班` | +| `scope` | object | — | `{ orgIds, onlyApproved, includeRaw }` | +| `splitSizeMb` | int | `50` | 分卷大小 | +| `desensitize` | bool | `false` | 是否脱敏导出 | +| `format` | string | `ZIP` | `ZIP` / `FOLDER` | +| `generateSummaryExcel` | bool | `true` | 生成汇总 Excel | +| `notifyOnFinish` | bool | `true` | — | + +**⚠️ 前置检查失败响应(`40035`)——导出按钮必须被禁用** + +```json +{ + "code": 40035, + "msg": "存在 3 项不合格文件,无法导出", + "data": { + "canExport": false, + "blocking": [ + { "type": "FILE_SIZE", "rosterId": 9012, "realName": "张三", + "materialCode": "WORK_PROOF", "fileId": 987654400, + "title": "工作证明 1.8M 超过 1M 上限", "action": "重新压缩或重传" }, + { "type": "NAME_INVALID", "rosterId": 9014, "realName": "李四", + "materialCode": "IDCARD", "fileId": 987654410, + "title": "文件名未规范化", "action": "触发重新处理" }, + { "type": "MISSING_MATERIAL", "rosterId": 9020, "realName": "王五", + "materialCode": "SCHOOL_ROLL", + "title": "缺少必传材料:学籍表", "action": "催报" } + ], + "summary": { "sizeExceeded": 1, "nameInvalid": 1, "missing": 1, "unapproved": 0 }, + "quickActions": ["RETRY_COMPRESS", "REPROCESS_NAMES", "NOTIFY_UNSUBMITTED"] + } +} +``` + +> **铁律 2**:`canExport = false` 时,前端**直接禁用「开始导出」按钮**,并指出是哪一个人的哪一项卡住了。**不允许"警告后放行"**。 + +**成功响应**:`{ "exportId": 555, "status": 0, "estimatedSeconds": 45 }` + +### 12.5 `GET /api/v1/exports` · `GET /api/v1/exports/{id}` + +**列表响应**:`{ list: ExportTaskVO[], total }` + +**进度响应** + +```json +{ + "code": 0, + "data": { + "id": 555, "status": 1, "progress": 62, + "currentStep": "COMPRESS", + "stepText": "正在压缩 24中技数控1班/工作证明", + "stats": { "fileCount": 112, "processedCount": 70, "rawSizeMb": 475, "finalSizeMb": 22.4, + "compressRatio": "95.3%" }, + "resultFiles": null, "errorMsg": null + } +} +``` + +**完成响应**:`resultFiles: [{ "name": "202609机电认定-24中技数控1班.zip", "sizeMb": 22.4, "url": "..." }]` + +### 12.6 `GET /api/v1/exports/{id}/download` + +**Query**:`fileIndex`(分卷序号,默认 0) + +**响应** + +```json +{ + "code": 0, + "data": { + "url": "https://oss.../export/555/part1.zip?Signature=...", + "expireSeconds": 1800, + "fileName": "202609机电认定-24中技数控1班.zip", + "sizeMb": 22.4, + "checksum": "sha256:abc123...", + "remainsCachedDays": 7 + } +} +``` + +**错误 `40302`**:签名过期 → 重新获取(不重复计费)。 + +### 12.7 通知 + +| 接口 | 请求要点 | 响应 | +|---|---|---| +| `GET /notify/templates` | `taskId`(可选,租户级/任务级) | `[{ id, templateCode, bizType, channel, title, content, enabled }]` | +| `PUT /notify/templates/{id}` | `{ title, content, enabled }`;`content` 支持变量 `{姓名}` `{任务名}` `{截止日}` `{驳回原因}` | `{ updated: true }` | +| `POST /notify/send` | `{ bizType, receiverIds[], channel, templateCode, extraText }` | `{ sent, failed, skipped }` | +| `GET /notify/records` | `taskId`、`bizType`、`status`、分页 | `{ list: NotifyRecordVO[], total, deliveryRate: "97.2%" }` | +| `POST /callback/wechat/msg` | 微信回调原始报文 | `{ errcode: 0 }` | + +**模板变量白名单**(越界变量渲染为空并告警): + +| 变量 | 适用 | +|---|---| +| `{姓名}` `{班级}` | 全部 | +| `{任务名}` `{截止日}` `{剩余天数}` | 全部 | +| `{驳回原因}` `{材料名}` | 驳回/催报 | +| `{提交进度}` | 催报 | +| `{导出包名}` `{下载链接}` | 导出完成 | + +**订阅消息限制**:微信订阅消息为**一次性订阅**,每次发送消耗 1 次额度(`collect_subscribe_quota`)。额度不足时**降级为公众号模板消息**,再不足降级短信(按条计费,见 §13.6)。 + +--- + +## §13 本次补齐的缺口接口 + +### 13.1 说明 + +> 来源:《需求追溯矩阵》§3.1 审计发现的 10 个缺口。**这些接口在原 PRD §8 中被需求提及但未定义**,若不补齐,对应页面将无数据来源。 + +| 缺口 | 补在哪 | 状态 | +|---|---|---| +| I1 报考范围读取/确认 | **§7.7 / §7.8**(已定义) | ✅ | +| I8 审核撤回 | **§11.4**(已定义) | ✅ | +| I9 任务暂停/恢复 | **§7.4**(已定义) | ✅ | +| I10 材料识别结果读取 | **并入 §11.2 详情响应**(不新增接口) | ✅ | +| I2 历史数据整理器 | **§13.2** | ✅ | +| I3 字典管理 | **§13.3** | ✅ | +| I4 / I5 日志 | **§13.4** | ✅ | +| I6 消息中心 | **§13.5** | ✅ | +| I7 订阅额度登记 | **§13.6** | ✅ | + +### 13.2 历史数据整理器(5 个)· 差异化获客功能 + +#### `POST /api/v1/cleanup/jobs` + +**说明**:上传已有压缩包/文件夹,用**与正式收集完全相同的规则**做规范化。 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `jobName` | string | ✅ | 如 `24中技数控1班-历史整理` | +| `sourceType` | int | ✅ | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 | +| `uploadToken` / `objectKey` | string | 条件 | `sourceType=1/2` 时必传(先走通用上传) | +| `sourceUrl` | string | 条件 | `sourceType=3` 时必传 | +| `namingTemplate` | string | — | 缺省用系统默认 | +| `compressConfig` | object | — | 缺省见 3.11 | +| `rosterTaskId` | int64 | — | **关联到已有任务的名册**(用于证件号纠错与串档检测) | + +**响应**:`{ "jobId": 301, "status": 0, "estimatedSeconds": 120 }` + +> `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化,**有名单才能做证件号校验、疑似串档检测**——而"疑似串档 1 例"正是报告里最有价值的一条。 + +#### `GET /api/v1/cleanup/jobs` + +**Query**:`status`、`keyword`、分页 **响应**:`{ list: CleanupJobVO[], total }` + +#### `GET /api/v1/cleanup/jobs/{id}` + +**响应** + +```json +{ + "code": 0, + "data": { + "id": 301, "jobName": "24中技数控1班-历史整理", "status": 3, "progress": 100, + "totalFiles": 112, "processedFiles": 112, + "parsedStudents": 28, "parsedMaterials": 4, + "rawSizeMb": 436, "finalSizeMb": 37, "compressRatio": "91.5%", + "resultPath": "/cleanup/301/result.zip", + "finishTime": "2026-09-28T16:00:00+08:00" + } +} +``` + +#### `GET /api/v1/cleanup/jobs/{id}/report` ⭐ 体检报告 + +**Query**:`format`(`json` / `xlsx`) + +**响应(json)** + +```json +{ + "code": 0, + "data": { + "jobId": 301, + "summary": { + "totalFiles": 112, + "nameInvalid": 59, "sizeExceeded": 31, "formatConverted": 56, + "idcardMismatch": 5, "idcardAutoFixed": 3, "idcardNeedConfirm": 2, + "crossFileSuspected": 1, "lowResolution": 1, "orphanImages": 0 + }, + "autoHandled": 90, + "needManual": 1, + "highlights": [ + { "type": "CROSS_FILE", "level": 3, "rosterId": 9012, "realName": "张三", + "title": "疑似串档", "detail": "该生 4 项材料的证件号与名册均不同,且 4 项彼此一致", + "suggestion": "可能整个文件夹被换过,请核对学生本人" }, + { "type": "LOW_RESOLUTION", "level": 2, "rosterId": 9008, "realName": "李四", + "title": "分辨率偏低", "materialCode": "IDCARD", + "detail": "558×745,同批其他人为 1279×1706", "suggestion": "建议索取原图" } + ], + "nameFixedSamples": [ + { "original": "身份证.jpg.jpg", "fixed": "曾伊豪-361029200907295110-身份证.jpg", "rule": "VAL-06" } + ], + "idcardConflicts": [ + { "rosterId": 9012, "realName": "张三", + "rosterValue": "3610292009******10", "fileNameValue": "3610292009******99", + "ocrValue": "3610292009******10", + "verdict": "AUTO_FIXED", "verdictText": "OCR 与名册一致,按名册修正文件名" } + ] + } +} +``` + +> **报告里最重要的一句话是 `autoHandled: 90 / needManual: 1`**——「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。这是最有说服力的销售话术,也是接口必须算出来的数字。 + +#### `POST /api/v1/cleanup/jobs/{id}/import` + +**说明**:把整理结果**导入为正式任务**(名册 + 材料一次成型)。 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `taskName` | string | ✅ | 新任务名 | +| `collectMode` | int | ✅ | `1` / `2` | +| `batchNo` | string | ✅ | — | +| `importRoster` | bool | — | 是否把解析出的学员建成名册(默认 true) | +| `importFiles` | bool | — | 是否把文件挂到填报单上(默认 true) | +| `markAsSubmitted` | bool | — | 是否直接置为"已提交待审"(默认 true) | + +**响应**:`{ "taskId": 2026090002, "rosterCount": 28, "fileCount": 112 }` + +### 13.3 字典管理(4 个) + +| 接口 | 说明 | +|---|---| +| `GET /api/v1/dicts` | 字典列表 `[{ dictCode, dictName, itemCount, builtin }]` | +| `GET /api/v1/dicts/{code}/items` | 字典项 `[{ id, itemCode, itemName, parentItemCode, sortNo, status }]`,**含层级**(如职业-工种) | +| `POST /api/v1/dicts/{code}/items` | 新增项:`{ itemCode, itemName, parentItemCode, sortNo }` | +| `PUT /api/v1/dicts/items/{id}` | 修改:`{ itemName, sortNo, status }` | + +**内置字典(`builtin: true`,不可删,只可停用单项)** + +| `dictCode` | 名称 | 项数 | +|---|---|---| +| `EDU_LEVEL` | 学历 | 11 | +| `JOB_LEVEL` | 职业技能等级 | 5(一~五级) | +| `CERT_TYPE` | 认定类型 | 3 | +| `RETARGET_SUBJECT` | 补考科目 | 3 | +| `OCCUPATION` | 职业(国标,可选) | 由 `sys_occupation_ref` 提供 | +| `REJECT_REASON` | 驳回原因 | 10 | + +**约束**:有下级项的字典项不可停用(`40036`)。修改 `itemCode` 不允许(会破坏历史数据)。 + +> **`OCCUPATION` 字典的特殊性**:官方 Excel 模板里「报考职业」「报考工种」**是自由填写、没有下拉**。所以字典在这里**只做拼写校验与联想建议(P2 级),不做强约束**。凡发现自由填写值不在字典中,记 `SCOPE_FREE_TEXT` 信息级提示,**不阻断**。 + +### 13.4 日志(2 个) + +#### `GET /api/v1/logs/operations` + +**Query**:`tenantId`、`operatorId`、`module`、`action`、`targetId`、`from` / `to`、分页 + +**响应** + +```json +{ + "code": 0, + "data": { + "list": [ + { "id": 70001, "operatorId": 10086, "operatorName": "王老师", "module": "ROSTER", + "action": "UPDATE_IDCARD", "targetType": "ROSTER", "targetId": 9012, + "beforeVal": "3610292009******10", "afterVal": "3610292009******99", + "ip": "10.1.2.3", "userAgent": "Chrome/120", "createTime": "2026-09-28T16:20:00+08:00" } + ], + "total": 1 + } +} +``` + +**必记的敏感操作**(PRD FR-506 / FR-1202) + +| `action` | 场景 | +|---|---| +| `UPDATE_IDCARD` | **改名册证件号**(留痕强制) | +| `UPDATE_ROSTER_NAME` | 改名 | +| `DELETE_ROSTER` | 删名册项 | +| `PUBLISH_TASK` / `PAUSE_TASK` / `ARCHIVE_TASK` | 任务状态流转 | +| `REVOKE_AUDIT` | **撤回审核** | +| `EXPORT_CREATE` | 创建导出 | +| `PROXY_UPLOAD` | 代传材料 | +| `VIEW_IDCARD_FULL` | **查看完整身份证号**(单独留痕) | +| `CONFIG_COMPRESS` | 改压缩配置 | + +**脱敏**:`beforeVal` / `afterVal` 中的证件号**一律脱敏存储**,即使日志泄露也不暴露明文。 + +#### `GET /api/v1/logs/logins` + +**Query**:`userId`、`userType`、`result`、`from` / `to`、分页 + +**响应**:`{ list: [{ id, userId, userName, userType, loginType, ip, location, device, result, failReason, loginTime }], total }` + +| `loginType` | 含义 | +|---|---| +| `PASSWORD` | PC 账号密码 | +| `WECHAT` | 小程序微信授权 | +| `PHONE` | 手机号快捷登录 | + +> **保留期**:操作日志 3 年,登录日志 1 年(可在租户配置中调整)。 + +### 13.5 消息中心(3 个) + +#### `GET /api/v1/mp/messages`(学生端) + +**Query**:`bizType`、`read`、分页 + +**响应** + +```json +{ + "code": 0, + "data": { + "list": [ + { "id": 80001, "bizType": 3, "title": "你的材料被退回", + "content": "「工作证明」未盖公章,请重新提供", + "read": false, "linkType": "SUBMISSION", "linkId": 5001, + "createTime": "2026-09-28T16:30:00+08:00" }, + { "id": 80002, "bizType": 2, "title": "距截止还有 3 天", + "content": "请在 10 月 10 日前完成填报", "read": true, + "linkType": "TASK", "linkId": 2026090001, "createTime": "2026-10-07T09:00:00+08:00" } + ], + "total": 2, + "unreadCount": 1 + } +} +``` + +#### `POST /api/v1/mp/messages/read` + +`{ "messageIds": [80001], "all": false }` → `{ "unreadCount": 0 }` + +#### `GET /api/v1/mp/teacher/messages`(教师端) + +同构,`bizType` 增加 `7`(加入名单审批)等教师专属类型。 + +> **消息中心 vs 通知记录的区别**:`collect_notify_record` 记的是**发送通道结果**(发出去没有、到没到),消息中心记的是**站内信内容**(用户看到什么)。二者通过 `bizType` 关联。 + +### 13.6 订阅消息额度登记(1 个) + +#### `POST /api/v1/mp/subscribe` + +**说明**:微信订阅消息为**一次性授权**,用户每点一次"允许"只能发一条。小程序端在关键节点**主动索取授权**并登记额度。 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `templateCode` | string | ✅ | 模板码,见下表 | +| `subscribeCount` | int | — | 本次授权次数(微信返回,默认 1) | + +**响应**:`{ "templateCode": "AUDIT_RESULT", "total": 5, "used": 2, "remain": 3 }` + +**模板码与索取时机** + +| `templateCode` | 索取时机 | 用途 | +|---|---|---| +| `SUBMIT_SUCCESS` | 提交成功后 | 通知审核进展 | +| `AUDIT_RESULT` | 提交成功后 | 通过 / 驳回通知 | +| `DEADLINE_REMIND` | 进入填报页时 | 临期提醒 | +| `EXPORT_READY` | 管理端创建导出时 | 导出完成 | +| `JOIN_APPROVE` | 申请加入名单时 | 审批结果 | + +**额度不足的降级链** + +``` +微信订阅消息(免费,额度受限) + ↓ 额度耗尽 +公众号模板消息(需关注公众号) + ↓ 未关注 +短信(按条计费,走 collect_subscribe_quota 计费口径) + ↓ 超出套餐 +仅站内信(不推送,用户主动进小程序可见) +``` + +> **降级必须静默**:学生端不显示"你的额度不够了"。运营侧在 `GET /tenants/{id}/usage` 里看到 `smsCount` 上涨与告警。 + +--- + +## §14 开放接口(供认定机构对接) + +**鉴权**:`appKey + timestamp + nonce + HMAC-SHA256` + +``` +X-App-Key: jxjd_2026 +X-Timestamp: 1790000000 +X-Nonce: 8f3a9c2e +X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body) +``` + +**签名规则**:`body` 为**原始 JSON 字符串**(未格式化、未排序);`timestamp` 与服务器时间偏差 > 5 分钟拒绝;`nonce` 5 分钟内不可重复。 + +### 14.1 `POST /open/v1/auth/token` + +**请求**:`{ "appKey": "jxjd_2026", "appSecret": "..." }` + +**响应**:`{ "accessToken": "...", "expireSeconds": 7200, "scope": ["submission:read","file:read"] }` + +### 14.2 `GET /open/v1/tasks/{id}/submissions` + +**Query**:`since`(增量拉取时间)、`status`(默认 `3` 已通过)、分页 + +**响应**:`SubmissionVO` 数组 + `idCardMask`(**开放接口一律不返回明文证件号**) + +### 14.3 `GET /open/v1/tasks/{id}/files` + +**Query**:`submissionIds`(逗号分隔,≤200) + +**响应** + +```json +{ + "code": 0, + "data": { + "files": [ + { "fileId": 987654321, "submissionId": 5001, "realName": "曾伊豪", + "materialCode": "IDCARD", "standardName": "曾伊豪-3610292009******10-身份证.jpg", + "url": "https://oss.../sign?expire=1800", "sizeKb": 512, "expireSeconds": 1800 } + ] + } +} +``` + +> 签名 URL **有效期 30 分钟**,过期需重新获取。 + +### 14.4 `POST /open/v1/callback/push` + +**说明**:认定机构提供回调地址,系统在终审通过后主动推送。 + +**请求(我方 → 机构)** + +```json +{ + "eventType": "SUBMISSION_APPROVED", + "occurTime": "2026-09-28T17:00:00+08:00", + "taskId": 2026090001, "taskCode": "2026090001", + "submissionId": 5001, + "data": { "realName": "曾伊豪", "idCardMask": "3610292009******10", + "occupation": "车工", "worktype": "数控车工", "jobLevel": "四级" }, + "files": [ { "materialCode": "IDCARD", "url": "https://...", "expireSeconds": 1800 } ] +} +``` + +**重试策略**:失败重试 5 次,间隔 1/5/30/120/600 秒;全部失败后标记并告警,支持人工重推。 + +--- + +## §15 错误码全集 + +> 在 PRD 附录 D(20 个码)基础上**新增 38 个**(标 ⭐),本节共 **58 个**(含 `0` 成功码)。所有码在 `code` 字段返回,HTTP 状态码见 §1.3。 + +### 15.1 客户端错误(4xxxx) + +| 码 | 含义 | 端处理 | +|---|---|---| +| `40001` | 存在阻断性校验问题,无法提交 | 展示 blocking 清单,引导逐项处理 | +| `40002` | 文件格式不在白名单 | 提示支持的格式 | +| `40003` | 文件体积超上限且压缩受限 | 提示重新提供 | +| `40004` | 身份证号校验位不通过 | **高亮错误位**,提示核对原件 | +| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 | +| `40006` | 疑似串档 | 转人工,**不自动修正** | +| `40007` | 门禁未匹配 | **统一提示**"没有找到你的信息",不透露哪项错 | +| `40007b` | 门禁尝试过于频繁 | 提示"操作过于频繁,请稍后再试" | +| `40007c` | 名单项已被他人绑定 | 提示"该信息已被登记" | +| `40008` | 任务已截止 / 已归档 / **已暂停** | 禁止提交 | +| `40009` | 重复提交(幂等键冲突) | 返回既有结果 | +| `40010` | 驳回次数超上限 | 提示联系班主任 | +| ⭐ `40011` | 批量操作超过 200 条上限 | 分批提交 | +| ⭐ `40012` | 账号或密码错误 | 重新输入(不透露账号是否存在) | +| ⭐ `40013` | 账号已锁定 | 提示剩余锁定时间 | +| ⭐ `40014` | 用户有在办任务,不可停用 | 提示先移交任务 | +| ⭐ `40015` | 组织被占用,不可删除 | 展示占用清单 | +| ⭐ `40016` | 名册为空,不可发布 | 引导导入名册 | +| ⭐ `40017` | 名册缺少「姓名」或「身份证号」列 | 引导重新导入 | +| ⭐ `40018` | 未配置必传材料项 | 引导配置材料 | +| ⭐ `40019` | 未设置覆盖班级或班主任 | 引导配置 | +| ⭐ `40020` | 命名规则试跑未通过 | 展示失败样例,引导修规则 | +| ⭐ `40021` | 仅已发布任务可暂停 | 刷新状态 | +| ⭐ `40022` | 仅已暂停任务可恢复 | 刷新状态 | +| ⭐ `40023` | 存在待审单,不可归档 | 展示待审单 | +| ⭐ `40024` | 报考范围未覆盖全部名册 | 返回差集(**漏人查验**) | +| ⭐ `40025` | 表单版本冲突 | 返回最新 schema,前端差异合并 | +| ⭐ `40026` | 内置字段不可删除 | 提示可改标签/顺序 | +| ⭐ `40027` | 材料项已有文件,不可删除 | 提示改为隐藏 | +| ⭐ `40028` | 无法自动定位表头行 | 返回前 10 行预览,要求手动指定 | +| ⭐ `40029` | 名册项已绑定/已提交,不可删除 | 提示改为"不参与本次认定" | +| ⭐ `40030` | 已进入审核,需先撤回才能删材料 | 引导撤回 | +| ⭐ `40031` | 已通过终审,学生不可撤回 | 提示联系管理员 | +| ⭐ `40032` | **存在红色问题,不允许通过** | 必须先驳回让学生修正 | +| ⭐ `40033` | **已导出打包,不可撤回审核** | 已交付数据不可追溯修改 | +| ⭐ `40034` | 已归档,不可撤回 | — | +| ⭐ `40035` | **存在不合格文件,禁止导出** | 禁用导出按钮 + 展示 blocking 清单 | +| ⭐ `40036` | 字典项有下级,不可停用 | 先处理下级 | +| ⭐ `40037` | **加入申请次数超限**(同 openid 每任务 >2) | 提示联系老师 | +| ⭐ `40038` | **该证件号已有待审申请** | "你已提交过申请,请耐心等待" | +| ⭐ `40039` | **该证件号已在名册中** | "你已在名单中,请返回重新核验"(引导回门禁,**不泄露新信息**) | +| ⭐ `40040` | 申请状态已变更(非待审) | 刷新列表 | +| ⭐ `40041` | **名册中该证件号已被占用** | 不覆盖,提示管理员核查 | +| ⭐ `40042` | 申请已通过,不可再驳回 | 如需撤销请走名册删除 | +| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) | +| ⭐ `40043` | **文件归属存疑**(文件名指向名册中的另一个人,或归属无法确证) | **阻断提交**,提示"这份材料可能不是你的",引导重新上传(**无"仍然继续"选项**) | +| `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 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| V1.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义;**补齐追溯矩阵审计发现的 10 个缺口(新增 21 个接口,含 1 个登记遗漏)**;错误码由 20 个扩至 **50 个**;新增 §2 枚举全集、§3 通用数据结构、§15 错误码全集 | + +| **V1.1** | **2026-09-28** | **客户要求移除「联系方式展示」**:删除 `GET /api/v1/mp/roster/contact`(原用于门禁失败页展示班主任脱敏手机号)。
**理由**:学生在班级群里本来就有班主任联系方式,系统内再放一份既不必要(用户已知怎么找人)、有维护成本(换人换号要同步)、又多一处隐私暴露面。
门禁失败页改为只保留两条**系统内动作**:重新填写 / 申请加入名单(FR-509)。纯文案提示("如有疑问请联系老师")保留,因**不涉及任何联系方式数据**。
**接口总数 122 → 121** | +| V1.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义 + +### 自校验记录 + +| 检查项 | 结果 | +|---|---| +| §4 各模块表行数合计 | **121** ✓ 与声称一致 | +| §4 内路径重复 | 无 ✓ | +| Markdown 表格列数一致性 | 0 行不符 ✓ | +| 文档引用的字段名 | 116 个唯一值,**全部存在于 DDL** ✓ | +| 错误码数量 | 58 个(新增 38 个标 ⭐)✓ | +| 文件规模 | 约 100 KB / 2570 行 | + diff --git a/docs/04-核心模块设计说明.md b/docs/04-核心模块设计说明.md new file mode 100644 index 0000000..20f27e4 --- /dev/null +++ b/docs/04-核心模块设计说明.md @@ -0,0 +1,1444 @@ +# 核心模块设计说明 + +> 版本:V1.0 | 日期:2026-09-28 +> 读者:后端开发、算法/图像处理开发、测试 +> 定位:**伪代码级**实现说明。PRD 讲「要什么」,接口文档讲「收发什么」,本文讲「**怎么做**」。 +> 只覆盖 **4 个复杂度最高的模块** + 2 个配套模块。CRUD 类不在此列。 + +--- + +## 目录 + +| 模块 | 职责 | 复杂度来源 | +|---|---|---| +| **M1 命名规范化引擎** | 把乱文件名变成规范名 | 规则交叉、优先级、误判代价不对称 | +| **M2 压缩引擎** | 压到 ≤1M 且尽量不损清晰度 | 五档策略、内存保护、二次有损防护 | +| **M3 材料识别引擎** | 判断文件属于哪类材料 | 四层递进、置信度分级、识别失败必须放行 | +| **M4 门禁匹配引擎** | 姓名+证件号核验与绑定 | 安全(防枚举)、限流、形近字纠错、并发绑定 | +| M5 校验规则引擎 | 汇总 VAL-01~20 并分级 | 规则依赖、豁免与自动修复 | +| M6 导出打包引擎 | 目录树、分卷、前置检查 | 铁律「有不合格文件禁止导出」 | + +--- + +## 全局约定 + +### 处理流水线的位置 + +``` +学生选文件 + ↓ +① 端侧预压缩(可选,仅加速,不影响最终质量) + ↓ +② 直传 OSS(原图) + ↓ +③ ┌─ M1 命名规范化 ────────────┐ + │ M2 压缩与格式规范 │ ← 本次请求的主链路 + │ M3 材料识别 │ + │ M5 校验规则聚合 │ + └────────────────────────────┘ + ↓ +④ 返回结果卡(命名前后对比 + 体积 + 识别结论 + 问题清单) + ↓ +⑤ 学生确认 → 提交 → M4 门禁(前置)→ 审核流 +``` + +> **M4 门禁在填报前执行**,③里的 M4 只出现在"提交时二次校验绑定关系"。 + +### 统一的「三态返回」约定 + +三个引擎(M1/M2/M3)**共用同一套结果契约**,前端因此可以用同一个组件渲染: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `status` | enum | `OK` / `FIXED` / `WARN` / `FAIL` | +| `before` | any | 处理前 | +| `after` | any | 处理后 | +| `steps[]` | array | 每一步的 `{step, before, after, success, costMs}` | +| `issues[]` | array | `{ruleCode, severity, title, detail, autoFixed}` | + +**核心原则**: + +| 原则 | 含义 | +|---|---| +| **能自动修的自动修,并告知** | 双扩展名、缺分隔符等 → `FIXED` + severity 1(信息) | +| **不能确定的只提示,不改位置** | M3 识别冲突 → `WARN` + `needManualPlace: true` | +| **彻底失败的放行** | 识别不出 → `status: FAIL` 但**不阻断提交** | +| **严重度 ≠ 阻断** | 只有 severity 3 且属于「材料缺失/证件号冲突」才阻断 | + +--- + +## M1 · 命名规范化引擎 + +### 1.1 职责与边界 + +| 项 | 内容 | +|---|---| +| **输入** | `originalName`(学生原始文件名)、`rosterItem`(名册项)、`materialDef`(材料定义) | +| **输出** | `standardName`、`steps[]`、`issues[]` | +| **做** | 修复扩展名、补分隔符、映射材料同义词、清非法字符、**归属校验**(判断这份文件是不是本人的) | +| **不做** | 不改文件内容;**不改文件归属槽位**(那是 M3 的事);**不"纠正"看起来属于别人的文件名**(要阻断,见 1.4) | +| **幂等** | ✅ 对已规范的文件再跑一次,结果不变、不产生 issue | + +### 1.2 关键参数 + +| 参数 | 默认 | 说明 | +|---|---|---| +| `template` | `{姓名}-{证件号}-{材料名}{后缀}` | 任务级可配 | +| `sep` | `-` | 连接符,可改为 `_` | +| `caseMode` | `keep` | `keep` / `lower` / `upper` | +| `maxNameLen` | 200 | 超长截断(保留证件号与材料名) | +| `nameSimilarityThreshold` | `0.75` | 姓名纠错阈值 | +| `keepOriginalIfNoRoster` | `true` | **无名册时不猜姓名**,只做格式清理 | + +### 1.3 主流程 + +``` +FUNCTION normalize(rawName, rosterItem, materialDef, template): + + steps = []; issues = [] + name = rawName + flags = { nameFixed: false, idcardFixed: false } + + # ── 步骤 1:拆解 ───────────────────────────── + base, exts = split_extensions(name) + # "张三-361029...身份证.jpg.jpg" + # → base = "张三-361029...身份证", exts = ["jpg", "jpg"] + + # ── 步骤 2:扩展名规范化 ────────────────────── + IF length(exts) > 1: + distinct = unique_lower(exts) + IF length(distinct) == 1: + ext = distinct[0] + issues.append(VAL-06, sev=1, "格式已转换", + detail = join(exts,'.') + " → " + ext, autoFixed=true) + ELSE: + # 例:abc.pdf.jpg —— 不可猜,取最后一个,标记待确认 + ext = last(exts) + issues.append(VAL-18, sev=2, "检测到多个不同扩展名", + detail = join(exts,'.'), autoFixed=true) + steps.append(NAME_EXT, before=join(exts,'.'), after=ext) + ELSE: + ext = first(exts) + + # ── 步骤 3:清非法字符 ─────────────────────── + # [\\/:*?"<>|\s] → Nothing + # 注意:中文空格、全角空格也算 + cleaned = replace(base, ILLEGAL_CHARS, '') + IF cleaned != base: + issues.append(VAL-18, sev=1, "已清理文件名中的非法字符") + base = cleaned + + # ── 步骤 4:抽取结构化信息 ─────────────────── + tokens = split(base, SEPARATORS) # - _ 空格 顿号 等 + idcard = find_idcard_in(tokens) # 见 1.4 + if len(idcard)==0: idcard = find_idcard_in(base) + matName = match_material_alias(tokens, materialDef, ALL_MATERIALS) + + # ── 步骤 5:归属校验(而不是"号码纠错")────── + # + # ⚠️ 这里是整个引擎最容易做错的地方。先想清楚一件事: + # 名册 的姓名+证件号是唯一权威,且先于填报存在(门禁已核验)。 + # 所以【填报单里的姓名永远不会错】。 + # 文件名的任何不符,含义不是"名称错误",而是【这份文件可能不属于这个人】。 + # + # 由此两条铁律: + # ① 输出名一律取名册值,文件名的值只用于判断归属 + # ② 归属存疑 → 阻断提交,让填报人自己改;绝不自动按名册改名 + # (自动改名会把"A 传了 B 的材料"静默洗白成 A 的,审核看不出来) + + owner = resolve_ownership( + fileNameIdcard = idcard, # 文件名的证件号(可能无) + fileNameName = pick_name_like(tokens), # 文件名的姓名(可能无,仅辅助) + roster = rosterItem, # 当前填报人(权威) + ocrIdcard = OCR_PENDING # 异步回填,见"两阶段" + ) + + SWITCH owner.verdict: + + # ── 无证件号:正常情况(如 微信图片_20260928.jpg)── + CASE NO_IDCARD_IN_NAME: + idcard = roster.idCardNo + name = roster.realName + # 不产生任何 issue —— 这不是问题,是默认路径 + flags.idcardFromRoster = true + + # ── 证件号 = 名册值:归属确证 ───────────── + CASE CONFIRMED_SAME: + idcard = roster.idCardNo + name = roster.realName + IF owner.nameDiffers: + # 姓名差异(如 扬晨 vs 杨晨)——归属已由证件号确证 + # 仅写审计日志,【不打扰学生】 + logonly(NO.NAME_DIFFERS, before=owner.fileName, after=name) + + # ── 🔴 铁证:证件号能反查到名册里的另一个人 ── + CASE BELONGS_TO_OTHER: + idcard = roster.idCardNo # 输出仍用名册(但不会走到提交) + name = roster.realName + issues.append(VAL-21, sev=3, + title="这份材料可能不是你的", + detail="文件名对应的是「" + owner.otherRosterName + "」,不是你", + suggestion="请重新选择你自己的材料", + autoFixed=false, blocking=true) + flags.blockSubmit = true # ★ 阻断提交 + + # ── 证件号合法但名册里查不到 ───────────── + CASE UNKNOWN_IDCARD: + IF ocrIdcard == roster.idCardNo: + # OCR 佐证:文件内容确实是本人,只是文件名写了个陌生号 + idcard = roster.idCardNo + name = roster.realName + issues.append(VAL-02, sev=1, "文件名中的证件号有误,已按名册修正", + expected=mask(roster.idCardNo), + actual=mask(owner.fileNameIdcard), autoFixed=true) + ELSE: + # 无证据 → 阻断 + idcard = roster.idCardNo + name = roster.realName + issues.append(VAL-21, sev=3, + title="无法确认这份材料属于你", + detail="文件名中的证件号不在本次名单内", + suggestion="请重新选择并上传你自己的材料", + autoFixed=false, blocking=true) + flags.blockSubmit = true + + # ── 证件号校验位失败 / 位数不足:只是写错了 ── + CASE CHECKSUM_INVALID: + # 校验位失败 = 这【不是一个真实存在的号码】→ 不构成归属证据,可以自动改 + # 实测 2 例:于豪、杨晨(末位错) + idcard = roster.idCardNo + name = roster.realName + issues.append(VAL-02, sev=1, "文件名中的证件号有误,已按名册修正", + expected=mask(roster.idCardNo), + actual=owner.rawFileNameIdcard, autoFixed=true) + + # ── 名册为空(未导名单 / 历史整理器无名单)── + CASE NO_ROSTER: + IF idcard.found: + name = pick_name_like(tokens) OR UNKNOWN_NAME + # 不猜、不补零、不纠错 + ELSE: + idcard = UNKNOWN_PLACEHOLDER + name = UNKNOWN_NAME + issues.append(VAL-01, sev=2, "无法确定证件号与姓名(本次无名单可依)") + + # ── 步骤 6:姓名取值(不再有独立规则)──────── + # + # ⚠️ 修订(客户确认):【姓名单独不构成规则】。 + # 名册先于填报存在且门禁已核验 → 填报单里的姓名永远是对的。 + # 文件名里的姓名既不用于命名,也不是可靠的归属证据(弱证据)。 + # + # 实测支撑:112 个真实文件里"姓名与名册不符"仅 1 例(扬晨 vs 杨晨), + # 且该例证件号是正确的 → 本来就该走 CONFIRMED_SAME,既不需要姓名规则, + # 也不该触发任何确认。一条只出现 1 次、且有更强判据可替代的规则,不值得做。 + # + # 所以此处只剩一句:名字从名册取;没有名册时才用文件名的。 + IF rosterItem: + name = rosterItem.realName # 一律名册 + ELSE: + name = pick_name_like(tokens) OR UNKNOWN_NAME + # ── 步骤 7:材料名映射 ─────────────────────── + IF matName.found: + materialName = matName.standardName + IF matName.isAlias: + issues.append(VAL-18, sev=1, "材料名已归一", + detail = matName.raw + " → " + materialName) + ELSE: + materialName = materialDef.materialName # 用目标槽位名兜底 + + # ── 步骤 8:套模板 ─────────────────────────── + standard = template + .replace("{姓名}", name) + .replace("{证件号}", idcard) + .replace("{材料名}", materialName) + .replace("{后缀}", "." + normalize_ext(ext)) # jpeg→jpg 由 outputFormat 决定 + .replace("{班级}", rosterItem.orgName OR "") + .replace("{序号}", pad(rosterItem.seqNo, 3)) + + standard = truncate_safely(standard, maxNameLen) + standard = apply_case(standard, caseMode) + standard = dedup_separators(standard) # "张三--123" → "张三-123" + + RETURN { standardName: standard, steps, issues, flags } +``` + +### 1.4 身份证号抽取与裁决 + +**抽取**(按优先级尝试,命中即停) + +``` +FUNCTION find_idcard_in(text): + 1. 18 位严格正则 [1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx] + → 校验位通过 → 直接返回 + → 校验位不通过 → 记为 candidate,继续 + 2. 15 位旧号正则 + 3. 形似变体:字母 O→0、I/l→1、S→5、B→8(OCR 常见混淆) + → 逐个试替换,最多替换 2 位,命中校验位则返回 + 4. 无 → NOT_FOUND +``` + +> ⚠️ **第 3 步的替换必须限制次数**(≤2 位)。不限次数的暴力替换会把"任意 18 位数字"强行凑成合法号,**制造假数据**。 + +**归属裁决表**(`resolve_ownership`) + +> ⚠️ **这不是"号码纠错表",是"这份文件是谁的"判定表。** 两者的区别决定了对错。 + +| # | 文件名证件号 | 名册 | OCR | 裁决 | 处理 | +|:--:|---|---|---|---|---| +| 1 | 无 | 有值 | — | `NO_IDCARD_IN_NAME` | 按名册补全,**无提示**(正常路径) | +| 2 | = 名册值 | 有值 | — | `CONFIRMED_SAME` | 按名册输出;姓名差异**仅留痕** | +| 3 | **= 名册中另一个人的值** | 有值 | — | 🔴 `BELONGS_TO_OTHER` | **阻断提交**(VAL-21) | +| 4 | 合法但名册查不到 | 有值 | = 名册 | `UNKNOWN_IDCARD`(OCR 佐证) | 按名册改,信息级 | +| 5 | 合法但名册查不到 | 有值 | ≠ 名册 / 无 | 🔴 `UNKNOWN_IDCARD`(无证据) | **阻断提交**(VAL-21) | +| 6 | 校验位失败 / 位数不足 | 有值 | — | `CHECKSUM_INVALID` | **自动按名册**,信息级 | +| 7 | 任意 | 空 | — | `NO_ROSTER` | 保留原值,标黄,**不猜** | + +**第 3 条 vs 第 6 条 —— 整张表的精髓** + +| 情形 | 含义 | 能不能自动改 | +|---|---|---| +| 校验位**失败** | 这**不是一个真实存在的号码** | ✅ **能**——它只可能是写错了 | +| 校验位**通过**(第 3 条) | 这是一个**真实存在的自然人**,且名册里能查到是谁 | ❌ **绝不能**——改了就是改了身份 | + +> 这条区分是校验位算法带来的最大收益:它把"这到底是谁的材料"从**人判断**变成**机器判断**。 + +**红线:绝不自动按名册"纠正"一个校验位通过的陌生证件号** + +反例(错误实现): + +``` +学生 A 选了同学 B 的材料上传 + ↓ 文件名:李四-3610...(B 的号码,校验位通过) +错误做法:判"与名册不符" → 改成 张三-3610...(A 的号码)→ 信息级,A 看不到 + ↓ +审核时:班主任看到一份名字正确的材料 → 通过 + ↓ +最终:B 的工作证明被认定成 A 的 ← 灾难,且事后无从追溯 +``` + +正确做法:`BELONGS_TO_OTHER` → **阻断** → 学生必须重新上传自己的材料。 + +**两阶段说明**:`OCR 值`在文件处理时**可能还没有**(OCR 是耗时操作)。因此: + +``` +阶段一(快,同步返回): + · 第 1/2/3/6 条 可在阶段一直接定案(只需文件名 + 一次名册反查) + · 第 4/5 条 因缺 OCR 而无法区分 → 先按第 5 条"阻断"给出提示 +阶段二(慢,异步回填 OCR): + · 若 OCR 值 = 名册 → 判定升级为第 4 条,【自动解除阻断】并重新放行 + · 若 OCR 值 ≠ 名册 → 维持阻断 + · 若结论由阻断变为放行 → 推送一条通知告知学生"可以继续了" +``` + +> ⚠️ **阶段二只能"解除"阻断,不能"新增"阻断**。理由:学生可能已经点了提交/去改别的材料, +> 此时突然因为异步结果被拦会非常困惑。**宁可阶段一从严(先拦),阶段二从宽(佐证后放)。** + +> ⚠️ **阶段二不得修改已提交的文件名**——只更新 issue 状态与放行判定。 +> 否则学生看到的名字会"自己变了"。 + +**性能说明**:第 3 条的"反查名册"是一次 `WHERE task_id = ? AND id_card_hash = ?` 查询, +走 `uk_task_idcard` 唯一索引,**< 1ms**。这是整张表里最强的判据,而实现成本最低。 + +### 1.5 边界与失败处理 + +| 场景 | 处理 | +|---|---| +| 空文件名 / 纯扩展名 | 全部字段用名册兜底;无名册则 `FAIL` 但放行 | +| 超长文件名(>200 字符) | 截断,**优先保留证件号与材料名**,截姓名部分 | +| 文件名全是乱码 | 尝试 UTF-8→GBK 转码;仍失败则用名册全兜底 | +| 无名册(历史整理器无名单) | `keepOriginalIfNoRoster=true`:只做格式清理,**不猜姓名与证件号** | +| 文件名里的证件号指向名册中他人 | **阻断**(VAL-21),不做任何自动修正 | +| 学生申诉"这就是我的材料" | 不是技术问题——**走「申请加入名单」通道**(FR-509)由管理员核实并入册;系统内不展示联系方式 | +| 模板变量全部为空 | 返回 `FAIL`,材料名用槽位名,其余用 `UNKNOWN_*` | +| 同一文件重复处理 | 幂等:比对 `file_hash`,已处理且输入未变则直接返回缓存结果 | + +### 1.6 测试要点(用真实数据) + +| 用例 | 输入 | 期望 | +|---|---|---| +| 双扩展名 | `曾伊豪-361029200907295110身份证.jpg.jpg` | `曾伊豪-361029200907295110-身份证.jpg` + VAL-06 sev1 | +| 缺分隔符 | `张三361029200907295110身份证.jpg` | 正确拆出三段 | +| 材料别名 | `...个人申报表.jpg` | 归一为 `工作证明` + VAL-18 sev1 | +| 乱名无号码 | `微信图片_20260928.jpg` | 按名册补全,**零提示**(`NO_IDCARD_IN_NAME`) | +| 姓名错别字(证件号正确) | `扬晨-360825200904303215-一寸照片.jpg`(名册为「杨晨」) | 按名册输出,**不产生面向学生的 issue**,仅留痕 | +| **他人材料(同任务)** | 文件名证件号能反查到名册里另一个人 | 🔴 **阻断提交**,标 `VAL-21` sev3,**无"仍然继续"** | +| **陌生人号码** | 证件号合法但名册查不到,且无 OCR 佐证 | 🔴 **阻断提交**,标 `VAL-21` sev3 | +| 陌生号 + OCR 佐证 | 同上但 OCR = 名册 | 自动按名册,信息级,**不阻断** | +| 校验位失败 | `于豪-...166169-身份证.jpg` | 自动按名册,信息级(**校验位失败 ≠ 真实号码,可自动改**) | +| 无名册 | 任意乱名 | 只清格式,不猜姓名 | +| **反例(防回归)** | 任何判为 `BELONGS_TO_OTHER` 的文件 | **绝不生成一份"名字正确"的输出**——这是数据污染,不是体验问题 | +| 幂等 | 同一文件跑两次 | 第二次 `steps=[]`、`issues=[]`、结果相同 | + +--- + +## M2 · 压缩引擎 + +### 2.1 职责与边界 + +| 项 | 内容 | +|---|---| +| **输入** | 图片/PDF 字节流 + `materialDef`(档位、上限、下限、目标尺寸) | +| **输出** | 处理后的字节流 + `{width, height, dpi, quality, finalSizeKb, profile, forced}` | +| **做** | 格式转换、等比缩放、质量调整、DPI 写入、证件照裁切 | +| **不做** | 不裁切(证件照除外);不改颜色;不加水印 | +| **硬约束** | **输出 ≤ `maxSizeKb`(默认 1024)**,无例外(D1 决策) | + +### 2.2 五档策略 + +| 档 | 名称 | 目标 | 适用 | 实测 | +|:--:|---|---|---|---| +| **P1** | 证件照 | 固定 **295×413**,白底,300dpi | 一寸照片 | 27/28 张原始 984×1378(5:7),来自同一照相馆 | +| **P2** | 证件翻拍 | 长边 ≤1600,q85 | 身份证 | 实测 1279×1706 为主 | +| **P3** | A4 扫描件 | 长边 **2480**(A4@300dpi),q85 | 工作证明 | 实测原始 1272dpi / 16MB → **806KB 均值、最大 831KB** | +| **P4** | 截图 | **不缩放**,保持 PNG | 学籍表、学信网备案表 | 实测 1025×590 PNG 40KB | +| **P5** | 兜底 | 长边 ≤2000,q80 | 未识别材料 | — | + +**关键参数**(可由 `compressConfig` 覆盖) + +| 参数 | 默认 | +|---|---| +| `defaultMaxSizeKb` | 1024 | +| `defaultTargetDpi` | 300 | +| `defaultQuality` | 85 | +| `minQuality` | 70(低于此值标记"强制压缩") | +| `hardFloorDpi` | 200 | +| `maxPixels` | 50,000,000(熔断) | +| `keepPngForScreenshot` | true | + +> ⚠️ **P4 档是唯一"不缩放"的档**。实测学籍表是教务系统截图(1025×590 PNG,40KB),**转 JPG 只会让文字边缘发虚,没有任何体积收益**。这条是实测结论,不是设计偏好。 + +### 2.3 主流程 + +``` +FUNCTION compress(stream, materialDef, config): + + meta = probe(stream) # 宽高、格式、EXIF 方向、原始字节数 + issues = []; steps = [] + + # ── 步骤 0:熔断检查 ───────────────────────── + IF meta.pixels > config.maxPixels: + issues.append(50003, "图片过大被熔断,转异步处理") + RETURN ASYNC_REQUIRED + + # ── 步骤 1:EXIF 方向校正(放在缩放前!)────── + # ⚠️ 顺序错了会导致宽高互换,最终图被拉扁 + IF meta.exifOrientation in [5,6,7,8]: + stream = rotate(stream, meta.exifOrientation) + meta = probe(stream) + steps.append(EXIF_ROTATE, before=meta.exifOrientation, after=1) + + # ── 步骤 2:格式决策 ───────────────────────── + profile = materialDef.compressProfile + targetFormat = decide_format(meta, profile, materialDef) + + # ── 步骤 3:尺寸决策 ───────────────────────── + targetSize = decide_size(meta, profile, materialDef, config) + # P1 → fixed(295,413) + # P4 → 原尺寸 + # 其他 → 长边等比缩到 targetLongSide,且不超过原尺寸(不放大!) + + # ── 步骤 4:先试"一次编码到位" ─────────────── + # 实测 300dpi/q85 在 28 张真实扫描件上 100% 达标 + # 所以绝大多数情况一次就够,无需循环降质 + result = encode(stream, size=targetSize, quality=config.defaultQuality, + format=targetFormat, dpi=materialDef.targetDpi) + + # ── 步骤 5:仍未达标才进入降质循环 ─────────── + trial = result + IF trial.sizeKb > materialDef.maxSizeKb: + issues.append(VAL-07, sev=2, "需降质压缩") + trial = binary_search_quality( + stream, targetSize, targetFormat, + lo = config.minQuality, + hi = config.defaultQuality, + targetKb = materialDef.maxSizeKb + ) + + IF trial.sizeKb > materialDef.maxSizeKb: + # 质量已到下限仍超标 → 降尺寸 + trial = shrink_until_fit( + stream, targetSize, targetFormat, + minQuality = config.minQuality, + minLongSide = config.hardFloorDpi 换算值, + targetKb = materialDef.maxSizeKb + ) + + IF trial.sizeKb > materialDef.maxSizeKb: + # 仍超标 → 强制模式 + trial = force_compress(stream, materialDef.maxSizeKb) # 允许低于 minQuality + forced = true + issues.append(VAL-07, sev=2, "强制压缩", + detail="已降至质量下限仍未达标,清晰度可能受损") + mark_for_spot_check(fileId) # FR-616 抽检清单 + ELSE: + forced = false + + # ── 步骤 6:体积下限保护 ───────────────────── + IF trial.sizeKb < materialDef.minSizeKb: + issues.append(VAL-20, sev=2, "文件体积异常小,可能不是原图", + detail="实测同批其他人为 " + peerMedianKb + "KB") + + # ── 步骤 7:二次有损防护 ───────────────────── + # 同一文件多次处理时,绝不在"已压过的结果"上再压 + # → 永远以 raw_path 的原图为输入 + assert input.is_raw == true + + RETURN { stream: trial, meta: ..., forced, issues, steps } +``` + +### 2.4 决策函数细节 + +**`decide_format`** + +``` +IF materialDef.outputFormat: + RETURN materialDef.outputFormat # 材料级指定优先 +IF profile == P4 AND config.keepPngForScreenshot: + RETURN PNG # 截图保持 +IF meta.format == GIF AND meta.frames > 1: + RETURN PNG # 动图取首帧转 PNG +IF meta.hasAlpha AND profile == P1: + RETURN PNG # 证件照保留透明通道 +RETURN JPG +``` + +**`decide_size`** + +``` +IF materialDef.fixedSize: # P1 证件照 + RETURN fixedSize # 295×413 +IF profile == P4: + RETURN (meta.width, meta.height) # 不缩放 +longSide = min(profile.targetLongSide, max(meta.width, meta.height)) + # ↑ 关键:不放大 +scale = longSide / max(meta.width, meta.height) +RETURN (round(meta.width*scale), round(meta.height*scale)) +``` + +> **"不放大"是一条例外安全阀**:实测有一张身份证仅 558×745,若按 P2 目标 1600 放大,**像素是插值出来的、体积反而更大**。放大不提供任何信息增益。 + +**`binary_search_quality`** + +``` +lo = minQuality; hi = defaultQuality; best = null +FOR i IN 1..6: # 最多 6 次编码 + mid = (lo + hi) / 2 + r = encode(stream, size, fmt, quality=mid) + IF r.sizeKb <= targetKb: + best = r; lo = mid # 达标,还能再试高质量 + ELSE: + hi = mid +RETURN best OR encode(stream, size, fmt, quality=minQuality) +``` + +> **二分而非线性递减**:线性从 q95 每次降 5,最坏要 6 次;二分 6 次能覆盖 q70~q95 的完整区间。实测数据显示**几乎总是第一次就达标**,所以这个循环是纯兜底。 + +### 2.5 资源与并发约束 + +| 约束 | 值 | 理由 | +|---|---|---| +| 单进程并发编码数 | **= CPU 核数** | 图像编码是 CPU 密集,超配会让所有请求一起变慢 | +| 单文件内存上限 | 512 MB | 5000 万像素的 RGBA 约 200MB,留 2.5 倍余量 | +| 熔断阈值 | 50,000,000 像素 | 超过转异步,不阻塞请求线程 | +| 单请求超时 | 同步 10s / 异步 120s | 超时转异步并通知 | +| 临时文件 | 处理完立即删 | 避免磁盘堆积 | + +**超大图处理**:先做**分块降采样**(读出时按比例抽点),再进编码。不要先解码整张再用库函数缩——那会瞬间吃掉几百 MB。 + +### 2.6 测试要点 + +| 用例 | 输入 | 期望 | +|---|---|---| +| **回归(Golden)** | 28 张真实 A4 扫描件 | **全部 ≤1M;均值 ≈806KB;最大 ≤831KB**;0 张 forced | +| 证件照规格化 | 实测 984×1378 的 27 张 | 输出恰好 295×413、300dpi | +| 截图保持 | 1025×590 PNG | 格式仍为 PNG,尺寸不变 | +| 极小图 | 558×745 身份证 | **不放大**,标 VAL-20 sev2 | +| 超大图 | 8000×10000 | 触发熔断转异步 | +| 二次有损 | 对已压过的文件再压 | 输入取自 `raw_path` 原图,不叠加损失 | +| EXIF | 带旋转标记的照片 | 宽高正确(不拉扁) | + +--- + +## M3 · 材料识别引擎 + +### 3.1 职责与边界 + +| 项 | 内容 | +|---|---| +| **输入** | 文件名、图片字节流、(可选)OCR 文本、(可选)VLM 结果 | +| **输出** | `{materialCode, confidence, source, conflict, needManualPlace, vlmCalled}` | +| **做** | 判断这份文件属于哪类材料;与所在槽位比对;冲突时提示 | +| **不做** | **不自动改文件归属**(唯一例外:散图分拣,见 3.6) | +| **失败策略** | **识别失败一律放行**——不得阻断提交 | + +### 3.2 四层递进结构 + +``` +层 1 文件名 ← 最快最准,实测 112/112 命中 + ↓ 未命中 +层 2 形态特征 ← 实测 99.1% 能分流派 + ↓ 仍不确定 +层 3 OCR 内容 ← 实测 32/32 命中(抽样) + ↓ 仍不确定 +层 4 VLM 视觉大模型 ← 实测触发率 2%~10%,成本可控 + ↓ 仍不确定 +结论:UNKNOWN → 保留在原槽位 + needManualPlace=false(放行) +``` + +> **为什么要四层而不是直接用 VLM**:VLM 最准但最贵最慢。前两层就能解决 99% 的分流,**绝大多数文件根本走不到第 4 层**。这是成本设计的核心。 + +### 3.3 层 1:文件名识别 + +``` +FUNCTION detect_by_name(fileName, materialDefs): + base = strip_extensions(fileName) + tokens = split(base, SEPARATORS) + + # ① 精确匹配材料名或别名 + FOR def IN materialDefs: + FOR alias IN ([def.materialName] + def.aliases): + IF alias IN base: + RETURN { code: def.materialCode, confidence: 0.99, source: NAME } + + # ② 强特征词匹配(可配置,见 3.5) + FOR word, code IN STRONG_FEATURES: + IF word IN base: + RETURN { code: code, confidence: 0.95, source: NAME } + + # ③ 槽位兜底:文件在哪个槽位上传的就认哪个 + IF materialDef.targetSlot: + RETURN { code: materialDef.targetSlot, confidence: 0.60, source: DEFAULT, weak: true } + + RETURN NOT_FOUND +``` + +**实测结论**:在 112 个真实文件上,文件名层**命中率 100%**。 +但注意——**这批文件是老师已经命名过的,属有偏样本**;学生的原始文件名可能是 `微信图片_2026....jpg` 这类无信息的名字。 + +> ✅ **该偏差已评估为"不影响交付"**(见确认函 D7 补充说明): +> 输出命名一律取名册、归属校验靠证件号反查名册、材料类型有**槽位兜底**、识别失败一律放行。 +> 即使层 1 完全失效,系统行为也只是**少一层"传错槽位"的交叉验证**,不会出错。 +> +> **因此本层的设计目标不是"猜得准",而是"有信息时能交叉验证,没信息时安静放行"。** + +### 3.4 层 2:形态特征 + +**只分流派,不定位具体材料**——这是实测得出的关键认知。 + +``` +FUNCTION detect_by_shape(meta, ocrHint): + + # ① 零文字判据(最可靠的一条) + IF ocrHint.textCharCount < 5: + RETURN { code: PHOTO1, confidence: 0.92, source: SHAPE, + reason: "整图文字量 < 5 字" } # 即 FR-614e + + # ② 尺寸与宽高比 + ratio = meta.width / meta.height + SWITCH: + CASE 0.70..0.72 AND textCharCount < 20: # 5:7 = 一寸比例 + RETURN { code: PHOTO1, confidence: 0.88, source: SHAPE } + CASE 0.62..0.66: # 身份证 85.6×54 反/正 + RETURN { code: IDCARD, confidence: 0.75, source: SHAPE } + CASE meta.width/meta.height > 1.4: # 横版 + RETURN { code: SCHOOL_ROLL, confidence: 0.62, source: SHAPE, + note: "疑似截图或表格式材料" } + CASE ELSE: + RETURN { code: WORK_PROOF, confidence: 0.55, source: SHAPE, + note: "竖版 A4 文档" } + + # ⚠️ 形态层的置信度上限是 0.88,不足以单独定案 + # 必须配合文件名或 OCR 才能提到 0.9+ +``` + +**实测数据支撑** + +| 特征 | 实测 | 用于 | +|---|---|---| +| 一寸照片 984×1378(5:7) | 27/28 张完全相同 | 形态判 PHOTO1 | +| A4 扫描件 1272dpi | 工作证明全是 | 分流派 | +| 学籍表 1025×590(16:9) | 截图特征明显 | 判 P4 档 | +| 身份证 1279×1706 | 为主流 | 判 IDCARD | + +> **形态层 99.1% 的"分流派"准确率**指的是能把材料分成 {证件照 / 证件翻拍 / A4文档 / 截图} 四派。**从"派"落到"具体材料名"还需要内容层**。 + +### 3.5 强特征词表(可配置) + +| 词 | 归属 | 实测状态 | +|---|---|---| +| 学籍 | `SCHOOL_ROLL` | ✅ 112 个真实文件实测 | +| 身份证、公民身份、人像面、国徽面 | `IDCARD` | ✅ 实测 | +| 一寸、证件照、免冠 | `PHOTO1` | ✅ 实测 | +| 申报表、工作证明、公章 | `WORK_PROOF` | ✅ 实测 | +| 学信网、备案表、电子注册 | `XJW_BA` | ⚠️ **零样本**——词是从官方模板文字要求里抄的,**待「社会评价」批次样本标定** | +| **(写不出词)** | `OTHER_PROOF` | ⚠️ **零样本**——含毕业证/社保单/职称/技能证书四类**版式完全不同**的东西,**可能不该做识别**,改为默认识别失败放行 + 学生自选槽位 | + +> ⚠️ **强特征词表目前只覆盖了自主认定批次的 4 类材料**(实测 112 个文件)。 +> `XJW_BA` 与 `OTHER_PROOF` **没有一份真实样本**——它们的词、形态阈值、压缩档位都是**推测**。 +> 详见确认函 D4。 + +**禁用词**(**禁止入表**——实测这些词在所有材料里都出现,无区分力) + +``` +姓名 · 学校 · 专业 · 出生 · 单位 · 年月 · 身份证号(作为词) +``` + +> ⚠️ **"身份证号"不能作为强特征词**:几乎每份材料上都印着这三个字。把通用词入表会导致**全部文件都被判成身份证**。这条是实测踩出来的坑。 + +### 3.6 层 3 / 层 4:内容识别与 VLM + +**层 3 OCR 内容** + +``` +FUNCTION detect_by_ocr(ocrText, rosterItem): + score = {} + score[IDCARD] += 3 IF /公民身份号码|中华人民共和国居民身份证/ + score[XJW_BA] += 3 IF /教育部学历证书电子注册备案表|在线验证码/ + score[SCHOOL_ROLL] += 2 IF /学籍|在读|入学日期|学号/ + score[WORK_PROOF] += 2 IF /职业技能等级认定|申报表|工作单位|兹证明/ + score[PHOTO1] += 4 IF textCharCount < 5 + RETURN argmax(score) IF max(score) >= 2 ELSE NOT_FOUND +``` + +**层 4 VLM** + +``` +FUNCTION detect_by_vlm(imageBytes, candidates): + IF NOT config.vlmEnabled: RETURN NOT_FOUND + IF estimatedCostExceeded(tenantId): RETURN NOT_FOUND # 成本闸 + prompt = "这是一份职业技能认定报名材料,请判断属于哪一类:" + candidates + r = vlm.classify(imageBytes, prompt) + RETURN { code: r.code, confidence: r.confidence, source: VLM } +``` + +| 成本控制 | 说明 | +|---|---| +| 触发条件 | 前三层都 `NOT_FOUND` 或最高置信度 < 0.7 | +| 租户级开关 | 可整体关闭 | +| 日成本闸 | 超预算自动关闭并告警 | +| `vlmCalled` 字段 | 落到 `collect_file`,**用于成本监控与效果回溯** | +| 展示规则 | **置信度 < 0.8 时不显示给用户**,避免误导 | + +### 3.7 冲突判定与处理 + +``` +FUNCTION evaluate_conflict(detected, slotMaterialCode, confidence): + IF detected == slotMaterialCode: + RETURN { conflict: false } + IF confidence < 0.70: + # 不确定就不说话 + RETURN { conflict: false, needManualPlace: false } + IF source == NAME AND confidence >= 0.95: + RETURN { conflict: true, needManualPlace: true, level: STRONG } + RETURN { conflict: true, needManualPlace: true, level: NORMAL } +``` + +**前端表现** + +| `level` | 文案 | +|---|---| +| `STRONG` | 这张图看起来是「**工作证明**」,你放在了「学籍表」。**要移过去吗?** [移到工作证明] [就放这里] | +| `NORMAL` | 这张图**可能是**「身份证」,你放在了「学籍表」。 [移过去] [保持不变] | + +> **一律不自动移动**。原因:识别错误时"自动移动"会把材料放到错误位置,而学生**不会发现**——最终在审核时才暴露,返工成本极高。让用户点一下,成本极低。 + +**唯一例外:散图分拣** + +学生一次传了 10 张图(如微信聊天记录里连续选),系统需要把散图分派到各材料槽。此时: + +``` +IF 一批文件属于同一次上传 AND 数量 > 该材料 maxCount: + 进入"分拣模式": + - 用识别结果给出建议分派 + - 在分拣界面里高亮"系统建议" + - 学生确认后落位 + - 未确认的留在"未分类"暂存区,不阻断提交 +``` + +### 3.8 失败与降级 + +| 场景 | 处理 | +|---|---| +| 四层全未命中 | `materialCode = null`,`confidence = 0`,**保留在原槽位**,`needManualPlace = false` | +| OCR 服务异常 | 跳过层 3,直接层 4 或 NOT_FOUND(`50002` 降级) | +| VLM 服务异常 | `50004`,取前三层最高分或 NOT_FOUND,**不阻断** | +| 成本闸触发 | 跳过层 4,行为同上 | +| 识别结果与槽位冲突且用户忽略 | 记录 `detect_conflict = true`,**审核时给班主任看**(供参考,不阻断) | + +### 3.9 测试要点 + +| 用例 | 输入 | 期望 | +|---|---|---| +| **回归(Golden)** | 112 个真实文件 | 文件名层 112/112;形态层分流派 99.1% | +| 零文字判据 | 一寸照片 | 判 PHOTO1,reason = "整图文字量 < 5 字" | +| 通用词陷阱 | 任意含"身份证号"四字的文件 | **不得**判成 IDCARD(除非其他证据) | +| 冲突不自动改 | 学籍表槽位放了工作证明 | `conflict=true`,**位置不变** | +| 低置信不报 | 置信度 0.65 的猜测 | `conflict=false`,不打扰用户 | +| 失败放行 | 全黑图片 | 识别失败但**能提交** | +| VLM 成本闸 | 触发后 | `vlmCalled=false`,走前三层 | + +--- + +## M4 · 门禁匹配引擎 + +### 4.1 职责与边界 + +| 项 | 内容 | +|---|---| +| **输入** | `realName`、`idCardNo`、`taskId`、`openid`、客户端 IP | +| **输出** | 匹配结果 + 绑定关系 + 预填数据 | +| **做** | 限流、规范化、匹配、形近字纠错、原子绑定、失败留痕 | +| **不做** | **不下发名单**;**不透露失败原因**;不记录失败尝试的明文证件号 | +| **安全等级** | **最高**——这是整个系统的入口闸门 | + +### 4.2 安全设计(六条) + +| # | 设计 | 防的是什么 | +|:--:|---|---| +| 1 | **统一失败提示** | 防探测:不区分"号码不在名单"与"姓名不符" | +| 2 | **名单不下发前端** | 防批量抓取:匹配全程服务端完成 | +| 3 | **失败不记明文证件号** | 防日志泄露 | +| 4 | **限流(openid + IP 双维度)** | 防枚举:18 位号码空间虽大,但配合常见生日可暴力 | +| 5 | **绑定原子性** | 防并发抢绑同一条名册项 | +| 6 | **索引走 hash 不走明文** | `id_card_hash` 上有唯一索引,明文列不建索引 | + +> **第 1 条最容易被做成"更友好"而破坏安全**:产品直觉会说"提示是号码错了更贴心",但那等于把名单变成了可枚举查询接口——攻击者能逐个确认"某身份证号是否在这批名单里"。**这是隐私泄露,不是体验问题。** + +### 4.3 主流程 + +``` +FUNCTION gate_match(realName, idCardNo, taskId, openid, ip): + + # ── 步骤 1:限流(在任何 DB 查询之前)─────── + r1 = rateLimit.check("gate:openid:" + openid, limit=10, window=3600) + r2 = rateLimit.check("gate:ip:" + ip, limit=20, window=60) + IF NOT r1.allowed OR NOT r2.allowed: + audit.log(attempt, openid, ip, result=RATE_LIMITED) + RETURN ERROR(40007b, "操作过于频繁,请稍后再试", + data={ retryAfterSec: max(r1.retryAfter, r2.retryAfter) }) + + # ── 步骤 2:规范化 ─────────────────────────── + name = normalize_name(realName) # 去空格、全角转半角、去不可见字符 + idc = normalize_idcard(idCardNo) # 转大写、去空格、O→0/I→1 不做! + # ⚠️ 门禁场景不做字符替换,只做格式净化 + IF NOT is_valid_format(idc): + RETURN ERROR(40007, UNIFIED_MSG) # 格式错也走统一提示,不给额外信息 + + hash = hmac_sha256(idc, SERVER_SALT) # 加盐哈希,防彩虹表 + + # ── 步骤 3:按 hash 定位 ───────────────────── + roster = SELECT * FROM collect_roster + WHERE task_id = :taskId + AND id_card_hash = :hash + AND deleted = 0 + LIMIT 2 + # ↑ LIMIT 2 而不是 1:若查出 2 条,说明名单里有重复证件号, + # 这是数据问题,需要走分支处理(见 4.5) + + IF roster IS EMPTY: + record_failure(taskId, openid, ip, hash) # 只记 hash + RETURN ERROR(40007, UNIFIED_MSG) + IF count(roster) > 1: + notify_admin("名单存在重复证件号", rosterIds) + RETURN ERROR(40007, UNIFIED_MSG) # 对外仍然不透露 + + r = roster[0] + + # ── 步骤 4:姓名比对 ───────────────────────── + IF r.real_name == name: + PASS + ELSE: + sim = max( char_similarity(name, r.real_name), # 形近字 + pinyin_similarity(name, r.real_name) ) # 同音字 + IF sim >= 0.85: + # 高相似 → 让用户确认(FR-504c) + token = issue_confirm_token(rosterId=r.id, openid=openid, ttl=300) + RETURN OK({ needConfirmName: true, + candidates: [{ rosterId: r.id, realName: r.real_name, similarity: sim }], + confirmToken: token, + tip: "你是不是「" + r.real_name + "」?" }) + ELSE: + record_failure(taskId, openid, ip, hash) + RETURN ERROR(40007, UNIFIED_MSG) + + # ── 步骤 5:绑定检查与原子绑定 ─────────────── + IF r.bind_user_id IS NOT NULL: + IF r.bind_user_id == currentUserId: + PASS # 幂等:自己绑过就放行 + ELSE: + RETURN ERROR(40007c, "该信息已被登记,如有疑问请联系老师") + + # 原子抢占:把"检查"和"写入"合成一条语句,杜绝并发窗口 + affected = UPDATE collect_roster + SET bind_user_id = :uid, bind_time = NOW(), bind_type = :type + WHERE id = :rosterId + AND bind_user_id IS NULL # ← 关键条件 + AND deleted = 0 + IF affected == 0: + # 说明在"检查"和"写入"之间被别人抢了 + RETURN ERROR(40007c, "该信息已被登记,如有疑问请联系老师") + + # ── 步骤 6:返回预填数据 ───────────────────── + RETURN OK({ + matched: true, + rosterId: r.id, + realName: r.real_name, + idCardMask: mask(idc), # ⚠️ 只返回脱敏 + orgName: r.org_name, + prefill: build_prefill(r) # 18 个业务列 + }) +``` + +### 4.4 并发绑定:为什么必须是 `UPDATE ... WHERE bind_user_id IS NULL` + +反例(错误实现): + +``` +r = SELECT ... WHERE id = :rid # 读到 bind_user_id = NULL +IF r.bind_user_id IS NULL: # 检查通过 + UPDATE collect_roster SET bind_user_id = :uid # 此时别人可能已写入! +``` + +两个请求同时读到 NULL,都会通过检查,都会 UPDATE —— **后者覆盖前者**,出现"一个人的材料挂到了另一个人名下"。 + +正确实现是**把判断放进 UPDATE 的 WHERE 里**,由数据库的行锁保证原子性,再看影响行数。 + +> 单机时代这可能只在压测时暴露;**但填报高峰(几百人同一时段抢)是必然场景**,这个洞一定会被踩到。 + +### 4.5 边界与失败处理 + +| 场景 | 处理 | +|---|---| +| 名单有重复证件号 | 记数据问题、通知管理员,对外统一失败 | +| 名册项已被他人绑定 | `40007c`(**这一条可以明确提示**——因为用户已经提供对了信息,提示不泄露新信息) | +| openid 绑定过但名册项被删 | 解绑,重新走门禁 | +| 用户换微信(openid 变了) | 走"申请换绑"流程 → 管理员审批(见确认函 B1) | +| 任务已截止/暂停 | 在门禁**之前**拦截,`40008` | +| 形近字确认 token 过期 | 重新走门禁(token TTL 5 分钟) | +| 学生被班主任代录 | `bind_type = 2`(代填),绑定人与提交人分离,审核时标注 | + +### 4.6 失败留痕策略 + +``` +FUNCTION record_failure(taskId, openid, ip, hash): + INSERT INTO 门禁失败记录 ( + task_id, openid, ip, id_card_hash, # ← 只有 hash,没有明文 + fail_time + ) + # 保留 7 天,用于: + # ① 限流计数 + # ② 异常模式识别(同一 IP 试很多个不同 hash = 枚举攻击) + # ③ 事后审计 +``` + +**异常模式告警**:同一 IP 在 10 分钟内用 > 20 个**不同** hash 尝试 → 判定为枚举攻击,IP 拉黑 24 小时并告警。 + +### 4.7 限流阈值标定 + +| 维度 | 建议值 | 依据 | +|---|---|---| +| 同 openid | 10 次/小时 | 正常学生最多输错 2~3 次(姓名不认识字、号码记错) | +| 同 IP | 20 次/分钟 | 学校机房的 NAT 出口下可能有多人同时填,**不能太低** | +| 同 IP 不同 hash 异常 | >20 个/10 分钟 | 枚举特征 | + +> ⚠️ **IP 维度是个双刃剑**:学校机房、运营商 NAT 会让几百人共享一个出口 IP。阈值定低了会**误伤正常填报**。所以 IP 维度只做**粗筛**(20 次/分钟),真正的严格限制放在 openid 维度。 + +### 4.8 测试要点 + +| 用例 | 期望 | +|---|---| +| 正确姓名+号码 | 通过,返回脱敏号与预填 | +| 号码对、姓名错 | **统一失败提示**(文案与"号码错"完全一致) | +| 号码错 | 统一失败提示 | +| 限流触发 | `40007b`,提示剩余等待时间 | +| 形近字(曾依豪 vs 曾伊豪) | 返回确认候选,`needConfirmName: true` | +| 员工 A 先绑,员工 B 再试 | B 得 `40007c` | +| **并发 100 个请求抢同一名册项** | **恰好 1 个成功,99 个得 `40007c`**(压测必测) | +| 失败尝试查库 | **无明文证件号** | +| 名单 5000 条时匹配耗时 | < 50ms(走 hash 索引) | +| 同 IP 20 人同时填报 | **全部通过**(不误伤 NAT) | + +--- + +### 4.9 名单加入申请通道(FR-509 · 客户确认后新增) + +> **这不是门禁的一部分,而是门禁的"旁路出口"**——学生进不来时,给他一条**受控的**求助路径。 + +#### 4.9.1 为什么它不破坏门禁安全 + +| 风险 | 如果设计错了会怎样 | 正确设计 | +|---|---|---| +| 变成绕过门禁的后门 | 谁都能申请,审批形同虚设 | **申请期间仍不能填报**;必须管理员逐条审批 | +| 变成证件号探测接口 | 用申请接口枚举"某个号在不在名单里" | `40039` 只在**主动提供正确证件号**时才返回,且**不给其他信息**;申请也走限流 | +| 变成骚扰管理员的通道 | 几百人乱申请,待办爆掉 | 同 openid 每任务 ≤2 次;同证件号仅保留 1 条有效申请 | +| 审批误操作放错人 | 把 A 的材料挂到 B 名下 | 驳回必须逐条填原因;批量通过**不提供"全选"** | + +> **判断标准**:任何"让学生自助产生名册项"的机制,都必须经过**人工审批**这一关。否则门禁就只是装饰。 + +#### 4.9.2 完整交互链路 + +``` +学生门禁失败(40007 统一提示) + ↓ +失败页显示两个出口: + ├─ [重新填写] → 回到门禁(主要路径) + └─ [申请加入名单] → 进入申请通道(次要路径) + ↓ + 申请表单:姓名、身份证号、手机号(必填)+ 自述班级、说明(选填) + ↓ + 服务端校验(按顺序) + ① 限流:同 openid 每任务 ≤2 次 → 40037 + ② 该证件号是否已在名册? → 40039(引导回去重填门禁) + ③ 该证件号是否已有待审/已通过申请? → 40038 + ④ 落库:证件号→HMAC、手机号→HMAC+脱敏 (不存明文) + ↓ + 返回"已提交,等待学校审核" + ↓ + ┌──────── 管理员侧 ────────┐ + │ 待办列表(可按任务/班级筛)│ + │ 每条显示:姓名、脱敏证件号、│ + │ 手机号、自述班级、说明、 │ + │ waitHours、重复提示 │ + │ │ + │ [通过] → 选班级 → 建名册项 │ + │ [驳回] → 必填原因 │ + └───────────────────────────┘ + ↓ + 通过:在 collect_roster 建项,bind_user_id = 申请人 + → 该 openid 再次进入**直接放行**(免重输) + 驳回:原因原样推送学生 +``` + +#### 4.9.3 审批通过的事务边界 + +``` +BEGIN + ① SELECT ... FROM collect_join_request WHERE id=? FOR UPDATE -- 行锁,防重复审批 + ② 校验 status = 0,否则 40040 + ③ SELECT ... FROM collect_roster + WHERE task_id=? AND id_card_hash=? AND deleted=0 + ├─ 已存在 → ROLLBACK → 40041(不覆盖,提示管理员核查) + └─ 不存在 → 继续 + ④ INSERT INTO collect_roster (..., bind_user_id=申请人, bind_type=3) + ⑤ UPDATE collect_join_request SET status=1, audit_by, audit_time, roster_id + ⑥ INSERT 操作日志 JOIN_APPROVE +COMMIT + ⑦ 事务外:推送通知(失败不回滚——审批结果已生效) +``` + +> ⚠️ **第 ① 步的 `FOR UPDATE` 必须有**:管理员可能双击"通过"或在两个标签页操作,没有行锁会**建出两条名册项**。 +> 这与门禁并发绑定是同一类问题(见 §4.4)。 +> +> ⚠️ **第 ⑦ 步在事务外**:通知发送失败不该回滚审批(用户已经点过了)。失败进重试队列。 + +#### 4.9.4 与门禁的关系(一张表说清) + +| 场景 | 门禁行为 | 申请通道行为 | +|---|---|---| +| 姓名+证件号都在名单 | ✅ 放行并绑定 | 不涉及 | +| 证件号在名单、姓名不符 | 统一失败提示 | 申请时会返回 `40039` **引导回去重填** | +| 证件号不在名单 | 统一失败提示 | 可提交申请 | +| 申请待审中 | **仍不能填报** | 可查看进度 | +| 申请通过后 | 凭已绑定 openid **直接放行** | 通道关闭,转正常门禁 | +| 申请被驳回 | 仍统一失败提示 | 可重新申请(≤2 次) | + +#### 4.9.5 测试要点 + +| 用例 | 期望 | +|---|---| +| 同 openid 第 3 次申请 | `40037` | +| 已驳回后再申请 | 允许(部分唯一索引放开非 0/1 状态) | +| 同证件号重复申请(待审中) | `40038` | +| 证件号已在名册但姓名不符 | `40039`,引导回门禁 | +| 数据库中查申请记录 | **无明文证件号与手机号** | +| 管理员双击"通过" | **只建 1 条名册项**(行锁 + 状态校验) | +| 审批通过后该 openid 再进 | 直接放行,不要求重输 | +| 通过时该证件号已被占用 | `40041`,不覆盖 | +| 通知发送失败 | 审批仍生效,通知进重试队列 | +| 单任务待审 > 50 | 列表返回 `alert` 告警 | + +--- + +## M5 · 校验规则引擎 + +### 5.1 职责与边界 + +| 项 | 内容 | +|---|---| +| **输入** | 一份填报单的全部文件与表单数据 + 名册项 + 任务配置 | +| **输出** | `issues[]`(含 `severity`、`autoFixed`),以及 `canSubmit` / `canExport` 判定 | +| **做** | 跑全部规则、去重、分级、决定阻断 | +| **不做** | 不修数据(修数据是 M1/M2 的事,M5 只判定) | + +### 5.2 规则清单与执行阶段 + +> **编号与 PRD §5.7 严格一致**(VAL-01~17 为原定义,VAL-18~20 为本次补充)。 + +| 规则 | 名称 | 执行阶段 | 默认级别 | +|---|---|---|---| +| `VAL-01` | 身份证号格式与校验位(ISO 7064 MOD 11-2) | ① 上传时 | 阻断 | +| `VAL-02` | 身份证号与名册一致 | ② 文件×名册 | 阻断 | +| `VAL-03` | 姓名与名册一致(近音字、形近字建议) | ② 文件×名册 | 警告 | +| `VAL-04` | **同一人跨材料证件号一致** | ③ 跨文件 | 阻断 | +| `VAL-05` | 材料完整性(必传项是否齐全) | ④ 跨材料 | 阻断 | +| `VAL-06` | 文件格式白名单(含**双扩展名**修正) | ① 上传时 | 信息(已修) | +| `VAL-07` | 文件体积上限(含降质与强制压缩) | ① 上传时 | 信息(已修)/ 警告(强制) | +| `VAL-08` | 图片可读性(分辨率过低 / 全黑 / 全白 / 模糊) | ① 上传时 | 警告 | +| `VAL-09` | 重复文件检测(文件哈希) | ⑥ 导入/上传 | 警告 | +| `VAL-10` | 材料有效期(身份证是否过期) | ⑦ 异步(OCR 后) | 警告 | +| `VAL-11` | 名册重复人员检测 | ⑥ 导入时 | 阻断 | +| `VAL-12` | 材料内容与类型匹配(防传错位置) | ⑦ 异步(识别后) | 警告 | +| `VAL-13` | 报考职业 / 工种 / 等级必须在报考范围内 | ⑤ 表单×配置 | 阻断 | +| `VAL-14` | 报考工种必须属于所选职业;无工种时只能填"无" | ⑤ 表单×配置 | 阻断 | +| `VAL-15` | 补考科目数量与等级匹配(三/四/五级 ≤1 科;一/二级 ≤2 科;**专项 0 科**) | ⑤ 表单×配置 | 阻断 | +| `VAL-16` | 在校生 = 是 时,学校 / 专业 / 入学时间三项必填 | ⑤ 表单×配置 | 阻断 | +| `VAL-17` | 本职业工作年限只能为阿拉伯数字 | ⑤ 表单×配置 | 阻断 | +| ⭐ `VAL-18` | **文件名规范化**(非法字符、缺分隔符、多扩展名冲突、材料名归一) | ① 上传时 | 信息(已修) | +| ⭐ `VAL-19` | **证件号疑似串档**(同一人跨材料互不一致) | ③ 跨文件 | 阻断(**转人工**) | +| ⭐⭐ `VAL-21` | **文件归属校验**(文件名证件号指向名册中的另一个人,或归属无法确证) | ① 上传时 | **阻断**(先拦后放,见 M1 §1.4) | +| ⭐ `VAL-20` | **文件体积下限保护**(异常小,疑似非原图) | ① 上传时 | 警告 | + +**执行阶段与依赖(必须按此顺序)** + +``` +① 上传时 · 单文件级(无外部依赖,可立即出结果) + VAL-06 格式白名单 | VAL-18 文件名规范化 | VAL-07 体积上限 + VAL-08 可读性 | VAL-01 证件号格式 | VAL-20 体积下限 + VAL-21 归属校验(反查名册,<1ms) + └─ 需要:该文件本身 + +② 文件 × 名册 + VAL-02 证件号与名册 | VAL-03 姓名与名册 + └─ 需要:①的结论 + 名册项 + +③ 跨文件(同一人的多份材料互相比对) + VAL-04 跨材料证件号一致 | VAL-19 疑似串档 + └─ 需要:②全部跑完(要拿到该生所有材料的证件号) + +④ 跨材料(该生的槽位完整性) + VAL-05 必传材料齐全 + └─ 需要:该生全部槽位状态 + +⑤ 表单 × 配置 + VAL-13 报考范围 | VAL-14 工种归属 | VAL-15 补考科目 + VAL-16 在校生三项 | VAL-17 工作年限 + └─ 需要:表单数据 + 任务配置 + 报考范围 + +⑥ 导入时(批量) + VAL-09 重复文件 | VAL-11 名册重复人员 + └─ 需要:全量数据 + +⑦ 异步(OCR / 识别完成后回填,不阻塞提交) + VAL-10 材料有效期 | VAL-12 内容与类型匹配 + └─ 需要:OCR 结果 / M3 识别结果 +``` + +> ⚠️ **`VAL-04` 必须等 `VAL-02` 全部跑完**。否则会出现"每个文件单独看都和名册一致,但彼此不一致"的情况被漏掉——这正是"疑似串档"的典型形态。 +> +> ⚠️ **阶段 ⑦ 的结论不回写学生端已展示的结果**。OCR 是异步的,若它在学生看过之后才发现"身份证已过期",只会**产生一条新 issue 供审核参考**,不会让学生的页面"自己变了"。 + +### 5.3 跨文件一致性判定(VAL-04) + +``` +FUNCTION check_cross_file(submission): + # 收集每份材料"最可信"的证件号 + verified = [] + FOR f IN submission.files WHERE f.is_current: + val = f.standardName.extractedIdcard + OR f.ocr.idcardValue + OR NULL + IF val IS NOT NULL: + verified.append({ fileId: f.id, materialCode: f.materialCode, value: val }) + + IF len(verified) < 2: RETURN NO_ISSUE # 只有一个来源,无从比对 + + groups = group_by(verified, value) + IF len(groups) == 1: RETURN NO_ISSUE + + # 多数派 = 出现次数最多的值 + majority = argmax(groups, by=count) + + # 与名册比对:名册所在的那一组就是"正确组" + IF roster.idCardNo IN groups: + correct = groups[roster.idCardNo] + ELSE: + correct = majority + # 名册值在文件里一个都没出现 —— 这才是最严重的 + RETURN ISSUE(VAL-19, sev=3, "疑似串档", + detail="该生全部材料的证件号均与名册不符,且彼此一致", + suggestion="可能整个文件夹被换过,请核对学生本人", + needHuman = true) # ← 绝不自动修正 + + FOR g IN groups WHERE g != correct: + RETURN ISSUE(VAL-04, sev=3, "本材料证件号与其他材料不一致", + detail = join(g.materialNames, '、') + "为 " + mask(g.value) + + "," + join(correct.materialNames,'、') + "为 " + mask(correct.value), + fileIds = g.fileIds) +``` + +**实测支撑**:真实数据里检出 **5 例证件号异常**,其中 **3 例可自动修正**(OCR 与名册一致,文件名错),**2 例需人工**,**1 例疑似串档**(4 项材料彼此一致但均与名册不符)。这段逻辑就是识别出那 1 例的核心。 + +### 5.4 阻断判定 + +``` +FUNCTION evaluate_blocking(issues): + blocking = [i FOR i IN issues IF i.severity == 3 AND NOT i.autoFixed] + warnings = [i FOR i IN issues IF i.severity == 2 AND NOT i.resolved] + infos = [i FOR i IN issues IF i.severity == 1] + + RETURN { + canSubmit: len(blocking) == 0, + canForceSubmit: len(blocking) == 0 AND len(warnings) > 0, + blocking, warnings, infos + } +``` + +**只有 5 条规则能产生阻断级问题**(`severity 3` 且不可自动修复) + +| 规则 | 场景 | +|---|---| +| `VAL-05` | 必传材料缺失 | +| `VAL-04` | 跨文件证件号不一致 / 疑似串档 | +| `VAL-13` | 报考项与范围不符 | +| `VAL-15` | 补考科目数超限 | +| `VAL-16` | 表单必填项缺失 | +| ⭐ `VAL-21` | **文件归属存疑**(疑似传错他人材料) | + +> **"严重度 3"不等于"阻断"**。`VAL-02` 在自动修正后仍是 severity 3 的**历史记录**,但 `autoFixed=true`,不阻断。这个区分不做,学生会看到一堆"你错了"而实际什么都不能改。 +> +> **但 `VAL-21` 是例外:它既严重又阻断**,且**不提供"仍然继续"**。归属错了就是错了, +> 没有"强制提交"这个选项——这一点与 VAL-08 之类的警告级问题有本质区别。 + +### 5.5 导出前置检查(与 M6 衔接) + +``` +FUNCTION evaluate_export(taskId): + blockers = [] + FOR s IN submissions WHERE status IN (2, 3): # 待终审 + 已通过 + FOR f IN s.currentFiles: + IF f.finalSizeKb > f.maxSizeKb: + blockers.append(FILE_SIZE, s, f) + IF NOT f.standardName.isNormalized: + blockers.append(NAME_INVALID, s, f) + FOR m IN s.requiredMaterials WHERE NOT s.hasFile(m): + blockers.append(MISSING_MATERIAL, s, m) + + RETURN { canExport: len(blockers) == 0, blocking: blockers } +``` + +> **铁律**:`canExport == false` 时**直接禁用导出按钮**。不允许"警告后放行"——这是一条经过争论后定下的硬规则,理由是:**带问题打包出去,问题会流到认定机构,返工成本远高于当场修**。 + +### 5.6 测试要点 + +| 用例 | 期望 | +|---|---| +| 真实批次全量跑 | 命名违规 **59**、双扩展名 **56**、体积超标 **31**、证件号异常 **5**(3 自动 + 2 人工)、疑似串档 **1**、分辨率不足 **1** | +| 单文件全对 | 0 blocking、0 warning | +| 缺必传材料 | `canSubmit=false`,blocking 含 VAL-05 | +| 自动修复的旧问题 | 出现在 issues 里但 `autoFixed=true`,**不阻断** | +| 跨文件不一致 | blocking 含 VAL-04,且**不自动改** | +| 导出前置 | 有 1 个超标文件 → `canExport=false` | + +--- + +## M6 · 导出打包引擎 + +### 6.1 职责与边界 + +| 项 | 内容 | +|---|---| +| **输入** | `taskId`、范围(班级/仅通过/含原图)、分卷大小、脱敏开关 | +| **输出** | 目录树 + 压缩包分卷 + 汇总 Excel | +| **硬约束** | **前置检查不通过则拒绝执行**(不生成任何产物) | + +### 6.2 输出结构 + +``` +{批次号}-{任务名}/ +├── 24中技数控1班/ +│ ├── 一寸照片/ 曾伊豪-361029200907295110-一寸照片.jpg +│ ├── 身份证/ 曾伊豪-361029200907295110-身份证.jpg +│ ├── 学籍表/ 曾伊豪-361029200907295110-学籍表.png +│ └── 工作证明/ 曾伊豪-361029200907295110-工作证明.jpg +├── 25中技数控2班/ +│ └── ... +└── 考生线下报名信息导入模板(汇总).xlsx +``` + +**汇总 Excel 的列**:与官方模板 21 列**严格对齐**(A–U),保证客户拿到就能直接用,不需再加工。 + +### 6.3 主流程 + +``` +FUNCTION create_export(taskId, scope, options): + + # ── 步骤 0:前置检查(铁律)──────────────── + chk = M5.evaluate_export(taskId, scope) + IF NOT chk.canExport: + RETURN ERROR(40035, "存在 N 项不合格文件,无法导出", data=chk) + + # ── 步骤 1:取快照 ─────────────────────────── + # ⚠️ 必须先把"要打包的文件清单"固化,再开始打包 + # 否则打包过程中学生撤回/重传会导致目录树与内容不一致 + snapshot = SELECT file_id, standard_name, std_path, org_name, real_name, material_name + FROM ... WHERE ... AND is_current = 1 + exportId = INSERT collect_export_task(task_id, scope_json=..., status=0) + + # ── 步骤 2:生成汇总 Excel ─────────────────── + xlsx = build_summary_excel(snapshot, officialTemplate21Cols) + + # ── 步骤 3:建目录树并写文件 ───────────────── + tree = build_tree(snapshot) # {班级: {材料: [文件名]}} + writer = ZipWriter(splitSizeMb = options.splitSizeMb, + entryNameEncoding = UTF8, # ★ 中文名必须显式指定 + compressLevel = 1) # ★ 图片已压过,再压没收益只耗时 + writer.add(xlsx.name, xlsx.bytes) + FOR student IN snapshot ORDER BY org_name, seq_no: + FOR file IN student.files: + writer.add(path = org/材料/标准名, source = storage.read(file.std_path)) + update_progress(exportId, delta=1) + save_checkpoint(exportId) # 每 200 个文件落一次进度 + + # ── 步骤 4:校验产物 ───────────────────────── + verify(writer): + assert 文件数 == snapshot.count + assert 每个文件名 == 规范名(再正则验一遍) + + # ── 步骤 5:落库并通知 ─────────────────────── + UPDATE collect_export_task SET status=2, result_files=..., file_count=..., finish_time=NOW(), + expire_time = NOW() + INTERVAL '7 days' + IF options.notifyOnFinish: send_notify(EXPORT_READY) + + RETURN OK({ exportId, resultFiles }) +``` + +### 6.4 三个易错点 + +| # | 错误做法 | 后果 | 正确做法 | +|:--:|---|---|---| +| 1 | 打包时现场查库决定文件 | 打包中数据变化 → 目录树与内容不一致、文件数对不上 | **先固化快照再打包** | +| 2 | zip 不指定 `entryNameEncoding` | 中文文件名在 Windows 解压**变乱码** | 显式指定 UTF-8,或输出前转 GBK 兼容模式 | +| 3 | 对已是 JPG 的图片再 zip 压缩 | CPU 白耗,体积**几乎不降** | `compressLevel = 1`(仅打包不压缩),图片的压缩已在 M2 完成 | + +**第 2 条是实测踩出来的**:中文 WinRAR/资源管理器解压 UTF-8 标记缺失的 zip 会显示乱码,而客户一定用 Windows。 + +### 6.5 分卷与断点续传 + +``` +分卷规则: + splitSizeMb = 50(默认) + 切分点:只在"文件边界"切,绝不切分单个文件 + 若单个文件 > splitSizeMb(罕见,如 PDF): + → 该卷单独容纳该文件,卷大小允许超出 + → 在汇总清单里标注"超大文件"供人工处理 +``` + +**断点续传**:每 200 个文件把 `progress` 落库。任务重启时从快照中断点继续,**已写入的条目不重写**。密码/签名 URL 有效期需覆盖最长打包时长(默认 2 小时)。 + +### 6.6 测试要点 + +| 用例 | 期望 | +|---|---| +| 真实批次(28 人 112 文件) | 目录树正确;**原始 475MB → ≈37MB**;128 个条目(112 文件 + 16 目录) | +| 中文文件名 | Windows 资源管理器解压**不乱码** | +| 存在超标文件 | **拒绝导出**,返回 blocking 清单 | +| 打包中数据变化 | 产物与快照一致(不含新提交) | +| 分卷 | 每卷 ≤50MB,**不切分单个文件** | +| 打包失败重试 | 从中断点继续,不重复打包已完成部分 | +| 导出包过期 | 7 天后自动清理,重新导出不重复计费 | + +--- + +## 附录 A · 模块间依赖与开发顺序 + +``` + ┌──────────────┐ + │ M4 门禁匹配 │ ← 无依赖,可最先开发 + └──────────────┘ + ┌──────────────┐ + │ M1 命名规范化 │ ← 无依赖,纯字符串处理,最易单测 + └──────┬───────┘ + ↓ +┌──────────────┐ ┌──────────────┐ +│ M3 材料识别 │───→│ M2 压缩引擎 │ ← 依赖 M1 的产物名 +└──────────────┘ └──────┬───────┘ + ↓ + ┌──────────────┐ + │ M5 校验引擎 │ ← 依赖 M1/M2/M3 的 issues + └──────┬───────┘ + ↓ + ┌──────────────┐ + │ M6 导出打包 │ ← 依赖 M5 的前置检查 + └──────────────┘ +``` + +**建议开发顺序与理由** + +| 顺序 | 模块 | 理由 | +|:--:|---|---| +| 1 | **M1** | 纯函数、无外部依赖、**可用 112 个真实文件立即做单测**,是最快出成果的一块 | +| 2 | **M4** | 安全关键,且完全独立;并发绑定测试需要压测环境,宜早准备 | +| 3 | **M2** | 依赖图像库选型;**参数已由实测标定**,验收标准明确 | +| 4 | **M5** | 依赖前三个的 issue 产出,天然靠后 | +| 5 | **M3** | 四层递进,层 3/4 依赖 OCR 与 VLM 选型;**但层 1/2 可先上** | +| 6 | **M6** | 依赖 M5,且可在主流程跑通后补 | + +> **M3 的层 1/2 可以先做、层 3/4 后补**——因为层 1 实测命中 100%,层 2 分流派 99.1%,这两层就够跑通主流程了。 + +--- + +## 附录 B · 回归测试基线(Golden Test Set) + +**数据集**:`D:\项目\202609机电填报系统\24中技数控1班\` —— 28 人 × 4 类 = 112 文件 + +**任何改动后必须复现以下数字,偏差即回归** + +| 指标 | 基线值 | 归属 | +|---|---|---| +| 原始总量 | 475 MB | — | +| 命名违规 | **59 / 112**(52.7%) | M1 | +| 双扩展名 | **56** | M1 | +| 体积超标(>1M) | **31** | M2 | +| 压缩后均值 | **806 KB** | M2 | +| 压缩后最大值 | **831 KB**(≤1024 ✓) | M2 | +| 强制压缩张数 | **0** | M2 | +| 证件号异常 | **5**(自动 3 + 人工 2) | M1/M5 | +| 疑似串档 | **1** | M5 | +| 分辨率不足 | **1**(558×745) | M5 | +| 文件名层识别 | **112 / 112** | M3 | +| 形态层分流派 | **99.1%** | M3 | +| OCR 内容层 | **32 / 32**(抽样) | M3 | +| VLM 触发率 | **2%~10%** | M3 | +| 导出包总量 | **≈37 MB**,压缩率 **≈92%** | M6 | +| ↳ 其中工作证明单项 | **444 MB → ≈22 MB**(28 份 A4 扫描件) | M2 | + +**测试环境要求** + +| 项 | 说明 | +|---|---| +| 数据库 | PostgreSQL 16(DDL 已实测通过) | +| 图像库 | 需支持 300dpi DPI 写入与 EXIF 方向读取 | +| 并发 | 门禁绑定测试需 ≥100 并发 | +| 字符集 | 文件名测试必须覆盖**中文 + 双扩展名 + 全角字符** | + +--- + +## 附录 C · 三条贯穿全局的设计原则 + +| # | 原则 | 体现 | +|:--:|---|---| +| 1 | **失败不阻断** | M3 识别失败放行;M5 只有 5 条规则可阻断;全部 5xxxx 错误码都定义了降级路径 | +| 2 | **不确定的不自动改** | M1 的 `NEED_HUMAN` 不自动改;M3 冲突不自动移位置;M5 疑似串档转人工 | +| 3 | **权威源单一** | 名册是姓名与证件号的唯一权威;M1/M5 都向它对齐,而不是互相猜 | + +> 这三条不是抽象原则,每一条都对应真实数据里踩出来的坑:**第 1 条**来自"识别不出就不让提交"会把完成率打到谷底;**第 2 条**来自"自动改错了没人发现"比"多看一眼"贵得多;**第 3 条**来自 5 例证件号异常里必须判断"以谁为准"。 + +--- + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| V1.0 | 2026-09-28 | 首版。覆盖 6 个模块(M1 命名 / M2 压缩 / M3 识别 / M4 门禁 / M5 校验 / M6 导出),全部为伪代码级说明;含模块依赖图、开发顺序建议、Golden Test Set 基线(15 项指标)、三条贯穿原则 | + + diff --git a/docs/05-开发任务拆解.md b/docs/05-开发任务拆解.md new file mode 100644 index 0000000..1c5e59a --- /dev/null +++ b/docs/05-开发任务拆解.md @@ -0,0 +1,505 @@ +# 开发任务拆解与排期(WBS) + +> 版本:V1.0 | 日期:2026-09-28 +> 读者:项目经理、技术负责人、全体开发、测试 +> 依据:《数据信息填报系统PRD.md》V2.3+、《03-接口设计.md》、《04-核心模块设计说明.md》 + +--- + +## 一、说明 + +### 1.1 怎么用这份文档 + +| 角色 | 用法 | +|---|---| +| 技术负责人 | 按 §3 的任务表派工;关注 §4 关键路径与 §5 风险 | +| 开发 | 认领任务 ID,从备注里取对应文档章节 | +| 测试 | 按 §7 的里程碑验收标准准备用例 | +| 项目经理 | 按 §6 团队配置排人;按 §7 卡节点 | + +### 1.2 估算口径 + +| 项 | 说明 | +|---|---| +| 单位 | **人日**(1 人日 = 1 名熟练工程师 6~7 小时有效产出) | +| 已含 | 自测、单元测试、联调、Code Review 返工 | +| 不含 | 需求变更、外部依赖等待、生产事故处理 | +| 缓冲 | **每个里程碑独立加 20% 缓冲**,不合并到总缓冲 | +| 依据 | 有明确设计文档支撑的任务按下限估;**无设计文档的任务(外部依赖/选型)按上限估** | + +### 1.3 前置假设 + +| # | 假设 | 若不成立的影响 | +|:--:|---|---| +| 1 | 微信小程序主体资质齐备 | **阻断上线**,见确认函 C1 | +| 2 | 认定机构书面接受 300dpi 输出 | 需重调压缩参数,影响 M3(+3 人日) | +| 3 | OCR 引擎可在 M2 结束时选定 | 影响 M4(+5 人日延期) | +| 4 | VLM 选型可延后至 M4 中期 | 影响小(M3 层 1/2 已能跑通) | +| 5 | 单租户数据量 < 5000 人 / 任务 | 超需额外分页与异步优化(+5 人日) | +| 6 | 客户名单格式与实测样本同构 | 表头探测需扩样本,影响 M2(+3 人日) | + +### 1.4 里程碑总览 + +``` +M0 ──→ M1 ──→ M2 ─────────────→ M3 ──→ M4 ──→ M5 ──→ M6 ──→ M7 +基础 任务 名册+门禁 填报 识别 审核 导出 收尾 +设施 配置 主流程 校验 闭环 交付 上线 + │ │ │ │ │ │ │ + └───────┴────────┴─────────────────┘ └───────┴──────┘ + ▲ 里程碑A:可演示"配置→收集" ▲ 里程碑B:可演示"全流程闭环" + (第 5 周末) (第 11 周末) + ▲ 里程碑C:可上线(第 15 周末) +``` + +### 1.5 工作量汇总 + +| 里程碑 | 内容 | 人日 | 含缓冲 | 关键角色 | +|:--:|---|:--:|:--:|---| +| **M0** | 基础设施与账号体系 | 40 | 48 | 后端 + 前端 | +| **M1** | 任务与配置闭环 | 44 | 53 | 前端(设计器重) | +| **M2** | 名册与门禁 | 58 | 70 | 后端(算法重)+ **新增 B1** | +| **M3** | 填报主流程 | 71 | 85 | **后端(引擎重)** | +| **M4** | 识别与校验 | 34 | 41 | 后端 + 算法 | +| **M5** | 审核闭环 | 56 | 67 | 前端 + 小程序 | +| **M6** | 导出与交付 | 32 | 38 | 后端 | +| **M7** | 收尾与上线 | 52 | 62 | 全员 | +| | **合计** | **387** | **464** | | + +--- + +## 一·五、一期范围(客户确认后收窄) + +> **一期只做「学校收集 + 自主认定」一条流程。** 详见 PRD「一期范围声明」。 + +| 后置项 | 原工作量 | 一期处理 | +|---|---|---| +| 社会收集审核流(跳过初审) | 配置驱动,≈0 | **不启用**;代码分支不做 | +| 社会评价材料(`XJW_BA` 学信网备案表) | 需样本标定 | **不做**;识别词表暂缺 | +| 专项职业能力支持 | 配置驱动,≈0 | **不启用**;`FR-310` 标二期 | +| 其它佐证材料(`OTHER_PROOF`) | 需样本标定 | **不做**;且倾向"不做识别、纯手动槽位" | + +**受影响的 5 个任务(各减 1 人日)** + +| 任务 | 原 | 现 | 减少原因 | +|---|:--:|:--:|---| +| M4-01 识别层 1(强特征词表) | 3 | **2** | 一期只标定 4 类材料 | +| M4-02 识别层 2(形态特征) | 3 | **2** | 一期只覆盖自主认定批次形态 | +| M5-01 待审列表 | 4 | **3** | 一期只有一条审核流 | +| M7-08 Golden Set 回归脚本 | 4 | **3** | 一期只覆盖 4 类材料 | +| M7-11 用户手册 / 培训 | 3 | **2** | 一期流程更简单 | + +> ⚠️ **二期启用时必须补的**:`XJW_BA` / `OTHER_PROOF` 的识别标定与回归断言、社会收集审核流、专项职业能力字段隐藏。 +> 这些都已在文档中保留描述,只是标注为二期。 + +--- + +## 二、任务编号规则 + +``` +M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4 个任务 +├─ 依赖:前置任务 ID,可并行则写"—" +├─ 人日:见 1.2 口径 +└─ 备注:指向对应文档章节,开发直接翻过去 +``` + +--- + +## 三、任务拆解 + +## M0 · 基础设施与账号体系(40 人日) + +> **目标**:能登录、能建组织、能建用户。这是所有后续任务的地基。 + +| ID | 任务 | 依赖 | 人日 | 角色 | 备注 | +|---|---|:--:|:--:|---|---| +| M0-01 | 环境搭建:PG 16 / Redis / OSS / 对象存储策略 / 日志采集 | — | 3 | 后端 | 执行 `db/schema-postgresql.sql`(已实测) | +| M0-02 | 后端项目骨架:分层、统一响应、异常处理、多租户拦截器 | M0-01 | 3 | 后端 | 接口设计 §1.3 | +| M0-03 | 前端项目骨架:PC 管理端 + 小程序,路由/请求封装/权限指令 | — | 3 | 前端 | — | +| M0-04 | CI/CD 与多环境(dev/test/prod)发布流水线 | M0-02 | 2 | 后端 | — | +| M0-05 | 租户/组织 CRUD(后端) | M0-02 | 4 | 后端 | 接口 §6.1~§6.4 | +| M0-06 | 用户与角色 CRUD(后端,含批量导入) | M0-05 | 4 | 后端 | 接口 §5.6~§5.9 | +| M0-07 | PC 登录鉴权(含图形验证码、失败锁定) | M0-06 | 3 | 后端 | 接口 §5.1 | +| M0-08 | 小程序登录 + 手机号绑定 | M0-02 | 2 | 后端 | 接口 §5.3 | +| M0-09 | 管理端整体框架:布局/导航/表格/表单组件库 | M0-03 | 5 | 前端 | 参照 `admin-web.html` 视觉 | +| M0-10 | 组织与用户管理页 | M0-09 | 4 | 前端 | — | +| M0-11 | 登录页 + 权限菜单渲染 | M0-09 | 3 | 前端 | — | +| M0-12 | 小程序框架与首页骨架 | M0-03 | 2 | 前端 | 参照 `student-app.html` | +| ⭐ M0-13 | **H5 扫码填报保底方案技术预研**(C1 风险对策) | M0-03 | 2 | 前端 | 若小程序资质受阻,H5 可保底上线;**只预研不开发** | +| **小计** | | | **40** | | | + +**M0 验收标准(可演示)** + +- [ ] 能开通租户 → 建组织树 → 导入 42 个用户 → 各角色登录看到不同菜单 +- [ ] 跨租户访问被拦截 +- [ ] `schema-postgresql.sql` 在测试环境零错误执行 + +--- + +## M1 · 任务与配置闭环(44 人日) + +> **目标**:管理员能配出一个完整任务(含表单与材料规则),但还不能收集。 + +| ID | 任务 | 依赖 | 人日 | 角色 | 备注 | +|---|---|:--:|:--:|---|---| +| M1-01 | 任务 CRUD + 状态机(草稿/发布/暂停/恢复/截止/归档) | M0-05 | 6 | 后端 | 接口 §7.1~§7.5;**暂停与截止语义不同** | +| M1-02 | 任务覆盖班级与班主任配置(`collect_task_org`) | M1-01 | 3 | 后端 | 接口 §7.6;主/副班主任 | +| M1-03 | 表单 schema 版本化存储与冲突检测 | M1-01 | 4 | 后端 | 接口 §7.9;`baseVersion` 冲突返回 40025 | +| M1-04 | 材料项定义 CRUD | M1-01 | 4 | 后端 | 接口 §7.10;28 个字段 | +| M1-05 | 规则模板保存与应用 | M1-04 | 3 | 后端 | 接口 §7.13 | +| M1-06 | 填报二维码 / 短链生成 | M1-01 | 2 | 后端 | 接口 §7.12 | +| M1-07 | **表单设计器**(字段库/画布/属性三栏,内置字段锁定) | M0-09 | 8 | 前端 | 原型 STEP 08;内置字段不可删 | +| M1-08 | 条件显示规则配置 UI | M1-07 | 3 | 前端 | 三条级联:补考/在校生/专项 | +| M1-09 | **材料规则配置**(命名模板编辑器 + 档位选择) | M0-09 | 6 | 前端 | 原型 STEP 09;档位下拉写明适用条件 | +| M1-10 | 任务向导与任务列表/详情页 | M1-07 | 5 | 前端 | 7 步向导 | +| **小计** | | | **44** | | | + +**M1 验收标准** + +- [ ] 配出一个任务:21 个内置字段 + 2 个扩展字段 + 4 个材料项 + 3 条条件规则 +- [ ] 已发布任务改表单 → 自动升版本,旧填报单不受影响 +- [ ] 内置字段删除按钮**不可点**(前后端双重校验) + +--- + +## M2 · 名册与门禁(58 人日) + +> **目标**:能导入真实名单、能聚合报考范围、学生能通过门禁进来。 + +| ID | 任务 | 依赖 | 人日 | 角色 | 备注 | +|---|---|:--:|:--:|---|---| +| M2-01 | **表头探测算法**(扫前 10 行 + 已知列名打分 + 候选回传) | M1-01 | 4 | 后端 | 04 文档 M1;**实测表头在第 1 行和第 6 行都有** | +| M2-02 | 名册导入 + 体检报告(证件号校验位/重复行/必填缺失) | M2-01 | 5 | 后端 | 接口 §8.1;`dryRun` 模式 | +| M2-03 | 名册追加导入(按证件号去重合并)与变更留痕 | M2-02 | 3 | 后端 | FR-506/508 | +| M2-04 | 报考范围聚合(按职业/工种/等级分组) | M2-02 | 4 | 后端 | 接口 §7.7;**不存在计划文件** | +| M2-05 | 报考范围一致性体检(5 类 issue) | M2-04 | 3 | 后端 | 含 `SCOPE_LEVEL_MIXED` 等 | +| M2-06 | **门禁匹配引擎**(限流/规范化/匹配/原子绑定) | M0-08 | 6 | 后端 | 04 文档 M4;**并发绑定走 UPDATE...WHERE** | +| M2-07 | 姓名形近字/同音字确认流程(含 confirmToken) | M2-06 | 2 | 后端 | FR-504c | +| M2-08 | 名册导入 UI(表头可视化 + 错行定位 + dryRun 确认) | M1-10 | 5 | 前端 | 原型 ③ | +| M2-09 | 报考范围确认 UI(分组 + 体检提示 + 排除操作) | M1-10 | 4 | 前端 | 原型 ④;**必须全覆盖校验** | +| M2-10 | 学生端门禁屏 + 统一失败页 + 形近字确认弹层 | M0-12 | 5 | 前端 | 原型 ①②③ | +| M2-11 | 学生端"我的任务列表" | M0-12 | 3 | 前端 | 接口 §9.1 | +| M2-12 | 班主任端班级看板 | M0-12 | 4 | 前端 | 接口 §10.1;**完成率=已提交率 82%** | +| ⭐ M2-13 | **加入申请后端**:提交 / 限流 / 去重 / 证件号与手机号转 HMAC | M2-06 | 3 | 后端 | 接口 §8.7.1~8.7.2;**不存明文**;`40037/40038/40039` 三个分支 | +| ⭐ M2-14 | **审批接口**:通过建模册项(行锁)/ 驳回 / 通知推送 | M2-13 | 2 | 后端 | 接口 §8.7.3~8.7.5;**`FOR UPDATE` 必须有**,事务外发通知 | +| ⭐ M2-15 | **学生端申请入口与表单屏**(失败页新增出口) | M2-10 | 2 | 前端 | 门禁失败页加「申请加入名单」+ 申请表单 + 我的申请状态 | +| ⭐ M2-16 | **管理端审批页**(列表 + 通过选班 + 驳回填原因) | M2-09 | 2 | 前端 | 驳回原因必填;**不提供"全选"** | +| ⭐ M2-17 | 社会收集名单来源落地(B2):与报名点对齐预报名名单流程 | M2-02 | 1 | 后端 | 说明性工作 + 与客户确认字段口径 | +| **小计** | | | **58** | | | + +**M2 验收标准** + +- [ ] 导入真实名单 `24中技数控1班`(28 人):正确识别表头在第 **6** 行 +- [ ] 导入官方空白模板(表头第 **1** 行):也能正确识别 +- [ ] 体检检出:证件号校验位失败 **1 例**、重复 **1 例** +- [ ] 门禁:正确通过 / 错误统一提示 / 形近字确认 / **并发 100 请求抢同一名册项恰好 1 个成功** +- [ ] 报考范围:3 组,其中专项职业能力组标 `NEED_REVIEW` + +--- + +## M3 · 填报主流程(71 人日)⚠️ 最重里程碑 + +> **目标**:学生能真实走完"选文件 → 处理 → 填写 → 提交"。**两个核心引擎在这里落地。** + +| ID | 任务 | 依赖 | 人日 | 角色 | 备注 | +|---|---|:--:|:--:|---|---| +| M3-01 | **M1 命名规范化引擎**(分层步骤 + 幂等) | M2-02 | 6 | 后端 | 04 文档 M1;**建议最先做,纯函数好测** | +| M3-02 | 证件号抽取(18/15 位 + 形近字替换 ≤2 位) | M3-01 | 2 | 后端 | 替换次数上限是硬约束 | +| M3-03 | 证件号三源裁决(文件名/名册/OCR) | M3-02 | 3 | 后端 | 含 `NEED_HUMAN` 不自动改 | +| M3-04 | 姓名一致性与形近字纠错 | M3-01 | 2 | 后端 | 复用 M2-07 的相似度算法 | +| M3-05 | **M2 压缩引擎**(五档 + EXIF + 二分降质 + 二次有损防护) | M0-01 | 12 | 后端 | 04 文档 M2;用 112 个真实文件回归 | +| M3-06 | 体积下限保护 + 强制压缩标记 + 抽检清单 | M3-05 | 3 | 后端 | FR-616、VAL-20 | +| M3-07 | 文件直传:upload-token + 秒传 + 端侧压缩提示 | M0-01 | 4 | 后端 | 接口 §9.5 | +| M3-08 | 文件处理接口(同步 + 异步 + 进度轮询) | M3-07 | 4 | 后端 | 接口 §9.6 | +| M3-09 | 批量处理(多材料一次提交) | M3-08 | 2 | 后端 | 接口 §9.7 | +| M3-10 | 表单填写页(预填标注 + 三态只读/可改/留痕 + 级联) | M1-07 | 8 | 前端 | 原型 ④;`changedFields` 高亮 | +| M3-11 | 草稿自动保存与并发冲突提示 | M3-10 | 3 | 前端 | 每 15 秒 | +| M3-12 | 材料上传页(三来源 + 槽位 + 多选 + 进度) | M0-12 | 6 | 前端 | 原型 ⑤⑥;**无拍照入口** | +| M3-13 | 处理结果卡(命名前后对比 + 体积压缩率 + 分步明细) | M3-08 | 5 | 前端 | 原型 ⑦ | +| M3-14 | 进度与问题清单页 | M3-13 | 4 | 前端 | 接口 §9.12 | +| M3-15 | 提交校验与阻断展示(blocking/warnings 分流) | M3-14 | 5 | 前端 | 接口 §9.10;`canForceSubmit` | +| M3-16 | 提交 / 撤回 | M3-15 | 2 | 前端 | — | +| **小计** | | | **71** | | | + +**M3 验收标准(用真实数据)** + +- [ ] **回归基线全部命中**:命名违规 **59**、双扩展名 **56**、体积超标 **31** +- [ ] 压缩后 28 张 A4 扫描件:**均值 ≈806KB、最大 ≤831KB、0 张强制压缩** +- [ ] 证件号异常 **5 例**(自动 3 + 人工 2) +- [ ] 学生能在一台手机上完整走完 4 类材料的上传与提交 +- [ ] 提交时的阻断清单能定位到**具体某人的某项材料** + +--- + +## M4 · 识别与校验(34 人日) + +> **目标**:材料自动归类、问题自动发现。**识别失败必须放行。** + +| ID | 任务 | 依赖 | 人日 | 角色 | 备注 | +|---|---|:--:|:--:|---|---| +| M4-01 | M3 识别层 1(文件名 + 强特征词表) | M3-01 | 2 | 后端 | 04 文档 M3;禁用通用词入表。**一期只标定 4 类材料**(二期再加 `XJW_BA`/`OTHER_PROOF`) | +| M4-02 | M3 识别层 2(形态特征 + 零文字判据) | M4-01 | 2 | 后端 | 零文字才是 PHOTO1 的可靠判据。**一期只覆盖自主认定批次形态** | +| M4-03 | **OCR 引擎接入与选型落地** | M0-01 | 3 | 后端 | 确认函 D1;需评估数据出境 | +| M4-04 | M3 识别层 3(OCR 内容打分) | M4-03 | 2 | 后端 | — | +| M4-05 | VLM 接入 + 成本闸 + 触发率监控 | M4-03 | 4 | 后端 | 04 文档 3.6;**置信度 <0.8 不展示** | +| M4-06 | 冲突判定与一键移动 | M4-02 | 3 | 后端 | **只提示不自动改位** | +| M4-07 | 散图分拣模式 | M4-06 | 3 | 后端 | 唯一允许"建议落位"的场景 | +| M4-08 | **M5 校验引擎**(20 条规则 + 7 阶段依赖) | M3-03 | 8 | 后端 | 04 文档 M5;**VAL 编号以 PRD 为准** | +| M4-09 | 跨文件一致性判定(VAL-04 / VAL-19) | M4-08 | 3 | 后端 | 疑似串档转人工 | +| M4-10 | 识别结果 UI(置信度展示 + 冲突卡片 + 一键移动) | M4-06 | 4 | 前端 | 学生端 ⑧ | +| **小计** | | | **34** | | | + +**M4 验收标准** + +- [ ] 112 个真实文件:文件名层 **112/112**,形态层分流派 **≈99%** +- [ ] 零文字判据命中一寸照片 +- [ ] **"身份证号"四个字不会导致误判**(通用词禁入表) +- [ ] 全黑图片:识别失败但**能提交** +- [ ] VLM 触发率在 **2%~10%** 区间 + +--- + +## M5 · 审核闭环(56 人日) + +> **目标**:班主任在手机上完成初筛,管理员在 PC 完成终审,全程可追溯。 + +| ID | 任务 | 依赖 | 人日 | 角色 | 备注 | +|---|---|:--:|:--:|---|---| +| M5-01 | 待审列表接口(红黄绿分级 + `batchApprovable` 服务端判定) | M4-08 | 3 | 后端 | 接口 §10.2。**一期只需处理学校收集一条审核流** | +| M5-02 | 审核详情接口(含识别结果、变更字段、时间轴) | M5-01 | 3 | 后端 | 接口 §11.2;I10 缺口落点 | +| M5-03 | 通过 / 驳回(含 redCount 拦截) | M5-02 | 3 | 后端 | **有红问题的单不允许通过** | +| M5-04 | 批量通过(**只带绿色项,服务端强制**) | M5-03 | 2 | 后端 | 铁律;红黄一律跳过 | +| M5-05 | 审核撤回(含"已导出不可撤"约束) | M5-03 | 2 | 后端 | 接口 §11.4 | +| M5-06 | 代传材料(留痕 `source=4`) | M5-03 | 3 | 后端 | 学生端可见"由李老师代传" | +| M5-07 | 待审卡片流(**只右滑通过**,左滑不绑定驳回) | M5-01 | 8 | 前端 | 原型 ②;`actionSource` 埋点 | +| M5-08 | 学生详情页(材料预览 + 问题清单 + 变更高亮) | M5-02 | 6 | 前端 | 原型 ③ | +| M5-09 | 驳回页(原因快选 + 二次确认 + 语音转文字) | M5-08 | 5 | 前端 | 原型 ⑤;**驳回必须点按钮** | +| M5-10 | 一键通过确认页(列出将通过的名单) | M5-07 | 3 | 前端 | 原型 ④ | +| M5-11 | 管理端终审列表与详情 | M5-02 | 5 | 前端 | 原型 ⑥ | +| M5-12 | 通知:订阅消息 + 降级链(订阅→公众号→短信→站内信) | M0-08 | 6 | 后端 | 接口 §13.6;额度登记 | +| M5-13 | 消息中心(学生端 + 教师端) | M5-12 | 4 | 前端 | 接口 §13.5 | +| M5-14 | 催报(定向 + 一键 + 去重) | M5-12 | 3 | 后端 | `excludeRecentHours` 防骚扰 | +| **小计** | | | **56** | | | + +**M5 验收标准** + +- [ ] 班主任在手机上:右滑通过 3 个、驳回 1 个(带原因)、一键通过剩余绿色项 +- [ ] 一键通过时**红色项被自动跳过并回报原因** +- [ ] 撤回一条已通过的记录 → 时间轴出现"撤回"记录 +- [ ] **已导出打包的单不可撤回**(返回 40033) +- [ ] 通知降级:额度耗尽时静默降级,学生端无异常提示 + +--- + +## M6 · 导出与交付(32 人日) + +> **目标**:一键产出可直接上报的规范包。**有不合格文件就禁用导出按钮。** + +| ID | 任务 | 依赖 | 人日 | 角色 | 备注 | +|---|---|:--:|:--:|---|---| +| M6-01 | 导出前置检查(三类 blocking) | M4-08 | 4 | 后端 | 铁律;`canExport=false` | +| M6-02 | **导出打包引擎**(快照固化 + 目录树 + 分卷 + 断点续传) | M6-01 | 10 | 后端 | 04 文档 M6;**先固化快照再打包** | +| M6-03 | 中文文件名编码(Windows 解压不乱码) | M6-02 | 2 | 后端 | `entryNameEncoding` | +| M6-04 | 汇总 Excel(严格对齐官方 21 列 A–U) | M6-02 | 4 | 后端 | 客户拿到就能用 | +| M6-05 | 导出中心 UI(进度 + 结果 + 下载) | M6-02 | 3 | 前端 | 原型 ⑦ | +| M6-06 | **目录树预览**(导出前可见最终交付形态) | M6-01 | 3 | 前端 | 原型 ⑦ 右侧 | +| M6-07 | 导出包过期清理与重新导出(不重复计费) | M6-02 | 2 | 后端 | 默认 7 天 | +| M6-08 | 开放接口(token / submissions / files / callback) | M6-04 | 4 | 后端 | 接口 §14;HMAC 加签 | +| **小计** | | | **32** | | | + +**M6 验收标准** + +- [ ] 真实批次导出:**原始 475MB → ≈37MB**,目录树正确 +- [ ] Windows 资源管理器解压**中文名不乱码** +- [ ] 存在 1 个超标文件 → **导出按钮禁用**,并指出是"谁的哪一项" +- [ ] 打包中途数据变化 → 产物与快照一致 +- [ ] 汇总 Excel 可被官方模板**直接打开并对齐 21 列** + +--- + +## M7 · 收尾与上线(52 人日) + +| ID | 任务 | 依赖 | 人日 | 角色 | 备注 | +|---|---|:--:|:--:|---|---| +| M7-01 | 数据看板(完成率/异常率/驳回率/平均时长 + 按工种统计) | M5-02 | 6 | 后端 | **`submitRate` 与 `passRate` 分开报** | +| M7-02 | 数据看板 UI(含"来自正式列"标注) | M7-01 | 5 | 前端 | 原型 ⑤;走索引实时 | +| M7-03 | 字典管理(6 个内置字典 + 租户自维护) | M0-05 | 4 | 后端 | **职业字典只做拼写校验,不强约束** | +| M7-04 | 操作日志 + 登录日志(含敏感操作留痕) | M0-06 | 4 | 后端 | `VIEW_IDCARD_FULL` 单独留痕 | +| M7-05 | 日志查询 UI | M7-04 | 2 | 前端 | — | +| M7-06 | **历史数据整理器**(上传→解析→规范化→报告) | M6-02 | 8 | 后端 | 差异化获客功能 | +| M7-07 | 整理器 UI(体检报告 + 结果导入) | M7-06 | 5 | 前端 | 原型 ⑩;`autoHandled: 90 / needManual: 1` | +| M7-08 | **Golden Set 回归脚本**(15 项断言自动化) | M3-05 | 3 | 测试 | 04 文档附录 B 的基线。**一期只覆盖 4 类材料**,二期补 `XJW_BA`/`OTHER_PROOF` | +| M7-09 | 压测与调优(门禁并发/上传并发/导出大任务) | M6-02 | 5 | 后端 | 重点关注 M4 并发绑定 | +| M7-10 | 灰度发布 + 生产环境准备 + 数据迁移预案 | M7-09 | 5 | 全员 | — | +| M7-11 | 用户手册 / 培训材料 / 客服话术 | M7-07 | 2 | 产品 | 一期只有学校收集一条流程,手册更简单 | +| M7-12 | 定时任务(9 项,见 PRD 附录 E) | M6-07 | 3 | 后端 | 清理/催报/超时提醒/用量统计 | +| **小计** | | | **52** | | | + +> 📌 **M7 含大量非编码工作**(培训、手册、灰度、压测),与前面纯开发里程碑性质不同。 + +--- + +## 四、关键路径 + +``` +M0-01 环境 ──→ M0-02 后端骨架 ──→ M0-05 组织 ──→ M0-06 用户 ──→ M0-07 登录 + │ + ▼ + M1-01 任务CRUD ──→ M2-01 表头探测 ──→ M2-02 名册导入 + │ │ │ + ▼ ▼ ▼ + M1-07 表单设计器 M2-06 门禁引擎 M3-01 命名引擎 + │ │ + │ ▼ + │ M3-05 压缩引擎 ★ + │ │ + ▼ ▼ + M2-10 门禁UI M3-08 文件处理 + │ + ▼ + M4-08 校验引擎 ──→ M5-03 审核动作 + │ + ▼ + M6-01 前置检查 ──→ M6-02 打包引擎 ★ + │ + ▼ + M7-09 压测 ──→ M7-10 上线 +``` + +**★ 两个最长的单点(合计 22 人日,且无法靠加人压缩)** + +| 任务 | 人日 | 为什么不能加人 | +|---|:--:|---| +| **M3-05 压缩引擎** | 12 | 图像参数调优是串行的验证循环,多人并行的收益是负的 | +| **M6-02 打包引擎** | 10 | 依赖快照设计,拆开做反而要重写 | + +**关键路径总长**:约 **6 + 5 + 4 + 6 + 12 + 4 + 8 + 3 + 4 + 10 + 5 = 67 人日**,客户确认后 **+9**(申请通道 4 个任务落在 M2)⇒ **76 人日** + +**工期由关键路径决定,不能按人日总数摊人**: + +| 算法 | 结果 | 说明 | +|---|---|---| +| 总工作量摊人 | 456 人日 ÷ 8.5 人 ≈ **11 周** | ❌ 这是**下限**,实际达不到 | +| **关键路径串行** | 76 人日 ≈ **15.2 周** | ✅ **这才是真实工期** | + +**为什么以关键路径为准**:这条链上的任务**互为前置**(没建好表就不能导名单,没导名单就不能做门禁,没门禁就不能跑填报), +**加人不会缩短它**。例如"压缩引擎"的 12 人日里,大部分是"改参数 → 跑 112 张真实图 → 看结果"的验证循环,**两人并行做反而要重跑**。 + +> ⇒ **结论:约 15 周**(客户确认 B1 后由 14 周顺延)。若要压缩,唯一可行的办法是**砍范围**(把 M7 的整理器与开放接口后置),而不是加人。 + +--- + +## 五、风险与对策 + +| # | 风险 | 概率 | 影响 | 对策 | 触发信号 | +|:--:|---|:--:|:--:|---|---| +| R1 | 小程序教育类目资质未获批 | 中 | **极高** | **M0 就启动法务准备**;备选 H5 扫码填报可保底 | 第 2 周仍未提交类目 | +| R2 | 认定机构不接受 300dpi | 低 | 高 | M3-05 预留参数可调;实测 28/28 达标是强证据 | 确认函 A1 回复 | +| R3 | OCR/VLM 选型拖延 | 中 | 中 | M2 结束前必须定;**层 1/2 不依赖它,可先跑通** | 第 6 周未选型 | +| R4 | 压缩引擎调优超期 | 中 | 高 | **M3-05 单独设 3 人日缓冲**;用 Golden Set 做回归门禁 | 回归超 2 项偏差 | +| R5 | 门禁并发绑定出问题 | 低 | **极高** | 04 文档 M4 已给正确实现;**M7-09 压测必测 100 并发** | 压测出现重复绑定 | +| R6 | 客户名单格式更复杂 | 中 | 中 | 表头探测已支持候选回传;M2 加 20% 缓冲 | dryRun 识别率 < 90% | +| R7 | 需求变更(如加"申请加入名单"通道) | 高 | 中 | 已列入确认函 B1;**若确认则 M2 追加 3 人日** | 客户回复 | +| R8 | 导出大任务内存溢出 | 中 | 中 | 分卷 + 流式写入;快照固化避免重查 | 单任务 > 5000 文件 | + +--- + +## 六、团队配置建议 + +| 角色 | 人数 | 主要投入 | 峰值在第几周 | +|---|:--:|---|---| +| 技术负责人 | 1 | 架构、Code Review、M3 引擎 | 全程 | +| 后端 | 3 | M0/M2/M3/M4/M6 | 第 5~10 周 | +| 前端(PC) | 1.5 | M1 设计器、M5 终审、M7 看板 | 第 4~6 周、第 9~11 周 | +| 前端(小程序) | 1 | M2 门禁、M3 填报、M5 审核 | 第 6~11 周 | +| 测试 | 1 | Golden Set 回归、压测 | 第 8 周起 | +| 产品/UI | 1 | 交互确认、文档、培训 | 第 1~3 周、第 13 周 | +| **合计** | **8.5** | | | + +**建议的并行策略** + +| 阶段 | 并行方式 | +|---|---| +| 第 1~3 周 | 后端做 M0,前端同时搭框架(不等后端) | +| 第 4~6 周 | 后端 M2 门禁与前端 M1 设计器**完全并行**(无依赖) | +| 第 7~10 周 | **M3-05 压缩引擎单独占 1 人专职**,其余人做 M3 周边 | +| 第 12~14 周 | M6/M7 并行;测试同步跑 Golden Set | +| 第 15 周 | 全量回归 + 灰度 + 上线 | + +--- + +## 七、里程碑验收与交付节奏 + +| 节点 | 时间 | 交付内容 | 验收方式 | +|---|---|---|---| +| **M0** | 第 3 周末 | 能登录、能建组织用户 | 内网演示 15 分钟 | +| **M1** | 第 5 周末 | 能配出完整任务 | **里程碑 A**:向客户演示"配置→任务就绪" | +| **M2** | 第 8 周末 | 能导入名单、能过门禁、能审批申请 | 用**真实名单**演示 | +| **M3** | 第 11 周末 | 学生能完整填报提交 | **里程碑 B**:真机演示全流程 + Golden Set 回归 | +| **M4** | 第 12 周末 | 识别与校验生效 | 112 个真实文件的识别报告 | +| **M5** | 第 13 周末 | 审核闭环 | 班主任真机演示 + 管理端终审 | +| **M6** | 第 14 周末 | 导出规范包 | 导出真实包并**用 Windows 解压验证** | +| **M7** | 第 15 周末 | **可上线** | **里程碑 C**:压测报告 + 灰度 → 全量 | + +### 建议的三个客户演示点 + +| 演示 | 时点 | 演示什么 | 目的 | +|---|---|---|---| +| **里程碑 A** | 第 5 周 | 任务配置闭环 + **历史数据整理器**(可用假数据先做简版) | 让客户看到"配置灵活",并**提前锁定需求** | +| **里程碑 B** | 第 11 周 | 真机全流程 + **用真实 112 个文件跑出的处理报告** | 最有说服力的一次——数字都是他们自己的数据 | +| **里程碑 C** | 第 15 周 | 压测 + 导出包 | 交付验收 | + +> **里程碑 A 里建议提前塞进"整理器"的简版**:客户手里有历史乱数据时,**"先看到价值再谈上线"**比任何功能列表都有用。这一步能让后续需求变更更容易谈。 + +--- + +## 八、给项目经理的三条提醒 + +| # | 提醒 | 原因 | +|:--:|---|---| +| 1 | **不要压缩 M3-05(压缩引擎)和 M6-02(打包引擎)的工期** | 这两个是唯一无法靠加人加速的任务,压缩工期只会换来质量债 | +| 2 | **Golden Set 回归脚本要在 M7 之前就准备好**(M7-08 建议提前到 M4 结束) | 它是后续所有改动的安全网;等到上线前才做,等于全程裸奔 | +| 3 | **确认函里的 B1(名单外的人怎么办)要在 M2 开始前拿到答复** | 它决定 M2 要不要多做一条审批流;M2 一旦开工,改动的代价是 3 人日变 6 人日 | + +--- + +## 九、核算口径(可复现) + +本文档所有数字均**逐任务求和**得出,非估算。核算方法: + +``` +① 读 §3 各里程碑表格,正则匹配每行 `| M\d-\d+ | ... | 人日 | 角色 | 备注 |` +② 按里程碑求和 → 与标题括号内数字比对 +③ 汇总到 §1.5 → 与(含缓冲)列比对 +④ 缓冲 = 各里程碑人日 × 1.2 后四舍五入 +``` + +**核算结果(2026-09-28)** + +| 里程碑 | 任务数 | 人日 | 含缓冲(×1.2) | +|:--:|:--:|:--:|:--:| +| M0 | 13 | 40 | 48 | +| M1 | 10 | 44 | 53 | +| M2 | 17 | 58 | 70 | +| M3 | 16 | **71** | **85** | +| M4 | 10 | 34 | 41 | +| M5 | 14 | 56 | 67 | +| M6 | 8 | 32 | 38 | +| M7 | 12 | 52 | 62 | +| **合计** | **100** | **387** | **464** | + +> ⚠️ **注意**:初稿的 §1.5 汇总表曾出现"先填汇总、后填任务明细"造成的错位,已按逐任务求和值修正。 +> **任何人改动任务明细后,必须重跑一遍核算**——这是本文档唯一的自校验手段。 +> +> **本次变更(客户确认 B1 后)**:新增 **6 个任务**(M0-13、M2-13~17),**+12 人日**;工期由 14 周顺延至 **15 周**。 +> +> **一期范围收窄后**:**−5 人日**(社会收集 / 社会评价 / 专项职业能力后置;省下的主要是测试与联调成本),**关键路径不变,仍 15 周**。 + +--- + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| V1.0 | 2026-09-28 | 首版。8 个里程碑 M0–M7,共 **94 个任务**;合计 **380 人日**(含缓冲 **456**);工期约 **14 周**(由关键路径决定)。含关键路径分析、8 项风险、团队配置与 3 个客户演示点。**全部人日为逐任务求和的实际值**,核算口径见 §9 | +| **V1.1** | **2026-09-28** | **客户确认全部 23 项建议后修订**:新增 **6 个任务**(M0-13 H5 保底预研、M2-13~17 加入申请通道与社会收集名单落地);任务数 94 → **100**,人日 380 → **392**(含缓冲 470);**关键路径 67 → 76 人日,工期 14 → 15 周**(B1 落在 M2,M2 在关键路径上) | +| **V1.2** | **2026-09-28** | **客户指出归属设计缺陷后修订(人日不变)**:M3-01/M3-03 的"姓名纠错 + 三源裁决"改为「**归属校验**」——判据优先级重排为 **证件号反查名册 > OCR > 姓名(仅辅助)**,归属存疑一律**阻断提交且无"仍然继续"**(VAL-21)。
**工作量中性**:删掉了姓名编辑距离+拼音相似度逻辑(约 −1.5 人日),新增名册反查与阻断交互(约 +1.5 人日),**净变化 0**,排期不变 | diff --git a/docs/06-验收测试清单.md b/docs/06-验收测试清单.md new file mode 100644 index 0000000..a6b8112 --- /dev/null +++ b/docs/06-验收测试清单.md @@ -0,0 +1,610 @@ +# 验收测试清单 + +> 版本:V1.0 | 日期:2026-09-28 +> 读者:测试工程师、开发(自测)、项目经理(验收) +> 配套:《04-核心模块设计说明.md》附录 B(基线)、《05-开发任务拆解.md》(里程碑验收) +> 核心原则:**能用真实数据断言的不靠人眼;能自动化的一次写、长期跑** + +--- + +## 一、测试策略 + +### 1.1 四层结构 + +``` +┌─────────────────────────────────────────────┐ +│ L4 验收测试(人工) ← §6 里程碑验收签署表 │ +│ 客户在场、用真实数据、不可自动化 │ +├─────────────────────────────────────────────┤ +│ L3 端到端(半自动) ← §3 主流程用例 │ +│ 真机 + 真实数据,脚本辅助准备与核对 │ +├─────────────────────────────────────────────┤ +│ L2 集成测试(自动) ← §4 功能验收清单 │ +│ 接口级,含边界与异常路径 │ +├─────────────────────────────────────────────┤ +│ L1 单元测试(自动) ← §2 Golden Assertions │ +│ 引擎级纯函数,**用 112 个真实文件做断言** │ +└─────────────────────────────────────────────┘ +``` + +### 1.2 各层覆盖目标 + +| 层 | 覆盖对象 | 目标 | 执行时机 | +|---|---|:--:|---| +| L1 | M1 命名引擎、M2 压缩引擎、M5 校验规则 | 行覆盖 ≥ 80%,**Golden 断言 100% 通过** | 每次提交(CI 门禁) | +| L2 | 全部 117 个接口 | 接口覆盖 100%,异常路径 ≥ 60% | 每日构建 | +| L3 | 学生填报、班主任审核、管理员导出 | 3 条主流程 × 平台差异 | 每周 / 里程碑 | +| L4 | 里程碑交付物 | 按 §6 签署表 | 里程碑节点 | + +### 1.3 Golden Test Set(唯一权威测试集) + +| 项 | 内容 | +|---|---| +| 路径 | `D:\项目\202609机电填报系统\24中技数控1班\` | +| 规模 | **28 人 × 4 类材料 = 112 个文件**,原始 **475 MB** | +| 配套名单 | `考生线下报名信息导入模板(24中技数车1班班).xlsx`(表头**第 6 行**) | +| 参照模板 | 官方空白模板(表头**第 1 行**),21 字段 | +| 特点 | **不是构造数据**——含真实的乱命名、双扩展名、证件号错误、串档、低分辨率 | + +> ⚠️ **这个数据集不可替代**。所有"实测"结论都来自它;换成构造数据,双扩展名 56/112 这种数字就复现不出来。 +> **必须纳入版本管理**(或至少保持只读副本),否则半年后无法回归。 + +--- + +## 二、Golden Assertions(L1 · 可自动化) + +> 以下断言**直接从 P4 附录 B 的基线转写**。任何改动后必须全绿。 + +### 2.1 命名规范化(M1) + +```python +# G-01 命名违规总数 +assert count_files(failing_naming_rule) == 59 # 59 / 112 = 52.7% +# G-02 双扩展名 +assert count_files(lambda f: re.search(r'\.(jpg|jpeg|png){2,}$', f.name, re.I)) == 56 +# G-03 规范化幂等性(关键!) +for f in all_files: + once = normalize(f.name, roster, material_def) + twice = normalize(once.standard_name, roster, material_def) + assert twice.standard_name == once.standard_name + assert twice.issues == [] # 第二次不应产生任何 issue +# G-04 双扩展名修复正确性 +sample = "曾伊豪-361029200907295110身份证.jpg.jpg" +assert normalize(sample).standard_name == "曾伊豪-361029200907295110-身份证.jpg" +# G-05 材料别名归一 +assert normalize("...个人申报表.jpg", slot="WORK_PROOF").material_name == "工作证明" +# G-06 证件号抽取:不许暴力凑合法号 +assert normalize("张三-111111111111111111.jpg").idcard_found == False # 非法号不得被"修好" +# G-07 无名册时不猜姓名 +assert normalize("乱名.jpg", roster=None).standard_name.startswith("未知姓名") is False # 用占位而非臆测 +``` + +### 2.2 压缩引擎(M2) + +```python +# G-08 体积上限:全部达标(D1 决策,无例外) +for f in a4_scans: # 28 张工作证明 + out = compress(f, profile="P3") + assert out.final_size_kb <= 1024, f"{f.name} 超标 {out.final_size_kb}KB" +# G-09 质量未受损:均值与最大值 +sizes = [compress(f, profile="P3").final_size_kb for f in a4_scans] +assert 700 <= mean(sizes) <= 900 # 实测均值 806KB +assert max(sizes) <= 900 # 实测最大 831KB +# G-10 零强制压缩 +assert count(out.forced == True for out in results) == 0 +# G-11 证件照规格化 +for f in photo_files: # 实测 984×1378 的 27 张 + out = compress(f, profile="P1") + assert (out.width, out.height) == (295, 413) + assert out.dpi == 300 +# G-12 截图不缩放、保持 PNG +out = compress(school_roll_png, profile="P4") # 实测 1025×590 PNG +assert (out.width, out.height) == (1025, 590) +assert out.format == "PNG" +# G-13 不放大(关键!) +out = compress(small_idcard, profile="P2") # 实测 558×745 +assert out.width <= 558 and out.height <= 745 +assert out.issues_contains("VAL-20") # 体积下限保护 +# G-14 EXIF 方向正确(不拉扁) +out = compress(rotated_photo, profile="P2") +assert out.width > out.height # 或按原图判断,绝不允许宽高互换 +``` + +### 2.2b 文件归属(M1 · 归属校验) + +```python +# G-14b 归属:证件号 = 名册值 + 姓名错别字 → 不打扰学生 +f = "扬晨-360825200904303215-一寸照片.jpg" # 名册为「杨晨」 +r = normalize(f, roster=yangchen, material_def=photo1) +assert r.standard_name == "杨晨-360825200904303215-一寸照片.jpg" +assert r.student_facing_issues == [] # ★ 不产生面向学生的 issue +assert r.logonly(NAME_DIFFERS) # 仅留痕 + +# G-14c 归属:乱名无号码 → 零提示(正常路径) +r = normalize("微信图片_20260928103015.jpg", roster=yangchen, material_def=photo1) +assert r.standard_name == "杨晨-360825200904303215-一寸照片.jpg" +assert r.issues == [] # ★ 零提示 +assert r.ownership.verdict == "NO_IDCARD_IN_NAME" + +# G-14d 归属:指向名册中的另一个人 → 阻断(最关键) +f = "李四-360122200902044232-工作证明.jpg" # 该号码在名册里能查到「李四」 +r = normalize(f, roster=zhangsan, material_def=work_proof) +assert r.ownership.verdict == "BELONGS_TO_OTHER" +assert r.ownership.block_submit == True # ★ 阻断 +assert r.ownership.other_roster_name == "李四" +assert r.ownership.can_force == False # ★ 没有"仍然继续" + +# G-14e 归属:陌生号 + OCR 佐证 → 不阻断 +r = normalize("张三-111111111111111111-身份证.jpg", roster=zhangsan, + ocr_idcard=zhangsan.id_card_no) +assert r.ownership.verdict == "UNKNOWN_IDCARD" +assert r.ownership.block_submit == False # OCR 佐证后放行 +assert r.issues_contains("VAL-02", severity=1) + +# G-14f 归属:陌生号 + 无 OCR → 阻断 +r = normalize("张三-111111111111111111-身份证.jpg", roster=zhangsan, ocr_idcard=None) +assert r.ownership.block_submit == True + +# G-14g 校验位失败 → 自动改,不阻断(实测 2 例:于豪、杨晨) +r = normalize("于豪-360103200805166169-身份证.jpg.jpg", roster=yuhuo, material_def=idcard) +assert r.ownership.verdict == "CHECKSUM_INVALID" +assert r.ownership.block_submit == False +assert r.standard_name == "于豪-360103200805186619-身份证.jpg" + +# G-14h ★ 回归:绝不把"别人的材料"静默洗白 +# 这是本设计最重要的一条断言——它防止的是数据污染,不是体验问题 +for f in all_files: + r = normalize(f, roster=current_student, material_def=md) + if r.ownership.verdict == "BELONGS_TO_OTHER": + assert r.standard_name != "{当前学生名}-{当前学生证件号}-..." or r.block_submit + # 即:凡判为他人材料,必须阻断,不得生成一份"名字正确"的输出 +``` + +### 2.3 材料识别(M3) + +```python +# G-15 文件名层命中率 +assert detect_by_name_accuracy(all_files) == 1.0 # 112/112 +# G-16 形态层分流派准确率 +assert detect_by_shape_accuracy(all_files) >= 0.99 # 实测 99.1% +# G-17 零文字判据 +assert detect(photo1).material_code == "PHOTO1" +assert detect(photo1).reason == "整图文字量 < 5 字" +# G-18 通用词禁入表(关键!) +assert "身份证号" not in STRONG_FEATURES +for f in all_files: + # 不得因为文件里出现"身份证号"四字就判成 IDCARD + assert not (contains(f, "身份证号") and detect(f).material_code == "IDCARD" + and detect(f).source == "NAME" and detect(f).confidence >= 0.9) +# G-19 识别失败必须放行 +assert detect(black_image).material_code is None +assert can_submit(submission_with(black_image)) == True # 不阻断! +# G-20 VLM 触发率在预算内 +assert 0.02 <= vlm_trigger_rate(all_files) <= 0.10 +``` + +### 2.4 校验与串档(M5) + +```python +# G-21 证件号异常总数 +issues = run_validation(all_submissions) +assert count(issues, rule="VAL-02") + count(issues, rule="VAL-01") == 5 +# G-22 自动/人工分流 +assert count(issues, rule="VAL-02", auto_fixed=True) == 3 +assert count(issues, rule="VAL-02", auto_fixed=False) == 2 +# G-23 疑似串档(最关键的一条) +assert count(issues, rule="VAL-19") == 1 +s = the_cross_file_issue +assert s.need_human == True and s.auto_fixed == False # 绝不自动改 +# G-24 跨文件一致性不误报 +assert count(issues, rule="VAL-04") == 0 # 除串档外无其他不一致 +# G-25 低分辨率检出 +assert count(issues, rule="VAL-08") == 1 +assert issues_for_rule("VAL-08")[0].dimension == "558×745" +``` + +### 2.5 导出(M6) + +```python +# G-26 导出总量 +result = export(task_id, scope={only_approved: False}) +assert 30 <= result.size_mb <= 45 # 实测 ≈37MB +# G-27 目录树结构 +assert result.tree["24中技数控1班"]["工作证明"][0].name \ + == "曾伊豪-361029200907295110-工作证明.jpg" +# G-28 汇总 Excel 列对齐 +xlsx = result.summary_excel +assert xlsx.header == OFFICIAL_21_COLUMNS # A–U 严格对齐 +# G-29 中文文件名编码 +with unzip(result.zip) as z: + for name in z.namelist(): + assert name.encode('utf-8').decode('utf-8') == name # 无乱码 +``` + +### 2.6 断言的"红灯"处理规则 + +| 情况 | 处理 | +|---|---| +| G-01~G-10 任一失败 | **阻断发布**。这是产品核心承诺 | +| G-11~G-14 失败 | **阻断发布**。直接影响认定机构接收 | +| G-15~G-20 失败 | 视偏差幅度:>5% 阻断;≤5% 记录并评估 | +| G-21~G-25 失败 | **阻断发布**。漏掉串档会流出错误数据 | +| G-26~G-29 失败 | **阻断发布**。导出包是最终交付物 | +| 基线数字本身需调整 | **必须走变更评审**,在 P4 附录 B 留版本记录 | + +--- + +## 三、端到端主流程用例(L3) + +### 3.1 主流程 A · 学校收集(学生 → 班主任 → 管理员) + +| 步 | 操作 | 期望 | 对应原型 | +|:--:|---|---|---| +| A1 | 学生扫码进小程序 | 进入门禁页 | ① | +| A2 | 输入名单上的姓名 + 证件号 | 通过,显示姓名与班级 | ② | +| A3 | 刻意输错一位证件号 | **统一提示**"没有找到你的信息",不透露哪项错 | ③ | +| A4 | 点"申请加入名单" | 进入申请表单;**系统内不展示任何联系方式** | ③B | +| A5 | 正确登录后进填报页 | 18 个字段已预填,标注"老师已填,请核对" | ④ | +| A6 | 修改"联系电话" | 可改,`changedFields` 记录 phone | ④ | +| A7 | 尝试改"身份证号" | **只读**,点击提示"如有误请联系老师" | ④ | +| A8 | 选"微信聊天记录"传工作证明(双扩展名乱名) | 上传成功,结果卡显示**命名前后对比** | ⑤⑦ | +| A9 | 查看结果卡 | 显示标准名、体积 17.5MB→512KB、`VAL-06` 信息级"格式已转换" | ⑦ | +| A10 | 传"学籍表"时误传工作证明 | 提示"这张图看起来是工作证明,要移过去吗?",**默认不移** | ⑧ | +| A11 | 点"移到工作证明" | 文件移动,学籍表槽位变空 | ⑧ | +| A12 | 故意不传必传材料直接提交 | 被拦,**列出缺哪项**,给出"去上传" | — | +| A13 | 补齐后提交 | 成功,状态"等待李老师初审" | ⑨ | +| A14 | 班主任打开待审列表 | 该生卡片为**绿色**(无问题) | ② | +| A15 | **右滑该卡片** | 通过,卡片移出列表,显示"已通过" | ② | +| A16 | 另一个学生有红色问题 | 卡片**灰色/不可滑**,点击进详情 | ②③ | +| A17 | 尝试对该生执行"一键通过" | **被跳过并回报原因**"存在红色问题" | ④ | +| A18 | 驳回该生(选"材料不清晰") | 需**二次确认**;提交后学生收到通知 | ⑤ | +| A19 | 学生查看消息 | 消息中心有"你的材料被退回" | ⑩ | +| A20 | 学生重新上传后提交 | 状态回到"待初审" | — | +| A21 | 管理员终审 | 通过后状态"已通过" | ⑥ | +| A22 | 管理员导出 | 前置检查通过 → 打包 → 下载 zip | ⑦ | +| A23 | **Windows 资源管理器解压** | **中文名不乱码**,目录树正确 | — | + +### 3.2 主流程 B · 社会收集(**二期**,一期不做) + +> ⚠️ **一期范围收窄后,本节整体后置**(见 PRD「一期范围声明」)。保留描述供二期启用时使用。 + +| 步 | 操作 | 期望 | +|:--:|---|---| +| B1 | 用社会收集任务重跑 A1~A13 | 提交后状态直接是"待终审" | +| B2 | 班主任打开待审列表 | **看不到该任务**(社会收集不经班主任) | +| B3 | 管理员终审 → 导出 | 正常 | + +### 3.3 主流程 C · 历史数据整理器 + +| 步 | 操作 | 期望 | +|:--:|---|---| +| C1 | 上传 `24中技数控1班.rar`(436MB) | 解析出 28 人 4 类 | +| C2 | 查看体检报告 | 命名违规 **59**、体积超标 **31**、证件号异常 **5**、疑似串档 **1** | +| C3 | 读报告结论 | "**90 个系统全自动处理,只有 1 个必须你亲自判断**" | +| C4 | 下载规范化包 | 475MB→**37MB**,目录树正确 | +| C5 | 点"导入为正式任务" | 生成任务 + 名册 + 112 个文件全挂上 | +| C6 | 进该任务看数据 | 真实、无占位、可直接导出 | + +### 3.4 异常与边界用例(23 条) + +| # | 场景 | 期望 | +|:--:|---|---| +| E1 | 门禁连错 11 次 | 第 11 次返回 `40007b`,提示稍后再试 | +| E2 | 同一 IP 20 人同时门禁 | **全部通过**(NAT 不误伤) | +| E3 | 并发 100 请求抢同一名册项 | **恰好 1 个成功**,99 个 `40007c` | +| E4 | 门禁失败后查库 | **无明文证件号**,只有 hash | +| E5 | 学籍表 4 项材料证件号均与名册不符 | 标 `VAL-19` 疑似串档,**不自动改** | +| E6 | 学生提交后撤回,再改再提交 | 正常;驳回次数不因撤回增加 | +| E7 | 任务暂停中 | 学生看到"已暂停",**已提交的仍可继续审核** | +| E8 | 任务截止后 | 学生不能提交,已提交的可审 | +| E9 | 归档有未审单 | 拒绝,返回 `40023` | +| E10 | 导出前有 1 个超标文件 | **导出按钮禁用**,指出"张三的工作证明 1.8M" | +| E11 | 已导出后撤回审核 | 拒绝,返回 `40033` | +| E12 | 表单版本冲突(两人同时改) | `40025` + 返回最新 schema | +| E13 | 删除内置字段 | `40026`,提示可改标签/顺序 | +| E14 | 报考范围未覆盖全员 | `40024` + 返回差集 | +| E15 | 名字含罕见字(生僻字) | 不误判、不阻断 | +| E16 | 上传 8000×10000 超大图 | 触发熔断转异步,**不超时** | +| E17 | OCR 服务不可用 | **降级为人工填写**,不阻断 | +| E18 | VLM 服务不可用 | 取前三层结果或放行 | +| E19 | 微信订阅消息额度耗尽 | **静默降级**,学生端无异常提示 | +| E20 | 网络中断后重进 | 草稿保留,续填 | +| E21 | 申请提交后管理员未处理,任务截止 | 申请自动置为已失效,学生收到提示 | +| E22 | 两人同时申请同一证件号 | 只有 1 条落库成功(部分唯一索引),另一条得 40038 | +| E23 | 管理员在审批页停留很久后点通过 | 状态已变更时返回 40040,不重复建册 | +| E24 | 学生传了同学的材料后点提交 | **被阻断**,提示文件名对应的是谁,**无"仍然继续"** | +| E25 | 学生把文件名改成自己的名字后重传,内容仍是同学的 | 证件号/OCR 仍会暴露 → 阻断或转人工(**改名绕不过去**) | +| E26 | 归属问题在异步 OCR 后才判定可放行 | 自动解除阻断 + 推送通知;**阶段二只解除、不新增阻断** | +| E27 | 文件名形如 `微信图片_2026....jpg`(无姓名无号码) | 按名册补全,**零提示**(这是最常见的情况) | + +--- + +## 四、功能验收清单(L2 · 按模块) + +> 编号规则:`F-{模块}-{序号}`。每条必须有明确的**可观测结果**。 + +### F-100 租户与组织(FR-100) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-100-1 | 开通租户 | 生成 `tenantCode`,同时创建首个管理员,返回初始密码 | +| F-100-2 | 组织树三层 | 学校/院系/班级,`ancestorPath` 自动维护 | +| F-100-3 | 删除被占用组织 | 拒绝,返回占用清单 | +| F-100-4 | **租户隔离** | A 租户 token 访问 B 租户数据 → `40301` | +| F-100-5 | 用量统计 | 学生数/存储/OCR/短信四项,超限告警 | + +### F-200 账号与角色(FR-200) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-200-1 | PC 登录 | 连续失败 2 次后**出现图形验证码** | +| F-200-2 | 账号锁定 | 5 次失败锁定,提示剩余时间 | +| F-200-3 | 小程序登录 | `code2session` 成功,绑定手机号 | +| F-200-4 | 一班主任多班 | 看板聚合展示多个班 | +| F-200-5 | 批量导入用户 | 返回体检报告 + 初始密码清单 | +| F-200-6 | 停用有在办任务的用户 | 拒绝 `40014` | + +### F-300 收集任务(FR-300) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-300-1 | 任务 CRUD 与状态机 | 草稿→发布→暂停→恢复→截止→归档 | +| F-300-2 | **暂停 ≠ 截止** | 暂停可恢复、审核继续;截止不可逆 | +| F-300-3 | 发布前置校验 5 项 | 任一不通过阻断,`force` 无法绕过 | +| F-300-4 | 任务克隆 | 名册复制但**清空绑定与提交状态** | +| F-300-5 | 二维码/短链 | 带租户+任务+班级参数,截止后扫码提示 | +| F-300-6 | 社会收集审核流 | **二期**。强制 `needClassAudit=false`,传 true 被忽略并告警 | + +### F-400 动态表单(FR-400) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-400-1 | 21 个内置字段 | 可改标签、可调顺序、**不可删除** | +| F-400-2 | 扩展字段 | 存 `extras_json`,**不参与统计** | +| F-400-3 | 条件显示 | 三条级联全部生效 | +| F-400-4 | 表单版本化 | 改后旧单不受影响,`formVersion` 各自保存 | +| F-400-5 | 版本冲突 | `40025` + 返回最新 schema | +| F-400-6 | 学生三态字段 | 锁定/可改留痕/正常 三种表现正确 | + +### F-500 名册(FR-500) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-500-1 | **表头自动探测** | 第 1 行与第 6 行两份真实文件都识别正确 | +| F-500-2 | 表头候选回传 | 多候选时返回并让用户确认 | +| F-500-3 | 无法定位表头 | `40028` + 返回前 10 行预览 | +| F-500-4 | 体检报告 | 证件号校验位/重复/必填缺失三项检出 | +| F-500-5 | `dryRun` 模式 | 只体检不落库 | +| F-500-6 | 追加导入去重 | 按证件号合并,新人立即可填 | +| F-500-7 | 改名册证件号留痕 | 操作日志可查改前改后(**脱敏**) | +| F-500-8 | 已绑定名册项不可删 | `40029` | +| F-500-9 | **门禁匹配** | 正确通过 / 错误统一提示 / 形近字确认 | +| F-500-10 | 门禁限流 | openid 10 次/小时;IP 20 次/分钟 | +| F-500-11 | **并发绑定** | 100 并发抢同一项,恰好 1 个成功 | +| F-500-12 | 失败不留明文 | 查库确认只有 hash | +| F-500-13 | 已绑定提示 | `40007c`,**这一条明确提示** | + +### F-509 名单加入申请(客户确认新增) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-509-1 | 失败页有申请入口 | 「申请加入名单」在门禁失败页,视觉为**次要路径**(虚线分隔、不与"重新填写"抢注意力) | +| F-509-2 | **不绕过门禁** | 申请期间**仍不能填报**;提交后页面明确写"审核期间还不能填报" | +| F-509-3 | 申请表单校验 | 姓名/证件号/手机号必填;格式错误当场提示 | +| F-509-4 | **敏感信息不落明文** | 查库 + 查日志:**无明文证件号与手机号**,只有 HMAC 与脱敏值 | +| F-509-5 | 同 openid 第 3 次申请 | `40037`,提示联系老师 | +| F-509-6 | 同证件号重复申请(待审中) | `40038`(**部分唯一索引**保证) | +| F-509-7 | 证件号已在名册但姓名不符 | `40039`,引导"返回重新核验"(**不泄露新信息**) | +| F-509-8 | 被驳回后重新申请 | **允许**(唯一索引只约束状态 0/1) | +| F-509-9 | 审批通过 | 自动在名册**新建名册项**,`roster_id` 回填,`bind_type=3` | +| F-509-10 | 审批通过后该 openid 再进 | **直接放行**,不要求重输 | +| F-509-11 | **管理员双击"通过"** | **只建 1 条名册项**(`FOR UPDATE` 行锁 + 状态校验) | +| F-509-12 | 通过时证件号已被占用 | `40041`,**不覆盖**原名册项 | +| F-509-13 | 驳回必填原因 | 原因为空时提交被拦;原因原样推送给学生 | +| F-509-14 | **不存在"全选"** | 批量通过需逐条勾选;**驳回不支持批量** | +| F-509-15 | 结果通知 | 微信订阅消息 `JOIN_APPROVE` / `bizType=7`;通知失败**不回滚审批** | +| F-509-16 | 他任务命中提示 | 证件号在其他任务名册中 → 列表显示「他任务已有」 | +| F-509-17 | 待审 > 50 告警 | 列表返回 `alert` | +| F-509-18 | 任务截止后 | 待审申请自动置为 `3` 已失效 | +| F-509-19 | 留痕 | 操作日志含申请人与审批人;申请记录保留 2 年 | + +### F-600 材料与规则(FR-600) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-600-1 | 命名模板配置 | 变量可拖拽,支持自定义连接符与大小写 | +| F-600-2 | 命名引擎 | 双扩展名/非法字符/缺分隔符/别名归一 全部修复 | +| F-600-3 | **幂等性** | 跑两次结果相同、第二次无 issue | +| F-600-4 | 证件号三源裁决 | 5 种组合按裁决表执行 | +| F-600-5 | **NEED_HUMAN 不自动改** | 两票对一票场景保持原值 | +| F-600-6 | 格式白名单 | 非白名单自动转换 | +| F-600-7 | 体积上下限 | 超限压缩;过小提示"可能不是原图" | +| F-600-8 | 五档压缩 | P1 固定尺寸 / P4 不缩放 各自正确 | +| F-600-9 | 材料识别四层 | 各层命中率达标 | +| F-600-10 | 冲突提示 | 只提示不自动移位置 | +| F-600-11 | 散图分拣 | 唯一允许建议落位的场景 | +| F-600-12 | 来源引导语 | 学生端可见自定义文案 | +| F-600-13 | **无拍照入口** | 只有聊天记录/相册/手机文件三种来源 | +| F-600-21 | **一期材料范围** | 只支持 `PHOTO1`/`IDCARD`/`SCHOOL_ROLL`/`WORK_PROOF` 四类;`XJW_BA`/`OTHER_PROOF` 的识别**二期启用** | +| F-600-22 | **一期收集模式** | 创建 `collectMode=2` 的任务被拒绝(`40044`) | +| F-600-14 | **归属校验:他人材料** | 文件名证件号指向名册中另一人 → **阻断提交**,提示"文件名对应的是「李四」" | +| F-600-15 | **归属校验:无"仍然继续"** | 归属问题的提示里**不提供**"仍然使用/强制提交"按钮(`canForce=false`) | +| F-600-16 | 归属校验:乱名无号码 | `微信图片_xxx.jpg` → 按名册补全,**零提示** | +| F-600-17 | 归属校验:姓名错别字 | 证件号正确 + 姓名形近 → 按名册输出,**学生端无任何提示**,仅服务端留痕 | +| F-600-18 | 归属校验:陌生号 + OCR 佐证 | OCR = 名册 → 自动放行,信息级 | +| F-600-19 | 归属校验:校验位失败 | 校验位失败 → 自动按名册,信息级,**不阻断** | +| F-600-20 | **姓名不做独立规则** | 构造"证件号正确但姓名完全不同"的文件 → 仍按名册输出,**不阻断**(归属由证件号定) | + +### F-800 审核(FR-800) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-800-1 | 待办分级 | 红/黄/绿三色,`batchApprovable` 服务端判定 | +| F-800-2 | **只右滑通过** | 左滑**不绑定驳回** | +| F-800-3 | 驳回需二次确认 | 不能误触 | +| F-800-4 | 驳回原因快选 | 10 条字典 + R999 手填 | +| F-800-5 | **批量通过只带绿项** | 红黄被跳过并回报原因 | +| F-800-6 | 红色问题不可通过 | `40032` | +| F-800-7 | 审核时间轴 | 含提交/初审/终审/撤回/代传 | +| F-800-8 | **撤回** | 未导出可撤;已导出 `40033` | +| F-800-9 | 代传材料留痕 | `source=4`,学生端可见 | +| F-800-10 | 驳回超上限升级 | 达 3 次通知管理员 | +| F-800-11 | 变更字段高亮 | 学生改过的预填字段在详情高亮 | + +### F-900 导出(FR-900) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-900-1 | **前置检查** | 有不合格文件 → `canExport=false` → 按钮禁用 | +| F-900-2 | blocking 定位 | 精确到"谁的哪一项" | +| F-900-3 | 目录树预览 | 导出前可见最终形态 | +| F-900-4 | 打包总量 | ≈37MB,压缩率 ≈92% | +| F-900-5 | **中文文件名** | Windows 解压不乱码 | +| F-900-6 | 汇总 Excel | 严格对齐官方 21 列 | +| F-900-7 | 分卷 | ≤50MB,不切分单个文件 | +| F-900-8 | 快照一致性 | 打包中数据变化不影响产物 | +| F-900-9 | 断点续传 | 失败后从断点继续 | +| F-900-10 | 过期清理 | 7 天自动清理,重导不重复计费 | + +### F-1000 通知(FR-1000) + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-1000-1 | 订阅消息额度登记 | 各节点索取授权并记录 | +| F-1000-2 | **降级链** | 订阅→公众号→短信→站内信,**静默** | +| F-1000-3 | 自动催报 | 截止前 7/3/1 天 + 当日,**仅催未提交者** | +| F-1000-4 | 催报去重 | `excludeRecentHours=24` 防骚扰 | +| F-1000-5 | 模板变量 | 白名单外的变量渲染为空并告警 | +| F-1000-6 | 消息中心 | 学生端 + 教师端可读可标记 | + +### F-1200 / F-1300 / F-1400 + +| # | 验收项 | 期望 | +|:--:|---|---| +| F-1200-1 | 字典管理 | 6 个内置字典,有下级项的不可停用 | +| F-1200-2 | 职业字典 | **只做拼写校验,不强约束**(Excel 模板本就无下拉) | +| F-1200-3 | 操作日志 | 9 类敏感操作全留痕,证件号脱敏存储 | +| F-1200-4 | 查看完整身份证号 | 单独接口、单独留痕 `VIEW_IDCARD_FULL` | +| F-1300-1 | 用量与套餐 | 四项用量 + 超限告警 | +| F-1400-1 | 整理器上传解析 | 436MB rar → 28 人 4 类 | +| F-1400-2 | **体检报告数字** | 59/31/5/1 全部命中 | +| F-1400-3 | 报告结论 | autoHandled 90 / needManual 1 | +| F-1400-4 | 结果导入为任务 | 名册 + 112 文件一次成型 | + +--- + +## 五、交互与视觉验收(对照原型) + +> 原型是视觉与交互的**唯一基准**。实现与原型不一致时,以原型为准(除非原型本身有错,走变更)。 + +| 原型 | 屏数 | 必须一致的要素 | +|---|:--:|---| +| `student-app.html` | 10 | 门禁失败页文案(不透露哪项错)、三态字段样式、结果卡命名前后对比、无拍照入口 | +| `teacher-app.html` | 9 | 卡片红黄绿三色、**只能右滑**、驳回二次确认、按钮 44px 触达区 | +| `admin-web.html` | 10 | 表格信息密度、目录树预览、设计器内置字段锁标记、整理器报告布局 | +| `index.html` | — | 三端入口 | + +### 视觉验收要点 + +| # | 项 | 标准 | +|:--:|---|---| +| V1 | 移动端触达区 | 主要按钮高度 ≥ 44px | +| V2 | 色彩语义 | 红=阻断 / 黄=警告 / 绿=通过,全局一致 | +| V3 | 无 emoji | 全部用内联 SVG 图标,1.7px 描边 | +| V4 | 零外部依赖 | 断网可打开(原型约束,生产可放宽) | +| V5 | 数字口径一致 | 教师端 **28 = 15+4+3+1+5**,完成率 **82% 是已提交率** | +| V6 | 体积表述 | 475MB → **37MB**,压缩率 **92%**(不得出现 22.4MB) | + +--- + +## 六、性能与安全测试 + +### 6.1 性能 + +| # | 场景 | 指标 | 说明 | +|:--:|---|---|---| +| P1 | 门禁匹配(5000 人名册) | **< 50ms** | 走 `id_card_hash` 索引 | +| P2 | 单文件处理(A4 扫描件) | **< 3s** | 实测 PIL 约 2s,libvips 更快 | +| P3 | 批量处理 4 个文件 | **< 8s** | 建议走异步 | +| P4 | 导出 112 文件 | **< 60s** | — | +| P5 | 导出 5000 文件 | **< 15min** | 分卷 + 流式 | +| P6 | 门禁并发 100 | 全部响应 < 1s,恰好 1 个绑定成功 | **必测** | +| P7 | 上传并发 200 | 无失败,p99 < 2s | — | +| P8 | 任务统计查询(10 万行) | < 500ms | 走正式列索引 | +| P9 | 页面首屏(小程序) | < 1.5s | — | + +### 6.2 安全 + +| # | 项 | 验证方法 | +|:--:|---|---| +| S1 | 跨租户越权 | 用 A 租户 token 请求 B 租户资源,全部 `40301` | +| S2 | 门禁防枚举 | 20 个不同证件号试同一 IP → 触发异常告警并拉黑 | +| S3 | **失败不泄露** | 抓包对比"姓名错"与"号码错"的响应,**字节级一致** | +| S4 | 失败不留明文 | 查库 + 查日志,无明文证件号 | +| S5 | 脱敏覆盖 | 遍历所有响应,确认 `idCardNo` 从不出现,只有 `idCardMask` | +| S6 | 签名 URL 过期 | 过期后返回 `40302` | +| S7 | 幂等键重放 | 同 key 重复提交返回首次结果,不产生脏数据 | +| S8 | 上传文件类型伪造 | 改扩展名的可执行文件被拒 | +| S9 | 路径穿越 | 文件名含 `../` 被清理 | +| S10 | 开放接口加签 | 篡改 body / 过期 timestamp / 重放 nonce 全部拒绝 | +| S11 | SQL 注入 | 全参数化,渗透测试 | +| S12 | 越权访问他人填报单 | 学生 A 访问学生 B 的 submissionId → 403 | + +--- + +## 七、里程碑验收签署表(L4) + +> 每个里程碑结束时由**项目经理 + 技术负责人 + 客户代表**三方签署。 + +| 里程碑 | 签署项 | 交付物 | 签署 | +|:--:|---|---|:--:| +| M0 | 登录/组织/用户可用,跨租户拦截 | 内网演示 | ☐ | +| M1 | 能配出完整任务;内置字段不可删 | 演示 + 截图 | ☐ | +| M2 | 真实名单导入正确;门禁并发测试通过 | **并发压测报告** | ☐ | +| M3 | **Golden Assertions G-01~G-14 全绿** | 回归报告 | ☐ | +| M4 | G-15~G-25 全绿;识别报告 | 112 文件识别报告 | ☐ | +| M5 | 三端审核闭环;一键通过跳过红项 | 真机录屏 | ☐ | +| M6 | G-26~G-29 全绿;**Windows 解压验证** | 导出包样本 | ☐ | +| M7 | 性能 P1~P9 达标;安全 S1~S12 通过 | 压测 + 渗透报告 | ☐ | + +### 上线前必须全部满足(Go/No-Go) + +| # | 条件 | 不满足的后果 | +|:--:|---|---| +| 1 | **全部 29 条 Golden Assertions 通过** | No-Go | +| 2 | 性能 P1、P2、P6 达标 | No-Go(P6 是安全底线) | +| 3 | 安全 S3、S4、S5、S11 通过 | No-Go | +| 4 | 小程序主体资质获批 | No-Go(无法上线) | +| 5 | 认定机构书面接受 300dpi | No-Go(材料可能被拒收) | +| 6 | 真实数据跑通一次完整导出并被客户确认 | No-Go | +| 7 | 回滚预案就绪(能退回上一版本) | 可延期但需记录 | + +--- + +## 八、测试环境要求 + +| 项 | 要求 | +|---|---| +| 数据库 | PostgreSQL 16(DDL 已实测) | +| 图像库 | 支持 300dpi DPI 写入、EXIF 方向读取 | +| 测试数据 | **Golden Test Set 只读副本**,纳入版本管理 | +| 真机 | iOS + Android 各 ≥ 1 台;Windows 用于验证解压不乱码 | +| 并发工具 | 支持 100 并发的压测工具 | +| 字符集 | 文件名测试必须覆盖中文 + 双扩展名 + 全角字符 | + +### 易被忽略的三个测试盲区 + +| # | 盲区 | 为什么容易漏 | +|:--:|---|---| +| 1 | **Windows 解压中文名** | 开发用 Mac/Linux 测试一切正常,交付到客户才炸 | +| 2 | **并发绑定的临界窗口** | 单机测试永远不复现,只有压测才暴露 | +| 3 | **「无」是合法值** | 测试数据里没人填"无",上线后真实数据一填就错 | + +--- + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| **V1.1** | **2026-09-28** | **客户确认后修订:修正「文件归属」设计缺陷**。原设计把"文件名姓名/证件号与名册不符"处理为"自动按名册修正"(信息级),**会把传错的文件静默洗白**。修正为:
① 新增 **7 条归属断言 G-14b~G-14h**(Golden 29 → **36**);② 判定优先级改为 **证件号反查名册 > OCR 内容 > 姓名(仅辅助)**;③ 新增 **VAL-21 文件归属校验(阻断,且无"仍然继续")**;④ **姓名不再作为独立规则**(客户确认);⑤ 新增 **7 条功能验收 F-600-14~20**(104 → **111**)与 **4 条异常边界 E24~E27**(23 → **27**)
**修订后总计:36 条断言 / 111 条功能验收 / 27 条异常边界 / 6 条视觉 / 9 项性能 / 12 项安全** | +| V1.0 | 2026-09-28 | 首版。**29 条 Golden Assertions**(可直接写成自动化)、3 条端到端主流程(32 步)、23 条异常边界、**104 条功能验收项**(含 F-509 共 19 条)、6 条视觉验收、9 项性能、12 项安全;含里程碑签署表与 7 条 Go/No-Go 条件 | diff --git a/docs/db/schema-postgresql.sql b/docs/db/schema-postgresql.sql new file mode 100644 index 0000000..a6b5cd4 --- /dev/null +++ b/docs/db/schema-postgresql.sql @@ -0,0 +1,712 @@ +-- ============ 1) 建表 ============ + +CREATE TABLE sys_tenant ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_code varchar(32) NOT NULL, + tenant_name varchar(128) NOT NULL, + tenant_type smallint NOT NULL DEFAULT 1, + contact_name varchar(64), + contact_phone varchar(20), + package_id bigint, + expire_time timestamp, + status smallint NOT NULL DEFAULT 1, + logo_url varchar(512), + config_json jsonb, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_tenant_code UNIQUE (tenant_code) +); +COMMENT ON TABLE sys_tenant IS '租户'; +COMMENT ON COLUMN sys_tenant.id IS '租户ID'; +COMMENT ON COLUMN sys_tenant.tenant_code IS '租户编码(短码,用于小程序扫码识别)'; +COMMENT ON COLUMN sys_tenant.tenant_name IS '租户名称(学校/机构)'; +COMMENT ON COLUMN sys_tenant.tenant_type IS '1学校 2认定机构 3报名点 4企业'; +COMMENT ON COLUMN sys_tenant.contact_name IS '联系人'; +COMMENT ON COLUMN sys_tenant.contact_phone IS '联系电话'; +COMMENT ON COLUMN sys_tenant.package_id IS '套餐ID'; +COMMENT ON COLUMN sys_tenant.expire_time IS '到期时间'; +COMMENT ON COLUMN sys_tenant.status IS '0停用 1正常 2试用'; +COMMENT ON COLUMN sys_tenant.logo_url IS 'Logo'; +COMMENT ON COLUMN sys_tenant.config_json IS '租户配置(默认压缩参数/文件保留天数等)'; + +CREATE TABLE sys_org ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + parent_id bigint NOT NULL DEFAULT 0, + org_name varchar(128) NOT NULL, + org_type smallint NOT NULL DEFAULT 3, + org_code varchar(64), + ancestor_path varchar(512), + leader_user_id bigint, + sort_no integer NOT NULL DEFAULT 0, + status smallint NOT NULL DEFAULT 1, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_tenant_parent ON sys_org (tenant_id, parent_id); +CREATE INDEX idx_tenant_name ON sys_org (tenant_id, org_name); +COMMENT ON TABLE sys_org IS '组织架构'; +COMMENT ON COLUMN sys_org.parent_id IS '父级ID,0为根'; +COMMENT ON COLUMN sys_org.org_name IS '组织名称,如 24中技数控1班'; +COMMENT ON COLUMN sys_org.org_type IS '1学校 2院系/专业 3班级 4报名点'; +COMMENT ON COLUMN sys_org.org_code IS '组织编码/班号'; +COMMENT ON COLUMN sys_org.ancestor_path IS '祖先路径,如 0,1,5'; +COMMENT ON COLUMN sys_org.leader_user_id IS '负责人(班主任)'; + +CREATE TABLE sys_occupation_ref ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + parent_id bigint NOT NULL DEFAULT 0, + name varchar(128) NOT NULL, + aliases varchar(512), + has_child smallint NOT NULL DEFAULT 0, + status smallint NOT NULL DEFAULT 1, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_parent ON sys_occupation_ref (parent_id); +CREATE INDEX idx_occref_name ON sys_occupation_ref (name); +COMMENT ON TABLE sys_occupation_ref IS '职业工种参考字典(仅拼写校验,非约束)'; +COMMENT ON COLUMN sys_occupation_ref.parent_id IS '0=职业; 非0=该职业下的工种'; +COMMENT ON COLUMN sys_occupation_ref.aliases IS '常见错写/别名,用于纠错提示'; +COMMENT ON COLUMN sys_occupation_ref.has_child IS '该职业是否有工种'; + +CREATE TABLE sys_user ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL DEFAULT 0, + org_id bigint, + username varchar(64) NOT NULL, + password varchar(128), + real_name varchar(64) NOT NULL, + phone varchar(20), + phone_hash char(64), + open_id varchar(64), + union_id varchar(64), + id_card_no varchar(128), + id_card_hash char(64), + avatar_url varchar(512), + user_type smallint NOT NULL DEFAULT 1, + status smallint NOT NULL DEFAULT 1, + last_login_time timestamp, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_tenant_username UNIQUE (tenant_id, username) +); +CREATE INDEX idx_phone_hash ON sys_user (phone_hash); +CREATE INDEX idx_idcard_hash ON sys_user (id_card_hash); +CREATE INDEX idx_openid ON sys_user (open_id); +COMMENT ON TABLE sys_user IS '用户'; +COMMENT ON COLUMN sys_user.tenant_id IS '0=平台运营'; +COMMENT ON COLUMN sys_user.org_id IS '所属组织'; +COMMENT ON COLUMN sys_user.username IS '登录名(教师)'; +COMMENT ON COLUMN sys_user.password IS 'BCrypt'; +COMMENT ON COLUMN sys_user.phone IS '手机号(AES加密存储)'; +COMMENT ON COLUMN sys_user.phone_hash IS '手机号HMAC,用于唯一检索'; +COMMENT ON COLUMN sys_user.open_id IS '微信openid'; +COMMENT ON COLUMN sys_user.id_card_no IS '证件号(AES加密存储)'; +COMMENT ON COLUMN sys_user.id_card_hash IS '证件号HMAC,用于比对与去重'; +COMMENT ON COLUMN sys_user.user_type IS '1教师/管理员 2填报人(学生) 3社会人员'; +COMMENT ON COLUMN sys_user.status IS '0停用 1正常 2待激活'; + +CREATE TABLE collect_task ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_code varchar(64) NOT NULL, + task_name varchar(200) NOT NULL, + collect_mode smallint NOT NULL DEFAULT 1, + batch_no varchar(64), + major_name varchar(64), + job_name varchar(64), + job_level varchar(32), + cert_type smallint NOT NULL DEFAULT 1, + start_time timestamp, + end_time timestamp, + audit_config jsonb, + naming_template varchar(256) NOT NULL DEFAULT '{姓名}-{证件号}-{材料名}', + compress_config jsonb, + roster_required smallint NOT NULL DEFAULT 1, + status smallint NOT NULL DEFAULT 0, + published_time timestamp, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_task_tenant_code UNIQUE (tenant_id, task_code) +); +CREATE INDEX idx_tenant_status ON collect_task (tenant_id, status); +CREATE INDEX idx_end_time ON collect_task (end_time); +COMMENT ON TABLE collect_task IS '收集任务'; +COMMENT ON COLUMN collect_task.task_code IS '任务编码,用于文件路径'; +COMMENT ON COLUMN collect_task.task_name IS '如 2026年9月机电专业认定材料收集'; +COMMENT ON COLUMN collect_task.collect_mode IS '1学校收集 2社会收集'; +COMMENT ON COLUMN collect_task.batch_no IS '批次号'; +COMMENT ON COLUMN collect_task.major_name IS '专业'; +COMMENT ON COLUMN collect_task.job_name IS '报考工种'; +COMMENT ON COLUMN collect_task.job_level IS '报考等级'; +COMMENT ON COLUMN collect_task.cert_type IS '1自主认定 2社会评价 3专项职业能力'; +COMMENT ON COLUMN collect_task.end_time IS '截止时间'; +COMMENT ON COLUMN collect_task.audit_config IS '审核流配置,见6.5'; +COMMENT ON COLUMN collect_task.compress_config IS '任务级压缩默认参数'; +COMMENT ON COLUMN collect_task.roster_required IS '是否需要名册: 固定为1,两种收集模式都必填(名单门禁)'; +COMMENT ON COLUMN collect_task.status IS '0草稿 1收集中 2已截止 3已归档 4暂停'; + +CREATE TABLE collect_task_org ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + org_id bigint NOT NULL, + head_user_id bigint, + deputy_user_ids varchar(256), + expected_count integer NOT NULL DEFAULT 0, + status smallint NOT NULL DEFAULT 1, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT uk_task_org UNIQUE (task_id, org_id) +); +CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id); +COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定'; +COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id'; +COMMENT ON COLUMN collect_task_org.deputy_user_ids IS '副班主任/协办人,逗号分隔'; +COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(冗余,由名册汇总)'; +COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效'; + +CREATE TABLE collect_form_schema ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + version integer NOT NULL DEFAULT 1, + schema_json jsonb NOT NULL, + is_current smallint NOT NULL DEFAULT 1, + create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_task_version ON collect_form_schema (task_id, version); +COMMENT ON TABLE collect_form_schema IS '表单定义(版本化)'; +COMMENT ON COLUMN collect_form_schema.version IS '版本号,已发布任务修改则+1'; +COMMENT ON COLUMN collect_form_schema.schema_json IS '字段定义数组'; + +CREATE TABLE collect_material_def ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + material_code varchar(32) NOT NULL, + material_name varchar(64) NOT NULL, + aliases varchar(512), + name_pattern varchar(256), + required smallint NOT NULL DEFAULT 1, + visible_when varchar(256), + file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png', + max_size_kb integer NOT NULL DEFAULT 1024, + compress_profile varchar(8) NOT NULL DEFAULT 'AUTO', + min_long_side integer NOT NULL DEFAULT 1400, + min_quality smallint NOT NULL DEFAULT 70, + target_dpi integer, + fixed_size varchar(32), + output_format varchar(16) NOT NULL DEFAULT 'jpg', + max_count smallint NOT NULL DEFAULT 1, + merge_enabled smallint NOT NULL DEFAULT 0, + ocr_type varchar(32), + sample_url varchar(512), + tip_text varchar(512), + sort_no integer NOT NULL DEFAULT 0, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_task_code UNIQUE (task_id, material_code) +); +CREATE INDEX idx_matdef_tenant ON collect_material_def (tenant_id); +COMMENT ON TABLE collect_material_def IS '材料项定义'; +COMMENT ON COLUMN collect_material_def.material_code IS '如 IDCARD/PHOTO1/SCHOOL_ROLL/WORK_PROOF'; +COMMENT ON COLUMN collect_material_def.material_name IS '规范材料名,如 身份证'; +COMMENT ON COLUMN collect_material_def.aliases IS '别名,逗号分隔,用于历史文件名识别'; +COMMENT ON COLUMN collect_material_def.name_pattern IS '命名模板,空则继承任务级'; +COMMENT ON COLUMN collect_material_def.visible_when IS '条件显示表达式'; +COMMENT ON COLUMN collect_material_def.file_types IS '格式白名单'; +COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB'; +COMMENT ON COLUMN collect_material_def.compress_profile IS '压缩档位: AUTO自动判定/P1证件照/P2证件翻拍/P3扫描件/P4截图/P5文档, 见6.3'; +COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(仅P2证件翻拍档生效)'; +COMMENT ON COLUMN collect_material_def.min_quality IS '质量下限,低于65视为不可接受'; +COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)'; +COMMENT ON COLUMN collect_material_def.fixed_size IS '固定像素尺寸(仅P1证件照档,如295x413)'; +COMMENT ON COLUMN collect_material_def.output_format IS 'P4截图档固定输出png'; +COMMENT ON COLUMN collect_material_def.max_count IS '允许上传份数,身份证正反面可2'; +COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张'; +COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE'; +COMMENT ON COLUMN collect_material_def.sample_url IS '示例图'; +COMMENT ON COLUMN collect_material_def.tip_text IS '填报提示语'; + +CREATE TABLE collect_roster ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + org_id bigint, + seq_no integer, + student_no varchar(64), + real_name varchar(64) NOT NULL, + name_pinyin varchar(128), + id_card_no varchar(128) NOT NULL, + id_card_hash char(64) NOT NULL, + id_card_mask varchar(32), + phone varchar(20), + phone_hash char(64), + ext_json jsonb, + bind_user_id bigint, + bind_time timestamp, + bind_type smallint, + submit_status smallint NOT NULL DEFAULT 0, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_task_idcard UNIQUE (task_id, id_card_hash) +); +CREATE INDEX idx_task_org ON collect_roster (task_id, org_id); +CREATE INDEX idx_bind_user ON collect_roster (bind_user_id); +CREATE INDEX idx_roster_name ON collect_roster (task_id, real_name); +COMMENT ON TABLE collect_roster IS '名册'; +COMMENT ON COLUMN collect_roster.org_id IS '班级'; +COMMENT ON COLUMN collect_roster.seq_no IS '序号(来自Excel)'; +COMMENT ON COLUMN collect_roster.student_no IS '学号(⚠️初稿遗漏,名册唯一性依据之一)'; +COMMENT ON COLUMN collect_roster.name_pinyin IS '拼音,用于近音纠错'; +COMMENT ON COLUMN collect_roster.id_card_no IS 'AES加密'; +COMMENT ON COLUMN collect_roster.id_card_hash IS 'HMAC,唯一性判断'; +COMMENT ON COLUMN collect_roster.id_card_mask IS '脱敏展示 3601**********1234'; +COMMENT ON COLUMN collect_roster.ext_json IS '名册其余字段(学历/工作单位/报考职业等)'; +COMMENT ON COLUMN collect_roster.bind_user_id IS '绑定的填报人'; +COMMENT ON COLUMN collect_roster.bind_type IS '1门禁核验匹配 2管理员指定'; +COMMENT ON COLUMN collect_roster.submit_status IS '0未提交 1草稿 2待审 3已通过 4已驳回'; + +CREATE TABLE collect_submission ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + roster_id bigint, + org_id bigint, + submitter_id bigint NOT NULL, + form_version integer NOT NULL DEFAULT 1, + form_data jsonb, + -- ⚠️ 与 collect_roster 对称的 18 个业务列:存学生「确认/修改后」的最终值 + -- 需要与名单值做 diff(留痕"学生改了哪些字段"),也是统计"实际报考各工种人数"的依据 + occupation varchar(64), + worktype varchar(64), + job_level varchar(32), + education varchar(32), + is_student smallint, + school varchar(128), + major varchar(128), + enroll_date date, + work_unit varchar(128), + is_retake smallint, + retake_theory varchar(64), + retake_practice varchar(64), + retake_overall varchar(64), + work_years smallint, + cert_occupation varchar(128), + cert_level varchar(32), + cert_no varchar(64), + extras_json jsonb DEFAULT '{}'::jsonb, -- 逃生舱 + changed_fields jsonb, -- 学生相对名单修改过的字段(留痕) + status smallint NOT NULL DEFAULT 0, + reject_count integer NOT NULL DEFAULT 0, + last_reject_reason varchar(512), + red_count integer NOT NULL DEFAULT 0, + yellow_count integer NOT NULL DEFAULT 0, + submit_time timestamp, + class_audit_by bigint, class_audit_time timestamp, + admin_audit_by bigint, admin_audit_time timestamp, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_task_submitter UNIQUE (task_id, submitter_id) +); +CREATE INDEX idx_task_status ON collect_submission (task_id, status, org_id); +CREATE INDEX idx_submitter ON collect_submission (submitter_id); +COMMENT ON TABLE collect_submission IS '填报单'; +COMMENT ON COLUMN collect_submission.roster_id IS '社会收集可为空'; +COMMENT ON COLUMN collect_submission.submitter_id IS '填报人user_id'; +COMMENT ON COLUMN collect_submission.form_data IS '表单数据快照(扩展字段;核心18列见上方独立列)'; +COMMENT ON COLUMN collect_submission.occupation IS '最终报考职业(学生确认后,应与名单一致)'; +COMMENT ON COLUMN collect_submission.worktype IS '最终报考工种'; +COMMENT ON COLUMN collect_submission.job_level IS '最终报考等级'; +COMMENT ON COLUMN collect_submission.is_student IS '是否在校生 0否 1是'; +COMMENT ON COLUMN collect_submission.work_unit IS '工作单位(没有填"无")'; +COMMENT ON COLUMN collect_submission.is_retake IS '是否补考 0否 1是'; +COMMENT ON COLUMN collect_submission.extras_json IS '租户/机构额外追加字段(逃生舱)'; +COMMENT ON COLUMN collect_submission.changed_fields IS '学生相对名单修改过的字段(留痕: {字段:{old,new,time}})'; +COMMENT ON COLUMN collect_submission.status IS '0草稿 1待初审 2待终审 3已通过 4已驳回 5已归档'; +COMMENT ON COLUMN collect_submission.red_count IS '红色问题数'; +COMMENT ON COLUMN collect_submission.yellow_count IS '黄色问题数'; + +CREATE TABLE collect_file ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + submission_id bigint NOT NULL, + roster_id bigint, + material_code varchar(32) NOT NULL, + version integer NOT NULL DEFAULT 1, + is_current smallint NOT NULL DEFAULT 1, + original_name varchar(512) NOT NULL, + standard_name varchar(512), + file_ext varchar(16) NOT NULL, + mime_type varchar(64), + raw_size bigint NOT NULL, + final_size bigint NOT NULL, + file_hash char(64) NOT NULL, + raw_path varchar(512), + std_path varchar(512), + raw_expire_time timestamp, + width integer, height integer, quality smallint, + dpi integer, + ocr_status smallint NOT NULL DEFAULT 0, + ocr_result jsonb, + ocr_confidence numeric(4,3), + detected_material_code varchar(32), + detect_confidence numeric(4,3), + detect_source varchar(32), + detect_conflict smallint NOT NULL DEFAULT 0, + need_manual_place smallint NOT NULL DEFAULT 0, + vlm_called smallint NOT NULL DEFAULT 0, + validate_status smallint NOT NULL DEFAULT 0, + source smallint NOT NULL DEFAULT 1, + upload_ip varchar(64), + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_file_submission ON collect_file (submission_id, material_code, is_current); +CREATE INDEX idx_task_material ON collect_file (task_id, material_code); +CREATE INDEX idx_hash ON collect_file (file_hash); +CREATE INDEX idx_raw_expire ON collect_file (raw_expire_time); +COMMENT ON TABLE collect_file IS '文件实体'; +COMMENT ON COLUMN collect_file.is_current IS '是否最新有效版本'; +COMMENT ON COLUMN collect_file.original_name IS '原始文件名'; +COMMENT ON COLUMN collect_file.standard_name IS '规范文件名'; +COMMENT ON COLUMN collect_file.raw_size IS '原始字节'; +COMMENT ON COLUMN collect_file.final_size IS '处理后字节'; +COMMENT ON COLUMN collect_file.file_hash IS 'SHA-256,用于查重'; +COMMENT ON COLUMN collect_file.raw_path IS '对象存储key-原始'; +COMMENT ON COLUMN collect_file.std_path IS '对象存储key-规范'; +COMMENT ON COLUMN collect_file.raw_expire_time IS '原始文件清理时间'; +COMMENT ON COLUMN collect_file.ocr_status IS '0未识别 1成功 2失败'; +COMMENT ON COLUMN collect_file.ocr_result IS 'OCR结构化结果(不存明文证件号,存hash)'; +COMMENT ON COLUMN collect_file.detected_material_code IS '材料识别结果(材料类型编码),见功能逻辑卷3.5-B'; +COMMENT ON COLUMN collect_file.detect_confidence IS '材料识别置信度'; +COMMENT ON COLUMN collect_file.detect_source IS '识别来源: FILENAME/形态KEYWORD/POSITION/VLM/MIXED'; +COMMENT ON COLUMN collect_file.detect_conflict IS '是否与槽位冲突(1=冲突)'; +COMMENT ON COLUMN collect_file.need_manual_place IS '是否需要人工归属(散图分拣用)'; +COMMENT ON COLUMN collect_file.vlm_called IS '是否触发了VLM兜底(用于成本核算)'; +COMMENT ON COLUMN collect_file.validate_status IS '0未校验 1通过 2警告 3错误'; +COMMENT ON COLUMN collect_file.source IS '1本人上传 2班主任代传 3管理员代传 4整理器导入'; + +CREATE TABLE collect_validation_issue ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + submission_id bigint NOT NULL, + file_id bigint, + rule_code varchar(32) NOT NULL, + severity smallint NOT NULL, + field_key varchar(64), + title varchar(128) NOT NULL, + detail varchar(512), + expected_val varchar(256), + actual_val varchar(256), + auto_fixed smallint NOT NULL DEFAULT 0, + resolved smallint NOT NULL DEFAULT 0, + resolved_by bigint, resolved_time timestamp, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_issue_submission ON collect_validation_issue (submission_id, resolved); +CREATE INDEX idx_task_severity ON collect_validation_issue (task_id, severity, resolved); +COMMENT ON TABLE collect_validation_issue IS '校验问题'; +COMMENT ON COLUMN collect_validation_issue.file_id IS '可为空(表单级问题)'; +COMMENT ON COLUMN collect_validation_issue.rule_code IS 'VAL-01..VAL-12'; +COMMENT ON COLUMN collect_validation_issue.severity IS '1信息 2警告 3错误'; +COMMENT ON COLUMN collect_validation_issue.expected_val IS '期望值(如名册中的证件号)'; +COMMENT ON COLUMN collect_validation_issue.actual_val IS '实际值'; +COMMENT ON COLUMN collect_validation_issue.auto_fixed IS '是否已自动修复'; + +CREATE TABLE collect_audit_record ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + submission_id bigint NOT NULL, + audit_node varchar(32) NOT NULL, + auditor_id bigint NOT NULL, + action smallint NOT NULL, + reason_code varchar(32), + reason_text varchar(512), + material_codes varchar(256), + duration_sec integer, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_audit_submission ON collect_audit_record (submission_id, create_time); +CREATE INDEX idx_task_node ON collect_audit_record (task_id, audit_node, create_time); +COMMENT ON TABLE collect_audit_record IS '审核记录'; +COMMENT ON COLUMN collect_audit_record.audit_node IS 'CLASS_AUDIT/ADMIN_AUDIT'; +COMMENT ON COLUMN collect_audit_record.action IS '1通过 2驳回 3批量通过 4批量驳回 5转交 6代传'; +COMMENT ON COLUMN collect_audit_record.reason_code IS '驳回原因编码'; +COMMENT ON COLUMN collect_audit_record.material_codes IS '被驳回的材料项'; +COMMENT ON COLUMN collect_audit_record.duration_sec IS '处理耗时'; + +CREATE TABLE collect_export_task ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + export_name varchar(256) NOT NULL, + scope_json jsonb, + split_size_mb integer NOT NULL DEFAULT 0, + desensitize smallint NOT NULL DEFAULT 0, + status smallint NOT NULL DEFAULT 0, + progress smallint NOT NULL DEFAULT 0, + file_count integer, total_size bigint, + result_files jsonb, + error_msg varchar(512), + expire_time timestamp, + create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + finish_time timestamp, + PRIMARY KEY (id) +); +CREATE INDEX idx_task ON collect_export_task (task_id, create_time); +CREATE INDEX idx_status ON collect_export_task (status); +COMMENT ON TABLE collect_export_task IS '导出任务'; +COMMENT ON COLUMN collect_export_task.scope_json IS '范围:orgIds/userIds/仅通过终审/是否含原图'; +COMMENT ON COLUMN collect_export_task.split_size_mb IS '0不分卷,>0按MB分卷'; +COMMENT ON COLUMN collect_export_task.desensitize IS '是否脱敏'; +COMMENT ON COLUMN collect_export_task.status IS '0排队 1处理中 2完成 3失败'; +COMMENT ON COLUMN collect_export_task.progress IS '0-100'; +COMMENT ON COLUMN collect_export_task.result_files IS '生成的包列表[{name,path,size,part}]'; +COMMENT ON COLUMN collect_export_task.expire_time IS '下载有效期'; + +CREATE TABLE collect_file_process_log ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + file_id bigint NOT NULL, + step varchar(32) NOT NULL, + before_val varchar(512), + after_val varchar(512), + extra_json jsonb, + cost_ms integer, + success smallint NOT NULL DEFAULT 1, + error_msg varchar(512), + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_file ON collect_file_process_log (file_id, step); +COMMENT ON TABLE collect_file_process_log IS '文件处理日志'; +COMMENT ON COLUMN collect_file_process_log.step IS 'NORMALIZE_NAME/COMPRESS/CONVERT/OCR/EXIF_FIX'; +COMMENT ON COLUMN collect_file_process_log.extra_json IS '如 {beforeSize,afterSize,attempts,quality,finalLongSide}'; + +CREATE TABLE collect_notify_record ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint, + channel varchar(16) NOT NULL, -- WECHAT_SUBSCRIBE / WECHAT_MP / SMS / INBOX + biz_type varchar(32) NOT NULL, -- TASK_PUBLISH / DEADLINE_REMIND / REJECT / APPROVE / EXPORT_DONE / AUDIT_TIMEOUT + receiver_id bigint NOT NULL, -- 接收人 sys_user.id + receiver_role varchar(16), -- 接收人角色(便于按角色统计) + template_code varchar(64), + content varchar(1024), + status smallint NOT NULL DEFAULT 0, -- 0待发 1成功 2失败 3额度不足跳过 + fail_reason varchar(512), + send_time timestamp, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_notify_task ON collect_notify_record (task_id, biz_type, create_time); +CREATE INDEX idx_notify_recv ON collect_notify_record (receiver_id, create_time); +COMMENT ON TABLE collect_notify_record IS '通知记录'; +COMMENT ON COLUMN collect_notify_record.channel IS '渠道: 微信订阅消息/服务号/短信/站内'; +COMMENT ON COLUMN collect_notify_record.biz_type IS '业务类型'; +COMMENT ON COLUMN collect_notify_record.status IS '0待发 1成功 2失败 3额度不足跳过'; +COMMENT ON COLUMN collect_notify_record.fail_reason IS '失败原因;额度不足时记录(应对R12订阅消息次数限制)'; + +-- 订阅额度余额(微信一次性订阅消息,发一条消耗一条) +CREATE TABLE collect_subscribe_quota ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + template_code varchar(64) NOT NULL, + total integer NOT NULL DEFAULT 0, -- 累计授权次数 + used integer NOT NULL DEFAULT 0, -- 已消耗次数 + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT uk_quota_user_tpl UNIQUE (user_id, template_code) +); +COMMENT ON TABLE collect_subscribe_quota IS '微信订阅消息额度余额'; +COMMENT ON COLUMN collect_subscribe_quota.total IS '累计授权次数'; +COMMENT ON COLUMN collect_subscribe_quota.used IS '已消耗次数;余额=total-used,不足时自动降级到服务号或短信'; + +CREATE TABLE collect_rule_template ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + template_name varchar(128) NOT NULL, + cert_type smallint, + form_schema jsonb, + materials jsonb, + naming_template varchar(256), + is_public smallint NOT NULL DEFAULT 0, + use_count integer NOT NULL DEFAULT 0, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_ruletpl_tenant ON collect_rule_template (tenant_id, is_public); +COMMENT ON TABLE collect_rule_template IS '规则模板'; +COMMENT ON COLUMN collect_rule_template.tenant_id IS '0=平台公共模板'; +COMMENT ON COLUMN collect_rule_template.template_name IS '如 自主认定标准模板'; +COMMENT ON COLUMN collect_rule_template.cert_type IS '1自主认定 2社会评价 3专项职业能力'; +COMMENT ON COLUMN collect_rule_template.form_schema IS '表单字段模板'; +COMMENT ON COLUMN collect_rule_template.materials IS '材料清单模板'; + +CREATE TABLE collect_cleanup_job ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + job_name varchar(128) NOT NULL, + source_type smallint NOT NULL DEFAULT 1, + source_path varchar(512) NOT NULL, + target_task_id bigint, + naming_template varchar(256) NOT NULL DEFAULT '{姓名}-{证件号}-{材料名}', + compress_config jsonb, + status smallint NOT NULL DEFAULT 0, + progress smallint NOT NULL DEFAULT 0, + report_json jsonb, + total_files integer, fixed_files integer, failed_files integer, + result_path varchar(512), + create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + finish_time timestamp, + PRIMARY KEY (id) +); +CREATE INDEX idx_cleanup_tenant ON collect_cleanup_job (tenant_id, create_time); +COMMENT ON TABLE collect_cleanup_job IS '历史数据整理任务'; +COMMENT ON COLUMN collect_cleanup_job.source_type IS '1上传压缩包 2对象存储目录'; +COMMENT ON COLUMN collect_cleanup_job.target_task_id IS '结果可导入某任务'; +COMMENT ON COLUMN collect_cleanup_job.status IS '0排队 1解析中 2处理中 3完成 4失败'; +COMMENT ON COLUMN collect_cleanup_job.report_json IS '体检报告:命名违规/证件号异常/体积超标/缺失/重复'; + + +-- ============ 1b) 客户确认后新增(FR-509) ============ + +CREATE TABLE collect_join_request ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + real_name varchar(64) NOT NULL, + id_card_hash char(64) NOT NULL, + id_card_mask varchar(32) NOT NULL, + phone_hash char(64), + phone_mask varchar(32), + org_hint varchar(128), + reason varchar(500), + status smallint NOT NULL DEFAULT 0, + reject_reason varchar(500), + roster_id bigint, + audit_by bigint, + audit_time timestamp, + applicant_openid varchar(64) NOT NULL, + applicant_uid bigint, + source smallint NOT NULL DEFAULT 1, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id) +); +CREATE UNIQUE INDEX uk_join_task_idcard ON collect_join_request (task_id, id_card_hash) + WHERE deleted = 0 AND status IN (0, 1); +CREATE INDEX idx_join_task_status ON collect_join_request (task_id, status, create_time); +CREATE INDEX idx_join_openid ON collect_join_request (applicant_openid); +COMMENT ON TABLE collect_join_request IS '名单加入申请'; +COMMENT ON COLUMN collect_join_request.real_name IS '申请人自述姓名(明文,业务必需)'; +COMMENT ON COLUMN collect_join_request.id_card_hash IS 'HMAC,与门禁一致,不存明文'; +COMMENT ON COLUMN collect_join_request.id_card_mask IS '脱敏展示'; +COMMENT ON COLUMN collect_join_request.org_hint IS '申请人自述班级(选填,可由管理员修正)'; +COMMENT ON COLUMN collect_join_request.reason IS '申请说明'; +COMMENT ON COLUMN collect_join_request.status IS '0待审 1已通过 2已驳回 3已失效(任务截止)'; +COMMENT ON COLUMN collect_join_request.reject_reason IS '驳回原因(驳回时必填)'; +COMMENT ON COLUMN collect_join_request.roster_id IS '审批通过后生成的名册项'; +COMMENT ON COLUMN collect_join_request.applicant_openid IS '用于限流(每任务<=2次)与审批通过后自动绑定'; +COMMENT ON COLUMN collect_join_request.source IS '1门禁失败页申请 2管理员代录'; + +-- ============ 2) 补列 ALTER ============ + +ALTER TABLE collect_task ADD COLUMN plan_scope_json jsonb; +ALTER TABLE collect_task ADD COLUMN plan_scope_confirmed smallint NOT NULL DEFAULT 0; +COMMENT ON COLUMN collect_task.plan_scope_json IS '本期报考范围快照(从名单聚合): {occupations:[{name,worktypes:[{name,levels:[]}]}], confirmed:true}'; +COMMENT ON COLUMN collect_task.plan_scope_confirmed IS '管理员是否已确认范围(未确认则追加名单不告警)'; + +-- 同学号的补齐:官方模板 21 字段中的非身份字段全部建为正式列 +-- 姓名、身份证号已有独立列(real_name / id_card_no),此处补齐其余 18 个 +ALTER TABLE collect_roster + ADD COLUMN occupation varchar(64), -- 报考职业 G + ADD COLUMN worktype varchar(64), -- 报考工种 H + ADD COLUMN job_level varchar(32), -- 报考等级 I + ADD COLUMN education varchar(32), -- 学历 D + ADD COLUMN is_student smallint, -- 是否在校生 N 0否 1是 + ADD COLUMN school varchar(128), -- 在读学校 O + ADD COLUMN major varchar(128), -- 所学专业 P + ADD COLUMN enroll_date date, -- 入学时间 Q YYYY-MM-DD + ADD COLUMN work_unit varchar(128), -- 工作单位 F 无则填"无" + ADD COLUMN is_retake smallint, -- 是否补考 J 0否 1是 + ADD COLUMN retake_theory varchar(64), -- 补考理论 K + ADD COLUMN retake_practice varchar(64), -- 补考实操 L + ADD COLUMN retake_overall varchar(64), -- 补考综合 M + ADD COLUMN work_years smallint, -- 本职业工作年限 R + ADD COLUMN cert_occupation varchar(128), -- 已获证书职业工种 S + ADD COLUMN cert_level varchar(32), -- 已获证书等级 T + ADD COLUMN cert_no varchar(64), -- 已获证书编号 U + ADD COLUMN extras_json jsonb DEFAULT '{}'::jsonb; -- 逃生舱:仅存租户/机构额外追加的字段 + +-- 报考范围聚合与按工种统计必需的索引 +CREATE INDEX idx_roster_scope ON collect_roster (tenant_id, task_id, occupation, worktype, job_level); +CREATE INDEX idx_roster_org ON collect_roster (task_id, org_id, deleted); + +COMMENT ON COLUMN collect_roster.occupation IS '报考职业(锁定,由老师确定)'; +COMMENT ON COLUMN collect_roster.worktype IS '报考工种(锁定;该职业无工种时填"无";专项职业能力为空)'; +COMMENT ON COLUMN collect_roster.job_level IS '报考等级(锁定;专项职业能力为空)'; +COMMENT ON COLUMN collect_roster.education IS '学历(下拉11项)'; +COMMENT ON COLUMN collect_roster.is_student IS '是否在校生 0否 1是'; +COMMENT ON COLUMN collect_roster.school IS '在读学校(在校生=是时必填)'; +COMMENT ON COLUMN collect_roster.major IS '所学专业(在校生=是时必填)'; +COMMENT ON COLUMN collect_roster.enroll_date IS '入学时间(在校生=是时必填,格式YYYY-MM-DD)'; +COMMENT ON COLUMN collect_roster.work_unit IS '工作单位(没有填"无")'; +COMMENT ON COLUMN collect_roster.is_retake IS '是否补考 0否 1是'; +COMMENT ON COLUMN collect_roster.retake_theory IS '补考理论(级联)'; +COMMENT ON COLUMN collect_roster.retake_practice IS '补考实操(级联)'; +COMMENT ON COLUMN collect_roster.retake_overall IS '补考综合(级联)'; +COMMENT ON COLUMN collect_roster.work_years IS '本职业工作年限(仅阿拉伯数字)'; +COMMENT ON COLUMN collect_roster.cert_occupation IS '已获证书职业工种(选填)'; +COMMENT ON COLUMN collect_roster.cert_level IS '已获证书等级(选填)'; +COMMENT ON COLUMN collect_roster.cert_no IS '已获证书编号(选填)'; +COMMENT ON COLUMN collect_roster.extras_json IS '租户/机构额外追加的字段(逃生舱,不参与核心统计)'; diff --git a/docs/prototype/admin-web.html b/docs/prototype/admin-web.html new file mode 100644 index 0000000..fd09e2e --- /dev/null +++ b/docs/prototype/admin-web.html @@ -0,0 +1,1564 @@ + + + + + +填报通 · 管理端(PC)交互原型 + + + + + + +
+
Prototype · 管理端 PC · V1.2
+

填报通 · 管理端交互原型

+

+ 11 屏:工作台 → 任务向导(命名试跑) → 名单导入 → 报考范围 → 数据看板 → 终审 → 导出中心,加上 表单设计器 / 材料规则配置 / 历史数据整理器 / 名单加入申请审批。
+ 移动端是「档案室」,桌面端往「台账」方向走——表格、树形、批量操作。信息密度比手机端高一个量级,因为管理员是坐在电脑前做批量决策的人。
+ 后三屏是**追溯矩阵审计后补的**:三者都是复杂交互,光靠文字描述开发会理解偏。 +

+
+ 缩放 + + + + 原型按 1180px 宽度绘制,屏幕窄时请选小缩放 +
+
+ +
+ + +
+
+
+
+
https://admin.tianbaotong.cn/tasks
+
+ +
+

工作台

江西省机电技师学院 · 2026年9月 + +
+
+
+
进行中任务
3
覆盖 6 个班级 / 168 人
+
待我终审
23
24中技数控1班 等 3 个班
+
异常待处理
7
证件号 3 · 低清 2 · 串档 1
+
本月导出
4次
最近 09-26 14:20
+
+ +
+
进行中的收集任务按截止时间排序
+ + + + + + + + + + + + + +
任务名称模式覆盖班级应填 / 已提交进度截止状态
2026年9月机电专业认定材料收集学校收集24中技数控1班28 / 23
82%
10-15收集中
2026年9月汽车维修工认定收集学校收集24中技汽修1班42 / 42
100%
10-12待终审
2026年秋季社会人员认定报名社会收集南昌报名点98 / 61
62%
10-20收集中
+
+ +
+
需要我处理的按紧急度排序
+
+
+ +
24中技数控1班 · 陈子文 疑似串档 一寸照片的证件号与他其他材料不一致,且两个号码校验位都通过(是两个真实的人)。需管理员判断是否驳回。
班主任已初审:标记为待管理员确认
+
+
+ +
24中技数控1班 · 报考范围发现异常项 28 人中 27 人报「车工/数控车工/四级中级工」,1 人报「车工/普通车工/三级高级工」(占比 3.6%),疑似填写错误。
+
+
+
+
+
+
+
+ STEP 01 + 工作台:先给结论,再给细节 + 四张统计卡回答"现在什么状况",下面两张卡回答"我要做什么"。异常按紧急度排在最上面——串档(有红项)在范围异常(黄项)之上。 + 注意"异常待处理 7"下面标注了构成(证件号 3 · 低清 2 · 串档 1)——管理员不用点进去就知道是什么类型的问题。 +
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/tasks/new?step=7
+
+ +
+

新建收集任务

草稿已保存 · 09:38
+
+
1基本信息
› +
2覆盖班级
› +
3名单
› +
4表单
› +
5材料
› +
6审核流
› +
7预览发布
+
+
+
+ +
发布前必须完成一次命名规则试跑。粘贴几条学生可能交上来的原始文件名,确认系统改出来的结果符合机构要求——这是防止"规则配错导致全员改名错误"的唯一闸门。
+
+ +
+
+
+
命名模板
+
+
+ {姓名}{证件号}{材料名} + {班级}{工种}{序号} +
+
{姓名}-{证件号}-{材料名}.jpg
+
+ 与机构模板一致。连接符为半角 -,大小写按名单原样。 +
+
+
+
+
纠错开关
+
+ + + + + +
+
+
+ +
+
+
试跑粘贴任意条数,不影响真实数据
+
+ +
+ + + 数据来自真实批次样本 +
+
+
+ +
+
试跑结果 + 自动修复 4 + 待确认 1 + 需人工 1 +
+ + + + + + + + + + + + + + + + + + + + + + +
原始文件名规范后判定
曾伊豪-361029200907295110身份证.jpg.jpg曾伊豪-361029200907295110-身份证.jpg自动修复
于豪-360103200805166169-身份证.jpg.jpg于豪-360103200805186619-身份证.jpg校验位裁决
扬晨-360825200904303215-一寸照片.jpg杨晨-360825200904303215-一寸照片.jpg形近字待确认
曹文锋-36012120081211239-一寸照片.jpg曹文锋-360121200812111239-一寸照片.jpg名册补齐
徐程宇360981200901292334-身份证.jpg.jpg徐程宇-360981200901292334-身份证.jpg自动修复
陈子文-360122200902044232-一寸照片.jpg— 不予自动修改 —疑似串档 · 转人工
+
+
+ +
最后一条必须人工处理。陈子文的照片证件号 360122200902044232 与名单 36010520090817121X 不一致,且两个号码校验位都通过——说明是两个真实存在的人,不是打字错误。系统不猜,交人工判断。
+
+
+
+
+
+
+
+ 试跑通过率 83%(5/6 可自动处理) + + + + +
+
+
+
+ STEP 02 · 最重要的一屏 + 命名规则试跑:发布前的唯一闸门 + 没有这一屏,规则配错就会导致全员改名错误——而且错到导出时才会发现,那时已无法挽回。所以它被设成发布的前置条件(未试跑不给发布)。 + 结果表用红底高亮"改了什么"、绿底标"补了什么"。最后一行陈子文的案例最有价值:系统明确说"两个号码校验位都通过 = 是两个真人,不是打字错误",并拒绝自动修改——把判断权交还给人。 +
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/tasks/1/roster/import
+
+ +
+

导入名单

2026年9月机电专业认定材料收集
+
+
+
① 表头识别已自动定位
+
+
+ +
系统没有假设"第一行是表头",而是在工作表中逐行查找同时包含「姓名」「身份证号」的行。 + 本次在工作表「填表注意事项」的第 6 行找到表头——该文件前 5 行是填表说明与示例。
+
+
+ + + + + + + + + + + + +
行ABCDE…判定
1特别注意:请勿调整模板!!!在第一个…说明
201、标题颜色:红色标题为必填项…说明
302、下拉选项:学历、报考等级…说明
4…说明
5…说明
6序号姓名身份证号学历联系电话…表头
71熊宇豪360981…4437技工学校199…0823…数据
82魏航360427…3318技工学校178…3067…数据
+
+
+
+ +
+
+
② 字段映射21 列已自动匹配
+
+ + + + + + + + + + + + +
Excel 列系统字段状态
B · 姓名姓名已匹配
C · 身份证号身份证号已匹配
G · 报考职业报考职业 锁定已匹配
H · 报考工种报考工种 锁定已匹配
I · 报考等级报考等级 锁定已匹配
D · 学历学历 可改已匹配
N · 是否在校生是否在校生 可改已匹配
— 未识别 —(其余 14 列)忽略
+
+
+ +
+
③ 导入体检可以导入
+
+
+
总行数
28
+
通过
28
+
警告
1
+
+
+ +
1 项需要注意(不阻断导入)
+ 「报考工种」出现 2 种写法:数控车工(27 人)与 普通车工(1 人)——见下一步的「报考范围确认」。
+
+
+ +
身份证号 28/28 全部通过校验位,无重复、无缺失。姓名列无重复。
+
+
+
+
+
+
+ 表头定位 + 字段映射已确认,可导入 + + + + +
+
+
+
+ STEP 03 + 名单导入:把"系统怎么认表头"摊开给人看 + 实测两份真实文件,表头一个在第 1 行、一个在第 6 行,工作表名也不同。所以系统不猜、也不要求用户改文件,而是把定位过程可视化——说明行标灰、表头行标绿,让人一眼确认"认对了没有"。 + 第②栏的字段映射里,报考三项带"锁定"标记——这是在导入阶段就告诉管理员:这三项由你确定,学生端只读。第③栏的体检把"警告"和"通过"分开呈现,并明确写"不阻断导入"。 +
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/tasks/1/scope
+
+ +
+

报考范围确认

2026年9月机电专业认定材料收集
+
+
+ +
本期报考范围由名单自动聚合而来。系统不做更正,只做一致性体检——单看一行都合法,但"27 人报 A、1 人报 B"这种情况只有聚合后才能发现。
+
+ +
+
聚合结果共 2 组 · 28 人
+ + + + + + + + + + + + + + +
报考职业报考工种报考等级人数占比判定
车工数控车工四级/中级工2796.4%主体范围
车工普通车工三级/高级工13.6%疑似填错
+
+
+ +
「普通车工 / 三级高级工」仅 1 人,占比 3.6%,明显偏离主体范围。 + 常见的三种可能:① 该生确实单独报考了另一个工种;② 老师录入时选错了;③ 该生是转班生,用的是上一批次的配置。
+ 请核对后再决定——系统不会自动改名单。
+
+
+
+ +
+
+
定位到的人
+
+ + + + + +
姓名身份证号归属
余饶平360281…403524中技数控1班
+
+ + +
+
+
+
+
后续影响重要
+
+ · 学生端展示
+ 学生核验通过后会看到"你报考:车工 / 普通车工 / 三级高级工"——如果这是错的,学生会发现并反馈。
+ · 中途追加名单
+ 若之后补录的人报考项超出已确认范围,系统会告警并要求你确认后才接受。 +
+
+
+
+
+ 确认后,学生端将按此范围展示报考信息 + + + + +
+
+
+
+ STEP 04 · 新概念 + 报考范围确认:不做更正,只做体检 + 「报考范围」这个实体是我们拆解官方模板时才发现的——模板规则 04/05/06 说"只能填当前评价计划中的职业/工种/等级",但实测确认这个范围就藏在名单里(老师导入时已填好),不存在独立的计划文件。 + 所以这里不设计"约束"设计,而是"体检"设计:系统把聚合结果摆出来,标出"少数派",给出三种常见原因,然后把决定权交还给人。这种"27 人一致、1 人例外"的问题,单看任何一行都合法,只有聚合后才能发现。 +
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/tasks/1/dashboard
+
+ +
+

数据看板

24中技数控1班 · 实时 + +
+
+
+
应填人数
28
1 个班级
+
已终审通过
15
53.6%
+
提交待处理
8
初审 4 · 待核对 3 · 异常 1
+
未提交
5
距截止 3 天
+
+ +
+
+
按报考工种统计来自正式列,实时聚合
+
+ + + + + + + + +
报考工种等级人数占比
数控车工四级/中级工27
96.4%
普通车工三级/高级工1
3.6%
+
+
+ +
这类统计之所以能实时出,是因为 18 个业务字段建成了正式列,而不是塞进 JSON。SQL 直接 GROUP BY,还能吃到索引。
+
+
+
+
+ +
+
材料异常清单需处理 4
+
+ + + + + + + + +
姓名问题级别
陈子文一寸照片证件号与其它材料不符串档
杨晨身份证图偏小 558×745低清
曹文锋证件号 17 位,已按名单补齐待核对
黎思明证件号 17 位,已按名单补齐待核对
+
+
+
+ +
+
处理进度按状态分布
+
+
+ + + + + +
+
+ 已通过 15 + 待初审 4 + 待核对 3 + 异常 1 + 未提交 5 +
+
+
+
+
+
+
+ STEP 05 + 数据看板:把"为什么能实时统计"写出来 + 这是当初坚持把 18 个业务字段建成正式列、而不是塞进 JSON 换来的能力——按工种统计、按学历筛选、按班级排名全部走 SQL 聚合 + 索引,实时出数。 + 看板上直接放了一句说明:"这类统计之所以能实时出,是因为字段建成了正式列。"——把架构决策的收益摆在用户能看到的地方,比写在技术文档里有用。 +
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/review?status=pending_final
+
+ +
+

终审

仅显示班主任已初审通过的填报单 + +
+
+ 全部 23 + 无异常 22 + 有异常 1 + + + "一键全部通过"仅在零异常时可用 + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
姓名班级初审人材料系统校验提交时间操作
曾伊豪24中技数控1班李老师4/4 · 2.9MB无问题09-28 09:41
李绍康24中技数控1班李老师4/4 · 3.1MB无问题09-28 08:12
何天宇24中技数控1班李老师4/4 · 2.6MB无问题09-27 21:03
陈子文24中技数控1班李老师4/4 · 1.4MB疑似串档09-27 16:55
曾云天24中技数控1班李老师4/4 · 2.2MB无问题09-27 15:20
+
+
+ +
"一键全部通过"按钮此刻是禁用状态(因存在 1 条异常)。这是刻意的:防止管理员大而化之地放行。只有整批零异常时才允许全通过。
+
+
+
+
+
+ STEP 06 + 终审:把"全选"这条路堵掉一半 + 终审是最容易出事的环节——管理员面对 23 条记录,天然想"全选通过"。所以设计上做了两层限制:异常行默认不勾选且标红,"一键全部通过"在没有异常时才可用。 + 表格里保留"初审人"一列——终审时能看到是谁初审判过的,出问题时可以回溯到人。责任可见,是审核质量的前提。 +
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/export/new
+
+ +
+

创建导出

2026年9月机电专业认定材料收集
+
+
+ +
前置检查未通过:1 份材料存在异常,无法打包。 +
陈子文的「一寸照片」疑似串档尚未处理。这是铁律——不合格的文件不允许进入汇总包。
+
+ +
+
+
+
导出范围
+
+ + + + +
+ + + +
+
+
+
+
打包设置
+
+
+ 分卷大小 + +
+
+ +
预计 1 卷,共 约 37 MB。对比原始文件总量 475 MB,压缩率 92%。
+
+
+
+
+ +
+
目录结构预览按机构要求生成
+
+
+
├─ 202609机电认定-24中技数控1班/
+
├─ 一寸照片/
+
│ └─ 熊宇豪-360981200812084437-一寸照片.jpg 96KB
+
├─ 身份证/
+
│ └─ 熊宇豪-360981200812084437-身份证.jpg 810KB
+
├─ 学籍表/
+
│ └─ 熊宇豪-360981200812084437-学籍表.png 41KB
+
├─ 工作证明/
+
│ └─ 熊宇豪-360981200812084437-工作证明.jpg 806KB
+
└─ 考生线下报名信息导入模板(汇总).xlsx 18KB
+
+
+
+ 共 15 人 × 4 项 = 60 个文件,已全部通过命名规范与体积校验。 +
+
+
+
+
+
+
+ 导出为异步任务,完成后微信通知你 + + + + +
+
+
+
+ STEP 07 + 导出中心:把"不允许带问题打包"做成按钮禁用 + PRD 里的铁律 2 是「没有规范文件名、没有压到 1M 内的文件,不得进入汇总包」。这里不是弹个警告了事,而是直接禁用"开始导出"按钮,并指出是哪一个人的哪一项卡住了。 + 右侧的目录树预览是关键——管理员在点导出之前就能看到最终交付的文件长什么样、名字对不对、体积标没标。不用等打完包再解压检查。
+ 底部还报了一个容易被忽略的数字:原始 475MB → 最终 37MB,压缩率 92%。
+
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/tasks/new?step=4
+
+ +
+

新建收集任务

第 4 步 · 表单设计
+
+
1基本信息
› +
2覆盖班级
› +
3名单
› +
4表单
› +
5材料
› +
6审核流
› +
7预览发布
+
+
+
+ +
表单分两层:内置字段(来自官方模板,存正式列、可统计)+ 扩展字段(机构额外要求,存 JSON)。内置字段的标签可改、顺序可调,但不能删除——它们是导出模板的固定列。
+
+
+ + +
+
字段库
+
+
基础
+
+
单行文本
+
数字
+
单选 / 多选
+
日期
+
下拉字典
+
+
专用
+
+
身份证号
+
手机号
+
职业工种级联
+
+
拖到右侧画布即可添加
+
+
+ + +
+
表单画布21 内置 + 0 扩展
+
+
内置字段 · 来自官方模板
+
⋮⋮姓名文本锁定
+
⋮⋮身份证号身份证锁定
+
⋮⋮报考职业 / 工种 / 等级级联锁定
+
⋮⋮学历下拉 11 项可改
+
⋮⋮联系电话手机号
+
⋮⋮工作单位文本
+
⋮⋮是否补考是/否
+
⋮⋮补考理论 / 实操 / 综合级联条件显示
+
⋮⋮是否在校生是/否
+
⋮⋮在读学校 / 所学专业 / 入学时间级联条件显示
+
⋮⋮本职业工作年限数字
+
⋮⋮已获证书职业工种 / 等级 / 编号文本
+
扩展字段 · 存 JSON,不参与核心统计
+
从左侧拖字段到这里
+
+
+ + +
+
字段属性学历
+
+
标签
+ +
选项来源
+ +
校验
+ + +
学生端行为
+
+ +
预填可改
名单有值则预填并标注"老师已填,请核对"
+
+
+
+ +
+ +
+
条件显示规则官方模板用绿色标题标识的级联列
+
+
+ 当是否补考=是 + →显示补考理论 / 补考实操 / 补考综合 + 并校验:科目数受等级约束 +
+
+ 当是否在校生=是 + →显示且必填在读学校 / 所学专业 / 入学时间 + 入学时间格式 YYYY-MM-DD +
+
+ 当认定类型=专项职业能力 + →隐藏报考工种 / 报考等级 / 全部补考科目 + 整组跳过校验 +
+
+
+
+
+
+
+ STEP 08 + 表单设计器:内置字段锁定,扩展字段自由 + 左中右三栏是老配方(字段库 / 画布 / 属性),但有一个关键约束:内置的 21 个字段带锁标记,可改标签、可调顺序,但不能删除——它们是导出模板的固定列,删了就无法写回官方 Excel。 + 底部那三条条件显示规则直接对应官方模板的"绿色标题 = 级联选项"。第三条(专项职业能力 → 整组隐藏并跳过校验)最容易被漏做,单独列出来。
+ "扩展字段"区域用虚线框标注存 JSON、不参与核心统计——把之前那个架构取舍摆在配置界面上,管理员就知道为什么有些字段能统计、有些不能。
+
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/tasks/new?step=5
+
+ +
+

新建收集任务

第 5 步 · 材料规则 + +
+
+
1基本信息
› +
2覆盖班级
› +
3名单
› +
4表单
› +
5材料
› +
6审核流
› +
7预览发布
+
+
+
+
+
材料清单4 项
+
+
一寸照片
P1 证件照 · ≤0.3M
+
身份证
P2 证件翻拍 · ≤1M
+
学籍表
P4 截图 · ≤1M
+
工作证明
P3 扫描件 · ≤1M
+ +
+
+ +
+
「一寸照片」的规则实测 984×1378 → 规格化 295×413
+
+
+
+
命名模板
+
{姓名}-{证件号}-{材料名}.jpg
+
+ {姓名}{证件号}{班级}{序号} +
+
别名(用于识别历史文件名)
+
+ 1寸照片 · 一寸电子照片 · 证件照 · 免冠照 +
+
+
+
格式与体积
+
+ jpgpngpdfheic +
+
+ 体积上限 + + KB + 含下限保护 +
+
压缩档位
+ +
+ +
本项有专属规则:证件照规格化会裁切并写入 300dpi
+
+
+
+ +
+
来源引导语(显示在学生端)
+ +
+ + + +
+
+
+
+
+
+
+
+
+ STEP 09 + 材料规则配置:把"压缩档位"做成可解释的选择 + 每个材料项独立配置命名模板、别名、格式白名单、体积上限、压缩档位、来源引导语。左侧列表直接显示每项的档位与上限——管理员一眼能看出四项材料用了四套不同规则。 + 三个细节:① 体积上限旁标"含下限保护"(防止过度压缩,实测有一张身份证只有 0.08MB);② 压缩档位下拉里写明每档的适用条件("P1 证件照(固定 295×413)"),不是让管理员去猜 "P1" 是什么;③ "开启拍照入口"默认不勾——因为实测 4 类材料无一来自现场拍照。 +
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/cleanup
+
+ +
+

历史数据整理

把已有的乱文件夹,一键变成规范包
+
+
+ +
不需要重新收集。把手上已有的压缩包或文件夹传上来,系统会用与正式收集完全相同的规则改名、压缩、体检,输出一份可直接上报的规范包 + 一份问题清单。
+
+ +
+
+
① 上传已解析
+
+
+ +
+
24中技数控1班.rar
+
436 MB · 4 个目录 · 112 个文件
+
+
+
+ +
已按「姓名-证件号-材料名」解析出 28 名学生的 4 类材料,无需命名规范即可识别。
+
+
+
+ +
+
② 定期体检报告用真实数据实测得出
+
+ + + + + + + + + + +
问题类型数量占比处理
命名违规5952.7%自动修复
体积超标(>1M)3127.7%自动压缩
证件号与名单不符517.9%3 自动 · 2 待确认
疑似串档13.6%必须人工
分辨率不足13.6%提示重传
散图待归属0——
+
+
+ +
112 个文件里 59 个需要改名、31 个需要压缩,但只有 1 个必须你亲自判断。其余 90 个系统全自动处理。
+
+
+
+
+
+ +
+
③ 输出结果规范化包已生成
+
+
+
原始总量
475 MB
+
规范化后
37 MB
+
压缩率
92%
+
+
+ + + +
+
+
+
+
+
+
+ STEP 10 · 差异化获客功能 + 历史数据整理器:让老师先看到价值,再谈上线 + 客户手里往往已经有一堆历史乱数据(本例是 436MB 的 rar)。不需要重新收集,直接传上来就能产出规范包和问题清单——这是最有力的演示,也是最有效的获客入口。 + 报告里的数字全部来自真实实测:命名违规 59、体积超标 31、证件号异常 5(其中 3 个可自动、2 个待确认)、疑似串档 1、分辨率不足 1。最后那句"90 个系统全自动处理,只有 1 个必须你亲自判断",比任何功能列表都有说服力。 +
+
+ + +
+
+
+
+
https://admin.tianbaotong.cn/roster/join-requests
+
+ +
+
+

名单加入申请

24中技数控1班 · 2026年9月认定 + +
+
+
+ +
这些人在门禁没匹配上,主动申请加入名单。通过后会自动建成名册项,该学生凭微信再次进入即可直接填报,不用重输。
+ 申请本身不能填报——通道只解决"进名单",不绕过门禁。
+
+ +
+
+
待审申请5 条待处理
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
申请人身份证号手机号自述班级 / 说明等待操作
李四361004********3318138****888824中技数控2班
我是这学期转到2班的
2.5h
王芳 他任务已有360105********1628139****2233—
2026年3月认定 已在本校名单
5.1h
赵敏361029********4021137****901224中技数控1班
名单上把我的名字写错了
7.8h
陈强362502********1830135****6677—
朋友说这里可以报名
21.3h
孙悦 第 2 次申请361024********0917136****334424中技数控2班
上次被驳回:班级不在本次范围
26.5h
+
+
+ +
+
+
批量处理
+
+ +
+ +
驳回必须逐条填原因,不支持批量驳回
+
+
+ 批量通过时会弹出确认框,逐条列出将建册的人员,不提供"全选"快捷键。 +
+
+
+ +
+
本任务申请统计
+
+
待审核5
+
已通过12
+
已驳回3
+
通过率80%
+
+ +
12 人已通过并自动入册
+
+
+
+
+
+
+
+
+
+ STEP 11 · 客户确认新增 + 名单加入申请:给门禁一条受控的出口,而不是开后门 + 门禁不通过的人原先只能"联系老师",老师再手工加名单。几百人的社会收集场景下,老师会被拖垮。这一屏把加人这件事从"老师手工"变成"学生自助 + 老师逐条裁决"。 + 四个细节:
+ ① 「他任务已有」标记——王芳的证件号在「2026年3月认定」的名册里匹配上了,系统主动提示"这可能是本校学生",帮管理员判断;
+ ② 「第 2 次申请」标记——孙悦上次被驳回,管理员能看到上次的驳回原因,避免重复纠结;
+ ③ 「驳回必须逐条填原因」且批量通过不提供全选——身份认定环节的误操作代价极高,与"批量通过只带绿色项"同源;
+ ④ 顶部那句「申请本身不能填报」是设计边界:通道只解决"进名单",不绕过门禁。
+
+
+ +
+ +
+

设计依据 · 桌面端与移动端的分工

+
+
+

01为什么管理端必须做在 PC

+ 任务向导 7 步、21 列字段映射、目录树预览、批量终审——这些都需要足够大的画布和多列信息并置。PRD 里明确"管理员在 PC 上操作",正是因为手机屏幕放不下这些决策所需的信息密度。 +
+
+

02为什么"命名规则试跑"是最重要的一屏

+ 规则配错的代价是全员改名错误,而且错到导出时才会发现——那时学生早就交完材料了。所以它被设成发布的前置条件(未试跑不给发布),而不是一个可选功能。 +
+
+

03为什么表头识别要可视化

+ 实测两份真实文件:一份表头在第 1 行、一份在第 6 行,工作表名还不同。系统自动定位之后,必须让人确认"认对了没有"——默默猜对是运气,猜错了没人知道。 +
+
+

04为什么"报考范围"只做体检不做更正

+ 范围来自名单(老师已填好),系统没有立场去改它。但"27 人一致、1 人例外"这类问题单看任何一行都合法,只有聚合后才能发现——所以给出数据、给出三种常见原因,把判断权交还给人。 +
+
+

05为什么导出要禁用按钮而不是弹警告

+ 警告可以点"我知道了"绕过,禁用不能。铁律必须体现在交互层面而非提示层面。同时明确告知"卡在谁身上、卡在哪一项",让管理员知道去哪儿解决。 +
+
+

06为什么数据看板要标注"来自正式列"

+ 这是把架构决策的收益摆到用户能看到的地方。当初为"存 JSON 还是建列"争论过一轮,最终选择建 18 个正式列——代价是 DDL 长一点,收益是按工种统计、按学历筛选、按班级排名全部实时且走索引。 +
+
+

07为什么内置字段要"锁"

+ 21 个内置字段来自官方模板,机构明确"请勿调整模板"。它们既是导出 Excel 的固定列,又是统计的正式列。所以设计器里**可改标签、可调顺序,但不能删除**——删了就无法写回官方模板。 +
+
+

08为什么压缩档位要写成"P1 证件照(固定 295×413)"

+ 如果下拉里只写"P1 / P2 / P3",管理员必须去翻文档才知道选哪个。**把档位的适用条件直接写在选项里**,配置界面本身就变成说明书。 +
+
+

09为什么整理器是获客利器

+ 客户手里普遍已有历史乱数据(本例 436MB 的 rar)。让他们**先看到价值再谈上线**:不重新收集,30 秒内出一份体检报告和规范包。报告里"90 个系统全自动处理,只有 1 个必须你亲自判断"这句话,比任何功能列表都有说服力。 +
+
+
+ + + + diff --git a/docs/prototype/index.html b/docs/prototype/index.html new file mode 100644 index 0000000..42b8fed --- /dev/null +++ b/docs/prototype/index.html @@ -0,0 +1,129 @@ + + + + + +填报通 · 原型索引 + + + + + +
+
Prototype Index · V1.0
+

填报通 · 交互原型

+

职业技能等级认定报名材料收集系统 · 两端原型
设计语言:「档案室 / 电子卷宗」

+
+ + + +
+ docs/prototype/ · 单文件自包含 · 零外部依赖 · 无 emoji
+ 数据取自真实批次「24中技数控1班」28 名学生 +
+ + diff --git a/docs/prototype/student-app.html b/docs/prototype/student-app.html new file mode 100644 index 0000000..526d21b --- /dev/null +++ b/docs/prototype/student-app.html @@ -0,0 +1,909 @@ + + + + + +填报通 · 学生端交互原型 + + + + + + + +
+
Prototype · 学生端 · V1.2
+

填报通 · 学生填报端交互原型

+

+ 11 屏完整流程:门禁核验 → 信息核对 → 选材上传 → 自动规范化 → 提交审核。
+ 新增 STEP 03B「申请加入名单」——门禁失败后的受控出口,客户确认后加入。
+ 设计方向为「档案室 / 电子卷宗」——本系统替代的是纸质材料袋与红章审批,界面应让人感到有秩序、可信、像一份正经公文。
+ 图标为内联 SVG 线性图标(统一 1.7px 描边、继承文字色),不用 emoji——避免跨平台渲染不一致,也避免消费级观感。
+ 全部文案遵循「零术语 · 指出位置 · 给出动作」三原则;数据取自真实批次 24中技数控1班 的 28 名学生。 +

+
+ 锁定:只读,名单为空则阻断发布 + 预填可改:老师已填,可核对修改 + 材料:学生唯一的重活 + 朱红:印章 / 需修正 +
+
+ +
+ + +
+
+
9:41
+ +
+
+ +
为保护你的信息,请先填写姓名和身份证号,与老师提供的名单核对后才能填报。
+
+
+ + +
+
+ + +
共 18 位,最后一位可能是字母 X(会自动转为大写)
+
+
+ +
格式检查通过
+
+
+
+ +
填写即表示同意《个人信息处理告知同意书》
+
+
+
+ STEP 01 + 门禁核验 + 进入填报的唯一入口。身份证号用等宽字体+字距呈现,因为它是"数据"不是"文字"。
+ 先在本地校验位数与校验位,不合格当场提示、不发请求——既省流量,也防刷接口。 +
+
+ + +
+
+
9:41
+ +
+
+

你是不是曾伊豪?

+

身份证号已和名单匹配上,但姓名不太一样。
请确认一下,这关系到你后续的证书信息。

+
+
+
+
你填写的曾依豪
+
老师名单曾伊豪
+
+
+ +
「依」和「伊」读音相同、字形相近,是最常见的写法差异。
+
+
+
+
+ + +
+
确认后姓名将按名单锁定,不可再改
+
+
+
+ STEP 02 + 形近字确认 + 不走"报错 → 重填",而是把系统判断摆出来让用户裁决。
+ 这个纠错能力来自真实数据——实测名单里就有「扬晨 ↔ 杨晨」的实例(差 1 字且拼音相同,命中规则但必须经人确认)。本屏用同类型的「曾依豪 ↔ 曾伊豪」示例,保持主角一致。 +
+
+ + +
+
+
9:41
+ +
+
+

没有找到你的信息

+

请核对姓名和身份证号是否与老师提供的名单一致。
如果确实已报名,可以申请加入名单,由老师审核。

+
+
+
+
本次填写 已提交
+
姓名曾伊豪
+
身份证号361029********5511
+
+
+ +
出于隐私保护,系统不会提示具体是哪一项不符。
+
+
+
+ +
+
名单里确实没有你?
+ +
+
连续 10 次未匹配将暂时锁定,1 小时后可重试
+
+
+
+ STEP 03 + 门禁失败(统一提示) + 故意不区分失败原因——无论"号码不在名单"还是"姓名不符",文案完全一致。
+ 否则这个接口就能被用来逐个探测身份证号是否在名单里。这一屏是安全设计,不是文案设计。 + 底部只有两条出路,都是系统内的动作:
+ ① 重新填写(主路径)——大多数情况只是名字打错了;
+ ② 申请加入名单(FR-509)——确实不在名单里时自助申请,由管理员逐条审批。
+ 系统内不展示任何联系方式——学生在班级群里本来就有班主任的联系方式,系统再放一份既不必要,也带来维护与隐私成本。
+
+
+ + +
+
+
9:41
+ +
+
+
1填写申请
+
2等待审核
+
3重新核验
+
+ +
+ +
提交后由学校老师审核,审核期间还不能填报。通过后你会收到通知,届时重新核验即可进入。
+
+ +
+
申请人信息
+
+ + +
+
+ + +
+
+ + +
用于老师与你联系,不会公开展示
+
+
+ +
+
补充说明 选填
+
+ + +
+
+ + +
+
+
+
+ +
同一任务最多申请 2 次 · 提交后不可修改
+
+
+
+ STEP 03B · 客户确认新增 + 申请加入名单 + 三步进度条把"提交 → 等待 → 重新核验"讲清楚,避免学生误以为提交完就能填了。
+ 顶部那条提示是本屏的关键:「审核期间还不能填报」。如果这句话不写,学生会反复回来点填报、以为系统坏了。
+ 补充说明是选填——实测里"我是转班过来的"这类信息对管理员判断很有用,但不能设成必填,否则会挡住真正需要帮助的人。
+
+
+ + +
+
+
9:41
+ +
+
2026年9月机电专业认定材料收集
+
截止 10-15江西省机电技师学院
+
+
+
+
2/4
+
+
曾伊豪 · 24中技数控1班
+
还差 2 份材料,去传一下就能提交
+
+
+ +
+
报考信息 老师已确定
+
报考职业车工
+
报考工种数控车工
+
报考等级四级/中级工
+
+ +
+
其他信息 请核对,可改
+
学历技工学校
+
是否在校生是
+
在读学校江西省机电技师学院
+
所学专业数控加工(数控车工)
+
入学时间2024-09-01
+
联系电话19970570823
+
+ +
+
需要上传的材料 你的活
+
+
+
一寸照片 已通过
+
曾伊豪-...-一寸照片.jpg · 96KB
+
+
+
+
身份证 已通过
+
曾伊豪-...-身份证.jpg · 810KB
+
+
+
+
学籍表 未上传
+
教务系统截图,从手机相册里选
+
+
+
+
工作证明 未上传
+
打印店扫描后会发给你,从聊天记录里选
+
+
+
+
+
+
+ STEP 04 · 核心屏 + 填报主页:一屏看完,几乎不用打字 + 三块分区严格对应「字段三类规则」——报考信息锁定、其他信息可核对修改、材料才是学生要干的活。
+ 实测老师名单已填满 21 项中的 18 项,所以学生上来只是核对着点两下 + 传文件。 +
+
+ + +
+
+
9:41
+ +
+
+ +
去哪里找这份材料?
打印店扫描完会发到你的微信,通常就在聊天记录里。
+
+
+
材料要求
+
格式JPG / PNG / PDF
+
大小不超过 1M(系统会自动压缩)
+
内容手签并加盖学校公章的扫描件
+
命名姓名-身份证号-工作证明.jpg
+
+
+ +
命名不用你操心,系统会自动改好。
+
+
+
+
+
+

选择材料来源

+
优先从聊天记录选——文件名里带着你的信息,系统能自动识别
+
+
+
从微信聊天记录选择 推荐
+
照相馆、打印店、老师发来的文件都在这里
+
+
+
+
从手机相册选择
+
系统截图、已有的翻拍件在这里
+
+
+
+
从手机文件选择
+
PDF 扫描件、Word 版申报表在这里
+
+
不提供"直接拍照"——所有材料都是已有的文件
+
+
+
+ STEP 05 + 材料来源选择 + 刻意没有"拍照"入口。实测 4 类材料(教务截图 / 打印店扫描件 / 照相馆电子版 / 翻拍件)没有一类来自现场拍照。
+ 「从聊天记录选」被标为推荐,因为微信会返回原始文件名——那是命名引擎最可靠的信息源。 +
+
+ + +
+
+
9:41
+ +
+
+
64%
+
正在处理你的材料
+
请稍候,不要退出
+
+
+
已上传 16.14 MB
+
识别为 工作证明(A4 扫描件)
+
正在规范化文件名…
+
压缩到 1M 以内
+
校验材料一致性
+
+
+
+
+ STEP 06 + 处理过程可见 + 把"秒级黑箱"拆成可读的步骤。
+ 实测最大的扫描件有 1.8 亿像素、处理需几秒——这几秒如果不解释,用户会以为卡死并退出。 +
+
+ + +
+
+
9:41
+ +
+
+
已规范
+
你上传的文件名
+
曾伊豪-361029200907295110工作证明.jpg.jpg
+
已自动规范化
+
规范后的文件名
+
曾伊豪-361029200907295110-工作证明.jpg
+
+ +
+
系统为你修正了 2 处
+
+ + 多余的扩展名 .jpg.jpg → .jpg
+
+ + 缺少连接符 → 已插入 -
+
+ +
+
体积压缩
+
+ 16.14 MB + → + 806 KB + 省下 95% +
+
已按 A4 纸张等比缩至 300dpi 标准扫描清晰度,公章与手写签名仍然清晰可辨。
+
+ +
+
校验结果
+
身份证号与老师名单一致
+
与你的其他材料一致
+
体积符合要求(≤ 1M)
+
+
+
+
+
+ STEP 07 · 全原型最核心 + 上传即规范化:让用户看见系统替他干了什么 + 这是整个产品的价值可视化。用户交上来一个 .jpg.jpg 的乱名文件,红笔划掉、盖"已规范"章,再报出压缩率。
+ 刻意把"原名"留得很丑、把"新名"留得很干净——对比越强烈,价值感越强。 +
+
+ + +
+
+
9:41
+ +
+
+ +
这张看起来是工作证明,你已经传过这一项了。
要重新选一张学籍表吗?
+
+
+
系统识别
+
工作证明 置信度 93%
+
+
识别依据
+
+ 检测到关键词「申报表」「职业技能等级认定」「兹证明」
+ 版面为 A4 纸张扫描件(约 1.6 亿像素) +
+
+
+ +
系统只做提示,不会自动移动或替换你的文件。
+
+
+
+
+
+
+
+ STEP 08 + 传错位置:只提示,不擅自改 + 识别必然有误判。自动挪位置比传错更危险——它会把用户原本放对的文件挪走。所以规则是:给依据、给建议、把决定权留给用户。
+ 本屏演示的是目标槽位已占用的情形——学生刚传完工作证明,再去传学籍表时误选了同一张图,系统识别为"重复"并只建议重新选择。
+ 若目标槽位为空(如工作证明还没传),系统会给出「移到『工作证明』」——能移才移,不能移就只提示。
+
+
+ + +
+
+
9:41
+ +
+
+
4 项材料 3 项就绪
+
+ 一寸照片 96KB
+
+ 身份证 810KB
+
+ 工作证明 806KB
+
+ 学籍表 分辨率偏低,放大后可能看不清
+
+
+ +
1 项建议处理
学籍表这张图只有 1024×590,建议让老师重发一张更清晰的。也可以选择继续提交。
+
+
+ + +
+
+
+
+
+ STEP 09 + 提交前确认:红黄绿汇总 + 红色阻断项必须处理才能提交;黄色警告项需勾选"我已确认"后放行。
+ 注意这里没有阻断项——因为不合格的内容在上传那一刻就已经被拦下并修正了,不会累积到最后。 +
+
+ + +
+
+
9:41
+ +
+
+

已提交,等待审核

+

班主任审核通过后,会汇总给学校管理员终审。

+
+
+
+
审核进度
+
+
+
材料提交完成
今天 09:41
+
●
+
班主任初审
李老师 · 一般 1 个工作日内处理
+
3
+
学校管理员终审
初审通过后开始
+
4
+
汇总上报
终审通过后由学校统一上报
+
+
+
+ +
审核结果会通过微信通知你,无需反复查看。
+
+
需要修改?联系班主任
+
+
+
+ STEP 10 + 进度透明,减少追问 + 把"提交后一片黑"变成可见的时间轴。
+ 实测场景里班主任最常被追问的就是"我交了没""审到哪了"——这一屏直接消灭这类沟通成本。 +
+
+ +
+ +
+

设计依据 · 每一屏都对着一条真实数据

+
+
+

01为什么学生几乎不用打字

+ 实测老师交上来的名单里,21 个字段已填满 18 个(学历、在校生、学校、专业、入学时间、报考三项、联系电话全部有值)。所以学生端的任务是核对 + 传文件,不是填表。 +
+
+

02为什么报考信息要锁定

+ 整班 28 人的报考三项完全一致(车工 / 数控车工 / 四级中级工)——这是老师按机构要求统一定的。让学生去选只会制造错误。要改,只能找老师。 +
+
+

03为什么没有"拍照"

+ 学籍表是教务系统截图、工作证明是打印店扫描件、一寸照是照相馆电子版、身份证是翻拍件——没有一类来自现场拍照。拍照只会引入模糊、畸变、反光。 +
+
+

04为什么失败提示不写原因

+ 如果提示"身份证号不在名单",这个接口就能被用来逐个探测身份证号是否在名单里。安全要求优先于用户体验。 +
+
+

05为什么压缩率要显式告知

+ 用户最怕的是"你把我文件动了"。把 16.14MB → 810KB 写出来,并说明"300dpi 清晰度无损",才能建立信任。这也是认定机构最关心的问题。 +
+
+

06为什么识别错误不自动纠正

+ 系统识别必然有误判。自动挪文件比传错更危险——它会把放对的文件挪走。所以只给建议与依据,决定权始终在用户手里。 +
+
+
+ + + + diff --git a/docs/prototype/teacher-app.html b/docs/prototype/teacher-app.html new file mode 100644 index 0000000..2d7f983 --- /dev/null +++ b/docs/prototype/teacher-app.html @@ -0,0 +1,1072 @@ + + + + + +填报通 · 班主任端交互原型 + + + + + + + +
+
Prototype · 班主任端 · V1.1
+

填报通 · 班主任端交互原型

+

+ 9 屏:花名册看板 → 待审列表 → 学生详情 → 批量通过 / 右滑通过 / 驳回 → 催报 → 消息 → 代传。
+ 视觉隐喻为「电子花名册」——把全班排成网格,一眼扫完;与学生端共用「档案室 / 电子卷宗」的设计语言。 +

+
+
+
学生端:极简
+ 几乎不用打字。校对着点两下 + 传文件,一屏搞定。目标:一次填对、不返工。 +
+
+
班主任端:高密度
+ 一屏看完全班 40 人、3 次点击处理一个学生、批量操作。手机单手可操作——通常在通勤路上处理。 +
+
+
+ +
+ + +
+
+
9:41
+ +
+
+
+
+
24中技数控1班
+
2026年9月机电认定 · 截止 10-15 剩 3 天
+
+
+
+ 82% +
+ 已提交率 23/28 +
+
+
+ + + + +
+
+ 已通过 15 + 待我审 4 + 待核对 3 + 有问题 1 + 未提交 5 +
+
+ +
+ +
+ +
+
+ 花名册 · 28 人 + 点格子看详情 +
+
+
陈子文待处理
+
杨晨低清
+
曹文锋待核对
+
黎思明待核对
+
李绍康待审
+ +
何天宇待审
+
曾云天待审
+
熊宇豪已通过
+
魏航已通过
+
余饶平已通过
+ +
曾伊豪待审
+
卢宇俊已通过
+
于豪已通过
+
杜明韬已通过
+
朱彬宇已通过
+ +
刘俊宇已通过
+
何森辉已通过
+
江训坚已通过
+
涂子峰已通过
+
郑志滔已通过
+ +
廖俊杰已通过
+
刘羿江已通过
+
唐昊宇已通过
+
谭宇峰未交
+
邱文俊未交
+ +
张仕勋未交
+
宋应兴未交
+
徐程宇未交
+
+
+ 有问题须处理 + 待核对 + 待我审 + 已通过 + 未提交 +
+
+
+ +
+
+ STEP 01 · 核心屏 + 班级看板:电子花名册,一屏扫完 28 人 + 用网格而不是列表——列表必须滚动才能看全,网格可以一屏扫完。每个格子就是一个人,颜色即状态。
+ 「陈子文」那格是唯一红色的,带呼吸动画——它需要我做决定,其余都是看一眼就过的。 +
+
+ + +
+
+
9:41
+ +
+ 全部 8 + 有问题 1 + 待核对 3 + 可批量 4 +
+
+
+
陈
+
+
陈子文 需处理
+
一寸照片的证件号和其他材料对不上,疑似传错人
+
+ + + + +
+
+ +
+ +
+
杨
+
+
杨晨 待核对
+
姓名已纠正(扬晨 → 杨晨);身份证图偏小 558×745
+
+ + + + +
+
+ +
+ +
+
曹
+
+
曹文锋 待核对
+
一寸照片证件号仅 17 位,已按名单补齐,请确认
+
+ + + + +
+
+ +
+ +
+
黎
+
+
黎思明 待核对
+
身份证证件号仅 17 位,已按名单补齐,请确认
+
+ + + + +
+
+ +
+ +
+
曾
+
+
曾伊豪 可批量
+
刚提交 · 4 项材料齐全,无问题
+
+ + + + +
+
+ +
+ +
+
李
+
+
李绍康 可批量
+
4 项材料齐全,无问题
+
+ + + + +
+
+ +
+ +
+
何
+
+
何天宇 可批量
+
4 项材料齐全,无问题
+
+ + + + +
+
+ +
+ +
+
曾
+
+
曾云天 可批量
+
4 项材料齐全,无问题
+
+ + + + +
+
+ +
+
+
+ +
+
+
+ STEP 02 + 待审列表:异常优先 + 材料缩略条 + 默认按异常优先排序——红色排最前,绿色的沉到底部。
+ 每条右侧那条「缩略条」是 4 项材料的状态速览:虚线=没交、琥珀=有疑点、朱红=有问题。不用点进去就知道哪里不对。
+ 绿色项还支持右滑直接通过(见 STEP 09);有疑点的项滑不动,必须点进去逐个判断。 +
+
+ + +
+
+
9:41
+ +
+
+
一寸照片 1/4
+
原图 / 处理后
+
+
+
295 × 413 · 96KB
+
+ 证件照 + + +
+
+
+ +
+
系统发现 1 个问题 需你判断
+
+ +
+ 这张「一寸照片」的证件号,和陈子文其他材料对不上
+ 照片文件名:360122200902044232
+ 其他材料:36010520090817121X
+ 两个号码校验位都通过 → 是两个不同的人,不是打字错误。 +
+
+
+ +
+
其他 3 项材料
+
+
身份证 已规范 · 810KB
+
+
学籍表 已规范 · 41KB
+
+
工作证明 已规范 · 806KB
+
+
+
+
+ + +
+
+
+
+ STEP 03 + 学生详情:给结论,也给我依据 + 不只说"有问题",而是把两个号码并列摆出来,并指出校验位都通过 = 是两个人而不是打字错误。
+ 班主任不需要懂身份证算法,只需要看懂这个结论——判断依据由系统给出,判断权交给人。 +
+
+ + +
+
+
9:41
+ +
+
+
将通过的 4 人
+
+ 只包含全部材料无问题的填报单。有疑点的已自动排除,不会被批量带过。 +
+
+ +
+ +
曾伊豪
4/4 项 · 无问题
+ 可批量 +
+
+ +
李绍康
4/4 项 · 无问题
+ 可批量 +
+
+ +
何天宇
4/4 项 · 无问题
+ 可批量 +
+
+ +
曾云天
4/4 项 · 无问题
+ 可批量 +
+ +
+ +
已自动排除 4 人(陈子文 / 杨晨 / 曹文锋 / 黎思明)——他们有疑点需要你逐个判断,系统不会替你放行。
+
+
+ +
陈子文
1 项有问题
+ 已排除 +
+
+ +
杨晨
2 项待核对
+ 已排除 +
+
+ +
曹文锋
1 项待核对
+ 已排除 +
+
+ +
黎思明
1 项待核对
+ 已排除 +
+
+
+ +
+ 通过后进入管理员终审,你仍可在终审前撤回 +
+
+
+
+ STEP 04 · 安全闸门 + 批量通过:只带无问题的,红黄一律排除 + 展开后把两种人并列展示:上面是可批量的(默认勾选),下面是已排除的(置灰 + 写明原因)。
+ 这是防"手快全选"的关键设计——如果只显示可批量的 4 人,班主任不会意识到自己放过了 4 个有疑点的。让他看见被排除了谁,他才会去想"为什么"。 +
+
+ + +
+
+
9:41
+ +
+
+
驳回哪一项
+
+ 一寸照片 + 身份证 + 学籍表 + 工作证明 +
+
+ +
+
驳回原因 必选
+
+ 材料不是本人 + 照片模糊,请重新提供 + 身份证号与名单不符 + 文件超过 1M + 工作证明未盖公章 + 缺少材料 + 其他(手填) +
+
+ +
+
补充说明 选填
+ +
+ 长按说话,自动转文字 +
+
+ +
+ +
学生收到通知后修改重传,会回到你的待办。这是第 1 次驳回。
+
+
+
+
+ + +
+
+
+
+ STEP 05 + 驳回:原因必选,但不必打字 + 常用原因做成快捷标签——80% 的驳回一句话就够。文字框是补充,不是必填。
+ 支持语音转文字:班主任常在通勤路上,单手打字很痛苦。 +
+
+ + +
+
+
9:41
+ +
+
+ +
微信订阅消息每人只剩 2 次额度。系统会在额度用尽时自动改用短信,但短信按条计费——建议合并催报,不要一天催三次。
+
+ +
+
+
未提交 5 人 剩 3 天截止
+
+
谭宇峰178****3238单独催
+
邱文俊136****1102单独催
+
张仕勋158****1859单独催
+
宋应兴139****0016单独催
+
徐程宇187****2334单独催
+
+ +
+
催报记录
+
+ +
今天 09:12 群发 5 人
微信订阅消息 · 4 人送达 · 1 人额度不足
+
+
+ +
昨天 10:30 群发 8 人
微信订阅消息 · 8 人送达 · 3 人已提交
+
+
+
+
+
+
+ STEP 06 + 催报:先说清代价,再让人决定 + 微信订阅消息是有额度的——一次授权只能发一条,用尽就得走短信(要花钱)。
+ 所以这里不藏这个事实,把剩余额度和每条历史记录的实际送达情况摆出来,让班主任自己权衡。 +
+
+ + +
+
+
9:41
+ +
+
+
+
陈子文 的材料有疑点09:41
+
一寸照片证件号与他其他材料不一致,需要你判断
+ +
+
+
+
你有 3 份待审超过 24 小时08:00
+
李绍康、何天宇、曾云天 的填报单等待初审
+ +
+
+
+
曾伊豪 已提交材料昨天
+
4 项材料齐全,系统未发现问题,可直接通过
+
+
+
+
催报已送达 4 人昨天
+
谭宇峰 的订阅额度不足,已改用短信
+
+
+
+
管理员导出完成昨天
+
24中技数控1班 汇总包已生成,共 22.4MB
+
+
+
+
距离截止还有 3 天前天
+
还有 5 人未提交,建议今天催一次
+
+
+
+
+ STEP 07 + 消息:只推需要我做决定的事 + 优先级严格排序:要判断的 → 快超时的 → 完成通知 → 系统提醒。
+ 每条消息都带下一步动作的暗示("可直接通过"、"建议今天催一次"),而不是只报事实。 +
+
+ + +
+
+
9:41
+ +
+
+ +
你是代替学生上传。系统会记录代传人是你,审核界面会标注出来。
+
+
+
选择学生
+
学生谭宇峰 · 未提交
+
联系电话178****3238
+
+
+
要代传哪一项
+
一寸照片选择
+
身份证选择
+
学籍表选择
+
工作证明选择
+
+
+ +
代传的材料同样会自动改名、压缩、校验——和学生自己传走完全一样的流程。
+
+
+
+
+
+ STEP 08 + 代传:兜底通道,但必须留痕 + 总有学生就是不会操作(老旧手机、不在身边、沟通不畅)。代传是必须有的兜底。
+ 但代传会让"谁交的"变模糊,所以记录代传人并在审核界面标注——责任可追溯,也避免老师被误解。 +
+
+ + +
+
+
9:41
+ +
+
+ +
右滑可通过(松手后需二次确认)。驳回请点进详情——驳回要选原因,不能滑。
+
+ +
+
+ 通过 +
+
+
曾
+
+
曾伊豪
+
4/4 项材料齐全 · 无问题
+
+
+
+ +
+
+ 通过 +
+
+
李
+
+
李绍康
+
4/4 项材料齐全 · 无问题
+
+
+
+ +
+
+ 需逐个判断 +
+
+
陈
+
+
陈子文
+
1 项有问题 · 不可滑动通过
+
+
+
+ +
+
+ 需逐个判断 +
+
+
杨
+
+
杨晨
+
2 项待核对 · 不可滑动通过
+
+
+
+
+
+
+

确认通过 曾伊豪?

+

通过后进入管理员终审。
在终审完成前,你仍可撤回。

+
+ + +
+
+
+
+ STEP 09 · 已确认的设计决策 + 只做「右滑通过」,不做「左滑驳回」 + 通过是正向动作、可撤回,适合低阻力的滑动;驳回是负向动作、要选原因、代价高(学生重传 + 班主任重审)。
+ 两者误触的后果不对称,所以驳回必须点按钮完成。
+ 注意有疑点的卡片滑动层是灰的、写着"需逐个判断"——它根本滑不动,从交互层面堵死"手快滑过"。 +
+
+ +
+ +
+

设计依据 · 每一条都对着 PRD 里的约束

+
+
+

01为什么用网格而不是列表

+ PRD §9.4 要求"一屏能看完整班状态,40 人无需滚动超过 2 屏"。列表逐行 60px,40 人要 2400px;网格每格 52px、5 列,40 人只要 8 行 ≈ 420px——真正的一屏看完。 +
+
+

02为什么批量通过要显示"被排除了谁"

+ PRD 明确"批量通过只带绿色项"。但如果只显示可批量的 4 人,班主任不会意识到还有 4 个疑点被跳过——把排除名单并列展示,是在提醒他"为什么"。 +
+
+

03为什么审批动作要 ≤3 次点击

+ PRD §9.4 的硬性约束。路径是:列表点人 → 看问题 → 通过/驳回,正好 3 次。详情页不做多层展开、不做标签页切换。 +
+
+

04为什么要把"校验位都通过"写出来

+ 陈子文那单,两个号码各自都合法——这说明是两个人而不是打字错误。班主任不需要懂算法,但需要这个结论才能正确判断该驳回还是该通过。 +
+
+

05为什么催报要先说额度

+ 微信订阅消息发一条消耗一次授权。如果不告知额度,班主任会频繁催报,第 3 次就发不出去了,还会以为系统坏了。把约束前置,比事后解释便宜。 +
+
+

06为什么学生端极简、班主任端高密度

+ 学生是一年用一两次的偶发用户,必须零学习成本;班主任是一学期用几十次的高频用户,愿意为效率接受更密的界面。同一个人群用同一套设计是错的。 +
+
+

07为什么只做右滑通过、不做左滑驳回

+ 通过是正向动作且可撤回,适合低阻力滑动;驳回是负向动作、要选原因、代价高(学生重传 +班主任重审)。误触后果不对称:滑错一个通过最多撤回,滑错一个驳回会打断学生。
+ 有疑点的卡片滑动层是灰的、根本滑不动——从交互层面堵死"手快滑过"。 +
+
+
+ + + + diff --git a/docs/交接摘要.md b/docs/交接摘要.md new file mode 100644 index 0000000..b6b042d --- /dev/null +++ b/docs/交接摘要.md @@ -0,0 +1,271 @@ +# 交接摘要 + +> 用途:**压缩后的唯一状态源**。新会话读这一份即可接续,不必回看历史对话。 +> 更新:2026-09-28 + +--- + +## 一、项目一句话 + +职业技能等级认定**报名材料收集 SaaS**。学生在微信小程序填报、班主任在手机初审、管理员在 PC 终审与导出。核心价值:**动态表单 + 规则引擎(命名/压缩/识别)+ 门禁核验 + 一键规范导出**。 + +工作目录 `D:\IDEA_Project\xiaotutongxue`(**空 git 仓库,无 commit,全部改动未提交**),交付物在 `docs/`。 + +--- + +## 一·二、一期范围(2026-09-28 客户确认收窄) + +> **一期只做「学校收集 + 自主认定」一条流程。** 详见 PRD「一期范围声明」。 + +| 维度 | 字段 | 一期 | 二期后置 | +|---|---|---|---| +| 收集模式 | `collect_mode` | `1` 学校收集 | `2` 社会收集 | +| 认定类型 | `cert_type` | `1` 自主认定 | `2` 社会评价 / `3` 专项职业能力 | + +**一期材料只有 4 类,全部有实测样本**:一寸照片 / 身份证 / 学籍表 / 工作证明 +**二期才做的 2 类材料零样本**:`XJW_BA` 学信网备案表 / `OTHER_PROOF` 其它佐证材料 + +**数据结构保留不启用**(二期直接开,不需 DB 迁移)——因为这三项本来就是**配置驱动**的 +(`needClassAudit` 是开关、字段隐藏走 `visibleWhen`、材料差异是材料项配置), +所以**省下的主要是测试成本与一个外部依赖,而不是代码量**。 + +**工期**:392 → **387 人日**(−5);**关键路径不变,仍 15 周**。 + +> ⚠️ **二期启动前必做**:索取社会评价样本(D4)→ 标定 `XJW_BA`/`OTHER_PROOF` → 补社会收集审核流与专项职业能力字段隐藏。 + +--- + +## 一·五、客户回执(2026-09-28 · 需求已冻结) + +> **客户全部 23 项按建议方案确认。** 唯一功能增量是 **B1:名单加入申请通道**。 + +| 组 | 需开发 | 说明 | +|---|:--:|---| +| A 认定机构 6 项 | **0** | 与现有设计完全一致(`min_long_side=1400`、`min_quality=70`、PNG 保留等都是当初按实测设计的) | +| B 校方业务 9 项 | **1** | **B1** 已落地:FR-509 系列 + `collect_join_request` 表 + 5 接口 + 2 屏原型 | +| C 商务法务 5 项 | 0 | C1 小程序资质推进中,H5 保底已列入 M0-13 预研 | +| D 内部技术 7 项 | 0 | **D4 已后置为二期**(一期不涉及零样本材料);**D7 已降级**(文件名识别准确率不影响任何交付项,残留只是上传页文案校准,班主任群里问两句即可)。**均已清除,无外部依赖** | + +**规模变化**:FR 115 → **125** / 接口 117 → **121** / 库表 19 → **20** / VAL 20 → **21** / 错误码 50 → **57** / Golden 断言 29 → **36** / 功能验收 85 → **111** / 原型 29 → **31** 屏 / 任务 94 → **100** / 人日 380 → **392** → **387**(一期收窄 −5)/ 工期 14 → **15 周**。 + +**B1 的设计要点(三条边界,不要在后继开发中放水)** + +| # | 边界 | 理由 | +|:--:|---|---| +| 1 | **只解决"进名单",不绕过门禁** | 申请期间仍不能填报;审批通过后才自动绑定放行 | +| 2 | **证件号与手机号仅存 HMAC + 脱敏值** | 与门禁口径一致 | +| 3 | **驳回必须逐条填原因;批量通过不提供"全选"** | 身份认定环节误操作代价极高 | + +--- + +## 二、已锁定的铁律(不要重开讨论) + +| # | 决策 | 关键理由 | +|---|---|---| +| 1 | **扫描件强制压到 ≤1M,无例外** | 认定机构硬要求 | +| 2 | 默认参数 **300dpi / q85** | 实测 28/28 达标,平均 806KB,清晰度零损失 | +| 3 | 材料来源只有 **聊天记录 / 相册 / 手机文件**,「直接拍照」默认关闭 | 实测 4 类材料无一来自现场拍照 | +| 4 | **必须先导名单**,姓名+身份证匹配不通过只提示、不放行(学校与社会收集皆然) | 门禁是产品根基 | +| 5 | 报考范围**从名单聚合**,不存在独立"计划清单"文件 | 真实名单验证 | +| 6 | 动态表单 = **内置字段(固定列,可统计)+ 扩展字段(extras_json,仅展示导出)** | 兼顾统计能力与灵活性 | +| 7 | 数据库 **PostgreSQL**(纯 PG);软删除字段名为 **`deleted`** | 客户已确认 | +| 8 | 班主任端**只做右滑通过**,驳回必须点按钮 | 误触后果不对称 | +| 9 | 批量通过**只带无问题项**,红黄一律排除 | 防"手快全选" | +| 10 | 有不合格文件时**直接禁用导出按钮**,不允许带问题打包 | 铁律 | +| 11 | 识别策略 **规则为主 + VLM 兜底**(触发率 2%~10%) | 成本可控 | +| 12 | 识别失败**一律放行**,不得阻断提交 | 可用性优先 | +| 13 | 原型**不用 emoji**(`→` `↔` `·` 等排版字符保留) | 设计约束 | + +--- + +## 三、已完成交付物 + +| 文件 | 版本/大小 | 状态 | +|---|---|---| +| `docs/数据信息填报系统PRD.md` | V2.3 / 173KB / 2816 行 | ✅ 含 115 条 FR、17 条 VAL、96 接口、19 表 DDL、实测附录 | +| `docs/数据信息填报系统PRD-功能逻辑卷.md` | V1.9 / 88KB | ✅ 纯功能逻辑,**不含库表/接口/代码** | +| `docs/db/schema-postgresql.sql` | 34KB | ✅ **PG 16.15 实测零错误**(19 表 / 167 列注释 / 61 索引) | +| `docs/prototype/student-app.html` | **11 屏** / 57KB | ✅ 含 STEP 03B「申请加入名单」 | +| `docs/prototype/teacher-app.html` | 9 屏 / 63KB | ✅ | +| `docs/prototype/admin-web.html` | **11 屏** / 114KB | ✅ 含表单设计器、材料规则配置、历史整理器、**申请审批** | +| `docs/prototype/index.html` | 7KB | ✅ 三端入口 | +| `docs/00-交付计划.md` | — | ✅ 7 项计划与进度记录 | +| `docs/01-客户与机构确认函.md` | 18KB | ✅ **P1** 23 项**已全部确认**(客户按建议执行,需求冻结) | +| `docs/02-需求追溯矩阵.md` | 10KB | ✅ **P2** 审计报告,含 10 个接口缺口 | +| `docs/03-接口设计.md` | **102KB / 2626 行** | ✅ **P3 117 个接口**字段级定义 + 枚举全集 + 12 个 VO + 50 个错误码;**自校验通过** | +| `docs/06-验收测试清单.md` | **29KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** | +| `../README.md` | **10KB** | ✅ **P7 文档导航**,按角色读法 + 快速上手 | +| `docs/04-核心模块设计说明.md` | **56KB / 1218 行** | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 | +| `docs/05-开发任务拆解.md` | **28KB / 468 行** | ✅ **P5 94 个任务 / 380 人日 / 14 周**,含关键路径与核算口径 | + +--- + +## 四、待办计划 + +| # | 任务 | 产出 | 状态 | +|---|---|---|---| +| ~~P1~~ | 客户与认定机构确认函 | `01-客户与机构确认函.md` | ✅ | +| ~~P2~~ | 需求追溯矩阵 | `02-需求追溯矩阵.md` | ✅ | +| ~~P2.5~~ | 管理端补 3 屏原型 | 并入 `admin-web.html` | ✅ | +| **P3** | **接口设计(OpenAPI)** | `03-接口设计.md` | ✅ **已完成**(117 个接口) | +| P4 | 核心模块设计说明(伪代码级) | `04-核心模块设计说明.md` | ✅ **已完成**(6 个模块) | +| P5 | 开发任务拆解 WBS(M0–M7) | `05-开发任务拆解.md` | ✅ **已完成**(94 任务 / 380 人日 / 14 周) | +| P6 | 验收测试清单(Golden Test Set) | `06-验收测试清单.md` | ✅ **已完成**(29 条 Golden 断言 + 85 条功能验收) | +| P7 | 文档导航 README | `../README.md` | ✅ **已完成** | + +### P3 已补齐的缺口(21 个接口,原审计发现 10 项) + +``` +✅ I1 报考范围读取/确认 GET/PUT /api/v1/tasks/{id}/plan-scope +✅ I2 历史数据整理器 POST /api/v1/cleanup/jobs(共 5 个) +✅ I3 字典管理 /api/v1/dicts(4 个) +✅ I4 操作日志 GET /api/v1/logs/operations +✅ I5 登录日志 GET /api/v1/logs/logins +✅ I6 消息中心 /mp/messages、/mp/messages/read、/mp/teacher/messages +✅ I7 订阅额度登记 POST /api/v1/mp/subscribe +✅ I8 审核撤回 POST /api/v1/audit/submissions/{id}/revoke +✅ I9 任务暂停/恢复 POST /api/v1/tasks/{id}/pause、/resume +✅ I10 材料识别结果读取 已并入 GET /api/v1/audit/submissions/{id} 响应 +➕ 文件规范化 process 原仅在 PRD §8.9 详述、未登记进清单,已补登 +``` + +**接口总数:96(PRD 原有)+ 21 = 117** + +--- + +## 五、关键事实速查 + +### 真实基准数据集(Golden Test Set) + +`D:\项目\202609机电填报系统\24中技数控1班\` —— **28 人 × 4 类材料 = 112 文件** + +| 实测项 | 结果 | +|---|---| +| 原始总量 | 475 MB | +| 命名违规 | 59 / 112(52.7%) | +| 双扩展名 | 56 | +| 体积超标(>1M) | 31 | +| 证件号异常 | 5 例(3 可自动、2 待人工) | +| 疑似串档 | 1(**唯一必须人工判断的**) | +| 压缩后 | 300dpi/q85 → 平均 806KB、最大 831KB、**0 张超标** | +| 材料识别 | 文件名层 112/112;形态层 99.1%;OCR 内容层 32/32 | + +### 官方模板要点 + +- 21 列(A–U):**红=必填 11 项、绿=级联 6 项、黑=选填 4 项** +- **「报考职业」「报考工种」在 Excel 里没有下拉,是自由填写** ← 我方产品的核心增量 +- 「职业技能等级个人申报表」的规范文件名却是 **「工作证明」**(机构既有习惯) +- **表头行与工作表名都不固定**(实测一份在第 1 行、一份在第 6 行) + +### 三个易错实现陷阱 + +1. **「无」是合法业务值,不是空值** +2. **专项职业能力:无工种 / 无等级 / 无补考科目(0 科)** +3. 补考约束:三/四/五级最多 1 科,一/二级最多 2 科,专项 0 科 + +### 压缩五档(PRD §6.3) + +A4 扫描件 300dpi(2480×3508)/q85 · 一寸照片固定 295×413 · 学籍表为 16:9 截图**保持 PNG 不缩放** + +### 教师端数字口径(原型必须自洽) + +28 人 = 已通过 15 + 待审 4 + 待核对 3 + 有问题 1 + 未提交 5;**完成率 82% 是已提交率(23/28)** + +--- + +## 六、环境与工具 + +| 项 | 内容 | +|---|---| +| PG 验证容器 | `docker exec -it pg-verify psql -U postgres -d filldb`;清理 `docker rm -f pg-verify` | +| 原型校验脚本 | `python C:\Users\93292\AppData\Local\Temp\opencode\check2.py ` | +| DDL 同步方式 | 从主 PRD 的 ` ```sql ` 代码块自动抽取生成,改文档后重跑抽取脚本 | +| 原型约束 | 单文件自包含、零外部依赖、内联 SVG(``+``,1.7px 描边 currentColor)、无 emoji | +| 原型校验基线 | 四份全通过:标签配对 ✓ / CSS 变量 ✓ / SVG 无冗余 ✓ / emoji 0 ✓ | + +--- + +## 七、重要教训 + +### 教训一:脚本审计前必须先验证脚本本身 +P2 审计时脚本因未匹配加粗编号 ``| **FR-614** |``,误报 3 个"需求条目问题"(实为假警报);若直接采信并"修复",会引入 3 处新错误。同时 FR 总数由误算的 102 修正为 **115**。 + +### 教训二:写新文档必须回查已有编号体系,不能凭记忆 +P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权威清单整体错位**: + +| P4 原写法 | 我以为的含义 | PRD 实际定义 | +|---|---|---| +| VAL-01 | 姓名与名册一致 | **证件号格式与校验位** | +| VAL-03 | 证件号格式 | **姓名与名册一致** | +| VAL-06 | 双扩展名 | **文件格式白名单** | +| VAL-07 | 多扩展名冲突 | **文件体积上限** | + +**后果**:若不修正,开发按 P4 实现会把规则完全接错,且两份文档互相矛盾。 +**修正**:已全部重映射到 PRD 体系(单次扫描替换避免 01↔03 碰撞),并给 PRD 补了 P4 需要的三条新规则 **VAL-18 文件名规范化 / VAL-19 疑似串档 / VAL-20 体积下限保护**。 +**规则**:新增文档引用任何已有编号(FR/VAL/错误码/接口)前,**先脚本抽取权威清单核对**,不凭记忆。 + +### 教训三:系统里的数据不会错,错的一定是「归属」 +客户指出:**"系统应该不存在名称错误的情况。是先录入了有这个姓名和身份证,然后才能填写的。"** + +这指出一个真实的设计缺陷: + +| 位置 | 会不会错 | 说明 | +|---|---|---| +| **填报单里的姓名/证件号** | **永远不会错** | 门禁已核验 + 字段锁定。客户说得对 | +| 文件名里的姓名/证件号 | 可能不同 | **但这不是"名称错误",而是"这份文件可能不属于这个人"** | + +**原设计的错误**:把"文件名姓证件号与名册不符"处理为「自动按名册修正」(信息级), +这会把"A 传了 B 的材料"**静默洗白**成 A 的材料——审核时完全看不出来,事后无从追溯。 + +``` +学生 A 在微信里误选了同学 B 的材料 + ↓ 文件名:李四-3610...(B 的号码,校验位通过) +错误做法:判"与名册不符" → 改成 张三-3610... → 信息级,A 看不到 + ↓ 审核:班主任看到一份名字正确的材料 → 通过 + ↓ 最终:B 的工作证明被认定成 A 的 ← 灾难 +``` + +**修正(客户已确认)**: +1. **判定优先级重排**为 `证件号反查名册 > OCR 内容 > 姓名(仅辅助)` +2. 新增 **VAL-21 文件归属校验**:归属存疑一律**阻断提交**,且**不提供"仍然继续"** +3. **姓名不再作为独立规则**——只在证件号佐证时按名册取值,仅留痕不打扰学生 +4. 核心判据:**文件名证件号能否在名册里反查到"另一个人"**(一次查询,走唯一索引,< 1ms) + +**精髓在于两条的区分**: +- **校验位失败** = 不是一个真实号码 → 只是写错了 → **可以自动改** +- **校验位通过** = 一个真实存在的自然人 → 改了就是改了身份 → **必须阻断** + +### 教训五:联系方式不进系统 +客户指出:**"学生本来就有班主任联系方式,不需要系统里面体现。"** + +原设计在门禁失败页放了一个「联系老师」按钮(脱敏手机号 + 一键拨打),并为此专门开了一个接口 `GET /mp/roster/contact`。 +已移除: + +| 移除项 | 原因 | +|---|---| +| `GET /mp/roster/contact` 接口 | 不必要(用户已知道怎么找人)+ 有维护成本(换人/换号要同步)+ 多一处隐私暴露面 | +| 门禁失败页「联系老师」按钮 | 同上。改为只保留两条**系统内动作**:重新填写 / 申请加入名单 | +| 功能逻辑卷中的联系方式展示描述 | 同步 | + +**保留**:纯文案提示(如"如有疑问请联系老师")——**不涉及任何联系方式数据**。 +**接口总数**:122 → **121**。 + +### 教训四:「社会评价」与「社会收集」只差一个字 +`cert_type`(认定类型:自主认定 / **社会评价** / 专项职业能力)决定**填什么材料**; +`collect_mode`(收集模式:学校收集 / **社会收集**)决定**谁审核**。二者可任意组合,已在 PRD §5.3 与接口 §2.3 分别写明。 +另外补全了 `cert_type` 的第三个取值(专项职业能力)在 DDL 注释与附录中的遗漏。 + +### 附带修正 +- **体积口径**:原型导出中心曾写"475MB → 22.4MB(95%)",与 PRD 及历史整理器屏(37MB/92%)矛盾。实为混淆——**444→22MB 是"工作证明"单项,475→37MB 是全班 112 文件合计**。已统一为 37MB / 92%。 + +--- + +## 八、外部依赖(未回) + +`01-客户与机构确认函.md` 已发出待回,其中**最高优先级 3 项**: + +- **A1** 认定机构是否书面接受 300dpi 扫描件 +- **B1** 名单外的人怎么处理(建议加"申请加入名单→管理员审批"通道) +- **B3** 是否允许学生修改老师预填字段(建议:姓名/证件号/报考三项锁定,其余可改留痕) + +未回复则按文档内建议方案推进。 diff --git a/docs/数据信息填报系统PRD-功能逻辑卷.md b/docs/数据信息填报系统PRD-功能逻辑卷.md new file mode 100644 index 0000000..ac0ed16 --- /dev/null +++ b/docs/数据信息填报系统PRD-功能逻辑卷.md @@ -0,0 +1,1378 @@ +# 数据信息填报系统 · 功能逻辑 PRD + +> 产品代号:**填报通 / CollectFlow**(暂定) +> 版本:V1.9(2026-09-28 同步数据库决策与字段落库方式) +> 日期:2026-09-24 +> 文档定位:**只描述"系统怎么运转",不含数据库设计、接口定义、代码结构** +> 配套文档:《数据信息填报系统PRD》(主 PRD,含库表与接口)—— 主 PRD 回答"做什么",本文回答"怎么运转" +> 读者:产品经理、研发、测试、UI 设计 + +## 变更记录 + +| 版本 | 日期 | 变更内容 | +|---|---|---| +| V1.0 | 2026-09-24 | 初稿:角色/流转/12 个功能逻辑/规则速查/20 个边界场景/文案规范 | +| **V1.1** | 2026-09-24 | **压缩逻辑按实测重写**:用 28 张真实 A4 扫描件实测,**300dpi/q85 下 28/28 达标(平均 806KB)**,无需牺牲清晰度。① §3.7.1 新增实测结论;② §3.7.3 由"逐级降质逼近"改为"一次编码到位 + 兜底路径";③ §3.7.4 抽检机制标注为"实测不触发";④ §4 速查表、§7 未决事项同步更新 | +| **V1.2** | 2026-09-24 | **修正材料来源通道**:取消"直接拍照"(实测 4 类材料无一来自现场拍照,均为截图/扫描件/照相馆电子版)。① §3.5 改为"**微信聊天记录 / 手机相册 / 聊天文件**"三通道,拍照默认关闭;② 新增"**必须保留原始文件名**"设计逻辑(聊天记录可拿到原始名,相册拿不到);③ 新增来源引导文案与 4 个相关异常分支;④ §4 速查表、§5 场景表(+4 条)、§6 文案、§7 未决事项同步更新 | +| **V1.3** | 2026-09-24 | **新增"名单门禁"(D6)**:① §2.1/§2.2 流程图改为"扫码 → 姓名+身份证核验 → 填报",**社会收集也必须先导名单**(废除"自由填报");② §2.4 由三条铁律扩为**四条**,新增"没有名单就没有填报";③ §3.1 整节重写为**门禁逻辑**(判定优先级、命中后绑定、统一失败提示、**限流防枚举**、名单不下发);④ §3.3 名单逻辑补充"姓名/身份证列为必填、缺列阻断发布";⑤ §4 速查、§5 场景(14/15 改写,+4 条)、§6 文案(+5 组门禁文案)、§7 未决事项(+4 项)同步更新 | +| **V1.4** | 2026-09-24 | **新增材料识别设计(方案 B:规则为主 + VLM 兜底)**:① §3.5 升级为「材料上传与材料识别」,新增 **3.5-B 材料识别**整节(四路信号、文件名/形态/关键词/印章四类规则、置信度分级、**VLM 三种触发条件**、冲突裁决表、成本预估、三条不可违背原则、六个集成点);② §4 速查新增"材料识别"块;③ §5 场景 +4 条(29–32);④ §6 文案 +3 组;⑤ §7 未决事项 +3 项 | +| **V1.5** | 2026-09-24 | **材料识别规则实测校正(用 112 个真实文件 + 32 个 OCR 抽样验证)**:
① **证伪初版形态规则**——初版写"身份证宽高比 ≈1.585",实测为 **0.732–1.016**,与一寸照片 0.714 **高度重叠,形态层根本无法区分**;② 架构改为**四层递进**:文件名 → 形态(**只分流派,不定型**)→ OCR 强词计分 → VLM;③ **证伪"命中即判定"**——实测关键词大量交叉命中(工作证明含"学籍/学校"、学籍表含"居民身份证"),改为**只用强词 + 加权计分取最高**;④ 新增**一寸照片"零文字"判据**(实测 8/8 无文字);⑤ **VLM 触发率由 15% 重估为 2%~10%**(初版偏高的原因是形态规则写错产生假冲突);⑥ 新增 §3.5-B 开头的**实测验证结果表 + OCR 耗时表 + 局限性说明**;⑦ §4 速查、§7 未决事项同步更新 | +| **V1.6** | 2026-09-28 | **按官方《考生线下报名信息导入模板》校准表单填写逻辑**:① §3.4 重写——新增**「评价计划」三级级联**(职业→工种→等级受计划约束,从源头防错,源自官方规则 04/05/06);② 新增**补考科目数量约束**(三/四/五级最多 1 科、一/二级最多 2 科、**专项职业能力无补考**);③ 新增**专项职业能力整体降级**(无工种/无等级/无补考);④ 明确 **"无"是合法业务值而非空值**(工作单位、报考工种);⑤ 补格式校验(入学时间 YYYY-MM-DD、工作年限阿拉伯数字、下拉严格受限);⑥ §4 速查新增"表单填写"块;⑦ 异常分支 +3 条 | +| **V1.7** | 2026-09-28 | **新增「评价计划导入」完整逻辑(确认下发文件模式 = A)**:① §3.2 拆为 **3.2-A 评价计划导入** + 3.2-B 任务创建向导;② 定义**导入七步流程**(上传 → 自动解析 → **人工确认列映射** → 导入体检 → **差异对比** → 确认写入 → 留痕);③ 明确列映射**必须人工确认但可保存复用**、原始文件**必须留档**、差异对比中**移除项须关联已有考生告警**;④ 7 条异常分支(扫描版PDF、合并单元格、增量 vs 全量、机构临时增补…);⑤ §4 速查新增"评价计划"块;⑥ §7 未决事项 +3 项(**需索取真实计划清单样本**) | +| **V1.8** | 2026-09-28 | **实测校正:不存在"计划清单"文件,报考范围来自名单**:
① §3.2-A 由"评价计划导入(七步流程)"整体重写为「**报考范围聚合**」——名单导入后自动聚合"职业×工种×等级"集合 + 一致性体检 + 管理员确认;
② 新增**追加名单告警机制**(超出已确认范围的报考项需管理员确认——范围失控的主要入口);
③ §3.4 新增 **⚠️ 最关键的第 1 条「字段三类规则」**:🔒锁定(姓名/身份证号/报考三项,空则阻断发布)/✏️预填可改(其余,标注"老师已填请核对")/📄材料(学生唯一重活)——依据是实测名单 21 项已填 18 项,**学生端几乎不用打字**;
④ §3.4 步骤重新编号为 13 步,异常分支措辞校正("计划已过期"→"报考范围已关闭");
⑤ §4 速查新增"报考范围""字段三类规则"两块,删除"评价计划"块;
⑥ §7 关闭 21/22/23,新增 24(预填字段能否由学生修改) | +| **V1.9** | 2026-09-28 | **同步数据库决策(PG / Vastbase)与字段落库方式**:① §3.4「字段三类规则」补**存储说明**——18 个业务字段在名单表与填报单表中为**正式列**(非 JSON),"按工种统计""按学历筛选"可直接走索引,仅机构额外追加字段进 `extras_json`;② 本文档其余部分不受影响(功能逻辑卷不承载库表设计,DDL 见主 PRD §7.3) | + +--- + +## 目录 + +- [0. 文档说明与关键决策](#0-文档说明与关键决策) +- [1. 角色与端](#1-角色与端) +- [2. 全局业务流转](#2-全局业务流转) +- [3. 功能逻辑详述](#3-功能逻辑详述) + - [3.1 身份识别与任务匹配](#31-身份识别与任务匹配) + - [3.2 报考范围聚合与任务创建](#32-报考范围聚合与任务创建) + - [3.2-A 报考范围聚合](#32-a-报考范围聚合按实际情况校正) + - [3.2-B 任务创建向导](#32-b-任务创建向导) + - [3.3 名册逻辑](#33-名册逻辑) + - [3.4 表单填写逻辑](#34-表单填写逻辑) + - [3.5 材料上传与材料识别逻辑](#35-材料上传与材料识别逻辑) + - [3.5-A 上传流程](#35-a-上传流程) + - [3.5-B 材料识别(这是什么材料)](#35-b-材料识别这是什么材料) + - [3.6 命名规范化逻辑](#36-命名规范化逻辑) + - [3.7 压缩逻辑(扫描件强制 1M)](#37-压缩逻辑扫描件强制-1m) + - [3.8 校验逻辑](#38-校验逻辑) + - [3.9 审核逻辑](#39-审核逻辑) + - [3.10 汇总导出逻辑](#310-汇总导出逻辑) + - [3.11 通知与催报逻辑](#311-通知与催报逻辑) + - [3.12 历史数据整理器逻辑](#312-历史数据整理器逻辑) +- [4. 一页纸规则速查](#4-一页纸规则速查) +- [5. 异常与边界场景](#5-异常与边界场景) +- [6. 用户可见文案规范](#6-用户可见文案规范) +- [7. 未决事项](#7-未决事项) + +--- + +## 0. 文档说明与关键决策 + +### 0.1 本文包含 / 不包含 + +| 包含 | 不包含 | +|---|---| +| 角色能做什么、不能做什么 | 数据库表结构 | +| 每个功能的触发条件与处理步骤 | 接口定义与字段 | +| 判断规则、优先级、兜底策略 | 代码结构、类设计、框架选型 | +| 状态流转与谁能改变状态 | 部署、性能参数 | +| 用户看到什么、看到哪句话 | 测试用例(另见主 PRD §12) | + +### 0.2 已决策事项(本文据此撰写) + +| 编号 | 决策 | 影响章节 | +|---|---|---| +| **D1** | **扫描件(工作证明/申报表等 A4 材料)强制压缩到 1M,不做体积放宽、不设例外。** 当"≤1M"与"清晰度"冲突时,**体积优先,牺牲清晰度**;被牺牲的文件必须标记出来供人工抽检 | §3.7 | +| D2 | 学籍表等**屏幕截图类材料保持原始像素与 PNG 格式**,不缩放、不转 JPG | §3.7 | +| D3 | 校验位(身份证)用于**裁决**证件号争议,而非仅做格式校验 | §3.6、§3.8 | +| D4 | 一寸照片**统一规格化为 295×413 px(25×35mm @300dpi)** | §3.7 | +| **D5** | **材料来源只提供"选择已有文件":微信聊天记录 / 手机相册 / 手机文件;不提供"直接拍照"**(默认关闭,材料级可配)。理由:实测 4 类材料无一来自现场拍照 | §3.5 | +| **D6** | **填报必须先过名单门禁**:学校收集与社会收集**都必须先导入名单**,填报人进入前必须填写「姓名 + 身份证号」与名单匹配,**匹配不到只提示、不放行**(取消原"自助认领"与"自由填报") | §3.1、§3.3 | + +--- + +## 1. 角色与端 + +| 角色 | 主用端 | 可以做什么 | 不可以做什么 | +|---|---|---|---| +| 平台运营 | PC | 开通/停用租户、查看全局用量、内容风控 | 查看任何考生的材料内容 | +| 学校管理员 | PC(主)+ 手机(次) | 建任务、配规则、导名册、终审、导出上报 | 修改已归档任务的配置 | +| 班主任 | PC + 手机 | 催报、初审、批量通过、代传材料 | 终审、导出、查看其他班级 | +| 填报人(学生) | 微信小程序 | 填报、上传、修改、撤回(未审核前) | 查看他人材料、修改已终审内容 | +| 社会填报人 | 小程序 / H5 | 自主填报 | 查看他人、指定班主任 | + +**端能力差异(功能逻辑层面)** + +| 能力 | 小程序 | PC | +|---|---|---| +| 填报 | ✅ 主入口 | ❌ | +| 材料来源选择 | ✅ 从微信聊天记录选(可拿原始文件名)/ 从相册选 / 从手机文件选;**不提供拍照** | ✅ 仅本地上传(拖拽或文件选择) | +| 初审 / 批量通过 | ✅ | ✅ | +| 表单设计与规则配置 | ❌(屏幕太小且易误操作) | ✅ 唯一入口 | +| 名册导入 | ❌ | ✅ | +| 汇总导出 | ❌ | ✅ | +| 查看大图 / 对比原图与压缩图 | ✅ 支持 | ✅ 支持 | + +--- + +## 2. 全局业务流转 + +### 2.1 学校收集(主流程) + +``` +【前置】学校管理员建任务 + ↓ 导入名单(Excel)+ 选定班级 + 配置表单与材料规则 + ↓ 发布任务 → 生成填报二维码 +填报人扫码 → 微信授权 + ↓ 【门禁】填写「姓名 + 身份证号」→ 与名单匹配 + ↓ 匹配成功 → 绑定名单项 → 回填只读信息 → 进入填报 + ↓ 匹配失败 → 统一提示页(重新填写 / 申请加入名单),不给填报 +填报人逐项填写表单 → 逐项上传材料 + ↓ 每次上传后立即:命名规范化 → 压缩 → 校验 → (必要时)OCR + ↓ 填报人看到"文件名已规范 / 体积已压缩 / 有无问题"的即时反馈 +填报人提交 + ↓ 系统做提交前总校验(有无缺失、有无红色阻断项) + ↓ 通过则进入【待初审】 +班主任初审(手机或 PC) + ↓ 一键批量通过全部绿色项 + ↓ 红黄项逐个处理:通过 或 驳回(选原因) + ↓ 驳回 → 填报人收到通知 → 修改 → 重新提交(回到【待初审】) +管理员终审 + ↓ 全部通过后进入【已通过】 +管理员导出 + ↓ 系统按目录规范打包(含分卷、汇总 Excel) + ↓ 下载上报至认定机构 +``` + +> 🔒 **门禁不变式**:**没有名单,就不能填报。** 名单是唯一的准入依据,不存在"自由填报"。 + +### 2.2 社会收集(差异流程) + +``` +【前置】管理员建任务(模式=社会收集,无班主任层,但同样必须先导入名单) + ↓ 导入名单(预报名名单 / 意向名单 / 报名点收集的名单) + ↓ 生成公开填报二维码/链接 +社会人员扫码 → 微信授权 + ↓ 【门禁】填写「姓名 + 身份证号」→ 与名单匹配(规则与学校收集完全一致) + ↓ 匹配失败 → 统一提示页,不给填报 +填报 → 上传材料(同样走规范化 / 压缩 / 校验) +提交 → 直接进入【待终审】(跳过初审) +管理员终审 → 通过 → 导出 +``` + +> ⚠️ **与学校收集唯一的差异**:审核流跳过班主任层。**名单导入与身份核验两项完全一致。** +> 📌 初稿曾写"社会收集名册可选、允许自由填报",本版已废除。 + +### 2.3 状态机 + +**填报单状态** + +| 状态 | 含义 | 谁能改变 | 可回到哪些状态 | +|---|---|---|---| +| 草稿 | 已开始填但未提交 | 填报人 | → 待初审/待终审(提交) | +| 待初审 | 已提交,等班主任 | 班主任、系统(跳过初审时) | → 待终审(通过)/→ 已驳回(驳回) | +| 待终审 | 初审通过,等管理员 | 管理员 | → 已通过(通过)/→ 已驳回(驳回) | +| 已驳回 | 被打回,等填报人修改 | 填报人 | → 待初审/待终审(重新提交) | +| 已通过 | 终审通过 | 管理员(解锁) | → 已归档 | +| 已归档 | 任务结束后锁定 | — | 不可逆 | + +> 🔒 **业务不变量**:终审通过后默认不可修改。如需更正,必须管理员主动"解锁",且解锁动作计入审计。 + +**任务状态** + +| 状态 | 含义 | 转入条件 | 转出条件 | +|---|---|---|---| +| 草稿 | 配置中,填报人不可见 | 新建 | 管理员点"发布" | +| 收集中 | 可填报 | 发布 | 到期自动截止 / 手动截止 | +| 暂停 | 临时停止接收 | 管理员操作 | 管理员恢复 | +| 已截止 | 不再接收新提交(已提交的仍可审核) | 到期或手动 | 归档 | +| 已归档 | 只读 | 管理员操作 | 不可逆 | + +**材料项状态** + +| 状态 | 含义 | +|---|---| +| 未上传 | 该材料尚未提交 | +| 已上传待处理 | 文件已到,正在规范化/压缩/校验 | +| 已受理 | 处理完成,无阻断问题 | +| 有警告 | 处理完成,存在黄项(可提交但需注意) | +| 有阻断 | 处理完成,存在红项(不可提交) | +| 已驳回 | 审核认为本项不合格,需重传 | +| 已通过 | 审核确认合格 | + +### 2.4 四条铁律 + +1. **没有名单,就没有填报**——两种收集模式都必须先导入名单,填报人必须通过「姓名 + 身份证号」核验才能进入填报页,核验不通过只提示、不放行。 +2. **一个人在一个任务下只有一份填报单**——不允许同一人重复建单,只允许在原有单上修改。 +3. **没有规范文件名、没有压到 1M 内的文件,不得进入汇总包**——导出时若发现不合格文件,导出直接报错并列出清单,不允许"带着问题打包"。 +4. **终审通过是"进入上报范围"的唯一门槛**——初审通过不算,材料上传完成也不算。 + +--- + +## 3. 功能逻辑详述 + +> 每个功能按「**触发 → 处理 → 输出 → 异常**」四段描述。 + +### 3.1 身份识别与名单匹配(进入填报的门禁) + +> ⚠️ **本版强化**:学生填报与外部(社会)填报**都必须先导入名单**,填报人**必须先通过「姓名 + 身份证号」匹配名单**,匹配成功才进入填报页。**匹配不到就不给填,只做提示。** + +**触发**:填报人扫码或点击链接进入任务。 + +**处理步骤** + +``` +扫码进入 + ↓ ① 微信授权(静默取 openid) + ↓ ② 解析任务:二维码带的任务标识 → 无效则提示"链接无效" + ↓ ③ 检查任务状态:未开始 / 已暂停 / 已截止 / 已归档 → 分别提示,不进入 + ↓ ④ 【门禁页】填写两项: + · 姓名 + · 身份证号(数字键盘、自动分段、末位 X 自动大写、支持粘贴) + 本地先做格式 + 校验位校验 → 不合格当场提示,不发请求 + ↓ ⑤ 提交匹配(在服务端完成) + ↓ ⑥ 命中 → 绑定 → 进入填报页 + 未命中 → 失败提示页(重新填写 / 申请加入名单) +``` + +**匹配判定顺序** + +| 优先级 | 判定条件 | 结果 | +|---|---|---| +| 1 | 身份证号 **+** 姓名 **完全一致** | ✅ 直接命中 | +| 2 | 身份证号命中,但姓名不一致 | 尝试**形近字 / 同音字纠错**(复用 §3.6 能力)→ 命中则请用户确认"你是不是 XXX?" | +| 3 | 身份证号命中,姓名纠错后仍不一致 | 🔴 失败(**不提示姓名错误,统一提示**) | +| 4 | 身份证号未命中 | 🔴 失败 | +| 5 | 命中多条(同一人在两个班) | 列出可选班级,由本人选择 | + +**命中后的处理** + +1. 该名单项**锁定绑定**到当前用户(openid),防止他人冒用; +2. 若该名单项**已被其他人绑定** → 提示"该信息已被登记,如有疑问请联系老师"; +3. 姓名、身份证号**回填并置为只读**(避免用户改动后又对不上名单); +4. 班级、专业、学号等名单中已有的信息一并回填; +5. 进入填报页。 + +**匹配失败提示(统一文案)** + +> "**没有找到你的信息。** +> 请核对姓名和身份证号是否与老师提供的名单一致。 +> 如果确实已报名,可以申请加入名单,由老师审核。" + +提供两个动作:**重新填写** / **申请加入名单**(FR-509)——**均为系统内动作,不展示任何联系方式** + +**🔒 安全要求(重要,必须实现)** + +| 要求 | 说明 | +|---|---| +| **限流** | 同一 openid 每小时最多 10 次匹配;同一 IP 每分钟最多 20 次;超出则锁定并提示"操作过于频繁,请稍后再试" | +| **失败原因不区分** | 无论"身份证号不在名单"还是"姓名不匹配",**提示文案完全一致**。否则可被用来探测某个身份证号是否在名单中 | +| **名单不下发** | 名单只在服务端参与匹配,**绝不下发到前端** | +| **不记录明文匹配日志** | 失败尝试只记 openid、时间、结果,不记明文姓名与身份证号 | +| **传输加密** | 姓名与身份证号走加密传输 | + +**异常分支** + +| 情况 | 处理 | +|---|---| +| 姓名对、身份证号错(或反之) | 统一失败提示,不透露是哪一项错 | +| 身份证号对、姓名有错别字(如"扬晨"↔"杨晨") | 形近字纠错命中 → "你是不是 **杨晨**?" → 用户确认后通过 | +| 一人在两个班的名单中 | 两条都列出,让本人选择班级;结果通知管理员核对 | +| 该名单项已被他人绑定 | 提示"该信息已被登记,如有疑问请联系老师" | +| 任务已截止但确需补交 | 提示已截止;管理员可延长截止或点名放行 | +| 同一微信帮多人填(哥哥帮弟弟) | 允许,但**切换填报人需重新走核验**,且记录操作留痕 | +| 名单外的人确实要报名 | 本版按需求实现为"**不给填、只提示**",需老师去后台把人加进名单(见下方待决策事项) | + +> 🤔 **待决策:名单外的人怎么处理?** +> **修订(FR-509)**:名单外的人原先只能"联系老师",老师需反复去后台加人——这在几百人的社会收集中会成为运营负担。现已改为**学生自助申请 + 管理员逐条审批**,且**系统内不展示联系方式**。 +> **建议方案**(未纳入本版):在失败页增加"**申请加入名单**"按钮 → 生成管理员待办 → **管理员批准后自动放行**。 +> 这样既守住"必须先有名单"的门禁,又不用老师手工加人。**请确认是否采纳。** + +### 3.2 报考范围聚合与任务创建 + +#### 3.2-A 报考范围聚合(⚠️ 按实际情况校正) + +> **纠正**:上一版假设"认定机构会下发一份计划清单文件",**该假设不成立**。 +> 经真实名单实测确认:**「报考职业 / 报考工种 / 报考等级」这三列,在导入名单时就已经由老师填好了**(28/28,均为「车工 / 数控车工 / 四级中级工」)。 +> ⇒ 报考范围**从名单聚合而来**,**不需要任何外部文件**。 + +**触发**:名单导入完成后自动执行。 + +**处理步骤** + +``` +名单导入 + ↓ +① 【聚合】扫描全部名单行的「报考职业 / 报考工种 / 报考等级」 + ↓ 生成"职业 × 工种 × 等级"的取值集合 + ↓ +② 【一致性体检】输出报告: + · 是否有人的三项为空(红色必填,不该为空) + · 同一职业下是否出现互相矛盾的工种 + · 等级是否与该工种的常规设置相符 + · 是否与参考字典拼写不符(如"数控车床工" vs "数控车工") + · 本次共涉及几组"职业/工种/等级" + ↓ +③ 【管理员确认 / 修正】 + ↓ 写入 collect_task.plan_scope_json + ↓ +④ 学生端只读展示 + 导出时原样写回 Excel 的 G/H/I 列 +``` + +**追加名单时的约束(关键防线)** + +| 情况 | 处理 | +|---|---| +| 追加的人报考项**在已确认范围内** | ✅ 直接接受 | +| 追加的人报考项**超出范围** | ⚠️ **告警**:"新增了范围外的报考项:车工 / 普通车工 / 三级",**需管理员确认后才接受**,并同步更新范围 | + +> 💡 中途补录最容易绕过审核,是报考范围失控的主要入口,必须设这道闸。 + +**异常分支** + +| 情况 | 处理 | +|---|---| +| 名单里有人三项为空 | 🔴 **阻断发布**:"名单中有 N 人未填写报考职业/工种/等级" | +| 整批出现多组不同的报考项 | 正常(一个任务可覆盖多个工种),但需管理员确认无误 | +| 老师发现填错想改 | 管理员可直接修改名单行(留痕),范围自动重新聚合 | +| 名单里出现参考字典没有的职业/工种 | 仅提示"可能拼写有误",**不阻断**(最终以机构要求为准) | +| 认定类型 = 专项职业能力 | 三项中只有"职业"有值,工种/等级允许为空 | + +**关键设计** + +| # | 设计点 | 理由 | +|---|---|---| +| 1 | **范围是"聚合结果",不是"约束来源"** | 老师已经确定好了,系统只做体检与展示,**不越权更正** | +| 2 | **一致性体检比范围校验更有价值** | 单看一行都合法,但"38 人报数控车工、2 人报车工"很可能是填错——**这类问题只有聚合后才能发现** | +| 3 | **追加名单必须告警** | 见上表 | +| 4 | **参考字典只提示、不阻断** | 机构可能有非常规的职业名称,**系统不该比机构更权威** | + +**输出**:`collect_task.plan_scope_json`(本期报考范围)+ 一致性体检报告。 + +#### 3.2-B 任务创建向导 + +**触发**:管理员在工作台点"新建收集任务"。 + +**七步逻辑** + +| 步骤 | 输入 | 校验 | 输出 | +|---|---|---|---| +| ① 基本信息 | 任务名、收集模式(学校/社会)、批次号、专业、工种、等级、起止时间 | 必填;结束时间必须晚于开始时间;批次号在租户内唯一 | 任务骨架 | +| ② 覆盖班级 | 勾选本次收集涉及哪些班级 | 学校模式至少选 1 个班;社会模式可选报名点 | 应填人范围 | +| ③ 名单 | 上传 Excel 或手动添加(**两种模式均必填**) | 必须含「姓名」「身份证号」列;人数 > 0 | 准入名单 | +| ④ 表单 | 选模板或自定义字段 | 至少 1 个字段;必填字段不得全部为空 | 填报字段集 | +| ⑤ 材料 | 材料清单、命名模板、体积上限、压缩档位 | 至少 1 项材料;命名模板必须含"姓名""证件号""材料名" | 材料规则集 | +| ⑥ 审核流 | 是否启用班主任层、驳回次数上限、超时小时数 | 学校模式默认启用;社会模式默认关闭 | 审核规则 | +| ⑦ 预览发布 | — | 试跑至少 1 条样本文件名,确认命名结果正确 | 二维码 / 链接 | + +**关键逻辑点** +- 第 ⑦ 步必须有"**命名规则试跑**":管理员粘贴几条学生可能的原始文件名,系统立即演示会被改成什么,确认无误才能发布。这是防止"规则配错导致全员改名错误"的唯一闸门。 +- 每一步都可保存为草稿,中途退出不丢失。 +- 已发布的任务修改配置时:**表单与材料规则的新改动只对新提交生效**,已提交的数据不受影响(避免数据错乱)。 +- 任务支持"克隆":复制配置与名册,**不复制任何已上传的文件**。 + +### 3.3 名单逻辑 + +> 🔒 **铁律**:**两种收集模式(学校 / 社会)都必须先导入名单**,名单是唯一的准入依据。 +> 初稿曾允许"社会收集可不导名单、自由填报",本版已废除。 + +**触发**:管理员在上传名单步骤选择 Excel 文件。 + +**处理步骤** +1. **定位表头**:不假设第 1 行是表头。系统从前 10 行中自动寻找包含"姓名""身份证"等关键词的行作为表头(真实模板第 1–5 行是说明文字,**第 6 行才是表头**)。 +2. **字段映射**:自动匹配标准字段(姓名、身份证号、手机号、学号、班级、专业等),无法自动识别的列由管理员手动指定或忽略。**「姓名」与「身份证号」为必填列**(缺任一列则无法用于门禁核验,必须补齐后才能发布任务)。 +3. **逐行体检**(导入前),生成《导入体检报告》: + - 身份证号格式与校验位是否通过 + - 姓名是否为空、是否有重复 + - 同一证件号是否重复出现 + - 手机号格式 +4. **按体检结果决定**:有致命问题(如证件号重复、校验位失败)→ 提示管理员确认后仍可导入,但问题行被标记。 +5. **建立名单**:证件号通过哈希去重存储,界面展示时脱敏(如 `3601**********1234`)。 +6. **发布前校验**:任务发布前必须确认名单人数 > 0 且姓名/身份证号列完整,否则不允许发布。 + +**输出**:名单列表 + 体检报告(可下载)。 + +**异常分支** + +| 情况 | 处理 | +|---|---| +| 找不到表头行 | 提示"未识别到表头,请检查 Excel",并允许手动指定行号 | +| **缺少「姓名」或「身份证号」列** | 🔴 阻断发布:"名单必须包含姓名和身份证号才能开启填报核验" | +| 模板被学生自行修改过(多列、少列、换表头) | 以字段映射向导兜底,不强行报错 | +| 名单里有人实际不报名 | 管理员可"移出应填名单",状态记为已移除,不计入完成率 | +| 名单导入后发现漏人 | 支持追加导入,按证件号去重合并;**追加后新人即可立即填报** | +| 名单项始终无人认领 | 不影响任务完成率统计口径(完成率 = 已提交 / 名单有效人数) | + +**名册变更留痕**:任何姓名/证件号的修改都必须记录"谁在何时把 X 改成 Y",并在审核界面可见。 + +### 3.4 表单填写逻辑 + +**触发**:填报人通过名单门禁后进入填报页。 + +**表单来源**:任务所属**评价计划** + 任务表单配置。默认套用官方《考生线下报名信息导入模板》的 21 字段(详见主 PRD §5.4.1)。 + +**处理步骤** + +1. **字段三类规则**(⚠️ 最关键:决定了学生端要填多少字) + + 实测老师交上来的名单里,**21 个字段已填满 18 个**。因此学生端的字段按三类呈现: + + | 类别 | 字段 | 学生端行为 | + |---|---|---| + | 🔒 **锁定** | 姓名、身份证号、**报考职业、报考工种、报考等级** | **只读不可改**。名单为空 → **阻断任务发布**(这三项必须由老师确定)。学生要改只能找老师 | + | ✏️ **预填可改** | 学历、是否在校生、在读学校、所学专业、入学时间、工作单位、联系电话、是否补考、补考科目、本职业工作年限、已获证书相关 | 名单有值 → 预填并标注"**老师已填,请核对**",学生可直接修改(留痕);名单为空 → 学生必填 | + | 📄 **材料** | 一寸照片、身份证、学籍表、工作证明等 | 学生上传(**学生唯一的重活**) | + + > 💡 实测数据说明为什么"锁定"是必要的:整班 28 人的报考三项**完全一致**(车工/数控车工/四级中级工)——这是老师根据机构要求统一定的。**让学生去选只会制造错误。** + > 💡 实测数据也说明为什么其余字段该允许改:联系电话有 28 个不同值、入学时间可能记错——**允许学生自己改正比回退给老师更高效**。 + > + > 📦 **存储说明**:这 18 个业务字段(除姓名/身份证号外)在名单表和填报单表里都是**正式列**(不是 JSON),因此"按工种统计报名人数""按学历筛选"这类查询可直接走索引。只有机构额外追加的字段才进 `extras_json`。详见主 PRD §7.3。 + +2. **报考范围的一致性体检**(在学生填报之前完成) + 系统从名单聚合出本期范围时,会做一次体检: + - 同一职业下出现互相矛盾的工种 → 提示老师确认 + - 等级与该工种常规设置不符 → 提示 + - 与参考字典拼写不符("数控车床工" vs "数控车工")→ 提示 + - 有人三项为空 → 🔴 阻断发布 + > 单看一行都合法,但"38 人报数控车工、2 人报车工"很可能是填错——**这类问题只有聚合后才能发现**。 + +3. **选择范围先收窄**(⚠️ 源自官方规则 04/05/06) + 「报考职业 / 报考工种 / 报考等级」三项**不是自由选择**,而是限定在**本期报考范围**内: + ``` + 本期范围 → 只列出范围内的职业 + ↓ 选中职业 + 只列出该职业范围内的工种(该职业无工种 → 只能填"无") + ↓ 选中工种 + 只列出该工种范围内的等级 + ``` + ⇒ **从源头杜绝填错**,而不是提交后报错。 + > 注:实测官方 Excel 模板里「报考职业」「报考工种」**根本没有下拉**(21 列中 13 列自由填写)——**这正是本系统相对模板的核心增量**。 + +4. **条件显示(级联)**:官方模板用**绿色标题**标识级联列,共两组: + + | 触发字段 | 值 | 级联行为 | + |---|---|---| + | **是否补考** | 是 | 按等级放开补考科目(见第 5 条) | + | **是否补考** | 否 | 补考理论 / 实操 / 综合 **全部隐藏且不校验** | + | **是否在校生** | 是 | 在读学校 / 所学专业 / 入学时间 **三项显示且必填** | + | **是否在校生** | 否 | 上述三项**隐藏且不校验** | + + > 隐藏的字段不参与必填校验,也不出现在导出结果中。 + +5. **补考科目数量约束**(官方规则 07,**最容易做错**) + + | 认定类型 / 等级 | 最多可选科目数 | 可选项 | + |:--:|:--:|---| + | **专项职业能力** | **0** | 无补考 | + | 三 / 四 / 五级 | **1** | 补考理论、补考实操 中选一个 | + | 一 / 二级 | **2** | 补考理论、补考实操、补考综合 中最多选两个 | + + ⇒ 达到上限后,其余科目选项**置灰不可选**(而不是事后报错)。 + +6. **专项职业能力的整体降级**:`认定类型 = 专项职业能力` 时,**工种、等级、补考三项全部隐藏**,官方规则 05/06/07 整体跳过。 + +7. **下拉严格受限**:官方规定"**若手动输入则只能填写下拉选项中包含的值**"。 + ⇒ 移动端一律用选择器;PC 端允许输入但做**严格值匹配**,不在选项内的值直接拒绝。 + +8. **"无"是合法值,不是空值**(⚠️ 实现陷阱) + - 工作单位没有 → 填"无" + - 报考工种该职业没有 → 填"无" + - ⇒ 必填校验时 **"无"算已填**;做单位统计、去重时**不能把"无"当成真实单位名**。 + +9. **格式校验** + - 联系电话:11 位数字 + - 入学时间:**必须 YYYY-MM-DD** + - 本职业工作年限:**只能阿拉伯数字**(0、1、2…) + +10. **实时校验**:每个字段失焦即校验,立即反馈,不等到提交时集中报错。 + +11. **草稿自动保存**:每 10 秒或字段变更后保存;断网时暂存本地,恢复网络后补传。 + +12. **跨任务记忆**:同一人第二次填报自动带出上次信息(仅本租户内)。 + +13. **提交前总校验**:汇总所有问题,分三类呈现 + - 🔴 阻断项:必须处理才能提交 + - 🟡 警告项:可勾选"我已确认"后继续 + - 🟢 通过项 + +**输出**:提交成功 → 进入待初审/待终审;或显示阻断清单。 + +**异常分支** + +| 情况 | 处理 | +|---|---| +| 填报中途退出 | 草稿保留,重新进入时提示"继续上次填写" | +| 提交时网络中断 | 提示"提交未成功,已保存草稿",不做"假成功" | +| 同一账号在两台设备同时填 | 以最后保存的版本为准,并提示"你的内容在另一台设备上被修改过" | +| 提交后又想改 | 未进入审核前可"撤回";已进入审核需联系老师驳回 | +| **报考范围已关闭**(认定机构批次结束) | 提示"本批次已结束",不允许新建填报单;已提交的不受影响 | +| **学生发现自己的报考职业/工种/等级不对** | 提示"该三项由老师统一确定,请联系老师修改",**不允许自行输入** | +| **选了"专项职业能力"却想填工种/等级** | 该三项不显示;若历史数据带出旧值,自动清空 | + +### 3.5 材料上传与材料识别逻辑 + +#### 3.5-A 上传流程 + +> ⚠️ **重要修正(V1.2)**:**不提供"直接拍照"入口。** +> 复核 4 类材料的真实来源后发现,**没有一类是"现场拍照"产生的**: +> +> | 材料 | 真实来源 | 实测证据 | +> |---|---|---| +> | 学籍表 | 教务系统**屏幕截图** | 28 张均为约 1025×590 的 16:9 截图 | +> | 工作证明 | **扫描仪扫描件**(多在打印店完成) | 28 张均为 1208–1352 dpi 的 A4 扫描图 | +> | 一寸照片 | **照相馆拍摄后的电子版** | 27/28 张尺寸完全一致(984×1378) | +> | 身份证 | 既有扫描件或翻拍件 | 尺寸跨度 558×745 ~ 2216×2796,来源不一 | +> +> 现场拍照只会引入模糊、畸变、反光、阴影问题,**反而制造返工**。填报入口必须是"**选择已有文件**"。 + +**触发**:填报人在某个材料项上点击"选择材料"。 + +**四种来源通道(按实际使用频率排序)** + +| # | 通道 | 适用材料 | 说明 | +|---|---|---|---| +| 1 | **从微信聊天记录选择** | 全部(**主力通道**) | 学生从照相馆、打印店、老师、家人处收到的文件,通常就躺在聊天记录里 | +| 2 | **从手机相册选择** | 学籍表截图、已有的翻拍件 | 手机截图会自动存入相册 | +| 3 | **从聊天记录 / 手机文件选择** | 扫描件 PDF、Word 版申报表 | 需要选中"文件"类型而非"图片"类型 | +| 4 | ~~直接拍照~~ | **默认关闭** | 材料级可配置开启(例如某批次允许现场补拍身份证),但默认不提供 | + +**两条关键设计逻辑** + +**① 必须保留原始文件名(极其重要)** + +- 从**聊天记录**选择文件时,微信会返回该文件的**原始名称**(例如 `张三-360121200010101234-工作证明.jpg`); +- 从**相册**选择图片时,拿不到文件名(通常是 `IMG_0001.jpg`)。 + +⇒ 系统必须优先使用前者。**文件名本身就是最有价值的信息**——命名规范化引擎(§3.6)能直接从文件名中读出姓名、证件号、材料类型,准确率远高于从无名截图里靠 OCR 猜。 + +⇒ 产品层要提示用户:"**优先从聊天记录里选**,因为文件名里带着你的信息,系统能自动帮你填。" + +**② 支持一次多选** +身份证正反面、多页材料允许一次选多张,减少重复操作。 + +**③ 来源引导(每个材料项都要给)** + +| 材料 | 引导文案 | +|---|---| +| 一寸照片 | "照相馆给过你电子版吗?在微信聊天记录里搜一下" | +| 工作证明 | "打印店扫描后会发给你,从聊天记录里选" | +| 学籍表 | "教务系统截图后,从手机相册里选" | +| 身份证 | "拍过的照片或者扫描件都可以,从聊天记录或相册里选" | + +**处理步骤(一次上传的完整生命周期)** + +``` +点击"选择材料" + ↓ 弹出来源选择:微信聊天记录 / 手机相册 / 手机文件 + ↓ 选中文件(可多选) + ↓ 【客户端】只做三件事: + ↓ ① 记录原始文件名(若来源支持) + ↓ ② 方向校正 + ↓ ③ 尺寸限幅(长边 ≤2400px) + ↓ 不做有损质量压缩(原因见 §3.7.6 二次有损防护) + ↓ 上传到存储(显示进度;支持断点续传) + ↓ 【服务端】形态识别 → 选定压缩档位 + ↓ 命名规范化(§3.6) + ↓ 压缩到目标体积(§3.7) + ↓ 格式转换(如需) + ↓ 校验(§3.8) + ↓ 必要时 OCR 识别并回填 + ↓ 返回处理结果 +填报人看到结果卡片: + ┌─────────────────────────────────────────┐ + │ 原名:曾伊豪-361029200907295110身份证.jpg.jpg │ + │ 已规范为:曾伊豪-...-身份证.jpg │ + │ 体积:17.1MB → 512KB ✅ │ + │ 校验:通过 ✅ │ + └─────────────────────────────────────────┘ +``` + +**输出**:材料项状态变为"已受理/有警告/有阻断",并附上处理前后对比。 + +**异常分支** + +| 情况 | 处理 | +|---|---| +| 上传中断 | 断点续传;已传部分不重复上传 | +| 格式不在白名单 | 提示支持的格式,并说明"建议用 JPG" | +| 从聊天记录选文件时超出微信单文件体积限制 | 提示改为"从相册选那张图片",并说明聊天中收到的图片可选择非原图版本(体积更小)。**各通道的实际体积上限需在开发期实测确认** | +| 聊天记录里找不到该文件(过期/被清理) | 提示替代方案:请对方重新发送 / 从手机文件里找 / 联系老师代传 | +| 从相册选的图片没有文件名(`IMG_0001.jpg`) | 无法从文件名提取信息 → 走 OCR 识别回填,并在界面提示"这张图没有名字信息,请确认归属" | +| 文件来自"文件传输助手" | 正常处理,文件名可保留 | +| 选中的是压缩过度的聊天图片(微信默认会压缩图片) | 检测分辨率是否偏低,若低于建议值则提示"这张图可能被压缩过,建议让对方发原图" | +| 文件过大(>50MB) | 提示"文件过大,请让对方重新发送一份清晰的扫描件" | +| 处理超时 | 转后台异步处理,页面显示"处理中",完成后推送通知 | +| 处理失败 | 保留原图,标记失败原因,允许一键重试;不丢弃用户已上传的内容 | +| 上传了错误的材料(如把身份证传到照片项) | 依靠 OCR 内容比对提示"这张看起来不是身份证",并允许一键移动到正确材料项 | +| 重复上传同一材料 | 保留历史版本,默认取最新;旧版本可回溯 | + +#### 3.5-B 材料识别(这是什么材料) + +> **决策(方案 B)**:**规则为主 + 视觉大模型兜底。** 先用零成本规则判断,只在"规则判不准"或"与用户声明冲突"时才调用视觉大模型。 + +**📊 实测验证结果(2026-09-24,用 112 个真实文件 + 32 个 OCR 抽样)** + +| 层 | 方法 | 实测结果 | 成本 | +|---|---|---|---| +| **第 1 层 文件名** | 别名表匹配 | **112/112 = 100%** | 0 | +| **第 2 层 形态** | 粗分流派 | **111/112 = 99.1%** 能正确分流派,但**流派内无法定型** | 0 | +| **第 3 层 内容 OCR** | 强词计分 + 零文字判据 | **32/32 = 100%**(4 类材料各 8 个) | OCR(本来就要做) | +| 第 4 层 VLM | 兜底 | 触发率 **2%~10%**(重估) | 按次 | + +**OCR 耗时实测**(CPU,图片降采样至 2000px) + +| 材料 | 耗时 | 文字量 | +|---|---|---| +| 一寸照片 | 0.1–0.3s | 0 字 | +| 身份证 | 0.9–1.6s | 107–138 字 | +| 学籍表 | 1.0–1.4s | 265–269 字 | +| 工作证明 | 3.5–4.9s | 702–755 字 | + +**本次验证发现并修正的两处设计错误** + +| # | 初版设计(错) | 实测真相 | 修正后 | +|---|---|---|---| +| 1 | 身份证宽高比 ≈ 1.585,可按形态直接判定 | 实际 **0.732–1.016**,与一寸照片 0.714 **高度重叠** | 形态层降级为"只分流派" | +| 2 | OCR 关键词"命中任一即判定" | 关键词**大量交叉命中**(工作证明里含"学籍/学校",学籍表里含"居民身份证") | 改为**只用强词 + 加权计分取最高** | + +**⚠️ 本次验证的局限性(必须在开发期补测)** + +1. **文件名 100% 是"有偏测试"**——这 112 个文件是老师**已经规范命名过**的;真实收集中学生传来的文件名更乱,准确率必然更低; +2. OCR 抽样仅 32/112,未覆盖全部文件; +3. 未覆盖的困难场景:**模糊件、翻拍反光、非标准材料**(学信网备案表、其它佐证材料在本数据集中不存在); +4. 上述结果**不能直接当作线上准确率承诺**,只用于验证架构方向。 + +**要解决什么问题** + +| # | 场景 | 问题 | +|---|---|---| +| 1 | 填报上传 | 用户在"身份证"槽位传了学籍表 → 需要发现并提示 | +| 2 | 整理器散图分拣 | 一堆没有目录的散图 → 需要判断每张是什么,才能归入目录 | +| 3 | 审核辅助 | 班主任想快速确认"这堆材料里有没有传错的" | +| 4 | 缺失检测兜底 | 例如"其它佐证材料"传了 3 张,需要区分它们分属什么 | + +**四路信号** + +| 信号 | 内容 | 可靠性 | 成本 | +|---|---|---|---| +| **S1 槽位** | 用户在**哪个材料项**上传的(用户声明) | 高,但**用户可能传错** | 0 | +| **S2 文件名** | 原始文件名里的材料名(如 `...-工作证明.jpg`) | **最可靠**(前提是聊天记录来源) | 0 | +| **S3 形态** | 宽高比、像素量、DPI、是否有大面积红色(印章) | 中(拍歪、裁图后会失效) | 极低(只读头部) | +| **S4 内容** | OCR 文字关键词、版面特征 | 高 | OCR 已在做,无额外成本 | + +**🔑 一个必须记住的前提:不是所有材料都有文字。** +一寸照片**完全没有文字**,只能靠形态(5:7 比例、纯色背景、人脸居中)判断。因此**不能因为"没识别出关键词"就判定材料错了**。 + +**规则层判定(必跑,零成本)** + +**① 文件名规则**(最强信号) +- 用材料别名表(§3.6)做最长匹配 +- 命中 → 直接定为该材料,置信度 **0.95** + +**🔧 三层递进架构(实测校正后的最终设计)** + +> ⚠️ **本文档初版把形态层写成"直接判定材料类型",实测证明是错的。** 正确架构如下: + +``` +第 1 层 文件名 → 能定死就定死(准确率实测 100%,零成本) + ↓ 判不出 +第 2 层 形态 → 【只分流派,不材料定型】 + ↓ 流派内还有多个候选 +第 3 层 内容(OCR) → 在流派内定死(准确率实测 100%) + ↓ 仍判不出 +第 4 层 VLM → 兜底(实测触发率仅 2%~10%) +``` + +**第 1 层:文件名规则**(最强信号) +- 用材料别名表(§3.6)做最长匹配 +- 命中 → 直接定为该材料,置信度 **0.95** +- ⚠️ 前提是来源为**微信聊天记录**(可保留原始文件名);相册来源拿不到文件名 + +**第 2 层:形态规则 —— 只分流派(关键修正)** + +| 形态流派 | 判定特征(实测标定) | 该流派内的候选材料 | 定型靠什么 | +|---|---|---|---| +| **A4 扫描件流** | 像素量 > **2000 万**(实测 1.44~1.81 亿) | 工作证明 / 申报表 / 备案表 | 内容层 | +| **宽屏截图流** | 宽高比 **1.60~1.90**(实测 1.689~1.758) | 学籍表 / 学信网备案表截图 | 内容层 | +| **竖版小照片流** | 宽高比 **0.60~1.05**,像素 < 2000 万(实测 0.714~1.016) | **一寸照片 / 身份证翻拍** ← 混合! | 内容层 | +| 未判定 | 其他 | — | VLM | + +> ❌ **本文档初版写的"身份证 宽高比 ≈1.585(85.6:54)"是错的。** +> 实测:真实身份证件是**竖版/近方拍摄**,宽高比 **0.732~1.016**,与**一寸照片的 0.714 高度重叠**,像素量也重叠(416K~6196K vs 1356K~3378K)。 +> ⇒ **形态层根本无法区分「一寸照片」和「身份证」**,必须交给内容层。这是本次验证最重要的结论。 + +**第 3 层:内容规则(OCR 关键词加权计分)** + +> ❌ **本文档初版写的"命中任一关键词即判定"是错的。** +> 实测发现**关键词大量交叉命中**:工作证明里同时出现"学籍/学校/入学/专业/姓名",学籍表里也出现"居民身份证",身份证里也出现"学校/姓名"。 +> ⇒ 必须**只用高唯一性强词 + 加权计分取最高**,通用词(姓名/学校/专业/出生/单位/本人)全部剔除。 + +| 材料 | **强特征词**(权重 ×3) | 实测得分 | +|---|---|---| +| 身份证 | 公民身份号码 / 签发机关 / 有效期限 / 居民身份证 | 9~12 | +| 学籍表 | 学籍 / 学籍号 / 学籍状态 / 学籍证明 | 6(另有身份证噪声 3) | +| 工作证明·申报表 | 职业技能等级认定 / 个人申报表 / 申报表 / 兹证明 / 技能等级 | 12(另有学籍表噪声 3) | +| **一寸照片** | ——(**零文字**,见下) | 9 | + +**🔑 一寸照片的"零文字"判据** +实测 8/8 张一寸照片 **OCR 文字量为 0**(真实证件照没有任何文字)。 +⇒ 规则:**整图文字量 < 5 字 → 判定为一寸照片**(结合竖版小照片流派)。 +这是整个识别体系里**最干净的信号**,比任何关键词都可靠。 + +**判定规则** +``` +对每个候选材料算分:分数 = 3 × (命中的强词数) +若整图文字量 < 5 字 → 一寸照片得 9 分 +取最高分者为识别结果;全部为 0 分 → 交 VLM +``` + +> 📌 强词表**必须可配置**(每个租户/机构材料名与措辞不同),并提供"从历史文件自动学习候选强词"的辅助功能。 +> 📌 学信网备案表、其它佐证材料等**当前数据集未覆盖**,需在新批次数据上补充标定。 + +**④ 印章检测**(辅助信号) +扫描件上大面积红色像素区域 → 提示"疑似盖章文件"。仅作辅助,不能单独定论。 + +**判定与置信度** + +``` +置信度 ≥ 0.90 → 直接采信识别结果 +置信度 0.70–0.90 → 采信但标记,界面提示用户确认 +置信度 < 0.70 → 不判断,以用户槽位为准(不打扰用户) +``` + +**VLM 兜底触发条件(成本控制的核心)** + +只有以下三种情况才调用视觉大模型: + +| # | 触发条件 | 理由 | +|---|---|---| +| 1 | 规则层置信度落在 **0.4–0.7**(疑似但不确定) | 值得花钱确认 | +| 2 | 规则识别结果**与槽位冲突** | 这是最需要判断的场景(防传错) | +| 3 | **散图分拣**且规则层无法判定 | 没有槽位可参考,只能靠内容 | + +**不触发 VLM 的情况(占绝大多数,零成本通过)** + +- 规则层置信度 ≥ 0.9(文件名清楚 / 关键词明确)→ 直接定 +- 槽位与识别结果一致 → 无需二次确认 +- 规则层置信度 < 0.4 且无冲突 → **不值得花钱**,直接放行 +- 一寸照片等纯形态材料 → 靠形态规则即可(比例特征极其稳定) + +**成本预估(已按实测重估)** + +| 方案 | 调用量(按 48 万文件/学期) | 相对成本 | +|---|---|---| +| 全部走 VLM | 48 万次 | 100% | +| **本方案(规则为主 + 兜底)** | **约 1~4.8 万次** | **约 2%~10%** | +| 只用规则 | 0 次 | 0%(但模糊件判不出) | + +**实测触发率(按"聊天记录来源占比"推算)** + +| 聊天记录来源占比 | VLM 触发率 | 48 万文件的调用量 | +|---|---|---| +| 90% | **2.0%** | 约 1.0 万次 | +| 80% | **4.0%** | 约 1.9 万次 | +| 70% | **6.0%** | 约 2.9 万次 | +| 50% | **10.0%** | 约 4.8 万次 | + +> ✅ **勘误**:本文档初版预估"触发率约 15%",**实测重估为 2%~10%**。 +> 初版之所以偏高,是因为形态规则写错(把身份证误判为一寸照片)产生了大量**假冲突**,从而错误触发 VLM。 +> 修正形态规则后,冲突大幅减少,成本也随之下降 60%~85%。 +> +> ⚠️ 上表的"相册来源中 20% 需 VLM"为保守假设,需在开发期用真实数据实测标定。 + +**冲突裁决表(核心规则)** + +| 用户槽位(声明) | 识别结果 | 置信度 | 系统动作 | 用户看到 | +|---|---|---|---|---| +| A | A | 任意 | ✅ 通过 | 无感 | +| A | **B** | ≥ 0.9 | 🟡 **提示 + 一键移动** | "这张看起来是**学籍表**,不是身份证。要移过去吗?" | +| A | B | 0.7–0.9 | 🟡 软提示,不强制 | "这张不太像身份证,请确认一下" | +| A | B | < 0.7 | 放行 | 无感 | +| A | 无法识别 | — | 放行 | 无感 | +| **无槽位**(散图) | A | ≥ 0.9 | ✅ **自动归入** | — | +| 无槽位 | A | 0.7–0.9 | 建议归入,可改 | "建议归到「学籍表」" | +| 无槽位 | 无法识别 | — | 进入「待人工归属」 | 拖拽归位界面 | + +**🔒 三条不可违背的原则** + +1. **只提示,不自动改位置(散图除外)** + 识别必然有误判。**自动改位置比传错更危险**——它会把用户原本放对的文件挪走。唯一的例外是散图分拣:那里本来就没有位置,自动归入是无害的。 +2. **识别结果不覆盖用户的声明** + 即使用户把身份证传到了"学籍表"槽位,系统也只是提示;最终位置由用户决定。 +3. **识别不出 ≠ 材料错** + 新材料类型、无文字的图片、模糊件都可能识别失败。**识别失败一律放行**,不得阻断提交。 + +**集成点** + +| 场景 | 时机 | 动作 | +|---|---|---| +| 填报上传 | 每次上传后(异步) | 冲突时提示 + 一键移动 | +| 提交前总校验 | 提交时 | 汇总"疑似传错"清单,按 🟡 警告呈现 | +| 整理器·散图分拣 | 无目录结构时 | 自动归属;判不出的进待人工归属 | +| 整理器·体检报告 | — | 输出"疑似错位"清单 | +| 审核辅助 | 班主任查看时 | 材料卡片上显示"系统判定:身份证 92%" | +| 规则优化 | 定期 | 用人工纠正确认的结果反哺关键词表 | + +**异常与降级** + +| 情况 | 处理 | +|---|---| +| OCR 服务不可用 | 降级为仅文件名 + 形态判定,不阻断 | +| VLM 超时(>5s)或失败 | 放弃识别,以用户槽位为准,放行 | +| 图片无法解码 | 交压缩模块报错处理 | +| 遇到系统中不存在的新材料类型 | 不判断,放行 | +| 同一文件被识别为多个材料且置信度接近 | 归入"待确认",提示用户选择 | + +**性能要求** +- 识别为**异步**执行,**不阻塞上传与提交**; +- 规则层(含关键词匹配)单文件 ≤300ms; +- VLM 兜底 5s 超时,超时即放弃。 + +### 3.6 命名规范化逻辑 + +**目标**:无论填报人交上来什么名字,系统都输出 `姓名-证件号-材料名.后缀`。 + +**处理步骤** + +| 步骤 | 做什么 | 关键判断 | +|---|---|---| +| 1 | 剥离扩展名 | 循环剥离,直到没有已知扩展名为止(解决 `.jpg.jpg`) | +| 2 | 提取证件号 | 从文件名中找出最长的 15–18 位号码片段 | +| 3 | 证件号归一化 | 补齐位数、末位 `x` 统一为大写;位数不足或校验不通过时启动裁决(见下) | +| 4 | 材料名识别 | 用材料别名表做最长匹配("在职证明""工作证明扫描件"→ 统一为"工作证明") | +| 5 | 姓名识别 | 取剩余的中文串;与名册比对,做形近字/同音字纠错 | +| 6 | 组装 | 按模板拼接,清理非法字符,重名自动加序号 | +| 7 | 标记 | 标注本次是"全自动""已修复""待确认"还是"需人工" | + +**证件号争议裁决表(核心规则)** + +| 文件名中的号 | 名册中的号 | 判定结论 | 系统动作 | +|---|---|---|---| +| 校验通过 | 校验通过 | 两个都是真实有效号码 → **疑似两人材料** | 🔴 不自动改,必须人工确认(串档) | +| 校验**不通过** | 校验通过 | **文件名写错了** | ✅ 自动按名册改正 | +| 校验通过 | 校验不通过 | 名册可能录错 | 🟡 提示管理员核对名册 | +| 位数不足 / 无法校验 | 校验通过 | 文件名残缺 | ✅ 按名册补齐 + 🟡 标黄供确认 | +| 名册中查无此人 | — | 非本任务人员 | 转"待认领"队列 | + +> 这条规则的价值:把"证件号到底谁对"从**人的判断**变为**算法的判断**。实测 5 个人工疑点中,3 个可自动处理,只有 1 个必须人工。 + +**姓名纠错规则** + +| 情况 | 判定 | 动作 | +|---|---|---| +| 与名册姓名不一致,但只差 1 个字且拼音相同(如"扬晨"↔"杨晨") | 高置信 | 自动改为名册姓名 | +| 与名册姓名不一致,差 1 个字但拼音不同 | 中置信 | 🟡 标黄,由填报人或班主任确认 | +| 与名册姓名完全不一致(如 3 个字都不同) | 低置信 | 🔴 标红,转人工 | + +**材料名别名表(可维护)** + +| 规范名 | 接受的别名 | +|---|---| +| 一寸照片 | 1寸照片、一寸电子照片、证件照、照片、免冠照 | +| 身份证 | 身份证正反面、身份证扫描件、身份证正面、身份证反面 | +| 学籍表 | 学籍证明、在读证明、学籍卡 | +| 工作证明 | 在职证明、工作证明扫描件、个人申报表、申报表、单位证明 | +| 学信网备案表 | 学信网学历备案表、学历证书电子注册备案表、备案表 | +| 其它佐证材料 | 毕业证书、社保个人权益单、职称证明、技能等级证书、佐证 | + +**异常分支** + +| 情况 | 处理 | +|---|---| +| 文件名完全无法解析(如 `IMG_20260924_0932.jpg`) | 保留原名 + 标红"需人工";同时提供"手动指定归属"入口 | +| 同一人同一材料出现多个版本 | 版本号递增,默认取最新;旧版本保留可回溯 | +| 规范后与他人重名 | 追加序号(如 `...身份证(2).jpg`),并提示 | +| 材料名与内容不符(如"工作证明"里其实是申报表) | 以规则的规范名为准(机构要求如此),不擅自改名 | + +### 3.7 压缩逻辑(扫描件强制 1M) + +#### 3.7.1 核心决策 + +> **D1:所有材料,包括 A4 扫描件,一律强制输出 ≤ 1M。不设例外、不提供体积放宽选项。** + +由此确定约束的优先级: + +| 约束 | 性质 | 冲突时的取舍 | +|---|---|---| +| 输出体积 ≤ 1M | **硬约束** | 不可协商 | +| 分辨率与清晰度(DPI / 质量) | 软约束 | 冲突时牺牲清晰度,但必须标记 | +| 原始文件保留 | 保障措施 | 始终保留 30 天,用于复核与重出 | + +> ✅ **重大实测结论(2026-09-24,用 28 张真实扫描件完整实测)** +> +> **"体积 ≤1M" 与 "清晰度" 根本不冲突。** 按 A4 等比缩放至标准扫描分辨率 300dpi(2480×3508)、质量 85 编码: +> +> | 输出参数 | 平均体积 | 最大体积 | 达标情况 | +> |---|---|---|---| +> | 300dpi / q90 | 958 KB | 964 KB | ✅ 28/28(余量仅 6%,不推荐) | +> | **300dpi / q85** | **806 KB** | **831 KB** | ✅ **28/28 ← 默认参数** | +> | 300dpi / q80 | 718 KB | 740 KB | ✅ 28/28 | +> | 300dpi / q75 | 653 KB | 673 KB | ✅ 28/28 | +> | 400dpi / q80 | 1070 KB | 1078 KB | ❌ 全部超标 | +> +> **结论:** +> 1. 输出的是**标准 300dpi 扫描件**,清晰度**零损失**,认定机构没有正当理由拒收。 +> 2. 整班 444MB → **22MB**,压缩率 **95%**;单张处理约 2 秒。 +> 3. 下文 3.7.3 的"逐级降质"路径**作为兜底保留,但预计 100% 不会触发**。 +> 4. §3.7.4 的"清晰度牺牲标记 + 人工抽检"机制**实测中不会产生任何待抽检文件**,作为极端情况的安全网保留。 +> +> 📌 参数可随实际情况调整:若某些批次扫描件内容更复杂(如满页手写、彩色底纹),系统会自动进入兜底路径,无需人工干预。 + +#### 3.7.2 材料形态识别与档位 + +系统先识别材料属于哪种物理形态,再套用对应策略(**不能对所有图片用同一把尺子**): + +| 档位 | 形态 | 典型材料 | 尺寸策略 | 输出格式 | +|---|---|---|---|---| +| P1 证件照 | 标准证件照(比例 5:7) | 一寸照片 | **固定 295×413 px** | JPG | +| P2 证件翻拍 | 手机翻拍(有透视变形) | 身份证、备案表 | 长边控制在 1400–2400 px | JPG | +| P3 扫描件 | **A4 纸张扫描件**(超高像素) | 工作证明、申报表 | **按 A4 等比缩放,用 DPI 控制** | JPG | +| P4 截图 | 屏幕截图(比例接近 16:9) | 学籍表 | **保持原始像素,不缩放** | **PNG** | +| P5 文档 | PDF / Word | 各类文档 | 页面重采样 | PDF | + +**形态自动判定依据**:像素总量、宽高比、格式、压缩特征。判定置信度低时,回退到管理员为该材料指定的档位。 + +#### 3.7.3 压缩执行顺序(关键逻辑) + +压缩有两个可调旋钮:**采样率(尺寸)** 和 **编码质量**。二者的使用顺序**按形态区分**: + +| 形态 | 顺序 | 原因 | +|---|---|---| +| **文字类(P3 扫描件 / P4 截图)** | **先降采样,后降质量** | 文字与印章是"高对比边缘"。质量降低会在笔画边缘产生锯齿状噪点(JPEG 伪影),直接破坏可读性;而等比重采样是可控的平滑,对文字更友好 | +| **照片类(P1 证件照 / P2 翻拍)** | **先降质量,后降采样** | 人脸细节对重采样敏感(一缩放就"糊脸"),对轻度质量下降反而不敏感 | + +**P3 扫描件的完整压缩路径(一次编码到位)** + +``` +1. 形态确认为 A4 扫描件 +2. 判断是否需要先做安全降采样(像素过大时先降到安全区,见 3.7.5) +3. 方向校正 + 色彩统一 + 剥离隐私信息(GPS、设备型号) +4. 用默认参数直接编码一次: + A4 等比缩放至 300dpi(2480×3508),JPEG 质量 85 +5. 测量输出体积: + ≤ 1M → 直接输出,结束 ← 实测本批 28/28 走这条 + > 1M → 进入兜底路径(第 6 步) +6. 兜底路径(预计极少触发;参数按形态区分顺序): + 300dpi/q80 → 300dpi/q75 → 300dpi/q70 + → 240dpi/q80 → 200dpi/q80 → 180dpi/q75 → 150dpi/q70 + → 120dpi/q65 → 继续降采样直至达标 +7. 写回清晰度等级(见 3.7.4) +``` + +**实测参考值(A4 扫描件,JPEG 编码)** + +| 输出 DPI | 质量 | 平均体积 | 结论 | +|---|---|---|---| +| 300 | 90 | 958 KB | ✅ 达标但余量小,不采用 | +| **300** | **85** | **806 KB** | ✅ **默认参数** | +| 300 | 80 | 718 KB | ✅ 备选 | +| 300 | 75 | 653 KB | ✅ 备选 | +| 400 | 80 | 1070 KB | ❌ 超标,不可用 | +| 500 | 80 | 1452 KB | ❌ 超标,不可用 | + +> 📌 与初始推算的差异:本文曾推算"要压进 1M,A4 扫描件只能取 150–170dpi",**该推算错误**。 +> 原因是文档扫描件以白底为主、内容稀疏,压缩率远高于照片(照片的 1M 上限确实需要大幅降质,文档不需要)。 + +#### 3.7.4 清晰度标记与人工抽检 + +**这是"强制 1M"的必要配套机制**——既然存在有损的可能,就必须让人知道哪些文件损了。 + +> ✅ **实测说明**:在 28 张真实扫描件上,默认参数(300dpi/q85)**全部直接达标**,因此**不产生任何 🟡 或 🔴 文件**。本机制作为极端情况(满页手写、彩色底纹、内容极复杂)的安全网保留,不会给班主任增加日常负担。 + +系统为每个文件打一个清晰度等级: + +| 等级 | 判定条件 | 系统动作 | +|---|---|---| +| 🟢 优 | DPI ≥ 200 且质量 ≥ 75 | 直接通过 | +| 🟢 良 | DPI 150–200 | 通过 | +| 🟡 可 | DPI 120–150 | 通过 + 进入**抽检清单** | +| 🔴 强制压缩 | DPI < 120,或触达质量地板 | 通过 + **必须人工确认** + 界面醒目标记 | + +**抽检机制** +1. 每个任务生成一份《压缩质量抽检清单》,列出所有 🟡 与 🔴 文件。 +2. 班主任/管理员在审核界面可**一键只看这些文件**。 +3. 抽检认为不可接受的,可驳回要求重传(并提示"请用手机拍摄而非扫描仪扫描")。 +4. 抽检结论回写到文件记录,用于后续参数优化。 + +#### 3.7.5 超大图的保护逻辑 + +真实数据中存在 1.6 亿像素的扫描件(约 1272dpi A4)。这类图片如果不做保护,会直接拖垮服务器。 + +**保护规则** +1. 处理前先只读取图片的头部信息取得宽高,**不解码像素**。 +2. 像素数超过 5000 万 → 先做一次流式降采样到安全区,再进入正常流程。 +3. 单个文件处理超过 20 秒 → 中断并转入后台队列,页面显示"处理中"。 +4. 单文件像素上限 2 亿、体积上限 50MB,超出直接拒绝并提示。 +5. 内存与并发:图片处理占用的内存远大于 CPU,因此**并发数按内存反推设定**,不能按 CPU 核数设定。 + +#### 3.7.6 二次有损防护 + +**问题**:如果客户端先压一次、服务端再压一次,图片会被"有损压缩两遍",清晰度损失远大于一次到位。 + +**规则** +1. **客户端只做无损操作**:方向校正 + 尺寸限幅,不做有损质量压缩。 +2. **最终编码由服务端一次性完成**,参数只在服务端确定。 +3. 服务端检测到"该图已被明显有损压缩过"(如体积远小于同尺寸的合理值)时,**不再压缩**,只做格式与命名处理,并标记"疑似过度压缩"。 + +#### 3.7.7 其他材料的压缩逻辑 + +| 材料 | 处理 | +|---|---| +| 一寸照片(P1) | 先降质量(85→80→76→72),触底后按比例缩尺寸;**最终统一输出 295×413 px** | +| 身份证(P2) | 长边压到 2400px 以内但不得低于 1400px;**低于 1400px 的原始图不压缩,标记"分辨率不足"** | +| 学籍表(P4) | **保持原始像素,输出 PNG**,仅做无损优化;不缩放、不转 JPG(转 JPG 会让文字边缘发虚) | +| 图片本身已 <1M | 仅做命名规范化与格式确认,**不重新压缩**(避免无谓降质) | +| 图片体积过小且分辨率过低 | 标记"疑似过度压缩",提示重传(**体积小不等于合格**) | + +#### 3.7.8 失败与异常 + +| 情况 | 处理 | +|---|---| +| 压缩后仍 >1M 且已触达下限 | 继续降采样直至达标(强制路径),标记为强制压缩 | +| 图片损坏 / 无法解码 | 保留原图,提示"文件无法识别,请重新上传" | +| 输出体积异常小(如 30KB) | 标记"疑似过度压缩",进入抽检 | +| 处理超时 | 转后台异步,不阻塞用户 | +| 用户重传同一材料 | 以新版本重新走完整流程,旧版本保留 | + +### 3.8 校验逻辑 + +**校验的三层呈现**:🔴 阻断(不可提交)/🟡 警告(可提交但需确认)/🟢 通过。 + +| 编号 | 规则 | 触发时机 | 级别 | 反馈给谁 | 系统动作 | +|---|---|---|---|---|---| +| V1 | 身份证号格式与校验位 | 输入时 / 上传后 | 🔴 | 填报人 | 高亮错误位置,要求核对原件 | +| V2 | 证件号与名册一致 | 输入时 / 上传后 | 🔴 | 填报人 | 展示名册值,按裁决表处理 | +| V3 | 姓名与名册一致 | 输入时 | 🟡 | 填报人 | 给出形近字建议 | +| V4 | **同一人跨材料证件号一致** | 每次上传后 | 🔴 | 填报人 | 标出是哪两份材料冲突 | +| V5 | 材料完整性(必传项) | 提交时 | 🔴 | 填报人 | 列出缺失清单,附上传入口 | +| V6 | 文件格式白名单 | 上传时 | 🔴 | 填报人 | 自动转换或提示重传 | +| V7 | 文件体积上限 | 上传时 | 🔴 | 填报人 | 自动压缩;压不动才提示 | +| V8 | 图片可读性(分辨率过低 / 全黑 / 全白 / 严重模糊) | 上传时 | 🟡 | 填报人 | 提示重新提供(重扫描 / 索取原图) | +| V9 | 重复文件(同一文件被多次上传) | 上传时 | 🟡 | 填报人 | 提示"与 XX 的材料重复" | +| V10 | 身份证有效期是否过期 | OCR 后 | 🟡 | 填报人 | 提示 | +| V11 | 名册内证件号重复 | 名册导入时 | 🔴 | 管理员 | 指出具体行号 | +| V12 | 材料内容与类型不符(如"身份证"位传了风景照) | OCR 后 | 🟡 | 填报人 | 提示疑似传错,提供"移到正确材料项"入口 | + +**校验的展示原则** +- 一次性列出全部问题,不做"改一个报一个"的循环; +- 每个问题必须包含三要素:**哪里错、错成什么、怎么改**; +- 阻断项必须给出直达修复的入口(点击跳转到对应字段/材料)。 + +### 3.9 审核逻辑 + +**触发**:填报人提交后进入待审队列。 + +**班主任初审逻辑** +1. 打开待审列表:默认按"异常优先"排序,红黄项排前面。 +2. 列表呈现:每行一个学生,含头像、姓名、班级、各材料缩略图、状态色点。 +3. **一键批量通过**:只对"全部材料均为 🟢 且无待确认项"的学生生效;任何有红黄项的自动跳过,不允许被批量带过。 +4. 逐项审核:查看大图、可对比原图与处理后的图、查看校验问题清单。 +5. 处理动作: + - 通过 → 进入待终审 + - 驳回 → 必须选择原因(提供常用原因快选),可补充文字 +6. 审核超时:超过设定时长未处理,系统每小时提醒一次,并升级通知管理员。 + +**管理员终审逻辑** +1. 只能看到"初审通过"的填报单。 +2. "全部通过"按钮**仅在整批零红黄项时可用**(防止管理员大而化之地放行)。 +3. 终审通过后默认锁定,不再允许学生修改。 + +**驳回逻辑** +1. 驳回必须带原因,原因是结构化选项 + 可选文字补充。 +2. 学生收到通知后修改,重新提交,回到**初审**(不是回到终审,除非该单此前已在终审环节被驳回)。 +3. 驳回次数累计,达到上限(默认 3 次)自动提醒管理员介入。 +4. 驳回记录全部留痕,审核时间轴对学生可见(让学生知道进度)。 + +**审核行为的边界** + +| 情况 | 规则 | +|---|---| +| 班主任帮学生代传材料 | 允许,但记录操作人;审核界面标注"由 X 老师代传" | +| 班主任批量通过后发现有错 | 支持在进入终审前撤回单个学生的通过结论 | +| 审核人变更(换班主任) | 支持转交,历史审核记录保留原审核人 | +| 同一份单被两个人同时审 | 以后提交的动作为准,前面的人收到"该单已被 XX 处理"提示 | + +### 3.10 汇总导出逻辑 + +**触发**:管理员点击"导出"。 + +**处理步骤** +1. **前置检查**:扫描导出范围内所有文件,若存在"命名未规范 / 体积超 1M / 存在红项未处理"的文件,**直接报错并列出清单**,不允许打包。(对应 §2.4 铁律 2) +2. **范围确定**:默认只导出"已通过终审"的记录;可选按班级、按人员筛选。 +3. **目录结构生成**(默认规范,可配置): + +``` +{批次号}-{任务名}/ +├── 24中技数控1班/ +│ ├── 一寸照片/ 张三-360121200010101234-一寸照片.jpg +│ ├── 身份证/ 张三-360121200010101234-身份证.jpg +│ ├── 学籍表/ 张三-360121200010101234-学籍表.jpg +│ └── 工作证明/ 张三-360121200010101234-工作证明.jpg +├── 24中技数控2班/ +└── 考生线下报名信息导入模板(汇总).xlsx +``` + +4. **汇总 Excel**:合并所有填报字段,一行一人,并附完成情况。 +5. **分卷压缩**:超过设定单包体积(如 50MB)时自动分卷,便于上传与传输。 +6. **异步执行**:导出在后台进行,页面显示进度,完成后通知。 +7. **有效期**:导出包默认保留 7 天,过期自动清理(避免长期堆积大文件)。 + +**输出**:可下载的压缩包(分卷)与汇总 Excel。 + +**异常分支** + +| 情况 | 处理 | +|---|---| +| 有文件未通过终审 | 报错并列出名单,提供"仅导出已通过者"的选项 | +| 有文件命名不规范 | 报错并列出,提供"立即修复"入口(一键重新规范化) | +| 导出过程中有人提交新数据 | 以导出任务创建时刻的快照为准,不动态变化 | +| 导出包过大 | 自动分卷;或提示"建议按班级分批导出" | + +### 3.11 通知与催报逻辑 + +**通知渠道优先级**:微信订阅消息(首选)→ 服务号模板消息 → 短信(兜底,计费)。 + +**触发点与内容** + +| 触发点 | 发给谁 | 内容要点 | +|---|---|---| +| 任务发布 | 全体应填人 | 任务名、截止时间、填报入口 | +| 临期提醒 | 尚未提交者 | 剩余天数、未完成项 | +| 提交成功 | 填报人 | 已提交、进入审核 | +| 审核驳回 | 填报人 | 驳回原因、修改入口 | +| 审核通过 | 填报人 | 已通过 | +| 待审提醒 | 班主任 | 有多少人待审、超时人数 | +| 超时升级 | 管理员 | 哪个班主任有单超时未处理 | +| 导出完成 | 管理员 | 下载入口与有效期 | + +**催报规则** +1. 自动催报:截至日前 7 天、3 天、1 天、当日各一次,**只催未提交者**,不打扰已完成的人。 +2. 手动催报:班主任/管理员可随时对指定人员或"全部未提交者"发起。 +3. **频次保护**:同一人同一天最多收到 2 条催报(防止打扰引发反感)。 +4. 夜间免打扰:22:00–08:00 不发送。 + +**订阅消息额度管理(重要)** +微信的一次性订阅消息需用户逐次授权,**发几次就消耗几次**,额度很容易耗尽。 + +对策: +- 填报人首次进入时一次性请求多条订阅授权,并引导其勾选"总是保持以上选择"; +- 系统记录每个人剩余的授权额度,额度不足时自动降级到服务号或短信; +- 产品层减少催报次数、提升一次填完率(减少返工即减少通知)。 + +### 3.12 历史数据整理器逻辑 + +**用途**:处理老师手上已经存在的"乱文件夹",无需重新收集即可产出规范结果。作为获客入口(先看到价值,再谈系统上线)。 + +**触发**:上传一个压缩包,或指定一个已有目录结构。 + +**处理步骤** +1. **解析文件树**:识别目录与文件名,生成清单。 +2. **逐文件走同一套规则**:形态识别 → 命名规范化(§3.6)→ 压缩(§3.7)→ 校验(§3.8)。 +3. **生成《材料体检报告》**,包含: + - 命名违规清单(原文件名 → 规范名) + - 证件号异常清单(与名册比对的结果,含裁决结论) + - 体积超标清单(压缩前后对比) + - 分辨率不足清单 + - 缺失材料清单(同一人缺了哪几项) + - 重复文件清单 +4. **输出规范化后的压缩包**,目录结构可直接上报。 +5. 报告与结果可生成分享链接给班主任。 + +**特殊能力:散图分拣** +老师从微信里收到的常常是**一堆没有目录、名字很乱的散图**。整理器需要能: +- 按证件号/姓名把散图自动归属到人; +- 无法归属的单独列出,供人工拖拽归位; +- 归位后按材料类型自动分入对应目录。 + +**异常分支** + +| 情况 | 处理 | +|---|---| +| 压缩包内有嵌套压缩包 | 提示是否一并展开处理 | +| 存在无法识别的文件(如 `IMG_001.jpg`) | 归入"待人工归属",不猜测、不放任 | +| 名册缺失(没有 Excel) | 允许无名册运行:仅做命名规范与压缩,不做一致性校验,报告中说明该限制 | + +--- + +## 4. 一页纸规则速查 + +**材料来源** +- ❌ **不提供"直接拍照"**——所有材料都是已有文件(截图、扫描件、照相馆电子版) +- 三个入口:**① 微信聊天记录(主力)→ ② 手机相册 → ③ 聊天/手机文件** +- **优先引导用户从聊天记录选**:那里能拿到**原始文件名**,系统可直接读出姓名+证件号+材料名 +- 相册来的图没有文件名(`IMG_0001.jpg`)→ 只能靠 OCR,须让用户确认归属 + +**报考范围(从名单聚合,不是外部文件)** +- ❌ **不存在"认定机构下发的计划清单文件"**——实测确认名单里已填好报考三项(28/28) +- 范围 = **名单中「职业 × 工种 × 等级」的聚合结果**,存 `collect_task.plan_scope_json` +- **一致性体检比范围校验更有价值**:单看一行都合法,但"38 人报数控车工、2 人报车工"只有聚合后才能发现 +- **追加名单必须告警**:超出已确认范围的报考项需管理员确认——这是范围失控的主要入口 +- 参考字典只提示拼写、**不阻断**(机构可能有非常规职业名) + +**字段三类规则(决定学生填多少字)** +- 🔒 **锁定**:姓名、身份证号、报考职业/工种/等级 → 只读,名单为空则**阻断发布** +- ✏️ **预填可改**:其余字段 → 名单有值则预填标"老师已填,请核对"并可改;空则学生必填 +- 📄 **材料**:学生上传 —— **这是学生唯一的重活** +- 实测老师名单已填满 21 项中的 18 项 ⇒ **学生端几乎不用打字** + +**表单填写(官方模板 21 字段)** +- **报考职业/工种/等级必须在"本期报考范围"内** —— 从源头防错 +- ⚠️ **官方 Excel 模板里「报考职业」「报考工种」根本没有下拉**(21 列中 13 列自由填写)→ **这正是我们相对模板的核心增量** +- **补考科目数**:三/四/五级最多 1 科;一/二级最多 2 科;**专项职业能力没有补考** +- **是否在校生=是** → 在读学校/所学专业/入学时间三项必填,入学时间 **YYYY-MM-DD** +- **"无"是合法业务值**,不是空值(工作单位、报考工种都可能填"无") +- **专项职业能力**:无工种、无等级、无补考,三项整体跳过 +- 官方要求:手动输入时**只能填下拉选项里有的值** + +**准入(门禁)** +- **两种模式都必须先导名单**,没有名单不能填报 +- 进入填报前必须**填写「姓名 + 身份证号」匹配名单**,不通过只提示、不放行 +- 匹配失败**不区分原因**(防止探测身份证号),只给"重新填写 / 申请加入名单"两个出口 +- 匹配接口必须**限流**(同 openid 每小时 ≤10 次) +- 姓名形近字/同音字可纠错,但必须**让用户确认**后才通过 + +**命名** +- 目标:`姓名-证件号-材料名.后缀` +- 证件号争议:**名册优先**,但"两个号码都合法"时**必须人工** +- 姓名纠错:差 1 字且拼音相同 → 自动;否则标黄/标红 +- 无法解析 → 保原名 + 标红,绝不乱猜 + +**材料识别(这是什么材料)** +- **四层递进**:① 文件名(能定就定)→ ② 形态(**只分流派,不定型**)→ ③ OCR 强词计分(在流派内定死)→ ④ VLM 兜底 +- **形态层不能区分「一寸照片」和「身份证」**——二者宽高比高度重叠(0.714 vs 0.732–1.016),必须靠内容层 +- **一寸照片永远是零文字** → "文字量 < 5 字"是最干净的判据 +- 关键词**必须只用强词 + 加权计分**,通用词(姓名/学校/专业/出生)全是噪声 +- **只提示、不自动改位置**(散图分拣除外)——自动挪位置比传错更危险 +- **识别不出 ≠ 材料错**,识别失败一律放行 +- 实测:文件名层 100%、形态分流 99.1%、内容层 100%;**VLM 触发率仅 2%~10%** + +**压缩(强制 1M)** +- 体积 ≤1M 是**硬约束**,扫描件**无例外** +- **默认参数:A4 等比缩至 300dpi(2480×3508)+ 质量 85** ← 实测 28/28 达标,平均 806KB,清晰度零损失 +- 一次编码到位;只有超限才进兜底路径(先降采样、后降质量) +- 兜底触发下限(<120dpi)→ 标记"强制压缩",必须人工确认(实测不会触发) +- 客户端的活:只做方向校正 + 尺寸限幅;**最终编码只在服务端做一次** + +**校验** +- 三层:🔴阻断 / 🟡警告 / 🟢通过 +- 一次列全,每条必须含"哪里错 + 错成什么 + 怎么改" + +**审核** +- 批量通过**只带绿色项**,红黄项一律不放过 +- 驳回必须选原因;驳回后回到初审 +- 终审通过即锁定 + +**导出** +- 有不合格文件**直接拒绝打包**(铁律 2) +- 只导出终审通过的;目录结构可配置;默认 7 天清理 + +**通知** +- 只催未提交者;同日同人 ≤2 条;夜间免打扰 +- 订阅消息额度有限,必须管理剩余额度 + +--- + +## 5. 异常与边界场景 + +> 用真实数据中的 28 人 / 112 个文件作为场景库。 + +| # | 场景 | 输入的实际情况 | 系统判定 | 输出给用户 | 是否需人工 | +|---|---|---|---|---|---| +| 1 | 学生把照片传给了别人 | 陈子文的一寸照片用的是另一个人的证件号 | 两个号码校验位都通过 → 不是打字错误,是两个人 | 🔴"这张照片上的证件号和你其他材料不一致,请检查是否传错" | ✅ 必须 | +| 2 | 学生证件号填错 | 于豪身份证文件名末 6 位倒置 | 文件名号码校验失败、名册通过 → 文件名错 | ✅ 自动按名册改正,提示"已自动纠正" | ❌ 自动 | +| 3 | 学生姓名写了错别字 | 扬晨(应为杨晨),证件号正确 | 差 1 字 + 拼音相同 → 高置信 | ✅ 自动改为"杨晨" | ❌ 自动 | +| 4 | 文件名多了一个后缀 | 工作证明、身份证共 56 份 `.jpg.jpg` | 循环剥离扩展名 | ✅ 自动修复 | ❌ 自动 | +| 5 | 文件名少了个横杠 | 曾伊豪、徐程宇的身份证文件 | 识别到证件号前后无分隔符 | ✅ 自动补上 | ❌ 自动 | +| 6 | 证件号少了一位 | 曹文锋一寸照片仅 17 位 | 位数不足无法校验 → 名册兜底 | 🟡"已按老师名单补齐,请确认是否正确" | 🟡 确认 | +| 7 | 学生传了扫描仪扫的 16MB A4 | 10517×15445、1272dpi | P3 扫描件档 → A4@300dpi/q85 | ✅"17.1MB → 810KB,清晰度无损" | ❌ 自动 | +| 8 | 学生传的证件照只有 558×745 | 杨晨身份证 0.08MB | 长边低于 1400px → 分辨率不足 | 🟡"这张图有点小,可能看不清。可以让对方发一张原图,或从聊天记录里选那份带文件名的" | 🟡 确认 | +| 9 | 学籍表是系统截图 | 1025×590、PNG | P4 截图档 → 不缩放、保持 PNG | ✅ 不处理,仅规范命名 | ❌ 自动 | +| 10 | 学生填到一半退出了 | 网络中断、切后台 | 草稿已本地暂存 | 重新进入提示"继续上次填写" | ❌ | +| 11 | 学生提交后又改了主意 | 已提交待审 | 未进入审核前允许撤回 | "已撤回,可继续修改" | ❌ | +| 12 | 学生重复提交同一份材料 | 传了两次身份证 | 版本号递增 | 保留最新,旧版本可回溯 | ❌ | +| 13 | 学生把身份证传到了照片项 | 材料名与内容不符 | OCR 识别出不是证件照 | 🟡"这张看起来不是一寸照片,是否移动到身份证?" | 🟡 | +| 14 | **名单里没有这个人** | 转班生/插班生/名单漏录 | 门禁匹配失败 | 🔴"没有找到你的信息。请核对姓名和身份证号是否与老师提供的名单一致" | ✅ 老师把此人加进名单 | +| 15 | 同一人在两个班的名单里 | 转班未清理旧名单 | 命中两条 | 列出两个班级让本人选择,结果通知管理员核对 | 🟡 | +| 16 | 班主任批量通过后发现错了 | 有人红项被漏看 | 批量通过只带绿项,理论上不该发生 | 支持在终审前撤回;审计留痕 | 🟡 | +| 17 | 任务截止了但学生要补交 | 学生忘交 | 任务已截止 | 提示"已截止";管理员可延长截止或点名放行 | 🟡 | +| 18 | 班主任离职/换人 | 账号停用 | 待办无人处理 | 支持转交;超时自动升级通知管理员 | 🟡 | +| 19 | 导出时有人材料不全 | 3 人未完成 | 前置检查不通过 | 🔴 报错并列出名单,提供"仅导出已通过者" | 🟡 | +| 20 | 老师手里已有 475MB 的乱文件夹 | 每班 112 个文件 | 走整理器路径 | 输出体检报告 + 规范包 | ✅(内容核对) | +| 21 | 学生从**聊天记录**选了照相馆发来的照片 | 文件带原始名 `张三-...-一寸照片.jpg` | 文件名可读 → 直接提取姓名/证件号/材料名 | ✅ 一次通过;文案"已按文件名自动识别" | ❌ 自动 | +| 22 | 学生从**相册**选了截图 | 文件名为 `IMG_4821.PNG`,1025×590 | 无文件名信息 → 走 OCR + 人工确认归属 | 🟡"这张图没有名字信息,已识别为学籍表,请确认" | 🟡 确认 | +| 23 | 学生从聊天记录收到的是被微信压缩过的图 | 分辨率明显偏低 | 低于建议值 | 🟡"这张图可能被压缩过,建议让对方发原图" | 🟡 确认 | +| 24 | 聊天记录里的文件已过期/被清理 | 选不到该文件 | 通道不可用 | 提示"请让对方重新发送,或从手机文件里找,也可请老师代传" | ❌ | +| 25 | 学生姓名打成"扬晨"(名单是"杨晨") | 形近字 | 身份证号命中 + 姓名纠错命中 | 🟡"你是不是 **杨晨**?" → 确认后通过 | 🟡 确认 | +| 26 | 有人反复试别人的身份证号 | 尝试枚举名单 | 触发限流 | 🔒 锁定并提示"操作过于频繁,请稍后再试";记录 openid | ❌ 系统自动拦截 | +| 27 | 同一个名单项被两个人绑定 | 冒用 / 代填冲突 | 已被绑定 | 🔴"该信息已被登记,如有疑问请联系老师" | 🟡 | +| 28 | 老师忘了导入名单就想发布任务 | 操作遗漏 | 发布前校验不通过 | 🔴"请先导入名单,名单必须包含姓名和身份证号" | ❌ 阻断发布 | +| 29 | 学生把学籍表传到了"身份证"槽位 | 槽位与内容冲突 | 规则层识别出关键词"学籍",置信度 0.93 | 🟡"这张看起来是**学籍表**,不是身份证。要移过去吗?"[一键移动] | 🟡 用户确认 | +| 30 | 学生传了一堆散图,没归类 | 微信收到的图片 | 整理器无槽位 → 按证件号归属到人 + 按内容判定材料类型 | 自动归入目录;判不出的进"待人工归属" | 🟡 部分人工 | +| 31 | 学生传了一张纯色背景的图 | 一寸照片(无文字) | 形态规则:5:7 比例 + 背景纯净 → 置信度 0.88 | 🟡"请确认这张是一寸照片" | 🟡 确认 | +| 32 | 学生把正确材料放在正确位置,但因图糊识别失败 | 槽位与内容无法比对 | 置信度 <0.4 → **不判断** | ✅ 直接放行(**不因识别失败而打扰用户**) | ❌ 系统静默放行 | + +--- + +## 6. 用户可见文案规范 + +### 6.1 三条原则 + +1. **不用术语**——不说"证件号校验位不通过",说"身份证号和老师名单上的不一样"。 +2. **指出位置**——必须告诉用户是哪一份材料、哪一个字段有问题。 +3. **给出动作**——每条错误都要有"接下来做什么",并提供直达入口。 + +### 6.2 文案对照表 + +| 场景 | ❌ 不要这样写 | ✅ 应该这样写 | +|---|---|---| +| 证件号不符 | 身份证号校验失败 | 身份证号第 16 位和老师名单上的不一样,请核对身份证原件(名单上是 `3601...6619`) | +| 疑似串档 | 跨材料一致性校验未通过 | 这张"一寸照片"上的身份证号是 `3601...4232`,和你其他材料不一致,请确认是否传错文件 | +| 分辨率不足 | 图片分辨率低于阈值 | 这张照片有点小,放大后可能看不清,建议重新拍一张 | +| 压缩受限 | 压缩比未达标 | 这张图内容太复杂,压到最小还是超过 1M,建议用手机拍一张更清晰的 | +| 缺材料 | 必填项校验失败 | 还差 1 项:学籍表。点击去上传 → | +| 公章不清 | 图像质量评分不足 | 照片有点模糊,公章可能看不清,建议找打印店重新扫描一份 | +| 任务已截止 | 任务状态异常 | 本次填报已在 9 月 20 日截止。如需补交,请联系你的班主任 | +| 姓名不符 | 姓名匹配度低 | 老师名单上的名字是"杨晨",你写的是"扬晨",是同一个字吗? | +| **材料从哪来** | — | "这份材料一般在**微信聊天记录**里:照相馆/打印店发给你之后,从这里选,系统能自动帮你填好信息" | +| **相册图片无名字** | 无法解析文件名 | "这张图没有名字信息,系统已识别为学籍表。请确认是不是这一份" | +| **聊天图片被压缩过** | 图片分辨率偏低 | "这张图好像被微信压缩过,有点糊。可以让对方发一张原图,或者点开图片后选『原图』再保存" | +| **门禁:匹配失败** | 未找到该身份证号 / 姓名不匹配 | "**没有找到你的信息。**请核对姓名和身份证号是否与老师提供的名单一致。如果确实已报名,可以申请加入名单,由老师审核" | +| **门禁:姓名疑似写错** | 身份证对、姓名形近 | "你是不是 **杨晨**?(老师名单上写的是这个名字)" | +| **门禁:已被绑定** | 名单项已绑他人 | "该信息已被登记,如有疑问请联系老师" | +| **门禁:限流** | 尝试过于频繁 | "操作过于频繁,请稍后再试" | +| **门禁:格式错误** | 身份证位数/校验位不对 | "身份证号看起来不太对(应该是 18 位),请再核对一下" | +| **材料传错位置(高置信)** | 槽位与识别冲突,置信度 ≥0.9 | "这张看起来是**学籍表**,不是身份证。要移过去吗?" [移动] / [不用,就放这里] | +| **材料存疑(中置信)** | 置信度 0.7–0.9 | "这张不太像身份证,请确认一下" | +| **识别失败(静默)** | 置信度 <0.7 或无冲突 | ——(**不提示,直接放行**,避免制造噪音) | + +### 6.3 好消息也要说 + +处理成功的反馈同样重要——**让用户看见系统替他做了什么**: + +- "文件名已自动规范化:`...110身份证.jpg.jpg` → `...110-身份证.jpg`" +- "体积已压缩:17.1MB → 900KB(省了 95%)" +- "全部 4 项材料已齐全,可以去提交了" + +> 这类反馈是让家长/学生愿意继续用下去的关键——他们能直观看到"这个系统真的帮我干活了"。 + +--- + +## 7. 未决事项 + +| # | 事项 | 影响 | 需谁确认 | +|---|---|---|---| +| 1 | **取得认定机构对"300dpi 扫描件"的书面认可**(D1 已定强制 1M,实测 300dpi 下 28/28 达标且清晰度零损失,此项已无技术争议,仅走确认流程) | 中 | 认定机构 | +| 2 | ~~压缩参数实测标定~~ **已完成(2026-09-24,28 张真实扫描件实测)**:默认 300dpi/q85,平均 806KB,0 张超标 | — | ✅ 已闭环 | +| 3 | ~~文字类降质顺序 A/B 验证~~ **已取消**:默认参数下不触发兜底路径,无需验证 | — | ✅ 已闭环 | +| 4 | 分辨率不足的判定阈值(建议长边 1400px)需认定机构确认 | 中 | 认定机构 | +| 5 | 截图类材料保持 PNG 是否会被拒收 | 中 | 认定机构 | +| 6 | 抽检机制由谁执行、抽检比例 | 低(实测不产生待抽检文件) | 客户 | +| 7 | 驳回次数上限默认 3 次是否合适 | 低 | 客户 | +| 8 | 导出包默认保留 7 天是否够用 | 低 | 客户 | +| 9 | **各来源通道的实际体积上限需实测**(微信聊天记录选文件、相册、文件选择三条通道各自能传多大的文件),并据此设计超限提示与降级路径 | 中 | 研发 | +| 10 | **微信相册/相机权限的隐私声明**需在小程序后台同步更新(当前声明需覆盖"从相册选择") | 中 | 研发 + 法务 | +| **11** | **名单外的人怎么处理?** 当前按需求实现为"不给填、只提示",需老师去后台加人。建议增加"申请加入名单 → 管理员审批后自动放行"通道,**请确认是否采纳** | **高** | 客户 | +| 12 | **门禁限流阈值**(同 openid 每小时 10 次)是否合适,需按实际人数与使用习惯标定 | 中 | 研发 | +| 13 | 社会收集的名单从哪来(预报名名单 / 报名点收集 / 机构导出)?若来源不稳定,"先导名单"的落地难度较大 | 中 | 客户 | +| 14 | 门禁核验是否还要加**手机号验证码**(防止他人拿到姓名+身份证号后代填) | 中 | 客户 | +| **15** | **材料识别的 VLM 选型与单价**(用于成本核算与供应商比价);触发率 15% 为预估值,需用真实数据实测标定 | 中 | 研发 + 商务 | +| 16 | 关键词表由谁维护(平台预置 + 租户自维护);是否需要"从历史文件自动学习关键词"功能 | 中 | 产品 | +| 17 | 一寸照片的纯形态判定阈值(宽高比容差、背景纯净度)需用真实样本标定 | 中 | 研发 | +| **18** | **强特征词表需在更多批次数据上补充标定**——学信网备案表、其它佐证材料在本数据集中不存在,其强词尚未定义 | **高** | 产品 + 研发 | +| **19** | 需要补充测试**困难场景**:模糊件、翻拍反光、非标准材料,当前 112 个样本未覆盖 | **高** | 研发 | +| 20 | ~~文件名规则的真实准确率待测~~ ✅ **已降级**:该指标不影响交付项(输出命名取自名册、归属靠证件号反查、识别有槽位兜底、失败放行)。残留需求仅为**上传页引导文案**要贴合学生真实文件名,班主任在群里问两句即可,**不需专门样本** | — | 产品 | +| ~~21~~ | ~~认定机构下发文件的真实格式样本~~ **已澄清:不存在该文件**,报考范围来自名单 → 问题关闭 | — | ✅ 已闭环 | +| ~~22~~ | ~~下发文件是全量还是增量~~ **同上,问题关闭** | — | ✅ 已闭环 | +| ~~23~~ | ~~国标职业字典初始数据来源~~ **降级**:字典改为可选的拼写校验(P2),不阻塞开发 | 低 | 产品 | +| **24** | **是否允许学生修改"老师预填"的字段?** 本版设计为"可改并留痕"(依据:电话有 28 个不同值、入学时间可能记错)。若机构要求严格一致,需改为"只读 + 申请修改" | **中** | 客户 | + +--- + +**文档结束** diff --git a/docs/数据信息填报系统PRD.md b/docs/数据信息填报系统PRD.md new file mode 100644 index 0000000..49827c2 --- /dev/null +++ b/docs/数据信息填报系统PRD.md @@ -0,0 +1,3012 @@ +# 数据信息填报系统 PRD + +> 产品代号:**填报通 / CollectFlow**(暂定) +> 版本:V2.3(2026-09-28 明确滑动交互规则) +> 日期:2026-09-28 +> 文档类型:产品需求文档(PRD)+ 数据库设计 + 接口清单 +> 适用场景:职业技能等级认定报名材料收集(可扩展至各类"班级/单位材料收集"场景) +> 📄 **配套文档**:《数据信息填报系统PRD-功能逻辑卷》—— 只写"系统怎么运转",不含库表/接口/代码 + +## 变更记录 + +| 版本 | 日期 | 变更内容 | 变更人 | +|---|---|---|---| +| V1.0 | 2026-09-24 | 初稿:背景/流程/功能/规则引擎/库表/接口 | 产品 | +| **V1.1** | 2026-09-24 | **复审修订**(用 112 个真实文件重新实测验证):
① **勘误**:命名违规率 98.2% → **52.7%**(59/112);双扩展名 55 → **56**;原始总量 436MB → **475MB**(436MB 实为 .rar 包体积);需处理并集 62 → **59**(原为重复计数)
② **推翻参数假设**:工作证明是 1272dpi A4 扫描件(非手机拍照)→ §6.3 压缩策略由"按长边"重写为"按材料形态分五档"
③ 学籍表是 16:9 截图 → 新增 P4 截图档,禁止缩放与转 JPG
④ 新增 §6.1 证件号裁决矩阵,人工量由 5 例降至 3 例
⑤ **新增风险 R11**(1.8 亿像素导致 OOM)、**R12**(微信订阅消息次数限制)
⑥ **补全 §9.4 班主任移动端页面清单**(初稿遗漏)
⑦ 修正 §1.4 指标口径混淆(班主任操作时长 vs 端到端周期)
⑧ 补充小程序隐私保护指引、DPA、KMS 等合规项
⑨ 补 `collect_task_org` 表、名册 `student_no` 字段、附录 D 错误码、附录 E 定时任务 | 产品 | +| **V1.2** | 2026-09-24 | **压缩决策 D1 落定**:① §6.3 P3 扫描件档由"建议放宽至 2M"改为 **≤1M 硬约束、无例外**;② 压缩机制改为"体积优先、清晰度可牺牲,但必须标记 + 人工抽检";③ 风险 R1 由"要不要压"改为"压完机构认不认";④ 待确认项 Q1 / Q2-d1 关闭;⑤ **拆出《功能逻辑卷》**,本文不再承载功能运转细节 | 产品 | +| **V1.3** | 2026-09-24 | **实测推翻了 D1 的估算,压缩问题彻底解决**:用 28 张真实 A4 扫描件完整实测,**300dpi/q85 下 28/28 全部 ≤1M(平均 806KB,最大 831KB)**。① §1.2 量化结论重写;② §6.3 压缩由"逐级降质逼近"改为"**一次编码到位**";③ 风险 **R1 由高降为低**;④ 待验证项闭环(A/B 测试取消,M3 工期 −0.5 周);⑤ 新增 V1.2 中 D1"强制 1M"的落地参数 | 产品 | +| **V1.4** | 2026-09-24 | **修正材料来源通道**:**取消"直接拍照"**(复核实测数据:学籍表是屏幕截图、工作证明是扫描仪扫描件、一寸照片是照相馆电子版、身份证是翻拍/扫描件,**无一来自现场拍照**)。① 新增 FR-601a 三通道(微信聊天记录 / 手机相册 / 聊天文件),拍照默认关闭;② 新增 FR-601b **保留原始文件名**(聊天记录通道可拿到原名,是命名规范化的首要输入);③ §9.1/§9.2 交互重写为来源选择弹层 + 每个材料的"去哪里找"引导;④ §10.3 隐私声明补相册权限、§10.4 补小程序能力依赖;⑤ 驳回原因与提示文案由"重拍"改为"重新扫描/索取原图" | 产品 | +| **V1.5** | 2026-09-24 | **新增「名单门禁」(新需求)**:学校收集与社会收集**都必须先导入名单**,填报人进入前必须填写「姓名 + 身份证号」与名单匹配,**匹配不到只提示、不放行**。① §4.1/§4.2 流程图与差异表重写,**废除"社会收集可自由填报"**;② 新增 FR-503 ~ FR-508(门禁核验、统一失败提示、**限流防枚举**、名单不下发、姓名形近字纠错、已绑定保护、发布前校验、追加导入);③ §9.1 新增「门禁核验」「门禁失败」两页,§9.2 新增门禁交互;④ §10.3 新增**门禁防枚举**合规项;⑤ §2 术语表补"门禁";⑥ 附录 D 补门禁错误码;⑦ §14 新增 Q11–Q14;⑧ 修正 §5.14 残留的错误数字(110 → 59) | 产品 | +| **V1.6** | 2026-09-24 | **补齐两处设计(回应评审提问)**:
**① 材料识别(原为空缺)**:新增 **FR-614 系列**,方案 B「规则为主 + VLM 兜底」——四路信号(槽位/文件名/形态/内容)、置信度分级、**VLM 仅三种触发条件**、**只提示不自动改位**、识别失败一律放行;VAL-12 由"一句要求"展开为可实现规则;`collect_file` 表补 6 个识别字段(含 `vlm_called` 用于成本核算)。完整逻辑见功能逻辑卷 §3.5-B。
**② 任务双层结构显性化**:新增 **FR-615** 并在 §5.3 点明「**填什么**(表单字段)+ **传什么**(材料清单)」两层独立配置、可互相条件联动、可存模板复用 —— 此前分散在「表单引擎」「材料采集」「表单定义表」「材料项定义表」四处,易被开发理解偏 | 产品 | +| **V1.7** | 2026-09-24 | **材料识别规则实测校正**(用 112 个真实文件做形态分析 + 32 个文件跑真实 OCR):
① **证伪**——初版形态规则写"身份证宽高比 ≈1.585",实测为 **0.732–1.016**,与一寸照片 0.714 重叠,**形态层无法区分二者**;② 架构改为**四层递进**(文件名 → 形态只分流派 → OCR 强词计分 → VLM);③ **证伪**"OCR 命中即判定"——实测关键词大量交叉命中,改为**只用强词 + 加权计分**;④ 新增 **FR-614e 一寸照片零文字判据**(实测 8/8 无文字);⑤ **VLM 触发率由 15% 重估为 2%~10%**;⑥ FR-614b 明确**通用词禁止入表** | 产品 | +| **V1.8** | 2026-09-28 | **按官方《考生线下报名信息导入模板》完整拆解校准**(含 2 个工作表、21 字段、颜色语义、下拉全集、9 条填表规则):
① **新增「评价计划」核心概念**(§5.3.1)——官方规则 04/05/06 明确"报考职业/工种/等级只能填当前评价计划内的",此前 PRD 无此实体,**导致这三项红色必填字段根本无法校验**;新增 FR-307~311 与 3 张库表(`sys_eval_plan` / `sys_occupation` / `sys_plan_occupation`);
② **认定类型由 2 种扩为 3 种**,新增**专项职业能力**(无工种、无等级、无补考);
③ 新增 **§5.4.1 预置表单模板**:21 字段全集 + 红/绿/黑三色语义 + 下拉选项全集;
④ 新增 **VAL-13~17** 校验规则(计划范围、工种级联、**补考科目数量约束**、在校生三项必填、工作年限数字);
⑤ 记录三个实现陷阱:**"无"是合法业务值**、专项要绕过三项校验、**表头行与工作表名都不固定**(实测 1 行 vs 6 行);
⑥ 向导第①步改为必选评价计划;术语表补 3 个概念 | 产品 | +| **V1.9** | 2026-09-28 | **确认计划来源为"认定机构下发文件",FR-311 提为 P0 并展开**:① 拆出 **FR-311a~e**(字段映射向导、导入体检、**差异对比**、导入留痕、Word/PDF 提取);② 新增 `sys_plan_import_log` 表,`sys_eval_plan` 补 `source_file_url` / `import_batch_no` 等留痕字段;③ **实测发现官方 Excel 模板的「报考职业」「报考工种」两列根本没有下拉(21 列中 13 列自由填写)** → 产品核心增量就是补上这个约束,已写入 §5.3.1 对比表;④ §14 新增 Q15/Q16(**需索取真实计划清单样本**);⑤ M2 里程碑 +1 周(新增计划导入模块),总工期 12.5–14.5 → **13.5–15.5 周** | 产品 | +| **V2.0** | 2026-09-28 | **实测校正:不存在"计划清单"文件,报考范围来自名单**(用户澄清 + 真实名单验证)
① **实测发现名单里「报考职业/工种/等级」三列老师已全部填好**(28/28,均为 车工/数控车工/四级中级工)→ **V1.9 的整套"文件导入"设计作废**;
② §5.3.1 重写为「**报考范围与名单预填**」:范围由**名单聚合**而来 + 一致性体检 + 追加名单告警,管理员可手工修正;
③ **删除 3 张库表**(`sys_eval_plan` / `sys_plan_occupation` / `sys_plan_import_log`),改为 `collect_task.plan_scope_json`;`sys_occupation_ref` 降级为 P2 拼写校验;
④ **更重要的发现:名单不是"名单",是预填好的报名信息表**——实测 21 个字段中 18 个已由老师填满(含学历/在校生/学校/专业/入学时间/联系电话)→ 新增 **FR-502a/502b「字段三类规则」**(🔒锁定 / ✏️预填可改 / 📄材料上传),**学生端几乎不用打字,只需核对+上传**;
⑤ §9.2 学生端交互重写;
⑥ 关闭 Q15/Q16/Q17,新增 Q18(预填字段能否由学生修改);
⑦ 工期回退:M2 3 周→2 周,总工期回到 **12.5–14.5 周** | 产品 | +| **V2.1** | 2026-09-28 | **数据库改用 PostgreSQL / Vastbase + 数据模型纠正**:
① **数据库定为 PG 系**(生产可跑 Vastbase,与现有 `schema-vastbase.sql` 技术栈一致,私有化可满足信创);新增 **§7.3 DDL 约定**表(IDENTITY / 小写标识符 / `deleted smallint` / `jsonb` / `COMMENT ON` 独立语句 / PG 无 ON UPDATE 等 11 项);
② **全部 19 个 SQL 块由 MySQL 语法转为 PG 语法**(17 张表 + 2 个 ALTER),并按用户确认改软删除字段 `del_flag` → **`deleted`**;
③ ⚠️ **数据模型纠正**:撤销"19 个字段塞 `prefilled_json`"的设计——官方模板结构稳定(两份真实文件列完全一致)、机构明写"请勿调整模板",属**强结构数据**,改为 **18 个正式列 + `extras_json` 逃生舱**;`collect_roster` 与 `collect_submission` **对称建列**(后者另加 `changed_fields` 用于 diff 留痕),并补 `idx_roster_scope` 支持按工种聚合统计;
④ §5.4 明确"动态表单"边界:**内置字段(固定列,可统计)+ 扩展字段(jsonb,仅展示导出)**,而非任意字段;
⑤ FR-502a 同步更新 | 产品 | +| **V2.2** | 2026-09-28 | **DDL 在真实 PostgreSQL 16.15 上执行验证通过(零错误)**:
① 用 Docker 起 PG 16,将文档全部 DDL 抽出后**真实执行**,并用真实名单数据跑通聚合查询;
② ⚠️ **实测发现并修掉 9 处命名冲突**——**PG 的索引名与约束名必须在 schema 内全局唯一**(MySQL 只需表内唯一,这是迁移最容易踩的坑):`uk_tenant_code`(2 处)、`idx_name`(2 处)、`idx_tenant`(3 处)、`idx_submission`(3 处),全部改为**表缩写前缀**形式;
③ §7.3 DDL 约定新增 **⭐ 命名唯一性**行 + **实测踩坑记录表**(含报错原文与修法);
④ 数据库定位由"PostgreSQL / Vastbase"明确为**纯 PostgreSQL**(不依赖 Vastbase 专有语法),并标注"已在 PG 16.15 实测通过";
⑤ 验证结果:19 张表 / 167 条列注释 / 19 条表注释 / 61 个索引;`collect_roster` 18 个业务列齐全;**报考范围聚合查询走 `Index Only Scan`**(索引设计生效);一致性体检能准确识别"少数派"报考项;`jsonb` 逃生舱读写正常 | 产品 | +| **V2.3** | 2026-09-28 | **明确班主任端滑动交互规则(原型审查后决策)**:§9.4 原写"支持滑动即通过(左滑驳回 / 右滑通过)",改为 **只支持「右滑通过」、不支持左滑驳回**,且必须二次确认。
理由:通过是正向动作且可撤回,适合低阻力滑动;驳回是负向动作、需选原因、代价高(学生重传 + 班主任重审),**误触后果不对称**,必须点按钮完成 | 产品 | + +--- + +## 目录 + +1. [项目背景与目标](#1-项目背景与目标) +2. [术语表](#2-术语表) +3. [用户角色与权限矩阵](#3-用户角色与权限矩阵) +4. [业务流程](#4-业务流程) +5. [功能需求](#5-功能需求) +6. [核心规则详述(规则引擎)](#6-核心规则详述规则引擎) +7. [数据模型与数据库设计](#7-数据模型与数据库设计) +8. [接口清单](#8-接口清单) +9. [移动端页面与交互](#9-移动端页面与交互) +10. [非功能需求](#10-非功能需求) +11. [实施计划与里程碑](#11-实施计划与里程碑) +12. [验收标准](#12-验收标准) +13. [风险与对策](#13-风险与对策) +14. [待确认事项](#14-待确认事项) + +--- + +--- + +## 一期范围声明(2026-09-28 客户确认) + +> **一期只做「学校收集 + 自主认定」这一条流程。** +> 社会收集、社会评价、专项职业能力 **全部后置**; +> 但 **数据结构与枚举保留不启用**——二期直接开,不需要 DB 迁移。 + +### 范围切分 + +| 维度 | 字段 | 一期 | 二期后置 | 为什么这么切 | +|---|---|---|---|---| +| **收集模式** | `collect_mode` | `1` 学校收集 | `2` 社会收集 | 一期只做一条审核流(学生→班主任→管理员),少一条分支、少一套测试 | +| **认定类型** | `cert_type` | `1` 自主认定 | `2` 社会评价
`3` 专项职业能力 | 后两者会引入**两类零实测样本的材料**,识别规则目前是推测 | + +### 一期支持的材料(4 类,全部有 112 个真实文件实测) + +| 材料编码 | 名称 | 规范文件名示例 | 压缩档位 | 实测 | +|---|---|---|---|:--:| +| `PHOTO1` | 一寸照片 | `张三-3601...-一寸照片.jpg` | P1 证件照 | ✅ | +| `IDCARD` | 身份证 | `张三-3601...-身份证.jpg` | P2 证件翻拍 | ✅ | +| `SCHOOL_ROLL` | 学籍表 | `张三-3601...-学籍表.jpg` | P4 截图 | ✅ | +| `WORK_PROOF` | 工作证明 | `张三-3601...-工作证明.jpg` | P3 扫描件 | ✅ | + +### 二期才做的材料(**零实测样本**) + +| 材料编码 | 名称 | 出现在 | 现状 | +|---|---|---|---| +| `XJW_BA` | 学信网备案表 | 社会评价 | 强特征词是从**官方模板的文字要求**里抄的,**未见一张真实文件** | +| `OTHER_PROOF` | 其它佐证材料 | 社会评价 / 专项 | 含毕业证/社保单/职称/技能证书**四类版式完全不同**的东西,**可能不该做自动识别** | + +### 后置清单(二期启动前要准备的事) + +| # | 事项 | 依赖 | +|:--:|---|---| +| 1 | 索取「社会评价」批次原始材料样本(≥10 份,须含备案表与各类佐证) | 客户提供(D4) | +| 2 | 标定 `XJW_BA` 的强特征词与形态阈值 | ↑ | +| 3 | 决定 `OTHER_PROOF` 是否做自动识别,还是改为纯手动槽位 | ↑ | +| 4 | 实现社会收集审核流(跳过班主任初审,直达管理员) | 无 | +| 5 | 实现专项职业能力的字段隐藏(工种/等级/补考三项)与校验绕过 | 无 | +| 6 | 社会收集的名单来源流程(报名点/机构维护预报名名单) | 客户确认(B2) | + +### 对现有设计的影响:几乎没有 + +**因为这三项本来就是配置驱动的**: + +| 后置项 | 现有实现方式 | 代码改动量 | +|---|---|---| +| 社会收集跳过初审 | `audit_config.needClassAudit` 是一个开关 | 0(只是一个配置值) | +| 专项职业能力的字段隐藏 | 走 `visibleWhen` 条件显示规则 | 0(配置一条规则) | +| 材料清单差异 | 材料项定义(`collect_material_def`) | 0(配置差异) | + +> ⇒ **一期收窄省下的主要是「测试与联调成本」和「一个外部依赖(D4)」,而不是代码量。** +> +> **工期影响**:**−5 人日**(392 → 387)。**关键路径不变,工期仍为 15 周。** + +### 一期不做的验证项(从测试范围移除) + +| 移除项 | 原因 | +|---|---| +| 主流程 B(社会收集,3 步) | 二期 | +| `XJW_BA` / `OTHER_PROOF` 的识别断言 | 零样本,无法定基线 | +| 专项职业能力的字段隐藏与 VAL-14/15 绕过用例 | 二期 | +| 社会收集的跳过初审用例 | 二期 | + +> ⚠️ **二期启用时必须要补的回归断言**:`XJW_BA` / `OTHER_PROOF` 的识别准确率、社会收集审核流、专项职业能力字段隐藏。 +> 这些都已在本文档与《06-验收测试清单》中保留描述,只是标注为二期。 + +--- + +## 1. 项目背景与目标 + +### 1.1 业务背景 + +职业技能等级认定机构(及合作院校)在每批次报名时,需要按认定机构下发的《申报材料上传要求》收集考生材料:线下报名信息表、一寸照片、身份证正反扫描件、学籍表 / 学信网备案表、工作证明 / 申报表、其它佐证材料等。 + +当前流程完全依赖"微信群发通知 → 学生各自命名 → 班主任手工核对 → 打包上传",存在三个结构性问题: + +- **命名靠人**:每个学生理解不同,命名五花八门; +- **校验靠眼**:身份证号、姓名一致性只能人工比对,易漏; +- **体积靠猜**:认定机构要求单文件 ≤1M,学生直接传手机原图(十几 MB)。 + +### 1.2 真实数据实测(本项目立项的直接依据) + +以真实样本批次 `24中技数控1班`(28 名学生 × 4 类材料 = 112 个文件)为样本,实测结果如下: + +**① 命名规范违反率** + +统计口径:文件名须严格等于 `姓名-证件号-材料名.后缀`,且姓名、证件号须与名册一致。 + +| 问题类型 | 实例 | 数量 | +|---|---|---| +| 双扩展名 `.jpg.jpg` | `于豪-360103200805186619-工作证明.jpg.jpg` | **56**(工作证明 28、身份证 28) | +| 缺少分隔符 | `曾伊豪-361029200907295110身份证.jpg.jpg`、`徐程宇360981200901292334-身份证.jpg.jpg` | 2 | +| 证件号末位小写 `x` | `朱彬宇-36012420080712697x-身份证.jpg.jpg` | 2 | +| 证件号与名册不符 | 于豪、杨晨、陈子文 | 3 | +| 证件号残缺(仅 17 位) | 曹文锋、黎思明 | 2 | +| 姓名与名册不符 | `扬晨-360825200904303215-一寸照片.jpg`(名册为"杨**晨**") | 1 | +| 目录 / 模板命名错误 | 目录名"24中技数**车**1班"、模板名"24中技数车1班**班**",规范名为"24中技数**控**1班" | — | + +> **命名合规率 53/112 = 47.3%,需改名文件 59 个(52.7%)。** +> ✅ **勘误**:V1.0 初稿此处写"110 个需改名、违规率 98.2%",系把"存在任何问题"与"需要改名"混同所致,现已按严格口径复核修正。 +> 📌 **初稿遗漏的重要事实**:**学籍表 28 个文件全部合规**,一寸照片 25 个合规 —— 并非"全员乱命名",而是**特定材料(工作证明、身份证)批量出错**,这恰恰是规则引擎价值最集中的地方。 + +**② 身份证号一致性错误(致命,直接导致报名失败)** + +| 姓名 | 材料文件名中的证件号 | 名册(Excel 模板)中的证件号 | 错误性质 | 校验位裁决 | +|---|---|---|---|---| +| 陈子文 | 一寸照片 `360122200902044232` | `36010520090817121X` | **串档,照片是别人的** | 两个号码**均通过** → 确为两个不同的人 | +| 于豪 | 身份证 `360103200805166169` | `360103200805186619` | 末 6 位倒置 | 文件**失败**/名册通过 → **文件名错** | +| 杨晨 | 身份证 `360825200904303218` | `360825200904303215` | 末位错误 | 文件**失败**/名册通过 → **文件名错** | +| 曹文锋 | 一寸照片 `36012120081211239`(17 位) | `360121200812111239` | 少 1 位 | 位数不足,无法校验 | +| 黎思明 | 身份证 `36100200907283419`(17 位) | `361002200907283419` | 少 1 位 | 位数不足,无法校验 | + +> 结论:28 人中 **5 人(17.9%)** 存在证件号错误,其中 1 例为材料串档。 + +> 🔑 **关键发现:ISO 7064 校验位可以直接"裁决"争议,无需人工判断。** +> - 名册 28 人的证件号校验位 **全部通过** → 名册可信,可作为权威基准; +> - 于豪、杨晨的文件名证件号 **校验位失败** → 确定性判定为文件名错误,且**可自动改正**(无需询问任何人); +> - 陈子文的两个号码 **校验位均通过** → 说明是两个真实存在的人 → **串档实锤,而非打字错误**,必须人工介入而非自动修正。 +> +> 这三种情形的**处理策略完全不同**(可自动修复 / 可自动修复 / 必须人工),而校验位算法仅凭数字本身就能区分它们。这是本产品最核心的校验资产,必须在 §6.2 完整实现。 + +**③ 文件体积与图像属性(实测,非估算)** + +| 材料类型 | 文件数 | 超 1M | 最大单文件 | 合计 | 实测分辨率区间 | 格式 | +|---|---|---|---|---|---|---| +| 工作证明 | 28 | **28(100%)** | 17.2 MB | **444 MB** | 9984×14442 ~ 11050×16170 | JPEG | +| 身份证 | 28 | 3 | 5.4 MB | 约 20 MB | 558×745 ~ 2216×2796 | JPEG 27、PNG 1 | +| 一寸照片 | 28 | 0 | 0.44 MB | 约 10 MB | 984×1378(27 张完全一致)/ 1553×2175 | JPEG | +| 学籍表 | 28 | 0 | 0.04 MB | 约 1 MB | 约 1025×590 | PNG | +| **班级打包 `.rar`** | 1 | — | **436 MB** | — | — | RAR | +| **合计(原始)** | 112 | **31** | — | **约 475 MB** | — | — | + +**④ 三项"反直觉"实测发现(直接推翻初稿的参数假设,必须写入设计)** + +| # | 发现 | 实测证据 | 对设计的影响 | +|---|---|---|---| +| **D1** | **工作证明不是"手机拍照",是超高 DPI 的 A4 扫描件** | 10517×15445 ≈ 1.6 亿像素,换算约 **1272 dpi**(A4@300dpi 仅 2480×3508) | ⚠️ 初稿"C 档:长边压到 1600px"= 把 1272dpi 降到约 195dpi,**降质 6.5 倍**。扫描件必须**按纸张规格(A4)+ 目标 DPI** 压缩,而非按长边。
✅ **后续实测(见下方量化结论)**:按 A4@300dpi/q85 处理,28/28 达标且清晰度无损,问题彻底解决 | +| **D2** | **学籍表是 16:9 屏幕截图,不是扫描件** | 28 张尺寸高度一致(1023–1030 × 583–607),比例 ≈1.74,PNG 格式 40KB | ⚠️ 初稿"扫描件档最小长边 1400px"会把这 28 张**全部误判为分辨率不足**。截图类材料必须单独设档,且**不宜转 JPG**(文字边缘会发虚) | +| **D3** | **体积小 ≠ 合格,存在"过度压缩"风险** | 杨晨身份证仅 **558×745 / 0.08MB**(同批其他人 1279×1706) | ✅ 验证了 VAL-08(可读性校验)的必要性;且说明**必须同时设"下限"**,不能只设 1M 上限 | +| **D4** | **一寸照片全部 984×1378(5:7 = 标准一寸比例)** | 27/28 张尺寸完全相同,显然来自同一照相馆批量产出 | ✅ 规格化(FR-608)可行且收益明确:984×1378 → 295×413 @300dpi,体积可再降 70% 且更符合规范 | + +> **D1 的量化结论(已用 28 张真实扫描件实测,推翻了本文原估算)** +> +> A4 等比缩放 + JPEG 编码,完整实测结果: +> +> | 输出参数 | 平均体积 | 最大体积 | 达标情况 | +> |---|---|---|---| +> | 300dpi / q90 | 958 KB | 964 KB | ✅ 28/28(余量仅 6%) | +> | **300dpi / q85** | **806 KB** | **831 KB** | ✅ **28/28 ← 推荐默认参数** | +> | 300dpi / q80 | 718 KB | 740 KB | ✅ 28/28 | +> | 300dpi / q75 | 653 KB | 673 KB | ✅ 28/28 | +> | 400dpi / q80 | 1070 KB | 1078 KB | ❌ 全部超标 | +> | 500dpi / q80 | 1452 KB | 1468 KB | ❌ 全部超标 | +> +> ⇒ ✅ **"≤1M"与"清晰度"根本不冲突。** 300dpi 是文档扫描的标准清晰度,在该参数下 **28/28 全部达标且留有余量**,无需牺牲任何清晰度。 +> ⇒ ✅ **更正**:本文曾推算"要压进 1M 只能取 150–170dpi",该估算**错误**——文档扫描件以白底为主,压缩率远高于照片。实测表明**一张都不用降质**。 +> ⇒ ✅ 风险 R1(清晰度不被认可)相应**从"高"降为"低"**:输出是标准 300dpi 扫描件,认定机构没有任何理由拒收。 +> ⇒ ✅ 整班 444MB → 22MB,**压缩率 95%**,单张处理耗时约 2 秒(PIL 基准,换用 libvips 预期更快)。 + +**⑤ 工作量口径(修正后)** + +| 口径 | 数量 | 占比 | +|---|---|---| +| 文件总数 | 112 | 100% | +| 命名不合规(需改名) | 59 | 52.7% | +| 体积超标(需压缩) | 31 | 27.7% | +| **需处理文件并集** | **59** | **52.7%** | +| ↳ 其中可全自动完成 | 56 | 50.0% | +| ↳ 其中需人工确认(串档 / 证件号残缺 / 分辨率不足) | 3 | 2.7% | +| 完全无需处理(已合规) | 53 | 47.3% | + +> ⚠️ **勘误**:本节曾写"需处理并集 62 个",属重复计数——**体积超标的 31 个文件全部同时存在命名问题**(工作证明与身份证的 28+28 份双扩展名),故并集就是命名的 59 个,而非 59+31−28=62。 +> +> 即:**112 个文件中 59 个必须处理,其中 56 个可全自动完成,仅 3 个需人工确认;其余 53 个已完全合规。** 这就是"机器先跑一遍、人只看 3 个"的价值模型,也是本产品的验收基线。 + +**⑥ 人工成本** + +单班 112 个文件,逐个检查命名、体积、证件号一致性,保守估计 **2–4 小时**;一所学校一学期通常 20–40 个班 ⇒ **40–160 人时/校/学期**。 + +### 1.3 产品定位 + +> **不是网盘,是"带规则引擎的资料收集与合规加工平台"。** + +核心价值主张:**让规范在收集的那一刻就自动发生,而不是事后返工。** + +``` +老师建任务 → 学生手机填报(上传即自动改名/压缩/校验) + → 班主任一键初审 → 管理员汇总终审 → 一键导出标准包上报 +``` + +### 1.4 产品目标(可量化) + +| 指标 | 现状(实测基线) | 目标 | +|---|---|---| +| 交付时文件名合规率 | **47.3%** | **100%**(系统自动生成) | +| 单文件 ≤1M 达标率 | **工作证明 0%**(全批 72.3%) | **100%** | +| 证件号一致性错误拦截率 | 0%(无拦截,5 例全部漏过) | **100%**(校验位可确定性裁决 3 例,另 2 例位数异常可检出) | +| 全量材料的**人工复核量** | 112 个文件全看 | **≤3 个**(仅串档/低清/姓名存疑项) | +| 系统自动处理覆盖率 | 0% | **≥95%**(59/62) | +| 学校管理员汇总导出耗时 | 30–60 分钟 | **≤2 分钟** | +| 端到端周期(任务发布→可上报) | 数天(多轮返工) | **≤24 小时** | + +> ⚠️ **指标口径澄清**:初稿把"班主任单班处理时长 ≤15 分钟"与端到端周期混为一谈。二者必须分开—— +> - **班主任操作时长**(只看 3 个异常 + 一键批量通过):目标 **≤15 分钟**; +> - **学生填报**是异步分散行为,不计入班主任时长,只统计"任务发布到收齐"的周期。 + +### 1.5 非目标(本期不做,架构预留) + +- 不做在线缴费 / 报名费支付; +- 不做与认定机构业务系统的实时数据对接(预留开放 API + 导出包); +- 不做视频材料采集(本期支持图片 / PDF / Office 文档); +- 不做证书制作与发放。 + +--- + +## 2. 术语表 + +| 术语 | 定义 | +|---|---| +| **租户(Tenant)** | 一套独立数据空间的客户单位,通常是"一所学校"或"一个认定机构/报名点" | +| **收集任务(Task)** | 一次完整的材料收集活动,如"2026年9月机电专业认定报名材料收集" | +| **报考范围(Plan Scope)** | ⚠️ 本期允许报考的**职业 × 工种 × 等级**集合。**由名单聚合而来**(老师在名单里已填好),管理员可手工修正。存于 `collect_task.plan_scope_json` | +| **专项职业能力** | ⚠️ 第三种认定类型(另有自主认定、社会评价)。**没有工种、没有等级、没有补考** | +| **职业 → 工种 → 等级** | 三级关系。"车工"是职业,"数控车工"是其下的工种,等级为五级/初级工 ~ 一级/高级技师。**某职业可能没有工种**(此时填"无") | +| **收集模式(Mode)** | `SCHOOL`=学校收集(有班主任层);`SOCIAL`=社会收集(无班主任层,直达管理员) | +| **名册(Roster)** | 任务下的**准入名单**,由 Excel 导入生成。「姓名」与「身份证号」为必填列,既是校验基准,也是填报的准入门槛(详见"门禁") | +| **门禁(Gate)** | 填报人进入填报页前必须通过的身份核验:填写「姓名 + 身份证号」与名册匹配,命中才放行。学校收集与社会收集**都启用** | +| **表单(Form Schema)** | 任务内需要填报的字段定义,JSON 存储,可版本化 | +| **材料项(Material)** | 需要上传的文件类型定义,如"一寸照片""身份证""学籍表" | +| **填报单(Submission)** | 一个填报人针对一个任务产生的数据集合(表单数据 + 材料文件) | +| **规范名(Standard Name)** | 由命名规则引擎根据模板生成的最终文件名 | +| **初审 / 终审** | 班主任审核 / 管理员审核 | +| **汇总包(Export Package)** | 按目录规范打包后可直接上报的文件集合 | +| **历史数据整理器** | 对已存在的"乱文件夹/压缩包"做批量规范化与体检的独立工具 | + +--- + +## 3. 用户角色与权限矩阵 + +### 3.1 角色定义 + +| 角色 | 使用端 | 核心诉求 | +|---|---|---| +| **平台运营管理员** | PC | 租户开通、套餐计费、全局监控、内容风控 | +| **学校管理员 / 机构管理员** | PC(主)+ 手机(次) | 建任务、管名册、终审、汇总导出、上报 | +| **班主任 / 收集负责人** | PC + 手机 | 催报、初审、批量通过、退回修改 | +| **填报人(学生)** | 微信小程序 | 3 分钟填完、少填字、从聊天记录/相册选图即传 | +| **社会填报人** | 微信小程序 / H5 | 自主填报、无班主任环节 | +| **上级接收方(只读)** | PC / 免登录链接 | 查看或下载汇总包(可选) | + +### 3.2 权限矩阵 + +| 功能 | 平台运营 | 学校管理员 | 班主任 | 填报人 | +|---|:--:|:--:|:--:|:--:| +| 租户 / 套餐管理 | ✅ | — | — | — | +| 组织架构 / 账号管理 | ✅ | ✅ | — | — | +| 创建 / 配置收集任务 | — | ✅ | — | — | +| 任务克隆 / 复用 | — | ✅ | — | — | +| 名册导入 / 修改 | — | ✅ | 🔸 本班 | — | +| 催报 / 发送通知 | ✅ | ✅ | 🔸 本班 | — | +| 查看填报进度看板 | ✅ | ✅ | 🔸 本班 | 🔸 本人 | +| 初审(通过 / 驳回) | — | ✅ | ✅ | — | +| 终审(通过 / 驳回) | — | ✅ | — | — | +| 教师代传材料 | — | ✅ | ✅ | — | +| 汇总导出 / 下载 | — | ✅ | — | — | +| 数据脱敏导出 | — | 🔸 需二次确认 | — | — | +| 全量操作日志 | ✅ | 🔸 本租户 | — | — | +| 填报 / 修改 / 撤回 | — | — | — | ✅(本人) | + +--- + +## 4. 业务流程 + +### 4.1 学校收集主流程 + +``` +┌────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 管理员 │ │ 系统 │ │ 填报人 │ │ 班主任 │ │ 管理员 │ +│ 建任务 │──▶│ 下发 │──▶│ 门禁核验 │──▶│ 初审 │──▶│ 终审 │ +│ 导名单 │ │ 通知 │ │ 填报上传 │ │ 通过/驳回│ │ 汇总导出 │ +└────────┘ └────────┘ └──────────┘ └────┬─────┘ └──────────┘ + │驳回 + ┌────────────▼────────────┐ + │ 通知填报人 → 修改重传 → │ + │ 回到班主任待办 │ + └─────────────────────────┘ +``` + +**关键设计点** + +1. **【门禁】必须先过名单核验**:填报人扫码 → 微信授权 → **填写「姓名 + 身份证号」与名单匹配** → 命中才进入填报页;**未命中只提示、不放行**(详见 §5.5、功能逻辑卷 §3.1)。 + > 🔒 学校收集与社会收集**都必须先导入名单**,不存在"自由填报"。 +2. 匹配成功后**回填并锁定**姓名、身份证号、班级、专业等信息为只读,避免用户改动后与名单不一致。 +3. 上传文件时,**客户端只做方向校正与尺寸限幅**,服务端做规范化与压缩。此举可把 475MB 的班级数据降到约 37MB,**带宽成本下降 92%**。 + > ⚠️ 客户端压缩参数必须与 §6.3 的档位一致(**不可对所有图片一刀切"长边 1600px"**——实测工作证明是 A4 扫描件、学籍表是截图,二者按长边处理都会出错)。 +4. 每个材料项上传后立即返回**校验结果**(命名是否已规范、体积是否达标、OCR 证件号是否与名单一致),不达标就地提示,不让错误流到班主任。 +5. 班主任端提供**批量初审**:一屏查看全班 40 人的红黄绿状态,绿色一键批量通过,仅处理红色异常。 + +### 4.2 社会收集主流程 + +``` +管理员建任务(Mode=SOCIAL)→ 导入名单(必填)→ 生成任务二维码/链接 +→ 社会人员扫码 → 门禁核验(姓名 + 身份证号)→ 命中才可填报 +→ 系统校验 → 管理员终审 → 汇总导出 +``` + +**与学校收集的差异** + +| 维度 | 学校收集 | 社会收集 | +|---|---|---| +| 名单 | **必填(含姓名 + 身份证号)** | **同样必填(含姓名 + 身份证号)** | +| 门禁核验 | **姓名 + 身份证号匹配名单** | **完全一致** | +| 归属 | 按班级/院系 | 按报名点 / 区域 / 批次 | +| 班主任层 | 有 | 无(提交后直达管理员终审) | +| 材料清单 | 学籍表、工作证明(学校版) | 学信网备案表、其它佐证材料(社会版,见 6.0) | +| 校验强度 | 强(与名单比对) | 强(同样与名单比对) | +| 名单来源 | 学校教务导出 | 预报名名单 / 报名点收集 / 机构提供(⚠️ 需确保来源稳定) | + +> ⚠️ **本版变更**:初稿写"社会收集名册可选、允许自由填报",现已废除。**两种模式在"名单 + 门禁"上完全一致,唯一差异是审核流是否经过班主任层。** + +### 4.3 状态机 + +**填报单状态(Submission)** + +``` +DRAFT ──submit──▶ PENDING_CLASS ──班主任通过──▶ PENDING_ADMIN ──管理员通过──▶ APPROVED + ▲ │ │ │ + │ 班主任驳回 管理员驳回 归档 + │ ▼ ▼ ▼ + └────编辑──── REJECTED ◀───────────────────────────┘ ARCHIVED + │ + └── resubmit ──▶ PENDING_CLASS / PENDING_ADMIN +``` + +- 学校收集:`DRAFT → PENDING_CLASS → PENDING_ADMIN → APPROVED → ARCHIVED` +- 社会收集:`DRAFT → PENDING_ADMIN → APPROVED → ARCHIVED`(跳过 `PENDING_CLASS`) +- 驳回后进入 `REJECTED`,填报人收到通知,可修改后重新提交(`reject_count` 累加,≥3 次自动升级通知管理员) +- 终审通过后默认可"更正提交"1 次(窗口期内),过期需管理员解锁 + +**任务状态(Task)** + +`DRAFT(草稿)→ PUBLISHED(收集中)→ CLOSED(已截止)→ ARCHIVED(已归档)` +支持 `PAUSED(暂停)`。 + +### 4.4 异常分支处理 + +| 场景 | 处理策略 | +|---|---| +| 逾期未提交 | 截止前 7/3/1 天自动催报;截止后管理员可"延长截止时间"或"点名催报" | +| 学生不在名单 | **门禁匹配失败 → 统一提示"没有找到你的信息",不给填报**;老师需在后台把此人加入名单后方可填报 | +| 一人多班 / 转班 | 名册以学号+证件号唯一;管理员可改归属 | +| 材料被驳回次数过多 | ≥3 次自动提醒管理员介入,可开启"班主任代传" | +| 学生不会操作 | 班主任/管理员可代传(代传记录标注操作人) | +| 重复上传同一材料 | 保留版本历史,取最新有效版本,旧版本进入回收站(30 天) | +| 上传中断网 | 支持断点续传与草稿自动保存(每 10 秒本地缓存) | + +--- + +## 5. 功能需求 + +### 5.1 租户与组织管理(FR-100) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-101 | 租户开通、停用、套餐绑定、到期提醒 | P0 | +| FR-102 | 租户级配置:Logo、名称、默认压缩参数、文件保留策略 | P0 | +| FR-103 | 组织树管理(学校 → 院系/专业 → 班级),支持批量导入 | P0 | +| FR-104 | 租户数据隔离(逻辑隔离 `tenant_id`,大客户支持独立库) | P0 | +| FR-105 | 微信小程序与租户绑定(统一小程序 + 扫码带参 `scene` 识别租户) | P0 | + +### 5.2 账号与角色(FR-200) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-201 | PC 端账号密码登录(图形验证码 + 登录失败锁定) | P0 | +| FR-202 | 小程序端微信授权登录(`code2session`)+ 手机号快捷绑定 | P0 | +| FR-203 | 账号批量导入(Excel),自动生成初始密码并短信/微信通知 | P0 | +| FR-204 | 一个班主任可管理多个班级;一个班级可配多个班主任(主/副) | P0 | +| FR-205 | 角色自定义与数据权限(本人 / 本班 / 本院系 / 全租户) | P0 | +| FR-206 | 密码重置、强制改密、离职停用 | P1 | + +### 5.3 收集任务管理(FR-300) + +#### 5.3.1 报考范围与名单预填(⚠️ 按实际情况校正) + +**事实澄清(经真实名单实测)** + +上一版设计假设"认定机构会下发一份计划清单文件",**该假设不成立**。实测真实名单 `24中技数控1班` 后确认:**报考职业/工种/等级这三列,是在导入名单时就已经由老师确定好的**。 + +| 字段 | 已填 | 实际取值 | +|---|:--:|---| +| 报考职业 | **28/28** | 车工 | +| 报考工种 | **28/28** | 数控车工 | +| 报考等级 | **28/28** | 四级/中级工 | + +⇒ **不存在独立的"计划清单"文件,报考范围就藏在名单里。** + +**设计改为:报考范围从名单中聚合,而不是从外部文件导入** + +``` +名单导入 + ↓ 自动聚合本期报考范围(职业 × 工种 × 等级的集合) + ↓ 【一致性体检】 + · 是否存在同一职业下互相矛盾的工种 + · 是否等级不符合该工种的常规设置 + · 是否有人漏填了这三项(红色必填) + · 是否与国标职业字典拼写不符(如"数控车床工" vs "数控车工") + ↓ 生成任务级「本期报考范围」→ 管理员可确认/手工修正 + ↓ 供学生端只读展示 + 导出写回 +``` + +**本期报考范围的四个用途** + +| # | 用途 | 说明 | +|---|---|---| +| 1 | **名单内部一致性检查** | 40 人里若 38 人报"数控车工"、2 人报"车工",很可能是填错,需提醒 | +| 2 | **追加名单时报警** | 中途补录的新人若报了本期没有的职业/工种,立即提示"新增了范围外的报考项" | +| 3 | **学生端只读展示** | 学生扫码核验后直接看到"你报考:车工 / 数控车工 / 四级中级工",不用自己选 | +| 4 | **导出写回** | 汇总导出时原样写回 Excel 模板的 G/H/I 列 | + +**保留手工修正**:管理员可编辑范围(应对临时增补、机构特殊要求等异常情况)。 + +**认定类型扩展为 3 种** + +| 类型 | 工种 | 等级 | 补考 | 面向谁 | 关键材料差异 | +|---|:--:|:--:|:--:|---|---| +| `1` **自主认定** | 可有 | 有 | 有 | 本单位职工 / **本校学生** | 要**学籍表** | +| `2` **社会评价** | 可有 | 有 | 有 | **社会人员**(非本校、非本单位) | 没有学籍,改要**学信网备案表** + 其它佐证(毕业证/社保权益单/职称证明) | +| `3` **专项职业能力** | **无** | **无** | **无** | 专项能力考核 | 规则 05/06/07 明确排除工种/等级/补考三项 | + +> ⚠️ **「社会评价」≠「社会收集」,这两个词只差一个字,含义完全不同,务必区分** +> +> | 概念 | 字段 | 取值 | 决定什么 | +> |---|---|---|---| +> | **认定类型** | `cert_type` | 自主认定 / **社会评价** / 专项职业能力 | 这次认定**是什么性质** → 决定**填什么材料** | +> | **收集模式** | `collect_mode` | 学校收集 / **社会收集** | 这次**怎么收** → 决定**谁审核**(有无班主任初审层) | +> +> 二者**可以任意组合**: +> - 学校收集 + 自主认定 = 最常见(院校统一组织本校学生) +> - 学校收集 + **社会评价** = 院校受委托组织社会人员报名(有班主任层,但材料是社会人员那套) +> - 社会收集 + 社会评价 = 报名点零散收集社会人员 +> - 任意 + 专项职业能力 = 无工种/等级/补考 +> +> **对材料识别的影响**:`社会评价` 类型的任务,其材料集合里**没有学籍表,而有学信网备案表 + 其它佐证材料**。 +> 因此**材料识别引擎的强特征词表必须在该类批次的数据上另行标定**——不能只用院校批次(学籍表为主)的样本推断。 + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-307 | **报考范围自动聚合**:名单导入后自动提取"职业 × 工种 × 等级"集合 | **P0** | +| FR-308 | **名单一致性体检**:职业/工种矛盾、等级异常、漏填、拼写不符 → 输出体检报告 | **P0** | +| FR-309 | 报考范围支持管理员**手工修正**,并留痕(谁改的、改前改后) | **P0** | +| FR-310 | **专项职业能力**类型支持:自动隐藏工种、等级、补考相关字段 | ~~P0~~ **二期**
⚠️ 一期只做自主认定(见「一期范围声明」) | +| FR-311 | 追加名单时,若出现**范围外的新报考项**,必须告警并由管理员确认后才接受 | **P0** | +| FR-312 | 可选:接入国标职业字典做**拼写校验与联想建议**(仅辅助,不作强约束) | P2 | + +> ✅ **已作废**:上一版的"计划文件导入(FR-311a~e)"、"字段映射向导"、"差异对比"、以及 `sys_eval_plan` / `sys_plan_occupation` / `sys_plan_import_log` 三张表——**因不存在该文件,全部删除**。 + +--- + +**更重要的发现:名单不是"名单",而是一张预填好的报名信息表** + +实测发现,老师交上来的名单里,**21 个字段几乎全部已填**: + +| 字段 | 已填 | 取值 | +|---|:--:|---| +| 学历 | 28/28 | 技工学校 | +| 是否补考 | 28/28 | 否 | +| 是否在校生 | 28/28 | 是 | +| 在读学校 | 28/28 | 江西省机电技师学院 | +| 所学专业 | 28/28 | 数控加工(数控车工) | +| 入学时间 | 28/28 | 2024-09-01 | +| 工作单位 | 28/28 | **无** ← 印证"无"是合法值 | +| 联系电话 | 28/28 | 28 个不同号码 | +| 本职业工作年限 / 已获证书×3 | 0/28 | 全空 | + +> ⇒ **学生端几乎不需要打字**。老师已经把所有信息填好了,学生上来只需**核对 + 上传材料**。 +> 这正好命中最初的产品目标:"3 分钟填完、少填字"。 + +**字段分三类处理(核心规则)** + +| 类别 | 字段 | 学生端行为 | +|---|---|---| +| 🔒 **锁定** | 姓名、身份证号、**报考职业、报考工种、报考等级** | **只读不可改**。名单为空则**阻断任务发布**(这三项必须由老师确定)。学生要改只能找老师 | +| ✏️ **预填可改** | 学历、是否在校生、在读学校、所学专业、入学时间、工作单位、联系电话、是否补考、补考科目、本职业工作年限、已获证书相关 | 名单有值 → **预填并标注"老师已填,请核对"**,学生可直接修改(修改留痕);名单为空 → 学生必填 | +| 📄 **材料** | 一寸照片、身份证、学籍表、工作证明等 | 学生上传(唯一的"重活") | + +> 💡 **为什么"报考职业/工种/等级"必须锁定**:这三项是老师根据机构要求和报考策略定的,学生不懂也不该改。让它们出现在学生端可编辑,只会制造错误。 +> 💡 **为什么其余字段允许学生改**:老师填的电话可能有误、学生可能转专业、入学时间可能记错——**允许改正比强制回退给老师更高效**,改动留痕即可。 + +**这带来的设计简化** + +| 项 | 上一版(已作废) | 本版 | +|---|---|---| +| 报考范围来源 | 外部计划文件导入 | **名单聚合** | +| 需要的新表 | 4 张(计划、字典、范围、导入日志) | **0 张**(存任务级 JSON) | +| 学生端填写量 | 21 个字段都可能要填 | **几乎为 0**(核对为主) | +| M2 工期 | 3 周 | **2 周**(回到原估) | + +> 💡 **这就是产品的核心价值**:官方模板对最易错的「报考职业/工种」**束手无策**(只能靠说明文档和人的自觉),而我们的系统从计划生成级联下拉,**从源头不可能填错**。 +> 而这三项恰恰是模板里的**红色必填项**——填错的后果是整批数据被打回重做。 + +**任务创建方式:向导式 7 步**(同时支持"复制已有任务"一键克隆,这是高频需求) + +> 🧩 **先理解任务的结构**:一个收集任务 = **两层独立配置** +> | 层 | 回答的问题 | 存哪 | 配置入口 | 典型内容 | +> |---|---|---|---|---| +> | **① 填什么** | 学生要填哪些字 | `collect_form_schema` | 向导 ④ 表单 | 姓名、身份证号、学历、报考工种… | +> | **② 传什么** | 学生要交哪些文件 | `collect_material_def` | 向导 ⑤ 材料 | 一寸照片、身份证、学籍表、工作证明… | +> +> 两层**可互相条件联动**(FR-402):例如"是否在校生=否" → 隐藏"学籍表"材料、显示"学信网备案表"。 +> **每个任务的这两层都可以完全不同**,且能存成规则模板(FR-306)跨学期复用。 + +| 步骤 | 内容 | 校验 | +|---|---|---| +| ① 基本信息 | 任务名称、收集模式(学校/社会)、**所属评价计划**、批次号、起止时间 | 批次号租户内唯一;结束时间 > 开始时间;**必须选择评价计划** | +| ② 覆盖班级 | 勾选本次收集涉及哪些班级(社会模式可挂报名点/区域) | 学校模式至少 1 个班 | +| ③ 名单 | **必填,两种模式都要**;Excel 导入或手动添加;字段映射预览 | **必须含「姓名」「身份证号」列且人数 > 0**,否则阻断发布 | +| ④ 表单 | 选择表单模板或自定义字段(见 5.4) | 至少 1 个字段 | +| ⑤ 材料 | 配置材料清单、命名规则、体积与格式要求(见 5.6) | 至少 1 项材料;命名模板必须含姓名/证件号/材料名 | +| ⑥ 审核流 | 是否启用班主任层、是否允许多人并审、驳回次数上限、超时小时数 | 学校模式默认启用;社会模式默认关闭 | +| ⑦ 预览发布 | 命名规则试跑(粘贴样本文件名看效果)、生成填报二维码/链接、预览填报端 | **未试跑确认命名规则不允许发布** | + +> ⚠️ 步骤 ③(名单)是**硬前置**:没有名单就没有准入门槛,任务无法发布。详见 §5.5。 + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-301 | 任务 CRUD、发布、暂停、截止、归档 | P0 | +| FR-302 | 任务克隆(跨学期复用,保留名册与规则配置) | P0 | +| FR-303 | 任务统计看板(完成率、异常率、驳回率、平均处理时长) | P0 | +| FR-304 | 填报二维码 / 短链生成(带租户与任务标识) | P0 | +| FR-305 | 多任务并行与批次管理 | P1 | +| FR-306 | 任务级"规则模板"保存与复用(形成规则模板市场) | P1 | + +### 5.4 动态表单引擎(FR-400) + +> 🧩 **先明确"动态"的边界**:本系统的表单是「**内置字段 + 扩展字段**」两段式,**不是任意字段**。 +> +> | 层 | 内容 | 存储 | 能力 | +> |---|---|---|---| +> | **内置字段** | 官方模板 21 项(姓名、身份证号、报考职业/工种/等级、学历、在校生相关…) | **固定列**(PG 强类型) | ✅ 可查询 ✅ 可索引 ✅ 可统计 ✅ 可校验 | +> | **扩展字段** | 租户/机构额外追加的字段 | `extras_json`(jsonb) | ✅ 可展示 ✅ 可导出 ❌ 不参与核心统计 | +> +> **为什么这样分**:实测确认官方模板结构稳定(两份真实文件列完全一致),且机构明写"**请勿调整模板**"——这些是**强结构数据**。把它们塞进 JSON 会换来灵活性、但**失去全部统计能力**,得不偿失。 +> 而"能动态设置表格"的需求由**扩展字段**满足。 + +**字段类型全集** + +| 类型 | 说明 | 校验能力 | +|---|---|---| +| 单行文本 | 姓名、单位等 | 长度、正则、去首尾空格 | +| 身份证号 | 专用控件 | 格式、校验位、出生日期、性别自动推导、与名册比对、OCR 回填 | +| 手机号 | 专用控件 | 11 位格式、短信验证、去重 | +| 数字 | 工作年限、年龄 | 范围、整数/小数 | +| 单选 / 多选 | 学历、是否在校生、补考科目 | 选项字典 | +| 日期 / 日期区间 | 入学时间、有效期 | 范围限制 | +| 级联选择 | 专业 → 工种 → 等级 | 层级校验 | +| 下拉字典 | 报考职业、报考等级 | 字典维护 | +| 文件 | 见材料项(5.6) | — | +| 说明文本 | 提示语、示例图 | 只读 | + +**关键能力** + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-401 | 可视化拖拽配置字段(PC 端) | P0 | +| FR-402 | **条件显示**:字段/材料可依赖其他字段值显示(例:`是否在校生=是` → 显示"学籍表";`=否` → 显示"学信网备案表") | P0 | +| FR-403 | 字段级校验规则配置(必填、正则、范围、自定义脚本) | P0 | +| FR-404 | Excel 模板一键生成与下载(带示例行、红色必填标识) | P0 | +| FR-405 | Excel 批量导入(名册即表单数据源),含字段映射向导 | P0 | +| FR-406 | 表单 schema 版本化:已发布任务改表单需生成新版本,避免数据错乱 | P0 | +| FR-407 | 表单模板库("职业技能等级认定报名表"等预置模板) | P1 | +| FR-408 | 移动端表单填写记忆(同一人跨任务复用已填信息) | P1 | + +#### 5.4.1 预置表单模板:职业技能等级认定报名表(21 字段) + +> 来源:真实《考生线下报名信息导入模板》完整拆解。**颜色即填写要求**——这是官方模板自带的语义,必须原样支持。 + +**模板结构** + +| 工作表 | 用途 | 结构 | +|---|---|---| +| `考生报名信息` | 数据录入区 | 表头在**第 1 行**,A–U 共 **21 列** | +| `填表注意事项` | 说明区 + 示例区 | A1:L5、A6:K23 合并单元格;第 25 行"示例 ↓";第 27–29 行为表头 + 2 行示例 | + +**字段全集与填写要求** + +| 列 | 字段 | 颜色语义 | 类型 | 校验规则 | +|:--:|---|---|---|---| +| A | 序号 | 🔴 必填 | 数字 | 正整数 | +| B | 姓名 | 🔴 必填 | 文本 | 2–20 字;与名单一致 | +| C | 身份证号 | 🔴 必填 | 身份证 | 18 位 + 校验位 + 与名单一致 | +| D | 学历 | 🔴 必填 | 下拉(11) | 见下方选项表 | +| E | 联系电话 | 🔴 必填 | 手机号 | 11 位数字 | +| F | 工作单位 | 🔴 必填 | 文本 | **没有则填"无"** | +| G | 报考职业 | 🔴 必填 | 字典 | **必须在当前评价计划的职业范围内** | +| H | 报考工种 | 🔴 必填 | 级联字典 | **只能填该职业包含的工种;无工种填"无";专项无需填写** | +| I | 报考等级 | 🔴 必填 | 下拉(5) | **只能填当前计划中该职业/工种已有的等级;专项无需填写** | +| J | 是否补考 | 🔴 必填 | 下拉 | 是 / 否 | +| K | 补考理论 | 🟢 级联 | 下拉(4) | 受 J 与等级约束(见下) | +| L | 补考实操 | 🟢 级联 | 下拉(6) | 同上 | +| M | 补考综合 | 🟢 级联 | 下拉(2) | 同上 | +| N | 是否在校生 | 🔴 必填 | 下拉 | 是 / 否 | +| O | 在读学校 | 🟢 级联 | 文本 | N=是 时必填 | +| P | 所学专业 | 🟢 级联 | 文本 | N=是 时必填 | +| Q | 入学时间 | 🟢 级联 | 日期 | N=是 时必填,**格式必须 YYYY-MM-DD** | +| R | 本职业工作年限 | ⚫ 选填 | 数字 | **只能填阿拉伯数字** | +| S | 已获证书职业工种 | ⚫ 选填 | 文本 | — | +| T | 已获证书等级 | ⚫ 选填 | 下拉(5) | — | +| U | 已获证书编号 | ⚫ 选填 | 文本 | — | + +**颜色语义(官方模板约定,必须支持)** + +| 颜色 | 含义 | 涉及列 | +|---|---|---| +| 🔴 红色 | **必填项** | A B C D E F G H I J N(11 项) | +| 🟢 绿色 | **级联选项**(与其它选项关联) | K L M O P Q(6 项) | +| ⚫ 黑色 | 选填项 | R S T U(4 项) | + +**官方下拉选项全集(照抄,不得擅改)** + +| 字段 | 选项 | +|---|---| +| 学历 | 博士研究生 / 硕士研究生 / 大学本科 / 大学专科 / 中等专科 / 职业高中 / 技工学校 / 普通中学 / 初级中学 / 小学 / 其他(11) | +| 报考等级、已获证书等级 | 五级/初级工 / 四级/中级工 / 三级/高级工 / 二级/技师 / 一级/高级技师(5) | +| 是否补考、是否在校生 | 是 / 否 | +| 补考理论 | 理论-纸笔作答 / 理论-机考 / 理论-直接认定 / 纸笔作答(4) | +| 补考实操 | 实操-现场操作 / 实操-模拟操作 / 实操-纸笔作答 / 实操-机考 / 实操-直接认定 / 技能操作(6) | +| 补考综合 | 综合评审 / 综合评审-直接认定(2) | + +> 官方说明:"**下拉选项无需手填,选中表格列点击箭头选择即可;若手动输入则只能填写下拉选项中包含的值**"。 + +**级联与业务规则(源自《填表注意事项》原文)** + +| # | 规则 | 原文要点 | +|---|---|---| +| 1 | **是否补考 = 否** → 补考理论/实操/综合 全部不填 | 级联 | +| 2 | **是否补考 = 是** → 按等级限制补考科目数量 | 见下 | +| 3 | **是否在校生 = 是** → 在读学校、所学专业、入学时间 **三项必填** | 级联 | +| 4 | **是否在校生 = 否** → 上述三项不填 | 级联 | +| 5 | **本职业工作年限** → 只能填阿拉伯数字 | 校验 | +| 6 | **工作单位** → 没有填"无" | 值约定 | +| 7 | **报考职业** → 只能填当前评价计划中的职业 | 计划约束 | +| 8 | **报考工种** → 只能填该职业包含的工种;无工种填"无";专项无需填写 | 计划约束 | +| 9 | **报考等级** → 只能填当前计划中该职业/工种已有的等级;专项无需填写 | 计划约束 | + +**补考科目数量约束(规则 07 原文,极易做错)** + +| 认定类型 / 等级 | 可补考科目数 | 可选字段 | +|---|---|---| +| **专项职业能力** | **0(没有补考)** | — | +| 职业技能等级 **三、四、五级** | **只能选 1 个** | 补考理论 / 补考实操 中选一个 | +| 职业技能等级 **一、二级** | **最多选 2 个** | 补考理论 / 补考实操 / 补考综合 中最多选两个 | + +**⚠️ 三个必须注意的实现陷阱** + +1. **"无"是合法业务值,不是空值** + 工作单位、报考工种都可能填"无"。系统在做必填校验、去重、单位统计时**不能把"无"当作未填写**处理。 +2. **专项职业能力是第三种认定类型**(见 §5.3),它**没有工种、没有等级、没有补考**——校验规则要整体绕过这三项。 +3. **表头位置不固定,且工作表名不固定** + 实测两份真实文件: + | 文件 | 工作表名 | 表头行 | + |---|---|---| + | 空白官方模板 | `考生报名信息` | 第 **1** 行 | + | 班级实际文件 | `填表注意事项` | 第 **6** 行(老师把数据填进了说明表) | + ⇒ **绝不能写死"第 1 行是表头"或"读某个固定工作表"**,必须按"前 10 行内查找含『姓名』『身份证号』的行"来识别(对应 FR-501)。 + +### 5.5 名册管理(FR-500) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-501 | Excel 导入(自动识别表头行、跳过示例行 —— **实测两份真实文件表头分别在第 1 行和第 6 行,且工作表名不同**,绝不能写死) | P0 | +| FR-502 | 导入前预校验:证件号格式、重复行、必填缺失,生成"导入体检报告" | P0 | +| **FR-502a** | **名单即预填报名表**:名单 21 列的值全部入库(**18 个正式列 + 姓名/身份证号已有列**),学生端按"锁定/预填可改/学生必填"三类规则呈现(见 §5.3.1) | **P0** | +| **FR-502b** | **名单字段三类规则**:姓名+身份证号+报考职业/工种/等级 → **锁定**(名单为空则阻断发布);其余 → 有值则预填可改、无值则学生必填 | **P0** | +| FR-503 | **名单核验(门禁)**:填报人进入前必须填写「姓名 + 身份证号」匹配名单,命中才进入填报页;命中后绑定名单项 | P0 | +| FR-504 | **未命中统一提示**(不区分"号码不在名单"与"姓名不符",防探测),提供 **"重新填写"** 与 **"申请加入名单"(FR-509)** 两条出路;**取消原"自助认领"** | P0 | +| FR-504a | **匹配接口限流**:同 openid 每小时 ≤10 次、同 IP 每分钟 ≤20 次,超出锁定并提示 | P0 | +| FR-504b | 名单**不下发前端**,匹配全程在服务端完成;失败尝试不记录明文证件号 | P0 | +| FR-504c | 姓名形近字/同音字纠错:身份证号命中但姓名不符时,给出"你是不是 XXX?"让用户确认 | P1 | +| FR-504d | 名单项**已被他人绑定**时提示"该信息已被登记",不覆盖原绑定 | P0 | +| FR-505 | 名单导出、批量修改(例如统一修改专业) | P1 | +| FR-506 | 名单变更留痕(谁在何时把 A 的证件号从 X 改成 Y) | P0 | +| FR-507 | **发布前校验**:名单人数 > 0 且必须含「姓名」「身份证号」列,否则阻断发布 | P0 | +| FR-508 | 名单追加导入(按证件号去重合并),**追加后新人立即可填报** | P0 | +| **FR-509** | **名单加入申请(客户已确认采纳的建议增量)**:门禁失败页提供「**申请加入名单**」入口 → 生成管理员待办 → **审批通过自动写入名册并放行**。解决了"名单外的人只能找老师手工加人"的运营痛点 | **P0** | +| FR-509a | **通道边界**:入口**只在门禁失败页**,不在填报页内;**申请期间仍不能填报**——通道只解决"进名单",**不绕过门禁** | P0 | +| FR-509b | 申请表单:姓名、身份证号、手机号(**均必填**)+ 自述班级 / 说明(选填);提交后展示"已提交,等待学校审核",并可查看审批进度 | P0 | +| FR-509c | **限流与防灌**:同 openid 每任务 ≤ **2 次**;**同证件号每任务仅保留 1 条待审或已通过申请**(被驳回后可重新申请);单任务待审申请 > 50 条时告警管理员 | P0 | +| FR-509d | **敏感信息处理与门禁一致**:姓名明文存(业务必需);**证件号与手机号仅存 HMAC + 脱敏值**,不存明文 | P0 | +| FR-509e | **管理员审批**:通过 → 自动在目标任务名册中**新建名册项**(姓名/证件号取申请值、班级取自述或由管理员指定)并回填 `roster_id`;驳回 → **必填原因** | P0 | +| FR-509f | **结果通知与可查**:审批结果通过微信订阅消息推送(`bizType=7` / 模板码 `JOIN_APPROVE`),申请人可随时在小程序查看申请状态与驳回原因 | P0 | +| FR-509g | **审批通过后该 openid 自动绑定新名册项**,学生再次进入**直接放行(免重输)**;名册列表中该行标注「**来自申请**」,便于追溯 | P1 | +| FR-509h | **全程留痕**:申请与审批均写入操作日志(申请人、审批人、时间、结果、驳回原因);申请记录保留 2 年 | P0 | +| FR-509i | **不能替代门禁**:审批通过建的名册项与导入的名册项**在后续流程中完全等价**(可填报、可审核、可导出) | P0 | + +### 5.6 材料采集与规则引擎(FR-600) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-601 | 材料项配置:材料名、别名集合、是否必传、条件显示、示例图、说明文案、**材料来源引导语** | P0 | +| **FR-601a** | **材料来源通道**(⚠️ 本版修正):仅提供 **① 从微信聊天记录选择 ② 从手机相册选择 ③ 从聊天/手机文件选择**;**「直接拍照」默认关闭**,仅材料级可配开启。依据:实测 4 类材料(截图/扫描件/照相馆电子版/翻拍件)**无一来自现场拍照** | P0 | +| **FR-601b** | **保留原始文件名**:从聊天记录选择文件时须取用微信返回的原始文件名作为命名规范化的首要输入(这是准确率最高的信息源);相册来源无文件名时降级为 OCR 识别 | P0 | +| FR-602 | **命名规则配置**:模板变量拖拽(`{姓名}-{证件号}-{材料名}{后缀}`),支持自定义前缀/后缀、大小写、连接符 | P0 | +| FR-603 | **命名规范化引擎**:自动修复双扩展名、缺分隔符、大小写、材料同义词、姓名与名册纠错(详见 6.1) | P0 | +| FR-604 | **格式白名单**:jpg / jpeg / png / pdf / heic(可配),非白名单自动转换 | P0 | +| FR-605 | **体积上限**:默认 ≤1M,材料级可配(0.3–5M);**同时具备体积下限保护**(防止过度压缩,见 D3) | P0 | +| FR-606 | **自动压缩**:按 §6.3 五档策略(P1 证件照 / P2 证件翻拍 / P3 A4 扫描件 / P4 截图 / P5 文档)智能压到目标体积且保证清晰度下限 | P0 | +| FR-607 | 图片自动纠偏:EXIF 方向校正、去黑边、去阴影(可选)、自动旋转 | P1 | +| FR-608 | 证件照规格化:一寸(**295×413 @300dpi**)、白底/蓝底替换、人脸居中 | **P1**(实测 984×1378 → 295×413 可再降 70% 体积,收益已验证,提前至 P1) | +| FR-609 | 多页材料合并(身份证正反面自动合成 1 张 / 1 个 PDF);**支持从聊天记录一次多选** | P1 | +| FR-610 | **上传即处理**:客户端预处理 + 服务端规范化,返回处理前后对比(体积、文件名) | P0 | +| FR-611 | 处理失败的兜底:保留原图并提示原因,允许人工重试或跳过 | P0 | +| FR-612 | 原图保留策略:默认保留 30 天(可配),便于回溯与重处理 | P0 | +| FR-613 | 教师代传(记录代传人身份);教师端同样走三个来源通道 | P0 | +| **FR-614** | **材料识别引擎(四层递进,实测校正后)**:① 文件名(能定就定,实测 100%)→ ② **形态只分流派、不定型** → ③ OCR 强词加权计分(实测 100%)→ ④ VLM 兜底(实测触发率仅 2%~10%)。详见功能逻辑卷 §3.5-B | P0 | +| FR-614a | 识别结果**只提示、不自动改位置**(散图分拣除外);**识别失败一律放行**,不得阻断提交 | P0 | +| FR-614b | **强特征词表**可配置(平台预置 + 租户自维护);通用词(姓名/学校/专业/出生/单位)**禁止入表**(实测为噪声) | P0 | +| FR-614c | 一键移动:用户确认后把文件从错误槽位移到正确槽位(保留版本) | P0 | +| FR-614d | 审核辅助:材料卡片显示"系统判定:身份证 92%",供班主任快速判断 | P1 | +| FR-614e | **一寸照片零文字判据**:整图 OCR 文字量 < 5 字 → 判定为一寸照片 | P0 | +| **FR-615** | **任务双层结构**:每个任务由「**填什么**(表单字段,`collect_form_schema`)」+「**传什么**(材料清单,`collect_material_def`)」两层独立配置,二者可互相条件联动(FR-402) | P0 | +| **FR-616** | **清晰度分级标记与抽检机制**:按实际 DPI/质量给文件打「优 / 良 / 可 / 强制压缩」四级;🟡「可」与🔴「强制压缩」进入抽检清单,供班主任在审核时顺带确认。**实测 28 张扫描件全部达标、不产生待抽检文件**——默认不启用,仅作兜底 | P1 | + +**材料清单默认模板(源自真实《申报材料上传要求》,含 §6.3 压缩档位)** + +| 场景 | 材料项 | 规范名示例 | 上限 | 压缩档位 | +|---|---|---|---|---| +| 通用 | 线下报名信息表 | 由系统生成 Excel | — | — | +| 自主认定(学校) | 职业技能等级个人申报表(手签+学校公章) | `张三-360121200010101234-工作证明.jpg` | 1M | **P3 扫描件** | +| 自主认定(学校) | 一寸电子照片 | `张三-360121200010101234-一寸照片.jpg` | 1M | **P1 证件照** | +| 自主认定(学校) | 身份证正反面扫描件 | `张三-360121200010101234-身份证.jpg` | 1M | **P2 证件翻拍** | +| 自主认定(学校) | 学籍表 | `张三-360121200010101234-学籍表.jpg` | 1M | **P4 截图** | +| 社会评价 | 个人申报表(手签+单位公章) | `张三-360121200010101234-工作证明.jpg` | 1M | **P3 扫描件** | +| 社会评价 | 一寸电子照片 | `张三-360121200010101234-一寸照片.jpg` | 1M | **P1 证件照** | +| 社会评价 | 身份证正反面扫描件 | `张三-360121200010101234-身份证.jpg` | 1M | **P2 证件翻拍** | +| 社会评价 | 学信网备案表 | `张三-360121200010101234-学信网备案表.jpg` | 1M | P2/P4(按形态自动判定) | +| 社会评价 | 其它佐证材料(毕业证/社保权益单/职称/技能证书) | `张三-360121200010101234-其它佐证材料.jpg` | 1M | P2/P3/P4(自动判定) | +| **专项职业能力** | 其它佐证材料(按机构要求) | `张三-...-其它佐证材料.jpg` | 1M | 自动判定 | + +> 📌 **档位由系统自动判定形态后套用**(依据:像素尺寸、宽高比、色彩深度、来源),也允许管理员在材料级强制指定。 + +### 5.7 校验引擎(FR-700) + +| 编号 | 校验规则 | 触发时机 | 严重级别 | 处理方式 | +|---|---|---|---|:--:| +| VAL-01 | 身份证号格式与校验位(ISO 7064 MOD 11-2) | 输入时 / 提交时 | 阻断 | 不允许提交 | +| VAL-02 | 身份证号与名册一致 | 输入时 / OCR 后 | 阻断 | 提示正确值,需确认 | +| VAL-03 | 姓名与名册一致(含近音字、形近字建议) | **仅门禁输入时** | 警告 | 建议纠正,可强制通过。
⚠️ **修订(客户确认)**:本条**仅适用于学生在门禁页手输姓名**的场景(手输会打错字);**不适用于文件名**——文件名里的姓名不是"错误",而是归属线索 | +| VAL-04 | **同一人跨材料证件号一致** | 每次上传后 | 阻断 | 标红,定位到冲突材料 | +| VAL-05 | 材料完整性(必传项是否齐全) | 提交时 | 阻断 | 列出缺失项 | +| VAL-06 | 文件格式白名单 | 上传时 | 阻断 | 自动转换或提示重传 | +| VAL-07 | 文件体积上限 | 上传时 | 阻断 | 自动压缩,压不动则提示 | +| VAL-08 | 图片可读性(分辨率过低 / 全黑 / 全白 / 模糊) | 上传时 | 警告 | 提示重新提供(重扫描 / 索取原图) | +| VAL-09 | 重复文件检测(文件哈希) | 上传时 | 警告 | 提示"与 XX 的材料重复" | +| VAL-10 | 材料有效期(身份证是否过期) | OCR 后 | 警告 | 提示 | +| VAL-11 | 名册重复人员检测 | 导入时 | 阻断 | 导入报告指出行号 | +| VAL-12 | **材料内容与类型匹配**(防传错位置):按 §3.5-B 材料识别逻辑判定,**仅提示、不阻断、不自动改位** | 上传后(异步) | 警告 | 提示"这看起来是 XX,要移过去吗?" + 一键移动 | +| **VAL-13** | **报考职业 / 工种 / 等级必须在「评价计划」范围内**(源自模板规则 04/05/06) | 选择时 + 提交时 | 阻断 | 只展示计划内选项,从源头防错 | +| **VAL-14** | **报考工种必须属于所选职业**;该职业无工种时只能填"无" | 选择时 | 阻断 | 级联联动 | +| **VAL-15** | **补考科目数量与等级匹配**:三/四/五级最多 1 科;一/二级最多 2 科;**专项职业能力无补考** | 提交时 | 阻断 | 超出则禁止勾选并提示 | +| **VAL-16** | **是否在校生 = 是 时,在读学校 / 所学专业 / 入学时间三项必填**;入学时间格式必须 **YYYY-MM-DD** | 提交时 | 阻断 | 列出缺失项 | +| **VAL-17** | 本职业工作年限只能为**阿拉伯数字** | 输入时 | 阻断 | 非数字直接拒绝 | +| **VAL-18** | **文件名规范化**:双扩展名、非法字符、缺分隔符、材料名归一(别名→规范名) | 上传时 | 信息 | **自动修复**,展示前后对比 | +| **VAL-19** | **证件号疑似串档**:校验位均通过,但与名册及其他材料均不符 | 上传后(异步) | 阻断 | **转人工,不自动修正** | +| **VAL-20** | **文件体积下限保护**:体积异常小,疑似非原图 | 上传时 | 警告 | 对照同批中位数提示"建议索取原图" | +| **VAL-21** | **文件归属校验**:文件名里的证件号**能反查到名册中的另一个人**,或归属无法确证 | 上传后(同步) | **阻断** | **阻断提交**,提示"这个文件名对应的是「李四」,不是你的材料",要求重新上传;**绝不自动按名册改名** | + +**校验结果呈现三层**:绿色(通过)/ 黄色(警告,可继续)/ 红色(阻断,必须处理),填报端即时反馈。 + +### 5.8 审核工作台(FR-800) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-801 | 待办列表(按状态、班级、异常类型筛选) | P0 | +| FR-802 | 学生卡片式浏览:头像、姓名、班级、5 项材料缩略图、红黄绿状态 | P0 | +| FR-803 | 材料在线预览(图片缩放、PDF 翻页、左右对比原图/压缩图) | P0 | +| FR-804 | 单个通过 / 驳回(填写驳回原因,支持常用原因快捷模板) | P0 | +| FR-805 | **批量通过**(仅一键通过全部绿色项) | P0 | +| FR-806 | 批量驳回(同因驳回) | P1 | +| FR-807 | 审核意见历史与时间轴留痕 | P0 | +| FR-808 | 审核人变更 / 转交 | P1 | +| FR-809 | 审核统计(人均处理时长、驳回率、驳回原因 TOP10) | P1 | +| FR-810 | 管理员终审 + "一键全部通过"(仅在零异常时可用) | P0 | + +**驳回原因快捷模板(示例)** +`材料不清晰,请重新提供` / `身份证号与名册不符` / `材料不是本人` / `文件超过 1M` / `缺少 XX 材料` / `工作证明未盖公章` + +### 5.9 汇总与导出(FR-900) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-901 | 导出范围选择:全任务 / 指定班级 / 指定人员 / 仅通过终审 | P0 | +| FR-902 | **目录树自动生成**(见下方规范) | P0 | +| FR-903 | 打包格式:ZIP / RAR(可选)/ 仅目录结构 | P0 | +| FR-904 | **分卷压缩**:支持按固定体积分卷(如 ≤50MB/卷),便于上传与传输 | P0 | +| FR-905 | 汇总 Excel 自动生成(合并全部表单字段 + 已完成情况) | P0 | +| FR-906 | 导出包命名规范:`{批次号}-{专业}-{班级}-{材料类型}-{日期}.zip` | P0 | +| FR-907 | 异步导出 + 进度查询 + 完成后微信/短信通知 | P0 | +| FR-908 | 导出历史与重复下载(不重复打包,命中缓存) | P1 | +| FR-909 | 脱敏导出(证件号打码,用于非必要场景) | P1 | +| FR-910 | 导出审计日志(谁、何时、导出了多少人的数据) | P0 | + +**导出目录规范(默认,可配置)** + +``` +{批次号}-{任务名称}/ +├── 24中技数控1班/ +│ ├── 一寸照片/ +│ │ └── 张三-360121200010101234-一寸照片.jpg +│ ├── 身份证/ +│ │ └── 张三-360121200010101234-身份证.jpg +│ ├── 学籍表/ +│ │ └── 张三-360121200010101234-学籍表.jpg +│ └── 工作证明/ +│ └── 张三-360121200010101234-工作证明.jpg +├── 24中技数控2班/ +│ └── ... +└── 考生线下报名信息导入模板(汇总).xlsx +``` + +### 5.10 通知与催报(FR-1000) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-1001 | 通知渠道:微信订阅消息(主)、短信(备,按条计费)、公众号模板消息 | P0 | +| FR-1002 | 通知模板可配置(任务发布、临期提醒、驳回通知、审核通过、导出完成) | P0 | +| FR-1003 | 自动催报策略:截止前 7/3/1 天 + 截止当日,仅催未提交者 | P0 | +| FR-1004 | 一键催报名单(可指定人员、班级) | P0 | +| FR-1005 | 通知记录与到达率统计 | P1 | +| FR-1006 | 班主任侧"班级完成率"日报(可开关) | P2 | + +### 5.11 数据看板(FR-1100) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-1101 | 任务总览:应填/已填/待审/已通过/驳回数、完成率环形图 | P0 | +| FR-1102 | 班级排行榜(完成率、异常率),便于管理员督促 | P1 | +| FR-1103 | 材料维度统计(哪类材料最易出错、平均处理时长) | P1 | +| FR-1104 | 异常清单导出(全部红黄项一览,供班主任逐条处理) | P0 | +| FR-1105 | 租户级运营看板(供平台运营) | P1 | + +### 5.12 系统管理与审计(FR-1200) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-1201 | 字典管理(专业、工种、等级、学历、地区) | P0 | +| FR-1202 | 操作日志(登录、增删改、审核、导出、下载) | P0 | +| FR-1203 | 文件下载/预览日志(含 IP、UA) | P0 | +| FR-1204 | 敏感信息脱敏展示(证件号 `3601**********1234`) | P0 | +| FR-1205 | 数据备份与恢复策略 | P0 | +| FR-1206 | 数据导出与账号注销(合规要求) | P1 | + +### 5.13 SaaS 计费(FR-1300) + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-1301 | 套餐定义:免费试用 / 标准版 / 旗舰版 / 私有化 | P0 | +| FR-1302 | 计量项:任务数、人数、存储量(GB·天)、OCR 次数、短信条数 | P0 | +| FR-1303 | 用量看板与超额提醒、续费提醒 | P1 | +| FR-1304 | 在线开通与试用(14 天自动到期降级) | P1 | + +**建议套餐(待定价)** + +| 版本 | 适用 | 额度 | +|---|---|---| +| 免费试用 | 单个班主任体验 | 1 个任务 / 60 人 / 14 天 | +| 标准版 | 单个学校 | 不限任务,按在册人数计费(如 ¥3–5/人/学期) | +| 旗舰版 | 多校区/大型机构 | 不限人数,含定制规则、专属客服、API 对接 | +| 私有化 | 政企/大型认定机构 | 一次性 + 年维护 | + +### 5.14 历史数据整理器(FR-1400)★ 差异化获客功能 + +**背景**:客户手头已有大量"历史乱数据"(如本项目的 436MB `.rar`)。让他们先看到价值,再谈系统上线。 + +| 编号 | 需求 | 优先级 | +|---|---|:--:| +| FR-1401 | 上传 ZIP/RAR 或指定目录结构,系统自动解析文件树 | P1 | +| FR-1402 | 自动识别"姓名 / 证件号 / 材料名"三要素并规范化命名 | P1 | +| FR-1403 | 批量压缩到目标体积、格式统一 | P1 | +| FR-1404 | 输出**《材料体检报告》**:命名违规清单、证件号异常清单、体积超标清单、缺失材料清单、重复文件清单 | P1 | +| FR-1405 | 输出规范化后的 ZIP 包 + 可直接上报的目录结构 | P1 | +| FR-1406 | 报告与结果一键分享给班主任(生成链接) | P2 | + +> 用本项目 `24中技数控1班` 做基准测试集,预期报告输出:命名违规 **59 项**、证件号异常 5 项、体积超标 31 项、疑似串档 1 项。 + +--- + +## 6. 核心规则详述(规则引擎) + +> 本章是本文档技术含量最高的部分,也是产品壁垒所在。 + +### 6.0 场景化材料清单(源自真实《申报材料上传要求》原文) + +| 场景 | 材料 | 命名要求 | +|---|---|---| +| 通用 | EXCEL:考生线下报名信息导入模板 | 系统生成 | +| 自主认定 | WORD:职业技能等级个人申报表(手签后**盖学校公章**的扫描件) | `张三-360121200010101234-工作证明.jpg` | +| 自主认定 | 一寸电子照片(jpg) | `张三-360121200010101234-一寸照片.jpg` | +| 自主认定 | 身份证正反扫描件 | `张三-360121200010101234-身份证.jpg` | +| 自主认定 | 学籍表 | `张三-360121200010101234-学籍表.jpg` | +| 社会评价 | WORD:个人申报表(手签后**盖单位公章**) | `张三-360121200010101234-工作证明.jpg` | +| 社会评价 | 一寸电子照片(jpg) | `张三-360121200010101234-一寸照片.jpg` | +| 社会评价 | 身份证正反扫描件 | `张三-360121200010101234-身份证.jpg` | +| 社会评价 | 学信网备案表 | `张三-360121200010101234-学信网备案表.jpg` | +| 社会评价 | 其它佐证材料(毕业证、社保权益单、职称证明、技能等级证书等) | `张三-360121200010101234-其它佐证材料.jpg` | +| 全局约束 | 所有图片格式 jpg / png | 单文件 **≤ 1M** | + +> ⚠️ 注意:"个人申报表"的**规范文件名却是"工作证明"**(认定机构的既有习惯),必须在材料项配置中通过"别名映射"支持这种反直觉规则。 + +### 6.1 文件名规范化引擎 + +**规则模板(可配置)** + +``` +{姓名}-{证件号}-{材料名}.{后缀} ← 默认 +``` + +可用变量:`{姓名}` `{证件号}` `{证件类型}` `{材料名}` `{班级}` `{专业}` `{工种}` `{等级}` `{报名点}` `{序号}` `{日期}` `{自定义字段.xxx}` + +**解析与规范化算法(7 步)** + +``` +输入文件名 → ① 剥离扩展名(循环剥离 .jpg/.jpeg/.png/.pdf/... 直至无匹配) + → ② 证件号提取(正则 \d{15,18}[xX]?,取最长匹配) + → ③ 证件号归一化(补零/大小写,17/19 位则触发名册纠错) + → ④ 材料名识别(与材料别名表做最长匹配;未命中则用"其他") + → ⑤ 姓名候选(剩余中文串,2–4 字;**仅作辅助证据**) + → ⑥ **归属校验**(证件号反查名册 → OCR 佐证 → 姓名仅辅助,见裁决矩阵) + → ⑦ 组装规范名(非法字符替换、重名加序号) +``` + +**文件归属裁决矩阵(核心算法,优先级从高到低)** + +> ⚠️ **先明确一件事**:名册的姓名与身份证号是**唯一权威**,且**先于填报存在**(门禁核验通过才能填)。 +> 因此**填报单里的姓名永远不会错**。文件名的任何不符,都不该被理解为"名称错误",而应被理解为 +> **「这份文件可能不属于这个人」**。 +> +> **由此推出两条铁律**: +> 1. **输出名一律取名册值**,文件名的值只用于**判断归属**,不用于命名; +> 2. **凡归属存疑,一律阻断提交**,让填报人自己改——**绝不自动按名册改名**。 +> 自动改名会把"A 传了 B 的材料"**静默洗白**成 A 的材料,审核时完全看不出来,事后无从追溯。 + +**三条证据(优先级从高到低)** + +| 级别 | 证据 | 可信度 | 说明 | +|:--:|---|---|---| +| **E1** | 文件名证件号**能反查名册**(定位到某一行) | ★★★ | 名册证件号有唯一索引,**能查到谁就是谁**——这是最强判据,一次查询即得 | +| **E2** | **OCR 读出的证件号** | ★★ | 读的是文件里印的字,比文件名可靠 | +| **E3** | 文件名里的姓名(相似度) | ★ | **弱证据,仅辅助**。姓名单独不构成规则 | + +**裁决矩阵** + +| # | 文件名情况 | 名册 | 结论 | 处理 | +|:--:|---|---|---|---| +| 1 | 无证件号(如 `微信图片_20260928.jpg`) | 有值 | **正常情况** | 按名册补全,**不产生任何提示** | +| 2 | 证件号 = 名册值 | 有值 | **归属确证** | 按名册输出。姓名若有差异,视为错别字,**仅内部留痕不打扰学生** | +| 3 | 证件号**能反查到名册中的另一个人** | 有值 | 🔴 **铁证:这是那个人的材料** | **阻断提交**——"这个文件名对应的是「李四」,不是你的材料" | +| 4 | 证件号合法,但名册中查不到 | 有值 | 归属存疑 | **有 E2 且 E2=名册 → 按名册处理(信息级)**;否则 **阻断提交**,要求重新上传 | +| 5 | 证件号校验位失败 / 位数不足 | 有值 | 文件名打错(实测 2 例) | **自动按名册**(信息级提示)——校验位失败说明这**不是一个真实的号码**,不构成归属证据 | +| 6 | 名册为空(尚未导入) | 无值 | 无名册可依 | 保留原值 + 标黄;**不猜** | + +> 💡 **第 3 条与第 5 条的区别是整张表的精髓**: +> **校验位失败 = 不是一个真实号码 → 只是写错了,可以自动改**; +> **校验位通过 = 一个真实存在的自然人 → 改了就是改了身份,必须阻断**。 +> 这正是校验位算法带来的直接收益:它把"这到底是谁的材料"从人判断变成机器判断。 + +> 📌 **已废除**:原矩阵中"不在名册 → 转**待认领**队列"——**"自助认领"机制已在 V1.5 废除**(会绕过门禁)。 +> 证件号不在名册时的正确出路是:**引导走「申请加入名单」通道**(FR-509,客户已确认采纳), +> 由管理员逐条审批后入册,而不是让系统自行认领。 + +**容错与纠正对照表(基于真实数据)** + +| 原始文件名 | 识别到的问题 | 规范名 | 自动 / 需确认 | +|---|---|---|---| +| `于豪-360103200805186619-工作证明.jpg.jpg` | 双扩展名 | `于豪-360103200805186619-工作证明.jpg` | 自动 | +| `曾伊豪-361029200907295110身份证.jpg.jpg` | 缺分隔符 + 双后缀 | `曾伊豪-361029200907295110-身份证.jpg` | 自动 | +| `徐程宇360981200901292334-身份证.jpg.jpg` | 缺分隔符 | `徐程宇-360981200901292334-身份证.jpg` | 自动 | +| `朱彬宇-36012420080712697x-身份证.jpg.jpg` | 小写 x | `朱彬宇-36012420080712697X-身份证.jpg` | 自动 | +| `于豪-360103200805166169-身份证.jpg.jpg` | 与名册不符,**校验位失败** | `于豪-360103200805186619-身份证.jpg` | ✅ **自动**(校验位裁决为文件名错误) | +| `杨晨-360825200904303218-身份证.jpg.jpg` | 与名册不符,**校验位失败** | `杨晨-360825200904303215-身份证.jpg` | ✅ **自动**(同上) | +| `陈子文-360122200902044232-一寸照片.jpg` | 与名册不符,**校验位均通过** | — | 🔴 **阻断提交**(两个真实的人 → 串档;**绝不自动改正**) | +| `曹文锋-36012120081211239-一寸照片.jpg` | 证件号 17 位 | `曹文锋-360121200812111239-一寸照片.jpg` | 名册兜底补齐 + 标黄确认 | +| `黎思明-36100200907283419-身份证.jpg.jpg` | 证件号 17 位 | `黎思明-361002200907283419-身份证.jpg` | 同上 | +| `扬晨-360825200904303215-一寸照片.jpg` | 姓名形近字(**证件号正确**) | `杨晨-360825200904303215-一寸照片.jpg` | ✅ **自动**(归属已由证件号确证;姓名仅留痕,**不打扰学生**) | +| `...-工作证明扫描件.jpg` | 材料名同义词 | `-工作证明.jpg` | 自动 | +| `1寸照片 / 照片 / 证件照` | 材料名同义词 | `-一寸照片.jpg` | 自动 | + +**材料名同义词表(可维护)** + +| 规范材料名 | 同义词 | +|---|---| +| 一寸照片 | 1寸照片、一寸电子照片、证件照、照片、免冠照 | +| 身份证 | 身份证正反面、身份证扫描件、身份证正面、身份证反面 | +| 学籍表 | 学籍证明、在读证明、学籍卡 | +| 工作证明 | 在职证明、工作证明扫描件、个人申报表、申报表、单位证明 | +| 学信网备案表 | 学信网学历备案表、学历证书电子注册备案表、备案表 | +| 其它佐证材料 | 毕业证书、社保个人权益单、职称证明、技能等级证书、佐证 | + +**姓名的定位:辅助证据,不是独立规则** + +> ⚠️ **修订**(客户确认):**姓名单独不构成规则**。 +> 理由:名册先于填报存在且门禁已核验,**填报单里的姓名永远是对的**; +> 文件名里的姓名既不用于命名,也不是可靠的归属证据(弱证据 E3)。 + +| 情形 | 处理 | +|---|---| +| 证件号 = 名册值,姓名与名册**有差异** | 归属已由证件号确证 → **按名册取值**(如"扬晨"→"杨晨")。
**不打扰学生**,仅写入 `collect_file_process_log` 供审计 | +| 证件号 = 名册值,姓名**完全一致** | 无事发生 | +| 证件号 = 名册值,姓名**完全不同**(如"李四"vs"张三") | 归属仍由证件号确证 → 按名册取值,**但记一条信息级提示**供审核参考 | +| **证件号存疑**(矩阵第 3/4 条) | **不看姓名**——直接走阻断流程。姓名相似不能成为放行理由 | + +> **实测支撑**:112 个真实文件里"姓名与名册不符"**仅 1 例**(`扬晨` vs 名册`杨晨`), +> 且该例的**证件号是正确的**——因此它本来就该按"矩阵第 2 条"处理,既不需要姓名规则,也不该触发任何确认。 +> **一条真实数据里只出现 1 次、且有更强判据可替代的规则,不值得单独做一套逻辑。** + +**姓名的技术实现(仅在需要时调用)** + +``` +FUNCTION name_differs_soft(fn, rn): # 仅用于矩阵第 2 条的"留痕" + RETURN edit_distance(fn, rn) <= 1 AND same_pinyin(fn, rn) + # 注意:返回值只影响"要不要写审计日志",不影响任何放行/阻断决策 +``` + +**冲突处理**:同一人同一材料重复上传 → 保留版本历史(`version` 递增),导出取最新有效版本。 + +### 6.2 身份证号校验算法(ISO 7064:1983 MOD 11-2) + +``` +1. 长度必须为 18(15 位旧号自动升级支持) +2. 前 17 位必须为数字,第 18 位为数字或 X/x +3. 地址码前 2 位必须是有效省级行政区代码 +4. 出生日期段(第 7–14 位)必须是合法日期,且年龄在合理区间(如 10–80 岁) +5. 校验位计算: + 加权因子 W = [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2] + S = Σ(a[i] × W[i]),i = 0..16 + 校验码表 C = "10X98765432" + 合法 ⟺ C[S mod 11] == a[17](X 需大写比较) +``` + +**用途**:填报端实时校验、名册导入体检、历史数据整理器、OCR 结果二次验证。 + +### 6.3 压缩规则(按材料形态分档 + 强制 1M) + +**核心决策(D1,已定)**:**所有材料一律强制输出 ≤1M,扫描件无例外、不设体积放宽选项。** +当"≤1M"与"清晰度"冲突时——**体积优先,牺牲清晰度**;但被牺牲清晰度的文件必须标记出来交人工抽检。 + +> ⚠️ **本节已按真实数据重写。** 初稿按"长边"单一口径设计,实测发现四类材料**物理形态完全不同**(A4 扫描件 / 手机翻拍 / 系统截图 / 证件照),用同一把尺子必然出错,详见 §1.2 ④。 +> 📄 **本文只保留设计结论;完整的压缩运转逻辑(执行顺序、标记规则、抽检机制、二次有损防护、超大图保护)见配套文档《数据信息填报系统PRD-功能逻辑卷》§3.7。** + +**五档策略(材料级可配)** + +| 档位 | 适用材料 | 形态判定 | 目标体积 | 尺寸规则 | 质量区间 | 依据 | +|---|---|---|---|---|---|---| +| **P1 证件照档** | 一寸照片 | 标准证件照 | ≤0.3M | **固定 295×413 px(25×35mm @300dpi)** | 85 | 真实数据 984×1378 为 5:7 标准比例,规格化收益明确 | +| **P2 证件翻拍档** | 身份证、备案表 | 手机翻拍(透视畸变) | ≤1M | 长边 clamp 至 **1400–2400 px** | 72–85 自适应 | 真实数据 558×745 ~ 2216×2796,跨度 4 倍 | +| **P3 扫描件档** | 工作证明、申报表 | **A4 纸张扫描/翻拍** | **≤1M(硬约束,无例外)** | 按 A4 等比缩放至 **300dpi**(2480×3508) | **85** | 实测 28/28 达标,平均 806KB,无需降质 | +| **P4 截图档** | 学籍表、系统导出件 | **屏幕截图**(16:9、像素规整) | ≤1M | **保持原始像素,不缩放** | 无损 / PNG | 真实数据 1025×590 PNG,缩放与转 JPG 均会损伤文字 | +| **P5 文档档** | PDF / Word | 文档 | ≤5M | 页面重采样 | — | — | + +> 📌 **A档与证件照规格统一**(初稿矛盾已修正):P1 档的"最小长边 600px"与 FR-608 的"295×413"互相冲突,现统一为**固定标准尺寸**,长边 413px。 + +**安全闸门(前置,必做)** + +``` +⚠️ 真实数据中存在 11050×16170 = 1.8 亿像素的扫描件。 + 全量解码需约 1.8亿 × 3B ≈ 540MB 内存,30 并发即 16GB,必然 OOM。 + +强制要求: +1. 图片处理引擎使用 libvips(流式、分块、金字塔降采样),**禁止使用 Java ImageIO / PIL 全量解码** +2. 任何处理前先读取头部获取宽高;像素数 > 5000 万 → 直接走 vips_thumbnail 降采样到安全区再处理 +3. 单文件处理超时 20s 熔断;处理队列设置单机并发上限(建议 ≤4 线程 × 2 实例,按内存反推) +4. 解压炸弹防护:单文件像素上限 2 亿、单文件体积上限 50MB +``` + +**压缩算法(一次到位 · 按形态定参数)** + +``` +输入:图片 img,档位 profile,目标体积 targetSize = 1M(硬约束) + +1. 【安全闸门】见上,超限先降采样 +2. EXIF 方向校正 → sRGB 色彩一致化 → 剥离 GPS/设备隐私信息 +3. 【尺寸归一化 + 一次编码】按档位取"已经实测达标"的参数直接输出: + P1 → 裁切并缩放至 295×413,写入 DPI=300,质量 85 + P2 → 长边压至 min(原长边, 2400),且不得低于 1400,质量 85 起下探 + P3 → 按 A4(210:297) 等比缩放至 300dpi(2480×3508),质量 85 ← 实测 28/28 达标 + P4 → 保持原始像素,仅无损优化 +4. 【体积检查】编码后测量体积: + 若 ≤ 1M → 直接输出,结束(预计 100% 走这条路径) + 若 > 1M → 进入降质路径(兜底,预计极少触发) +5. 【兜底降质路径】顺序按形态区分: + · 文字类(P3/P4):先降采样(×0.85 步长)→ 触底后再降质量(85→80→75→70→65→60) + 理由:JPEG 量化伪影集中出现在高对比边缘(正是笔画处), + 而等比重采样是受控的平滑,对文字可读性更友好 + · 照片类(P1/P2):先降质量(85→80→76→72)→ 触底后再降采样 + 理由:人脸细节对重采样敏感,对轻度质量下降不敏感 +6. 【地板】DPI 不低于 120、质量不低于 60;触碰地板仍超 → 继续降采样直至达标, + 并标记"强制压缩",必须人工确认 +7. 【过度压缩保护】若输出体积 < 100KB 且分辨率低于建议值 → 标记"疑似过度压缩" +8. 记录处理前后体积、质量、尺寸 → collect_file_process_log +``` + +> ✅ **待验证项已闭环(2026-09-24 实测完成)**:初稿担心的"降质顺序"问题**在本批数据上根本不会触发**——300dpi/q85 一次编码即全部达标(平均 806KB / 最大 831KB / 28 张 0 超标)。 +> ⇒ 降质顺序仍需实现(作为兜底),但**不再是关键路径**,也不必再为此做 A/B 测试与机构确认。 +> ⇒ 真正需要保留的验证是:**认定机构确认 300dpi 扫描件可接受**(这已不是技术问题)。 + +**输出格式决策** + +| 原格式 | 场景 | 输出 | +|---|---|---| +| JPG/JPEG | 全部 | JPG(保留方向与色彩) | +| PNG | 照片 / 扫描件(无透明通道) | JPG(白底填充) | +| **PNG** | **截图 / 系统导出(P4 档)** | **PNG(无损优化)** ⚠️ 初稿会在此处转 JPG 并损伤文字,已修正 | +| HEIC/HEIF | iPhone 拍摄 | JPG | +| PDF | 多页扫描件 | PDF(图片重采样)或转 JPG(单页) | +| BMP/TIFF | 少见 | JPG | + +**性能预期(基于真实样本实测)** + +| 输入(真实文件) | 输出 | 耗时目标 | +|---|---|---| +| `谭宇峰-工作证明` 11114×16320 / 17.2MB(1.8 亿像素) | **A4@300dpi 2480×3508 / 约 810KB**(实测) | ≤3s(libvips) | +| `某-工作证明`(本批 28 张全部) | 平均 806KB / 最大 831KB / **0 张超标** | 28 张合计 ≤60s | +| `于豪-身份证` 1148×1520 / 0.95MB | 约 0.4MB | ≤1s | +| `杨晨-身份证` 558×745 / 0.08MB ⚠️ | **不压缩,标记"分辨率不足"** | ≤0.3s | +| `某-学籍表` 1025×590 / 0.04MB PNG | 原样(无损优化后 ≈0.035MB) | ≤0.3s | +| `某-一寸照片` 984×1378 / 0.39MB | 295×413 / 0.06–0.12MB | ≤0.5s | + +**体积估算(以本项目为基准,实测)** + +| 材料 | 原始合计 | 处理目标 | 说明 | +|---|---|---|---| +| 工作证明 × 28 | 444 MB | ≈ 22 MB | 0.8MB/份 | +| 身份证 × 28 | ≈ 20 MB | ≈ 11 MB | 0.4MB/份 | +| 一寸照片 × 28 | ≈ 10 MB | ≈ 3 MB | 0.1MB/份 | +| 学籍表 × 28 | ≈ 1 MB | ≈ 1 MB | 不变 | +| **合计** | **≈ 475 MB**(`.rar` 打包后 436MB) | **≈ 37 MB** | **压缩率 ≈ 92%** | + +> ✅ 修正:初稿写"原始 436MB",实为 `.rar` 压缩包体积;**原始文件合计 475MB**。 + +### 6.4 OCR 识别与自动回填 + +| 材料 | OCR 引擎能力 | 提取字段 | 用途 | +|---|---|---|---| +| 身份证正面 | 身份证识别(专用) | 姓名、性别、民族、出生日期、住址、身份证号 | 自动回填表单 + 与名册/文件名三方比对 | +| 身份证反面 | 身份证识别 | 签发机关、有效期限 | 有效期是否过期校验 | +| 学籍表 / 备案表 | 通用文字识别 | 姓名、身份证号、学校 | 内容一致性校验 | +| 工作证明 | 通用文字识别 + 关键词检测 | 是否含"公章"字样区域、姓名、单位 | 盖章完整性提示 | + +**置信度策略** + +| 置信度 | 处理方式 | +|---|---| +| ≥ 0.90 | 自动采纳并回填,标注"OCR 自动识别" | +| 0.70 – 0.90 | 提示填报人确认识别结果 | +| < 0.70 | 不采纳,要求人工填写;记录到"OCR 失败"统计供算法优化 | + +**三方一致性比对(核心价值点)** + +``` +文件名中的证件号 ⟷ 名册中的证件号 ⟷ OCR 识别出的证件号 + │ │ │ + └────────── 三者不一致 → 红色阻断告警 ──────┘ +``` + +> 该机制可 100% 命中本项目实测的 5 例证件号错误与 1 例材料串档。 + +### 6.5 审核流可配置规则 + +```json +{ + "flowType": "SCHOOL", + "nodes": [ + {"node": "CLASS_AUDIT", "name": "班主任初审", "enabled": true, + "assignee": "ROSTER_CLASS_HEAD", "mode": "ANY", "timeoutHours": 48}, + {"node": "ADMIN_AUDIT", "name": "管理员终审", "enabled": true, + "assignee": "TENANT_ADMIN", "mode": "ANY", "timeoutHours": 72} + ], + "reject": { + "maxRejectCount": 3, + "escalateToAdmin": true, + "allowResubmitAfterApproved": false, + "resubmitWindowDays": 7 + }, + "autoApprove": { + "enabled": false, + "conditions": ["noRedIssue", "noYellowIssue", "ocrConfidenceAbove:0.95"] + } +} +``` + +--- + +## 7. 数据模型与数据库设计 + +### 7.1 ER 关系概览 + +```mermaid +erDiagram + sys_tenant ||--o{ sys_org : "拥有" + sys_tenant ||--o{ sys_user : "拥有" + sys_tenant ||--o{ collect_task : "创建" + sys_org ||--o{ sys_user : "归属" + sys_org ||--o{ collect_roster : "包含" + collect_task ||--o{ collect_form_schema : "定义表单" + collect_task ||--o{ collect_material_def : "定义材料" + collect_task ||--o{ collect_roster : "含名单" + collect_task ||--o{ collect_task_org : "覆盖班级" + collect_task ||--o{ collect_submission : "产生填报单" + collect_roster ||--o| collect_submission : "对应" + collect_submission ||--o{ collect_file : "挂载文件" + collect_file ||--o{ collect_validation_issue : "触发" + collect_submission ||--o{ collect_audit_record : "审核记录" + collect_task ||--o{ collect_export_task : "导出" + collect_task ||--o{ collect_notify_record : "通知" +``` + +### 7.2 表清单总览 + +| 分类 | 表名 | 说明 | 状态 | +|---|---|---|---| +| 平台 | `sys_tenant` | 租户(学校/机构) | ✅ | +| 平台 | `sys_org` | 组织树(院系/班级) | ✅ | +| 平台 | `sys_occupation_ref` | 职业工种**参考字典**(仅拼写校验与联想,非约束,P2) | ✅ | +| 平台 | `sys_user` | 用户 | ✅ | +| 平台 | `sys_tenant_package` | 租户套餐与用量 | 从略 | +| 平台 | `sys_role` / `sys_user_role` | 角色与用户角色 | 从略 | +| 平台 | `sys_dict_type` / `sys_dict_data` | 字典(学历、等级、补考科目等下拉项) | 从略 | +| 平台 | `sys_operation_log` | 操作日志 | 从略 | +| 业务 | `collect_task` | 收集任务 | ✅ | +| 业务 | `collect_task_org` | 任务覆盖组织与班主任绑定 | ✅ | +| 业务 | `collect_form_schema` | 表单定义(含 21 内置字段 + 扩展字段定义,版本化) | ✅ | +| 业务 | `collect_material_def` | 材料项定义(含命名与压缩规则) | ✅ | +| 业务 | `collect_roster` | **名单(即预填报名表,含 18 个业务列)** | ✅ | +| 业务 | `collect_submission` | 填报单(与名单对称建列 + `changed_fields`) | ✅ | +| 业务 | `collect_file` | 文件实体(含材料识别结果) | ✅ | +| 业务 | `collect_file_process_log` | 文件处理日志(压缩/改名/OCR) | ✅ | +| 业务 | `collect_validation_issue` | 校验问题 | ✅ | +| 业务 | `collect_audit_record` | 审核记录 | ✅ | +| 业务 | `collect_export_task` | 导出任务 | ✅ | +| 业务 | `collect_notify_record` | 通知记录与订阅额度 | ✅ | +| 业务 | `collect_subscribe_quota` | **微信订阅消息额度余额**(应对 R12 次数限制) | ✅ | +| 业务 | `collect_rule_template` | 规则模板(可复用) | ✅ | +| 业务 | `collect_cleanup_job` | 历史数据整理任务 | ✅ | +| 业务 | `collect_join_request` | **名单加入申请**(⚠️ 客户确认后新增,FR-509) | ✅ | + +> ✅ = 本文提供完整 DDL;从略 = 标准表(角色/字典/日志),沿用现有框架实现。 +> +> ⚠️ **已合并**:`collect_submission_item`(填报单明细项)**取消**——`collect_file` 已直接挂在 `collect_submission` 上并带 `material_code`,无需中间表。ER 图已同步。 +> ⚠️ **已合并**:`collect_task_auditor`(审核人绑定)已并入 `collect_task_org`。 + +> **通用字段约定**(所有业务表):`id` `tenant_id` `create_by` `create_time` `update_by` `update_time` `deleted` +> +> **本项目数据库为 PostgreSQL**,DDL 全部按 PG 语法书写并**已在 PG 16 实测执行通过**。 + +### 7.3 DDL 约定(PostgreSQL) + +> ✅ **本节全部 DDL 已在 PostgreSQL 16.15 真实执行验证通过(零错误)**,并用真实名单数据跑通了聚合查询与索引验证。 + +| 项 | 约定 | 说明 | +|---|---|---| +| 数据库 | **PostgreSQL 12+**(实测于 16.15) | 纯 PG,不依赖 Vastbase 专有语法;如需国产化可整体迁移到 PG 兼容库 | +| 主键 | `id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL` | 替代 MySQL 的 `AUTO_INCREMENT` | +| 标识符 | **全小写 + snake_case**(不加引号) | PG 会把未加引号的标识符折叠为小写,避免大小写敏感问题 | +| 软删除 | **`deleted smallint DEFAULT 0 NOT NULL`** | 对齐现有 schema 习惯(不是 `del_flag`) | +| 布尔语义 | **`smallint`**(0/1) | 与现有 schema 一致(不用 PG 的 `boolean`) | +| 时间 | **`timestamp`** | ⚠️ PG **没有** `ON UPDATE CURRENT_TIMESTAMP`,更新时间由**应用层**统一写入,或建触发器 | +| JSON | **`jsonb`**(不是 `json`) | 二进制存储、可建 GIN 索引、支持 `@>` / `?` / `->>`;仅用于扩展字段 | +| 注释 | **独立语句** `COMMENT ON COLUMN t.c IS '...'` | PG 不支持列定义里的行内 `COMMENT` | +| 索引 | **独立语句** `CREATE INDEX ... ON t (...)` | PG 不支持建表语句里的 `KEY idx_x (...)` | +| 唯一键 | 表内 `CONSTRAINT uk_x UNIQUE (...)` | — | +| **⭐ 命名唯一性** | **索引名与约束名必须在 schema 内全局唯一** | ⚠️ **这是 MySQL 迁移最容易踩的坑**:MySQL 的索引名只需表内唯一,PG 需全库唯一。所有索引与约束名必须加**表名缩写前缀**(如 `idx_roster_scope` 而不是 `idx_scope`) | +| 无符号 | **PG 不支持 `unsigned`** | 已在转换中去除 | +| 存储子句 | 本 DDL 不含 `WITH (...)` 存储子句,保持可移植 | 需要时可自行追加 | + +**实测踩到的两个真实错误(已修正)** + +| # | 报错 | 原因 | 修法 | +|---|---|---|---| +| 1 | `relation "uk_tenant_code" already exists` | `sys_tenant` 与 `collect_task` 都用了这个约束名 | `collect_task` 改为 `uk_task_tenant_code` | +| 2 | `relation "idx_name" / "idx_tenant" / "idx_submission" already exists` | 索引名在多个表间重复(MySQL 允许,PG 不允许) | 全部改为 `idx_<表缩写>_<列>` 形式(共 9 处) | + +> 💡 **教训**:DDL 的语法转换(类型、注释、索引)可以自动化,但**命名唯一性这类"语义级差异"必须靠真跑一遍才能发现**。仅靠人工审阅或正则替换一定会漏。 + +### 7.3.1 核心表 DDL(PostgreSQL) + +#### 7.3.1 租户 + +```sql +CREATE TABLE sys_tenant ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_code varchar(32) NOT NULL, + tenant_name varchar(128) NOT NULL, + tenant_type smallint NOT NULL DEFAULT 1, + contact_name varchar(64), + contact_phone varchar(20), + package_id bigint, + expire_time timestamp, + status smallint NOT NULL DEFAULT 1, + logo_url varchar(512), + config_json jsonb, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_tenant_code UNIQUE (tenant_code) +); +COMMENT ON TABLE sys_tenant IS '租户'; +COMMENT ON COLUMN sys_tenant.id IS '租户ID'; +COMMENT ON COLUMN sys_tenant.tenant_code IS '租户编码(短码,用于小程序扫码识别)'; +COMMENT ON COLUMN sys_tenant.tenant_name IS '租户名称(学校/机构)'; +COMMENT ON COLUMN sys_tenant.tenant_type IS '1学校 2认定机构 3报名点 4企业'; +COMMENT ON COLUMN sys_tenant.contact_name IS '联系人'; +COMMENT ON COLUMN sys_tenant.contact_phone IS '联系电话'; +COMMENT ON COLUMN sys_tenant.package_id IS '套餐ID'; +COMMENT ON COLUMN sys_tenant.expire_time IS '到期时间'; +COMMENT ON COLUMN sys_tenant.status IS '0停用 1正常 2试用'; +COMMENT ON COLUMN sys_tenant.logo_url IS 'Logo'; +COMMENT ON COLUMN sys_tenant.config_json IS '租户配置(默认压缩参数/文件保留天数等)'; +``` + +#### 7.3.2 组织 + +```sql +CREATE TABLE sys_org ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + parent_id bigint NOT NULL DEFAULT 0, + org_name varchar(128) NOT NULL, + org_type smallint NOT NULL DEFAULT 3, + org_code varchar(64), + ancestor_path varchar(512), + leader_user_id bigint, + sort_no integer NOT NULL DEFAULT 0, + status smallint NOT NULL DEFAULT 1, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_tenant_parent ON sys_org (tenant_id, parent_id); +CREATE INDEX idx_tenant_name ON sys_org (tenant_id, org_name); +COMMENT ON TABLE sys_org IS '组织架构'; +COMMENT ON COLUMN sys_org.parent_id IS '父级ID,0为根'; +COMMENT ON COLUMN sys_org.org_name IS '组织名称,如 24中技数控1班'; +COMMENT ON COLUMN sys_org.org_type IS '1学校 2院系/专业 3班级 4报名点'; +COMMENT ON COLUMN sys_org.org_code IS '组织编码/班号'; +COMMENT ON COLUMN sys_org.ancestor_path IS '祖先路径,如 0,1,5'; +COMMENT ON COLUMN sys_org.leader_user_id IS '负责人(班主任)'; +``` + +#### 7.3.3 报考范围(⚠️ 按实际情况校正) + +> **变更说明**:上一版设计了 `sys_eval_plan` / `sys_plan_occupation` / `sys_plan_import_log` 三张表,前提是"认定机构会下发计划清单文件"。 +> **该前提不成立**——实测确认真实名单里「报考职业/工种/等级」三列已由老师填好(28/28)。 +> ⇒ 报考范围**从名单聚合而来**,只需在任务上存一份快照,**三张表全部删除**。 + +```sql +ALTER TABLE collect_task ADD COLUMN plan_scope_json jsonb; +ALTER TABLE collect_task ADD COLUMN plan_scope_confirmed smallint NOT NULL DEFAULT 0; +COMMENT ON COLUMN collect_task.plan_scope_json IS '本期报考范围快照(从名单聚合): {occupations:[{name,worktypes:[{name,levels:[]}]}], confirmed:true}'; +COMMENT ON COLUMN collect_task.plan_scope_confirmed IS '管理员是否已确认范围(未确认则追加名单不告警)'; +``` + +**可选参考字典(P2,非强约束)** + +仅用于**拼写校验与联想建议**(例如"数控车床工"→提示"是否要填数控车工")。**不作为报考范围的约束来源**——约束来源是名单本身。 + +```sql +CREATE TABLE sys_occupation_ref ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + parent_id bigint NOT NULL DEFAULT 0, + name varchar(128) NOT NULL, + aliases varchar(512), + has_child smallint NOT NULL DEFAULT 0, + status smallint NOT NULL DEFAULT 1, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_parent ON sys_occupation_ref (parent_id); +CREATE INDEX idx_occref_name ON sys_occupation_ref (name); +COMMENT ON TABLE sys_occupation_ref IS '职业工种参考字典(仅拼写校验,非约束)'; +COMMENT ON COLUMN sys_occupation_ref.parent_id IS '0=职业; 非0=该职业下的工种'; +COMMENT ON COLUMN sys_occupation_ref.aliases IS '常见错写/别名,用于纠错提示'; +COMMENT ON COLUMN sys_occupation_ref.has_child IS '该职业是否有工种'; +``` + +**名单行上需要存的预填值 —— 18 个正式列 + 1 个逃生舱** + +> ⚠️ **设计更正**:上一版把 19 个字段统一塞进一个 `prefilled_json`,理由是"每个机构模板可能不同"。 +> 实测两份真实文件后确认:**官方模板结构稳定,两份文件的列完全一致(都是 A–U 共 21 列)**,且机构明写"请勿调整模板"。 +> ⇒ 这些是**强结构、要查询、要统计**的数据,**必须建正式列**。JSON 只留给"机构额外追加的字段"。 + +```sql +-- 同学号的补齐:官方模板 21 字段中的非身份字段全部建为正式列 +-- 姓名、身份证号已有独立列(real_name / id_card_no),此处补齐其余 18 个 +ALTER TABLE collect_roster + ADD COLUMN occupation varchar(64), -- 报考职业 G + ADD COLUMN worktype varchar(64), -- 报考工种 H + ADD COLUMN job_level varchar(32), -- 报考等级 I + ADD COLUMN education varchar(32), -- 学历 D + ADD COLUMN is_student smallint, -- 是否在校生 N 0否 1是 + ADD COLUMN school varchar(128), -- 在读学校 O + ADD COLUMN major varchar(128), -- 所学专业 P + ADD COLUMN enroll_date date, -- 入学时间 Q YYYY-MM-DD + ADD COLUMN work_unit varchar(128), -- 工作单位 F 无则填"无" + ADD COLUMN is_retake smallint, -- 是否补考 J 0否 1是 + ADD COLUMN retake_theory varchar(64), -- 补考理论 K + ADD COLUMN retake_practice varchar(64), -- 补考实操 L + ADD COLUMN retake_overall varchar(64), -- 补考综合 M + ADD COLUMN work_years smallint, -- 本职业工作年限 R + ADD COLUMN cert_occupation varchar(128), -- 已获证书职业工种 S + ADD COLUMN cert_level varchar(32), -- 已获证书等级 T + ADD COLUMN cert_no varchar(64), -- 已获证书编号 U + ADD COLUMN extras_json jsonb DEFAULT '{}'::jsonb; -- 逃生舱:仅存租户/机构额外追加的字段 + +-- 报考范围聚合与按工种统计必需的索引 +CREATE INDEX idx_roster_scope ON collect_roster (tenant_id, task_id, occupation, worktype, job_level); +CREATE INDEX idx_roster_org ON collect_roster (task_id, org_id, deleted); + +COMMENT ON COLUMN collect_roster.occupation IS '报考职业(锁定,由老师确定)'; +COMMENT ON COLUMN collect_roster.worktype IS '报考工种(锁定;该职业无工种时填"无";专项职业能力为空)'; +COMMENT ON COLUMN collect_roster.job_level IS '报考等级(锁定;专项职业能力为空)'; +COMMENT ON COLUMN collect_roster.education IS '学历(下拉11项)'; +COMMENT ON COLUMN collect_roster.is_student IS '是否在校生 0否 1是'; +COMMENT ON COLUMN collect_roster.school IS '在读学校(在校生=是时必填)'; +COMMENT ON COLUMN collect_roster.major IS '所学专业(在校生=是时必填)'; +COMMENT ON COLUMN collect_roster.enroll_date IS '入学时间(在校生=是时必填,格式YYYY-MM-DD)'; +COMMENT ON COLUMN collect_roster.work_unit IS '工作单位(没有填"无")'; +COMMENT ON COLUMN collect_roster.is_retake IS '是否补考 0否 1是'; +COMMENT ON COLUMN collect_roster.retake_theory IS '补考理论(级联)'; +COMMENT ON COLUMN collect_roster.retake_practice IS '补考实操(级联)'; +COMMENT ON COLUMN collect_roster.retake_overall IS '补考综合(级联)'; +COMMENT ON COLUMN collect_roster.work_years IS '本职业工作年限(仅阿拉伯数字)'; +COMMENT ON COLUMN collect_roster.cert_occupation IS '已获证书职业工种(选填)'; +COMMENT ON COLUMN collect_roster.cert_level IS '已获证书等级(选填)'; +COMMENT ON COLUMN collect_roster.cert_no IS '已获证书编号(选填)'; +COMMENT ON COLUMN collect_roster.extras_json IS '租户/机构额外追加的字段(逃生舱,不参与核心统计)'; +``` + +> **规则**:名单有值的字段 → 学生端预填(🔒锁定项只读 / ✏️其余可改并留痕);名单无值的字段 → 学生必填。详见 §5.3.1。 +> **`collect_submission` 同样需要这 18 列**(存学生确认/修改后的最终值),以便与名单值做 diff 留痕。 + +#### 7.3.4 用户 + +```sql +CREATE TABLE sys_user ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL DEFAULT 0, + org_id bigint, + username varchar(64) NOT NULL, + password varchar(128), + real_name varchar(64) NOT NULL, + phone varchar(20), + phone_hash char(64), + open_id varchar(64), + union_id varchar(64), + id_card_no varchar(128), + id_card_hash char(64), + avatar_url varchar(512), + user_type smallint NOT NULL DEFAULT 1, + status smallint NOT NULL DEFAULT 1, + last_login_time timestamp, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_tenant_username UNIQUE (tenant_id, username) +); +CREATE INDEX idx_phone_hash ON sys_user (phone_hash); +CREATE INDEX idx_idcard_hash ON sys_user (id_card_hash); +CREATE INDEX idx_openid ON sys_user (open_id); +COMMENT ON TABLE sys_user IS '用户'; +COMMENT ON COLUMN sys_user.tenant_id IS '0=平台运营'; +COMMENT ON COLUMN sys_user.org_id IS '所属组织'; +COMMENT ON COLUMN sys_user.username IS '登录名(教师)'; +COMMENT ON COLUMN sys_user.password IS 'BCrypt'; +COMMENT ON COLUMN sys_user.phone IS '手机号(AES加密存储)'; +COMMENT ON COLUMN sys_user.phone_hash IS '手机号HMAC,用于唯一检索'; +COMMENT ON COLUMN sys_user.open_id IS '微信openid'; +COMMENT ON COLUMN sys_user.id_card_no IS '证件号(AES加密存储)'; +COMMENT ON COLUMN sys_user.id_card_hash IS '证件号HMAC,用于比对与去重'; +COMMENT ON COLUMN sys_user.user_type IS '1教师/管理员 2填报人(学生) 3社会人员'; +COMMENT ON COLUMN sys_user.status IS '0停用 1正常 2待激活'; +``` + +#### 7.3.5 收集任务 + +```sql +CREATE TABLE collect_task ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_code varchar(64) NOT NULL, + task_name varchar(200) NOT NULL, + collect_mode smallint NOT NULL DEFAULT 1, + batch_no varchar(64), + major_name varchar(64), + job_name varchar(64), + job_level varchar(32), + cert_type smallint NOT NULL DEFAULT 1, + start_time timestamp, + end_time timestamp, + audit_config jsonb, + naming_template varchar(256) NOT NULL DEFAULT '{姓名}-{证件号}-{材料名}', + compress_config jsonb, + roster_required smallint NOT NULL DEFAULT 1, + status smallint NOT NULL DEFAULT 0, + published_time timestamp, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_task_tenant_code UNIQUE (tenant_id, task_code) +); +CREATE INDEX idx_tenant_status ON collect_task (tenant_id, status); +CREATE INDEX idx_end_time ON collect_task (end_time); +COMMENT ON TABLE collect_task IS '收集任务'; +COMMENT ON COLUMN collect_task.task_code IS '任务编码,用于文件路径'; +COMMENT ON COLUMN collect_task.task_name IS '如 2026年9月机电专业认定材料收集'; +COMMENT ON COLUMN collect_task.collect_mode IS '1学校收集 2社会收集'; +COMMENT ON COLUMN collect_task.batch_no IS '批次号'; +COMMENT ON COLUMN collect_task.major_name IS '专业'; +COMMENT ON COLUMN collect_task.job_name IS '报考工种'; +COMMENT ON COLUMN collect_task.job_level IS '报考等级'; +COMMENT ON COLUMN collect_task.cert_type IS '1自主认定 2社会评价 3专项职业能力'; +COMMENT ON COLUMN collect_task.end_time IS '截止时间'; +COMMENT ON COLUMN collect_task.audit_config IS '审核流配置,见6.5'; +COMMENT ON COLUMN collect_task.compress_config IS '任务级压缩默认参数'; +COMMENT ON COLUMN collect_task.roster_required IS '是否需要名册: 固定为1,两种收集模式都必填(名单门禁)'; +COMMENT ON COLUMN collect_task.status IS '0草稿 1收集中 2已截止 3已归档 4暂停'; +``` + +#### 7.3.6 任务-组织关联(⚠️ 初稿遗漏) + +> 初稿只让 `collect_roster.org_id` 承载班级归属,但**任务创建时必须先选定"本次收集覆盖哪些班级"**,才能算出应填人数、下发通知、统计完成率。且社会收集模式下任务可挂"报名点/区域"而非班级。故必须独立成表。 + +```sql +CREATE TABLE collect_task_org ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + org_id bigint NOT NULL, + head_user_id bigint, + deputy_user_ids varchar(256), + expected_count integer NOT NULL DEFAULT 0, + status smallint NOT NULL DEFAULT 1, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT uk_task_org UNIQUE (task_id, org_id) +); +CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id); +COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定'; +COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id'; +COMMENT ON COLUMN collect_task_org.deputy_user_ids IS '副班主任/协办人,逗号分隔'; +COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(冗余,由名册汇总)'; +COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效'; +``` + +#### 7.3.7 表单定义 + +```sql +CREATE TABLE collect_form_schema ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + version integer NOT NULL DEFAULT 1, + schema_json jsonb NOT NULL, + is_current smallint NOT NULL DEFAULT 1, + create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_task_version ON collect_form_schema (task_id, version); +COMMENT ON TABLE collect_form_schema IS '表单定义(版本化)'; +COMMENT ON COLUMN collect_form_schema.version IS '版本号,已发布任务修改则+1'; +COMMENT ON COLUMN collect_form_schema.schema_json IS '字段定义数组'; +``` + +**`schema_json` 示例** + +```json +[ + {"key":"name","label":"姓名","type":"text","required":true,"maxLen":20, + "source":"ROSTER","readonlyOnMatched":true}, + {"key":"idCard","label":"身份证号","type":"idcard","required":true, + "validate":["format","checksum","rosterMatch"],"ocrFill":true}, + {"key":"phone","label":"联系电话","type":"phone","required":true,"smsVerify":false}, + {"key":"education","label":"学历","type":"select","required":true,"dictCode":"education"}, + {"key":"isStudent","label":"是否在校生","type":"radio","required":true, + "options":[{"label":"是","value":"Y"},{"label":"否","value":"N"}]}, + {"key":"school","label":"在读学校","type":"text","required":true, + "visibleWhen":"isStudent == 'Y'"}, + {"key":"xxwba","label":"学信网备案表","type":"file","materialCode":"XXWBA", + "visibleWhen":"isStudent == 'N'"} +] +``` + +#### 7.3.8 材料项定义 + +```sql +CREATE TABLE collect_material_def ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + material_code varchar(32) NOT NULL, + material_name varchar(64) NOT NULL, + aliases varchar(512), + name_pattern varchar(256), + required smallint NOT NULL DEFAULT 1, + visible_when varchar(256), + file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png', + max_size_kb integer NOT NULL DEFAULT 1024, + compress_profile varchar(8) NOT NULL DEFAULT 'AUTO', + min_long_side integer NOT NULL DEFAULT 1400, + min_quality smallint NOT NULL DEFAULT 70, + target_dpi integer, + fixed_size varchar(32), + output_format varchar(16) NOT NULL DEFAULT 'jpg', + max_count smallint NOT NULL DEFAULT 1, + merge_enabled smallint NOT NULL DEFAULT 0, + ocr_type varchar(32), + sample_url varchar(512), + tip_text varchar(512), + sort_no integer NOT NULL DEFAULT 0, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_task_code UNIQUE (task_id, material_code) +); +CREATE INDEX idx_matdef_tenant ON collect_material_def (tenant_id); +COMMENT ON TABLE collect_material_def IS '材料项定义'; +COMMENT ON COLUMN collect_material_def.material_code IS '如 IDCARD/PHOTO1/SCHOOL_ROLL/WORK_PROOF'; +COMMENT ON COLUMN collect_material_def.material_name IS '规范材料名,如 身份证'; +COMMENT ON COLUMN collect_material_def.aliases IS '别名,逗号分隔,用于历史文件名识别'; +COMMENT ON COLUMN collect_material_def.name_pattern IS '命名模板,空则继承任务级'; +COMMENT ON COLUMN collect_material_def.visible_when IS '条件显示表达式'; +COMMENT ON COLUMN collect_material_def.file_types IS '格式白名单'; +COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB'; +COMMENT ON COLUMN collect_material_def.compress_profile IS '压缩档位: AUTO自动判定/P1证件照/P2证件翻拍/P3扫描件/P4截图/P5文档, 见6.3'; +COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(仅P2证件翻拍档生效)'; +COMMENT ON COLUMN collect_material_def.min_quality IS '质量下限,低于65视为不可接受'; +COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)'; +COMMENT ON COLUMN collect_material_def.fixed_size IS '固定像素尺寸(仅P1证件照档,如295x413)'; +COMMENT ON COLUMN collect_material_def.output_format IS 'P4截图档固定输出png'; +COMMENT ON COLUMN collect_material_def.max_count IS '允许上传份数,身份证正反面可2'; +COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张'; +COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE'; +COMMENT ON COLUMN collect_material_def.sample_url IS '示例图'; +COMMENT ON COLUMN collect_material_def.tip_text IS '填报提示语'; +``` + +#### 7.3.9 名册 + +```sql +CREATE TABLE collect_roster ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + org_id bigint, + seq_no integer, + student_no varchar(64), + real_name varchar(64) NOT NULL, + name_pinyin varchar(128), + id_card_no varchar(128) NOT NULL, + id_card_hash char(64) NOT NULL, + id_card_mask varchar(32), + phone varchar(20), + phone_hash char(64), + ext_json jsonb, + bind_user_id bigint, + bind_time timestamp, + bind_type smallint, + submit_status smallint NOT NULL DEFAULT 0, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_task_idcard UNIQUE (task_id, id_card_hash) +); +CREATE INDEX idx_task_org ON collect_roster (task_id, org_id); +CREATE INDEX idx_bind_user ON collect_roster (bind_user_id); +CREATE INDEX idx_roster_name ON collect_roster (task_id, real_name); +COMMENT ON TABLE collect_roster IS '名册'; +COMMENT ON COLUMN collect_roster.org_id IS '班级'; +COMMENT ON COLUMN collect_roster.seq_no IS '序号(来自Excel)'; +COMMENT ON COLUMN collect_roster.student_no IS '学号(⚠️初稿遗漏,名册唯一性依据之一)'; +COMMENT ON COLUMN collect_roster.name_pinyin IS '拼音,用于近音纠错'; +COMMENT ON COLUMN collect_roster.id_card_no IS 'AES加密'; +COMMENT ON COLUMN collect_roster.id_card_hash IS 'HMAC,唯一性判断'; +COMMENT ON COLUMN collect_roster.id_card_mask IS '脱敏展示 3601**********1234'; +COMMENT ON COLUMN collect_roster.ext_json IS '名册其余字段(学历/工作单位/报考职业等)'; +COMMENT ON COLUMN collect_roster.bind_user_id IS '绑定的填报人'; +COMMENT ON COLUMN collect_roster.bind_type IS '1门禁核验匹配 2管理员指定'; +COMMENT ON COLUMN collect_roster.submit_status IS '0未提交 1草稿 2待审 3已通过 4已驳回'; +``` + +#### 7.3.10 填报单 + +```sql +CREATE TABLE collect_submission ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + roster_id bigint, + org_id bigint, + submitter_id bigint NOT NULL, + form_version integer NOT NULL DEFAULT 1, + form_data jsonb, + -- ⚠️ 与 collect_roster 对称的 18 个业务列:存学生「确认/修改后」的最终值 + -- 需要与名单值做 diff(留痕"学生改了哪些字段"),也是统计"实际报考各工种人数"的依据 + occupation varchar(64), + worktype varchar(64), + job_level varchar(32), + education varchar(32), + is_student smallint, + school varchar(128), + major varchar(128), + enroll_date date, + work_unit varchar(128), + is_retake smallint, + retake_theory varchar(64), + retake_practice varchar(64), + retake_overall varchar(64), + work_years smallint, + cert_occupation varchar(128), + cert_level varchar(32), + cert_no varchar(64), + extras_json jsonb DEFAULT '{}'::jsonb, -- 逃生舱 + changed_fields jsonb, -- 学生相对名单修改过的字段(留痕) + status smallint NOT NULL DEFAULT 0, + reject_count integer NOT NULL DEFAULT 0, + last_reject_reason varchar(512), + red_count integer NOT NULL DEFAULT 0, + yellow_count integer NOT NULL DEFAULT 0, + submit_time timestamp, + class_audit_by bigint, class_audit_time timestamp, + admin_audit_by bigint, admin_audit_time timestamp, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + CONSTRAINT uk_task_submitter UNIQUE (task_id, submitter_id) +); +CREATE INDEX idx_task_status ON collect_submission (task_id, status, org_id); +CREATE INDEX idx_submitter ON collect_submission (submitter_id); +COMMENT ON TABLE collect_submission IS '填报单'; +COMMENT ON COLUMN collect_submission.roster_id IS '社会收集可为空'; +COMMENT ON COLUMN collect_submission.submitter_id IS '填报人user_id'; +COMMENT ON COLUMN collect_submission.form_data IS '表单数据快照(扩展字段;核心18列见上方独立列)'; +COMMENT ON COLUMN collect_submission.occupation IS '最终报考职业(学生确认后,应与名单一致)'; +COMMENT ON COLUMN collect_submission.worktype IS '最终报考工种'; +COMMENT ON COLUMN collect_submission.job_level IS '最终报考等级'; +COMMENT ON COLUMN collect_submission.is_student IS '是否在校生 0否 1是'; +COMMENT ON COLUMN collect_submission.work_unit IS '工作单位(没有填"无")'; +COMMENT ON COLUMN collect_submission.is_retake IS '是否补考 0否 1是'; +COMMENT ON COLUMN collect_submission.extras_json IS '租户/机构额外追加字段(逃生舱)'; +COMMENT ON COLUMN collect_submission.changed_fields IS '学生相对名单修改过的字段(留痕: {字段:{old,new,time}})'; +COMMENT ON COLUMN collect_submission.status IS '0草稿 1待初审 2待终审 3已通过 4已驳回 5已归档'; +COMMENT ON COLUMN collect_submission.red_count IS '红色问题数'; +COMMENT ON COLUMN collect_submission.yellow_count IS '黄色问题数'; +``` + +#### 7.3.11 文件实体 + +```sql +CREATE TABLE collect_file ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + submission_id bigint NOT NULL, + roster_id bigint, + material_code varchar(32) NOT NULL, + version integer NOT NULL DEFAULT 1, + is_current smallint NOT NULL DEFAULT 1, + original_name varchar(512) NOT NULL, + standard_name varchar(512), + file_ext varchar(16) NOT NULL, + mime_type varchar(64), + raw_size bigint NOT NULL, + final_size bigint NOT NULL, + file_hash char(64) NOT NULL, + raw_path varchar(512), + std_path varchar(512), + raw_expire_time timestamp, + width integer, height integer, quality smallint, + dpi integer, + ocr_status smallint NOT NULL DEFAULT 0, + ocr_result jsonb, + ocr_confidence numeric(4,3), + detected_material_code varchar(32), + detect_confidence numeric(4,3), + detect_source varchar(32), + detect_conflict smallint NOT NULL DEFAULT 0, + need_manual_place smallint NOT NULL DEFAULT 0, + vlm_called smallint NOT NULL DEFAULT 0, + validate_status smallint NOT NULL DEFAULT 0, + source smallint NOT NULL DEFAULT 1, + upload_ip varchar(64), + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_file_submission ON collect_file (submission_id, material_code, is_current); +CREATE INDEX idx_task_material ON collect_file (task_id, material_code); +CREATE INDEX idx_hash ON collect_file (file_hash); +CREATE INDEX idx_raw_expire ON collect_file (raw_expire_time); +COMMENT ON TABLE collect_file IS '文件实体'; +COMMENT ON COLUMN collect_file.is_current IS '是否最新有效版本'; +COMMENT ON COLUMN collect_file.original_name IS '原始文件名'; +COMMENT ON COLUMN collect_file.standard_name IS '规范文件名'; +COMMENT ON COLUMN collect_file.raw_size IS '原始字节'; +COMMENT ON COLUMN collect_file.final_size IS '处理后字节'; +COMMENT ON COLUMN collect_file.file_hash IS 'SHA-256,用于查重'; +COMMENT ON COLUMN collect_file.raw_path IS '对象存储key-原始'; +COMMENT ON COLUMN collect_file.std_path IS '对象存储key-规范'; +COMMENT ON COLUMN collect_file.raw_expire_time IS '原始文件清理时间'; +COMMENT ON COLUMN collect_file.ocr_status IS '0未识别 1成功 2失败'; +COMMENT ON COLUMN collect_file.ocr_result IS 'OCR结构化结果(不存明文证件号,存hash)'; +COMMENT ON COLUMN collect_file.detected_material_code IS '材料识别结果(材料类型编码),见功能逻辑卷3.5-B'; +COMMENT ON COLUMN collect_file.detect_confidence IS '材料识别置信度'; +COMMENT ON COLUMN collect_file.detect_source IS '识别来源: FILENAME/形态KEYWORD/POSITION/VLM/MIXED'; +COMMENT ON COLUMN collect_file.detect_conflict IS '是否与槽位冲突(1=冲突)'; +COMMENT ON COLUMN collect_file.need_manual_place IS '是否需要人工归属(散图分拣用)'; +COMMENT ON COLUMN collect_file.vlm_called IS '是否触发了VLM兜底(用于成本核算)'; +COMMENT ON COLUMN collect_file.validate_status IS '0未校验 1通过 2警告 3错误'; +COMMENT ON COLUMN collect_file.source IS '1本人上传 2班主任代传 3管理员代传 4整理器导入'; +``` + +#### 7.3.12 校验问题 + +```sql +CREATE TABLE collect_validation_issue ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + submission_id bigint NOT NULL, + file_id bigint, + rule_code varchar(32) NOT NULL, + severity smallint NOT NULL, + field_key varchar(64), + title varchar(128) NOT NULL, + detail varchar(512), + expected_val varchar(256), + actual_val varchar(256), + auto_fixed smallint NOT NULL DEFAULT 0, + resolved smallint NOT NULL DEFAULT 0, + resolved_by bigint, resolved_time timestamp, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_issue_submission ON collect_validation_issue (submission_id, resolved); +CREATE INDEX idx_task_severity ON collect_validation_issue (task_id, severity, resolved); +COMMENT ON TABLE collect_validation_issue IS '校验问题'; +COMMENT ON COLUMN collect_validation_issue.file_id IS '可为空(表单级问题)'; +COMMENT ON COLUMN collect_validation_issue.rule_code IS 'VAL-01..VAL-12'; +COMMENT ON COLUMN collect_validation_issue.severity IS '1信息 2警告 3错误'; +COMMENT ON COLUMN collect_validation_issue.expected_val IS '期望值(如名册中的证件号)'; +COMMENT ON COLUMN collect_validation_issue.actual_val IS '实际值'; +COMMENT ON COLUMN collect_validation_issue.auto_fixed IS '是否已自动修复'; +``` + +#### 7.3.13 审核记录 + +```sql +CREATE TABLE collect_audit_record ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + submission_id bigint NOT NULL, + audit_node varchar(32) NOT NULL, + auditor_id bigint NOT NULL, + action smallint NOT NULL, + reason_code varchar(32), + reason_text varchar(512), + material_codes varchar(256), + duration_sec integer, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_audit_submission ON collect_audit_record (submission_id, create_time); +CREATE INDEX idx_task_node ON collect_audit_record (task_id, audit_node, create_time); +COMMENT ON TABLE collect_audit_record IS '审核记录'; +COMMENT ON COLUMN collect_audit_record.audit_node IS 'CLASS_AUDIT/ADMIN_AUDIT'; +COMMENT ON COLUMN collect_audit_record.action IS '1通过 2驳回 3批量通过 4批量驳回 5转交 6代传'; +COMMENT ON COLUMN collect_audit_record.reason_code IS '驳回原因编码'; +COMMENT ON COLUMN collect_audit_record.material_codes IS '被驳回的材料项'; +COMMENT ON COLUMN collect_audit_record.duration_sec IS '处理耗时'; +``` + +#### 7.3.14 导出任务 + +```sql +CREATE TABLE collect_export_task ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + export_name varchar(256) NOT NULL, + scope_json jsonb, + split_size_mb integer NOT NULL DEFAULT 0, + desensitize smallint NOT NULL DEFAULT 0, + status smallint NOT NULL DEFAULT 0, + progress smallint NOT NULL DEFAULT 0, + file_count integer, total_size bigint, + result_files jsonb, + error_msg varchar(512), + expire_time timestamp, + create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + finish_time timestamp, + PRIMARY KEY (id) +); +CREATE INDEX idx_task ON collect_export_task (task_id, create_time); +CREATE INDEX idx_status ON collect_export_task (status); +COMMENT ON TABLE collect_export_task IS '导出任务'; +COMMENT ON COLUMN collect_export_task.scope_json IS '范围:orgIds/userIds/仅通过终审/是否含原图'; +COMMENT ON COLUMN collect_export_task.split_size_mb IS '0不分卷,>0按MB分卷'; +COMMENT ON COLUMN collect_export_task.desensitize IS '是否脱敏'; +COMMENT ON COLUMN collect_export_task.status IS '0排队 1处理中 2完成 3失败'; +COMMENT ON COLUMN collect_export_task.progress IS '0-100'; +COMMENT ON COLUMN collect_export_task.result_files IS '生成的包列表[{name,path,size,part}]'; +COMMENT ON COLUMN collect_export_task.expire_time IS '下载有效期'; +``` + +#### 7.3.15 文件处理日志 + +```sql +CREATE TABLE collect_file_process_log ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + file_id bigint NOT NULL, + step varchar(32) NOT NULL, + before_val varchar(512), + after_val varchar(512), + extra_json jsonb, + cost_ms integer, + success smallint NOT NULL DEFAULT 1, + error_msg varchar(512), + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_file ON collect_file_process_log (file_id, step); +COMMENT ON TABLE collect_file_process_log IS '文件处理日志'; +COMMENT ON COLUMN collect_file_process_log.step IS 'NORMALIZE_NAME/COMPRESS/CONVERT/OCR/EXIF_FIX'; +COMMENT ON COLUMN collect_file_process_log.extra_json IS '如 {beforeSize,afterSize,attempts,quality,finalLongSide}'; +``` + +#### 7.3.16 通知记录与订阅额度 + +```sql +CREATE TABLE collect_notify_record ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint, + channel varchar(16) NOT NULL, -- WECHAT_SUBSCRIBE / WECHAT_MP / SMS / INBOX + biz_type varchar(32) NOT NULL, -- TASK_PUBLISH / DEADLINE_REMIND / REJECT / APPROVE / EXPORT_DONE / AUDIT_TIMEOUT + receiver_id bigint NOT NULL, -- 接收人 sys_user.id + receiver_role varchar(16), -- 接收人角色(便于按角色统计) + template_code varchar(64), + content varchar(1024), + status smallint NOT NULL DEFAULT 0, -- 0待发 1成功 2失败 3额度不足跳过 + fail_reason varchar(512), + send_time timestamp, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX idx_notify_task ON collect_notify_record (task_id, biz_type, create_time); +CREATE INDEX idx_notify_recv ON collect_notify_record (receiver_id, create_time); +COMMENT ON TABLE collect_notify_record IS '通知记录'; +COMMENT ON COLUMN collect_notify_record.channel IS '渠道: 微信订阅消息/服务号/短信/站内'; +COMMENT ON COLUMN collect_notify_record.biz_type IS '业务类型'; +COMMENT ON COLUMN collect_notify_record.status IS '0待发 1成功 2失败 3额度不足跳过'; +COMMENT ON COLUMN collect_notify_record.fail_reason IS '失败原因;额度不足时记录(应对R12订阅消息次数限制)'; + +-- 订阅额度余额(微信一次性订阅消息,发一条消耗一条) +CREATE TABLE collect_subscribe_quota ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + template_code varchar(64) NOT NULL, + total integer NOT NULL DEFAULT 0, -- 累计授权次数 + used integer NOT NULL DEFAULT 0, -- 已消耗次数 + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT uk_quota_user_tpl UNIQUE (user_id, template_code) +); +COMMENT ON TABLE collect_subscribe_quota IS '微信订阅消息额度余额'; +COMMENT ON COLUMN collect_subscribe_quota.total IS '累计授权次数'; +COMMENT ON COLUMN collect_subscribe_quota.used IS '已消耗次数;余额=total-used,不足时自动降级到服务号或短信'; +``` + +#### 7.3.17 规则模板 + +```sql +CREATE TABLE collect_rule_template ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + template_name varchar(128) NOT NULL, + cert_type smallint, + form_schema jsonb, + materials jsonb, + naming_template varchar(256), + is_public smallint NOT NULL DEFAULT 0, + use_count integer NOT NULL DEFAULT 0, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX idx_ruletpl_tenant ON collect_rule_template (tenant_id, is_public); +COMMENT ON TABLE collect_rule_template IS '规则模板'; +COMMENT ON COLUMN collect_rule_template.tenant_id IS '0=平台公共模板'; +COMMENT ON COLUMN collect_rule_template.template_name IS '如 自主认定标准模板'; +COMMENT ON COLUMN collect_rule_template.cert_type IS '1自主认定 2社会评价 3专项职业能力'; +COMMENT ON COLUMN collect_rule_template.form_schema IS '表单字段模板'; +COMMENT ON COLUMN collect_rule_template.materials IS '材料清单模板'; +``` + +#### 7.3.18 历史数据整理任务 + +```sql +CREATE TABLE collect_cleanup_job ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + job_name varchar(128) NOT NULL, + source_type smallint NOT NULL DEFAULT 1, + source_path varchar(512) NOT NULL, + target_task_id bigint, + naming_template varchar(256) NOT NULL DEFAULT '{姓名}-{证件号}-{材料名}', + compress_config jsonb, + status smallint NOT NULL DEFAULT 0, + progress smallint NOT NULL DEFAULT 0, + report_json jsonb, + total_files integer, fixed_files integer, failed_files integer, + result_path varchar(512), + create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + finish_time timestamp, + PRIMARY KEY (id) +); +CREATE INDEX idx_cleanup_tenant ON collect_cleanup_job (tenant_id, create_time); +COMMENT ON TABLE collect_cleanup_job IS '历史数据整理任务'; +COMMENT ON COLUMN collect_cleanup_job.source_type IS '1上传压缩包 2对象存储目录'; +COMMENT ON COLUMN collect_cleanup_job.target_task_id IS '结果可导入某任务'; +COMMENT ON COLUMN collect_cleanup_job.status IS '0排队 1解析中 2处理中 3完成 4失败'; +COMMENT ON COLUMN collect_cleanup_job.report_json IS '体检报告:命名违规/证件号异常/体积超标/缺失/重复'; +``` + +#### 7.3.19 名单加入申请(⚠️ 客户确认后新增) + +> 对应 FR-509 系列。解决"名单外的人只能找老师手工加人"的运营痛点。 + +```sql +CREATE TABLE collect_join_request ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + tenant_id bigint NOT NULL, + task_id bigint NOT NULL, + -- 申请人自述(证件号与手机号不存明文,与门禁一致) + real_name varchar(64) NOT NULL, + id_card_hash char(64) NOT NULL, + id_card_mask varchar(32) NOT NULL, + phone_hash char(64), + phone_mask varchar(32), + org_hint varchar(128), + reason varchar(500), + -- 审批 + status smallint NOT NULL DEFAULT 0, + reject_reason varchar(500), + roster_id bigint, + audit_by bigint, + audit_time timestamp, + -- 申请人标识(用于限流与自动绑定) + applicant_openid varchar(64) NOT NULL, + applicant_uid bigint, + source smallint NOT NULL DEFAULT 1, + create_by bigint, + create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by bigint, + update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted smallint DEFAULT 0 NOT NULL, + PRIMARY KEY (id) +); +-- 同证件号每任务仅保留 1 条"待审或已通过"申请;被驳回后可重新申请 +CREATE UNIQUE INDEX uk_join_task_idcard ON collect_join_request (task_id, id_card_hash) + WHERE deleted = 0 AND status IN (0, 1); +CREATE INDEX idx_join_task_status ON collect_join_request (task_id, status, create_time); +CREATE INDEX idx_join_openid ON collect_join_request (applicant_openid); +COMMENT ON TABLE collect_join_request IS '名单加入申请'; +COMMENT ON COLUMN collect_join_request.org_hint IS '申请人自述班级(选填,可由管理员修正)'; +COMMENT ON COLUMN collect_join_request.status IS '0待审 1已通过 2已驳回 3已失效(任务截止)'; +COMMENT ON COLUMN collect_join_request.roster_id IS '审批通过后生成的名册项'; +COMMENT ON COLUMN collect_join_request.applicant_openid IS '用于限流(每任务<=2次)与审批通过后自动绑定'; +COMMENT ON COLUMN collect_join_request.source IS '1门禁失败页申请 2管理员代录'; +``` + +### 7.4 对象存储路径规范 + +``` +# 原始文件(保留 30 天,可配) +raw/{tenantId}/{taskId}/{yyyyMM}/{uuid}.{ext} + +# 规范文件(长期保留) +std/{tenantId}/{taskId}/{orgId}/{materialCode}/{规范文件名} + +# 示例 +std/1001/2026090001/2001/IDCARD/张三-360121200010101234-身份证.jpg + +# 导出包(保留 7 天) +export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip +``` + +**说明** +- 对象存储私有读,访问走**签名 URL**(有效期 5 分钟); +- 上传走 **前端直传 OSS**(服务端签发 STS 临时凭证),避免应用服务器带宽瓶颈; +- 原始文件到期由定时任务清理(`collect_file.raw_expire_time`),仅保留规范文件; +- 规范文件名作为 OSS Key 的一部分需 URL 编码,同时单独存于 `collect_file.standard_name`。 + +### 7.5 索引与性能要点 + +| 场景 | 索引 | +|---|---| +| 待办列表(班主任) | `collect_submission(task_id, status, org_id)` | +| 名册匹配 | `collect_roster(task_id, id_card_hash)` 唯一索引 | +| 文件查重 | `collect_file(file_hash)` | +| 异常清单 | `collect_validation_issue(task_id, severity, resolved)` | +| 原始文件清理 | `collect_file(raw_expire_time)` | +| 证件号检索 | `sys_user.id_card_hash`(HMAC 而非明文,避免全表扫描) | + +--- + +## 8. 接口清单 + +### 8.1 通用约定 + +| 项 | 约定 | +|---|---| +| 风格 | RESTful,`/api/v1/**` | +| 鉴权 | JWT(PC 端 Header `Authorization: Bearer`;小程序端同) | +| 租户透传 | JWT 中携带 `tenantId`,服务端强制校验,防越租户 | +| 统一响应 | `{ "code": 0, "msg": "success", "data": {...} }`;业务错误码:4xxxx 客户端 / 5xxxx 服务端 | +| 分页 | `?pageNum=1&pageSize=20`,响应 `{list,total,pageNum,pageSize}` | +| 幂等 | 提交、上传、导出接口需 `Idempotency-Key` | +| 限流 | 登录 5 次/分钟;上传 200 次/分钟/用户;OCR 30 次/分钟/用户 | +| 加签 | 开放接口 `appKey + timestamp + nonce + HMAC-SHA256` | +| 时间 | ISO 8601,服务端统一 UTC+8 | + +### 8.2 认证与账号 + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| POST | `/api/v1/auth/login` | PC 账号密码登录 | 公开 | +| POST | `/api/v1/auth/logout` | 退出 | 登录用户 | +| GET | `/api/v1/auth/me` | 当前用户信息 + 权限菜单 | 登录用户 | +| PUT | `/api/v1/auth/password` | 修改密码 | 登录用户 | +| POST | `/api/v1/mp/login` | 小程序 code 换 session | 公开 | +| POST | `/api/v1/mp/bind-phone` | 微信手机号快捷绑定 | 登录用户 | +| POST | `/api/v1/mp/roster/match` | **门禁核验**:提交姓名 + 身份证号匹配名单(限流、统一失败提示) | 小程序登录 | +| GET | `/api/v1/users` | 用户列表 | 管理员 | +| POST | `/api/v1/users` | 新增用户 | 管理员 | +| PUT | `/api/v1/users/{id}` | 修改用户 | 管理员 | +| DELETE | `/api/v1/users/{id}` | 停用用户 | 管理员 | +| POST | `/api/v1/users/import` | 批量导入(Excel) | 管理员 | +| POST | `/api/v1/users/{id}/reset-password` | 重置密码 | 管理员 | + +### 8.3 组织与租户 + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/api/v1/orgs/tree` | 组织树 | 管理员 | +| POST | `/api/v1/orgs` | 新增组织 | 管理员 | +| PUT | `/api/v1/orgs/{id}` | 修改 | 管理员 | +| DELETE | `/api/v1/orgs/{id}` | 删除 | 管理员 | +| POST | `/api/v1/orgs/import` | 批量导入组织 | 管理员 | +| GET | `/api/v1/tenants` | 租户列表 | 平台运营 | +| POST | `/api/v1/tenants` | 开通租户 | 平台运营 | +| PUT | `/api/v1/tenants/{id}` | 修改租户/套餐 | 平台运营 | +| GET | `/api/v1/tenants/{id}/usage` | 用量统计 | 平台运营 | + +### 8.4 任务与配置 + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/api/v1/tasks` | 任务列表(按角色过滤) | 登录用户 | +| POST | `/api/v1/tasks` | 创建任务 | 管理员 | +| GET | `/api/v1/tasks/{id}` | 任务详情 | 管理员/班主任 | +| PUT | `/api/v1/tasks/{id}` | 修改任务 | 管理员 | +| DELETE | `/api/v1/tasks/{id}` | 删除(草稿) | 管理员 | +| POST | `/api/v1/tasks/{id}/publish` | 发布任务 | 管理员 | +| POST | `/api/v1/tasks/{id}/close` | 截止任务 | 管理员 | +| POST | `/api/v1/tasks/{id}/clone` | 克隆任务 | 管理员 | +| POST | `/api/v1/tasks/{id}/archive` | 归档任务(⚠️ 复审补充) | 管理员 | +| GET | `/api/v1/tasks/{id}/orgs` | 查询任务覆盖班级及班主任(⚠️ 复审补充) | 管理员 | +| PUT | `/api/v1/tasks/{id}/orgs` | 设置任务覆盖班级、指定/调整班主任(⚠️ 复审补充) | 管理员 | +| GET | `/api/v1/tasks/{id}/form-schema` | 获取表单定义 | 管理员 | +| PUT | `/api/v1/tasks/{id}/form-schema` | 保存表单定义(版本化) | 管理员 | +| GET | `/api/v1/tasks/{id}/materials` | 材料项列表 | 管理员/班主任 | +| POST | `/api/v1/tasks/{id}/materials` | 新增材料项 | 管理员 | +| PUT | `/api/v1/materials/{id}` | 修改材料项 | 管理员 | +| DELETE | `/api/v1/materials/{id}` | 删除材料项 | 管理员 | +| **POST** | **`/api/v1/tasks/{id}/naming-rules/preview`** | **命名规则预览:传样本文件名,返回规范化结果与问题** | 管理员 | +| GET | `/api/v1/tasks/{id}/statistics` | 任务统计 | 管理员/班主任 | +| GET | `/api/v1/tasks/{id}/qrcode` | 获取填报二维码 | 管理员 | +| GET | `/api/v1/rule-templates` | 规则模板列表 | 管理员 | +| POST | `/api/v1/rule-templates/{id}/apply` | 应用模板到任务 | 管理员 | + +### 8.5 名册 + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| POST | `/api/v1/tasks/{id}/roster/import` | 导入名册(返回体检报告) | 管理员/班主任 | +| GET | `/api/v1/tasks/{id}/roster` | 名册列表/分页 | 管理员/班主任 | +| PUT | `/api/v1/roster/{rid}` | 修改名册项 | 管理员 | +| DELETE | `/api/v1/roster/{rid}` | 删除名册项 | 管理员 | +| GET | `/api/v1/tasks/{id}/roster/template` | 下载导入模板 | 管理员 | +| POST | `/api/v1/roster/{rid}/notify` | 定向催报 | 管理员/班主任 | +| POST | `/api/v1/tasks/{id}/roster/notify-unsubmitted` | 一键催报未提交者 | 管理员/班主任 | + +### 8.6 填报(小程序端) + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/api/v1/mp/tasks` | 我的任务列表(含进度) | 填报人 | +| GET | `/api/v1/mp/tasks/{id}/form` | 获取表单结构 + 已填数据 | 填报人 | +| POST | `/api/v1/mp/submissions` | 创建/获取草稿 | 填报人 | +| PUT | `/api/v1/mp/submissions/{id}` | 保存草稿(自动保存) | 填报人 | +| POST | `/api/v1/mp/submissions/{id}/submit` | 提交 | 填报人 | +| POST | `/api/v1/mp/submissions/{id}/withdraw` | 撤回(未审核前) | 填报人 | +| GET | `/api/v1/mp/submissions/{id}/progress` | 填报进度与问题清单 | 填报人 | +| POST | `/api/v1/mp/files/upload-token` | 获取 OSS 直传凭证(含路径规则) | 填报人 | +| **POST** | **`/api/v1/mp/files/{fileId}/process`** | **触发规范化:改名+压缩+格式转换+校验(详见 8.9)** | 填报人 | +| GET | `/api/v1/mp/files/{fileId}` | 获取预览签名 URL | 填报人 | +| DELETE | `/api/v1/mp/files/{fileId}` | 删除材料(软删,保留版本) | 填报人 | +| POST | `/api/v1/mp/ocr/idcard` | 身份证 OCR 识别 | 填报人 | +| POST | `/api/v1/mp/files/batch-process` | 批量处理(多材料一次提交) | 填报人 | + +### 8.6b 班主任移动端(⚠️ 复审补充) + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/api/v1/mp/teacher/dashboard` | 班级看板(完成率、未提交、异常数) | 班主任 | +| GET | `/api/v1/mp/teacher/audit/todos` | 待审列表(学生卡片流) | 班主任 | +| GET | `/api/v1/mp/teacher/audit/{submissionId}` | 学生详情(材料预览 + 问题清单) | 班主任 | +| POST | `/api/v1/mp/teacher/audit/{submissionId}/approve` | 通过 | 班主任 | +| POST | `/api/v1/mp/teacher/audit/{submissionId}/reject` | 驳回(原因快选 + 可选语音转文字) | 班主任 | +| POST | `/api/v1/mp/teacher/audit/batch-approve` | 一键通过全部绿色项 | 班主任 | +| POST | `/api/v1/mp/teacher/proxy-upload` | 代传材料(记录代传人) | 班主任 | +| POST | `/api/v1/mp/teacher/notify/unsubmitted` | 一键催报未提交者 | 班主任 | +| GET | `/api/v1/mp/teacher/notify/records` | 催报历史 | 班主任 | +| **POST** | **`/api/v1/mp/subscribe`** | **请求并登记微信订阅消息授权次数(⚠️ 应对 R12)** | 全部用户 | + +### 8.7 审核 + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| GET | `/api/v1/audit/todos` | 待办列表(含红黄绿状态) | 班主任/管理员 | +| GET | `/api/v1/audit/submissions/{id}` | 填报单详情(含材料预览、问题清单) | 班主任/管理员 | +| POST | `/api/v1/audit/submissions/{id}/approve` | 通过 | 班主任/管理员 | +| POST | `/api/v1/audit/submissions/{id}/reject` | 驳回(原因) | 班主任/管理员 | +| **POST** | **`/api/v1/audit/batch`** | **批量通过/驳回(详见 8.10)** | 班主任/管理员 | +| GET | `/api/v1/audit/submissions/{id}/timeline` | 审核时间轴 | 班主任/管理员 | +| GET | `/api/v1/audit/statistics` | 审核统计 | 管理员 | +| GET | `/api/v1/audit/reason-codes` | 驳回原因字典 | 班主任/管理员 | +| POST | `/api/v1/audit/submissions/{id}/proxy-upload` | 代传材料 | 班主任/管理员 | + +### 8.8 校验、导出与通知 + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| POST | `/api/v1/validation/run` | 触发任务级全量校验 | 管理员 | +| GET | `/api/v1/validation/issues` | 问题清单(按严重级别筛选) | 管理员/班主任 | +| GET | `/api/v1/validation/issues/export` | 导出异常清单 Excel | 管理员 | +| POST | `/api/v1/exports` | 创建导出任务 | 管理员 | +| GET | `/api/v1/exports` | 导出历史 | 管理员 | +| GET | `/api/v1/exports/{id}` | 导出进度 | 管理员 | +| GET | `/api/v1/exports/{id}/download` | 获取下载签名 URL | 管理员 | +| GET | `/api/v1/notify/templates` | 通知模板 | 管理员 | +| PUT | `/api/v1/notify/templates/{id}` | 修改模板 | 管理员 | +| POST | `/api/v1/notify/send` | 手动发送通知 | 管理员/班主任 | +| GET | `/api/v1/notify/records` | 通知记录 | 管理员 | +| POST | `/api/v1/callback/wechat/msg` | 微信订阅消息回调 | 公开 | + +### 8.9 关键接口详述 + +#### ① 上传凭证 `POST /api/v1/mp/files/upload-token` + +**请求** +```json +{ + "taskId": 2026090001, + "materialCode": "IDCARD", + "fileName": "曾伊豪-361029200907295110身份证.jpg.jpg", + "fileSize": 17962304, + "contentType": "image/jpeg" +} +``` + +**响应** +```json +{ + "code": 0, + "data": { + "fileId": 987654321, + "uploadUrl": "https://oss.xxx.com/raw/1001/...&Signature=...", + "objectKey": "raw/1001/2026090001/202609/9f3a....jpg", + "expireSeconds": 900, + "clientCompressHint": { + "enabled": true, + "maxLongSide": 1600, + "quality": 80, + "targetSizeKb": 1024 + } + } +} +``` + +#### ② 文件规范化 `POST /api/v1/mp/files/{fileId}/process` + +**请求** +```json +{ "async": true, "runOcr": true, "dryRun": false } +``` + +**响应(同步模式)** +```json +{ + "code": 0, + "data": { + "fileId": 987654321, + "originalName": "曾伊豪-361029200907295110身份证.jpg.jpg", + "standardName": "曾伊豪-361029200907295110-身份证.jpg", + "rawSize": 17962304, + "finalSize": 524288, + "compressRatio": "97.1%", + "width": 1600, "height": 1200, "quality": 74, + "issues": [ + { "ruleCode": "VAL-06", "severity": 1, "title": "格式已转换", "detail": "jpg.jpg → jpg", "autoFixed": true }, + { "ruleCode": "VAL-02", "severity": 3, "title": "身份证号与名册不一致", + "expectedVal": "3610292009072**110", "actualVal": "3610292009072**510", + "detail": "OCR 识别值与名册一致,文件名号码有误,已按名册修正" } + ], + "ocr": { "status": 1, "confidence": 0.973, "fields": { "name": "曾伊豪", "idCardHash": "a1b2..." } } + } +} +``` + +**异步模式**:返回 `taskToken`,通过 `GET /api/v1/mp/files/{fileId}/process-status` 轮询或 SSE 推送进度。 + +#### ③ 提交 `POST /api/v1/mp/submissions/{id}/submit` + +**请求**:`{ "confirmWarning": true }` + +**校验失败响应** +```json +{ + "code": 40001, + "msg": "存在 2 项必须处理的材料问题", + "data": { + "canSubmit": false, + "blocking": [ + { "materialCode": "SCHOOL_ROLL", "ruleCode": "VAL-05", "title": "缺少材料:学籍表" }, + { "materialCode": "IDCARD", "ruleCode": "VAL-04", "title": "本材料证件号与其他材料不一致", + "detail": "一寸照片为 360122200902044232,身份证为 36010520090817121X" } + ], + "warnings": [ + { "materialCode": "PHOTO1", "ruleCode": "VAL-08", "title": "照片分辨率偏低,建议让对方发一张原图" } + ] + } +} +``` + +#### ④ 批量审核 `POST /api/v1/audit/batch` + +**请求** +```json +{ + "taskId": 2026090001, + "submissionIds": [1001, 1002, 1003], + "action": "APPROVE", + "reasonCode": null, + "reasonText": null +} +``` + +**响应** +```json +{ + "code": 0, + "data": { "success": 2, "skipped": 1, "failed": 0, + "details": [{ "submissionId": 1003, "skipped": true, "reason": "存在红色问题,需单独审核" }] } +} +``` + +#### ⑤ 创建导出 `POST /api/v1/exports` + +**请求** +```json +{ + "taskId": 2026090001, + "exportName": "202609机电认定-24中技数控1班", + "scope": { "orgIds": [2001], "onlyApproved": true, "includeRaw": false }, + "splitSizeMb": 50, + "desensitize": false, + "format": "ZIP", + "generateSummaryExcel": true, + "notifyOnFinish": true +} +``` + +**响应** +```json +{ "code": 0, "data": { "exportId": 555, "status": 0, "estimatedSeconds": 45 } } +``` + +### 8.10 开放接口(预留,供认定机构对接) + +| 方法 | 路径 | 说明 | +|---|---|---| +| 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` | 主动推送审核结果到机构系统 | + +--- + +## 9. 移动端页面与交互 + +### 9.1 小程序页面清单 + +| 页面 | 路径 | 关键交互 | +|---|---|---| +| 首页(任务列表) | `pages/index` | 卡片式展示任务;显示进度环"已完成 3/5 项";倒计时提醒 | +| **门禁核验** | `pages/gate/verify` | ⚠️ **新增(入口第一屏)**:填写「姓名 + 身份证号」;身份证输入用数字键盘、自动分段、末位 X 大写、支持粘贴;本地先校验格式与校验位;提交后匹配名单 | + | **门禁失败** | `pages/gate/fail` | 统一提示"没有找到你的信息";**两个系统内动作:重新填写 / 申请加入名单**(FR-509)。**不展示任何联系方式** | +| 任务详情 | `pages/task/detail` | 材料清单、状态、示例图、操作指引 | +| 信息填写 | `pages/form/index` | 分步表单;已匹配名册自动回填;身份证 OCR 一键识别 | +| 材料上传 | `pages/upload/index` | **来源选择:微信聊天记录 / 手机相册 / 手机文件**(无"拍照");每个材料项附"去哪里找"引导;上传即显示"压缩中 92%…";实时校验结果 | +| 材料来源选择 | `pages/upload/source` | 半屏弹层,三个来源卡片 + 每个材料的引导文案;**推荐标"从聊天记录选"**(可保留原始文件名) | +| 文件预览 | `pages/preview/index` | 原图/压缩图对比滑动条;显式展示规范文件名 | +| 提交确认 | `pages/submit/confirm` | 红黄绿三项汇总;阻挡项明确提示"去处理" | +| 我的状态 | `pages/status/index` | 审核时间轴;驳回原因与修改入口 | +| 消息中心 | `pages/message/index` | 系统通知、催报、审核结果 | + +### 9.2 关键交互设计 + +**⓪ 门禁核验(第一道关,决定后续一切)** + +``` +扫码进入 + ↓ 微信授权(静默) + ↓ ┌────────────────────────────────────┐ + │ 请填写你的信息,与老师提供的名单核对 │ + │ │ + │ 姓名 [ 张 三 ] │ + │ 身份证号 [ 3601 21 2000 1010 1234 ] │ ← 数字键盘、自动分段、末位X大写 + │ │ + │ [ 下一步 ] │ + └────────────────────────────────────┘ + ↓ 本地先校验(位数、校验位)→ 不合格当场提示,不发请求 + ↓ 服务端匹配名单 + ├─ 命中 ─▶ 回填【全部预填字段】→ 进入填报页 + │ 🔒 姓名 / 身份证号 / 报考职业 / 报考工种 / 报考等级 → 只读 + │ ✏️ 其余字段 → 预填并标注"老师已填,请核对",可改 + │ 📄 学生真正要做的:上传材料 + ├─ 号码命中但姓名形近 ─▶ "你是不是 杨晨?" ─▶ 确认后通过 + └─ 未命中 ─▶ ┌──────────────────────────────────┐ + │ 没有找到你的信息 │ + │ 请核对姓名和身份证号是否与老师 │ + │ 提供的名单一致。 │ + │ 如果确实已报名,可申请加入名单。  │ + │ │ + │[ 重新填写 ] [ 申请加入名单 ]  │ + └──────────────────────────────────┘ +``` + +> 🔒 **两条不能妥协的规则**: +> 1. **失败提示不区分原因**——无论"身份证号不在名单"还是"姓名不匹配",**文案完全一致**。否则可被用来逐个探测某个身份证号是否在名单里。 +> 2. **必须限流**——同 openid 每小时 ≤10 次,超出锁定。 + +> 💡 **学生端实际工作量极小**:实测老师交上来的名单里 21 个字段已填满 18 个(含学历、在校生、学校、专业、入学时间、报考三项、联系电话)。学生匹配成功后**只需核对 + 上传材料**——这才是"3 分钟填完"的真正实现方式。 + +**① 上传即规范化(核心体验)** + +``` +点击"选择材料" + ↓ 弹出来源(无"拍照"选项): + ┌──────────────────────────────────────┐ + │ 📋 从微信聊天记录选择 ← 推荐 │ + │ 照相馆/打印店发的文件在这里 │ + │ 🖼 从手机相册选择 │ + │ 系统截图、已有的照片在这里 │ + │ 📄 从手机文件选择 │ + │ PDF 扫描件、Word 申报表在这里 │ + └──────────────────────────────────────┘ + ↓ 客户端预处理(方向校正 + 长边限幅 ≤2400px,不做有损压缩) + ↓ 上传至 OSS(显示进度条) + ↓ 服务端规范化(改名 / 精准压缩 / 校验 / OCR) + ↓ 卡片翻转动画展示: + ┌────────────────────────────────────┐ + │ 曾伊豪-361029200907295110身份证.jpg.jpg │ ← 原文件名(红色删除线) + │ ↓ 已自动规范 │ + │ 曾伊豪-361029200907295110-身份证.jpg │ ← 规范文件名(绿色) + │ 17.1MB → 810KB(压缩 95%,清晰度无损) │ + └────────────────────────────────────┘ +``` + +**①-a 来源提示(决定成败的细节)** + +| 材料 | 界面提示 | +|---|---| +| 一寸照片 | "照相馆给过电子版吗?在**微信聊天记录**里搜一下" | +| 工作证明 | "打印店扫描后会发给你,从**聊天记录**里选" | +| 学籍表 | "教务系统截图后,从**手机相册**里选" | +| 身份证 | "扫描件或拍过的照片都行,从聊天记录或相册里选" | + +> 💡 **优先引导用户从聊天记录选**:该通道能拿到**原始文件名**,命名规范化引擎可直接读出姓名、证件号、材料类型,一次通过率最高;相册来源只能拿到 `IMG_0001.jpg` 这类名字,必须靠 OCR 兜底。 + +**② 错误提示文案(面向家长,必须"零术语")** + +| 场景 | 文案 | +|---|---| +| 证件号与名册不符 | 「身份证号第 X 位和老师名单上的不一样,请核对身份证原件(老师名单上是 `3601...1234`)」 | +| 文件过大且压缩受限 | 「这张图太复杂,压到最小还是 1.2M,建议重新拍一张更清晰的」 | +| 照片可能是别人 | 「这张"一寸照片"上的身份证号是 `3601...4232`,和你其他材料不一致,请检查是否传错了」 | +| 缺少材料 | 「还差 1 项:学籍表。点击去上传 →」 | +| 照片模糊 | 「照片有点模糊,公章可能看不清,建议找打印店重新扫描一份,或让对方发一张原图给你」 | + +**③ 无障碍与低门槛** + +- 全流程最多 3 步、单页最多 6 个字段; +- 支持"上一步保存的草稿"自动恢复; +- 支持语音输入(可选); +- 大字号模式(适配家长群体)。 + +### 9.3 管理端(PC)页面清单 + +| 模块 | 页面 | +|---|---| +| 工作台 | 待办、任务总览、异常概览、快捷入口 | +| 收集任务 | 任务列表 / 任务向导(7 步)/ 任务详情与统计 | +| 表单设计器 | 拖拽式字段编排、条件显示配置、实时预览 | +| 材料规则 | 材料项配置、命名规则可视化编辑器(**支持上传样本文件名试跑**) | +| 名册 | 导入向导、体检报告、名册列表、认领管理 | +| 审核中心 | 待办列表、学生卡片视图、批量通过、时间轴 | +| 数据中心 | 异常清单、完成率看板、班级排行、材料统计 | +| 导出中心 | 创建导出、进度、历史、下载 | +| 通知中心 | 模板管理、发送记录、催报设置 | +| 系统设置 | 组织、用户、角色、字典、日志、套餐用量 | +| 整理器(差异化) | 上传压缩包 → 体检报告 → 一键规范化 → 下载结果 | + +**命名规则编辑器**(重点) + +``` +左侧:模板变量面板(拖拽) 右侧:实时试跑 + ┌──────────────────────┐ ┌────────────────────────────────┐ + │ {姓名} {证件号} │ │ 原始:曾伊豪-...110身份证.jpg.jpg │ + │ {材料名} {班级} │ → │ 结果:曾伊豪-...110-身份证.jpg │ + │ {后缀} {序号} {日期} │ │ 状态:✅ 已自动修复 │ + └──────────────────────┘ └────────────────────────────────┘ + 模板:[{姓名}-{证件号}-{材料名}{后缀}] [批量试跑 20 条] [下载报告] +``` + +### 9.4 班主任移动端(小程序)页面清单 ⚠️ 初稿缺失,已补 + +> **用户明确要求"班主任在电脑或手机操作"**,但初稿只给了 PC 管理端和填报端,遗漏了班主任移动端。这是使用频次最高的一环——班主任通常在微信里被学生追问、在通勤路上随手处理。 + +| 页面 | 路径 | 关键交互 | +|---|---|---| +| 班级看板 | `pages/teacher/dashboard` | 完成率进度条、未提交名单、异常项数;顶部大按钮"一键催报未提交" | +| 待审列表 | `pages/teacher/audit/list` | 学生卡片流:头像、姓名、5 项材料缩略图、🟢🟡🔴 状态点;支持按状态筛选(只看红/只看未审) | +| 学生详情 | `pages/teacher/audit/detail` | 材料大图横滑预览、缩放、**原图/压缩图对比**;问题清单;底部"通过 / 驳回" | +| 批量通过 | `pages/teacher/audit/batch` | 勾选全部绿项 → 一键通过;红黄项自动留在列表 | +| 驳回反馈 | `pages/teacher/audit/reject` | 常用原因快选(§附录 C)+ 语音输入补充 | +| 代传材料 | `pages/teacher/proxy-upload` | 班主任帮学生上传/替换材料,记录代传人 | +| 催报中心 | `pages/teacher/notify` | 未提交/未通过名单、一键催报、催报历史 | +| 消息中心 | `pages/teacher/message` | 学生提交提醒、超时提醒、导出完成通知 | + +**设计约束** +- 一屏能看完整班状态(40 人无需滚动超过 2 屏); +- 审批动作 ≤3 次点击(进入 → 选人 → 通过); +- 图片预览使用**渐进式加载 + 缩略图优先**,弱网下先出低清再补高清; +- **只支持「右滑通过」,不支持左滑驳回**,且必须带二次确认。 + > ⚠️ **设计决策(已确认)**:通过是**正向动作**、可撤回,适合用低阻力的滑动触发;驳回是**负向动作**、需要选原因、会打断学生的流程,**必须点按钮**完成。若两者都用滑动,班主任在通勤路上一次误触就可能误驳学生——而驳回的代价(学生重传、班主任重审)远高于通过。 + +--- + +## 10. 非功能需求 + +### 10.1 性能指标 + +| 项 | 指标 | +|---|---| +| 页面首屏(小程序) | ≤2s(4G 网络) | +| 表单保存(草稿自动保存) | ≤500ms | +| 客户端压缩(4000×3000) | ≤1.5s | +| 服务端规范化(改名+压缩+校验) | P95 ≤3s | +| OCR 识别 | P95 ≤2.5s | +| 导出打包(1 个班 112 文件) | ≤60s | +| 导出打包(50 个班 5600 文件) | ≤10min(异步) | +| 并发上传 | ≥500 并发 | +| 单文件上传上限 | 50MB(客户端预压后实际远小于此) | +| **图片解码内存峰值** | **≤1GB/进程**(实测存在 1.8 亿像素图片,必须用 libvips 流式处理,详见 §6.3 安全闸门) | +| **超大图处理耗时** | 1.8 亿像素扫描件 ≤6s;超 20s 熔断并转异步 | +| **图片处理并发上限** | 按内存反推设定(不得按 CPU 核数设定),超出进队列 | + +### 10.2 容量与成本估算 + +**以 100 所学校 / 校均 30 个班 / 班均 40 人 / 人均 4 类材料 测算**(单文件体积取自 §1.2 实测均值): + +| 项 | 计算 | 结果 | +|---|---|---| +| 文件数 | 100×30×40×4 | **48 万个/学期** | +| 规范文件存储 | 48 万 × 0.33MB(实测均值) | **约 158 GB/学期** | +| 原始文件存储 | 48 万 × 4.24MB(实测均值) | **约 2.0 TB/学期** ⚠️ | +| OCR 调用次数 | 48 万 × 1.5 | **约 72 万次/学期** | + +> ✅ 修正:初稿的"原始平均 8MB / 3.8TB"是按 1000 万像素手机照片估的;实测该批数据是**扫描件**,均值 4.24MB,故修正为 2.0TB。 +> ⚠️ **关键结论不变且更紧迫:原始文件绝不能长期保留。** 2.0TB vs 158GB 仍相差 13 倍。 +> 💡 **客户端预压缩**可将单班上传量从 **475MB 降至约 37MB**(降幅 92%),是成本控制的第一道闸门。 + +**参考成本(公有云,仅供参考)** +- 对象存储:288GB ≈ ¥35/月(标准存储) +- 外网下行流量:按需,建议导出走内网 + 签名 URL +- OCR:72 万次/学期 × ¥0.015 ≈ ¥10,800/学期 ⇒ **建议对"高置信度"结果缓存,同一张图不重复识别** +- 短信:¥0.045/条,建议仅用于未绑定微信的用户 + +### 10.3 安全与合规 + +依据《个人信息保护法》《数据安全法》及教育行业数据管理要求: + +| 项 | 措施 | +|---|---| +| 传输安全 | 全站 HTTPS/TLS 1.2+;OSS 私有读 + 5 分钟签名 URL | +| 存储加密 | 身份证号、手机号 **AES-256 加密存储**;检索用 HMAC 索引(`id_card_hash`) | +| 展示脱敏 | 默认 `3601**********1234`;查看明文需二次授权并记录日志 | +| 访问控制 | 租户隔离 + 数据权限(本班/本院系)+ 最小权限原则;**名单不下发前端,匹配在服务端完成** | +| **门禁防枚举** | ⚠️ 新增。① 匹配失败**不区分原因**(防探测某身份证号是否在名单中);② 匹配接口**限流**(同 openid 每小时 ≤10 次、同 IP 每分钟 ≤20 次);③ 失败尝试**不记录明文姓名与身份证号**;④ 名单接口禁止任何形式的导出枚举 | +| 审计留痕 | 登录、查看明文、下载、导出、代传、删除全部记录(含 IP、UA) | +| 授权同意 | 填报前需勾选《个人信息处理告知同意书》,未成年人需监护人同意 | +| 水印 | 预览与导出可加**隐形水印**(含操作人 ID),防外泄溯源 | +| 反爬防盗链 | 签名 URL 一次性、限时、限 IP 段(可选) | +| 内容安全 | 小程序 UGC 内容调用微信 `msgSecCheck`(图片 + 文本) | +| 数据留存 | 原始文件 30 天;规范文件随任务保留;账号注销后 30 天内清除 | +| 等保 | 目标等保二级(SaaS 公有云) | +| **微信小程序隐私保护指引** | ⚠️ 初稿遗漏。小程序需在微信后台**声明收集的敏感信息**(身份证号、手机号、**相册(仅上传)**),并配置《用户隐私保护指引》。**未配置将无法调用相关接口、审核不通过**。需在 M1 阶段完成 | +| **受托处理者协议** | 与学校/机构签署《数据处理协议》(DPA),明确 SaaS 方为受托处理者、校方为个人信息处理者 | +| **密钥管理** | AES 密钥托管于 KMS,禁止硬编码;密钥轮换周期 ≤12 个月 | + +### 10.4 兼容性 + +| 端 | 要求 | +|---|---| +| 微信小程序 | 基础库 ≥2.30;覆盖微信 8.0+;iOS 14+ / Android 8+。
**依赖能力**:`wx.chooseMessageFile`(从聊天记录选文件,需基础库 ≥2.5.0)、`wx.chooseMedia`(相册,需 ≥2.10.0)、`wx.uploadFile`、订阅消息。**不使用相机能力**(默认关闭拍照入口) | +| PC 管理端 | Chrome / Edge 100+(推荐 Chromium 内核) | +| 图片格式 | 输入:JPG/JPEG/PNG/HEIC/BMP/TIFF/PDF(含从聊天记录选中的 PDF/Word 扫描件);输出:JPG/PNG/PDF | + +### 10.5 可维护性 + +- 规则引擎配置化(材料、别名、校验规则、压缩档位均不入代码); +- 全链路追踪(traceId 贯穿上传 → 处理 → 校验 → 审核 → 导出); +- 文件处理失败可重放(保留原图 30 天即为重放保障); +- 关键算法(命名解析、压缩、校验)需覆盖单元测试,**以本项目 28 人真实数据为回归测试集**。 + +--- + +## 11. 实施计划与里程碑 + +| 里程碑 | 内容 | 工期 | 交付物 | +|---|---|---|---| +| **M0 需求与原型** | 需求确认、原型设计、**§14 待确认项闭环(尤其 Q1/Q2)** | 1 周 | 原型图、需求确认书、**压缩参数书面确认函** | +| **M1 基础平台** | 租户、组织、用户、角色、登录、字典、**微信小程序隐私保护指引配置** | 2 周 | 可登录的骨架系统 + 隐私合规就绪 | +| **M2 任务与表单** | 任务向导、动态表单引擎、名单导入(含**报考范围聚合**与**字段三类规则**)、任务-班级绑定 | 2 周 | 可建任务、可导名单 | +| **M3 规则引擎(核心)** | 命名规范化、**五档压缩(libvips)**、格式转换、校验位裁决、OCR、**超大图 OOM 防护** | 3.5 周(A/B 验证已因实测达标而取消,较 V1.2 减 0.5 周) | 可上传即规范化 | +| **M4 审核与导出** | 审核工作台、批量审核、汇总导出、分卷打包 | 2 周 | 全流程闭环(PC 端) | +| **M5 移动端** | 小程序填报端 + **班主任端**、通知与催报(含订阅消息授权策略) | 2.5 周 | 手机端可用 | +| **M6 整理器** | 历史数据批量规范化 + 体检报告 | 1.5 周 | 获客工具 | +| **M7 联调与试运行** | 压测(**必须用 28 张 1.6 亿像素真实扫描件**)、真实数据回归、试点 1–2 个班 | 2 周 | 试运行报告 | +| **合计** | 部分任务可并行 | **12.5–14.5 周** | V1.0 上线 | + +**MVP 建议范围(若需 6 周内出可用版本)** +保留:M1 + M2(简化版:表单字段固定为模板)+ M3 + M4 + M5 +砍掉:整理器(M6)、证件照规格化、自动催报、部分统计看板 + +> ⚠️ **不建议再压缩 M3**:超大图 OOM 防护与压缩参数验证是项目成败的关键路径,压缩这部分的工期会直接把风险推到试运行阶段(即"在真实客户面前爆炸")。 + +--- + +## 12. 验收标准 + +### 12.1 功能验收(以真实数据为基准测试集) + +使用 `D:\项目\202609机电填报系统\24中技数控1班` 全部 112 个文件作为 **Golden Test Set**: + +| 验收项 | 期望结果 | +|---|---| +| 命名合规率(处理后) | 112 个文件输出规范名,**合规率 100%** | +| 需改名文件 | 精确识别 **59 个**(误差 0),不多改、不漏改 | +| 双扩展名修复 | **56 个** `.jpg.jpg` 全部修复(工作证明 28 + 身份证 28) | +| 缺分隔符修复 | 2 个(曾伊豪、徐程宇)全部修复 | +| 大小写统一 | 2 个末位 `x` → `X` | +| 证件号异常检出 | **必须检出全部 5 例**,且按 §6.1 裁决矩阵正确分类 | +| ↳ 可自动改名 | 2 例:于豪、杨晨(校验位失败 → 裁决为文件名错误) | +| ↳ 名册兜底补齐(标黄确认) | 2 例:曹文锋、黎思明(17 位,无法校验) | +| ↳ 必须人工 | **1 例**:陈子文(校验位均通过 → 串档,不得自动修正) | +| 姓名纠错 | `扬晨` → `杨晨`(编辑距离 1 + 拼音一致,可自动);其余 27 人姓名不得误改 | +| 体积达标率 | 31 个超标文件全部压至 ≤1M;**另 81 个文件不得被无谓重压** | +| 过度压缩保护 | `杨晨-身份证`(558×745) 必须被标记"分辨率不足"而非被进一步压缩 | +| 截图类保护 | 学籍表 28 个 PNG 保持 PNG 且不缩放(像素尺寸不变) | +| 图片处理稳定性 | 处理 28 个 1.6 亿像素扫描件**不得 OOM**,内存峰值 ≤1GB/进程 | +| 章迹清晰度 | 压缩后公章文字在 100% 缩放下肉眼可辨(人工抽检 10 份);**输出为 300dpi 标准扫描件**,实测 28/28 达标 | +| 班级包总量 | 从 475MB 降至 **≤40MB**(实测预期约 37MB) | +| 导出目录结构 | 与本文档 5.9 规范的目录树一致 | +| 端到端耗时 | 单班从上传完成到导出可上报 **≤15 分钟**(不含学生填报时间) | + +> 📊 **测试基线已固化**:本节的 59 / 56 / 31 / 3 等数字均来自 2026-09-24 的实测脚本,可直接作为自动化回归断言的期望值。 + +### 12.2 性能验收 + +- 500 并发上传无失败; +- 单文件规范化 P95 ≤3s(1.8 亿像素超大图豁免,单独 ≤6s); +- **处理 28 个 1.6 亿像素扫描件时,JVM/进程内存峰值 ≤1GB,无 OOM**; +- 1 个班导出打包 ≤60s。 + +### 12.3 安全验收 + +- 越权测试:A 租户无法访问 B 租户任意资源(含文件签名 URL); +- 明文查看、导出、下载均有审计日志; +- 数据库中的证件号、手机号为加密存储(肉眼不可读); +- 签名 URL 过期后无法访问; +- **微信小程序隐私保护指引已配置并通过审核**。 + +--- + +## 13. 风险与对策 + +| # | 风险 | 影响 | 对策 | +|---|---|---|---| +| R1 | ~~认定机构不接受强制压缩后的清晰度~~ **已基本解除,降为低风险** | **低** | ✅ 实测证明 300dpi/q85 下 28/28 达标(平均 806KB),输出的是**标准 300dpi 扫描件**,清晰度无损失,机构无正当理由拒收。
剩余动作:① 取得机构对"300dpi 扫描件"的书面认可(走个形式);② 保留原图 30 天可随时重出;③ 清晰度标记 + 抽检机制作为兜底保留 | +| R2 | OCR 对**低质量来源图片**识别率低(聊天中被压缩过的图、翻拍照有反光/遮挡) | 中 | ① 置信度分级 + 人工兜底;② **优先引导从聊天记录选文件**(可用原始文件名,不完全依赖 OCR);③ 检测到分辨率偏低时提示"索要原图";④ 双引擎兜底(腾讯云 + 百度) | +| R3 | 微信小程序审核受阻(涉及教育/UGC 类目、需资质) | 高 | ① 提前确认类目与资质(可能需 ICP + 办学许可或合作方资质);② 备选 H5 方案(扫码即填)保底 | +| R4 | 个人信息合规(身份证号属敏感个人信息) | 高 | 见 10.3;签约时明确数据处理者角色与责任划分(SaaS 服务商 = 受托处理者) | +| R5 | 存储与带宽成本超预期(原始文件 3.8TB/学期) | 中 | 强制原始文件 30 天清理 + 客户端预压缩 + 导出走签名 URL | +| R6 | 老师抵触新系统("又要多学一个东西") | 高 | ① "历史数据整理器"作为钩子,先让老师看到价值;② 名册 Excel 直接导入,无需重复录入;③ 一屏批量审核,比现在更快 | +| R7 | 学生不会操作 / 使用老旧手机 | 中 | 三步极简流程 + 班主任代传兜底 + 大字号模式 | +| R8 | 重复提交 / 版本混乱 | 低 | 版本化文件管理 + 导出去重取最新 | +| R9 | 与现有系统(如需对接认定机构平台)不兼容 | 中 | 预留开放 API(8.10);导出包格式遵循机构要求 | +| R10 | 试用期后不续费 | 中 | 免费版限制在"1 任务 / 60 人",让老师在真实任务中形成依赖 | +| **R11** | **超大扫描件导致图片处理服务 OOM** ⚠️ 初稿未识别 | **高** | 实测单图 1.8 亿像素(全解码约 540MB 内存)。对策:① 强制 libvips 流式处理;② 5000 万像素以上先金字塔降采样;③ 按内存反推并发上限(非 CPU);④ 20s 熔断;⑤ 压测必须用这 28 张真实扫描件做样本 | +| **R12** | **微信订阅消息无法反复催报** ⚠️ 初稿未识别 | **中** | 微信"一次性订阅消息"需用户每次授权才能发 1 条,催报 3–4 次很快耗尽额度。对策:① 填报时一次性请求多次订阅(用户可勾选"总是保持以上选择");② 引导关注**服务号**用模板消息(无次数限制);③ 短信兜底(按条计费);④ 产品层减少催报次数、提高一次填完率 | +| **R13** | **家长/学生手机号本身可能来自他人**(代填场景) | 低 | 允许一人代填多份(如哥哥帮弟弟),需在名册认领时留痕 | + +--- + +## 14. 待确认事项 + +| # | 事项 | 影响 | 需要谁确认 | +|---|---|---|---| +| Q1 | ~~认定机构是否书面接受"压缩后 ≤1M"~~ **已决策:D1 强制 1M,无例外。待办转为:取得机构对「300dpi 扫描件」的书面认可**(实测 28/28 达标,无技术争议) | 中 | 业务/客户 | +| Q2 | 压缩参数底线:**A4 扫描件的目标 DPI 实测标定值**、质量下限 | 高 | 认定机构 | +| Q2-d1 | ~~是否接受体积放宽到 2M~~ **已决策:D1 不接受放宽,强制 1M** | — | 已关闭 | +| Q2b | **截图类材料(学籍表)是否必须转 JPG**?保留 PNG 是否会被拒收 | 中 | 认定机构 | +| Q2c | **分辨率下限**:身份证翻拍长边低于多少判定为不可接受(建议 1000px) | 中 | 认定机构 | +| Q3 | 微信小程序主体资质是否齐备(教育类目可能需办学许可) | 高 | 商务/法务 | +| Q4 | "工作证明"材料的规范名与材料实际内容不符(个人申报表),是否按机构习惯保留 | 中 | 客户 | +| Q5 | 社会收集的人员是否需公安实名核验(涉及额外成本) | 中 | 客户 | +| Q6 | OCR 引擎选型(腾讯云 / 百度 / 阿里云 / 本地模型),成本与数据出境要求 | 中 | 技术 + 合规 | +| Q7 | 定价策略:按人数 / 按任务 / 按年订阅 | 中 | 商务 | +| Q8 | 是否需要向学生收取费用(如报名服务费),涉及支付资质 | 中 | 商务 | +| Q9 | 一期是否需要与"认定机构业务系统"对接 | 中 | 客户 | +| Q10 | 私有化部署版本是否同步开发(当前决策为 SaaS 优先、私有化后置) | 低 | 产品 | +| **Q11** | **名单外的人怎么处理?** 当前按需求实现为"不给填、只提示",老师需去后台加人。建议增加"**申请加入名单 → 管理员审批后自动放行**"通道(既不破门禁、又免手工加人)。**请决策** | **高** | 客户 | +| **Q12** | **社会收集的名单从哪来?** 社会收集同样"先导名单",但若名单来源不稳定(预报名名单 / 报名点收集 / 机构提供),落地难度较大。需确认名单来源与责任方 | **高** | 客户 | +| Q13 | 门禁是否再加一层**手机号验证码**(防止有人拿到他人姓名+身份证号即可代填) | 中 | 客户 | +| Q14 | 门禁限流阈值(同 openid 每小时 10 次)是否合适,需按实际人数标定 | 中 | 研发 | +| ~~Q15~~ | ~~认定机构下发的"计划清单"样本~~ **已澄清:不存在该文件**,报考范围来自名单,问题关闭 | — | ✅ 已闭环 | +| ~~Q16~~ | ~~计划下发是全量还是增量~~ **同上,问题关闭** | — | ✅ 已闭环 | +| ~~Q17~~ | ~~国标职业字典初始数据来源~~ **降级**:字典改为可选的拼写校验(P2),非强约束,不阻塞开发 | 低 | 产品 | +| **Q18** | **是否允许学生修改"老师预填"的字段?** 本版设计为"可改并留痕"。若机构要求严格一致,需改为"只读+申请修改" | **中** | 客户 | + +--- + +## 附录 A:数据字典(部分) + +**材料类型 `material_code`** + +| 编码 | 名称 | +|---|---| +| `PHOTO1` | 一寸照片 | +| `IDCARD` | 身份证 | +| `SCHOOL_ROLL` | 学籍表 | +| `XJW_BA` | 学信网备案表 | +| `WORK_PROOF` | 工作证明 / 个人申报表 | +| `OTHER_PROOF` | 其它佐证材料 | + +**收集模式 `collect_mode`**:`1` 学校收集 / `2` 社会收集 +**认定类型 `cert_type`**:`1` 自主认定 / `2` 社会评价 / `3` 专项职业能力 + +> ⚠️ **`社会评价`(认定类型)与 `社会收集`(收集模式)是两回事**: +> 前者决定**填什么材料**(社会人员要学信网备案表,不要学籍表),后者决定**谁审核**(有无班主任层)。详见 §5.3。 +**填报单状态**:`0` 草稿 / `1` 待初审 / `2` 待终审 / `3` 已通过 / `4` 已驳回 / `5` 已归档 +**问题级别 `severity`**:`1` 信息 / `2` 警告 / `3` 错误 + +## 附录 B:命名规则正则参考 + +```regex +# 身份证号(18位,末位可X) +[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx] + +# 身份证号(15位旧号) +[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3} + +# 完整规范文件名(校验用) +^[\u4e00-\u9fa5]{2,6}-[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]-(一寸照片|身份证|学籍表|学信网备案表|工作证明|其它佐证材料)\.(jpg|jpeg|png)$ + +# 需清理的非法字符 +[\\/:*?"<>|\s] + +# 重复扩展名检测 +\.(jpg|jpeg|png|pdf|bmp|tiff|gif|heic){2,}$ +``` + +## 附录 C:驳回原因字典 + +| 编码 | 文案 | +|---|---| +| `R001` | 材料不清晰,请重新提供(建议找打印店重新扫描或索取原图) | +| `R002` | 身份证号与老师名单不一致 | +| `R003` | 材料不是本人 | +| `R004` | 文件超过 1M | +| `R005` | 缺少材料 | +| `R006` | 工作证明未盖公章 | +| `R007` | 材料不完整(缺正反面 / 缺页) | +| `R008` | 信息填写有误 | +| `R009` | 照片背景不符合要求 | +| `R999` | 其他(手填) | + +## 附录 D:错误码表(⚠️ 复审补充) + +| 码 | 含义 | 端处理 | +|---|---|---| +| `0` | 成功 | — | +| `40001` | 存在阻断性校验问题,无法提交 | 展示 blocking 清单,引导逐项处理 | +| `40002` | 文件格式不在白名单 | 提示支持的格式 | +| `40003` | 文件体积超上限且压缩受限 | 提示重新提供(重扫描 / 索取原图 / 换来源通道) | +| `40004` | 身份证号校验位不通过 | 高亮错误位,提示核对原件 | +| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 | +| `40006` | 疑似串档(校验位均通过但与他人不符) | 转人工,不自动修正 | +| `40007` | 门禁未匹配(姓名或身份证号与名单不符) | **统一提示**"没有找到你的信息",提供**重新填写 / 申请加入名单**(不透露是哪一项错) | +| `40007b` | 门禁尝试过于频繁(触发限流) | 提示"操作过于频繁,请稍后再试" | +| `40007c` | 名单项已被他人绑定 | 提示"该信息已被登记,如有疑问请联系老师" | +| `40008` | 任务已截止 / 已归档 | 禁止提交,提示联系管理员 | +| `40009` | 重复提交(幂等键冲突) | 返回既有结果 | +| `40010` | 驳回次数超上限 | 提示联系班主任 | +| `40101` | 未登录 / token 失效 | 静默重新登录 | +| `40301` | 无权限(含跨租户) | 跳转 403 | +| `40302` | 签名 URL 已过期 | 重新获取 | +| `42901` | 触发限流 | 退避重试 | +| `50001` | 文件处理失败 | 保留原图,允许重试 | +| `50002` | OCR 服务异常 | 降级为人工填写 | +| `50003` | 图片过大被熔断 | 转异步处理 | + +## 附录 E:定时任务清单(⚠️ 复审补充) + +| 任务 | 频率 | 说明 | +|---|---|---| +| 原始文件清理 | 每日 02:00 | 删除 `raw_expire_time` 到期的原图(默认 30 天) | +| 导出包清理 | 每日 02:30 | 删除过期导出包(默认 7 天) | +| 自动催报 | 每日 09:00 | 按截止日 7/3/1 天规则,仅催未提交者 | +| 审核超时提醒 | 每小时 | 初/终审超 `timeoutHours` 未处理 → 提醒并升级 | +| 任务状态流转 | 每小时 | 到期任务自动 `PUBLISHED → CLOSED` | +| 待认领名册清理 | 每日 | 超时未认领的人员移出应填名单并通知管理员 | +| 用量统计 | 每日 01:00 | 租户存储量/OCR/短信用量快照,供计费与超额告警 | +| 压缩失败重试 | 每 10 分钟 | 扫描"压缩受限/处理失败"文件,重试 ≤3 次 | +| 租户到期提醒 | 每日 08:00 | 到期前 30/7/1 天提醒 | + +## 附录 F:复审方法论说明 + +本次复审(V1.1)的验证方式,供后续版本沿用: + +1. **用真实数据复核每一个数字**:文档中所有"数量/占比/体积"均通过脚本对 `24中技数控1班` 112 个文件重新实测,不依赖估算; +2. **用真实数据复核每一条参数假设**:读取每张图片的实际像素尺寸与格式,而非假设"学生上传的是手机照片"——本次正是靠这一步才发现工作证明是 1272dpi 扫描件、学籍表是截图; +3. **用算法裁决业务争议**:ISO 7064 校验位把"证件号到底谁对"从人判断变为机器判断; +4. **把实测结果固化为回归断言**:§12.1 的 59/56/31/3 可直接写成自动化测试的期望值。 + +## 附录 G:PostgreSQL DDL 实测验证记录(2026-09-28) + +**验证环境**:`postgres:16-alpine`(PostgreSQL 16.15),Docker 本地实例,全新空库。 + +**验证方式**:从本文档 §7.3 抽取全部 DDL → 重排为「先建表、后 ALTER」→ 建空库执行 → 再用真实名单数据跑功能查询。 + +### G.1 DDL 执行结果 + +| 项 | 结果 | +|---|---| +| SQL 块 | 20 个(18 个 CREATE TABLE + 2 个 ALTER) | +| 执行错误 | **0** | +| 建成表 | **19 张** | +| 列注释 | **167 条** | +| 表注释 | **19 条** | +| 索引 | **61 个** | + +### G.2 实际踩到并修掉的错误(原始报错) + +``` +ERROR: relation "uk_tenant_code" already exists +ERROR: relation "idx_name" already exists +ERROR: relation "idx_submission" already exists +ERROR: relation "idx_tenant" already exists +ERROR: relation "collect_task" does not exist ← 上面第 1 个错误的连锁反应 +``` + +| 冲突名 | 涉及表 | 修法 | +|---|---|---| +| `uk_tenant_code` | sys_tenant、collect_task | → `uk_task_tenant_code` | +| `idx_name` | sys_occupation_ref、collect_roster | → `idx_occref_name` / `idx_roster_name` | +| `idx_tenant` | collect_material_def、collect_rule_template、collect_cleanup_job | → `idx_matdef_tenant` / `idx_ruletpl_tenant` / `idx_cleanup_tenant` | +| `idx_submission` | collect_file、collect_validation_issue、collect_audit_record | → `idx_file_submission` / `idx_issue_submission` / `idx_audit_submission` | + +> ⚠️ **根因**:**MySQL 的索引名只需表内唯一,PG 要求 schema 内全局唯一。** +> 这是 MySQL→PG 迁移最容易踩的坑,且**语法检查器查不出来**(SQL 本身完全合法),只有真跑才会报错。 +> ⇒ **本项目的所有索引/约束命名必须带表前缀**,这是硬规范。 + +### G.3 功能查询验证(用真实名单数据) + +写入 3 条真实名单(含 1 条故意填错工种的"少数派"): + +| 验证项 | 结果 | +|---|---| +| `collect_roster` 业务列 | **18 个齐全** ✅ | +| 报考范围聚合 | 车工/数控车工/四级 → 2 人;车工/普通车工/三级 → 1 人 ✅ | +| **一致性体检(识别少数派)** | ✅ 准确筛出"普通车工 1 人(占比 <50%)" | +| 按学历/在校生筛选 | ✅ 正常 | +| jsonb 逃生舱读写(含中文键) | ✅ 正常 | +| **聚合查询执行计划** | **`Index Only Scan using idx_roster_scope`** ✅ | + +> 💡 **`Index Only Scan` 的意义**:说明"按报考范围聚合""按工种统计人数"这类高频查询**完全不回表**,直接从索引取数。这是把 18 个字段建成正式列(而非塞 JSON)换来的直接收益——如果当初走 `prefilled_json`,这类查询只能全表扫描并逐行解析 JSON。 + +### G.4 一处刻意不做的约束 + +**"报考三项为空 → 阻断任务发布"没有做成 CHECK 约束**,是应用层校验。 + +原因:名单**导入过程中**允许暂时为空(老师分批补录),若加 `CHECK (occupation IS NOT NULL)` 会导致导入直接失败。 +正确做法是**导入时体检 + 发布时阻断**(见 §5.3.1 与功能逻辑卷 §3.2-A)——**该拦的地方拦,不该拦的地方别拦**。 + +--- + +**文档结束**