4轮文档修订、2个致命漏洞:一次微信托收接入复盘,总结读懂陌生业务系统与提炼数据流的方法

comsince

comsince

FshareIM Team

最近经手了一个很典型的任务:给一个已经跑了多年的收费系统,接入一种全新的第三方代扣(微信托收)能力,同时要求旧的托收渠道零改动。整个实现由 AI 编码代理承担了从设计文档到落地代码的主要工作,最终拆成 15 个任务、跑通近 90 个单元测试,但一次独立的整体评审依然揪出了 2 个会导致资金重复扣款的致命问题。这篇文章不讲这个具体渠道怎么接,只谈从这次经历里提炼出来的一套通用方法:怎么在动手写代码之前快速吃透一个陌生业务系统的具体逻辑,以及怎么把散落在几十张表、几十个类里的业务规则,收拢成一张能反复复用的数据流图。

读懂陌生系统·提炼数据流


一、背景:一次很有代表性的"陌生系统 + 新增外部对接"任务

场景抽象一下是这样的:一套跑了多年的收费系统,账单从"应收"到"实收核销"已经有一条成熟的托收链路(下称渠道 A),现在要接入一种新的托收方式——通过第三方支付平台发起代扣、由平台异步通知结果(下称渠道 B)。业务方提的要求很朴素:"渠道 A 原有逻辑一行都不能动,Controller 层要能按托收方式路由,两种渠道并存"。

这类任务的难点不在于写代码本身,代扣请求-回调-核销这条链路并不复杂。难点在于:

  1. 渠道 A 的隐性规则散落在几十个类、几十张表里,很多约束没写在任何文档中,只体现在某一行 WHERE 条件或者某个锁的 key 上;
  2. 新渠道要复用这些隐性规则(账单锁、冻结状态机、实收核销入口),但又不能触碰产生这些规则的代码; 3 这是一个直接涉及资金的场景,任何一个状态迁移条件写错,后果是"重复扣款"或"账单永久冻结",容错空间极小。

整个实现走的是规格驱动开发(Spec-Driven Development)流程:先写详细设计文档,评审通过后拆成一系列任务,每个任务由一个实现子代理落地代码、再由独立的评审子代理判定"是否符合设计规格"和"代码质量是否过关",有问题打回重做,全部任务完工后再做一次跨任务的整体评审。最终数字是:

  • 详细设计文档从 v1.0 修订到 v1.3,四个版本;
  • 数据库设计阶段标记了 7 处字段/结构缺口,逐条推进关闭;
  • 15 个实现任务、近 90 个单元测试全部通过、涉及模块编译全部通过;
  • 全部任务完工后的整体评审,仍然发现了 2 个 Critical、5 个 Important、7 个 Minor 级别的问题——全部集中在"并发条件下状态会不会错乱"和"资金会不会重复扣"这两类问题上。

这组数字本身就是这篇文章的论据:每一层评审都是干净的,但组合起来依然有资金风险级别的漏洞。下面把从这次经历里提炼出来的方法拆开讲。


二、先搞懂"为什么",再搞懂"怎么做":读一个陌生业务系统的正确顺序

1. 跟着"状态"走,别跟着代码目录走

拿到一个陌生系统,最没效率的做法是打开目录树,从上到下把类名读一遍。真正有效的做法是先用一句话把业务压缩成一个状态机描述:谁,在什么条件下,把什么东西的状态从 A 改成了 B

这次场景压缩出来是三条主线:

实体状态迁移谁能触发
托收协议(签约关系)未签约 → 已签约 → 已解约(可重新签约回"已签约")第三方支付平台的异步通知
应收账单正常/部分缴清 → 已冻结 → 已核销(或核销失败退回冻结前状态)发起托收申请 / 核销结果回调
代扣扣款单(明细)未发起 → 处理中 → 成功/失败 → 已关闭(超时兜底)发送指令 / 结果通知 / 定时补偿任务

这三条状态机一旦画出来,代码目录里几十个类就有了坐标系:每个类要么是"读状态做展示",要么是"在某个条件下推进状态迁移",要么是"维护状态之间的一致性(比如协议解约时账单要不要连带处理)"。带着这张表去读代码,效率远高于漫无目的地翻文件——先问三个问题:这个状态机有几个状态?谁能触发状态迁移?哪些是终态(不可逆)?答完这三个问题,业务的骨架就立住了。

2. 找"这套代码里已经沉淀的规则",而不是自己发明新规则

