# 核心模块设计说明 > 版本: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(40071, "操作过于频繁,请稍后再试", 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(40072, "该信息已被登记,如有疑问请联系老师") # 原子抢占:把"检查"和"写入"合成一条语句,杜绝并发窗口 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(40072, "该信息已被登记,如有疑问请联系老师") # ── 步骤 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 边界与失败处理 | 场景 | 处理 | |---|---| | 名单有重复证件号 | 记数据问题、通知管理员,对外统一失败 | | 名册项已被他人绑定 | `40072`(**这一条可以明确提示**——因为用户已经提供对了信息,提示不泄露新信息) | | 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 测试要点 | 用例 | 期望 | |---|---| | 正确姓名+号码 | 通过,返回脱敏号与预填 | | 号码对、姓名错 | **统一失败提示**(文案与"号码错"完全一致) | | 号码错 | 统一失败提示 | | 限流触发 | `40071`,提示剩余等待时间 | | 形近字(曾依豪 vs 曾伊豪) | 返回确认候选,`needConfirmName: true` | | 员工 A 先绑,员工 B 再试 | B 得 `40072` | | **并发 100 个请求抢同一名册项** | **恰好 1 个成功,99 个得 `40072`**(压测必测) | | 失败尝试查库 | **无明文证件号** | | 名单 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 项指标)、三条贯穿原则 | --- > **实现说明(2026-09-28)**:本文件中的 `40007b` / `40007c` 已规范化为 **`40071`** / **`40072`**。 > 原因:`code` 按 §1.3 约定是数字,带字母后缀会在 JSON 里变成字符串,破坏前端 `code === 0` 的判断。 --- > **DDL 修正(2026-09-28)**:`sys_tenant.contact_phone` / `sys_user.phone` / `collect_roster.phone` 由 `varchar(20)` 放宽到 `varchar(128)`。 > 原因:这三列存的是 AES-256-GCM 密文(约 52 字符),`varchar(20)` 是按明文长度建的,插入必失败。