简历要同时存快照:脏输入对象的三层模型与状态机防错

一、三层存储:身份、经历、投递是三性质不同的事实
简历入库最容易忽略的一点是,它不是一条记录。一份文件里同时装着三种性质完全不同的东西:经历是天然多值且带时序的,三段工作、两个项目、四处教育,各有排序;投递是一次性的事实,它发生的那一刻就固定了;而投递两端指向的对象都在继续变化——职位的要求与区间会被改,简历会被本人反复编辑。等出现争议再去主表里查,查到的是改完之后的样子,不是当时被看到的样子。
按这个划分,存储分三层。
- 主表(身份层):基本信息、求职意向、隐私授权标记、完善度评分。这一层的字段以单值为主,是列表页筛选与排序的落点。
- 子表(经历层):工作、项目、教育各自独立成表,带排序权重。独立成表的收益是经历可以局部修正,不必整份重写;排序权重存进库里而不是靠前端排,避免多端展示顺序不一致。
- 投递记录表(快照层):一次投递一行,必须同时保存职位快照与简历快照,用途是纠纷追溯。少任何一份都还原不了当时看到的画面。
快照存原文还是存结构化摘要,是一个明确的取舍。只存摘要占空间小,但摘要吃掉了版式、上下文和原文用词,事后无法复核抽取对不对;整份原文可复原度最高,代价是每投一次存一份,量大时存储与备份压力都在这一层。折中做法是存结构化摘要加上原始文件的不可变标识与内容指纹,同时保留抽取后的字段集——文件本身不入投递表,用指纹引用。展示时还要防误导:快照视图必须与当前档案分屏呈现,标题写清是投递当时的状态,不能让用户把快照当成档案改错了对象。
二、派生字段落库的边界与口径版本
完善度评分这类字段是典型的派生值。它由多个字段的存在性与质量算出,本身不由用户填。把它放进列表页实时计算,代价是每一次翻页都要重算一遍整屏的分数,慢查询会集中出现在筛选页。做法是在保存时计算并落库,读的时候直接取。
但落库就带来一个新问题:口径改了,历史数据就不可比。所以派生字段必须成对存储——值本身,加上计算口径的版本标识。这个版本标识至少包含口径版本号、参与计算的输入字段清单、以及生效方式。有了它,运营侧发现分数分布异常时,能一眼判断是数据变了还是尺子变了;做批量重算时也有依据:通常让新口径只对新写入生效,历史值保持原样,如果确实要统一,就显式跑一批并留下批次标识,而不是让一次代码发布悄悄改写全表。
哪些派生值该落库,判据是读取频率而不是计算复杂度。列表页、筛选、排序、批量导出会读到的,落库;只在详情页出现、且口径仍在频繁调整的,先不落。反过来,把只在详情页用的字段也落库,等于给自己增加一批必须维护一致性的隐性副本。
三、解析流水线的四段责任
从文件到可入库的字段,中间是一条流水线,四段各有各的失败模式,也各有各必须留下的记录。
| 段 | 责任 | 失败时的处置 | 必须留存的字段 |
|---|---|---|---|
| 校验段 | 格式与大小限制、能否被解析、恶意内容检查(脚本、宏、伪装扩展名、可疑外链) | 直接拒收并给明确原因,不进队列 | 文件指纹、拒收原因码 |
| 分流段 | 按类型走不同路径:文档优先取文本层,取不到再走图像识别;结构化文档走标签解析;纯图片走整页识别 | 降级到更通用但更贵的路径,而不是整体失败 | 实际走的路径标识、降级次数 |
| 抽取段 | 双通道并行:规则通道基于标题锚点分节,模型通道补齐与归一化 | 单通道失败时另一通道仍可出结果,冲突记录入表 | 每字段的通道来源、冲突标记 |
| 兜底段 | 按字段风险比较置信度与阈值,低置信不直接落库 | 转补录路径,回填原文位置引导用户确认 | 阈值判定结果、候选值、补录状态 |
分流段的设计要点是记录走了哪条路。没有这个字段,事后看不出质量差异来自哪一路径,也就无法判断某次改动是否让大批文件集体降级。
抽取段的双通道不是冗余设计,而是分工:规则负责结构与可核对,模型负责补齐与归一化。同一件事两家给出不同结果时以规则为准,模型结果只作为低置信度补充。这个默认方向值得说清理由。第一是可解释性——规则的输出能指回原文哪一段匹配了哪个锚点,模型不能。第二是错误代价不对称:漏掉一个字段,用户可以补;填错一个字段,它会直接进筛选、排序与推荐流程,下游没人知道它是错的。让可核对的一方占优,本质是把不可解释的错误挡在决策之外。
四、置信度要按字段风险分级,不能只记一个分数
字段级置信度不是一个数字,它至少记四件事:抽取来源(规则、模型、人工补录)、命中的原文片段位置、候选值列表、阈值判定结果。缺了来源与位置,补录界面就无法把用户引到该看的那一段,兜底段就退化成让用户重新打字。
阈值按字段风险分级设,而不是一刀切。
- 决策关键字段:姓名、时间区间、当前在职状态。这类字段进筛选条件,阈值取严,宁可进补录。
- 结构化字段:公司名、职位名、学历。允许少量候选,取中,错值靠补录收敛。
- 描述性字段:技能标签、项目摘要。多候选可接受,阈值取宽,因为它主要影响搜索召回而不是筛掉人。
- 合规字段:隐私授权标记。不参与抽取,只能由显式动作置位,抽取结果一律不接受。
低置信要不要引导用户补录,是转化代价与数据质量的权衡。补录要占用人手操作,引导多了用户走;引导少了错值扩散,事后清洗更贵。按字段分档决定,而不是全站统一开或统一关。还有一个常被忽略的处置:低置信值不入库,但可以只进搜索不进筛选——让这份简历能被找到,却不因为一个错抽的字段被系统性排除。
五、状态机:枚举收敛、禁止回退、冲突必须出声
投递流程的枚举收敛为五个:投递、已查看、面试、录用、结工。设计约束有两条,每一条都有反面教材。
第一条是禁止回退。业务上确实有重新考虑的情况,但如果靠状态回退表达,代价立刻显现:转化率算不清(同一份简历从面试退回投递再前进,算一次还是两次),周期统计被污染,责任判定失真——回退是两端都可能做的动作,出了分歧查不到是谁的决策。做法是前进状态保持不变,用附加事实记录来表达反复,比如在同一条投递上追加一条备注或一次新的操作记录,而不是把主状态往回拨。同理,不要设进行中这种无法判定的中间态,它既不能回答现在卡在哪,也不能回答该谁动。
第二条是并发冲突要出声。企业端的批量操作与求职端的撤回会撞在一起:一次批量把十份简历推到已查看,同一时刻有一份被撤回。用乐观锁,写入前比对版本或更新时间,冲突方返回明确提示而不是静默覆盖。这里最坏的结果不是失败,而是覆盖——两端各自看到成功,数据却只有一份是对的,等到下一次人工核对才发现,此时已经没有可追溯的现场。
合法迁移表要落成文档并进代码评审:五个状态、谁能触发哪一条迁移、被拒绝的迁移返回什么。状态机改动等于业务规则改动,不该由一个顺手提交的补丁完成。
六、通知解耦、中间态对账与上线前的四条验收
副作用要和主流程分开。状态变更先落库,通知进重试队列,消费侧必须带幂等键。这里有个具体坑:幂等键要按状态迁移事件生成,而不是按状态值。按状态值去重,会让同一状态被合法进入两次时第二次通知被当成重复丢掉,用户侧表现为石沉大海。失败的通知不阻塞主流程,但必须可重放,重放要留下批次与原因。
对账任务补的是另一类问题。定时扫描长时间停在中间态的记录,补发通知并推进。判定条件要区分两种情况:业务上正常的慢(用人方就是不回复)和系统没推进(通知丢了、队列卡了)。前者不该被自动改状态,后者才该动。实现上通常给每类状态配一个停留时长阈值和一个观察指标,触发后先补齐通知、再人工看,而不是直接替用户决策。
洹拓在做类似流水线时,把这四件事定为上线前的最低验收:
- 一份刻意带乱格式的简历,能否被兜底段拦住并进入补录路径,而不是落一个错值。
- 两个操作端同时改同一条记录,是否有一方拿到明确的冲突提示。
- 一条投递记录里,职位快照与简历快照是否都在,能否独立读回。
- 通知重放一次,是否产生重复消息。
这四条应该进回归用例,而不是等线上有反馈再补。它们测的都是同一样东西:脏输入进来的路径上,每一段的兜底是不是真的接得住。
最后说一句适用范围。这套三层存储、双通道抽取、禁止回退的状态机,抽掉简历这两个字,形状和合同、发票、工单、报销单是一样的:外部输入格式不可控,抽取一定有错,流程跨两端且周期以月计。换对象时三层与双通道基本可以照搬,需要重写的只有两样——字段风险分级表,和状态枚举的合法迁移。至于本篇描述的结构是否适用于所有招聘系统,这不是我们能替别人下的结论;它只说明这类对象为什么不该按一张宽表来设计。