《互联网产品的客户端架构·交易、增长与平台化》6.2 支付对账的数据口径

端上无法直接看渠道账单,只能把订单状态、支付单状态与渠道流水号三路信号按 orderId、payId、channelTxnId 对齐,再把差异分成漏单、重复、金额不符与状态不一致。本节给出一个纯函数分类器与带触发时机的待确认队列,并附真实测试输出。

问题:端上能看到的三路信号

支付完成后,端上通常会拿到三样东西:订单状态、支付单状态、渠道流水号。三者来自不同系统,更新时机也不同。订单可能先变成已支付,支付单稍后才落库;渠道流水可能已经到了,支付单却还停在待确认。如果端上只拿其中一路做判断,就会得到片面的结论。只看订单状态,会把「订单已支付但支付单缺失」当成正常;只看支付单,会把「渠道已扣款但支付单未创建」当成漏单;只看渠道流水,又无法知道这笔流水属于哪张订单。

对账要做的不是再查一次接口,而是把三路信号按固定的键对齐,输出一份可解释的差异清单。端上不修改任何状态,只负责发现不一致并把它送进待确认队列。

三路信号与三把键

信号来源主键能回答的问题
订单状态订单服务orderId这笔交易在业务上是否已支付
支付单状态支付服务payId端上发起的某次支付是否成功
渠道流水渠道账单或查询接口channelTxnId渠道侧是否真的收到钱

三把键的关系是:订单与支付单一对多,支付单与渠道流水通过 channelTxnId 桥接。支付单上保存渠道流水号,渠道流水上保存支付单号,两边互为索引。对账时先按 orderId 收拢同一订单,再按 payId 找到支付单,最后按 channelTxnId 找到渠道记录。任何一处断链都会产生差异:支付单没有流水号是「缺流水」,流水找不到支付单是「漏单」,同一流水被两张支付单引用是「重复」。

差异分类:漏单、重复、金额、状态

差异典型场景严重度端上动作
missing_payment渠道已结算,但没有已支付支付单严重进入待确认,提示稍后刷新
missing_channel支付单已支付,但缺少渠道流水严重进入待确认,禁止重复支付
duplicate_payment两张已支付支付单,或同一流水被多次引用严重冻结支付入口,转人工核实
amount_mismatch支付单与流水金额不等,或支付总额与订单实付不等严重不展示成功,进入待确认
state_mismatch支付成功但订单仍待支付,或渠道流水失败、退款警告或严重按订单状态刷新,保留证据

分类结果必须稳定:同一份输入无论查询多少次,都得到同一组差异。这样端上才能用差异的「是否为空」判断对账是否完成,而不是依赖某次请求的成败。

待确认队列:不要支付完立刻对账

支付刚成功时,支付单、订单、渠道流水往往还没全部落库。立刻对账会把正常的最终一致性误判成漏单。端上应该把这类订单放进待确认队列,延迟一段时间再查。队列项至少包含四个字段:订单号、进入原因、已尝试次数、下次检查时间。支付成功后默认延迟 3 分钟入队;回调超时、金额不符等异常可以立即入队。每次查询未对齐就把尝试次数加一,按指数退避重排;对齐后立即移出队列;退避是为了避免在渠道账单还没生成时反复查询,把「暂时查不到」误当成「永远查不到」。

触发时机:启动、回前台、定时器

触发源行为约束
启动检查整个待确认队列无条件执行,优先恢复中断的核对
回到前台检查到期项受最小间隔保护,避免频繁切换页面时刷请求
定时器只检查到期项作为前台驻留时的补充,不假设一定执行
手动检查整个队列用户点「刷新状态」时忽略间隔

最小间隔解决的是「用户反复切前后台」造成的请求风暴。它只限制回前台触发的频率,不限制启动与手动触发,因为后两者有明确的用户意图或恢复需求。

对账模块的实现

下面的模块属于 L1:分类是纯函数,时间由调用方传入,队列操作返回新数组,不注册定时器,也不发请求。

// file: src/reconcile/reconcile.ts
// tier: L1
// verify: node --test test/reconcile/reconcile.test.ts

export type PaymentState = "paid" | "pending" | "failed" | "closed";
export type ChannelStatus = "settled" | "refunded" | "failed";
export type OrderStatus = "created" | "pending_payment" | "paid" | "shipped" | "completed" | "cancelled" | "refunding" | "refunded";

