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

68 KiB
Raw Blame History

核心模块设计说明

版本: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-0117 为原定义,VAL-1820 为本次补充)。

规则 名称 执行阶段 默认级别
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 项指标)、三条贯穿原则