如何处理 IVR 与银行 web service 的 timeout 差异?
结论
不要把 IVR 的 30 秒超时直接判定为付款失败。付款流程应采用“异步请求 + 状态查询”的设计,并为每笔付款生成全局唯一的交易号和幂等键。
IVR 在 30 秒内只需获得以下三种业务状态之一:
SUCCEEDED:银行已明确确认付款成功。FAILED:银行已明确拒绝付款。PENDING:结果尚未确定,后台仍在处理或等待核实。
调用银行超时后,应返回 PENDING,不能返回 FAILED。后台再通过银行的状态查询接口、异步通知或对账机制确认最终结果。人工客服、IVR 重试及其他渠道都要查询同一笔交易,不能重新创建付款。
为什么超时不能代表失败
超时只能说明调用方没有在规定时间内收到响应,无法证明银行没有处理请求。可能出现的情况包括:
- 请求尚未到达银行。
- 银行正在处理。
- 银行已经扣款,但响应在网络中丢失。
- 银行已经返回响应,但 BizTalk 或 IVR 没有及时收到。
- 连接已经超时,银行后台却仍在执行交易。
因此,超时后的付款状态应是“未知”,而不是“失败”。如果系统直接告诉客户付款失败,客户可能再次付款,造成重复扣款。
立即发送取消请求也无法彻底解决这个问题。取消请求本身可能超时,也可能先于付款、晚于付款,或者与付款同时到达银行。除非银行提供了明确可靠的撤销协议,否则不能依靠请求到达顺序判断最终结果。
推荐的处理流程
1. 先创建本地付款记录
客户确认付款后,中间件先创建一条持久化记录,并生成不会重复的交易号:
paymentId: 01K3F7D8V6JQ2R9M4A1C5E0NXT
status: CREATED
amount: 128.00
currency: CNY
customerId: C12345
idempotencyKey: payment-C12345-20260901-01
必须先将交易记录写入数据库,再调用银行。即使 BizTalk、服务器或网络中途出现故障,系统也能继续处理并查询这笔交易。
不要把完整信用卡号写入普通业务日志或数据库。卡号、CVV 等敏感数据应按照支付机构的接口要求进行令牌化和加密,并实施访问控制。
2. 使用幂等键调用银行
调用银行时,应传递稳定且唯一的业务流水号。同一笔付款无论重试多少次,都使用相同的幂等键:
POST /payments
Idempotency-Key: payment-C12345-20260901-01
Content-Type: application/json
{
"merchantReference": "01K3F7D8V6JQ2R9M4A1C5E0NXT",
"amount": 128.00,
"currency": "CNY",
"paymentToken": "tok_xxxxx"
}
银行最好能够保证:相同的 Idempotency-Key 或 merchantReference 不会创建两笔付款,而是返回原交易的处理结果。
如果银行既不支持幂等键,也不能按商户流水号查询交易,调用方就无法独立保证“绝不重复扣款”。这是银行接口能力上的缺口,需要双方约定唯一流水号、查询接口或批量对账机制。
3. 分离前端等待时间与后台处理时间
IVR 可以同步等待一小段时间,例如 20~25 秒,为转接和播报留出时间:
IVR
└─ 提交付款
├─ 25 秒内成功确认 → 播报付款成功
├─ 25 秒内明确拒绝 → 播报付款失败
└─ 未获得明确结果 → 播报正在处理中并转接人工
IVR 超时后,不应取消 BizTalk 中仍在运行的业务流程。后台可以继续等待银行响应,也可以通过队列和状态查询任务完成确认。
建议将 BizTalk 流程拆成两个阶段:
接收付款请求
↓
持久化付款记录
↓
投递到持久化队列
↓
立即返回 paymentId 和初始状态
↓
后台调用银行
↓
保存银行响应
↓
更新最终状态
这是异步请求和响应模式,也可以理解为持久化的“存储并转发”。
建议的状态模型
不要只用一个布尔字段表示成功或失败。至少应区分以下状态:
CREATED
QUEUED
SUBMITTED
PENDING
SUCCEEDED
FAILED
CANCEL_PENDING
CANCELLED
REVIEW_REQUIRED
各状态的含义如下:
SUBMITTED:请求已经发往银行,但结果尚未确认。PENDING:调用超时,或者银行明确表示交易仍在处理。FAILED:只有收到明确拒绝,或通过查询确认未付款时,才能进入此状态。REVIEW_REQUIRED:多次查询后仍无法确定结果,需要人工核查或对账。CANCELLED:银行已经明确确认取消成功。不能因为系统发送过取消请求就设置此状态。
状态更新应遵循单向规则。例如,付款一旦进入 SUCCEEDED,就不能被迟到的超时事件改回 PENDING。
API 设计示例
提交付款接口可以返回当前已知状态,不必一直等待银行完成处理:
POST /payments
Content-Type: application/json
{
"customerId": "C12345",
"amount": 128.00,
"currency": "CNY",
"paymentToken": "tok_xxxxx",
"idempotencyKey": "payment-C12345-20260901-01"
}
银行尚未返回最终结果时:
HTTP/1.1 202 Accepted
Location: /payments/01K3F7D8V6JQ2R9M4A1C5E0NXT
{
"paymentId": "01K3F7D8V6JQ2R9M4A1C5E0NXT",
"status": "PENDING",
"message": "Payment submitted; final status is not yet available."
}
查询接口:
GET /payments/01K3F7D8V6JQ2R9M4A1C5E0NXT
最终成功时:
{
"paymentId": "01K3F7D8V6JQ2R9M4A1C5E0NXT",
"status": "SUCCEEDED",
"bankTransactionId": "BANK-9842517",
"completedAt": "2026-09-01T10:32:48+08:00"
}
IVR 转接人工客服时,应将 paymentId 一并传给客服系统。客服打开客户资料后,先查询这笔付款,不能再次提交。
后台处理示例
下面是简化后的 C# 示例,重点在于区分“明确失败”和“结果未知”:
public async Task ProcessPaymentAsync(
Payment payment,
CancellationToken cancellationToken)
{
if (payment.Status is PaymentStatus.Succeeded
or PaymentStatus.Failed
or PaymentStatus.Cancelled)
{
return;
}
await repository.MarkSubmittedAsync(payment.Id, cancellationToken);
try
{
BankPaymentResult result = await bankClient.PayAsync(
merchantReference: payment.Id,
idempotencyKey: payment.IdempotencyKey,
amount: payment.Amount,
cancellationToken: cancellationToken);
switch (result.Status)
{
case BankPaymentStatus.Succeeded:
await repository.MarkSucceededAsync(
payment.Id,
result.BankTransactionId,
cancellationToken);
break;
case BankPaymentStatus.Declined:
await repository.MarkFailedAsync(
payment.Id,
result.DeclineReason,
cancellationToken);
break;
default:
await repository.MarkPendingAsync(
payment.Id,
"Bank returned a non-final status.",
cancellationToken);
await statusQueue.EnqueueAsync(
payment.Id,
cancellationToken);
break;
}
}
catch (TimeoutException)
{
// 超时表示结果未知,不能标记为失败。
await repository.MarkPendingAsync(
payment.Id,
"Bank response timed out.",
cancellationToken);
await statusQueue.EnqueueAsync(
payment.Id,
cancellationToken);
}
}
状态确认任务可以按照银行的限流要求进行退避重试:
public async Task ConfirmPendingPaymentAsync(
string paymentId,
CancellationToken cancellationToken)
{
Payment payment = await repository.GetAsync(
paymentId,
cancellationToken);
if (payment.Status != PaymentStatus.Pending)
{
return;
}
BankPaymentResult result = await bankClient.GetPaymentByReferenceAsync(
payment.Id,
cancellationToken);
switch (result.Status)
{
case BankPaymentStatus.Succeeded:
await repository.MarkSucceededAsync(
payment.Id,
result.BankTransactionId,
cancellationToken);
break;
case BankPaymentStatus.Declined:
case BankPaymentStatus.NotFound:
await repository.MarkFailedAsync(
payment.Id,
result.Status.ToString(),
cancellationToken);
break;
default:
await statusQueue.EnqueueWithDelayAsync(
payment.Id,
TimeSpan.FromSeconds(30),
cancellationToken);
break;
}
}
能否将 NotFound 直接视为失败,取决于银行查询接口的具体语义。如果银行存在处理延迟或最终一致性,首次查询得到的 NotFound 可能只是暂时查不到。此时必须按照双方约定,等待一段确认窗口。
如何避免重复付款
不能只靠“尽量不重试”保证可靠性,还要同时设置以下保护:
- IVR 为每笔付款生成一个稳定的
paymentId。 - 客户重复按键、IVR 重发及 BizTalk 重试时,都复用同一个幂等键。
- 数据库为
idempotencyKey建立唯一约束。 - 银行按照商户流水号进行幂等处理。
- 人工客服先查询原付款状态,不能直接新建付款。
- 只有原付款被明确拒绝、撤销或确认不存在后,才能创建新的付款。
- 保存银行交易号,并为其建立唯一约束,避免同一银行结果被重复入账。
数据库约束可以作为最后一道防线:
CREATE UNIQUE INDEX UX_Payments_IdempotencyKey
ON Payments (IdempotencyKey);
CREATE UNIQUE INDEX UX_Payments_BankTransactionId
ON Payments (BankTransactionId)
WHERE BankTransactionId IS NOT NULL;
IVR 应该如何向客户播报
付款状态尚未确定时,话术应准确说明当前情况。例如:
付款请求已经提交,目前仍在处理中。请勿重复付款。客服人员将为您查询最终结果。
除非系统已经收到银行的明确拒绝结果,否则不要播报“付款失败,请重新支付”。
如果业务允许,系统也可以在最终状态确定后,通过短信、邮件或回呼通知客户。使用这些渠道前需要取得相应授权,通知内容中也不能暴露完整卡号等敏感信息。
取消与补偿的正确使用方式
取消请求是一种补偿操作,不能作为处理调用超时的通用办法。发起取消前,应先查询原付款状态:
- 原付款已明确成功:按照业务规则发起撤销或退款。
- 原付款仍在处理:等待状态确定,或调用银行明确支持的“取消处理中交易”接口。
- 原付款已明确失败或确认不存在:不需要取消。
- 原付款状态未知且银行无法查询:转入人工核查和对账流程。
付款和取消必须分别使用各自的幂等键,并记录两者的关联关系。不能假设取消请求先发送,就一定会先执行。
必须具备的兜底机制
即使在线查询流程已经完善,支付系统仍应提供日终或周期性对账:
- 按商户流水号匹配本地记录和银行记录。
- 找出本地状态为
PENDING、银行记录已成功的交易。 - 找出本地记录成功,但银行记录不存在或已经撤销的异常交易。
- 识别金额、币种或交易号不一致的记录。
- 将无法自动修复的问题标记为
REVIEW_REQUIRED。 - 保留每次状态变更、请求、响应及人工操作的审计记录。
整个设计不需要强求银行在 30 秒内完成处理。系统应把“结果暂时未知”视为正常的业务状态,并让所有渠道围绕同一个交易号继续查询。这样既不会误报付款失败,也能在重试、转接或系统故障时避免重复付款。
备注:内容仅供参考。