export interface PaymentView {
  payId: string; orderId: string; state: PaymentState; amountCents: number; channelTxnId: string | null;
}

export interface ChannelRecord {
  channelTxnId: string; payId: string; orderId: string; amountCents: number; status: ChannelStatus;
}

export interface ReconcileInput {
  orderId: string; orderState: OrderStatus; orderPaidCents: number;
  payments: PaymentView[]; channels: ChannelRecord[];
}

export type DiffKind = "matched" | "missing_payment" | "missing_channel" | "duplicate_payment" | "amount_mismatch" | "state_mismatch";

export interface ReconcileDiff {
  kind: DiffKind; severity: "info" | "warn" | "critical"; orderId: string;
  payId: string | null; channelTxnId: string | null;
  expectedCents: number; actualCents: number; note: string;
}

const PAID_STATES: OrderStatus[] = ["paid", "shipped", "completed", "refunding", "refunded"];

function present(value: string | null): string | null {
  const trimmed = value === null ? "" : value.trim();
  return trimmed === "" ? null : trimmed;
}

function validCents(cents: number, positive: boolean): boolean {
  return Number.isSafeInteger(cents) && (positive ? cents > 0 : cents >= 0);
}

function checkInput(input: ReconcileInput): string {
  const orderId = input.orderId.trim();
  if (orderId === "") throw new Error("订单号不能为空");
  if (!validCents(input.orderPaidCents, false)) throw new Error("订单实付金额必须是非负整数分");
  for (const payment of input.payments) {
    if (payment.payId.trim() === "") throw new Error("支付单号不能为空");
    if (!validCents(payment.amountCents, true)) throw new Error(`支付金额 ${payment.payId} 必须是正整数分`);
  }
  for (const record of input.channels) {
    if (record.channelTxnId.trim() === "") throw new Error("渠道流水号不能为空");
    if (!validCents(record.amountCents, true)) throw new Error(`渠道金额 ${record.channelTxnId} 必须是正整数分`);
  }
  return orderId;
}

// 三条对齐键:订单用 orderId,支付单用 payId,渠道流水用 channelTxnId 桥接支付单。
export interface PaymentLink {
  payId: string; orderId: string; channelTxnId: string | null; channel: ChannelRecord | null;
}

export function alignPayments(input: ReconcileInput): PaymentLink[] {
  const byTxn = new Map(input.channels.map((record) => [record.channelTxnId.trim(), record]));
  return input.payments.map((payment) => {
    const txn = present(payment.channelTxnId);
    return { payId: payment.payId, orderId: payment.orderId, channelTxnId: txn, channel: txn === null ? null : (byTxn.get(txn) ?? null) };
  });
}

