问题:三个动作被当成一个动作
很多客户端把支付写成一个函数:拼参数、跳转渠道、回跳后读 status=success,然后把订单标成已支付。代码只有三十行,线上却会以三种方式出错:
- 渠道页打开了,用户没付就返回,端上把「返回」当成「成功」。
- 用户付了,但回跳链接被系统浏览器拦截,端上永远收不到参数。
- 回跳参数被用户或中间页篡改,端上展示成功,服务端却没有这笔流水。
根因不是某个接口写错,而是把三件不同的事压成了一个动作。支付编排至少包含三个独立动作:拉起渠道、轮询结果、解析回跳参数。它们各有自己的成功标准,也各有自己的失败出口。
拉起成功不等于支付成功
先把三个动作的判断标准写成表。
| 动作 | 端上能确认什么 | 不能确认什么 |
|---|---|---|
| 拉起 | 渠道接收了参数,前台切换到渠道页 | 用户是否付款 |
| 回跳 | 浏览器回到了指定页面 | 渠道是否扣款 |
| 轮询 | 服务端查询到的支付状态 | 未来是否会发生退款 |
这张表最重要的一行是第一行。拉起接口返回 ok,只说明「请求被受理」,可能用户下一秒就点了取消。端上如果把 ok 映射成 paid,等于替渠道做了它没做的决定。
所以编排状态里必须有 launching 与 polling 两个阶段,且只有服务端查询结果能把流程推进到 succeeded。
发起支付时固定四个参数
一次支付请求至少要带四样东西,而且它们在一次支付意图内不能变:
- 订单号:服务端用来关联订单与流水。
- 金额:整数分,且必须与订单实付金额一致;端上不能用预演价。
- 支付单号:本次支付尝试的唯一标识,重试时复用。
- 幂等键:服务端据此判断「同一笔支付」还是「用户想再付一次」。
支付单号与幂等键都由「订单号 + 随机段」确定,而不是每次点击重新生成。随机段在进入支付页时生成一次,长度不少于 8 位,只含字母与数字。这样即使请求超时后重试,服务端看到的仍是同一个支付单号。
回跳参数:只当口信,不当凭证
回跳链接通常带三个字段:支付单号、结果、渠道流水号。它们有用,但只能用来决定「要不要马上查一次」,不能用来决定「是否成功」。
处理原则只有三条:先校验支付单号,单号不一致直接拒绝;success、fail、cancel 都只表示需要验证,只有流程已终态时回跳才只确认收到;回跳可以提前触发一次查询,因为用户回来意味着渠道页已经结束。
轮询:退避、上限与页面可见性
轮询不是 setInterval 一秒一次。固定频率会在渠道处理慢时浪费请求,也会在用户切后台时继续消耗电量。一个可执行的策略是:
| 参数 | 建议值 | 作用 |
|---|---|---|
| 首次查询 | 拉起成功后立即 | 快速拿到同步返回的结果 |
| 基础间隔 | 1.2 秒 | 覆盖大多数渠道的同步耗时 |
| 退避系数 | 1.6 | 逐次拉长,降低无效查询 |
| 单次上限 | 8 秒 | 避免间隔无限增长 |
| 总次数 | 8 次 | 约 40 秒后转入「未知」 |
页面隐藏时暂停计数,恢复可见时立即查一次。注意暂停的是计数,不是把已经过去的真实时间补回来:用户切后台三分钟,回来仍应从当前时刻重新排下一次查询,而不是补发三次请求。
编排模块的实现
下面的模块属于 L2:时间由 now() 注入,模块内部不注册定时器;渠道拉起与查询结果都由调用方喂进来。测试用假时钟推进,不需要真的等待。
// file: src/pay/orchestration.ts
// tier: L2
// verify: node --test test/pay/orchestration.test.ts
export type PayChannel = "wechat" | "alipay" | "applepay" | "sandbox";
export type FlowPhase =
| "idle"
| "launching"
| "polling"
| "paused"
| "succeeded"
| "failed"
| "unknown";
export type FlowAction = "wait" | "poll" | "paused" | "give_up" | "done";
export type ReturnResult = "success" | "fail" | "cancel" | "missing";
export interface PollPolicy {
baseMs: number;
factor: number;
capMs: number;
maxAttempts: number;
}
export interface ReturnSignal {
paymentNo: string | null;
result: ReturnResult;
channelTxnId: string | null;
trusted: false;
rawKeys: string[];
}
const CHANNELS: PayChannel[] = ["wechat", "alipay", "applepay", "sandbox"];
const NONCE_MIN = 8;
export const DEFAULT_POLL: PollPolicy = {
baseMs: 1_200,
factor: 1.6,
capMs: 8_000,
maxAttempts: 8,
};
function text(value: string, label: string): string {
const out = value.trim();
if (out === "") throw new Error(`${label}不能为空`);
return out;
}
function time(value: number, label: string): number {
if (!Number.isFinite(value) || value < 0) throw new Error(`${label}非法`);
return value;
}
export function validatePollPolicy(policy: PollPolicy): void {
if (!Number.isInteger(policy.baseMs) || policy.baseMs <= 0) throw new Error("轮询基础间隔必须是正整数");
if (!Number.isFinite(policy.factor) || policy.factor < 1) throw new Error("轮询退避系数不能小于 1");
if (!Number.isInteger(policy.capMs) || policy.capMs < policy.baseMs) throw new Error("轮询上限不能小于基础间隔");
if (!Number.isInteger(policy.maxAttempts) || policy.maxAttempts < 1) throw new Error("轮询次数必须是正整数");
}
// 支付单号与幂等键都由订单号和随机段确定,重试时必须复用。
export function makePaymentNo(orderId: string, nonce: string): string {
const id = text(orderId, "订单号");
const tail = text(nonce, "随机段");
if (tail.length < NONCE_MIN) throw new Error(`随机段至少 ${NONCE_MIN} 位`);
if (!/^[A-Za-z0-9]+$/.test(tail)) throw new Error("随机段只能是字母或数字");
return `pay-${id}-${tail}`;
}
export function buildLaunchRequest(input: {
orderId: string;
amountCents: number;
channel: PayChannel;
nonce: string;
}) {
const orderId = text(input.orderId, "订单号");
if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0) {
throw new Error("支付金额必须是正整数分");
}
if (!CHANNELS.includes(input.channel)) throw new Error(`未知支付渠道:${input.channel}`);
const nonce = text(input.nonce, "随机段");
return {
orderId,
amountCents: input.amountCents,
currency: "CNY" as const,
channel: input.channel,
paymentNo: makePaymentNo(orderId, nonce),
idempotencyKey: `idem-${orderId}-${nonce}`,
};
}
const RESULT_MAP: Record<string, ReturnResult> = {
success: "success", paid: "success", ok: "success",
fail: "fail", failed: "fail", error: "fail",
cancel: "cancel", cancelled: "cancel", canceled: "cancel",
};
// 回跳参数只是渠道写给浏览器的「口信」,不能当作已到账的凭证。
export function parseReturnParams(
input: string | Record<string, string | undefined>,
): ReturnSignal {
const params = new URLSearchParams();
if (typeof input === "string") {
const raw = input.trim().split("#")[0];
const at = raw.indexOf("?");
const query = at >= 0 ? raw.slice(at + 1) : raw;
for (const [key, value] of new URLSearchParams(query)) params.set(key, value);
} else {
for (const [key, value] of Object.entries(input)) {
if (typeof value === "string") params.set(key, value);
}
}
const pick = (...keys: string[]): string | null => {
for (const key of keys) {
const value = params.get(key)?.trim();
if (value !== undefined && value !== "") return value;
}
return null;
};
const status = (pick("status", "result") ?? "").toLowerCase();
return {
paymentNo: pick("payNo", "paymentNo", "payment_no"),
result: RESULT_MAP[status] ?? "missing",
channelTxnId: pick("txn", "transaction_id", "channelTxnId"),
trusted: false,
rawKeys: [...params.keys()],
};
}
export function pollDelay(attempt: number, policy: PollPolicy = DEFAULT_POLL): number {
validatePollPolicy(policy);
if (!Number.isInteger(attempt) || attempt < 0) throw new Error("轮询序号必须是非负整数");
return Math.min(policy.capMs, Math.round(policy.baseMs * policy.factor ** attempt));
}
export class PayFlow {
paymentNo: string;
phase: FlowPhase = "idle";
polls = 0;
returnSeen = false;
reason: string | null = null;
nextPollAt: number | null = null;
policy: PollPolicy;
now: () => number;
constructor(paymentNo: string, now: () => number, policy: PollPolicy = DEFAULT_POLL) {
this.paymentNo = text(paymentNo, "支付单号");
validatePollPolicy(policy);
this.policy = policy;
this.now = now;
}
beginLaunch(): void {
if (this.phase !== "idle") throw new Error("支付已经发起");
this.phase = "launching";
}
// 拉起成功只代表渠道收到了参数,接下来必须轮询结果。
launchSucceeded(at?: number): void {
if (this.phase !== "launching") throw new Error("当前不在拉起阶段");
this.phase = "polling";
this.nextPollAt = time(at ?? this.now(), "拉起完成时间");
this.reason = null;
}
launchFailed(reason: string): void {
if (this.phase !== "launching") throw new Error("当前不在拉起阶段");
this.phase = "failed";
this.reason = text(reason, "失败原因");
this.nextPollAt = null;
}
// 回跳只负责把用户带回来,并把验证提前;它不改写支付结果。
onReturn(signal: ReturnSignal, at?: number) {
const t = time(at ?? this.now(), "回跳时间");
if (signal.paymentNo !== null && signal.paymentNo !== this.paymentNo) {
return { accepted: false, needsVerify: false, reason: "payment_no_mismatch" };
}
this.returnSeen = true;
if (this.phase === "succeeded" || this.phase === "failed") {
return { accepted: true, needsVerify: false, reason: "already_terminal" };
}
this.phase = "polling";
this.nextPollAt = t;
return { accepted: true, needsVerify: true, reason: "needs_verify" };
}
visibility(state: "hidden" | "visible", at?: number): void {
const t = time(at ?? this.now(), "可见性切换时间");
if (state === "hidden" && this.phase === "polling") {
this.phase = "paused";
} else if (state === "visible" && this.phase === "paused") {
this.phase = "polling";
this.nextPollAt = t;
}
}
tick(at?: number): FlowAction {
const t = time(at ?? this.now(), "轮询检查时间");
if (this.phase === "idle" || this.phase === "launching") return "wait";
if (this.phase === "paused") return "paused";
if (this.phase === "succeeded" || this.phase === "failed") return "done";
if (this.phase === "unknown") return "give_up";
if (this.polls >= this.policy.maxAttempts) {
this.phase = "unknown";
this.reason = "poll_exhausted";
this.nextPollAt = null;
return "give_up";
}
if (this.nextPollAt !== null && t >= this.nextPollAt) {
this.polls += 1;
this.nextPollAt = t + pollDelay(this.polls - 1, this.policy);
return "poll";
}
return "wait";
}
resolve(status: "succeeded" | "failed" | "cancelled" | "pending" | "unknown"): void {
if (status === "succeeded") {
this.phase = "succeeded";
this.reason = null;
this.nextPollAt = null;
} else if (status === "failed" || status === "cancelled") {
this.phase = "failed";
this.reason = status;
this.nextPollAt = null;
} else if (this.phase !== "succeeded" && this.phase !== "failed") {
this.phase = "polling";
this.reason = status === "pending" ? null : "query_unknown";
}
}
paid(): boolean {
return this.phase === "succeeded";
}
}
代码里的四个判断
launchSucceeded 只改阶段,不改结果。 它把 launching 推进到 polling,并把下一次查询设为当前时刻。此时 paid() 仍是 false,这是整个模块最重要的一条不变量。
onReturn 返回的是决策,不是状态。 needs_verify 表示「请立即查一次」,already_terminal 表示「结果已定,回跳只是迟到」,payment_no_mismatch 表示「这不是当前这笔支付的回跳」。调用方按决策调度,而不是按回跳参数改 UI。
tick 是唯一的轮询推进器。 它返回 wait、poll、paused、give_up、done 五种动作,界面只负责执行。把「什么时候查」与「怎么查」分开,才能用假时钟测试退避与暂停。
resolve 是结果入口,不是状态机。 它只接收服务端查询结论,并把 pending / unknown 留在轮询里。完整的五态合并、序号比较与重试条件放在 5.2,这里不重复。
测试与真实输出
22 条用例覆盖参数校验、回跳解析、退避序列、暂停恢复、次数耗尽与终态收敛。本机运行结果如下:
$ node --test --test-concurrency=1 test/pay/orchestration.test.ts
✔ 支付单号由订单号与随机段确定 (0.605041ms)
✔ 支付单号拒绝空订单号与非法随机段 (0.225375ms)
✔ 拉起参数包含订单号、金额、支付单号与幂等键 (0.405667ms)
✔ 拉起参数拒绝非法金额与未知渠道 (0.097333ms)
✔ 回跳参数解析出支付单号、结果与渠道流水号 (0.184625ms)
✔ 回跳结果映射失败、取消与缺失 (0.116042ms)
✔ 回跳参数支持对象入参并忽略非字符串值 (0.068583ms)
✔ 轮询间隔按指数退避并在上限截断 (0.084333ms)
✔ 轮询策略拒绝非法参数 (0.106458ms)
✔ 拉起成功进入轮询而不是支付成功 (0.15625ms)
✔ 拉起失败进入失败态并停止轮询 (0.104583ms)
✔ 到达轮询时刻才发起查询并推进下一次时间 (0.062584ms)
✔ 同一时刻重复检查不会重复计数 (0.048708ms)
✔ 轮询次数耗尽后转为未知并放弃 (0.063625ms)
✔ 页面隐藏时暂停轮询,恢复后立即查询 (0.070625ms)
✔ 回跳成功只要求验证,不把流程标成已支付 (0.120291ms)
✔ 回跳携带其他支付单号时拒绝 (0.053167ms)
✔ 冷启动落在回跳页时直接进入轮询 (0.053792ms)
✔ 服务端确认成功后才标记已支付并停止轮询 (0.067125ms)
✔ 服务端确认失败或取消后停止轮询 (0.044666ms)
✔ 已终态时回跳只确认收到,不再要求验证 (0.049291ms)
✔ 未发起时不能重复拉起或报告拉起结果 (0.071125ms)
ℹ tests 22
ℹ suites 0
ℹ pass 22
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 139.074208
一次「回跳即成功」的复盘
某交易客户端的支付页在回跳后读取 status=success,直接展示「支付成功」,并把订单状态改成已支付,同时停止轮询。上线一段时间后出现一类投诉:用户看到成功页,订单却一直显示待支付,随后又付了一次。
复盘发现三个问题叠加:回跳参数没有校验支付单号,另一个订单的成功回跳也能点亮当前页;端上停止轮询后,服务端回调失败或延迟,没有任何补偿路径;订单页读取端上缓存的「已支付」,与订单接口返回的待支付并存。
修复把「回跳」降级为提示:回跳只触发一次立即查询,成功与否以查询结果为准;查询仍不确定时保留轮询并展示「结果确认中」;订单状态只从服务端读取。回归重点验证三个场景:回跳丢失仍能轮询到结果、伪造回跳不能改变状态、页面切后台再回来不会补发请求。
这个复盘的结论可以压成一句话:回跳负责把用户带回来,查询负责把事实带回来。
小结
支付编排由拉起、轮询、回跳三个动作组成,三者的成功标准不同。拉起成功只代表渠道收到参数,回跳只代表浏览器回到页面,只有服务端查询能确认支付结果。支付单号与幂等键在进入支付页时固定;轮询用指数退避并设置次数上限;页面隐藏时暂停计数,恢复后立即查询一次。把这些规则写进一个时间可注入的模块,才能在不碰真实渠道的情况下验证边界。
下一节讨论查询结果本身:当服务端也说不清时,端上如何用五个状态表达「不确定」。
阅读导航:上一节:4.3 待支付超时与取消 · 下一节:5.2 支付结果不确定时的状态机 。
常见错误与边界
- 把拉起接口的
ok当成支付成功,直接跳转成功页。 - 回跳参数不校验支付单号,其他订单的结果也能点亮当前页。
- 每次点击重新生成支付单号,超时重试在服务端变成两笔支付。
- 用端上预演金额发起支付,与订单实付金额不一致。
- 轮询用固定一秒间隔,渠道慢时请求堆积,切后台后继续空转。
- 页面恢复可见时补发后台期间的所有查询,瞬间打出多个请求。
- 回跳结果只认
success,把fail与cancel当成网络错误重试。 - 轮询次数耗尽后直接判失败,而不是转入「结果未知」等待查询。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。