免签支付回调链路与Java支付平台实战:从码商上报到幂等发货

发布时间:2026/9/14 2:40:23
免签支付回调链路与Java支付平台实战:从码商上报到幂等发货 简介面向需要为游戏产品快速接入个人免签支付的开发者和站长这份通用游戏支付平台程序提供了完整可运行的 Java 支付系统源码。系统已对接成熟免签支付渠道使用个人支付宝/微信收款码即可自动完成回调发货兼容 MySQL 与 SQL Server 数据库并支持全局搜索修改支付接口地址便于替换为自有免签系统或二次开发。压缩包共 2533 个文件、约 149MB以 JSP/Java 源码、class/jar 编译文件、SQL 数据库脚本、前端 CSS/JS 及图片为主附带 exe/dll 等运行组件可直接部署使用。已有 1466 人学习/下载适合具备一定 Java Web 基础的开发者参考借助完整目录与后台入口可对照研究支付回调、订单处理、平台配置等关键模块。1. 通用游戏支付平台里的免签支付先定回调链路再谈源码下载 JAVA 游戏支付源码标题写着“通用游戏支付平台程序–已对接正在运营的免签支付”第一眼容易把重点放在收银台、二维码和订单列表。真正决定这套系统能不能起来的是回调链路用户扫码付款钱不经过官方商户通道而是先落到个人收款码码商端的监控程序把到账消息推回平台平台验完来源再把支付结果转给游戏服。整个过程没有官方接口背书每一跳都可能断。“正在运营”只说明打包者那套环境跑通过不说明解压到本机就能直接跑。接这种源码的正确顺序是先弄清码商到平台怎么上报、平台到游戏服怎么回调最后才是界面和运营后台。下文按这个顺序拆解。2. 免签支付的技术链路个人收款码如何变成可对账的订单在写任何 Java 代码之前先把链路图画在脑子里。免签支付系统实际上是三方会话玩家在游戏里下单平台生成一笔个人收款码订单玩家扫码转账后钱进码商的微信或支付宝码商手机侧的程序识别到账记录把订单号与金额上报给支付平台平台核对后回调游戏服发货。最容易忽略的事实是整条链路上唯一能证明“钱到账”的凭证是码商上报的那个 HTTP 请求而不是银行流水。所以免签支付平台的核心工程能力不是二维码渲染得多快而是上报采集、签名校验、状态机推进这三件事做到可靠。2.1 上报采集钱到账的消息从哪来个人收款码没有像商户号那样的官方回调接口“到账消息”只能靠边缘端采集。常见做法有两种。第一种是监听通知栏码商手机装一个客户端读取微信或支付宝的通知栏文案用正则把“收款 XX 元”抠出来再通过 HTTP 上报。优点是实现门槛低缺点是手机厂商对通知栏权限收得越来越紧进程也容易被系统杀掉。第二种是账单轮询客户端定时读取手机上的账单页或云账单把新增到账报送平台。延迟比通知栏高但不受通知栏权限影响。免签支付通道的稳定性九成取决于这里的采集方式。两种方式最终上报的数据相同金额、付款备注、通道流水号、上报时间。2.2 三种上报模式与掉单率对比码商通道与服务端之间的对接模式决定支付平台要写哪种适配器也直接决定掉单率。上报模式上报方典型延迟掉单率平台对接方式监控端直推码商手机应用1-3秒较高App被杀/网络抖动一个统一回调入口 签名平台轮询拉取通道方的报表接口10秒-2分钟一般每N秒扫一次幂等取单服务商直转聚合服务商1-5秒较低服务商回调到指定URL选型依据是单量。日单量几百时监控端直推就够掉单后人工补单也能接受日单量上万监控端直推的掉单率会被放大很多必须换服务商直转模式由服务商替你做采集和重推。多数仍在运营的免签支付平台会在通道表里同时挂这两种模式用权重做流量分配。2.3 平台接收码商上报的最小接口平台侧要接收通道上报先得有一个按通道区分的入口。为每个通道建一条记录存密钥、状态、所属分组然后按 channelCode 路由这样不同通道的加解密方式可以隔离。RestController RequestMapping(/callback) public class ChannelCallbackController { PostMapping(/{channelCode}) public ResultVoid receive(PathVariable String channelCode, RequestBody CallbackRequest req) { ChannelConfig channel channelService.loadByCode(channelCode); if (channel null || !channel.isEnabled()) { return Result.fail(channel unavailable); } // 上报请求来自码商手机来源IP不固定必须靠签名识别身份 if (!signService.verify(req.getSign(), channel.getSecret(), req.buildParamMap())) { log.warn(sign mismatch, channel{}, order{}, channelCode, req.getMerchantOrderNo()); return Result.fail(bad sign); } // 幂等更新订单状态避免同一笔到账被上报两次 return payOrderService.markPaidFromChannel(channel.getId(), req); } }这里几个参数不能省channelCode 用来区分密钥merchantOrderNo 是平台发给玩家的内部单号amount 用于和订单金额比对channelTradeNo 是通道侧流水号留作对账。收到上报后不要立刻回调游戏服先落库把状态从“待支付”改成“已支付”再走通知逻辑。2.4 从到账到发货的四次状态迁移订单状态每变化一次都是一次不可逆推进。状态码含义触发方典型动作0待支付游戏服下单生成二维码给玩家1已支付码商上报且验签通过记录通道流水号准备通知游戏服2已通知平台补单任务回调游戏服并记录响应3金额不符/异常定时任务挂起人工核查很多跑出问题的平台都是把状态 1 和状态 2 合并成一步码商上报一到就立刻回调游戏服回调失败就只剩一个“已支付但没发货”的悬空单。把“支付成功”和“通知成功”分开落库中间才有补单的抓手。3. 把 Java 通用支付平台本地跑起来建库、配置、模拟回调3.1 解压源码后先看四个文件拿到这种压缩包最忌讳直接mvn spring-boot:run。先花五分钟定位四个文件pom.xml 看 Spring Boot 版本和依赖方向application.yml 看数据源和 Redis 配置sql 目录看建表脚本是否完整最后看是否开启了自动建表。很多源码默认 ddl-autoupdate会让表结构变得完全不可控。常见技术栈是 Spring Boot 2.x MyBatis-Plus MySQL Redis。JDK 版本与 Spring Boot 版本强相关但这类支付源码对 JDK 不敏感对 Redis 版本反而敏感建议直接上 Redis 6。3.2 支付订单表和回调日志表怎么建建表脚本是源码包里最值得先改的部分。两张表必须存在字段建议按下述方式设计。CREATE TABLE pay_order ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT 平台内部订单号, merchant_order_no VARCHAR(64) NOT NULL COMMENT 游戏服下单时的业务订单号, amount DECIMAL(10,2) NOT NULL COMMENT 订单应付金额, channel_code VARCHAR(32) NOT NULL COMMENT 支付通道标识, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已通知 3异常, channel_trade_no VARCHAR(64) DEFAULT NULL COMMENT 通道流水号, notify_url VARCHAR(255) NOT NULL COMMENT 游戏服回调地址, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_merchant_order_no (merchant_order_no), KEY idx_status_update_time (status, update_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付订单表;两个细节值得注意merchant_order_no 加唯一键防止游戏服重复下单status 和 update_time 建联合索引补单任务查“超时未通知”订单时不用全表扫。CREATE TABLE pay_callback_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT 平台内部订单号, notify_url VARCHAR(255) NOT NULL COMMENT 本次回调地址, request_body TEXT COMMENT 回调请求报文, response_body VARCHAR(255) COMMENT 游戏服返回内容, http_status INT DEFAULT NULL COMMENT 游戏服HTTP状态码, retry_count INT NOT NULL DEFAULT 0 COMMENT 已重试次数, next_retry_time DATETIME DEFAULT NULL COMMENT 下次重试时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1成功 2失败, KEY idx_next_retry (status, next_retry_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付回调日志表;回调日志表是排查掉单的第一现场。request_body 存的是平台发给游戏服的原始报文response_body 存游戏服返回结果这两个字段在排障时比业务日志可靠得多。3.3 application.yml 的必调参数以 Spring Boot 为例重点参数集中在 pay 这个自定义前缀下server: port: 8080 servlet: context-path: /pay spring: datasource: url: jdbc:mysql://127.0.0.1:3306/pay_platform?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: pay password: pay2026 redis: host: 127.0.0.1 port: 6379 pay: notify-secret: 9f8a7b6c5d4e3f2a1b0c notify-whitelist: 127.0.0.1, 192.168.1.0/24 order-timeout-minutes: 30参数作用常见的坑context-path统一接口前缀方便后面挂 Nginx 转发忘了配所有回调地址错位pay.notify-secret平台回调游戏服时的签名密钥多套游戏共用一个 secret泄漏后全部可伪造回调pay.notify-whitelist接收下单和回调的游戏服 IP 白名单只写域名不写 IP游戏服切机后回调全拒order-timeout-minutes超过该时间的待支付订单自动关单设太短玩家付款晚一步就被关单白名单策略建议精确到网段不要写 0.0.0.0/0。免签支付平台的游戏服基本固定白名单写死是成本最低的防护。3.4 用 curl 模拟一次完整回调数据库和配置就绪后不启动前端直接用 curl 走通两端。先模拟游戏服下单curl -X POST http://127.0.0.1:8080/pay/api/order \ -H Content-Type: application/json \ -d { gameOrderNo: G20250101001, amount: 68.00, channel: pay_wx, notifyUrl: http://127.0.0.1:9001/pay/notify, roleId: 10001 }再模拟码商把到账上报给平台curl -X POST http://127.0.0.1:8080/pay/callback/pay_wx \ -H Content-Type: application/json \ -d { merchantOrderNo: G20250101001, amount: 68.00, channelTradeNo: 420000123320250101, sign: 自行按通道密钥生成 }最后查库SELECT order_no, amount, status FROM pay_order WHERE merchant_order_no G20250101001;两次 curl 分别对应状态 0 到 1 的推进。第二次请求返回 bad sign先把通道表里的 secret 和 sign 的生成方式对齐再排查别的。本地验证时把 notifyUrl 指向一个临时 HTTP Server能打印请求体即可不用真的发游戏道具。4. 游戏服接入支付平台下单、验签、幂等发货4.1 下单接口把金额与回调地址绑定游戏服侧第一件要做的事是拉起支付订单。下单接口由支付平台提供游戏服带上订单号、金额、渠道和回调地址调用。PostMapping(/api/order) public ResultCreateOrderResponse createOrder(RequestBody CreateOrderRequest req) { // 游戏服与支付平台之间的密钥校验防止外部用户构造请求 if (!signService.verifyRequestSign(req)) { return Result.fail(invalid sign); } if (orderMapper.existsByMerchantOrderNo(req.getGameOrderNo())) { return Result.fail(duplicated game order); } PayOrder order new PayOrder(); order.setOrderNo(generateOrderNo()); order.setMerchantOrderNo(req.getGameOrderNo()); order.setAmount(req.getAmount()); order.setChannelCode(req.getChannel()); order.setNotifyUrl(req.getNotifyUrl()); order.setStatus(ORDER_STATUS_UNPAID); orderMapper.insert(order); return Result.ok(CreateOrderResponse.of(order.getOrderNo())); }把请求签名校验放在最前面是因为免签支付平台没有用户会话概念任何知道接口地址的人都能调下单接口。下单请求里的 amount 不能直接信任最好在本机按商品配置重新计算或至少在下单前用一份商品价格表做二次校验。请求参数类型必填约束说明gameOrderNostring是最长64位游戏服订单号不能用自增idamountstring是0.01-50000金额用字符串传避免精度问题channelstring是pay_wx / pay_alipay平台校验通道是否启用notifyUrlstring是http/https建议限制到后台配置的域名白名单4.2 回调验签先排序、再拼接、再比对平台向游戏服发起回调时游戏服第一件事是验签。靠谱的验签套路是参数按 ASCII 排序拼成 keyvaluekeyvalue末尾拼上密钥用 HMAC-SHA256 取摘要再比对。public boolean verifyNotify(String secret, String sign, MapString, String params) { if (secret null || sign null || params null) { return false; } TreeMapString, String sorted new TreeMap(params); String raw sorted.entrySet().stream() .filter(e - !sign.equals(e.getKey())) .filter(e - e.getValue() ! null !e.getValue().isEmpty()) .map(e - e.getKey() e.getValue()) .collect(Collectors.joining()); String expected hmacSha256Hex(raw, secret); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), sign.toLowerCase().getBytes(StandardCharsets.UTF_8)); }三个细节用 TreeMap 保证字典序稳定用 HMAC-SHA256 而不是裸 MD5用 MessageDigest.isEqual 做恒定时间比较避免时序侧信道。验签通过后从 pay_order 里查这笔订单比对金额是否等于订单应付金额再检查 channelTradeNo 是否第一次出现。4.3 幂等发货一个数据库原子更新挡住重复回调免签支付的补单任务会重放回调网络抖动也会让同一笔回调到达两次幂等是接入的重中之重。最常见的错误写法是先查 status再 set 成已支付两条并发请求同时读到 status0都去发货玩家拿到双倍道具。正确做法是用条件更新把状态迁移变成原子操作int rows orderMapper.updateStateAtomic( orderNo, ORDER_STATUS_UNPAID, // 当前状态 ORDER_STATUS_PAID, // 目标状态 channelTradeNo); if (rows ! 1) { // 状态已不是待支付说明这笔处理过忽略重复回调 return Result.fail(order already processed); } // 只有 rows 1 的线程能走到这里逻辑上保证只发货一次 gameNotifyService.send(orderNo);对应 SQLUPDATE pay_order SET status #{toStatus}, channel_trade_no #{channelTradeNo}, update_time NOW() WHERE order_no #{orderNo} AND status #{fromStatus};用受影响行数判断结果天然避开“先查后改”的竞态。回调处理前把 order_no 和 channel_trade_no 记进日志表后面才能回答“这笔钱到底发货没有”。下面这张决策表适合直接写进代码注释订单状态重复回调处理是否发货0 待支付更新为已支付是1 已支付忽略当前回调否走补单2 已通知返回成功否3 异常记录后人工介入否5. 掉单抢救三级重试、回调回放、通道自动摘除5.1 三级重试先快后慢再人工码商上报到达平台后回调游戏服失败的重试节奏第一级建议 1 分钟 ×3 次第二级 5 分钟 ×3 次第三级 30 分钟 ×4 次。调度任务用一条 SQL 捞待重试队列SELECT order_no, notify_url, retry_count, next_retry_time FROM pay_callback_log WHERE status 0 AND next_retry_time NOW() AND retry_count 10 ORDER BY retry_count ASC, id ASC LIMIT 200;重试成功后把响应写入 response_body状态置 1失败则 retry_count 加 1按阶梯计算 next_retry_time。超过 10 次订单状态置为 3触发告警。5.2 手动回调回放与幂等保护补单逻辑再完善也抵不住游戏服半夜维护。预留一个管理端回放接口POST /admin/replay?orderNoP202502010001forcetrue实现时重新走正常回调逻辑而不是直接把订单状态改成“已通知”否则会出现库里有状态、游戏里没货的脏数据。5.3 通道健康度自动摘除免签通道回调成功率长期低于 99%问题往往不在网络而在采集端失效。用两个 Redis 计数器按小时滚动Redis Key 模式用途ch:hit:{channelCode}:{yyyyMMddHH}通道小时到账回调总数ch:fail:{channelCode}:{yyyyMMddHH}通道小时失败数每分钟计算一次失败率连续三分钟超过 2% 就把通道置为 disabled新订单创建时自动跳过该通道等人工检查采集端后再启用。5.4 上线前自检上线前用三条命令做快速体检# 订单状态分布正常平台是0和2占绝大多数 mysql -upay -ppay pay_platform -e SELECT status, COUNT(*) FROM pay_order GROUP BY status; # 近一小时回调日志失败比例 mysql -upay -ppay pay_platform -e SELECT status, COUNT(*) FROM pay_callback_log WHERE create_time NOW() - INTERVAL 1 HOUR GROUP BY status; # 验证游戏服回调端口 nc -zvw3 192.168.1.20 9001把这三条命令丢进 cron 每分钟跑一次回调失败率越过阈值立刻告警免签支付平台的掉单问题才算真正收口。本文还有配套的精品资源点击获取