// 端上只做分类,不改写任何状态;同一份输入必须得到同一份差异列表。
export function classifyReconciliation(input: ReconcileInput): ReconcileDiff[] {
  const orderId = checkInput(input);
  const diffs: ReconcileDiff[] = [];
  const add = (
    kind: DiffKind, severity: "info" | "warn" | "critical", note: string,
    values: { payId?: string | null; channelTxnId?: string | null; expectedCents?: number; actualCents?: number } = {},
  ): void => {
    diffs.push({
      kind, severity, orderId, note,
      payId: values.payId ?? null, channelTxnId: values.channelTxnId ?? null,
      expectedCents: values.expectedCents ?? 0, actualCents: values.actualCents ?? 0,
    });
  };
  const paid = input.payments.filter((payment) => payment.state === "paid");

  if (paid.length > 1) {
    const total = paid.reduce((sum, payment) => sum + payment.amountCents, 0);
    add("duplicate_payment", "critical", `同一订单存在 ${paid.length} 张已支付支付单`, {
      expectedCents: input.orderPaidCents, actualCents: total,
    });
  }
  const paymentTxn = new Map<string, number>();
  for (const payment of paid) {
    const txn = present(payment.channelTxnId);
    if (txn !== null) paymentTxn.set(txn, (paymentTxn.get(txn) ?? 0) + 1);
  }
  for (const [txn, count] of paymentTxn) {
    if (count > 1) add("duplicate_payment", "critical", `渠道流水 ${txn} 被 ${count} 张支付单引用`, { channelTxnId: txn });
  }
  const channelTxn = new Map<string, number>();
  for (const record of input.channels) {
    const txn = record.channelTxnId.trim();
    channelTxn.set(txn, (channelTxn.get(txn) ?? 0) + 1);
  }
  for (const [txn, count] of channelTxn) {
    if (count > 1) add("duplicate_payment", "critical", `渠道流水 ${txn} 存在 ${count} 条记录`, { channelTxnId: txn });
  }

  const referenced = new Set<string>();
  for (const payment of paid) {
    const txn = present(payment.channelTxnId);
    if (txn !== null) referenced.add(txn);
  }
  for (const record of input.channels) {
    if (record.status === "settled" && !referenced.has(record.channelTxnId.trim())) {
      add("missing_payment", "critical", "渠道已结算但没有对应的已支付支付单", {
        channelTxnId: record.channelTxnId, expectedCents: record.amountCents,
      });
    }
  }

  for (const payment of paid) {
    if (payment.orderId.trim() !== orderId) {
      add("state_mismatch", "critical", `支付单 ${payment.payId} 属于其他订单`, { payId: payment.payId });
      continue;
    }
    const txn = present(payment.channelTxnId);
    if (txn === null) {
      add("missing_channel", "critical", `支付单 ${payment.payId} 缺少渠道流水号`, {
        payId: payment.payId, expectedCents: payment.amountCents,
      });
      continue;
    }
    const record = input.channels.find((item) => item.channelTxnId.trim() === txn);
    if (!record) {
      add("missing_channel", "critical", `支付单 ${payment.payId} 的渠道流水 ${txn} 不存在`, {
        payId: payment.payId, channelTxnId: txn, expectedCents: payment.amountCents,
      });
      continue;
    }
    if (record.amountCents !== payment.amountCents) {
      add("amount_mismatch", "critical", `支付单 ${payment.payId} 与渠道金额不一致`, {
        payId: payment.payId, channelTxnId: txn,
        expectedCents: payment.amountCents, actualCents: record.amountCents,
      });
    }
    if (record.payId.trim() !== payment.payId) {
      add("state_mismatch", "warn", `渠道流水 ${txn} 关联的支付单号不一致`, {
        payId: payment.payId, channelTxnId: txn,
      });
    }
    if (record.status === "failed") {
      add("state_mismatch", "critical", `支付单 ${payment.payId} 已支付但渠道流水为失败`, {
        payId: payment.payId, channelTxnId: txn,
      });
    } else if (record.status === "refunded") {
      add("state_mismatch", "warn", `渠道流水 ${txn} 已退款`, { payId: payment.payId, channelTxnId: txn });
    }
  }

  const paidSum = paid.reduce((sum, payment) => sum + payment.amountCents, 0);
  if (paid.length > 0 && paidSum !== input.orderPaidCents) {
    add("amount_mismatch", "critical", "支付单金额之和与订单实付不一致", {
      expectedCents: input.orderPaidCents, actualCents: paidSum,
    });
  }
  const orderLooksPaid = PAID_STATES.includes(input.orderState);
  if (paid.length > 0 && !orderLooksPaid) {
    add("state_mismatch", "critical", "支付已成功但订单状态不是已支付", { expectedCents: input.orderPaidCents });
  }
  if (paid.length === 0 && orderLooksPaid && input.orderPaidCents > 0) {
    add("missing_payment", "critical", "订单已支付但缺少已支付支付单", { expectedCents: input.orderPaidCents });
  }

  if (diffs.length === 0) {
    return [
      {
        kind: "matched",
        severity: "info",
        orderId,
        payId: null,
        channelTxnId: null,
        expectedCents: input.orderPaidCents,
        actualCents: input.orderPaidCents,
        note: "订单、支付单与渠道流水一致",
      },
    ];
  }
  return diffs;
}

export type PendingReason = "post_pay" | "callback_timeout" | "amount_mismatch" | "manual";

export interface PendingItem {
  orderId: string; reason: PendingReason; attempts: number;
  nextAt: number; lastCheckedAt: number | null;
}

export interface TriggerContext {
  source: "launch" | "foreground" | "timer" | "manual"; now: number;
  lastGlobalCheckAt: number | null; foregroundMinIntervalMs?: number;
}

