简介一份为游戏充值场景深度定制的支付系统二开源码整合话费、电网、抖音、快手、紫水晶等支付通道插件集统一收银、订单管理与云端部署能力于一体操作界面交互流畅适合需要快速搭建或扩展支付渠道的游戏开发者、平台运营方及技术学习者。资源包共2003个文件、37.33MB前端以JavaScript1411个、HTML210个、CSS128个为主覆盖业务逻辑与页面样式JSON98个和MD130个分别承担配置定义与使用说明SQL、Shell、XML等文件补充数据库脚本、自动化部署及扩展标记支持目录结构清晰便于定位与二次开发。目前已有954人学习浏览建议备有一定PHP或前端基础后再实践。通过源码可深入理解多支付插件整合模式、支付网关对接流程以及云端源码的本地化配置方法同时参考现有前后端模块快速接入新通道是研究支付系统二开、支付接口集成与部署落地的完整参考素材。1. 小呆支付全新二开不是重画界面是给支付通道换一套挂载方式接到这个需求时标题写着“小呆支付全新二开集合多种支付插件”我原本以为是把原来的收银台重新画一遍、再加几个支付按钮。真正翻开老代码才发现这个内部老系统之前只接了一条支付通道所有支付逻辑全部写在一个控制器里下单、回调、查询、退款全挤在一起新增一个渠道就要复制粘贴再改三四处试错成本极高。所谓二开核心不是界面而是把支付逻辑拆成插件式结构让新渠道可以通过一个插件文件加一条配置就挂上去。这个改造方向适合维护老支付系统的开发者也适合准备做聚合支付收银台的团队。下面这段笔记按“先看懂结构、再动手接入、最后讲坑”的顺序展开。2. 把支付差异抽象成统一接口插件形式的挂载原理与主流程设计老系统改插件式很多人上来就写代码结果改到一半发现各渠道参数差异太大接口怎么定都别扭。这里最忌讳的是先把所有渠道看完再抽象渠道是看不完的抽象出一版能覆盖绝大多数场景的接口尽快跑通后面遇到特殊渠道再局部扩展才是长期维护的正路。2.1 为什么要先定支付插件接口渠道差异比你想象的更碎支付渠道之间的差异非常细碎。比如下单参数有的渠道用XML有的用JSON签名算法有的是MD5拼接有的用RSA2异步验签有的用HMAC-SHA256回调字段更是五花八门同一个含义的字段在不同渠道里叫法完全不同“订单号”可能是out_trade_no也可能是merchant_order_id。如果这些差异不收敛在一个统一接口后面二开出来就是一个巨大的if-else泥潭。常见做法是抽象出三个核心能力我一般会命名为createPayOrder、verifyNotify、queryOrder再加一个refundOrder作为后续扩展点。对应的接口定义类似这样?php namespace App\Pay\Contracts; interface PayChannelInterface { /** * 发起支付 * param array $order 订单数据: order_no, amount(分), subject, optional * return array 支付参数如二维码内容、跳转链接或小程序参数 */ public function createPayOrder(array $order): array; /** * 验签并解析回调 * param array $payload 渠道推送的原始数据 * param array $header 渠道推送的原始请求头部分签名在header里 * return array 统一格式的回调数据: order_no, amount, trade_no, paid_at */ public function verifyNotify(array $payload, array $header []): array; /** * 主动查单 * param string $orderNo 内部订单号 * param string $tradeNo 渠道流水号可为空 * return array 统一格式的查询结果 */ public function queryOrder(string $orderNo, string $tradeNo ): array; }接口只做约定不碰任何渠道特有逻辑。真正落地时你还会发现有些渠道回调不是HTTP POST而是表单跳转GET带参这类特殊渠道可以单独扩展一个handleRedirectReturn方法基类里留一个空实现就可以。表格式地看抽象前后差别非常大对比项未抽象状态统一接口后新增渠道成本复制控制器代码改URL、签名、字段新增一个插件文件改一条配置回调逻辑每个渠道一个回调地址维护N个入口统一回调入口按渠道码分发测试能力只能手动下单和等待回调可构造模拟回调统一测日志追踪各渠道各打各的格式混乱基类统一记录字段一致这个抽象没有什么玄学就是把“会变的”和“不会变的”分开渠道差异是变化的主流程是不变的。主流程只面向接口编程渠道细节全部收进插件里。2.2 从下单到回调的完整主流程二开时最容易改坏的一段我把整个支付流程拆成四个阶段每个阶段都有对应的代码落点。第一阶段是下单发起。业务系统先写内部订单表状态为待支付然后调用createPayOrder拿到渠道返回的支付参数二维码链接或跳转URL再把支付参数返回给前端收银台。这里有个约定内部订单号一定是系统自己生成的不能用渠道返回的流水号当业务主键。第二阶段是支付跳转与用户操作。用户扫码或跳转到渠道收银台完成付款这个阶段系统拿不到实时状态只能等渠道异步通知或者依赖前端轮询主动查单。第三阶段是异步通知。渠道服务器直接请求你的回调地址这是整条链路里最需要小心维护的环节。回调到达后先验签再查内部订单比对金额最后更新状态。常见做法是回调入口统一放在同一个控制器根据渠道码分发到对应插件。第四阶段是主动查单。它服务于两类场景一是前端轮询用户付完款后前端不太信任回调速度会主动调查单接口二是对账补偿定时任务把超过一定时间仍未关闭的待支付订单拉出来主动向渠道问一遍结果。这里有个最容易被二开改坏的点回调更新订单状态时不能无条件直接覆盖。假如一笔订单已经在主动查单流程里被标记为已支付晚到的回调又把状态改成待支付订单就翻车了。解决办法是让状态更新带上条件后面第4章我会拿具体例子展开。2.3 插件配置落库用配置表驱动而不是改代码硬编码插件结构定好之后下一步是解决“系统怎么知道某个渠道对应哪个插件”的问题。很多团队习惯在代码里写一个映射比如return [ wxpay WechatPayPlugin::class, alipay AlipayPlugin::class, ];这种方式在渠道数量少时能用但运营想临时关掉某个渠道、调整排序或修改渠道参数就必须发版。我一般会建议直接把渠道信息放数据库做一个pay_channel配置表。表结构大致这样CREATE TABLE pay_channel ( id int unsigned NOT NULL AUTO_INCREMENT, code varchar(32) NOT NULL COMMENT 渠道码全局唯一如 wxpay, name varchar(64) NOT NULL COMMENT 渠道展示名, plugin_class varchar(128) NOT NULL COMMENT 插件类名命名空间全路径, config text COMMENT 渠道配置JSON格式密钥、商户号放这里, status tinyint NOT NULL DEFAULT 0 COMMENT 0关闭 1启用, sort int NOT NULL DEFAULT 0 COMMENT 展示排序数值越小越靠前, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付渠道配置表;字段含义这里多说几句。code是渠道码接口请求和回调都会带这个码用来定位插件plugin_class存类的完整命名空间PHP可以用class_exists校验其他语言同理config字段用JSON保存密钥、商户号、网关地址这样同一个渠道可以配出多套环境沙箱和生产各存一行互不干扰。为什么要配置驱动而不是硬编码因为在真实项目里渠道的开关和参数调整非常频繁。比如某渠道搞活动要临时提升限额你在后台改一下JSON就能切到新配置不用重新走发布流程。对二开项目来说这个决定直接决定后期维护成本。3. 动手实现支付插件下单、验签、回调、查询四段代码一次跑通结构设计好后进入实际编码。这里我以PHP为例不过思路在任何语言里都能平移。整个实现分为四层插件基类、具体渠道插件、统一下单入口、统一回调入口。每一层都有明确的职责。3.1 插件基类把请求、签名、日志、异常统一收口基类解决的是重复代码问题。每个渠道都要发起HTTP请求、都要记录请求和响应日志、都要在异常时给出统一错误结构。这些东西如果每个插件自己写一遍后面排查问题时日志格式参差不齐排查效率很低。?php namespace App\Pay\Channels; use App\Pay\Contracts\PayChannelInterface; use GuzzleHttp\Client; abstract class BasePayChannel implements PayChannelInterface { protected array $config []; protected Client $httpClient; /** * 子类构造时把渠道配置传进来 */ public function __construct(array $config) { $this-config $config; $this-httpClient new Client([ timeout 10, connect_timeout 5, http_errors false, ]); } /** * 统一请求封装记录请求和响应日志 */ protected function request(string $method, string $url, array $options []): array { $logData [ url $url, method $method, options $this-maskSensitiveFields($options), ]; try { $response $this-httpClient-request($method, $url, $options); $body $response-getBody()-getContents(); $result json_decode($body, true); // 有些渠道返回XML这里要按渠道类型分别解析 if (json_last_error() ! JSON_ERROR_NONE) { $result $this-parseXml($body); } $logData[response] $result; $this-writeLog(pay_request_success, $logData); return $result; } catch (\Throwable $e) { $logData[error] $e-getMessage(); $this-writeLog(pay_request_failed, $logData); throw $e; } } /** * 日志统一写库或写文件子类可覆盖 */ protected function writeLog(string $type, array $data): void { // 建议记录渠道码、订单号、请求地址、响应摘要、耗时 } /** * 敏感字段脱敏密钥、签名值不要原样进日志 */ protected function maskSensitiveFields(array $options): array { $sensitiveKeys [key, secret, sign, cert_content]; foreach ($sensitiveKeys as $key) { if (isset($options[form_params][$key])) { $options[form_params][$key] ***; } if (isset($options[json][$key])) { $options[json][$key] ***; } } return $options; } abstract protected function parseXml(string $xml): array; }这里有两个细节值得注意。第一构造参数是渠道配置而不是把整个配置对象传进来避免插件里出现$this-config[pay_channel][wxpay][appid]这种深层引用后期改配置结构时插件会很难维护。第二maskSensitiveFields这步非常关键支付日志里的密钥一旦明文落盘就成了安全漏洞。3.2 一个具体渠道插件的完整示例下单与验签实现以最常见的扫码支付为例插件文件要完成三件事构造下单参数并请求渠道、解析回调并验签、主动查单。渠道之间的差异在这里集中体现。?php namespace App\Pay\Channels; class WxpayChannel extends BasePayChannel { private string $gatewayUrl https://api.examplewx.com/v3/pay/transactions; /** * 构建下单请求 */ public function createPayOrder(array $order): array { $payload [ appid $this-config[appid], mch_id $this-config[mch_id], out_trade_no $order[order_no], amount [ // 金额单位必须是分传元会直接导致金额不符 total (int) $order[amount], currency CNY, ], description $order[subject] ?? 商品描述, notify_url $this-config[notify_url], ]; $payload[sign] $this-sign($payload); $result $this-request(POST, $this-gatewayUrl, [ json $payload, ]); if (($result[code] ?? ) ! SUCCESS) { throw new \RuntimeException(渠道下单失败: . ($result[message] ?? 未知错误)); } return [ type qr_code, content $result[code_url], trade_no $result[prepay_id] ?? , ]; } /** * 回调验签与解析 */ public function verifyNotify(array $payload, array $header []): array { $signature $header[signature] ?? ; $timestamp $header[timestamp] ?? ; $nonce $header[nonce] ?? ; if (!$this-verifySignature($timestamp, $nonce, $payload, $signature)) { throw new \RuntimeException(回调验签失败); } if (($payload[trade_state] ?? ) ! SUCCESS) { throw new \RuntimeException(交易未成功: . ($payload[trade_state_desc] ?? )); } return [ order_no $payload[out_trade_no], trade_no $payload[transaction_id], amount (int) $payload[amount][total], paid_at date(Y-m-d H:i:s, (int) $payload[success_time]), ]; } /** * 渠道签名算法各渠道实现不同 */ private function sign(array $payload): string { ksort($payload); $string urldecode(http_build_query($payload)); $string . key . $this-config[api_key]; return strtoupper(md5($string)); } private function verifySignature(string $timestamp, string $nonce, array $payload, string $signature): bool { // 实际项目中按渠道验签规则实现这里用HMAC-SHA256占位 $content $timestamp . \n . $nonce . \n . json_encode($payload, JSON_UNESCAPED_UNICODE); $calc base64_encode(hash_hmac(sha256, $content, $this-config[api_v3_key], true)); return hash_equals($calc, $signature); } }代码里注释已经点出了关键amount字段传的是整数分这个约定必须全项目统一。你在哪个环节把它当元处理对账就会在哪个环节出问题。verifyNotify里返回的amount同样转成整数分业务层比较金额时才不会出现0.1 0.2这类浮点笑话。3.3 统一下单入口用配置表映射到插件实例有了插件后业务系统不能自己去new渠道插件要经过一个统一的入口这样日志、限流、开关判断都集中在同一个地方。?php namespace App\Pay; use App\Pay\Channels\BasePayChannel; class PayManager { private array $channels []; /** * 根据渠道码获取插件实例 * 配置从DB读取按code索引 */ public function channel(string $channelCode): BasePayChannel { if (isset($this-channels[$channelCode])) { return $this-channels[$channelCode]; } $row $this-getChannelConfig($channelCode); if (!$row || (int)$row[status] ! 1) { throw new \RuntimeException(支付渠道未启用: . $channelCode); } $class $row[plugin_class]; if (!class_exists($class)) { throw new \RuntimeException(插件类不存在: . $class); } $config json_decode($row[config], true); $plugin new $class($config); return $this-channels[$channelCode] $plugin; } /** * 统一下单 */ public function createOrder(string $channelCode, array $order): array { $plugin $this-channel($channelCode); // 统一记录订单发起日志 $this-log(pay_order_create, [ channel $channelCode, order_no $order[order_no], amount $order[amount], ]); return $plugin-createPayOrder($order); } }这个门面类有两个作用。第一渠道开关的判断收拢在channel方法里运营后台关掉渠道后所有接入方立即生效不需要他们改代码。第二插件实例做了缓存同一个请求生命周期内多次调用不会重复创建对象也避免重复读取数据库。订单号生成我一般用date(YmdHis) . str_random(16)确保唯一性的同时兼具可读性。金额参数一律从订单表读取控制器接收前端传入的金额时必须只信任服务端重新计算的值不能直接拿前端传参下单。3.4 统一回调入口验签、锁单、更新状态一个都不能少回调入口是所有渠道共用的代码写好后基本不需要动。它的职责是定位渠道插件、调用verifyNotify、比对订单信息、更新订单状态、返回渠道要求的应答报文。?php namespace App\Http\Controllers\Pay; use App\Pay\PayManager; use Illuminate\Http\Request; class PayNotifyController extends Controller { public function handle(string $channelCode, Request $request) { $manager new PayManager(); $plugin $manager-channel($channelCode); // 拿到原始报文不要提前解析成数组 $payload $request-getContent(); try { // 第一步验签并解析 $notify $plugin-verifyNotify($request-all(), $request-headers-all()); // 第二步查出内部订单 $order $this-getOrderByOrderNo($notify[order_no]); if (!$order) { throw new \RuntimeException(订单不存在: . $notify[order_no]); } // 第三步金额一致性校验 if ((int)$order-amount ! (int)$notify[amount]) { throw new \RuntimeException(订单金额不符); } // 第四步条件更新防止重复回调覆盖状态 $updated $this-markOrderPaid($order-order_no, $notify[trade_no]); if ($updated) { // 触发业务侧通知发短信、关闭超时任务等 event(new OrderPaidEvent($order)); } // 渠道要求返回固定的应答格式 return $this-successResponse($channelCode); } catch (\Throwable $e) { $this-logError(pay_notify_failed, [ channel $channelCode, error $e-getMessage(), ]); return $this-failResponse($channelCode); } } /** * 带条件的更新语句这是幂等的关键 */ private function markOrderPaid(string $orderNo, string $tradeNo): bool { return $this-updateOrder( where: [ order_no $orderNo, status pending, // 只有待支付状态才能被更新 ], values: [ status paid, trade_no $tradeNo, paid_at now(), ] ) 1; } }markOrderPaid是整个回调流程的核心保障。它把“状态变更”变成了“带条件的原子操作”只有当订单当前状态是待支付时更新才会生效。如果重复回调到来这条语句影响行数为0业务侧事件不会重复触发天然实现幂等。这个方法解决了支付开发里最典型的一个坑具体现象和分析在第4章展开。4. 二开支付插件避坑指南四类常见翻车现场与排查路径支付二开和普通业务开发最大的区别在于出问题的时候外部渠道不会等你调试。无论是测试还是生产踩坑都是必然的区别只在于有没有提前布防。这里把我在类似项目里反复遇到的四类问题列出来每一条都是花钱买来的教训。4.1 重复回调导致订单状态被回退现象订单已经支付成功后台日志显示状态又变回了待支付甚至用户收到了两条支付成功通知。查业务订单时发现状态被旧回调覆盖。原因渠道的重试机制会带来重复回调而回调请求到达顺序没有保证。老系统直接用当前时间覆盖状态后来的旧回调把新状态覆盖掉了。解决更新语句必须带条件像第3章写的那样让订单状态从待支付到已支付成为一个不可逆的流转。不要写“先查出订单在PHP里判断状态再更新”这种读改写不是原子的并发请求下同样会翻车。直接用一条UPDATE语句解决问题影响行数就是幂等判断依据。4.2 金额精度问题用浮点数比较永远有惊喜现象测试小额订单一切正常一单 5800.01 元的订单系统报“金额不符”但用户确实付了这么多。原因订单表金额是元渠道回调金额按分返回。比较时一个转float保留了两位小数另一个被PHP的隐式转换处理5800.01 ! 5800.0100000000002的情况随机出现。解决统一最小货币单位。数据库里金额字段直接用整数分存储前端展示时再除以100渠道回调的金额也先转成整数分再比较。转换时用字符串函数或者intval(round(...))兜底避免大数精度问题。另外所有金额字段在数据库层面不要用float类型用bigint或decimal(10,2)。4.3 证书与密钥权限问题测试环境能付生产环境必挂现象本地沙箱测试扫码支付正常代码原样部署到生产服务器首次下单就报“证书无法读取”或“密钥无效”渠道侧显示没有收到请求。原因本地开发环境常用root或当前用户运行服务证书文件权限是 0644 也能读生产环境服务用户是低权限账号证书文件放在项目目录且权限不足过程用户读不到。解决证书文件不要放在代码目录里放到/etc/paycerts/这类受控目录设置服务用户可读即可。项目启动时增加自检代码检查文件是否存在、是否可读、证书是否过期失败就提前告警不要等用户下单才发现。密钥文件权限设为 0600避免同服务器其他用户读取。4.4 异步通知消息丢失日志全空时先查渠道侧重发现象用户支付成功但系统没有任何回调日志订单一直停留在待支付状态。此时业务侧开始着急怀疑是不是回调地址不对。原因回调地址配置错误只占少部分更多是渠道的异步通知有延迟或丢失尤其是测试环境下渠道没有启用消息重推或者回调URL填的是内网地址渠道无法访问。解决不要只依赖异步通知。服务端要有一个定时任务把超过一定时间未支付的订单主动查单。查单逻辑和回调逻辑共用同一个验签和状态更新方法确认支付成功就调markOrderPaid。定时任务的时间间隔建议5分钟扫最近两小时内创建的待支付订单每次最多处理200条。这条兜底路径在聚合支付场景里是必选项不是可选项。5. 把二开成果养得更稳自检命令、灰度开关与验证习惯多支付插件系统接入新渠道时最怕“看起来通了但某个环节没测到”。这里分享两个我每次接渠道都会做的步骤比写100行注释都管用。5.1 建一个模拟回调自检命令每接完一个渠道不管沙箱测试多少次我都会写一个命令行自检脚本核心是“自己给自己模拟回调”。php artisan pay:self-check --channelwxpay --order-no202501010001脚本做的事情分成三步先创建一个待支付测试订单并写入数据库再模拟渠道构造一份完整回调报文最后走一遍统一回调入口并检查最终落库状态。?php namespace App\Console\Commands; use Illuminate\Console\Command; use App\Pay\PayManager; class PaySelfCheck extends Command { protected $signature pay:self-check {--channel} {--order-no}; public function handle() { $channel $this-option(channel); $orderNo $this-option(order-no); $manager new PayManager(); $plugin $manager-channel($channel); // 模拟渠道回调解析 $mockPayload [ out_trade_no $orderNo, transaction_id MOCK_TX_ . time(), trade_state SUCCESS, amount [total 100, currency CNY], success_time time(), ]; $notify $plugin-verifyNotify($mockPayload, []); $this-info(验签通过: . $notify[order_no]); // 走统一的业务更新逻辑 $this-updateOrderStatus($notify); // 检查最终结果 $order $this-getOrder($orderNo); if ($order-status paid) { $this-info(自检通过); } else { $this-error(自检失败: 订单状态未更新); } } }这个自检脚本的价值在于可重复。每次升级渠道配置、替换证书、调整回调逻辑先跑一遍自检再上生产能挡掉大部分低级失误。它不能替代沙箱测试但它把回归成本降到了秒级。5.2 用灰度开关控制渠道上线节奏新渠道不要直接全量放开。我的习惯是在pay_channel配置里增加一个放量比例字段比如percent存 0 到 100下单时按订单号取模判断是否走新渠道。public function isGrayOpen(string $orderNo, int $percent): bool { if ($percent 0) { return false; } if ($percent 100) { return true; } // 用订单号取模保证同一个订单多次请求结果一致 return (hexdec(substr(md5($orderNo), 0, 4)) % 100) $percent; }灰度期间重点看两个指标支付成功率是否明显低于老渠道、回调平均延迟是否在可接受范围。一旦指标不对把配置里的 percent 改回 0 立即切回老渠道比改代码提交发布要快得多。这个开关配合自检脚本一起用整个二开系统的抗风险能力会好很多。我现在的习惯是每接一个新渠道先写自检用例再开5%灰度观察一天后逐步放量。这套节奏帮我避免过至少三次线上支付异常。如果你也在二开同类支付系统希望今天这篇能帮到你少踩几个坑。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?