PAYATHON 2026

Desktop Applications 的 Credit Card Payment Solutions

支付阿杰

结论

Desktop application 不应直接集成信用卡收单接口,也不应在客户端保存卡号。更合适的做法是:

  1. Desktop application 只发起购买请求并展示支付状态。
  2. 首次绑卡时,通过系统浏览器或支付服务商的托管支付页面采集信用卡信息。
  3. 支付服务商返回 customer ID、payment method token 等非敏感标识,由业务服务器保存。
  4. 后续扣款金额发生变化时,由业务服务器发起支付,并通过 webhook 确认最终结果。
  5. 如果单笔金额很小,可以优先考虑预充值余额、累计结算或达到阈值后再扣款,否则手续费可能超过业务收入。

这种结构可以满足 Australia 可用、不保存信用卡资料、按不同金额重复扣款,以及接近 1-click payment 的需求。但真正的”一次点击”仍会受到银行验证、3-D Secure、风控和当地支付规则的影响,无法保证每笔支付都完全不需要用户操作。

推荐的系统结构

Desktop Application
        |
        | HTTPS + user authentication
        v
Application Backend
        |
        | secret API key
        v
Payment Processor
        |
        | webhook
        v
Application Backend

不能把支付服务商的 secret API key 放进 Desktop application。桌面程序运行在用户控制的设备上,无论经过混淆、加密还是打包,密钥都有可能被提取。

服务器通常需要保存:

internal_user_id
processor_customer_id
processor_payment_method_token
payment_status
consent_timestamp

不要保存:

card_number
card_security_code
magnetic_stripe_data
raw_payment_form_data

token 通常无法还原为卡号,但仍属于敏感业务数据,需要做好访问控制、日志脱敏和静态加密。

支付服务商应具备的能力

选择服务商时,不能只看它是否提供”订阅”功能。固定周期、固定金额的 subscription API 未必适合这种场景。需要确认服务商是否具备以下能力:

  • 支持 Australia 商户开户、当地结算币种及所需的跨境币种。
  • 支持 tokenization 或 card-on-file。
  • 支持首次建立 payment method,并在后续发起 merchant-initiated 或 off-session payment。
  • 允许每次使用不同的扣款金额。
  • 提供 webhook、退款、争议处理和幂等请求。
  • 提供托管支付页、Hosted Fields 或类似的安全输入组件。
  • 支持 3-D Secure 等持卡人验证流程。
  • 费率适合 micro payments,或允许采用余额和合并结算模式。

评估时通常会考虑 Stripe、Adyen、Braintree、PayPal 等服务商。不过,它们的开户地区、币种、最低费用、off-session 规则和产品名称都可能调整。正式选型前,需要根据商户主体、所属行业、平均交易金额和目标国家核对当时的官方条款。

首次绑卡流程

首次付款无法静默完成,因为支付服务商需要安全采集信用卡资料,银行也可能要求用户完成验证。

可以采用以下流程:

  1. Desktop application 请求业务服务器创建绑卡会话。
  2. 服务器调用支付服务商,创建 customer 和绑卡流程。
  3. Desktop application 使用系统浏览器打开服务商返回的 URL。
  4. 用户在托管页面输入信用卡资料并完成验证。
  5. 支付服务商通过 webhook 将绑卡结果通知服务器。
  6. 服务器只保存 customer ID 和 payment method token。
  7. 浏览器通过自定义 URI scheme 或 Universal Link/App Link 返回桌面程序。
  8. Desktop application 向业务服务器查询最终状态。

不要只根据浏览器的跳转参数判断绑卡是否成功。跳转参数可能被伪造,而且跳转也可能发生在 webhook 到达之前。

后续 1-click payment 流程

用户点击购买后,Desktop application 只向自己的服务器提交订单内容,不提交信用卡信息:

POST /api/payments
Authorization: Bearer <access-token>
Idempotency-Key: 2ab93a6e-8e93-4c75-94aa-844f6b4eafc2
Content-Type: application/json

{
  "productId": "usage-credits",
  "quantity": 12
}

服务器必须根据 productId、实际用量或订单记录计算金额,不能接受客户端直接传入的最终金额,否则攻击者可能通过修改本地请求改变支付价格。

随后,服务器使用已保存的 payment method token 发起扣款。如果银行要求再次认证,服务器应返回 requires_action,Desktop application 再打开支付服务商提供的验证页面。验证完成后,仍应以 webhook 返回的结果为准。

TypeScript 示例

下面的代码使用应用内部的支付适配器,并不对应任何一家服务商的官方 SDK。paymentProvider 需要按照最终选定服务商的真实 API 实现。

import express from "express";
import crypto from "node:crypto";

const app = express();
app.use(express.json());