export interface TriggerPlan {
  shouldCheck: boolean; orderIds: string[];
  reason: "launch" | "foreground" | "interval_guard" | "due" | "manual" | "empty";
}

export function scheduleAfterPay(orderId: string, paidAt: number, delayMs = 180_000): PendingItem {
  const id = orderId.trim();
  if (id === "") throw new Error("订单号不能为空");
  if (!Number.isFinite(paidAt)) throw new Error("支付时间必须是有限数字");
  if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error("延迟时间非法");
  return { orderId: id, reason: "post_pay", attempts: 0, nextAt: paidAt + delayMs, lastCheckedAt: null };
}

export function backoffMs(attempts: number, options: { baseMs?: number; capMs?: number } = {}): number {
  if (!Number.isInteger(attempts) || attempts < 1) throw new Error("重试次数必须是正整数");
  const base = options.baseMs ?? 60_000;
  const cap = options.capMs ?? 900_000;
  if (!Number.isFinite(base) || base <= 0) throw new Error("退避基数非法");
  if (!Number.isFinite(cap) || cap <= 0) throw new Error("退避上限非法");
  return Math.min(cap, base * 2 ** Math.min(attempts - 1, 20));
}

export function dueOrderIds(queue: PendingItem[], now: number): string[] {
  if (!Number.isFinite(now)) throw new Error("当前时间必须是有限数字");
  return queue.filter((item) => item.nextAt <= now).map((item) => item.orderId);
}

// 触发时机收敛在一处:启动无条件查,回前台受最小间隔约束,定时器只查到期项。
export function planReconcile(queue: PendingItem[], context: TriggerContext): TriggerPlan {
  if (!Number.isFinite(context.now)) throw new Error("当前时间必须是有限数字");
  if (queue.length === 0) return { shouldCheck: false, orderIds: [], reason: "empty" };
  if (context.source === "manual" || context.source === "launch") {
    return { shouldCheck: true, orderIds: queue.map((item) => item.orderId), reason: context.source };
  }
  const due = dueOrderIds(queue, context.now);
  if (context.source === "timer") return { shouldCheck: due.length > 0, orderIds: due, reason: "due" };
  const interval = context.foregroundMinIntervalMs ?? 60_000;
  if (!Number.isFinite(interval) || interval < 0) throw new Error("前台检查间隔非法");
  if (context.lastGlobalCheckAt !== null && context.now - context.lastGlobalCheckAt < interval) {
    return { shouldCheck: false, orderIds: [], reason: "interval_guard" };
  }
  return { shouldCheck: due.length > 0, orderIds: due, reason: "foreground" };
}

// 查询仍未对齐时按指数退避重排;对齐后立即移出待确认队列。
export function applyReconcileResult(
  queue: PendingItem[], orderId: string, resolved: boolean, now: number,
  options: { baseMs?: number; capMs?: number } = {},
): PendingItem[] {
  if (!Number.isFinite(now)) throw new Error("当前时间必须是有限数字");
  const id = orderId.trim();
  if (!queue.some((item) => item.orderId === id)) throw new Error(`待确认队列中没有订单 ${id}`);
  if (resolved) return queue.filter((item) => item.orderId !== id);
  return queue.map((item) => {
    if (item.orderId !== id) return item;
    const attempts = item.attempts + 1;
    return { ...item, attempts, lastCheckedAt: now, nextAt: now + backoffMs(attempts, options) };
  });
}

alignPayments() 先建立渠道流水的索引,再把每张支付单映射到对应流水。它只做对齐,不判断对错;缺少流水时 channel 为 null,把判断留给分类器。classifyReconciliation() 按固定顺序检查:重复支付、渠道孤岛、逐张支付单的流水与金额、订单级汇总、订单状态。顺序固定意味着输出稳定,调用方可以按严重度排序后展示,而不必担心同一问题每次换一个类型。planReconcile() 把四种触发源收敛成一个 shouldCheck 与订单列表。启动与手动返回全部,回前台受间隔保护,定时器只返回到期项。applyReconcileResult() 在未对齐时按 1、2、4、8 分钟退避,上限 15 分钟;对齐后直接移除。

测试与真实输出

23 条用例分成四组:对齐与分类(14 条)、入队与到期(2 条)、触发时机(4 条)、退避与出队(3 条)。全部是纯函数调用,没有网络与真实等待。本机运行结果如下:

