多平台对账的"统一抽象 vs 平台特化":架构选择的工程哲学
多平台对账的"统一抽象 vs 平台特化":架构选择的工程哲学
摘要:「多平台对账系统应该抽一个统一抽象基类,还是让每个平台各自实现?」——这是每个架构师都会撞上的问题,标准答案通常分两派:「统一抽象派」认为代码复用 > 一切,「平台特化派」认为业务差异 > 一切。本文给出第三种答案:这不是一道是非题,是 6 道分层选择题。数据层必须平台特化(账单物理结构决定)、契约层必须统一抽象(用户期望决定)、判定层必须平台特化(算法依赖结构)、差异语义层必须统一抽象(机读码需要可比)、执行层必须统一抽象(沙箱、队列、状态机)、扩展机制必须平台特化(每平台独立演进)。选错层的代价不是代码冗余、是指数级返工成本——v4.0 → v3.0 重构那次,我们为了把"统一抽象"从数据层挪走,烧了 6 周 × 3 人。
技术评审会上那个永不消失的问题
2026 年 5 月,我们启动 v4.0 → v3.0 重构。第一次架构评审会上,一位资深工程师提出了那个永远会出现在多平台系统评审里的问题:
"5 个平台 × 4 个账单类型 = 20 个 BillRow 模型,字段重合度 60%+,要不要抽一个
AbstractBillRow基类?"
紧接着是对账侧:"要不要抽 IReconciliationStrategy 接口统一 5 个平台的判定算法?" 转化侧:"要不要抽 ITransformRule 把 164 条规则的平台特化部分挤掉?"
三个问题问完,会议室安静了 30 秒。我当时的回答是:先别回答。这不是一道是非题,是 6 道选择题——每一层都有不同的答案。
那天的结论是"先按平台特化写出 5 个最小可用版本,再回头看哪些层真的需要统一抽象"。6 周后我们回头看的时候,发现 6 个层里只有 2 层需要统一抽象,4 层必须平台特化——这个比例不是我事先猜到的,是 5 个平台真实业务逼出来的。
下面这张图就是 5 个已落地平台 + 3 个最近接入平台的全景,每个平台的对账特征都不一样——这种"业务侧自然就长得不一样"的现状,是讨论"统一 vs 特化"的唯一起点:
一、6 层架构 × 6 个分层决策
多平台对账系统不是一个单一系统,是 6 个层次的叠加。每一层回答的问题不一样,需要的"统一抽象率"也不一样。架构师的工作不是做一道统一抽象 vs 平台特化的二选一,是给 6 个层分别选:
| 层级 | 回答的核心问题 | 决策 | 理由 |
|---|---|---|---|
| L1 数据层 | "账单物理结构长什么样?" | 平台特化 | 账单结构是业务世界的物理事实,不是工程选择 |
| L2 契约层 | "调用方期望拿到什么?" | 统一抽象 | 用户期望"调一次拿到所有平台",契约必须统一 |
| L3 判定层 | "差异怎么算出来?" | 平台特化 | 算法依赖账单结构(整单轧差 vs 分向不轧差) |
| L4 语义层 | "差异怎么命名、怎么机读?" | 统一抽象 | 标签需要跨平台可比、跨报表可聚合 |
| L5 执行层 | "脚本怎么跑、日志怎么落?" | 统一抽象 | 沙箱、队列、状态机是基础设施,与平台无关 |
| L6 扩展层 | "新平台怎么接进来?" | 平台特化 | 每平台独立演进,扩展点必须平台隔离 |
把这张表背下来。当你面对"要不要抽基类"的问题时,第一步是问自己"这个问题在 6 层里属于哪一层"——答案错了,怎么选都是债。
下面这张图是 v4.0 旧版与 v3.0 新版的对比,左边红色的是"统一抽象派"的代价——20 张含抽象基类的表 + tenantId 多租户 + deletedAt 软删除 + 5 层沙箱防护 + 抽象基类继承;右边绿色的是 v3 重构后的形态——24 张独立表 + 约定规范 10 条 + 平台聚合范式 + 双层沙箱 + 23 个 diffReason 标签:
二、L1 数据层:必须平台特化("基类 = 债")
L1 数据层的答案永远是平台特化,没有例外。原因是账单物理结构是业务世界本来就长得不一样,不是工程选择:
- 京东 POP 账户流水(gb18030 编码、前 6 行汇总、第 7 行表头):核心字段是「商户号 + 账户代码 + 收支方向」
- 亚马逊 Transaction(UTF-8 BOM、前 9 行元数据、第 10 行表头、15 个金额列宽表):核心字段是「settlement id + type + sku + 15 个 fee 列」
- 支付宝账户流水(utf-8-bom、含 20+ 列资金流字段):核心字段是「支付宝交易号 + 流水号 + 对方账户 + 业务基础订单号」
- 抖店 account_flow:核心字段是「子行 rowSeq + 主行/子行拆分 + 拆行注释」
- 拼多多 capital:核心字段是「资金类型 + 店铺编号」
如果硬抽基类,必然出现 4 种事故场景:
- 基类被迫加 60 个字段——为了覆盖所有平台特有字段,基类变成"全字段字典",每个 BillRow 只填其中 1/3,其余 2/3 是
null; - 基类一次改动 = 全平台编译失败——亚马逊加一个
settlement_id字段,京东 / 抖店 / 支付宝的 BillRow 都要跟着加,否则 TS 报错"未实现父类抽象字段"; - 基类的
aggregate()模板方法——5 个平台的聚合逻辑(亚马逊按腿聚合 vs 京东整单轧差 vs 抖店五轮匹配 vs 支付宝三表对账 vs 拼多多单腿)根本无法用同一个模板描述,最后基类的aggregate()退化成if (platform === "AMAZON") ... else if ...的另一种耦合; - 新人 onboarding 时被迫读基类——新人想了解京东聚合,必须先读 200 行的
AbstractBillRow基类代码、再读 200 行的京东子类,无法独立读懂京东。
我们当前的实际状态是 23 张完全独立的 BillRow 表(apps/api/prisma/schema/jdpop.prisma × 3 + douyin.prisma × 1 + amazon.prisma × 2 + alipay.prisma × 3 + sph.prisma × 2 + xiaohongshu.prisma × 2 + pdd.prisma × 1 + independent-site.prisma × 4 + aliexpress.prisma × 5 = 23),分布在 9 个平台专属的 .prisma 文件里。这些表之间没有任何 extends / interface 关系,没有任何外键关联(BillRow 之间的关联是通过 businessOrderNo 业务字段跨表 join,不是通过 SQL 外键)。
数据层的硬约束:3 条
| 约束 | 出处 | 违反后果 |
|---|---|---|
| 不抽抽象基类 | 23 张表实际状态 | 基类变成"全字段字典" |
| 跨表关联走业务字段 | businessOrderNo 业务层 join | 加索引成本陡增、外键循环依赖 |
| 字段命名靠 10 条约定 | CAT.* / SR.* / ZC.* 命名规范 | 命名混乱、grep 失效 |
[来源:2026-05 v3 业务规范数据模型定稿记录]
三、L2 契约层:必须统一抽象(用户期望的"一次接入")
L2 契约层的答案永远是统一抽象。原因是用户的期望是"调一次接口拿 5 个平台"——契约层不统一,整个产品就是个 5 个工具的拼盘。这也是轻易云多平台治理哲学在契约层的核心:用户面对的是「一个对账系统」,不是「5 套对账工具」。
平台聚合范式:2 个标准方法 + 3 个 TypeScript 接口 + 1 个注册表
契约面的核心是 apps/api/src/biz_reconciliation/common/platform-aggregation-registry.ts 一个文件,4 个 TypeScript 定义撑起所有平台的对外契约:
// apps/api/src/biz_reconciliation/common/platform-aggregation-registry.ts
export interface PlatformAggregation {
aggregateIncome(input: IncomeAggregationInput): Promise<IncomeAggregationRow[]>;
aggregateExpense(input: ExpenseAggregationInput): Promise<ExpenseAggregationRow[]>;
/** 无收入订单费用(可选):订单账期内无主营收入的费用行按 订单 × 核算项目 汇总 */
aggregateNoIncomeFees?(input: ExpenseAggregationInput): Promise<NoIncomeFeeRow[]>;
}
const FACTORIES: Partial<Record<PlatformEnum, (prisma: PrismaClient) => PlatformAggregation>> = {
[PlatformEnum.JD_POP]: createJdpopAggregation,
[PlatformEnum.AMAZON]: createAmazonAggregation,
// ... 8 个平台注册项
};
这是 L2 统一抽象的典型形态——接口用 TypeScript interface 声明、注册表用 Partial<Record<PlatformEnum, Factory>> 静态映射。IncomeReconciliationService.startReconcile() 调用方一行代码不区分平台:
const aggregation = FACTORIES[platform]!(prisma);
const rows = await aggregation.aggregateIncome(input);
这个范式从 2026-07-17 落地到 2026-09-20 接入第 7 平台 XHS 小红书,一共加过 4 次平台(PDD / INDEPENDENT_SITE / ALIEXPRESS 速卖通 / XHS 小红书)、加过 1 个可选第三方法 aggregateNoIncomeFees,没有一次动过注册表或调用方代码——这就是「约定规范代替抽象基类」的力量。
[来源:2026-09-20 第 7 平台接入实测 + 平台聚合注册表调用链路]
契约层的硬约束:3 条
| 约束 | 出处 | 违反后果 |
|---|---|---|
| 接口签名 6 个月内不允许改 | 第 7 平台接入 0 改动实测 | 调用方编译失败 |
新方法必须可选(? 后缀) | aggregateNoIncomeFees 范例 | 老平台被迫实现空方法 |
| 注册表只允许加行不允许减行 | v3 以来 0 减行 | 平台下线时全调用方报错 |
下面这张图是业务模块架构全景——上半部是平台基座(统一抽象:Auth / Gateway / Queue / Settings / Prisma / Redis),下半部是 biz_reconciliation(平台聚合范式承载多平台治理):
四、L3 判定层:必须平台特化(算法依赖账单结构)
L3 判定层的答案是平台特化,且这一层的特化是最有业务深度、最不能合并的特化。原因是判定算法依赖账单的物理结构——账单结构不一样,算法就不一样,没有"通用算法"这回事。
京东整单轧差 vs 亚马逊分向不轧差是最经典的对照案例(详见《整单轧差 vs 分向不轧差:京东 / 亚马逊的两种对账策略》一文)。根因在账单层:
- 京东账单按商家业务订单号归集——同一订单的收款 + 退款落在同一组体行、能合并成净额;
- 亚马逊账单按平台单号归集——同一订单的收款和退款是两条独立体行、天然分向。
这两件事不是工程选择,是物理事实。强行合并判定算法会出现「if (platform === "AMAZON") ... else if ...」的反模式——基类退化成另一种耦合。
判定层的硬约束:3 条
| 约束 | 出处 | 违反后果 |
|---|---|---|
每个平台一个 .ts 文件 | jdpop.reconciliation.ts × 5 | 合并文件后 5000 行 |
| 严格相等无容差为原则 | 2026-08-04 v3.0.0 定稿 | 容差模糊券差异、补差价 |
| 承载行机制强制 | 京东 v3.3.1 + 抖店 v3.6.3 同构 | 互抵单被闸门误判 |
我们当前 5 个平台收入对账脚本的实际长度:京东 v3.4.2 1058 行、抖店 v3.6.3 777 行、支付宝 v3.6.5 1109 行、亚马逊 v3.9.6 932 行、速卖通 v3.x.x 856 行——5 个平台 × 平均 950 行 = 4750 行的"代码即业务"。这 4750 行不能合并,也不应该合并——每行都是该平台独有的判定逻辑,合并后就是 5000 行的"伪基类",新人 onboarding 时不知道从哪读起。
五、L4 语义层:必须统一抽象(标签需要跨平台可比)
L4 语义层的答案是统一抽象。虽然判定算法平台特化,但判定结果的命名必须统一——因为 C 端的报表、跨平台分析、自动化处置都要求差异标签跨平台可比。
23 个 diffReason 机读码就是 L4 统一抽象的产物:
| 类别 | 标签数 | 代表码 | 跨平台复用 |
|---|---|---|---|
| 取消/退货类 | 4 | CANCELLED_UNSHIPPED / AFTER_SALE_SERVICE_DIFF / GIFT_WIPES | 5 平台共用 |
| 金额差异类 | 5 | AMOUNT_DIFF / PRICE_DIFF_ORDER / PRICE_PROTECT_DIFF | 4 平台共用 |
| 部分结算类 | 3 | PARTIAL_SETTLEMENT / DIRECT_COMPENSATION | 3 平台共用 |
| 平台特化类 | 7 | FBA_REIMBURSEMENT / INSTANT_REFUND / JIT_AMOUNT_DIFF | 1-2 平台专用 |
| 其他 | 4 | OTHER + 自定义兜底 | 全部 |
[来源:INCOME_DIFF_REASON_LABELS 机读码 + 中文标签映射]
L4 的关键纪律是"机读码与人读标签分离"——AMOUNT_DIFF 是机读码(机器判定、自动化处置脚本消费),"金额差异" 是人读标签(业务人员理解)。两者 1:1 映射、互相冗余、永远同步。
语义层的硬约束:3 条
| 约束 | 出处 | 违反后果 |
|---|---|---|
| 标签机读码不可改 | 自 2026-09 起冻结 | 已有数据失读、自动化脚本失效 |
| 新增机读码走 10 处同步链 | platform-script-onboarding 同步链条章 | 单边遗漏导致报表不准 |
| 中文标签可改但需留 alias | 历史 联合优惠券/立减差异 多版本 | 老数据需重新映射 |
六、L5 执行层 + L6 扩展层:执行统一 / 扩展特化
L5 执行层必须统一抽象。原因是沙箱、队列、状态机是基础设施,与平台无关。沙箱代码只关心"安全隔离 IPC + 30 秒超时 + SIGKILL",队列只关心"并发数 + 重试 + 失败兜底",状态机只关心"7+5+3+4+4 态迁移规则"——这些代码0 行业务字、0 行平台特化,天然就是统一抽象的最佳土壤。
apps/api/src/queue/queue.constants.ts 是 L5 的典型——5 个平台 × 5 个队列 + 4 个 worker + JobTask 4 态状态机,完全没有平台特化代码:
// apps/api/src/queue/queue.constants.ts
export const QUEUE_BILL_IMPORT_JD_POP = 'queue:bill:import:jd_pop';
export const QUEUE_BILL_IMPORT_DOUYIN = 'queue:bill:import:douyin';
export const QUEUE_BILL_IMPORT_ALIPAY = 'queue:bill:import:alipay';
export const QUEUE_BILL_IMPORT_AMAZON = 'queue:bill:import:amazon';
export const QUEUE_BILL_IMPORT_ALIEXPRESS = 'queue:bill:import:aliexpress';
export const QUEUE_INCOME_RECONCILE = 'queue:income:reconcile';
export const QUEUE_EXPENSE_ALLOCATE = 'queue:expense:allocate';
L6 扩展层必须平台特化。每平台独立演进,扩展点必须平台隔离。新平台接入流程是 L6 的黄金案例——8 步 checklist(聚合注册 → 核算项目 → 解析脚本 → 对账脚本 → 转换脚本 → 规则 seed → 知识库 / help → 验证)每一步都是平台独立的:
| 步骤 | 平台特化体现 | 接入实测耗时 |
|---|---|---|
| 聚合注册 | 1 个 .ts + 1 行注册表 | 0.5 天 |
| 核算项目 seed | 平台专属 L3 叶子 | 0.5 天 |
| 解析脚本 | 平台专属 BillRow + 解析函数 | 2 天 |
| 对账脚本 | 平台专属判定算法(按标准骨架) | 4 天 |
| 转换脚本收入 + 费用各一 | 平台专属转换逻辑 | 3 天 |
| 差异机读码 | 优先复用,新增走 10 处同步 | 1 天 |
| 知识库 + help 主题 | 平台专属文档 | 1 天 |
| 验证 + 批次轧平 | 平台专属 harness 回放 | 2 天 |
5 + 3 + 独立站 = 8 个平台平均接入耗时 14 天 → 第 7 平台 XHS 小红书接入耗时降到 9 天(复用既有 diffReason + 既有核算项目)——这就是 L6 平台特化的成本红利。
[来源:2026-09 新平台接入 8 步 checklist + 5 平台版本演进史]
下面这张图是多店多平台统一对账的 hub-and-spoke 视图——中心是统一对账引擎(聚合范式承载),周围是 38+ 店铺(5 平台),每个店铺走独立的解析+对账+转换路径,但被同一套调度、同一套状态机、同一套差异标签统一管理:
七、错层的代价:v4.0 → v3.0 那 6 周的复盘
为了给"分层决策"提供反向证据,我把 v4.0 时代选错层的代价摆在下面——这不是代码冗余的成本,是指数级返工的成本:
| 选错层的场景 | v4.0 时代真实代价 | v3.0 修复方法 |
|---|---|---|
数据层抽 AbstractBillRow 基类 | 200 行基类 + 5 子类继承,亚马逊加字段全平台编译失败 | 删基类、23 张独立表、约定规范 10 条 |
| 契约层漏统一(5 个平台 5 套 API) | 前端要写 5 套适配层 | 平台聚合范式(4 interface + 1 注册表) |
| 判定层强行模板方法 | 200 行 aggregate() 退化为 if-else 链 | 5 个平台 5 个独立 .ts、严格标准骨架 |
| 语义层每个平台自己造码 | 5 套差异标签无法跨平台报表 | 23 个 diffReason 机读码统一 |
| 执行层 5 套沙箱 | 5 套 IPC + 5 套超时配置,运维不可观测 | 双层沙箱 + 队列统一抽象 |
| 扩展层没有约定 | 新平台接入 30 天起 | 8 步 checklist + 复用既有 diffReason 降到 9 天 |
[来源:2026-05-04 至 2026-06-15 v4.0 至 v3.0 重构记录]
八、3 条心法(架构师的判别准则)
把 6 层决策浓缩成 3 条心法,架构师面对"要不要抽基类"问题时按顺序问自己:
心法 1:"业务的物理事实"先于"工程美感"
字段重合度 60%+ 是物理事实造成的吗?是 → 平台特化。是因为历史命名混乱造成的吗?→ 约定规范治理。在 L1 数据层,账单物理结构永远是业务世界的真,工程美感是次要的——宁可 23 张独立表难看,不可 1 个基类债重。
心法 2:"用户期望"决定 L2 契约是否统一
用户期望"调一次拿到所有平台" → L2 必须统一抽象;用户期望"按平台独立调用" → L2 可以平台特化。L2 抽象率过高 = 浪费,过低 = 难用。当前 5 个平台的 aggregateIncome / aggregateExpense 抽象率 = 100%(每平台必须实现 2 个方法),第 3 方法 aggregateNoIncomeFees 抽象率 = 25%(仅亚马逊实现)。
心法 3:"扩展成本"决定 L6 是否平台隔离
如果新平台接入耗时是 30 天起 → 扩展层出了问题(要么 L2 抽象过紧、要么 L3 骨架不清晰);如果新平台接入耗时降到 9 天 → L6 平台隔离做对了。L6 是架构决策的"延迟验证"——3 个月后回头看接入耗时就知道了。
九、整体架构视角:4 层分层 × 6 层决策
把 6 层决策叠到 4 层架构上(表现层 / 业务层 / 数据层 / 基础设施层),你能看到统一抽象和平台特化是交错出现的,不是泾渭分明的两层:

