问题:端上能看到的三路信号
支付完成后,端上通常会拿到三样东西:订单状态、支付单状态、渠道流水号。三者来自不同系统,更新时机也不同。订单可能先变成已支付,支付单稍后才落库;渠道流水可能已经到了,支付单却还停在待确认。如果端上只拿其中一路做判断,就会得到片面的结论。只看订单状态,会把「订单已支付但支付单缺失」当成正常;只看支付单,会把「渠道已扣款但支付单未创建」当成漏单;只看渠道流水,又无法知道这笔流水属于哪张订单。
对账要做的不是再查一次接口,而是把三路信号按固定的键对齐,输出一份可解释的差异清单。端上不修改任何状态,只负责发现不一致并把它送进待确认队列。
三路信号与三把键
| 信号 | 来源 | 主键 | 能回答的问题 |
|---|---|---|---|
| 订单状态 | 订单服务 | 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 退款与售后状态 。
常见错误与边界
- 只轮询订单状态,不查支付单与渠道流水,把漏单显示成处理中。
- 用订单号去匹配渠道流水,忽略支付单号这层桥接。
- 支付成功后立刻对账,把最终一致性造成的空窗当成漏单。
- 同一份输入每次输出不同顺序的差异,调用方无法判断是否对齐。
- 待确认队列只存订单号,不存原因、尝试次数与下次检查时间。
- 回到前台就全量查询,用户切几次页面就发几轮请求。
- 查询失败直接当成不存在,放行新的支付或退款。
- 端上根据差异自行修改订单状态,而不是把差异交给服务端处理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。