设计文档开篇第一条总原则就是"旧渠道已有代码零改动"。这一句话直接决定了后续所有设计的姿势:只能新增+适配层,不能重构、不能抽公共基类。所以读代码时首先要找的不是"这里应该怎么设计更优雅",而是这个仓库里已经存在、被反复验证过的模式——已有的分布式锁 key 命名规则、已有的异步任务调度封装、已有的账单冻结/解冻的字段约定。复用它们,比设计一套新抽象重要得多。

这里有一个很值得记录的教训:新渠道的某个 Service 需要对列表结果做后处理(拼装展示字段),逻辑和旧渠道的对应方法几乎一样。评审时点出"这是重复代码,应该抽取公共方法",但最终的裁决是保留,不抽取——因为抽取公共方法必须回去改旧渠道的代码,违反了第一条总原则。

架构洁癖要让位于既定的风险约束。 "这段代码看起来该抽取"是一个局部判断,"抽取的代价是打开一条本来被封死的回归风险敞口"才是全局判断。读一个陌生系统时,先搞清楚它显式定下的约束边界,很多"看起来不优雅"的选择其实是刻意为之。

3. 用"缺口清单"代替"我觉得应该有"

数据库设计阶段,参照的外部字段规范和实际要落的库表之间有多处对不上——有的字段外部规范里没有但核销必须要用,有的字段设计推导出来但后来发现不需要。这类不确定点的正确处理方式,不是凭经验补一个字段,也不是跳过不管,而是开一张缺口清单:每一条写清楚"缺什么、为什么需要、严重等级、当前状态"。

这次实际标记了 7 处缺口(编号 A 到 G),处理结果大致是:

等级处理方式
会导致"一份记录能插多条、直接引发重复扣款"级别的缺口必须在建表前关闭,不允许"先上线再说"
会导致"核销必需的关键字段没地方存"级别的缺口标记为高优先级,明确写"待补",建表前一并加上
不影响正确性、只影响可排查性的缺口(比如缺一张报文流水表)标记为中等,记录"建议补",交给后续迭代

这套格式本质上是需求分析里"风险登记册"的细粒度版本——绑定到具体的表和字段级别,能直接对应到"要不要为此发起一次业务方确认"这个动作,而不是含糊地写在某个人的脑子里。遇到不确定的地方,先记录成一个带等级、可追踪的问题项,再决定要不要立刻处理,这比"我觉得应该这样,先这么写"要可靠得多。

4. 把"口径确认"当一等公民记录下来,而不是记在脑子里

这次的设计文档从 v1.0 一路改到 v1.3,每一版都留了一张"推导 vs 最终确认结论"的对照表,比如:

早期推导最终确认
代扣发起的定位标识用户身份标识 + 支付协议号项目编号 + 户号组合
一个用户能否同时签约多套房源假设一个用户一份协议,直接建唯一约束一个用户可能名下多套房产/车位,唯一约束必须落在"房源"维度而不是"用户"维度

第二条尤其典型:如果最初的推导没被推翻,唯一约束会建在错误的维度上,导致"业主签完第一套房源后,第二套房源永远签不进来"——这是一个只有在业务方确认口径之后才会暴露的坑,代码逻辑本身完全没写错,错的是对业务规则的假设。

这套"版本化的口径对照表"的价值在于:半年后(甚至换一个人、换一个 AI 代理)接手这套代码,不需要考古聊天记录或提交历史,读这张表就知道"为什么是这么设计的",而不只是"现在是这样"。任何一次业务口径的确认,哪怕只是一句话,都值得留一行"结论是什么、从哪来、推翻了什么假设",而不是让结论悄悄改到代码里就算完成。


三、把业务系统的数据流画出来,而不是背下来

搞懂了"怎么读",下一步是把读到的东西沉淀成一个可复用、可传递的产出物——而不是留在读代码人的脑子里。这里有四个具体做法。

1. 状态迁移表:把"谁能改、前置条件是什么"写全

上一节画出的三条状态机,光有状态节点还不够,真正有信息量的是每一条迁移边上标注的前置条件。比如"账单从正常变成已冻结"这条边,代码里对应的不是一句简单的赋值,而是一条带条件的更新语句:

UPDATE bill SET status = '已冻结', prev_status = status
WHERE id = ? AND status IN ('正常', '部分缴清')

这条 WHERE 条件就是并发安全的全部秘密:它保证了"只有当账单当前确实处于可托收状态时,这次冻结才会生效",如果影响行数是 0,说明账单已经被别的渠道或别的批次抢先冻结了,代码必须显式检查这个返回值并抛出异常,而不是默认"更新总会成功"。

