PAYATHON 2026

H5支付链接被生成二维码诱导付款,如何优化风控方案?

支付阿杰

明确结论

只比较“下单 IP”和“支付后 IP”,既无法可靠识别这类诈骗,也无法阻止受害者付款。移动网络切换、NAT、代理以及 IPv4/IPv6 变化都可能造成误判。骗子和受害者处于相近网络时,这种检查也可能失效。

更合理的做法是:

  1. 从源头限制支付链接在不同设备、不同会话之间复用;
  2. 将 IP 作为风险信号,而不是单独用于拦截;
  3. 在支付前绑定设备、会话、账号和订单;
  4. 只根据支付机构的服务端异步通知确认支付结果;
  5. 暂停履约异常订单,必要时自动退款或转人工审核;
  6. 结合账号、商品、收货信息和支付行为计算风险评分。

这里有一个能力边界:如果支付机构允许任何人扫描同一支付链接付款,又不提供付款人身份绑定或指定终端能力,商户系统就无法仅靠技术手段准确判断扫码者是否受到了诱骗。此时只能通过防止链接转发、增加业务限制以及支付后及时止损来降低风险。

为什么现有方案效果有限

支付完成页不是可信的支付凭证

支付完成后的页面跳转来自浏览器,可能没有发生,也可能被重复调用或伪造。用户付款后直接关闭页面,也会导致系统收不到跳转请求。因此:

  • 不能根据前端跳转判断订单已经支付;
  • 不能让跳转页面决定是否发货、充值或发放虚拟权益;
  • 应以支付机构签名验证通过的服务端异步通知或主动查询结果为准。

IP 变化不等于欺诈

在正常使用中,以下情况都可能导致 IP 发生变化:

  • Wi-Fi 与蜂窝网络切换;
  • 双卡网络切换;
  • 运营商 NAT 出口变化;
  • IPv4 与 IPv6 切换;
  • 使用企业代理、VPN 或隐私中继;
  • 支付 App 与浏览器走不同的网络链路。

同样,IP 一致也不能证明付款人就是下单人。IP 更适合用于风险评分。例如,跨省、跨国家或数据中心代理地址可以增加风险分,但不宜直接“一票否决”。

支付完成后才拦截订单,也无法挽回受害者已经支付的钱。此时系统能做的是停止履约,并尽快发起退款。

推荐的分层风控方案

1. 不向前端暴露可长期复用的支付地址

前端只持有商户生成的短期支付令牌,不直接保存或展示支付机构返回的完整支付 URL。令牌应满足以下条件:

  • 绑定订单、账号和当前登录会话;
  • 有效期尽量短,例如根据实际支付流程设置为数分钟;
  • 只能使用一次;
  • 使用高强度随机数,不能通过订单号推算;
  • 服务端只保存令牌摘要;
  • 首次兑换后立即失效;
  • 订单取消、超时或重新发起支付时同步失效。

如果兑换后返回的支付机构 URL 仍可复制,短期令牌只能缩短攻击窗口,无法彻底阻止二维码被转发。

2. 在创建支付前绑定会话和设备

下单时可以记录以下信号:

  • 登录账号及账号注册时长;
  • 服务端会话 ID;
  • 可信设备 Cookie;
  • User-Agent;
  • App 安装实例 ID;
  • 设备证明或 App Attestation 结果;
  • IP 网段、ASN、国家或地区;
  • 收货地址、手机号、商品和金额;
  • 同一设备、账号或收货信息过去的支付情况。

兑换支付链接时,再次核对这些信号。Cookie、User-Agent 和设备 ID 都可以伪造,不能单独充当身份凭证,但结合使用可以增加批量攻击的成本。

对于自有 App,应优先使用服务端签发的安装实例密钥、设备证明和 App 内支付流程。普通 H5 浏览器很难提供可靠、稳定的设备身份,因此不建议把浏览器指纹作为唯一依据。

3. 优先采用不适合跨设备传播的支付产品

需要向支付服务商或收单机构确认,当前产品是否支持以下能力:

  • 只能在原设备或原应用内继续支付;
  • 绑定已授权的付款账号;
  • 限制支付链接的有效期和使用次数;
  • 允许商户撤销未支付交易;
  • 返回经过授权的付款方标识;
  • 提供风险控制或反诈骗接口;
  • 禁止将 H5 链接转换为二维码供其他设备支付。

不同支付产品、接入渠道和合同权限提供的能力可能不同,不能假定某个参数必然存在。具体应以当前支付服务商的正式接口文档和商务确认结果为准。

