简介:本资源为游戏支付平台与第三方支付网关的完整源码包,面向游戏运营方、支付系统开发者及需要搭建充值通道的技术团队,可解决游戏内充值、订单网关对接与多渠道支付集成等核心问题。包内共约2000个文件,涵盖315个jsp页面、301个class编译文件、46个java源码、65个jar依赖包,以及96个xml配置、26个properties参数文件和7个sql脚本,另含大量gif、jpg、png等界面素材与css、js前端资源,压缩包整体约151.44MB,目录结构完整,便于二次开发与部署参考。目前已有233人学习下载,适合具备Java Web基础、希望研究支付网关实现逻辑或搭建游戏充值平台的开发者。资源包含支付接口调用、订单处理、数据库脚本及前端交互页面等模块,可作为支付系统架构学习与功能扩展的实践参考。
1. 游戏支付平台源码这套东西,到底在解决什么问题
如果你做过游戏联运或者私服充值,大概率遇到过这种局面:玩家点充值,钱扣了,钻石没到账;或者渠道方对账时发现订单号对不上,一笔一笔翻日志翻到凌晨。游戏支付平台源码、游戏充值平台、第3方支付平台源码、游戏网关支付接口,这四个词其实描述的是同一件事的不同切面——一套把「玩家付款」和「游戏发货」安全串起来的中间层系统。
它要解决的核心问题有三个:第一,屏蔽下游渠道差异,让游戏服只对接一个统一接口;第二,保证订单状态最终一致,钱和货不能各说各话;第三,把第三方支付的异步回调、签名验签、重试补偿这些脏活收拢到一处。适合谁看?正在自研充值系统的后端、需要接多个支付通道的联运平台、以及想理解支付网关设计思路的工程师。下面按「先立住模型、再动手跑通、最后讲坑」的顺序拆开讲。
2. 支付网关的核心模型:订单、回调与幂等怎么设计
2.1 一张订单从创建到发货的完整状态流转
游戏支付平台源码里最容易被低估的就是状态机。很多翻车案例不是代码写错,而是状态定义含糊。我一般会把订单状态收敛成这几个:CREATED(已创建未支付)、PAYING(已跳转支付通道)、PAID(通道确认收款)、DELIVERED(游戏服已发货)、FAILED(支付失败或超时关闭)、REFUNDED(已退款)。
关键在于PAID和DELIVERED必须分开。第三方支付回调只代表钱到了,不代表货发了。如果回调里直接发货,一旦发货接口超时或游戏服宕机,这笔订单就永远卡在「钱到了货没到」的状态,玩家投诉你还没法自证。正确做法是回调只负责把订单推进到PAID,再由一个独立的发货任务去消费PAID订单,发货成功才置DELIVERED。
状态流转必须单向,禁止从DELIVERED回退到PAID。每次状态变更写一条流水记录,包含旧状态、新状态、操作来源(回调/主动查询/人工)、时间戳。这套流水在后期对账和排查时就是你的后悔药。
2.2 幂等键怎么选,为什么不能用订单号
幂等是支付系统的命门。第三方支付的回调会重发,网络抖动、通道方重试策略、你自己服务重启,都可能导致同一笔回调进来多次。如果幂等没做好,玩家充 6 元到账 60 元的事故就是这么来的。
幂等键的选择有个血泪经验:不要只用商户订单号。因为同一个订单号可能对应「支付成功回调」和「退款回调」两种语义,混在一起会互相干扰。我一般用业务类型 + 商户订单号 + 通道流水号组合成唯一键,落一张idempotent_record表,加唯一索引。处理前先插入,插入冲突就直接返回成功,不重复执行业务逻辑。
CREATE TABLE idempotent_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型: PAY_NOTIFY/REFUND_NOTIFY', merchant_order_no VARCHAR(64) NOT NULL COMMENT '商户订单号', channel_trade_no VARCHAR(64) NOT NULL COMMENT '通道流水号', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_idem (biz_type, merchant_order_no, channel_trade_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段建表语句的关键在UNIQUE KEY。插入时用INSERT IGNORE或捕获唯一键冲突异常,冲突即代表这笔回调已处理过。biz_type区分支付和退款,避免两类回调撞车。channel_trade_no是通道方给的流水号,同一订单多次支付尝试时它不同,能进一步降低误判。
2.3 网关支付接口的统一抽象长什么样
游戏网关支付接口的价值在于「一次对接,多处复用」。下游游戏服不应该关心你接的是哪家第三方支付,它只调你的统一接口。我一般定义三个核心接口:创建支付单、查询订单、接收异步通知。
创建支付单的入参至少包含:商户订单号、金额(分)、商品描述、回调地址、客户端 IP、签名。返回支付跳转 URL 或二维码内容。查询订单用于主动补偿——当回调迟迟不来时,定时任务去通道方查真实状态。异步通知则是通道方回调你的入口,必须验签后再处理。
统一抽象的好处是,新增一个第三方支付通道时,你只需要实现一个适配器,把通道的请求参数、签名算法、回调格式翻译成内部标准模型,业务层代码一行不用改。这也是第3方支付平台源码里最值得抄的部分——不是抄它的具体通道实现,而是抄它的适配器分层。
3. 从零跑通一套最小可用的充值链路
3.1 环境准备与依赖选型
跑通最小链路不需要多复杂的环境。一台能跑 MySQL 和 Redis 的机器,一个 Python 3.10+ 或 PHP 8.x 运行时即可。选 Python 是因为异步生态成熟,处理回调并发方便;选 PHP 是因为大量现成的游戏支付平台源码是 PHP 写的,改起来快。这里以 Python + FastAPI 为例,MySQL 存订单,Redis 做分布式锁和幂等缓存。
依赖清单:fastapi、uvicorn、sqlalchemy、pymysql、redis、cryptography(验签用)。安装命令一行搞定:
pip install fastapi uvicorn sqlalchemy pymysql redis cryptography选型理由:FastAPI 自带请求校验和异步支持,写回调接口很顺手;SQLAlchemy 方便做状态流转的事务控制;Redis 的SET NX是实现幂等锁最轻量的方案。如果你的量级不大,Redis 都可以先省掉,直接用数据库唯一索引兜底。
3.2 创建支付单与签名生成
创建支付单是整条链路的起点。核心逻辑是:校验参数、生成商户订单号、落库、调用通道适配器拿支付 URL、返回给客户端。
import hashlib import time import uuid from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI() class CreateOrderReq(BaseModel): game_id: str player_id: str amount: int # 单位:分 channel: str # 通道标识,如 alipay_mock notify_url: str def gen_sign(params: dict, secret: str) -> str: # 按 key 字典序拼接,末尾追加密钥,做 MD5 raw = "&".join(f"{k}={params[k]}" for k in sorted(params) if params[k] != "") raw += f"&key={secret}" return hashlib.md5(raw.encode("utf-8")).hexdigest().upper() @app.post("/api/pay/create") def create_order(req: CreateOrderReq): if req.amount <= 0: raise HTTPException(400, "金额必须大于0") merchant_order_no = f"G{int(time.time())}{uuid.uuid4().hex[:8]}" # 落库逻辑省略,状态置 CREATED sign_params = { "merchant_order_no": merchant_order_no, "amount": req.amount, "channel": req.channel, } sign = gen_sign(sign_params, "your_secret_key") # 调用通道适配器,返回支付跳转地址 pay_url = f"https://mock-pay.example.com/pay?order={merchant_order_no}&sign={sign}" return {"code": 0, "merchant_order_no": merchant_order_no, "pay_url": pay_url}逻辑说明:gen_sign是签名核心,按 key 字典序拼接再追加密钥,这是绝大多数第三方支付的通用规则。merchant_order_no用时间戳加随机串,保证全局唯一且可读。amount用分做单位,避免浮点精度问题——这是支付系统的铁律,用元做单位迟早出 0.01 的差错。
参数说明:game_id和player_id用于发货时定位角色;channel决定走哪个适配器;notify_url是通道回调你的地址,必须是公网可达的 HTTPS 地址,否则回调永远收不到。
3.3 异步回调的验签与状态推进
回调接口是攻击面最大的地方。任何人拿到你的回调地址都能伪造请求,所以验签是第一步,验签不过直接拒绝。
@app.post("/api/pay/notify/{channel}") async def pay_notify(channel: str, request: dict): # 1. 验签 received_sign = request.pop("sign", "") expected_sign = gen_sign(request, "your_secret_key") if received_sign != expected_sign: return {"code": "FAIL", "msg": "sign error"} # 2. 幂等检查 idem_key = f"notify:{request['merchant_order_no']}:{request['channel_trade_no']}" if not redis.set(idem_key, "1", nx=True, ex=86400): return {"code": "SUCCESS", "msg": "duplicate"} # 3. 状态推进:CREATED/PAYING -> PAID # update orders set status='PAID' where merchant_order_no=? and status in ('CREATED','PAYING') # 4. 返回通道要求的成功标识 return {"code": "SUCCESS", "msg": "ok"}逻辑说明:先验签再处理,顺序不能反。幂等用 RedisSET NX加 24 小时过期,重复回调直接返回成功——注意这里必须返回成功,否则通道方会一直重试。状态推进用带条件的 UPDATE,where status in (...)保证不会把已发货订单改回PAID。
参数说明:channel_trade_no是通道流水号,参与幂等键;ex=86400是过期时间,设太长占内存,设太短可能覆盖不了通道的重试周期,一天是经验值。返回体格式要严格按通道文档来,有的要返回success字符串,有的要返回 JSON,写错通道方会判定失败并持续重试。
3.4 主动查询补偿与发货任务
回调不是 100% 可靠,通道方可能因为网络问题没发出来,或者你的服务当时在重启。所以必须有一个定时任务去主动查询「长时间处于 PAYING 状态」的订单。
# 伪代码:每 30 秒执行一次 def compensate_paying_orders(): orders = query_orders(status="PAYING", created_before=now() - 120) for order in orders: real_status = channel_adapter[order.channel].query(order.merchant_order_no) if real_status == "PAID": mark_paid(order) elif real_status == "CLOSED": mark_failed(order)逻辑说明:只查创建超过 120 秒还是PAYING的订单,避免刚下单就去查造成无效请求。查到PAID就补一次状态推进,后续发货任务自然会消费到。查到CLOSED就关闭订单,释放库存或优惠名额。
发货任务独立于回调,只消费PAID状态订单。发货前先查一次幂等(防止重复发货),调用游戏服的发货接口,成功则置DELIVERED,失败则记录错误并重试。重试要有上限,超过次数转人工处理,别让它无限循环。
4. 避坑指南:支付对接里那些让人半夜爬起来的问题
4.1 回调验签通过但金额对不上
现象:验签成功,订单状态也推进了,但对账时发现通道回调和本地订单金额不一致。原因通常是回调参数里金额字段被篡改,或者你验签时把金额字段排除了。解决:验签必须覆盖所有参与签名的字段,尤其是金额和订单号,一个都不能漏。验签通过后,再单独比对回调金额和本地订单金额,不一致直接告警并拒绝处理。
4.2 重复发货导致玩家刷钻石
现象:玩家反馈同一笔充值到账两次。原因多半是发货任务没有做幂等,或者回调重试时状态判断有并发漏洞。解决:发货前用merchant_order_no做唯一约束插入发货记录,插入失败即代表已发过。同时状态推进用数据库行锁或乐观锁版本号,避免两个线程同时读到PAID都去发货。
4.3 通道回调地址配错导致永远收不到通知
现象:订单一直卡在PAYING,主动查询才发现早就支付成功了。原因:notify_url填了内网地址、带了端口、或者用了 HTTP 被通道方拒绝。解决:回调地址必须是公网可达的 HTTPS,不带多余参数,路径里不要有会被 URL 编码的特殊字符。上线前用通道方提供的测试工具打一次回调,确认能通。
4.4 签名算法大小写和编码踩坑
现象:本地算的签名和通道方算的对不上,反复检查逻辑没问题。原因:MD5 输出大小写不一致、中文参数编码用了 GBK 而通道要求 UTF-8、或者空值参数该不该参与签名没对齐。解决:严格按通道文档来,文档说大写就大写,说 UTF-8 就 UTF-8。空值参数的处理规则最容易忽略,有的通道要求排除空值,有的要求保留,必须逐字对照文档。
4.5 订单超时关闭与支付成功并发
现象:用户下单后没付,系统 30 分钟自动关单,结果关单瞬间用户付款成功了,钱收了货没发。原因:关单和支付回调并发,关单逻辑没检查最新状态。解决:关单用带条件的 UPDATE,where status='CREATED',如果此时状态已被回调改成PAID,关单影响行数为 0,自然失败。同时回调侧对已关闭订单要能重新打开或走退款流程,不能直接丢弃。
5. 进阶技巧:用对账文件兜住最后一道防线
前面讲的都是实时链路,但实时链路再稳,也可能因为极端情况丢单。真正让支付系统睡得着觉的,是每天的对账。通道方一般会在次日凌晨提供前一天的账单文件,你要做的就是下载、解析、和本地订单逐笔比对。
对账的核心逻辑分三类:本地有、通道有,金额一致,正常;本地有、通道无,可能是通道漏单,需要发起查询确认;通道有、本地无,这是最危险的,说明你漏记了订单,必须人工介入补单或退款。我一般会写一个对账任务,把差异结果落一张reconcile_diff表,每天早上看一眼有没有新增差异。
def reconcile(local_orders, channel_bills): local_map = {o.merchant_order_no: o for o in local_orders} channel_map = {b.merchant_order_no: b for b in channel_bills} diffs = [] for no, order in local_map.items(): bill = channel_map.get(no) if not bill: diffs.append(("LOCAL_ONLY", no, order.amount)) elif bill.amount != order.amount: diffs.append(("AMOUNT_MISMATCH", no, order.amount, bill.amount)) for no, bill in channel_map.items(): if no not in local_map: diffs.append(("CHANNEL_ONLY", no, bill.amount)) return diffs这段代码不复杂,但价值极高。LOCAL_ONLY通常是用户下单未支付,正常;AMOUNT_MISMATCH要立刻查,可能是签名或金额单位问题;CHANNEL_ONLY必须当天处理,否则就是资金风险。对账文件格式各家不同,有的是 CSV,有的是定长文本,解析时注意编码和分隔符,别用错。
还有一个习惯我坚持了很多年:所有和钱相关的操作,日志里必须带merchant_order_no和channel_trade_no,且日志级别不低于 INFO。出问题时,你能用订单号在几分钟内串起整条链路,而不是在几个 G 的日志里大海捞针。支付系统没有玄学,只有没打够的日志和没测到的边界。希望帮到你。
本文还有配套的精品资源,点击获取