把这次场景里所有类似的条件更新语句收集起来,整理成一张"迁移边 → 前置条件 → 影响行数校验"的表,基本上就是把这个系统的并发安全模型看完了——这比读十个 Service 方法更快抓住业务本质,也是新人(或者 AI 代理)接手这类系统时最应该产出的第一份文档。

2. 标出"终态判定只能有一条路径"这类不变式

资金类系统里最值钱、也最容易在扩展新功能时被无意破坏的规则,往往是一句简单的话:这件事最终成没成功,只能由一个地方说了算

这次的具体表现是:发起代扣请求时,第三方平台的同步响应已经能告诉你"这笔交易已经成功了",但响应里恰恰不包含核销所必需的流水号、实际金额、手续费这些字段。如果图省事,看到"同步响应显示成功"就直接在发起请求的那一步做核销,代码逻辑是通的,但会导致一个隐蔽的后果:一旦后续的异步结果通知重复到达,或者本地重试逻辑被触发,核销可能被执行两次。

设计文档里把这一条显式记成了一句不变式:"同步成功不代表可以核销,核销路径必须唯一"——不管发起请求时看到了什么迹象,唯一能把账单标记为"已核销"的入口,只有异步结果通知或者定时的结果查询补偿这一条路径。这类约束必须被显式写下来、显式画进数据流图,而不能只隐含在"代码恰好是这么写的"这个事实里——因为下一个不知道这条隐含规则的人(或者下一次需求变更),很容易在别的入口里也加一段"看起来合理"的核销逻辑。

3. 用一张"生产方 → 消费方"契约表管理并行开发

15 个任务并行推进时,有大量符号——枚举取值、常量命名、某个工具类的方法签名——是任务 A 定义、任务 B~F 使用的。如果按经验做法,等所有任务都写完再联调,最容易在这时候才发现"字段名对不上""某个方法签名变了但调用方没同步改"。

这次的做法是在启动所有任务之前,先建一张契约核对表:每一行是一个会被跨任务共享的符号,列出"由哪个任务生产、被哪些任务消费、需要核对的具体内容是什么、核对结论"。举例(简化后的示意):

符号生产方任务消费方任务核对内容结论
交易状态判定结果类型任务 5任务 6、任务 11、任务 13方法名与返回类型是否一致一致
路由接口的方法集合任务 8任务 12各渠道实现的方法签名是否匹配一致
序列号生成工具的方法命名任务 9任务 11、任务 13命名是否一致一致

这张表不需要任何复杂工具支撑,就是最朴素的表格,但价值很高——尤其是当"开发者"是多个并行运行、彼此看不到对方进度的 AI 代理时,人工审查很难在每个任务提交时都记住全部上下游依赖,而这张表把"接口契约"从"靠记性、靠事后审查兜底"变成了显式登记、显式勾兑,在所有任务真正开工之前就把最容易扯皮的不一致问题排除掉了。

4. 决策留痕:写清楚"选了什么、为什么选、如果错了会怎样"

整个过程中,每一个需要拍板的技术决策,都用同一种三段式格式记录下来:决定是什么 — 为什么这么选 — 如果这个判断错了,后果是什么。举几个脱敏后的例子:

决定:限流组件从"超速直接抛异常"换成"超速时阻塞排队"。 理由:限流应该让发送变慢,而不是让一部分请求被跳过——之前的实现里,超速抛出的异常被上层 catch 吞掉,导致部分扣款单卡在"处理中"状态却从未真正发起过请求。 若错:发送任务在限流时会阻塞等待,异步任务整体耗时变长——但这是可接受的代价。

决定:定时补偿任务查询"应该关闭"的记录时,显式排除"结果异常待人工处理"这一状态。 理由:如果不排除,一笔平台侧可能已经扣款成功、但本地核销出了异常的记录,会被补偿任务当成"从未发起"直接关闭并解冻账单,造成重复扣款。 若错:这类异常记录需要人工介入处理,不能指望系统自动兜底。

这套记录方式的价值在于:评审者或者后来者看到一条记录,不需要重新论证一遍利弊,直接看"若错"那一栏就知道这个风险有多大、要不要现在就处理。它本质上是架构决策记录(ADR)的轻量版,但绑定在具体的实现细节而不是宏观架构层面,密度更高,覆盖的正是那些容易在事后被遗忘、却在下一次改动时被重新踩一遍的坑。


四、这套方法论落地时踩出来的几条通用教训

跨任务的整体评审揪出的问题,几乎都不是"某个函数写错了",而是"两个各自正确的局部逻辑组合在一起后产生的错误"。挑几条最有代表性的,抽象成通用规律:

