PAYATHON 2026

Secure Web 支付表单的正确处理方式是什么?

支付阿杰

结论

支付表单更稳妥的处理方式,是将它设计为由服务端控制、支持幂等操作的状态机,而不是在表单提交后直接扣款。

处理时应遵循以下原则:

  • 浏览器只提交支付令牌和必要的用户输入,不处理或保存银行卡敏感数据。
  • 商品、价格、折扣和应付金额由服务端重新计算。
  • 每次支付操作使用唯一的幂等键,防止重复扣款。
  • 支付结果以支付服务商的最终状态为准,不能只看浏览器的跳转结果。
  • 订单创建、支付请求、回调处理和业务交付分开执行。
  • 所有状态变化均可追踪,并且可以安全重试。

推荐的处理流程

1. 先在服务端创建支付意图

用户打开结算页时,服务端根据购物车、库存、优惠规则和税费计算金额,同时创建一条待支付记录。

function prepareCheckout(userId, cartId):
    cart = loadCartForUser(userId, cartId)

    if cart is empty:
        return error("购物车为空")

    items = reloadProductsFromDatabase(cart.items)
    amount = calculateAmount(items, cart.coupons, cart.shippingAddress)

    order = createOrder(
        userId = userId,
        items = snapshot(items),
        amount = amount,
        currency = "CNY",
        status = "PENDING_PAYMENT"
    )

    payment = createPaymentRecord(
        orderId = order.id,
        amount = amount,
        currency = order.currency,
        status = "CREATED",
        idempotencyKey = generateUniqueKey()
    )

    return {
        orderId: order.id,
        displayAmount: amount,
        paymentSession: createProviderPaymentSession(payment)
    }

浏览器提交的最终金额不能直接作为扣款金额。

即使页面包含下面这样的字段,它也只能用于显示,不能作为扣款依据:

<input type="hidden" name="amount" value="99.00">

隐藏字段也可以被修改。服务端必须通过 orderId 查找订单,再次确认应付金额。

2. 在浏览器中收集支付信息并进行令牌化

如果支付服务商提供托管字段、Hosted Checkout、iframe 或官方 SDK,应优先使用。银行卡号等信息直接发送给支付服务商,应用服务器只接收一次性令牌或支付方式标识。

async function submitPaymentForm(event) {
    event.preventDefault();

    const button = document.querySelector("#pay-button");
    button.disabled = true;

    try {
        const result = await paymentProvider.createPaymentMethod({
            cardElement: cardElement,
            billingDetails: collectBillingDetails()
        });

        if (result.error) {
            showError(result.error.message);
            return;
        }

        await confirmOrderPayment({
            orderId: currentOrderId,
            paymentMethodToken: result.paymentMethodToken
        });
    } catch (error) {
        showError("支付请求未能完成,请检查订单状态后再试。");
    } finally {
        button.disabled = false;
    }
}

禁用前端按钮可以减少连续点击,但无法防止重复扣款。用户可能刷新页面、重复发送 HTTP 请求,也可能因为网络超时再次提交,所以服务端仍须实现幂等控制。

3. 服务端验证请求并发起支付

服务端收到支付请求后,需要验证当前用户是否有权支付该订单、订单状态是否允许支付,以及相应的支付记录是否已经存在。

function confirmPayment(userId, request):
    validateRequestFormat(request)

    beginTransaction()

    order = lockOrderForUpdate(request.orderId)

    if order does not exist:
        rollback()
        return error("订单不存在")

    if order.userId != userId:
        rollback()
        return error("无权操作该订单")

    if order.status == "PAID":
        rollback()
        return success(order)

    if order.status not in ["PENDING_PAYMENT", "PAYMENT_FAILED"]:
        rollback()
        return error("当前订单状态不允许支付")

    payment = findOrCreatePaymentAttempt(
        orderId = order.id,
        idempotencyKey = order.paymentIdempotencyKey,
        amount = order.amount,
        currency = order.currency
    )

    markPaymentAsProcessing(payment)
    commitTransaction()

    try:
        providerResult = paymentProvider.charge(
            amount = payment.amount,
            currency = payment.currency,
            paymentMethodToken = request.paymentMethodToken,
            idempotencyKey = payment.idempotencyKey,
            metadata = { orderId: order.id }
        )
    catch NetworkTimeout:
        markPaymentAsUnknown(payment)
        return processing("支付结果确认中")

    applyProviderResult(payment, providerResult)
    return buildPaymentResponse(payment)

