Java对接银联聚合二维码支付:C扫B业务全流程解析

发布时间:2026/10/14 17:38:39
Java对接银联聚合二维码支付:C扫B业务全流程解析 最近在帮一个零售连锁客户改造门店收银系统需求很明确收银台要支持微信、支付宝、云闪付但又不能一家一家去申请对接最好一个聚合二维码全搞定。客户走的通道就是银联电子商务海南的聚合二维码支付技术实现上通过 POS 通插件的 C 扫 B 业务也就是用户扫商户的码完成支付。整套对接做完之后回头梳理发现这里面有不少细节在官方文档里写得并不细踩坑基本靠试。这篇把完整的 Java 对接过程、代码思路和问题排查整理出来给后面接同一类业务的兄弟做个参考。先说清楚这篇适合谁。如果你是第一次接触聚合支付、分不清聚合二维码和普通二维码有什么区别或者正准备做收银台改造、对接支付插件那这篇文章值得你看完。如果你是老手重点看第三节的签名、回调幂等和第四节的对账设计这几个地方最容易出问题。1. 先把这个业务彻底看清楚聚合二维码和 C 扫 B 是什么1.1 一个二维码解决所有支付工具的难题门店收银场景里用户付款方式五花八门。用微信的人扫微信收款码用支付宝的人扫支付宝收款码用云闪付的还得专门找银联码收银台上贴三四个码既难看又容易扫错。聚合二维码解决的就是这个问题——一个二维码下面微信、支付宝、云闪付都能扫由收单机构在后台把不同渠道的交易路由到对应的支付平台。这里的角色感要先建立起来。商户系统是交易发起方用户是实际付款方银联电子商务海南是收单机构微信、支付宝、银联云闪付这些是真正的资金渠道。商户系统不需要分别去对接每个渠道只需要把订单信息交给收单机构收单机构再去跟各个渠道打交道。站在商户系统的角度整个支付对接其实变成了商户系统--银联电子商务海南这一条链路复杂度一下子降下来。这就是很多人第一次接触聚合支付时容易搞混的地方。你以为你在对接微信支付实际上你对接的是收单机构的一个聚合接口微信支付只是这个接口后面的一个路由目标而已。理解不了这层关系后面调试就容易抓瞎因为很多报错信息不是你直接调用微信接口会看到的。1.2 C 扫 B扫的是商户的码用户主动发起C 扫 B 是Custrmer Scan Business的缩写用户扫商户。这个和用户拿付款码给你扫的 B 扫 C 正好反过来。C 扫 B 的钱是扫码枪扫走然后把你的付款码交给收银台这部分涉及前置扫码设备的迭代需求不在本文讨论范畴。C 扫 B 的核心特征有两个。第一支付动作由用户主动发起商户系统只需要把二维码展示在屏幕上或者打印在小票上用户自己打开微信、支付宝、云闪付去扫扫码之后在手机上完成支付确认。第二交易结果依赖回调通知和主动查询两条腿走路因为商户端无法实时感知用户到底扫了没有、付了没有。这个交互模型决定了对接设计上的几个要点。二维码本身是一个字符串不是图片文件前端拿到这个字符串之后用二维码库渲染出来就行。二维码有有效期一般几分钟到十几分钟不等过期之后需要重新下单生成新的码。商户订单号在二维码有效期内必须保持唯一同一个订单号不能重复下单否则收单平台会直接报错或者把两次交易混淆。1.3 POS 通插件在整个链路里的定位POS 通插件这个名字听起来像一个硬件驱动程序实际上它在软件层面工作作用是连接商户系统的 POS 收银终端与收单机构的支付网关把收银端的交易请求转换为支付网关能够识别的报文同时把网关返回的结果解析回商户系统。商户系统通过 POS 通插件提供的接口下单拿到该笔订单对应的聚合二维码数据经过前端的二维码渲染流程展示给用户。用户扫码支付成功后收单机构通过回调通知把支付结果告诉商户系统商户系统再根据这个结果更新订单状态。整条链路里商户系统直接打交道的只有 POS 通插件后端的渠道路由、对账拆分、资金清分都藏在插件后面。正因为插件这一层做了大量统一化的处理商户侧的对接逻辑才比较固定。核心工作集中在报文组装与签名、下单单接口调用、回调验签处理、对账文件解析这四块。把这四块做好了整个支付闭环就通了。2. 对接准备参数、密钥、网络一项都不能漏2.1 申请商户资料的完整清单对接之前先要完成商户进件。在银联电子商务海南那边签约聚合支付产品拿到一套商户参数这套参数是后续所有请求的基础。具体包括以下内容。参数项说明注意事项商户号商户的唯一标识即 merId每个独立结算主体一个商户号终端号同一商户下的收银终端编号同一商户不同门店建议申请多个终端号便于区分交易来源证书商户签名证书及对应的密钥对私钥放服务端不能泄露收单机构平台证书用于验证平台回调签名的公钥回调验签的关键一般由机构提供API 网关地址正式环境和联调环境的接口地址两者区分开避免线上调测试环境回调地址商户系统接收支付结果通知的 URL必须公网可访问推荐 HTTPS别小看终端号这个字段。如果客户有好几个门店最好一个门店申请一个终端号。别图省事全用一个终端号不然出问题排查的时候连哪笔交易是哪个门店的都对不上对账和差错处理会很难受。2.2 签名机制这批业务逃不掉的核心逻辑支付类接口必须做签名。POS 通插件的接口报文里凡是商户侧发出的请求都要使用商户私钥对报文关键字段做签名收单机构回调商户系统的通知报文则使用收单机构侧的公钥做验签。两边各持一套证书互相验证对方身份。常见的签名算法是 RSA 和国密 SM2银联系通道现在有不少已经切到国密。这意味着你的 Java 项目里最好准备好两套签名工具的适配层。RSA 用 JDK 自带的SHA256withRSA或者SHA1withRSA就能做国密 SM2 需要额外引入bcprov或者hutool-crypto等库来支持。签名要按照报文里约定的顺序把所有参与签名的字段拼接起来一般是将请求参数去掉signature本身之后按照字段名 ASCII 码升序排序拼接成keyvaluekey2value2格式的字符串然后做签名。这里有个老坑字段的取值不能为 null 或者空字符串拼出来的串两边一定要一致否则签名必然对不上。联调阶段出现验签失败九成是参数字段拼接顺序不对或者是某个字段该传没传、传成了空值。2.3 回调地址与联调环境的打通回调通知是 C 扫 B 业务里最关键的异步机制。用户在手机上完成支付之后收单机构的服务端会往商户系统配置的回调地址发送支付结果。这个回调地址需要满足几个硬性要求。首先必须是公网可达的地址。本地调试的时候可以用内网穿透工具把本机端口暴露出去但生产环境千万不能用必须是具备足够稳定性的正式公网域名。其次协议推荐 HTTPS收单机构方面通常也要求 HTTPS因为涉及支付数据明文 HTTP 传递敏感信息不合规。最后回调地址对应的服务要能快速响应并返回明确的成功应答让收单机构知道我收到了避免它因为等不到应答而反复重发。联调阶段常见的问题是对回调地址的防火墙和反向代理配置不熟。曾经有一次某开发同事把回调地址配到了测试服务器的内网 IP 上怎么等都收不到通知排查了半天发现是这台服务器根本没映射公网。这个坑看着低级实际遇到时特别容易让人怀疑是接口文档理解错了。3. Java 侧核心实现下单、回调、查询三件套3.1 工程结构与关键依赖对接模块建议单独建一个 Maven 模块不要把支付逻辑散落在业务代码里后续维护和排查都会轻松很多。工程内部分成client、service、controller、handler几个包client 封装 HTTP 请求service 负责业务编排controller 暴露下单和回调接口handler 处理不同支付结果事件。依赖方面HTTP 通信我用的是 Apache HttpClient 4.5JSON 序列化用 Fastjson2或者 Jackson 也行加密签名方面引入了 Hutool 的 crypto 模块来做国密 SM2 支持。Hutool 的好处是封装了 BC 底层直接用SM2对象就能做签名验签不用自己钻研 BC 的复杂 API。3.2 下单接口拿到聚合二维码字符串核心的请求过程是这样的组装请求参数 - 签名 - 发送 HTTP 请求 - 解析响应 - 提取二维码数据。请求参数里面商户订单号orderId必须全局唯一时间字段txnTime用yyyyMMddHHmmss格式金额txnAmt单位是分一定不能直接传元否则出现 0.01 元的诡异金额误差排查的时候会非常崩溃。下面是一段简化的下单请求核心代码。public String createQrCode(String merchantId, String terminalId, String orderId, long amountFen) { MapString, String params new TreeMap(); params.put(version, 1.0); params.put(encoding, UTF-8); params.put(signMethod, SM2); params.put(merId, merchantId); params.put(termId, terminalId); params.put(orderId, orderId); params.put(txnTime, DateTimeUtil.currentTimestamp()); params.put(txnAmt, String.valueOf(amountFen)); params.put(txnType, 01); // 01 表示消费 params.put(businessType, C_SMOKE); // C 扫 B 场景标识 params.put(backUrl, callbackUrl); params.put(qrTimeout, 300); String sign Sm2Util.sign(joinParams(params), merchantPrivateKey); params.put(signature, sign); String response HttpClientUtil.postJson(gatewayUrl /pay/gateway/qrcode, JSON.toJSONString(params)); // 解析响应取 qrCode 字段 JSONObject json JSON.parseObject(response); if (00.equals(json.getString(respCode))) { return json.getString(qrCode); } throw new PaymentException(下单失败: json.getString(respMsg)); }注意TreeMap的使用。签名前必须保证参数有序自然排序用的就是 ASCII 码升序TreeMap自动做了这件事比手动排序更稳妥。响应里的qrCode是聚合二维码的字符串内容前端拿到之后用 QRCode 库渲染成图片展示。这里有一个实操细节。二维码超时时间qrTimeout建议设置成 120 到 300 秒之间。太短了用户还没掏出手机就过期了影响体验太长了订单堆积且过期之后商户侧没有自动取消机制容易产生无效订单。我实际操作下来超市便利店场景设 180 秒餐饮门店场景设 300 秒体感比较合理。3.3 回调处理验签、幂等、业务联动回调是 C 扫 B 业务里风险最高的环节因为收单机构的通知在极端情况下可能发送多次商户系统必须做到重复通知不影响业务状态。回调处理的标准流程是先验签再查本地订单再判断状态再更新业务数据最后返回应答给收单机构。验签环节使用收单机构平台证书对回调报文里的指定字段做验签。如果验签失败直接返回失败应答让收单机构继续重发。验签通过之后需要拿着回调报文里的orderId去本地数据库查一下订单当前状态。如果订单已经是支付成功状态说明这是重复通知直接返回成功应答即可不要重复更新。如果订单还处于待支付状态则执行状态流转逻辑更新订单为成功触发后续业务动作比如通知库存系统、推送小票打印指令。下面是回调处理的逻辑示意。PostMapping(/pay/callback) public String callback(RequestBody String rawBody) { JSONObject params JSON.parseObject(rawBody); if (!Sm2Util.verify(joinParams(params), params.getString(signature), platformPublicKey)) { return 验签失败; } String orderId params.getString(orderId); PayOrder order payOrderMapper.selectByOrderId(orderId); if (order null) { // 订单不存在理论上不该发生记录告警后返回成功避免收单机构一直重发 log.warn(收到未知订单回调: {}, orderId); return success; } if (SUCCESS.equals(order.getStatus())) { return success; } // 核心更新带上状态条件防止并发重复更新 int rows payOrderMapper.compareAndSetStatus(orderId, PENDING, SUCCESS); if (rows 1) { // 状态流转成功执行业务联动如库存扣减、消息推送 eventPublisher.publish(new PaySuccessEvent(orderId)); } return success; }这个compareAndSetStatus是幂等处理的关键。更新 SQL 里带一个WHERE status PENDING只有从待支付流转到成功的那一次更新能成功后续重复通知进来时状态已经不是 PENDING更新行数为 0不会重复触发业务逻辑。比先查状态再更新的两段式写法更安全因为避免了并发场景下两次通知同时到达、两次都以为自己是第一次的问题。考虑到银行类通道的回调可能走表单 POST 格式而非 JSON有些收单机构的通知报文是 XML 格式。我当时对接时用了一个请求 wrapper统一把不同 content-type 的请求体封装成 Map再交给验签逻辑。这一步能省掉很多换通道时的改造量建议顺手做好。3.4 主动查询兜底回调丢了也不能不管回调通知是异步机制网络抖动、收单机构服务异常或者商户系统回调接口短暂不可用都可能导致通知发不出来。支付业务不能把宝全押在回调上必须配合主动查询来做兜底。主动查询的逻辑是对处于待支付状态且下单时间超过一定阈值的订单定时向收单机构发起交易状态查询接口。POS 通插件一般提供query接口入参是商户号和商户订单号响应里带着交易状态、渠道交易号、实际支付金额等信息。我的做法是提供一个定时任务每分钟跑一次把超过 90 秒还没支付成功的订单捞出来批量发起查询。查到已支付就把订单状态更新成成功并触发业务联动查到未支付就继续等待查到明确失败比如订单已撤销就更新成失败状态。定时任务的扫描 interval 要控制好。太频繁会给收单机构造成不必要的请求压力太稀疏又会让用户已经支付但迟迟收不到结果。实际操作下来 60 秒一轮比较均衡。另外查询接口也要做签名请求参数的拼装规则和下单一脉相承可以复用同一套工具类。4. 状态管理与对账钱账必须分毫不差4.1 订单状态机怎么设计才不给自己挖坑支付订单的状态不能只有成功和失败两个中间态必须明确。以 C 扫 B 业务为例我把状态分为这几档待支付、支付成功、支付失败、已关闭、已退款。待支付是下单成功但用户还没付的状态已关闭包括用户主动取消、二维码超时未被扫描、以及订单被人工撤销已退款是支付成功后发生退款流程的终态。状态流转规则要写在代码里禁止随意跳转。待支付可以到支付成功、支付失败、已关闭支付成功只能到已退款已关闭和已退款都是终态。在数据库层面除了状态字段务必记录状态变更时间、支付渠道交易号、支付金额、支付完成时间这几个关联字段后续对账和客服查单全靠它们。一个容易忽略的点是支付金额的核对。回调报文里带的实付金额和本地订单的应付金额要对上对不上要进入异常告警流程不能直接更新成成功。有时候用户用了优惠券实付金额小于应付金额这种情况要看收单机构的报文里怎么标识优惠金额需要在设计阶段就把字段映射关系确认清楚别等上线了才发现金额对不齐。4.2 日终对账机制资金安全不能只看回调回调通知能覆盖 99% 的交易但真正能让商户放心的只有对账。收单机构每天会提供交易对账文件里面包含了当天所有通过该商户号完成的交易明细包括每笔交易的订单号、渠道交易号、交易时间、交易金额、支付状态等字段。商户系统每天定时下载这个文件与本地订单数据进行逐笔勾对。对账的核对维度有四个订单号是否存在、金额是否一致、交易状态是否一致、本地有没有对账文件中不存在的订单。对不上的记录进入差错表人工介入处理。为了保证对账的准确性本地订单表里要有单独的渠道交易号字段回调和主动查询的结果都要把这个字段落库对账的时候才能准确关联。我的一个经验是对账逻辑写成独立的服务不要跟普通业务逻辑混在一起而且对账任务的执行要等日切完成后再跑。日切时间在收单机构侧是固定的一般是晚上 0 点前后我通常会设置凌晨 1 点执行对账任务留足日切和数据生成的缓冲时间。对账文件下载失败要有告警因为有一天不核对资金的准确性就多一天不可控。5. 高频问题与排查实录这些坑我替你踩过了5.1 验签失败为什么报错永远是同一个联调阶段最频繁的错误就是验签失败。排查方向其实非常固定先看签名原文是不是一致的再看签名算法是否匹配最后看密钥是否配对。签名原文不一致的情况最多根源在于前后端拼接参数时字段顺序或值的内容不同。比如金额字段在请求里传的是字符串1000签名时拼的却是1000 带了个空格这能不失败吗。建议在收单机构沙箱环境里多做几次原样截取请求报文、原样参与签名的对比测试把请求体完整打印出来人工比对两边参与签名的字段列表。密钥配对问题也很值得讲一下。有些通道的签名用的不是一对公私钥而是二步组合商户证书私钥签名 收单机构平台公钥验签。验签失败时除了看自己的私钥对不对还要确认手里引用的平台公钥是不是对应当前环境的那把。测试环境和生产环境的平台公钥通常是不同的拿测试公钥去验生产环境的回调结果就是要死不活的验签失败。5.2 二维码展示出来了扫码却提示订单不存在二维码本身没有过期但扫码后提示订单不存在或者已撤销。这类问题基本是这两类原因第一二维码对应的订单已经在别的端被支付或关闭了扫描端拿到的是快照再扫已经失效第二下单接口返回二维码成功后商户系统本地事务回滚了二维码生成了但订单没落库。第二类原因属于代码层面的经典 bug。HTTP 请求发出去了收单机构已经生成了订单号并返回成功但是商户系统在处理响应之后、更新本地订单表之前发生了异常本地没有这单用户扫码自然查不到。这种问题的规避办法是在调用下单接口之前先在本地生成商户订单号并插入一条待支付状态的订单记录然后再去调用收单机构下单如果调用失败再本地标记失败。也就是先本地落单再通知通道这样无论后面发生什么本地订单一定存在。5.3 用户明明付了款商户系统却没收到回调这个属于异步通知机制的经典问题。排查顺序我建议按下面来确认回调地址在收单机构侧配置的是不是当前环境有效的地址。确认回调地址的公网可达性直接在浏览器访问一下看通不通。看回调接口有没有做 IP 白名单限制收单机构服务端 IP 是否在白名单内。检查回调接口的响应时间。收单机构对回调响应有超时限制如果商户系统在回调里写了大段的同步业务逻辑比如同步调库存、同步发送短信处理时间过长收单机构那边已经断开连接了就会判定回调失败并触发重发但重发次数有限次数用完就不再通知了。检查回调接口是不是返回了非约定格式的应答。有些通道要求返回特定字符串比如success或者OK如果你返回了一段 JSON通道可能不认。第 4 条很关键。回调接口的第一原则是快所有重量级的业务动作生成出库单、推送消息、同步会员积分全部异步化。我在回调控制器里只做三件事验签、更新订单状态、返回成功应答业务联动全部通过事件发布器发到内存队列里由独立线程池去消费。用这种设计之后回调接口的响应时间基本稳定在 50 毫秒以内。5.4 对账文件里出现本地不存在的交易对账文件里的订单在本地查询不到先不要慌按两类情况处理。第一种本地订单确实没有落库那就是下单接口先调通道、后落本地导致的属于 5.2 里说的顺序问题修复代码顺序后就不会再出现新的。第二种本地订单已经存在但状态还是待支付而文件里显示已支付这种情况说明用户支付成功了但回调没送达到商户系统主动查询兜底任务因为下单时间未达到阈值还没有跑到这一单。处理方式是手工把这笔订单修正为成功同时反思兜底任务的扫描范围设置是不是太窄了。我还遇到过一个更冷门的情况。用户反复刷新二维码页面每次都重新下单生成了多个订单但只付了最后那笔。对账的时候之前的订单在本地显示待支付通道侧显示未支付这种不叫差错叫无效订单定期清理就行。防止这个问题要从源头控制——用户在二维码有效期内重复刷新前端应提示已有二维码是否重新生成而不是无脑新下单。5.5 金额对不上的核心原因不是单位错了就是精度丢了金额类 bug 基本都出在两个地方单位换算和浮点数精度。对接银联系通道txnAmt的单位是分但业务系统里很多用户输入的是元中间做了一次元 * 100的换算。如果用的是double类型19.99 * 100的结果是1998.9999...转成整数就变成了 1998用户的 19.99 元错付成了 19.98 元。这种 bug 属于隐蔽性极高的那种测试的时候不容易发现一上线就有用户投诉。正确做法是金额全程用BigDecimal处理数据库字段要么存BIGINT分要么存DECIMAL绝不用double或float。换算代码写成这样BigDecimal amountYuan new BigDecimal(19.99); long amountFen amountYuan.multiply(BigDecimal.valueOf(100)).longValueExact();longValueExact()会在无法精确转换时抛异常宁可程序报错也不要默默地把钱算错。回调和查询报文里返回的金额字段解析时也统一用分转元的BigDecimal方式不要随手Integer.parseInt就完事。最后分享一点个人体会整套对接做下来我的感受是聚合二维码支付的代码量并不大真正的难点在于对业务链路完整性的把握。下单、回调、查询、对账这四个环节缺一不可稍有疏漏线上就会冒出各种莫名其妙的用户付了钱但订单没变的客诉。我实际操作中最深的体会是签名工具类和对账逻辑这两块一定要写得足够健壮、足够通用。因为一个收单机构往往对应多种支付产品后面的退款、撤销、账单查询接口大概率还会用到签名工具类对账逻辑换一个新商户号也能复用。前期多花一天时间把它们打磨好后面长期受益。最后再分享一个小技巧给正要动手的兄弟联调阶段准备一个简单的请求日志拦截器把所有发出的请求报文和接收的响应报文完整记录下来出了问题先翻日志不用盲猜。我当时靠这个拦截器定位了不少奇奇怪怪的问题很多你纠结半天的 Bug其实就是日志里某个字段的值跟你预期的不一样而已。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询