问题:两种结果不够用
支付页最常见的实现只有一个布尔值:paid。请求成功设为 true,失败设为 false。它无法表达第三种情况:请求超时、回跳丢失、查询返回「处理中」。这时候端上既不能展示成功,也不能展示失败,只能停在原地。
于是用户看到的是一个没有出口的页面。产品经理通常会要求加一句「支付失败,请重试」,工程师照做,事故就从这里开始:第一笔支付其实已经成功,只是回执丢了;用户按提示重试,第二笔也成功。两张订单,两笔扣款。
正确的做法是把「不确定」提升为一等状态,并给它规定明确的进入条件、退出条件和展示方式。
五个状态
| 状态 | 含义 | 是否终态 | 端上动作 |
|---|---|---|---|
pending | 支付已发起,结果尚未确认 | 否 | 继续轮询,展示倒计时 |
unknown | 超时、回跳或查询未决 | 否 | 只查询,不重新支付 |
succeeded | 服务端确认扣款成功 | 是 | 展示成功,进入订单 |
failed | 服务端确认未扣款 | 是 | 允许在限额内重试 |
cancelled | 用户或订单侧取消 | 是 | 展示已取消,不允许重试 |
unknown 与 failed 的区别是整节的支点。failed 是一个已确认的事实:服务端明确告诉你这笔支付没有成功;unknown 是一个未确认的疑问:没有人知道结果。前者可以重试,后者只能查询。
unknown 的进入与退出
进入 unknown 只有三条路径:
- 支付流程超时。发起后超过约定时间仍未拿到查询结果。
- 回跳结果不可信。回跳带了
success、fail或cancel,但它只是浏览器参数。 - 查询本身未决。服务端返回「处理中」或查询请求再次超时。
退出 unknown 只有一条路径:一次成功的服务端查询。查询可能返回 pending、succeeded、failed 或 cancelled,端上按结果迁移。超时不会让 unknown 变成 failed,回跳也不会让 unknown 变成 succeeded。
这条规则可以用一句话记住:unknown 只能被查询解答,不能被时间或参数解答。
查询结果的合并优先级
同一个支付可能被多个来源同时查询:回跳触发的立即查询、定时轮询、页面恢复后的补偿查询。它们的返回顺序不可控,必须有一套合并规则。
规则分三层:
| 层级 | 规则 | 目的 |
|---|---|---|
| 1 | serverSeq 更小的结果直接丢弃 | 旧查询不能覆盖新状态 |
| 2 | 同序号按状态强弱:成功 > 取消 > 失败 > 待支付 > 未知 | 同一时刻的冲突取更确定的一方 |
| 3 | succeeded 一旦出现即吸收后续结果 | 宁可多查,不能把已到账改判成失败 |
serverSeq 是服务端给每次状态变更分配的单调序号,不是端上收到的顺序。端上收到的顺序会受到网络、页面调度和重试影响,只有服务端序号能还原事实的先后。
第三层是资金安全的选择。如果同一序号既有成功又有失败,取成功;如果成功之后又来了一条更高序号的失败,仍然保持成功并记录异常,等待人工对账。端上不能替服务端做「撤销成功」的决定。
还有一条保护:pending 与 unknown 不能把已经确认的终态降级。终态是失败或取消时,后续查询说「还在处理中」,端上应保持终态并记录,而不是把页面退回转圈。
允许重试的条件
重试支付是一个会产生新资金动作的按钮,条件必须同时满足:
- 状态是
failed,不是unknown,也不是cancelled。 - 失败是服务端确认的(
verified === true),不是端上推测的。 - 重试次数小于上限(本书示例取 2 次)。
重试时状态回到 pending,serverSeq 清空,因为这是一次新的支付尝试。实际系统里还应换一个新的支付单号,并把旧支付单号保留在订单上,供对账使用。
cancelled 不允许重试,因为它通常意味着订单已经关闭;succeeded 更不允许,重复支付正是 6.1 要解决的问题。
状态机的实现
下面的模块属于 L1:纯状态迁移,没有网络、没有定时器,事件由调用方按到达顺序喂入。
// file: src/pay/result-state.ts
// tier: L1
// verify: node --test test/pay/result-state.test.ts
export type PayState = "pending" | "unknown" | "succeeded" | "failed" | "cancelled";
export type ReturnHint = "success" | "fail" | "cancel";
export type PayEvent =
| { kind: "launch_ok"; at: number }
| { kind: "launch_failed"; at: number; reason: string }
| { kind: "return_signal"; at: number; result: ReturnHint }
| { kind: "timeout"; at: number }
| { kind: "query"; at: number; serverSeq: number; status: PayState; reason?: string };
export interface PaySnapshot {
state: PayState;
verified: boolean;
serverSeq: number | null;
reason: string | null;
retries: number;
history: string[];
}
const PRIORITY: Record<PayState, number> = {
unknown: 0,
pending: 1,
failed: 2,
cancelled: 3,
succeeded: 4,
};
export function isTerminal(state: PayState): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled";
}
export function canRetry(
state: PayState,
verified: boolean,
retries: number,
maxRetries = 2,
): boolean {
return state === "failed" && verified && retries < maxRetries;
}
function isPayState(value: string): value is PayState {
return value in PRIORITY;
}
function requireText(value: string, label: string): string {
const text = value.trim();
if (text === "") throw new Error(`${label}不能为空`);
return text;
}
// 支付结果只能由服务端查询确认;回跳、超时与拉起成功都只是线索。
export class PayResultMachine {
state: PayState;
verified: boolean;
serverSeq: number | null;
reason: string | null;
retries: number;
maxRetries: number;
history: string[];
constructor(maxRetries = 2) {
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
throw new Error("最大重试次数必须是非负整数");
}
this.state = "pending";
this.verified = false;
this.serverSeq = null;
this.reason = null;
this.retries = 0;
this.maxRetries = maxRetries;
this.history = ["init:pending"];
}
snapshot(): PaySnapshot {
return {
state: this.state,
verified: this.verified,
serverSeq: this.serverSeq,
reason: this.reason,
retries: this.retries,
history: [...this.history],
};
}
note(entry: string): void {
this.history.push(entry);
}
apply(event: PayEvent): PaySnapshot {
if (!Number.isFinite(event.at)) throw new Error("事件时间必须是有限数字");
switch (event.kind) {
case "launch_ok":
if (isTerminal(this.state)) {
this.note("ignore:launch_ok_terminal");
} else if (this.state === "pending") {
this.reason = "launching";
this.note("launch_ok");
} else {
this.note("ignore:launch_ok_late");
}
break;
case "launch_failed": {
if (this.state === "succeeded") {
this.note("ignore:absorbing");
break;
}
this.state = "failed";
this.verified = true;
this.reason = requireText(event.reason, "失败原因");
this.note(`launch_failed:${this.reason}`);
break;
}
case "return_signal":
if (isTerminal(this.state)) {
this.note("ignore:return_terminal");
} else {
this.state = "unknown";
this.verified = false;
this.reason = `return_${event.result}_unverified`;
this.note(`return:${event.result}`);
}
break;
case "timeout":
if (isTerminal(this.state)) {
this.note("ignore:timeout_terminal");
} else if (this.state === "pending") {
this.state = "unknown";
this.verified = false;
this.reason = "timeout";
this.note("timeout");
} else {
this.note("timeout_again");
}
break;
case "query": {
if (!Number.isInteger(event.serverSeq) || event.serverSeq < 0) {
throw new Error("服务端序号必须是非负整数");
}
if (!isPayState(event.status)) {
throw new Error(`未知支付状态:${String(event.status)}`);
}
if (this.state === "succeeded") {
this.note("ignore:absorbing");
break;
}
if (!isTerminal(event.status) && isTerminal(this.state)) {
this.note("ignore:terminal_guard");
break;
}
if (this.serverSeq !== null) {
if (event.serverSeq < this.serverSeq) {
this.note(`ignore:stale#${event.serverSeq}`);
break;
}
if (
event.serverSeq === this.serverSeq &&
PRIORITY[event.status] <= PRIORITY[this.state]
) {
this.note(`ignore:weaker#${event.serverSeq}`);
break;
}
}
this.state = event.status;
this.serverSeq = event.serverSeq;
this.verified = true;
this.reason = event.reason ?? (isTerminal(event.status) ? event.status : null);
this.note(`query:${event.status}#${event.serverSeq}`);
break;
}
default:
throw new Error("未知支付事件");
}
return this.snapshot();
}
retryable(maxRetries = this.maxRetries): boolean {
return canRetry(this.state, this.verified, this.retries, maxRetries);
}
recordRetry(): PaySnapshot {
if (!this.retryable()) throw new Error("当前状态不允许再次支付");
this.retries += 1;
this.state = "pending";
this.verified = false;
this.serverSeq = null;
this.reason = "retry_requested";
this.note(`retry#${this.retries}`);
return this.snapshot();
}
}
代码里的四个约束
只有 query 与 launch_failed 能产生终态。 return_signal 与 timeout 最多把状态推到 unknown。这让「回跳即成功」在类型层面就写不出来。
序号比较先于状态比较。 旧序号直接丢弃,不管它的状态看起来多合理;同序号才进入优先级比较。这样轮询与回调乱序到达时,结果仍然收敛到同一个终态。
成功是吸收态。 query 进入 succeeded 后,后续任何事件都只记录、不改变状态。代价是极端情况下可能错过一次服务端更正,收益是端上永远不会把已扣款展示成失败。
重试是显式动作。 recordRetry 检查 retryable(),通过后清空序号并回到 pending。UI 不能直接改字段,只能调用这个方法,测试也只需要盯住这一个入口。
测试与真实输出
23 条用例覆盖五态迁移、unknown 进出、序号与同序号优先级、成功吸收、终态保护与重试上限。本机运行结果如下:
$ node --test --test-concurrency=1 test/pay/result-state.test.ts
✔ 初始状态是待支付且未经验证 (0.897125ms)
✔ 拉起成功只记录进行中,不改写支付结果 (0.141ms)
✔ 渠道明确拉起失败进入可重试的失败态 (0.092875ms)
✔ 拉起失败不能覆盖已经成功的支付 (0.072875ms)
✔ 回跳成功只进入未知态,不算验证成功 (0.070375ms)
✔ 回跳失败与取消同样只进入未知态 (0.066167ms)
✔ 查询确认成功后才进入成功态 (0.062917ms)
✔ 查询返回待支付时从未知态回到待支付 (0.053ms)
✔ 查询仍不确定时保持未知态 (0.059209ms)
✔ 超时把待支付转为未知但不判失败 (0.103583ms)
✔ 超时不会覆盖已经确认的终态 (0.063625ms)
✔ 旧序号查询结果被忽略 (0.052167ms)
✔ 同序号成功压过失败 (0.051166ms)
✔ 同序号更弱的结果被忽略 (0.051583ms)
✔ 更高序号的终态可以修正先前判断 (0.049458ms)
✔ 成功后吸收后续所有查询 (0.043584ms)
✔ 未知与待支付不能把终态降级 (0.042333ms)
✔ 只有已确认的失败才允许再次支付 (0.042833ms)
✔ 记录重试会回到待支付并递增次数 (0.2585ms)
✔ 取消与成功都不允许再次支付 (0.063584ms)
✔ 非法序号与未知状态被拒绝 (0.09525ms)
✔ 每个事件都留下可排查的历史 (0.044041ms)
✔ 构造时校验最大重试次数 (0.056709ms)
ℹ tests 23
ℹ suites 0
ℹ pass 23
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 135.364167
一次「超时即失败」的复盘
某交易客户端在支付请求超过 10 秒后把状态标成 failed,按钮从「支付中」变成「重新支付」。用户点击后发起第二笔支付,第二笔成功;几分钟后,第一笔的渠道回调也到了,服务端把同一张订单的另一笔支付也记为成功。
复盘时看到三个问题:
- 超时被当成失败,
unknown这个状态根本不存在。 - 重试没有换支付单号,两笔支付在服务端看起来像同一笔的两次尝试。
- 订单页只展示最后一次支付结果,第一笔成功没有任何入口可查。
修复做了三件事:超时进入 unknown,按钮改为「查询支付结果」;查询确认未支付后才允许重试,并生成新的支付单号;订单详情展示该订单下的所有支付尝试,成功的那笔高亮。回归重点验证:超时后不出现重试按钮、查询未决时保持 unknown、两笔支付都能在订单里找到。
这个复盘的结论是:端上可以不知道结果,但不能把不知道说成失败。
小结
支付结果需要五个状态,而不是两个。unknown 是一等状态,只能由查询退出;failed 是已确认的事实,才允许重试。
查询结果按服务端序号合并,同序号按状态强弱裁决,成功一旦出现即吸收;pending 与 unknown 不能降级终态。重试需要同时满足「已确认失败、未取消、未超次数」,并生成新的支付尝试。
下一节讨论多个来源同时到达时的时序竞争:回调、轮询与端上确认,谁说了算。
阅读导航:上一节:5.1 支付渠道的端上编排:拉起、轮询与回跳 · 下一节:5.3 回调、轮询与端上确认的时序竞争 。
常见错误与边界
- 用布尔值
paid表达支付结果,超时只能落到失败。 - 超时后直接允许重试,第一笔成功时形成重复支付。
- 回跳带
success就把状态设为成功,不经过服务端查询。 - 查询按到达顺序取最后一条,旧结果覆盖新状态。
- 同序号冲突时取最后到达的一方,而不是取更强的一方。
- 成功之后又被一条失败查询改判,展示「支付失败」。
cancelled状态仍显示重试按钮,用户为已关闭订单再次付款。- 重试不换支付单号,服务端无法区分两次支付尝试。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。