zhaojie.tech/me
支付与交易 2026-10-12T00:00:00.000Z

g01-payment-channel-aggregation


title: 聚合三渠道:微信、支付宝、收钱吧的统一收银架构 date: 2026-10-14 tags: [支付, 策略模式, 架构]

聚合三渠道:微信、支付宝、收钱吧的统一收银架构

最早只有微信和支付宝两条直连通道,后来部分门店进件了收钱吧。三家的报文格式、签名算法、错误码词汇、回调应答要求各不相同——如果这些差异散落在收银 Service 里,就是if (channel == WECHAT) ... else if (channel == ALIPAY) ...的地狱:每接一个新渠道,就把整个 Service 重写一遍,而且所有渠道的 bug 会互相传染。

设计:一个接口收编所有渠道

我把”渠道能做什么”抽象成一个接口 PosPaymentChannelClient,覆盖收银受理的完整动作面:查询、主扫下单、关单、被扫受理、撤销、退款、退款查询。所有渠道差异——报文怎么拼、怎么签、错误怎么翻——全部封在实现类里。

注册用最朴素的 EnumMap:

private final Map<PosPaymentChannelType, PosPaymentChannelClient> clientMap =
        new EnumMap<>(PosPaymentChannelType.class);

@PostConstruct
public void register() {
    for (PosPaymentChannelClient client : clients) {   // Spring 注入全部实现
        clientMap.put(client.channelType(), client);
    }
}

Service 拿到渠道类型,clientMap.get(channelType) 一步路由。新接一个渠道的成本 = 写一个实现类,注册自动完成,已有代码零改动。

三个实现各自的”方言”:

  • WechatChannelClient:V2 接口,XML 报文;HTTP 通道复用 IJPay,MD5 签名规则(参数字典序拼接加商户密钥)与应答解析在业务侧自行实现;
  • AlipayChannelClient:RSA2 签名的表单请求,同步应答;
  • AggregatedChannelClient(收钱吧):SHA256WithRSA 对 raw body 的签名放在请求头——注意必须是原始报文字节的签名,先反序列化成 Map 再重新序列化去验签,字段顺序一变签名就对不上;回调应答必须是纯文本 success,回 JSON 或 XML 对方都不认,会按 1s/5s/30s/600s 的节奏反复重发。

错误码收敛:白名单制,而不是映射表制

三家的错误词汇完全不同,我的做法不是维护一张”第三方码 → 内部码”的全量映射表,而是白名单制:以收钱吧为例,官方文档里的明确失败码(条码过期、余额不足、超限额这类”请求被拒、资金未动”的错误)组成一个小集合,命中才归为 FAILED;集合之外的任何码一律归为”结果未知”,交给查询确认流程裁决——宁可多查一次,不猜成失败。

白名单里刻意没收 TRADE_HAS_SUCCESS 是个典型决策:这个码的字面意思是”交易已被支付”——资金已经动了,判失败会让收银员重新收款,造成顾客双付;归入未知走查询,会自然归并成支付成功正常记账。

同一个”收敛成内部语义”的思想也贯穿在被扫受理上:各家”支付中/未知/失败”的词汇全部归并成四个统一枚举(成功 / 用户支付中 / 结果未知 / 明确失败)。以支付宝为例,10000 是调用成功、再看 trade_status 细分(TRADE_SUCCESS 落成功、WAIT_BUYER_PAYMENT 归支付中);10003 是用户正在输入密码(支付中);20000 服务暂不可用属结果未知(查询归并);40004 这类明确拒绝才落”明确失败”;微信的 SYSTEMERROR 同样归并为未知。判据就一条:结果未知的宁可多查一轮,不猜成失败——猜错方向的代价是收银员重收、顾客双付。

付款码路由:规则表,不是 if-else

被扫场景顾客出示付款码,系统要先识别这是哪家钱包。付款码前缀有官方规则:18 位、以 10–15 开头是微信;25–30 位、以 25–30 开头是支付宝;62 开头是银联。我没写成 if-else,而是”规则表 + 匹配器”:

private static final List<PayCodeRule> RULES = Arrays.asList(
        new PayCodeRule(WECHAT, 18, 18, 10, 15),
        new PayCodeRule(ALIPAY, 25, 30, 25, 30));
// 62 银联段预留:规则未核验前返回 null(未识别),不猜

这么做的直接动机是官方码段在扩——支付宝的前缀和长度规则就扩充过一轮。硬编码的分支逻辑改起来要动路由代码,规则表只改一行数据;未命中任何规则的码统一走”未识别付款码”的报错引导,不冒险路由。

踩过的坑

  • 收钱吧回调验签,一开始用了解析后的参数重新拼串,偶发验签失败——重发风暴来了才定位到 raw body 的问题。此后所有渠道验签一律用原始报文。
  • 微信配置走 ThreadLocal(IJPay 的设计),在线程池里只设不清会残留脏配置,下一个请求可能拿到上一个门店的商户号——所有实现统一 try/finally 清理。

收尾一句话

聚合层的价值不是”多接几家渠道”,而是把渠道差异压进一个实现类、把第三方词汇翻译成一套内部语义——让上层业务相信自己只对接了一个稳定的支付渠道。

← 全部文章 赵杰 · me.zhaojie.tech