ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JAVA游戏支付源码实战:免签平台订单模型、回调幂等与补单对账

JAVA游戏支付源码实战:免签平台订单模型、回调幂等与补单对账 简介这是一套基于Java开发的通用游戏支付平台源码面向游戏运营者与具备一定开发能力的后端人员用于解决游戏内虚拟商品购买、自动发货与个人账户收款问题。系统已对接正在运营的免签支付平台使用个人支付宝、微信收款二维码即可完成自动过发货收款直接进入个人账户规避第三方托管风险同时兼容MySQL与SQLServer数据库若想更换支付通道只需全局搜索源码中的免签支付地址并替换即可。压缩包共约2000个文件整体151.86MB以gif、jsp、class、jpg、xml、png、jar、java等为主涵盖页面模板、编译类、静态资源、配置与依赖库另含sql脚本、properties配置及详细安装说明便于快速部署。目前已有412人学习下载适合需要搭建或二次开发游戏支付系统的开发者参考。1. 从一笔订单说起JAVA游戏支付源码到底在解决什么问题玩家在游戏里点下充值按钮从选档位到钻石到账中间要穿过下单、签名、跳转收银台、异步回调、补单对账五道关卡。JAVA游戏支付源码要做的就是把这条链路封装成一套可复用的服务让接入方不用每次对接新渠道都重写一遍订单逻辑。而「免签支付平台」这个词指的是那些不需要游戏方自己去申请官方商户号、由平台侧统一提供收款通道并回调通知的聚合方案。这套东西适合两类人一是手里有游戏产品、想快速把充值跑通的中小团队二是做游戏联运或私服工具链、需要一套通用支付中台的开发者。它不解决「怎么拿到支付牌照」只解决「订单怎么建、回调怎么验、掉单怎么补」。2. 通用支付平台的订单模型与免签回调机制2.1 为什么订单表要拆成三张而不是一张很多新手拿到一份 JAVA游戏支付源码第一反应是找Order实体类然后发现里面字段多到看不懂。常见做法是把订单拆成三层业务订单游戏侧发起记录玩家、商品、金额、支付订单平台侧生成记录渠道、实际支付金额、手续费、回调流水记录每一次渠道通知的原始报文和验签结果。拆开的好处是当免签平台重复回调或者回调参数被篡改时你能在流水层留下证据而不是把业务订单改得面目全非。三张表的核心字段大致如下表名关键字段作用game_orderorder_no, player_id, product_id, amount, status游戏侧业务订单status 只关心待支付/已支付/已关闭pay_orderpay_no, game_order_no, channel_code, real_amount, notify_status平台侧支付单一个业务单可能对应多次支付尝试notify_logpay_no, raw_body, sign_verify_result, create_time每次回调原样落库用于对账和纠纷排查这样拆的另一个原因是免签平台的回调并不可靠。它可能因为网络抖动重发三次也可能因为你的接口超时先返回失败、过一会儿再补一次。如果只有一张订单表你很难区分「这是第一次通知」还是「这是第三次重试」。2.2 免签回调的验签与幂等处理免签支付平台通常给你一个商户密钥回调时带上sign参数。验签逻辑本身不复杂但坑在于参数顺序和空值处理。不同平台对参数排序的要求不一样有的按 ASCII 升序有的按文档里列出的固定顺序。下面是一段常见的验签代码public boolean verifySign(MapString, String params, String secretKey) { // 1. 剔除 sign 和空值参数 MapString, String sorted new TreeMap(); for (Map.EntryString, String e : params.entrySet()) { if (sign.equals(e.getKey()) || e.getValue() null || e.getValue().isEmpty()) { continue; } sorted.put(e.getKey(), e.getValue()); } // 2. 按 key 升序拼接 k1v1k2v2 StringBuilder sb new StringBuilder(); for (Map.EntryString, String e : sorted.entrySet()) { sb.append(e.getKey()).append().append(e.getValue()).append(); } sb.append(key).append(secretKey); // 3. MD5 后转大写与回调 sign 比对 String expected DigestUtils.md5Hex(sb.toString()).toUpperCase(); return expected.equals(params.get(sign)); }逻辑说明先过滤掉sign本身和空值再用TreeMap保证 key 的字典序最后拼上商户密钥做 MD5。参数说明secretKey是平台分配给你的密钥不要硬编码在代码里放配置中心或环境变量DigestUtils来自 commons-codec如果项目里没有用MessageDigest手写也行。验签通过之后幂等才是真正决定系统稳不稳的地方。常见做法是用pay_no做唯一索引在更新订单状态时加where status WAIT_PAY条件靠数据库的行锁来保证只有第一次回调能把状态改成已支付。如果update返回 0 行说明已经处理过直接返回成功给平台避免它继续重试。UPDATE pay_order SET notify_status SUCCESS, update_time NOW() WHERE pay_no #{payNo} AND notify_status WAIT_NOTIFY;这条 SQL 的notify_status条件就是幂等锁。返回影响行数为 1 才继续给玩家发货为 0 就直接记一条日志返回success。2.3 从零跑通一笔沙箱订单的最小步骤假设你拿到一份 JAVA游戏支付源码想先在本地把链路跑通可以按下面几步来建库建表把源码里sql目录下的脚本导入 MySQL确认三张核心表都存在。修改application.yml里的数据库连接和免签平台的商户号、密钥、回调地址。启动 Spring Boot 应用用 Postman 调/api/pay/create创建一个测试订单记下返回的payNo和收银台跳转链接。在浏览器打开跳转链接用免签平台提供的测试金额完成一笔支付。观察控制台日志确认回调接口被调用、验签通过、pay_order状态变为SUCCESS。手动往回调接口重放一次相同报文确认第二次返回成功但订单状态没有被重复修改。这六步走完你对这套 JAVA游戏支付源码的订单流转就有了体感后面改渠道、加风控都是在上面叠。3. 把免签平台接进游戏服务端配置、回调与补单3.1 渠道配置表怎么设计才能少改代码免签支付平台往往不止一家今天用 A 平台明天可能换 B 平台。如果每接一家就改一次PayService代码会越来越乱。常见做法是抽一张pay_channel表把渠道编码、网关地址、商户号、密钥、签名方式、回调路径都存进去代码里只根据channel_code去查配置。public PayChannel getChannel(String channelCode) { // 从缓存或数据库加载渠道配置 PayChannel channel channelCache.get(channelCode); if (channel null) { throw new BizException(渠道不存在: channelCode); } return channel; }参数说明channelCode是游戏侧下单时传入的渠道标识比如mianqian_a、mianqian_b。PayChannel里至少要有gateUrl、mchId、secretKey、signType四个字段。这样新增一个免签平台时只需要在表里插一条记录不用动 Java 代码。3.2 异步回调接口的四个必做动作回调接口是整套 JAVA游戏支付源码里最容易被攻击的地方。一个健壮的回调接口应该按顺序做四件事记录原始报文在方法入口就把request.getInputStream()读出来存进notify_log不要等验签失败再存否则你连对方发了什么都不知道。验签用上一节的verifySign方法校验签名失败直接返回fail不要给任何多余信息。幂等更新用带状态条件的update更新支付单返回 0 行就说明处理过直接返回success。通知游戏服发货更新成功后通过内部 RPC 或消息队列通知游戏服发货发货结果单独记录不要和支付状态混在一起。PostMapping(/notify/{channelCode}) public String notify(PathVariable String channelCode, HttpServletRequest request) { String rawBody readBody(request); // 1. 落流水 notifyLogService.save(channelCode, rawBody); // 2. 验签 MapString, String params parseParams(rawBody); if (!verifySign(params, getChannel(channelCode).getSecretKey())) { return fail; } // 3. 幂等更新 int rows payOrderService.markSuccess(params.get(payNo)); if (rows 0) { return success; } // 4. 通知发货 gameNotifyService.sendDelivery(params.get(payNo)); return success; }逻辑说明readBody要支持重复读取Spring 默认的HttpServletRequest流只能读一次可以用ContentCachingRequestWrapper包装。参数说明channelCode放在 URL 路径里方便不同渠道配不同的回调地址返回success是告诉免签平台不要再重试返回fail则会触发重试。3.3 掉单补单定时任务该查什么免签平台偶尔会漏回调或者你的服务在回调那一刻正好重启。补单任务就是兜底。常见做法是每 5 分钟跑一次查pay_order里notify_status WAIT_NOTIFY且创建时间超过 10 分钟的订单主动去免签平台查单接口确认支付状态。Scheduled(cron 0 */5 * * * ?) public void repairOrder() { ListPayOrder list payOrderMapper.selectWaitNotify(10); for (PayOrder order : list) { ChannelQueryResult result channelService.query(order.getPayNo()); if (result.isPaid()) { payOrderService.markSuccess(order.getPayNo()); gameNotifyService.sendDelivery(order.getPayNo()); } } }参数说明selectWaitNotify(10)里的 10 是分钟数表示只查创建超过 10 分钟还没收到回调的订单。ChannelQueryResult是查单接口的返回封装不同免签平台的查单字段不一样需要在渠道适配层做转换。注意补单任务也要走幂等更新否则和正常回调并发时会重复发货。4. 这套 JAVA游戏支付源码的避坑与排查清单4.1 回调验签失败但参数看起来都对现象本地用同样的参数算出来的 sign 和平台发来的 sign 不一致但参数肉眼检查没问题。原因最常见的是编码问题。免签平台可能用 UTF-8 对参数值编码后再参与签名而你的代码直接用了原始字符串。另一种是参数里有号HTTP 传输时被解码成空格。解决在验签前统一做一次URLDecoder.decode(value, UTF-8)并且确认拼接时用的是解码后的值。如果平台文档要求编码后再签名就按文档来不要凭感觉。4.2 订单状态更新成功但玩家没收到钻石现象pay_order已经是SUCCESS但游戏服里玩家余额没变。原因回调接口里「更新支付单」和「通知发货」是两步如果发货那一步抛异常被吞掉支付单已经改了货却没发。解决把发货动作做成可重试的。常见做法是发货前先往delivery_task表插一条待处理记录发货成功再改状态。这样即使发货接口超时补单任务也能根据delivery_task重试而不是依赖支付单状态。4.3 同一笔订单被发了两次货现象玩家充值一笔钻石到账两次。原因回调接口的幂等只做到了支付单层面但发货接口本身没有幂等。当免签平台重试回调、或者补单任务和正常回调并发时两次都通过了支付单的幂等检查因为第一次可能还没提交事务然后都去调了发货。解决发货接口也要用pay_no做唯一约束。可以在delivery_task表上给pay_no加唯一索引插入失败就说明已经发过直接返回成功。4.4 本地测试正常上线后回调收不到现象本地用内网穿透测试一切正常部署到服务器后免签平台一直提示回调失败。原因服务器防火墙没放行回调端口或者 Nginx 配置里把POST请求体大小限制得太小回调报文被截断。解决先看 Nginx 的access.log有没有收到请求。如果没有检查安全组和防火墙如果有但报 413调大client_max_body_size。另外确认回调地址用的是公网域名不要用 IP 加端口有些免签平台会校验域名格式。4.5 补单任务把未支付的订单也补了现象玩家还没付款补单任务却把订单改成了已支付。原因查单接口的返回解析写错了把「订单不存在」或「待支付」也当成了已支付。解决查单结果一定要用明确的枚举值判断不要用result.getStatus() ! null这种模糊条件。建议在渠道适配层把不同平台的返回统一转成PAID、WAIT_PAY、CLOSED三个状态补单任务只认PAID。5. 进阶用对账文件兜住最后一道底前面讲的回调、补单、幂等都是围绕单笔订单在转。但真正让财务放心的是每天跑一次对账。免签支付平台一般会在次日提供前一天的账单文件格式可能是 CSV 或固定长度文本。我的习惯是写一个独立的对账任务把平台账单和本地pay_order做全量比对输出三类差异本地有平台无、平台有本地无、金额不一致。public void reconcile(LocalDate date) { ListChannelBill bills channelService.downloadBill(date); MapString, ChannelBill billMap bills.stream() .collect(Collectors.toMap(ChannelBill::getPayNo, b - b)); ListPayOrder localOrders payOrderMapper.selectByDate(date); for (PayOrder order : localOrders) { ChannelBill bill billMap.remove(order.getPayNo()); if (bill null) { log.warn(本地有平台无: {}, order.getPayNo()); } else if (bill.getAmount().compareTo(order.getRealAmount()) ! 0) { log.warn(金额不一致: {} 本地{} 平台{}, order.getPayNo(), order.getRealAmount(), bill.getAmount()); } } // 剩下的就是平台有本地无 billMap.keySet().forEach(payNo - log.warn(平台有本地无: {}, payNo)); }逻辑说明先把平台账单按payNo建索引然后遍历本地订单去匹配匹配上的从 map 里移除最后 map 里剩下的就是平台有本地无。参数说明date是对账日期一般跑 T-1downloadBill需要根据免签平台的接口实现有的平台是 HTTP 下载有的是 SFTP。差异日志不要只打日志最好落一张reconcile_diff表方便财务第二天跟进。对账这件事平时看不出价值一旦出现掉单或者金额纠纷它就是唯一的后悔药。我自己的习惯是每接一家新的免签平台第一周每天手动跑一次对账确认差异为零之后再改成定时任务。另外对账文件下载下来之后不要直接覆盖按日期存一份原始文件至少保留 30 天。这样即使后面发现对账逻辑写错了还能用原始文件重新跑一遍。希望帮到你。本文还有配套的精品资源点击获取
返回列表