技术栈(已在 4.1.1 上实测跑通,非纸面选型)
Java 21 + Spring Boot 4.1.1 + MyBatis-Plus 3.5.17(boot4) + PostgreSQL 16.15
+ Redis 7 + Flyway 12.4 + springdoc 3.1.1;前端定 Vue 3 + Element Plus + uni-app
交付内容
M0-01 基础设施 deploy/docker-compose.yml(PG + Redis + MinIO,容器名与端口避让本机既有项目)
M0-02 后端骨架 xt-common + xt-server;统一响应 / 全局异常 / 58 个错误码 /
多租户拦截 / 软删除 / 字段自动填充 / AES-GCM / HMAC 索引 / RSA / JWT
M0-05 租户 开通租户(一次完成建租户 + 发 6 个内置角色 + 建首个管理员)、列表、详情
M0-06 组织 CRUD + 组织树 + ancestor_path 自动维护 + 占用校验
M0-07 PC 登录 RSA 解密 + 图形验证码 + 失败锁定 + 等时比较防账号枚举 + 审计日志
M0-08 登出/当前用户 Token 黑名单(存 SHA-256,TTL 到自然过期)
M0-01 数据库 Flyway 2 个迁移 → 26 张表(20 业务 + 5 标准 + 1 迁移记录)
安全姿态
· 租户拦截器【默认拒绝】:匿名请求触及租户表直接抛 40101,不做「不加条件放行」
· 权限码定义在代码里(PermissionDef / BuiltinRole),不建 sys_permission 表,杜绝前后端漂移
· 匿名接口只靠 @PublicApi 放行,不做路径白名单,避免两套机制各说各话
· 账号不存在时也跑一次 BCrypt(假哈希),拉平响应时间防枚举
· 登录密码 RSA 传输,防止网关/APM 把明文密码记进日志
脚本
scripts/sync-ddl.py 文档 DDL → Flyway 迁移,带 --check 防漂移
scripts/e2e-m0.py M0 验收 36 项,可进 CI
scripts/verify-docs.py 文档一致性(接口数【逐节】核对、错误码、跨文档数字、表格列数)
scripts/dev-login.py RSA 加密登录助手
scripts/dev-clean-e2e.py 清理 E2E 测试数据
文档修正(实测暴露,非人工审查)
· DDL 缺陷:sys_tenant.contact_phone / sys_user.phone / collect_roster.phone
原 varchar(20) 按明文长度建,装不下 AES-GCM 密文(约 52 字符),改 varchar(128)(PRD V2.4)
· 错误码 40007b/40007c 带字母后缀与「code 为数字」的约定冲突,规范化为 40071/40072
· 07-开发实施计划 新增 §9 版本兼容踩坑记录(10 条实录)与 §10 M0 完成情况
69 KiB
核心模块设计说明
版本: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-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 项指标)、三条贯穿原则 |
实现说明(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)是按明文长度建的,插入必失败。