不要在数据库事务中长时间等待第三方支付接口返回。示例先锁定并更新本地记录,结束事务后再调用外部服务,以免数据库锁被长期占用。

支付接口超时也不能立即视为支付失败。请求可能已经到达支付服务商并完成扣款,只是响应未能返回。此时应将状态标记为 UNKNOWN 或 PROCESSING,随后通过查询接口或异步回调确认结果。

用状态机管理支付过程

支付并不只有成功和失败两种结果,可以使用类似下面的状态:

CREATED
  -> PROCESSING
  -> REQUIRES_ACTION
  -> SUCCEEDED
  -> FAILED
  -> CANCELED

PROCESSING
  -> UNKNOWN
  -> SUCCEEDED
  -> FAILED

状态转换需要受到明确限制,以免迟到的回调覆盖最终结果。

function transitionPayment(payment, targetStatus):
    allowedTransitions = {
        "CREATED": ["PROCESSING", "CANCELED"],
        "PROCESSING": ["REQUIRES_ACTION", "SUCCEEDED", "FAILED", "UNKNOWN"],
        "REQUIRES_ACTION": ["PROCESSING", "SUCCEEDED", "FAILED", "CANCELED"],
        "UNKNOWN": ["SUCCEEDED", "FAILED"],
        "SUCCEEDED": [],
        "FAILED": [],
        "CANCELED": []
    }

    if targetStatus not in allowedTransitions[payment.status]:
        return ignoreOrLogInvalidTransition()

    payment.status = targetStatus
    save(payment)

具体的状态名称应根据支付服务商的 API 调整,但明确规定允许的状态转换,适用于大多数支付系统。

正确处理异步回调

浏览器显示”支付成功”,不能作为发货、充值或开通服务的唯一依据。用户可能关闭页面,跳转可能失败,前端结果也可能被伪造。

处理支付回调时需要:

  1. 验证回调签名和时间戳。
  2. 使用支付服务商的事件 ID 去重。
  3. 查找本地支付记录。
  4. 核对商户标识、金额、币种和支付对象。
  5. 在数据库事务中更新支付和订单状态。
  6. 提交事务后,再触发发货等后续任务。
function handleWebhook(httpRequest):
    event = verifyAndParseWebhook(
        payload = httpRequest.rawBody,
        signature = httpRequest.headers["Provider-Signature"]
    )

    beginTransaction()

    if webhookEventExists(event.id):
        commitTransaction()
        return httpResponse(200)

    saveWebhookEvent(event.id, event.type)

    payment = lockPaymentByProviderId(event.paymentId)

    if payment does not exist:
        recordUnmatchedEvent(event)
        commitTransaction()
        return httpResponse(200)

    if event.merchantId != configuredMerchantId:
        rollback()
        return httpResponse(400)

    if event.amount != payment.amount or event.currency != payment.currency:
        markForManualReview(payment, "支付金额或币种不匹配")
        commitTransaction()
        return httpResponse(200)

    if event.type == "payment.succeeded":
        markPaymentSucceeded(payment)
        markOrderPaidOnce(payment.orderId)

    else if event.type == "payment.failed":
        markPaymentFailedIfNotSucceeded(payment)

    commitTransaction()

    enqueueOrderFulfillmentIfNeeded(payment.orderId)
    return httpResponse(200)

回调可能重复、乱序或延迟到达,所以处理函数必须具备幂等性。同一个事件即使执行两次,也不能造成两次发货、两次余额充值或生成两张兑换券。

将扣款和业务交付解耦

支付成功后,不宜直接在回调请求中完成所有业务操作。更可靠的方式,是在同一个数据库事务中写入待处理事件,再由后台任务完成交付。

beginTransaction()

markOrderPaid(orderId)

insertOutboxEvent(
    type = "ORDER_PAID",
    aggregateId = orderId,
    uniqueKey = "order-paid:" + orderId
)

