PAYATHON 2026

如何用 PHP 处理 Viva Wallet API 的 3DS 响应?

支付阿杰

结论

PHP 可以通过 json_encode() 原样返回 chargeToken 和 redirectToACSForm,但收到 chargeToken 后不能立即创建交易。

出现 redirectToACSForm,说明支付仍在进行 3DS 身份验证。前端需要先渲染并提交其中的 HTML 表单,由持卡人完成验证。浏览器从 ACS 验证页面正常返回后,服务端才能用同一个 chargeToken 创建交易。

PHP 无法在一次 HTTP 请求中同步等待用户完成验证。支付流程需要拆成多个请求,并根据状态逐步处理。

正确的处理流程

可以将每次支付尝试记录为以下状态:

创建 charge token
        ↓
是否存在 redirectToACSForm?
   ├─ 否:直接创建交易
   └─ 是:保存 chargeToken,状态设为 awaiting_3ds
                    ↓
             浏览器展示 ACS 页面
                    ↓
             用户完成或取消验证
                    ↓
        浏览器返回 sessionRedirectUrl
                    ↓
       服务端使用原 chargeToken 创建交易

处理时要注意以下几点:

  • 返回 chargeToken 并不表示 3DS 已通过。
  • 不要使用固定延迟,例如 sleep(5) 或 setTimeout(..., 5000)。
  • 输出 ACS 表单后,不要继续执行 PHP 支付代码。
  • 在验证结束前,不要刷新、关闭或跳离承载验证流程的页面。
  • 完成后的跳转地址、回调字段和状态查询方式,应以当前 Native Checkout v2 文档为准。

PHP 正确返回 JSON

不要手工拼接 JSON,也不要先对 HTML 调用 htmlspecialchars()。json_encode() 会正确处理 HTML 中的引号、换行和反斜杠。

<?php

declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

$result = [
    'chargeToken' => $vivaResponse['chargeToken'] ?? null,
    'redirectToACSForm' => $vivaResponse['redirectToACSForm'] ?? null,
];

try {
    echo json_encode(
        $result,
        JSON_THROW_ON_ERROR
        | JSON_UNESCAPED_UNICODE
        | JSON_UNESCAPED_SLASHES
    );
} catch (JsonException $exception) {
    http_response_code(500);

    echo json_encode([
        'error' => 'Unable to encode payment response',
    ]);
}

exit;

前端使用 response.json() 读取响应时,JSON 中转义的换行和引号会自动还原,不需要再次调用 JSON.parse()。

前端渲染 3DS 表单

收到响应后,先检查是否存在 redirectToACSForm:

<div id="three-ds-container"></div>

<script>
async function startPayment(cardData) {
    const response = await fetch('/api/create-charge-token.php', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        credentials: 'same-origin',
        body: JSON.stringify(cardData)
    });

    const data = await response.json();

    if (!response.ok) {
        throw new Error(data.message || 'Unable to create charge token');
    }

    if (!data.chargeToken) {
        throw new Error('Viva Wallet did not return a chargeToken');
    }

    if (data.redirectToACSForm) {
        sessionStorage.setItem('pendingChargeToken', data.chargeToken);
        renderThreeDS(data.redirectToACSForm);
        return;
    }

    await completeTransaction(data.chargeToken);
}
</script>

直接执行下面这行代码并不一定可靠:

container.innerHTML = html;

通过 innerHTML 插入的 <script> 通常不会执行。如果 Viva Wallet 返回的 HTML 依赖脚本自动提交表单,3DS 流程可能无法启动。

一种可行的承载方式是使用专用 iframe 的 srcdoc:

<script>
function renderThreeDS(redirectToACSForm) {
    const container = document.getElementById('three-ds-container');
    container.replaceChildren();

    const iframe = document.createElement('iframe');
    iframe.name = 'viva-3ds-frame';
    iframe.title = '3D Secure authentication';
    iframe.style.width = '100%';
    iframe.style.minHeight = '600px';
    iframe.style.border = '0';

    iframe.setAttribute(
        'sandbox',
        'allow-forms allow-scripts allow-top-navigation-by-user-activation'
    );

    iframe.srcdoc = redirectToACSForm;
    container.appendChild(iframe);
}
</script>

能否在 iframe 中显示验证页面,取决于 Viva Wallet 当前的集成要求和发卡行 ACS 页面的安全策略。如果页面拒绝嵌入、跳转失败或出现兼容性问题,应改用整页跳转。

