Files
xiaotutongxue/docs/04-核心模块设计说明.md
T
lovife 63aec55329 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
2026-09-28 16:19:19 +08:00

1445 lines
68 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 核心模块设计说明
> 版本:V1.0 | 日期:2026-09-28
> 读者:后端开发、算法/图像处理开发、测试
> 定位:**伪代码级**实现说明。PRD 讲「要什么」,接口文档讲「收发什么」,本文讲「**怎么做**」。
> 只覆盖 **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 项指标)、三条贯穿原则 |