1. 补充的更新语句容易漏掉已有的一致性字段。 主流程的更新语句里带着乐观锁版本号自增,但后来为了修另一个问题临时加的一条更新语句,漏掉了这个版本号字段。教训:任何新增的更新语句写完后,都要跟同一张表上已有的同类更新语句逐字段比对一遍,而不是只保证"这条语句本身逻辑对"。

2. 限流组件的语义要看清楚是"拒绝"还是"排队"。 选型时如果没注意到某个限流 API 是"超速直接抛异常"而不是"超速阻塞等待",异常被上层某个宽泛的 catch 吞掉后,会造成"表面上流程继续了,实际上这一步什么都没做"的静默失败。教训:涉及资金/状态流转的操作,任何可能被跳过执行的分支,都必须让调用方能明确感知到"跳过了",而不能被吞掉。

3. "重试同一个动作会不会被执行两次"要单独设计防护。 批量发起请求的任务如果中途失败重跑,原本"已经发起过"的记录不能被再次发起一次。这次的解法是给每条记录加一个"发起时间戳"字段,调用外部接口前先用条件更新去抢占这个字段(WHERE 发起时间戳 IS NULL),抢占失败就跳过——用一个字段把"最多执行一次"这个语义落到了数据库层面,而不是指望内存里的状态或者调用方的判断逻辑。

4. 批量扫描/补偿类任务必须把"异常态"当成独立分支。 定时扫描"应该关闭/应该重试"的记录时,很容易只写两个分支——"已完成"和"未处理",而把"处理中出现异常、结果不确定"这类记录默认归到某一个分支里。这次的教训是:这类"结果不确定"的记录必须单独判断、单独处理(通常是转人工),绝不能被批量任务的默认逻辑当成"未处理"重新处理一遍,否则会把一笔实际已经扣款成功的记录错误地关闭并解冻,造成重复扣款。

5. 收益小于代价的边界情况,允许选择"更简单但需要人工兜底"的方案。 最后一轮评审发现两个定时任务之间存在一个概率极低的竞态窗口——理论上可能存在,但触发条件极为苛刻。当时有两个选择:为这个窗口专门写一条更复杂的条件更新语句并补测试,或者干脆去掉这个自动关闭分支、交给人工处理。最终选了后者,理由很直接:这个窗口出现的概率极低,而为了消除它引入的代码复杂度和新的测试维护成本却是确定的。把"这是权衡后的选择"如实记录下来,比为了功能完整硬修一个复杂度不成比例的边界情况更专业——前提是这个判断必须显式写下来交给业务方确认,而不是自己悄悄决定。


五、一张可复用的检查单

  • 先画状态机,再看代码目录。 用"谁、在什么条件下、把状态从 A 改成 B"这句话压缩业务,代码里的类才有坐标系。
  • 先找仓库里已经沉淀的规则,把它当边界条件,而不是重新发明。 架构上"看起来该重构"的地方,很多时候是刻意换来的低回归风险。
  • 不确定的地方开一张缺口清单,标等级、可追踪、逐条关闭。 不要凭经验补,也不要跳过不管。
  • 每一次业务口径的确认都要留痕:结论+推翻了什么假设,而不是只悄悄改代码。 版本化的对照表比考古聊天记录靠谱得多。
  • 状态迁移表把"谁能改、前置条件是什么"写全,基本等于看完了这个系统的并发安全模型。 条件更新语句里的 WHERE,才是并发安全的真正秘密。
  • 显式写下"终态判定只能有一条路径"这类不变式。 这是资金/状态机类系统里最容易被后续需求无意破坏的规则。
  • 多任务/多人并行开发前,先建一张"生产方→消费方"契约表。 把符号级别的一致性检查前置到开工之前,而不是留到联调阶段。
  • 决策记录写"选择—理由—若错的后果"三段式。 让评审和后人不用重新论证一遍,直接看风险大小。
  • 批量扫描/补偿类任务,把"异常态"当独立分支处理。 不能被"已完成/未处理"两个默认分支吞掉。
  • 收益小于代价的边界情况,允许如实记录并选择更简单的方案,但要交给业务方确认,而不是自己悄悄拍板。

这次落地的具体场景是一次支付渠道的托收对接,但上面这十条和这个具体场景没有必然关系——换成任何一个"要在陌生的、已经运行多年的业务系统里新增一块功能,还要求不动老代码"的任务,这套读代码、画数据流、留决策痕迹的方法都适用。