如果业务不需要“他人代付”,可以在取得合法授权且接口支持的情况下,校验订单账号与支付机构返回的稳定付款方标识。如果无法提前取得合法、可靠的付款方标识,就无法准确强制“付款人必须等于下单人”。

4. 将硬拦截前移到支付发起阶段

遇到以下情况,可以在创建支付前直接拒绝请求或要求加强验证:

  • 支付令牌已经使用或过期;
  • 当前会话与下单会话不一致;
  • 订单状态不是 CREATED;
  • 短时间内反复创建、取消支付;
  • 新账号购买高风险虚拟商品;
  • 单台设备控制大量账号;
  • 多个账号使用相同的收货手机号或地址;
  • 金额、商品或购买频率明显异常;
  • 已明确识别为自动化工具或数据中心代理。

中低风险订单可以增加短信验证、登录确认、滑块验证或延迟履约。对于高风险订单,应直接停止创建支付,不能先允许用户付款,再简单拒绝订单。

5. 支付成功后先确认资金,再决定履约

建议分别管理支付状态和履约状态:

CREATED
  -> PAYMENT_PENDING
  -> PAID_RISK_REVIEW
  -> FULFILLABLE
  -> FULFILLED

PAID_RISK_REVIEW
  -> REFUND_PENDING
  -> REFUNDED

收到支付机构的成功通知后,先将订单更新为 PAID_RISK_REVIEW,再检查:

  • 实付金额是否与订单金额完全一致;
  • 商户号、应用 ID 和订单号是否属于当前系统;
  • 通知签名是否验证通过;
  • 支付流水号是否重复;
  • 当前订单是否已经支付或关闭;
  • 付款方标识是否满足业务约束;
  • 支付前后的风险评分是否达到审核阈值。

只有审核通过的订单才能发货、充值、兑换或发放佣金。高风险订单应冻结相关权益,并按照支付服务商的规则发起原路退款。

不能只把订单标记为“拦截”,然后长期占用用户资金。对于已经确认不再履约的交易,应建立清晰的自动退款、人工处理和投诉响应流程。

一个可落地的令牌绑定示例

下面是一个简化的 Java 风格示例,主要展示服务端状态校验。字段名和存储方式需要根据实际系统调整。

public PaymentLaunchResponse createPaymentLaunch(
        Order order,
        UserSession session,
        RequestContext request) {

    if (!order.getUserId().equals(session.getUserId())) {
        throw new ForbiddenException("order owner mismatch");
    }

    if (order.getStatus() != OrderStatus.CREATED) {
        throw new IllegalStateException("invalid order status");
    }

    RiskResult risk = riskService.evaluateBeforePayment(
        order,
        session,
        request.getTrustedDeviceId(),
        request.getIpAddress(),
        request.getUserAgent()
    );

    if (risk.getDecision() == RiskDecision.REJECT) {
        throw new RiskRejectedException();
    }

    String rawToken = SecureRandomToken.generate(32);
    String tokenHash = sha256(rawToken);

    PaymentLaunch launch = new PaymentLaunch();
    launch.setTokenHash(tokenHash);
    launch.setOrderId(order.getId());
    launch.setUserId(session.getUserId());
    launch.setSessionId(session.getId());
    launch.setTrustedDeviceId(request.getTrustedDeviceId());
    launch.setExpiresAt(Instant.now().plus(Duration.ofMinutes(3)));
    launch.setUsed(false);

    paymentLaunchRepository.save(launch);

    return new PaymentLaunchResponse(rawToken, launch.getExpiresAt());
}

兑换支付令牌时,必须使用事务或原子更新,避免同一个令牌被并发兑换多次:

@Transactional
public URI redeemPaymentLaunch(
        String rawToken,
        UserSession session,
        RequestContext request) {

    String tokenHash = sha256(rawToken);

    PaymentLaunch launch = paymentLaunchRepository
        .findByTokenHashForUpdate(tokenHash)
        .orElseThrow(() -> new ForbiddenException("invalid token"));

    if (launch.isUsed() || launch.getExpiresAt().isBefore(Instant.now())) {
        throw new ForbiddenException("expired or used token");
    }

    if (!launch.getUserId().equals(session.getUserId())
            || !launch.getSessionId().equals(session.getId())) {
        throw new ForbiddenException("session mismatch");
    }

    RiskResult risk = riskService.evaluateAtLaunch(
        launch,
        request.getTrustedDeviceId(),
        request.getIpAddress(),
        request.getUserAgent()
    );

    if (risk.getDecision() == RiskDecision.REJECT) {
        throw new RiskRejectedException();
    }

    launch.setUsed(true);
    launch.setUsedAt(Instant.now());
    paymentLaunchRepository.save(launch);

    Order order = orderRepository.findByIdForUpdate(launch.getOrderId())
        .orElseThrow();

    order.markPaymentPending();
    orderRepository.save(order);

    return paymentProvider.createH5Payment(order);
}

