资讯动态

Java对接银联POS通C扫B聚合支付:下单、异步通知与验签实战

发布时间:2026/10/15 2:39:46 来源:尧图企业网站定制
做 Java 支付对接这些年银联商务系的聚合二维码支付里POS 通插件的 C 扫 B 业务是我觉得最容易被文档绕晕的一种通道。先给结论这类对接本质上是两件事——服务端向银联方提交一笔“主扫下单”拿到二维码内容收银台再用 POS 通插件在本地唤起并接收支付结果同时银联服务器还会异步推送一条通知到商户系统后端靠它把订单真正落库。很多人卡住不是因为写代码难而是没理清“服务端下单、插件感知、异步通知”这三条线的先后关系。这篇文章就围绕这个标题把完整的对接流程、签名验签细节、同步和异步回调联动逻辑以及我实际踩过的坑都写下来。适合 Java 后端、正在做收银系统或聚合支付通道的开发者对照参考如果你第一次接触银联电商类产品读完整篇也能建立比较清晰的全局认知。1. 先把业务场景和支付链路说透1.1 C 扫 B 是什么和 B 扫 C 有什么区别C 扫 B 是扫码支付里最常见的概念之一。C 指 Customer也就是顾客B 指 Business也就是商户。C 扫 B 就是顾客用支付宝、微信或其他 App 扫商户展示的二维码完成支付。典型的场景是餐厅桌上立着收款码牌你扫码输入金额付款或者收银台屏幕上展示一个动态二维码顾客对着它扫。与之相对的是 B 扫 C收银员拿扫码枪去扫顾客手机上的付款码这种方式走的是“被扫”或“反向扫码”的流程。两种模式的时序不一样风险控制也不同。B 扫 C 往往要求商户终端实时上报金额和条码内容C 扫 B 则是先下单生成二维码再等顾客主动扫码支付。POS 通插件模式下这个 C 扫 B 业务一般会落到收银机或商户软件上顾客扫的是收银台屏幕上的码而不是打印出来的静态码。这个区别决定了你既要有后端生成二维码内容又要有客户端插件感知支付结果好让收银员及时看到“已支付”并触发下一步操作。很多商户系统在这里容易犯迷糊只做了服务端下单忽略了插件端回调结果顾客钱付了收银员那台机器却一直停留在待支付界面。1.2 聚合二维码支付的整体流程下面按一笔完整交易从创建到落库来走一遍商户收银台向自己的 Java 后端发起“创建支付订单”请求后端组装参数带订单号、金额、商品描述、通知地址向银联方下单接口发起请求银联方返回二维码内容通常是一段 URL 或以 URL 为核心的拼接串也可能是一个可直接生成图片的字符串后端把这个二维码内容通过接口返回给收银台前端收银台把内容渲染成二维码图片展示同时准备监听 POS 通插件回调顾客扫码、确认支付资金从顾客账户扣除顾客端支付成功后收银台 POS 通插件能实时感知到结果同步跳到支付成功页银联后台也向商户后端配置的 notifyUrl 发送异步通知后端验签、校验订单、更新数据库状态商户后端给银联返回“SUCCESS”这笔交易闭环。你看这里面其实是两条并行线路一条是插件端到收银台的实时感知一条是服务端到商户后端的异步通知。前者负责体验后者负责数据落库和对账。真正订单状态的最终依据一定是服务端收到的异步通知或者后续主动查单的结果而不是插件端展示的成功页面。1.3 为什么很多开发者卡在这个对接里就我观察卡壳普遍不是因为接口复杂而是因为三个原因第一文档分散。银联商务不同产品线的文档版本很多POS 通插件是客户端的能力二维码聚合支付又是服务端的产品能力两边用的术语和参数字段对不上新手容易把两个体系的文档混着看。第二回调链路太长。既要处理服务端下单又要管插件本地回调还要管服务器异步通知稍有疏漏就出现“支付成功但系统没反应”。第三真实环境和测试环境的差异。有些参数在沙箱里能用生产环境必须重新申请密钥、终端号、商户号都是严格一一对应的换一个环境就验签失败。对接银联电商类支付产品我个人建议先把“下单、通知、查单、退款”这四类接口画在一张纸上再动手写代码。支付对接最忌一上来就调接口因为一旦状态不同步排查成本远高于前期设计成本。2. 对接前的材料与密钥体系梳理2.1 需要准备哪些商户参数和软件环境在正式写 Java 代码之前先确定银联方给你开通了哪些权限。一般来说POS 通插件和 C 扫 B 聚合支付对接会涉及以下材料商户号标识是哪个商户在收款终端号标识是哪个收银终端发起交易操作员号用于权限记录和部分接口的调用POS 通插件 APK 或 SDK收银台设备上安装或集成服务端下单接口的请求地址、版本号、交易类型商户私钥和银联公钥用于加签和验签通知地址 notifyUrl必须是公网可访问的 POST 接口。有一点容易被忽略POS 通插件的“终端号”与服务端下单接口里的“终端号”要保持一致。很多联调问题都出在两边终端号对不上导致插件收到了支付回调但服务端下单记录里找不到对应终端最终判定交易异常。拿到材料后第一步不是写代码而是把这些参数整理成一份配置清单逐个核对所属环境。2.2 加签和验签逻辑务必搞清楚银联商务聚合支付接口的签名规则在不同产品线里略有差异但大多数还是沿用“参数名 ASCII 升序拼接再加密钥做摘要”的模式。以常见的 MD5 加签为例规则大致如下参与签名的参数按参数名的 ASCII 码升序排列每个参数以keyvalue形式拼接中间用连接值为空或不参与签名的字段要过滤掉最后拼接上商户密钥形如key商户密钥对整串内容做 MD5结果转成大写。核心在于参与签名集合必须与银联服务器完全一致。你多传一个文档没列出的字段或者少拼一个空值字段都可能验签失败。服务端加签用来保证请求来自合法商户响应和通知验签用来保证数据来自银联。写代码时要用SortedMap天然处理按 Key 排序避免手写排序出错。public class SignUtil { public static String md5Sign(SortedMapString, String params, String secretKey) { StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : params.entrySet()) { String k entry.getKey(); String v entry.getValue(); if (v null || v.trim().length() 0) { continue; } sb.append(k).append().append(v.trim()).append(); } sb.append(key).append(secretKey); return DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8)).toUpperCase(); } public static boolean verify(SortedMapString, String params, String sign, String secretKey) { String calSign md5Sign(params, secretKey); return calSign.equals(sign); } }注意trim()密钥或参数值末尾如果带有空格线上环境最容易出这种低级问题。代码里用StandardCharsets.UTF_8显式指定编码不要依赖系统默认编码否则换服务器环境就会出现中文参数加签对不上的诡异问题。2.3 测试环境与生产环境的差异处理银联商务的对接环境通常分为沙箱测试和生产两部分。测试环境虽然接口流程和生产一致但商户号、终端号、密钥、证书都是独立的。我见过有人把测试密钥配到生产地址上结果所有请求都被拒。配置管理上建议用配置文件区分不要写死到代码里。如果你是本地开发想让银联服务器回调到本地不能直接把通知地址配成 localhost。开发阶段可以借助内网穿透工具把本机端口临时暴露到公网同时要注意银联服务器对回调响应有时间要求透传工具如果加了一层自定义响应头也可能导致验签失败。我的习惯是先用一个最简单的公网临时接口测试回调能稳定收到通知再接真实业务逻辑。联调环境还有一个坑测试环境返回的二维码内容可能可以直接扫码但对应的支付通道是沙箱通道用真实支付宝是扫不出来的。这时候不要急是环境问题不是代码问题。拿银联方提供的测试 App 或测试通道去完成支付即可。3. 核心流程落地Java 后端下单与通知处理3.1 下单接口的请求组装与发送服务端下单是整个对接的第一步。把它类比成去餐厅点餐你给服务员报菜名、报桌号服务员给你一张带桌号的小票顾客扫小票上的码付钱。这里的“菜名”是商品描述“桌号”是订单号“小票上的码”就是银联返回的二维码内容。参数组装时有几个字段尤其注意订单号上下游唯一必须在商户系统内不重复。我习惯用“日期业务线随机数”生成避免用数据库自增主键直接当订单号交易金额绝大多数接口以“分”为单位。1 元要传 100不是 1.00通知地址必须是公网可访问的 POST 地址协议用 HTTPS 更稳妥交易类型C 扫 B 主扫场景有固定的交易类型码对接文档里会注明不能拿 B 扫 C 的交易类型来用终端号必须与收银台 POS 通插件配置的终端号一致。发送时一般用表单提交application/x-www-form-urlencoded不是 JSON。public QrPayResponse createQrPayOrder(QrPayRequest request) { SortedMapString, String params new TreeMap(); params.put(version, 1.0); params.put(merId, config.getMerId()); params.put(termId, config.getTermId()); params.put(orderId, request.getOrderId()); params.put(txnTime, formatTxnTime(LocalDateTime.now())); params.put(txnAmt, String.valueOf(request.getAmountFen())); params.put(prodInfo, request.getProductInfo()); params.put(notifyUrl, config.getNotifyUrl()); params.put(txnType, 01); // 以实际文档为准 params.put(sign, SignUtil.md5Sign(params, config.getSecretKey())); String resp HttpUtil.postForm(config.getOrderUrl(), params); MapString, String respMap parseQueryString(resp); if (!SignUtil.verify(respMap, respMap.get(sign), config.getSecretKey())) { throw new PayException(下单响应验签失败); } return assembleQrPayResponse(respMap); }这里把txnTime格式化成yyyyMMddHHmmss一定要用服务器当前时间不要用客户端传入的时间。如果商户系统服务器时钟和银联服务器时钟偏差太大可能出现交易时间不在允许范围内的报错。生产环境建议给服务器做 NTP 时间同步别小看这步支付系统里时间不一致是隐藏炸弹。3.2 拿到二维码内容之后怎么展示银联返回的二维码内容一般有两种形式。一种是直接的 URL 链接扫码后跳到支付页面另一种是一段需要拼装的字符串前端要自行拼接成完整 URL 再生成二维码。无论哪种后端最好原样返回给前端拼接工作放前端做避免后端因为字符串编码问题提前破坏内容。二维码展示常见做法是前端用 ZXing 或类似库把后端返回的 content 转成二维码图片。收银台是 Android 设备的话也可以用 POS 通插件自带的二维码控件渲染。二维码都有有效期C 扫 B 场景通常是几分钟到几十分钟不等。如果顾客长时间不扫二维码过期收银台应提示重新下单生成新码。有效期的判断后端要留一个字段记录二维码生成时间而不是依赖银联返回的过期时间因为不同交易类型这个字段不一定都有。顾客扫码后插件端会收到支付结果。这个结果要用来“刷新收银台界面”不能直接作为订单落库依据。正确的状态流转顺序是插件端亮出支付成功同时服务端等异步通知到达后再改订单状态。如果异步通知迟迟不到就需要主动查单。3.3 异步通知处理验签、幂等、应答异步通知是最容易埋雷的一环。银联服务器在顾客支付成功后会向商户系统的 notifyUrl 发送 POST 请求通知参数包含订单号、交易状态、渠道方订单号、交易金额等。处理逻辑必须遵循“先验签、再幂等、后更新”三步。PostMapping(/pay/notify) public String payNotify(HttpServletRequest request) { MapString, String params new HashMap(); request.getParameterMap().forEach((key, value) - params.put(key, value[0])); log.info(收到银联异步通知: {}, params); SortedMapString, String sorted new TreeMap(params); if (!SignUtil.verify(sorted, params.get(sign), config.getSecretKey())) { log.warn(银联异步通知验签失败: {}, params); return FAIL; } String orderId params.get(orderId); String status params.get(status); // 幂等判断订单已经是终态且金额一致直接返回成功 if (orderService.isIdempotentPaid(orderId, params)) { return SUCCESS; } // 业务处理更新订单状态、记录渠道流水、触发发货 boolean ok orderService.processPaidOrder(orderId, params); return ok ? SUCCESS : FAIL; }注意返回给银联的应答。通常要求返回固定字符串比如SUCCESS表示收到且处理成功其他内容表示失败。如果返回非成功标识银联会按照既定的重试策略重复发送通知重试次数和间隔以文档为准。有人图省事直接返回SUCCESS不管业务是否处理成功这是绝对要避免的。一旦业务没处理成功就返回成功银联不会再通知这笔单就永远卡在支付成功、系统未更新的状态。幂等处理也相当关键。银联的异步通知可能重复发送可能前端重新查询触发可能出现并发请求同时到达。如果重复处理可能给用户发两次货或者产生两条流水。我的做法是用数据库唯一索引兜底订单状态只有“待支付”才能流转到“支付成功”已经在终态的订单直接视为幂等命中。3.4 订单查询与主动补单异步通知是理想状态下最及时的手段但现实中总有丢通知、服务重启、网络闪断的情况。订单查询接口就是用来兜底的。我习惯在商户系统里做一个定时任务每隔一段时间把“待支付但已超过合理时间”的订单捞出来请求银联查单接口。查单逻辑可以这样设计查询条件订单号、商户号、终端号、交易时间对查询结果同样验签如果银联返回已支付则把本地订单修正为支付成功如果银联返回未支付不做修改等待下次轮询或提示重新下单如果银联返回交易不存在则标记为异常单人工介入。定时任务的频率不要过于密集一般几分钟一次足够。对账补单是商户侧的责任银联不会因为你没收到通知而主动帮你补所以这个任务必须在上线前设计好。4. 工程化细节状态机、幂等、对账与安全4.1 支付订单状态机设计支付系统里订单状态机越简单越好。C 扫 B 场景一般只需要以下几个状态状态含义流转方式待支付已下单生成二维码顾客还未扫码创建订单时进入支付成功银联确认收款商户已收到异步通知或查单确认异步通知处理、查单修正支付失败交易被银联拒绝下单响应报错或查单返回失败已关闭二维码过期、商户主动撤单超时关闭、人工关闭已退款支付成功的订单发生退款退款接口处理状态机最关键的原则是只有待支付才能变成支付成功只有支付成功才能变成已退款。支付成功之后任何重复通知都不能把状态往回改。数据库层面可以加状态位和版本号做乐观锁避免并发更新。4.2 回调重复、通知丢失、状态漂移的无奈与对策现实世界比教科书残酷得多。我曾经遇到过一次顾客已经支付成功但银联异步通知延迟了大半个小时才到达的情况。期间顾客在收银台前干等店员也不知道是否支付成功场面很尴尬。后来排查发现银联通知服务在那段时间出现拥堵属于通道方的问题。这个案例告诉我们只靠异步通知做实时性保障是不够的。对策通常有两层。第一层收银台插件端感知到支付成功后立即主动调用商户系统后端接口询问订单最新状态。这个过程叫“前端主动刷新”它不是最终依据但能大幅缩短收银员的等待时间。第二层后端定期查单兜底。即使前端刷新和异步通知都失效轮询任务也能在几分钟内把订单修正。还有一点容易被忽略前端主动刷新和异步通知都到达时要保证处理结果是幂等的。不要让第一次请求更新了订单第二次请求又触发一遍业务动作。我习惯把订单更新操作放在数据库事务里并且加上状态条件UPDATE order SET status SUCCESS WHERE order_id ? AND status WAITING返回值影响行数为 0 就说明重复处理了。4.3 银联侧查单结果与商户本地不一致怎么办不一致的情况大致分三类。第一类银联显示已支付商户本地是待支付。原因多为异步通知丢失。处理方式是信任查询结果把本地订单修正为支付成功记录对账日志。第二类银联显示未支付商户本地却是支付成功。这种情况非常少见但一旦出现绝不能直接改库。必须先看业务日志确认支付成功状态是从哪里写入的。可能来源包括测试数据误写、异步通知处理逻辑缺陷、前端刷新接口误写等。第三类银联返回交易不存在商户本地有订单。大概率是下单请求没有真正到达银联或者下单响应超时但实际已经受理。这种订单不要自动关闭要人工介入核对。我不会把查单结果直接当作唯一真理而是把它和本地订单状态放在一起做冲突检测。任何状态变更都保存一条变更日志带时间戳和来源这样出问题时可回溯。4.4 安全加固接口与数据的底线支付接口最不能省的是安全配置。通知回调方面只用 HTTPS 地址防止参数在传输过程中被篡改。验签必须放在业务处理之前而且要防止时序攻击。这里说的不是要你用多复杂的算法而是固定用银联文档指定的摘要算法和编码不要自行发明拼接规则。日志是排查问题的唯一线索但也是最容易泄露敏感信息的地方。订单号、金额、交易状态可以打日志但完整的卡号、手机号、身份证号绝不能明文打印。我一般会在日志里输出 key 和 value 长度或者在 DTO 里定义脱敏的 toString 方法从源头防止敏感数据进入日志。后台操作和退款接口要单独做权限控制。特别是在 C 扫 B 场景退款权限通常只给财务或店长角色不能每个收银员都能发起退款。支付侧和管理侧至少分两个接口权限避免因权限过大导致资金风险。5. 常见问题排查与避坑实录5.1 验签失败的几个高发原因验签失败是我被问得最多的问题而且九成以上是同一个套路把参数接收后用 map 遍历却忽略了空值过滤字段或者密钥配置里多了空格或者是订单金额单位的字符串带着小数点又或者是加签时把sign字段自己也放进签名集合里。排查时要养成一个习惯把收到的原始参数按 ASCII 排序后打印出完整串同时打印出计算出来的摘要和你收到的 sign 字段逐字符比对。肉眼看完基本能定位。不要只猜“是不是密钥错了”密钥错了会有固定特征所有请求的验签都会失败而不是偶尔失败。还有一个隐蔽坑银联通知里有的字段可能带前后空格有的字段值里有转义字符。收到参数时如果直接getParameter取出来就参与验签大概率没问题但如果框架对参数做了 trim 处理反而会破坏原始报文。所以验签要用原始值业务处理时再 trim。5.2 同步返回成功但顾客扫码支付失败或一直提示待支付同步返回成功只代表下单接口受理成功不代表支付本身成功。遇到顾客扫了码但支付失败先看顾客 App 里的提示再收银台看插件日志最后查银联订单查询接口。这三个环节能定位到 80% 的问题。如果顾客已经支付成功但收银台一直停在扫码界面那大概率是 POS 通插件没有收到回调或者插件回调的商户订单号和后端下单订单号对不上。插件端拿到的是展示二维码与服务端关联是依靠订单号透传的。收银台在发起下单时务必把后端生成的 orderId 原样传给插件或页面不要自己重新生成一个。5.3 金额错误1 元变 100或者 100 元变 1 元金额单位问题在支付开发中几乎人人踩过。银联聚合支付普遍以“分”为单位前端输入的是“元”后端要转成“分”。转换时不要用Double相乘会出现精度问题。正确做法是把元转成分的字符串做 BigDecimal 计算最终用 long 存储。public static long yuanToFen(String yuan) { BigDecimal amount new BigDecimal(yuan); return amount.multiply(BigDecimal.valueOf(100)).longValueExact(); }强烈建议后端契约里金额字段一律用 long 类型并且命名成amountFen、txnAmt这样带单位的名字。这样至少在代码审查时能一眼看出单位问题不会出现把“元”当“分”传出去的低级错误。5.4 二维码过期、被覆盖和订单号重复C 扫 B 场景里顾客可能扫了旧的二维码然后再扫新的或者同一个二维码被多人扫。必须做到一个订单号一次只能对应一个有效二维码重新下单时生成新订单号不要复用旧订单号二维码过期后旧订单要能安全关闭不会影响新订单同一订单被重复扫码时银联侧一般会拒绝或提示重复商户系统要能识别并提示顾客。有些商户系统为了省事一个订单号反复申请二维码想着反正支付成功后统一处理。这种设计在延迟通知场景下会引发严重的状态混乱。你无法判断回调到底是哪次下单对应的唯一能做的就是强制订单号唯一。5.5 常见问题速查表现象可能原因排查要点所有请求都验签失败密钥配置错误、密钥末尾空格、参与签名字段集合不一致打印签名原文逐字符对比下单返回成功但二维码无效测试环境通道未打通、交易类型码不对确认环境、确认交易类型字段顾客支付成功但服务端无感知异步通知丢失、回调地址不通、返回非 SUCCESS查订单查询接口做补单任务订单状态重复变更未做幂等控制、前端刷新误触发加唯一索引加状态条件更新回调地址能访问但收不到框架拦截、响应格式不符先打日志再验签确认响应字符串准确退款金额不一致退款接口单位不是分统一用 long 类型金额6. 长期对接支付接口我个人保持的几个习惯支付对接做了几年踩过的坑多了慢慢形成了几个固定习惯分享出来供参考。第一个习惯是联调阶段把每笔请求的原文和响应原文都在日志里完整保留。不仅是控制器层还要在 HTTP 客户端发出前后各打一份。这样一旦出问题不需要猜参数是被谁改的翻日志就能看到原始报文。日志文件按天滚动保留时间至少一个月支付纠纷追查时靠这些日志救命。第二个习惯是回调处理第一行先把参数打印出来再做验签。有些人的代码先验签验签失败直接抛异常结果连原始报文都没留下排查时只能干瞪眼。调试时宁可先打日志再验签定位问题优先级高于安全洁癖日志脱敏做好就行。第三个习惯是做一个 Mock 银联回调的工具。用测试环境没法每次手动支付成功尤其在自动化联调里写一个本地脚本或测试接口模拟银联回调向自己的 notifyUrl 发 POST 参数能大大加快开发效率。等真实联调时再把 Mock 数据与真实通道结果覆盖验证一遍。第四个习惯是同步返回只当提示异步通知和查单才是事实。商户系统很多纠纷都源于收银台界面上显示了支付成功但后端订单没有更新。任何展示给用户的支付结果都必须以后端订单状态为准。第五个习惯是上线前至少用真实环境手动核几笔交易。支付一毛钱、退一毛钱核对商户后台、银联系统、本地数据库三边的订单号、金额、状态和时间是否完全一致。一次完整闭环验证下来比写一百行单元测试都安心。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