Desktop Applications 的 Credit Card Payment Solutions
结论
Desktop application 不应直接集成信用卡收单接口,也不应在客户端保存卡号。更合适的做法是:
- Desktop application 只发起购买请求并展示支付状态。
- 首次绑卡时,通过系统浏览器或支付服务商的托管支付页面采集信用卡信息。
- 支付服务商返回 customer ID、payment method token 等非敏感标识,由业务服务器保存。
- 后续扣款金额发生变化时,由业务服务器发起支付,并通过 webhook 确认最终结果。
- 如果单笔金额很小,可以优先考虑预充值余额、累计结算或达到阈值后再扣款,否则手续费可能超过业务收入。
这种结构可以满足 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 规则和产品名称都可能调整。正式选型前,需要根据商户主体、所属行业、平均交易金额和目标国家核对当时的官方条款。
首次绑卡流程
首次付款无法静默完成,因为支付服务商需要安全采集信用卡资料,银行也可能要求用户完成验证。
可以采用以下流程:
- Desktop application 请求业务服务器创建绑卡会话。
- 服务器调用支付服务商,创建 customer 和绑卡流程。
- Desktop application 使用系统浏览器打开服务商返回的 URL。
- 用户在托管页面输入信用卡资料并完成验证。
- 支付服务商通过 webhook 将绑卡结果通知服务器。
- 服务器只保存 customer ID 和 payment method token。
- 浏览器通过自定义 URI scheme 或 Universal Link/App Link 返回桌面程序。
- 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 体验和变动金额的重复支付。
备注:内容仅供参考。