更稳妥的方式是让浏览器打开专用的 3DS 页面,由该页面直接输出 Viva Wallet 返回的 HTML,而不是将这段 HTML 放进普通 JSON 接口。

等待验证完成

这里的“等待”应通过回跳页面或官方提供的完成事件处理,不能让最初的 PHP 请求一直保持连接。

创建 charge token 时,应按照 Native Checkout v2 当前文档配置类似 sessionRedirectUrl 的返回地址。例如:

https://merchant.example.com/payment/3ds-return

具体参数名称及其传递位置,必须以当前使用的 Viva Wallet API 版本为准。用户完成认证后,ACS 或 Viva Wallet 会让浏览器返回该地址。

回跳页面只需恢复支付流程:

<?php

declare(strict_types=1);

session_start();

$paymentAttemptId = $_SESSION['pending_payment_attempt'] ?? null;

if ($paymentAttemptId === null) {
    http_response_code(400);
    exit('Payment session not found.');
}

// 根据 paymentAttemptId 从服务端存储中读取 chargeToken。
// 不要直接信任查询参数中传入的 chargeToken。

header('Location: /checkout/complete.html');
exit;

随后,完成页面调用服务端接口:

async function resumePayment() {
    const response = await fetch('/api/complete-transaction.php', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        credentials: 'same-origin',
        body: JSON.stringify({
            paymentAttemptId: sessionStorage.getItem('paymentAttemptId')
        })
    });

    const result = await response.json();

    if (!response.ok) {
        throw new Error(result.message || 'Payment could not be completed');
    }

    window.location.assign('/payment/success');
}

complete-transaction.php 从服务端存储中取出原来的 chargeToken,再调用 Viva Wallet 的交易接口:

<?php

declare(strict_types=1);

session_start();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');

$input = json_decode(file_get_contents('php://input'), true);

$paymentAttemptId = $input['paymentAttemptId'] ?? null;

if (!is_string($paymentAttemptId) || $paymentAttemptId === '') {
    http_response_code(400);
    echo json_encode(['message' => 'Invalid payment attempt']);
    exit;
}

/*
 * 1. 根据当前用户和 paymentAttemptId 查询服务端支付记录。
 * 2. 确认记录状态为 awaiting_3ds。
 * 3. 取得此前保存的 chargeToken。
 * 4. 使用该 chargeToken 调用 Viva Wallet 创建交易接口。
 * 5. 保存 Viva Wallet 返回的最终交易状态。
 *
 * 此处不展示具体请求 URL 和参数,因为应严格采用当前
 * Native Checkout v2 文档中对应环境的交易接口。
 */

echo json_encode([
    'status' => 'processing',
]);

为什么会出现 PaymentsCardHolderAuthenticationInvalidStatus

这个错误通常表示创建交易时,Viva Wallet 看到的持卡人验证状态尚未完成。常见原因有:

  • 收到 redirectToACSForm 后,立即使用 chargeToken 创建交易。
  • HTML 只是作为文本显示,其中的 ACS 表单并未真正提交。
  • 使用 innerHTML 插入内容后,自动提交脚本没有执行。
  • 用户关闭了弹窗、标签页或 iframe。
  • 页面刷新,导致原支付会话或 chargeToken 丢失。
  • 3DS 返回地址配置错误,认证结束后没有回到正确的流程。
  • 使用了其他支付尝试对应的 chargeToken。
  • ACS 页面拒绝在 iframe 中运行。
  • 浏览器隐私策略、Content Security Policy 或跨站 Cookie 设置阻断了认证流程。

官方所说的“Browser closed before authentication finished”是指认证会话没有正常结束,不一定表示用户手动关闭了整个浏览器。提前跳转、表单未提交或认证页面被拦截,也可能产生同类状态错误。

实现时的注意事项

chargeToken 应保存在服务端,并与当前用户、订单号和一次性的支付尝试 ID 绑定。不要只依赖 sessionStorage,也不要接受前端任意提交的 chargeToken。

创建交易的接口还需要实现幂等控制,防止用户刷新完成页面或重复点击时产生重复扣款。

只有 Viva Wallet 返回的 redirectToACSForm 才能作为 HTML 执行。不要渲染客户端自行提交的 HTML,否则会造成严重的 XSS 风险。

浏览器回到商户页面,只能说明交互流程已经返回,并不表示付款成功。最终结果仍应以 Viva Wallet 交易接口返回的数据为准;如果使用服务器端通知,还应验证其签名。

备注:内容仅供参考。