Payment Gateway 不提供测试信用卡,如何安全测试交易?
明确结论
这种做法确实存在,但不是成熟 Payment Gateway 常见的开发体验。正规的支付接入通常会提供 sandbox、测试卡号或交易结果模拟功能。如果只能使用真实信用卡并产生实际扣款,对方提供的更像是受限的 live testing,而不是真正隔离的测试环境。
不要把所有测试都放在真实交易中完成。比较稳妥的安排是:
- 用 mock 和契约测试覆盖大部分逻辑。
- 取得 Gateway 对真实交易测试的书面确认。
- 只用经过授权的自有卡完成少量端到端验证。
- 优先采用 authorize-only,并在授权后立即 void。
- 无法 void 时,再进行极小额 capture 和 refund。
- 避免让服务器接触或记录完整卡号和 CVV。
如果对方没有 sandbox,无法模拟拒付、超时、3-D Secure 等场景,还要求反复进行真实扣款,就需要认真考虑是否更换服务商。
为什么会出现这种情况
一些规模较小、系统较旧,或者主要面向线下收单的 Gateway,可能没有完整的测试基础设施。它们所谓的 test environment 也许只能验证 API 连接、签名和商户配置,交易仍会进入真实的收单链路。
真实交易通常会经过:
Merchant → Payment Gateway → Acquirer → Card Network → Issuer
交易一旦进入这条链路,就可能产生:
- 持卡人账户的真实授权冻结;
- 实际清算和资金转移;
- Gateway、收单行或卡组织手续费;
- 退款等待时间;
- 汇率差异;
- 风控告警;
- 退款率、撤销率等商户指标变化。
所以,“扣款后退款”并不等于没有成本的测试交易。
有些收单协议还可能限制商户使用本人信用卡向自己的商户账户付款。测试前必须向 Gateway 或收单机构确认,并保留书面答复,不能只根据客服的口头说明操作。
先向 Gateway 确认这些问题
不要直接开始刷真实交易。至少要确认:
- 是否允许商户使用员工或公司负责人的信用卡进行集成测试;
- 是否支持 authorize-only,也就是授权但不立即 capture;
- 已授权交易能否 void,以及允许 void 的时间窗口;
- capture 后的交易如何 refund;
- authorization、void、capture 和 refund 是否分别收费;
- 最低交易金额及币种限制;
- 是否有专门的测试商户号或受限生产商户号;
- 如何测试 decline、timeout、3-D Secure、重复请求和 webhook 重试;
- 测试交易是否会影响商户风控、退款率或账户审核;
- 是否提供认证测试清单,并由对方配合触发特定结果。
如果对方无法回答这些问题,风险就不只是开发不便,还涉及生产资金和合规。
推荐的测试分层
1. 在本地模拟支付结果
业务代码不应直接依赖某个 Gateway SDK。可以先定义自己的支付接口,再分别提供真实实现和 mock 实现。
type PaymentResult =
| { status: "authorized"; transactionId: string }
| { status: "declined"; declineCode: string }
| { status: "pending"; transactionId: string };
interface PaymentProvider {
authorize(input: {
amount: number;
currency: string;
paymentMethodToken: string;
idempotencyKey: string;
}): Promise<PaymentResult>;
void(transactionId: string): Promise<void>;
refund(transactionId: string, amount?: number): Promise<void>;
}
测试实现可以稳定覆盖不同结果:
class MockPaymentProvider implements PaymentProvider {
constructor(
private readonly result: PaymentResult,
) {}
async authorize(): Promise<PaymentResult> {
return this.result;
}
async void(): Promise<void> {
return;
}
async refund(): Promise<void> {
return;
}
}
例如,可以这样测试拒绝交易:
const provider = new MockPaymentProvider({
status: "declined",
declineCode: "insufficient_funds",
});
const result = await provider.authorize({
amount: 100,
currency: "CNY",
paymentMethodToken: "mock-token",
idempotencyKey: "test-order-001",
});
if (result.status !== "declined") {
throw new Error("Expected transaction to be declined");
}
这样就能测试订单状态、错误提示、库存回滚和重试逻辑,不必真的制造一笔拒付。
2. 做 API 契约测试
根据 Gateway 的正式文档,保存经过脱敏的请求和响应样例,验证:
- 请求字段和数据类型;
- 金额单位;
- 签名算法;
- 状态码映射;
- webhook 验签;
- 对未知字段和未知状态的兼容处理;
- 网络超时后的查询与重试策略。
金额单位尤其需要确认。有些 API 使用主货币单位,有些使用最小货币单位。例如,100 可能表示 100.00 CNY,也可能表示 1.00 CNY,具体含义只能以对方文档为准。
3. 只做少量真实端到端交易
真实交易只用于验证模拟环境无法覆盖的部分,例如:
- 商户凭据是否有效;
- 收单路由是否开通;
- 真实授权能否成功;
- webhook 是否能到达生产入口;
- void 或 refund 是否正常执行;
- 对账记录能否关联到内部订单。
不要用真实交易运行完整的自动化回归测试。
优先选择 authorize-only 和 void
如果 Gateway 支持,应先创建不自动 capture 的授权:
const payment = await gateway.authorize({
amount: MINIMUM_ALLOWED_AMOUNT,
currency: "CNY",
paymentMethodToken,
capture: false,
idempotencyKey: orderId,
});
try {
await verifyAuthorization(payment);
} finally {
await gateway.void(payment.transactionId);
}
这里的 authorize、capture、void 和参数名称只是通用示意,必须换成 Gateway 官方 API 使用的实际名称。
authorize-only 通常不会完成资金清算,但可能临时占用信用卡额度。void 用于撤销尚未清算的授权,一般比 capture 后再 refund 更适合测试。不过,冻结额度何时解除取决于发卡行和卡组织的处理流程,不能假设执行 void 后额度会立即恢复。
必须扣款时如何控制风险
如果 Gateway 明确要求先 capture 再 refund,可以建立专门的人工测试流程:
const MAX_LIVE_TEST_AMOUNT = 100; // 示例上限,不代表任何 Gateway 的最低金额
function assertLiveTestAllowed(input: {
environment: string;
amount: number;
operatorApproved: boolean;
testAccount: boolean;
}) {
if (input.environment !== "production") {
throw new Error("Unexpected environment");
}
if (!input.operatorApproved || !input.testAccount) {
throw new Error("Live payment test is not authorized");
}
if (input.amount > MAX_LIVE_TEST_AMOUNT) {
throw new Error("Live test amount exceeds the configured limit");
}
}
执行时应遵守以下要求:
- 使用独立的测试订单和专用测试账号;
- 使用经过持卡人明确授权的卡;
- 设置很低的单笔金额和每日总额上限;
- 每笔请求使用唯一的
idempotencyKey; - 禁止并发批量执行;
- 保存 Gateway 返回的 transaction ID;
- 验证交易状态后立即 void 或 refund;
- 通过轮询或 webhook 确认最终状态;
- 每日核对 Gateway 后台、内部订单和银行账单;
- 为未退款或退款失败设置告警。
退款请求成功,不代表持卡人已经收到退款。系统应区分以下状态:
refund_requested
refund_pending
refund_succeeded
refund_failed
收到 HTTP 200 后,不要直接将退款标记为最终完成。只有 Gateway 明确规定该响应表示退款已经完成时,才能这样处理。
防止重复扣款
真实环境中的网络超时尤其危险。客户端超时,并不代表 Gateway 没有完成扣款。
下面这种超时后立即重新提交的处理方式并不安全:
// 不安全:第一次请求可能已经成功
await gateway.charge(payment);
await gateway.charge(payment);
应使用同一个幂等键重试,或者先查询原交易:
const idempotencyKey = `payment:${orderId}`;
try {
return await gateway.charge({
...payment,
idempotencyKey,
});
} catch (error) {
if (!isNetworkTimeout(error)) {
throw error;
}
const existing = await gateway.findByIdempotencyKey(idempotencyKey);
if (existing) {
return existing;
}
return await gateway.charge({
...payment,
idempotencyKey,
});
}
只有 Gateway 官方支持幂等键或按商户订单号查询时,才能使用相应机制。如果两者都不支持,需要向对方确认超时后的恢复流程,不能自行认定重复调用是安全的。
信用卡数据的安全边界
即使使用真实卡测试,也不应把完整卡号直接提交到自己的服务器。优先采用 Gateway 提供的 hosted payment page、iframe、客户端 SDK 或 tokenization 方案:
Browser → Gateway 获取 paymentMethodToken
Browser → Merchant Server 提交 token
Merchant Server → Gateway 使用 token 发起交易
以下底线必须遵守:
- 不在数据库中保存完整 PAN;
- 不在日志、异常堆栈或监控事件中记录 PAN;
- 不记录 CVV;
- 不通过聊天、邮件或工单传递卡号;
- 不把卡号写入自动化测试数据、源码或 CI/CD 变量;
- 限制生产凭据的权限,并定期轮换;
- webhook 必须验签,不能只依赖来源 IP 或请求字段;
- 根据实际支付架构评估 PCI DSS 适用范围。
即使卡号经过遮罩,也要检查 HTTP access log、APM、错误追踪和反向代理,确认它们不会记录原始请求体。
无法通过真实扣款安全覆盖的场景
如果只有一张正常信用卡,就无法可靠测试以下场景:
- insufficient funds;
- expired card;
- suspected fraud;
- 3-D Secure challenge、失败或取消;
- 发卡行超时;
- 部分 capture;
- 重复 webhook;
- 异步拒绝;
- 特定卡组织或不同国家的卡;
- chargeback。
不要尝试通过故意输入错误卡号、连续制造失败交易或其他异常行为来触发这些结果。这些操作可能触发发卡行、Gateway 或商户账户的风控。
这些场景应通过 mock、契约测试或 Gateway 提供的认证工具覆盖。如果 Gateway 要求上线前完成验证,却不能提供模拟功能或人工配合机制,这属于供应商能力缺口。
最后的判断标准
少量真实交易可以用于最终上线验证,但不能成为日常开发测试的基础。一个可接受的方案至少需要具备:
- 得到明确许可的真实测试流程;
- authorize/void 或可靠的 refund 能力;
- 幂等和交易查询机制;
- webhook 验签及重试说明;
- 无需商户系统直接处理敏感卡数据的集成方式;
- 能够覆盖失败场景的模拟或人工认证渠道。
如果这些条件都无法满足,仅靠“每次真实扣款后申请退款”进行测试,不但效率低,还可能带来手续费、额度冻结、重复扣款、合规和风控问题。此时应要求 Gateway 补充测试能力,或者更换支持标准 sandbox 的服务商。
备注:内容仅供参考。
