简介:这是一套基于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_order | order_no, player_id, product_id, amount, status | 游戏侧业务订单,status 只关心待支付/已支付/已关闭 |
| pay_order | pay_no, game_order_no, channel_code, real_amount, notify_status | 平台侧支付单,一个业务单可能对应多次支付尝试 |
| notify_log | pay_no, raw_body, sign_verify_result, create_time | 每次回调原样落库,用于对账和纠纷排查 |
这样拆的另一个原因是免签平台的回调并不可靠。它可能因为网络抖动重发三次,也可能因为你的接口超时先返回失败、过一会儿再补一次。如果只有一张订单表,你很难区分「这是第一次通知」还是「这是第三次重试」。
2.2 免签回调的验签与幂等处理
免签支付平台通常给你一个商户密钥,回调时带上sign参数。验签逻辑本身不复杂,但坑在于参数顺序和空值处理。不同平台对参数排序的要求不一样,有的按 ASCII 升序,有的按文档里列出的固定顺序。下面是一段常见的验签代码:
public boolean verifySign(Map<String, String> params, String secretKey) { // 1. 剔除 sign 和空值参数 Map<String, String> sorted = new TreeMap<>(); for (Map.Entry<String, String> e : params.entrySet()) { if ("sign".equals(e.getKey()) || e.getValue() == null || e.getValue().isEmpty()) { continue; } sorted.put(e.getKey(), e.getValue()); } // 2. 按 key 升序拼接 k1=v1&k2=v2 StringBuilder sb = new StringBuilder(); for (Map.Entry<String, 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. 验签 Map<String, 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() { List<PayOrder> 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) { List<ChannelBill> bills = channelService.downloadBill(date); Map<String, ChannelBill> billMap = bills.stream() .collect(Collectors.toMap(ChannelBill::getPayNo, b -> b)); List<PayOrder> 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-1;downloadBill需要根据免签平台的接口实现,有的平台是 HTTP 下载,有的是 SFTP。差异日志不要只打日志,最好落一张reconcile_diff表,方便财务第二天跟进。
对账这件事,平时看不出价值,一旦出现掉单或者金额纠纷,它就是唯一的后悔药。我自己的习惯是,每接一家新的免签平台,第一周每天手动跑一次对账,确认差异为零之后再改成定时任务。另外,对账文件下载下来之后不要直接覆盖,按日期存一份原始文件,至少保留 30 天。这样即使后面发现对账逻辑写错了,还能用原始文件重新跑一遍。希望帮到你。
本文还有配套的精品资源,点击获取