commitTransaction()

后台任务读取事件:

function processOrderPaid(event):
    if fulfillmentAlreadyCompleted(event.aggregateId):
        return

    fulfillOrder(event.aggregateId)
    markFulfillmentCompleted(event.aggregateId)

这样可以处理”订单已经标记为已付款,但消息发送失败”等情况。后台任务即使重试,也不会重复交付。

验证应分为两层

前端验证用于改善用户体验,例如检查必填项和输入格式:

if (!emailInput.validity.valid) {
    showError("请输入有效的电子邮箱地址");
    return;
}

服务端验证是业务规则的最终边界:

validate:
    order belongs to current user
    order is payable
    products still exist
    inventory is available or reserved
    coupon is valid
    calculated amount matches stored order amount
    currency is supported
    payment token format is acceptable

即使前端已经完成验证,服务端验证也不能省略。

数据保存原则

应用通常只需保存:

  • 本地订单 ID
  • 支付服务商的支付 ID
  • 支付状态
  • 金额和币种
  • 幂等键
  • 支付方式的非敏感摘要,例如品牌和尾号
  • 请求与回调时间
  • 错误代码和状态变化记录

除非业务具备相应的合规能力,否则不要自行保存完整卡号、磁道数据、安全码或支付密码。CVV/CVC 尤其不能作为可重复使用的支付凭据保存。具体的数据处理要求,应以所用支付服务商的接口规范和适用的 PCI DSS 范围为准。

日志中也要避开敏感字段:

log({
    orderId: order.id,
    providerPaymentId: payment.providerPaymentId,
    status: payment.status,
    errorCode: providerResult.errorCode
})

不要直接记录完整请求体、支付令牌、卡号或身份验证信息。

容易忽略的问题

重复提交

不能只依赖前端按钮锁定。服务端应为同一个业务支付生成稳定的 idempotencyKey,并在本地数据库和支付服务商接口中使用同一个键。

网络超时

超时表示”结果未知”,不等于”支付失败”。在确认支付服务商没有成功受理原请求之前,不应换用新的幂等键再次扣款。

页面刷新和返回按钮

支付进度应保存在服务端。页面重新打开后,前端通过订单 ID 查询状态,不要依赖 JavaScript 内存中的结果。

const status = await fetch(`/api/orders/${orderId}/payment-status`);
renderPaymentStatus(await status.json());

金额变化

支付开始后,应使用订单快照或有效期明确的报价。支付回调到达时,不能再按当前商品价格重新计算,否则可能造成支付金额与订单金额不一致。

并发回调

更新状态时应使用数据库行锁、条件更新或乐观锁:

UPDATE payments
SET status = 'SUCCEEDED', version = version + 1
WHERE id = :payment_id
  AND status IN ('PROCESSING', 'UNKNOWN')
  AND version = :expected_version;

如果受影响的行数为 0,说明记录已被其他请求处理。此时需要重新读取状态,不能直接覆盖。

失败后的重试

失败的支付尝试应与订单分开记录。一个订单可以有多次支付尝试,但只能有一个被确认为有效的成功结果。如果多次尝试意外成功,应进入退款或人工复核流程。

一个完整的职责划分

Browser:
    collect non-sensitive checkout fields
    tokenize payment details with provider SDK
    submit orderId + paymentMethodToken
    display current server-side status

Application Server:
    authenticate user
    calculate authoritative amount
    create order and payment attempt
    enforce idempotency
    call payment provider
    process verified webhooks
    expose payment status

Database:
    store order snapshot
    store payment attempts
    enforce unique constraints
    record webhook event IDs
    record fulfillment state

Payment Provider:
    handle sensitive payment credentials
    perform authorization or capture
    run required customer authentication
    provide status query and signed webhooks

Background Worker:
    reconcile unknown payments
    fulfill paid orders
    retry recoverable operations
    raise manual-review alerts

支付表单本身并不是最难处理的部分。表单提交后的状态管理才需要严谨设计:金额必须可信,请求必须幂等,结果必须能够核实,失败后必须可以恢复。无论任何操作重复执行多少次,都不能造成重复扣款或重复交付。

备注:内容仅供参考。