PAYATHON 2026

如何处理 IVR 与银行 web service 的 timeout 差异?

支付老李

结论

不要把 IVR 的 30 秒超时直接判定为付款失败。付款流程应采用“异步请求 + 状态查询”的设计,并为每笔付款生成全局唯一的交易号和幂等键。

IVR 在 30 秒内只需获得以下三种业务状态之一:

  • SUCCEEDED:银行已明确确认付款成功。
  • FAILED:银行已明确拒绝付款。
  • PENDING:结果尚未确定,后台仍在处理或等待核实。

调用银行超时后,应返回 PENDING,不能返回 FAILED。后台再通过银行的状态查询接口、异步通知或对账机制确认最终结果。人工客服、IVR 重试及其他渠道都要查询同一笔交易,不能重新创建付款。

为什么超时不能代表失败

超时只能说明调用方没有在规定时间内收到响应,无法证明银行没有处理请求。可能出现的情况包括:

  1. 请求尚未到达银行。
  2. 银行正在处理。
  3. 银行已经扣款,但响应在网络中丢失。
  4. 银行已经返回响应,但 BizTalk 或 IVR 没有及时收到。
  5. 连接已经超时,银行后台却仍在执行交易。

因此,超时后的付款状态应是“未知”,而不是“失败”。如果系统直接告诉客户付款失败,客户可能再次付款,造成重复扣款。

立即发送取消请求也无法彻底解决这个问题。取消请求本身可能超时,也可能先于付款、晚于付款,或者与付款同时到达银行。除非银行提供了明确可靠的撤销协议,否则不能依靠请求到达顺序判断最终结果。

推荐的处理流程

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 可能只是暂时查不到。此时必须按照双方约定,等待一段确认窗口。

如何避免重复付款

不能只靠“尽量不重试”保证可靠性,还要同时设置以下保护:

  1. IVR 为每笔付款生成一个稳定的 paymentId。
  2. 客户重复按键、IVR 重发及 BizTalk 重试时,都复用同一个幂等键。
  3. 数据库为 idempotencyKey 建立唯一约束。
  4. 银行按照商户流水号进行幂等处理。
  5. 人工客服先查询原付款状态,不能直接新建付款。
  6. 只有原付款被明确拒绝、撤销或确认不存在后,才能创建新的付款。
  7. 保存银行交易号,并为其建立唯一约束,避免同一银行结果被重复入账。

数据库约束可以作为最后一道防线:

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 秒内完成处理。系统应把“结果暂时未知”视为正常的业务状态,并让所有渠道围绕同一个交易号继续查询。这样既不会误报付款失败,也能在重试、转接或系统故障时避免重复付款。

备注:内容仅供参考。