该设计还需要配合数据库唯一约束或条件更新。例如:

UPDATE payment_launch
SET used = TRUE, used_at = CURRENT_TIMESTAMP
WHERE token_hash = :tokenHash
  AND used = FALSE
  AND expires_at > CURRENT_TIMESTAMP;

只有受影响行数为 1 时,系统才能继续创建支付。

服务端支付通知示例

处理通知时,必须先验证支付服务商的签名,再执行幂等更新。下面是通用伪代码,不对应任何一家支付机构的具体 SDK 接口:

@Transactional
public void handlePaymentNotification(
        Map<String, String> parameters,
        String signature) {

    if (!paymentProvider.verifySignature(parameters, signature)) {
        throw new InvalidSignatureException();
    }

    PaymentNotice notice = paymentProvider.parseNotice(parameters);

    Order order = orderRepository
        .findByMerchantOrderNoForUpdate(notice.getMerchantOrderNo())
        .orElseThrow();

    if (!expectedMerchantId.equals(notice.getMerchantId())) {
        throw new SecurityException("merchant mismatch");
    }

    if (notice.getAmount().compareTo(order.getPayableAmount()) != 0) {
        throw new SecurityException("amount mismatch");
    }

    if (paymentRepository.existsByProviderTradeNo(
            notice.getProviderTradeNo())) {
        return;
    }

    paymentRepository.save(PaymentRecord.from(notice));

    RiskResult risk = riskService.evaluateAfterPayment(order, notice);

    if (risk.getDecision() == RiskDecision.APPROVE) {
        order.markFulfillable();
    } else {
        order.markPaidRiskReview();
    }

    orderRepository.save(order);
}

金额应使用整数形式的最小货币单位或精确十进制类型,不能使用 float 或 double:

long amountInCents = 1999L;
// 或
BigDecimal amount = new BigDecimal("19.99");

IP 应该如何参与判断

可以保留 IP,但应把它作为风险特征使用:

下单与支付发起 IP 完全一致:不加分
同一运营商且地理位置接近:少量加分或不处理
IPv4/IPv6、Wi-Fi/蜂窝切换:低风险
短时间跨国家或跨远距离地区:中高风险
数据中心代理、匿名代理或异常 ASN:提高风险
同一 IP 批量控制大量新账号:高风险

不必追求 IP 绝对一致。即使比较 /24、/64 等网段,也只能得到启发式判断,无法证明用户身份。IP 地理位置数据库本身也存在误差。

业务侧通常比单一技术特征更有效

刷单诈骗通常需要“快速变现”,因此可以重点限制以下行为:

  • 新账号购买虚拟商品或高流动性商品;
  • 支付成功后立即提现、转赠或兑换;
  • 订单收货信息频繁变化;
  • 同一设备、手机号、地址或受益账户关联大量账号;
  • 高频小额试单后突然出现大额订单;
  • 支付人与最终受益人长期不一致;
  • 佣金在退款窗口结束前立即结算。

对于高风险商品,可以延迟发货和佣金结算,并禁止立即转赠或提现。即使受害者已经付款,这些限制也会增加骗子快速变现的难度,为退款和人工核查留出时间。

注意事项

  • 不要记录或传播完整的支付 URL。日志、埋点、客服系统和监控平台都应进行脱敏,防止二次泄露。
  • 页面应设置合适的 Referrer-Policy,同时检查第三方脚本是否会收集当前 URL。
  • 不要把支付令牌放在容易被分析平台采集的查询参数中。如果必须这样传递,令牌应保持短期、单次,并对日志做脱敏处理。
  • 回调接口必须验证签名、金额、订单号、商户身份和交易状态,同时实现幂等处理。
  • 不要将浏览器指纹作为唯一的拦截依据。需要评估其稳定性、可伪造性和隐私合规风险。
  • 风控规则应保存命中原因和相关证据,以便人工复核、处理申诉和回溯规则。
  • 自动退款前应确认支付状态,并使用支付流水号保证退款幂等,防止重复退款。
  • 收集付款方标识、设备指纹、手机号和地理位置时,应满足隐私告知、最小化收集、保存期限和访问控制要求。

改造时,应先停止依赖支付完成页确认交易,再实现短期单次令牌和会话绑定。接着将高风险订单调整为“付款成功但暂不履约”,最后结合支付渠道能力绑定付款人或限制只能在原设备支付。这样可以减少误判,并将止损环节放到付款前和履约前。

备注:内容仅供参考。