统一抽象带在"基座"上(沙箱 / 队列 / 状态机 / Prisma / Redis),平台特化带在"业务层内部"(数据层 + 判定层 + 扩展层)。这就是分层决策的可视化结果——架构图不再是非此即彼的"统一 vs 特化",而是6 个有界面的层、每个层有自己明确的抽象率。
十、给架构师的 take-away
回到开篇的问题:「多平台对账系统应该抽一个统一抽象基类,还是让每个平台各自实现?」
答案是:先别回答,问自己 6 个分层问题:
- 这是数据层吗?→ 平台特化(账单物理结构是事实)
- 这是契约层吗?→ 统一抽象(用户期望)
- 这是判定层吗?→ 平台特化(算法依赖结构)
- 这是语义层吗?→ 统一抽象(标签需要可比)
- 这是执行层吗?→ 统一抽象(基础设施无关业务)
- 这是扩展层吗?→ 平台特化(每平台独立演进)
6 个问题问完,6 个答案几乎自动出现——数据层 23 张独立表、契约层 4 interface + 1 注册表、判定层 5 个独立 .ts、语义层 23 个 diffReason 统一码、执行层双层沙箱 + 队列、扩展层 8 步 checklist。这不是一道抽象 vs 特化的二选一,是6 道分层选择 + 每层独立的最优解。
这套工程哲学我们叫它「分层抽象率」——不追求单一层的最高抽象率,追求 6 层的最适配分布。它帮我们把第 5 平台支付宝接入耗时压到 9 天,把第 7 平台小红书接入耗时至 9 天(复用既有 diffReason + 核算项目)——这就是把抽象成本花在"对的位置"的回报。
如果你想在自己的对账系统里复用这套分层决策法,完整工程范式沉淀在项目内的 platform-script-onboarding skill 中——6 层决策的每层硬约束、错层代价的真实数据、新平台接入的 8 步 checklist,都在那里整理好了。
架构决策不追求"全局最优抽象",追求"每一层的最适配抽象率"——这就是多平台对账系统统一抽象 vs 平台特化的工程哲学答案。