app.post("/api/payments", async (req, res) => {
  const user = await requireAuthenticatedUser(req);
  const idempotencyKey = req.header("Idempotency-Key");

  if (!idempotencyKey) {
    return res.status(400).json({ error: "missing_idempotency_key" });
  }

  const product = await findProduct(req.body.productId);
  const quantity = Number(req.body.quantity);

  if (!product || !Number.isInteger(quantity) || quantity <= 0) {
    return res.status(400).json({ error: "invalid_order" });
  }

  // 金额由服务器计算,并使用最小货币单位,例如 AUD cents。
  const amount = product.unitAmount * quantity;

  const order = await createOrderOnce({
    userId: user.id,
    idempotencyKey,
    productId: product.id,
    quantity,
    amount,
    currency: "AUD",
  });

  const savedMethod = await findSavedPaymentMethod(user.id);

  if (!savedMethod) {
    return res.status(409).json({
      status: "payment_method_required",
    });
  }

  const result = await paymentProvider.chargeSavedMethod({
    amount: order.amount,
    currency: order.currency,
    customerToken: savedMethod.customerToken,
    paymentMethodToken: savedMethod.paymentMethodToken,
    idempotencyKey: order.id,
    reference: order.id,
  });

  await recordProcessorReference(order.id, result.paymentId);

  return res.json({
    orderId: order.id,
    status: result.status,
    actionUrl: result.actionUrl ?? null,
  });
});

金额应使用整数表示最小货币单位,以免产生浮点数误差:

const amountInCents = 199; // AUD 1.99

不要这样计算:

const amount = 0.1 + 0.2;

处理 webhook 时需要验证签名,并确保重复通知不会导致重复入账:

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.header("Payment-Signature");

    const event = paymentProvider.verifyWebhook({
      rawBody: req.body,
      signature,
    });

    const alreadyHandled = await hasProcessedEvent(event.id);

    if (alreadyHandled) {
      return res.sendStatus(200);
    }

    await runInTransaction(async () => {
      if (event.type === "payment.succeeded") {
        await markOrderPaid(event.reference, event.paymentId);
      } else if (event.type === "payment.failed") {
        await markOrderFailed(event.reference, event.reason);
      }

      await markEventProcessed(event.id);
    });

    return res.sendStatus(200);
  }
);

不同服务商使用的 webhook 签名头、事件名称和验证函数并不相同。实现时必须采用该服务商官方 SDK 和文档中的字段,不能直接照搬示例名称。

Micro payments 的成本问题

小额、变动金额的重复扣款在技术上可以实现,但从成本上看未必划算。信用卡费用通常由按笔固定费用和按比例费用组成。交易金额越小,固定费用占比就越高。

例如,假设单笔费用模型为:

fee = fixed_fee + amount × percentage

当 amount 很小时,fixed_fee 会成为主要成本。常见的处理方式包括:

  • 用户先充值,再根据实际用量扣除站内余额。
  • 将多笔用量累计到每日、每周或每月统一结算。
  • 达到最低扣款阈值后再发起信用卡支付。
  • 设置低余额自动充值,例如余额低于指定数值时充值固定金额。

如果采用站内余额,需要先确认退款、余额到期、未使用资金及当地监管要求。某些余额模式可能带来额外的财务或合规义务,不能只把余额当作普通数据库字段处理。

自定义界面的边界

如果需要较高程度的界面定制,可以使用服务商提供的 Hosted Fields 或嵌入式安全组件。这样既能让输入框的外观接近应用界面,也能让信用卡资料直接发送给支付服务商。

对于 Desktop application,系统浏览器通常比内嵌 WebView 更稳妥,原因包括:

  • 银行认证页面对 WebView 的兼容性可能较差。
  • 系统浏览器更容易配合密码管理器并处理银行跳转。
  • 可以减少桌面程序接触原始信用卡资料的机会。
  • 支付页面升级时,无须重新发布客户端。

界面可以定制品牌、颜色和返回路径,但不要为了追求完全一致的视觉效果而自行制作普通信用卡输入框。

合规与安全注意事项

“不保存信用卡资料”不代表完全不受 PCI DSS 约束。具体范围取决于支付页面的集成方式、服务器是否接触卡号、页面是否由服务商托管,以及商户和服务商之间如何划分责任。适用范围应由支付服务商或合格的合规顾问确认。

此外,还需要处理以下问题:

  • 首次绑卡时取得明确授权,并说明后续扣款的触发条件和金额计算方式。
  • 保存授权时间、条款版本和可审计的用量记录。
  • 提供解绑银行卡、停止自动扣款和查看账单的入口。
  • 对扣款接口实施用户认证、幂等键、速率限制和服务端金额校验。
  • 不要在日志、崩溃报告或分析系统中记录支付页面参数及 token。
  • 将 webhook 作为支付最终状态的来源,不以客户端提示作为入账依据。
  • 正确处理拒付、退款、过期卡、余额不足和额外认证。
  • 上线前测试客户端关闭、网络中断、重复点击和 webhook 重放等情况。

合适的实现方式不是寻找一个专门面向 Desktop application 的信用卡组件,而是让服务器与支付服务商负责支付。桌面端只处理订单交互和状态展示,信用卡资料由托管页面采集,后续扣款通过 token 完成。这样的结构能够兼顾安全、1-click 体验和变动金额的重复支付。

备注:内容仅供参考。