$ node --test --test-concurrency=1 test/reconcile/reconcile.test.ts
✔ 对齐键把支付单与渠道流水连起来 (0.965167ms)
✔ 完全一致的对账返回 matched (0.287292ms)
✔ 订单已支付但缺少支付单判为漏单 (0.106833ms)
✔ 渠道已结算但没有已支付支付单判为漏单 (0.079083ms)
✔ 支付单已支付但缺少渠道流水判为缺少流水 (0.091334ms)
✔ 同一订单两张已支付支付单判为重复 (0.121959ms)
✔ 同一渠道流水被两张支付单引用判为重复 (0.089042ms)
✔ 支付单与渠道金额不一致判为金额不符 (0.093875ms)
✔ 支付金额之和与订单实付不一致判为金额不符 (0.085041ms)
✔ 支付已成功但订单仍待支付判为状态不一致 (0.125458ms)
✔ 渠道流水状态为失败判为状态不一致 (0.093084ms)
✔ 渠道流水关联的支付单号不一致给出警告 (0.066958ms)
✔ 渠道已退款时给出状态警告 (0.126375ms)
✔ 非法金额输入被拒绝 (0.241ms)
✔ 支付成功后按延迟进入待确认队列 (0.10125ms)
✔ 定时器只拾取到期的待确认项 (0.134542ms)
✔ 启动时无条件检查整个队列 (0.049291ms)
✔ 回到前台受最小检查间隔保护 (0.054625ms)
✔ 回到前台超过间隔后检查到期项 (0.055083ms)
✔ 手动触发忽略间隔并检查全部 (0.044084ms)
✔ 查询未对齐时按退避重排 (0.102792ms)
✔ 查询对齐后移出队列 (0.047ms)
✔ 退避时间有上限 (0.056833ms)
ℹ tests 23
ℹ suites 0
ℹ pass 23
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 127.841333

一次「只看订单状态」的漏单复盘

某交易客户端的支付结果页只判断订单状态。订单接口返回已支付就展示成功,否则展示「支付处理中」。上线初期看起来正常,后来客服收到一类反馈:用户说钱已经扣了,页面却一直显示处理中,第二天订单被自动取消。复盘发现,订单状态与支付单状态来自两个服务,订单服务在支付回调丢失时不会自动变成已支付。端上只轮询订单,永远等不到那个状态;而渠道侧其实已经扣款。支付单在另一个接口里,端上从未查询。修复做了三件事:支付结果页同时拉取订单、支付单与渠道流水;三路信号按 orderId、payId、channelTxnId 对齐;对齐后仍有差异的订单进入待确认队列,启动与回前台时继续查询。回归时专门模拟了「渠道已结算、支付单未落库、订单待支付」的组合,确认页面不再把漏单显示成处理中。

这个复盘的教训是:订单状态是业务结论,不是支付事实。支付事实至少要由支付单与渠道流水共同证明。

小结

端上对账的输入只有三路信号:订单状态、支付单状态、渠道流水。三者的对齐键分别是 orderId、payId、channelTxnId,缺一条链就会产生漏单、重复或金额不符。对账不是支付后立刻查一次,而是把未对齐的订单放进待确认队列,按启动、回前台、定时器与手动四种时机查询,未对齐时指数退避,对齐后移出。端上只分类、不修改状态,差异清单交给服务端与人工处理。

下一节讨论交易链路的最后一段:退款与售后状态,尤其是部分退款时金额如何分摊、状态如何展示。

阅读导航:上一节:6.1 端上如何避免重复支付 · 下一节:6.3 退款与售后状态 。

常见错误与边界

  • 只轮询订单状态,不查支付单与渠道流水,把漏单显示成处理中。
  • 用订单号去匹配渠道流水,忽略支付单号这层桥接。
  • 支付成功后立刻对账,把最终一致性造成的空窗当成漏单。
  • 同一份输入每次输出不同顺序的差异,调用方无法判断是否对齐。
  • 待确认队列只存订单号,不存原因、尝试次数与下次检查时间。
  • 回到前台就全量查询,用户切几次页面就发几轮请求。
  • 查询失败直接当成不存在,放行新的支付或退款。
  • 端上根据差异自行修改订单状态,而不是把差异交给服务端处理。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查