ARTICLE DETAIL

资讯详情

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

代付、卡密与聚合支付源码实战:订单状态机、幂等设计与对账避坑

代付、卡密与聚合支付源码实战:订单状态机、幂等设计与对账避坑 简介这套源码面向电商支付场景的开发者与商家整合了淘宝天猫代付、京东油卡卡密及聚合支付三类模块可用于虚拟卡密店铺的自动发货与协议回调。系统支持淘宝天猫卡密店铺对接天猫代付模块附带CK获取软件与使用教程上手门槛相对较低京东中石油模块则需自行研究调试。需注意京东中石化、比心、快手小店等仅保留名称入口功能尚未开发完成。资源包共约2000个文件以1412个js脚本、209个html页面、131个md说明文档、117个css样式及111个json配置为主另含sql建表脚本、sh部署脚本与docx、pptx说明材料压缩包约75.33MB目录结构完整便于二次开发与模块定位。目前已有157人学习关注。对于需要搭建卡密代付与聚合支付流程的开发者而言可借此快速理解支付回调、卡密分发与多平台接入的整体实现思路并在此基础上按需扩展未完成模块。1. 代付、卡密与聚合支付一套源码要同时扛住三条业务线淘宝天猫代付、京东油卡卡密、聚合支付这三个词放在同一个源码标题里不是拼凑而是很多中小团队真实的业务组合。代付解决的是「我下单、别人付款」的链路油卡卡密解决的是「虚拟商品即时交付」的链路聚合支付解决的是「多个通道统一收单」的链路。三条线共用一套订单、账户、回调、对账底座才是这类源码真正的价值所在。我接触过的团队里有人拿它做电商代付工具有人拿它做虚拟卡券自动发货也有人把它当聚合支付的学习脚手架。适合谁适合已经懂 PHP 或 Java 基础、想研究支付订单状态机、想搞明白异步回调怎么防重放的人。不适合谁不适合想直接上线收钱却不愿做风控和对账的人。这篇就按「先立住模型、再跑通链路、最后讲坑」的顺序把这类源码拆开讲清楚。2. 三条业务线共用的订单与账户模型2.1 代付、卡密、聚合支付到底差在哪很多人一上来就翻源码找支付接口结果被一堆表名绕晕。先把三条线的业务本质分清楚后面看代码才有方向。代付的核心是「付款方和下单方分离」。A 发起订单B 确认并支付系统要记录发起人、付款人、订单金额、代付状态。它的状态机比普通支付多一层待认领、已认领、待支付、已支付、已关闭。淘宝天猫代付场景里最常见的是把订单号生成一个短链或口令付款方打开后走支付通道。京东油卡卡密的核心是「虚拟商品库存与核销」。油卡、话费、视频会员这类商品没有物流下单后直接从卡密池取一条可用卡密标记为已售返回给用户。它的关键表是卡密表字段通常包括卡号、卡密、面值、状态、批次号、绑定订单号。状态流转是未使用、锁定中、已使用、已作废。聚合支付的核心是「多通道统一收单」。同一笔订单可能走微信、支付宝、云闪付系统要按路由规则选通道统一封装请求统一处理异步通知统一对账。它的关键不是支付本身而是通道适配层和回调幂等。三条线共用的底座是用户表、账户表、订单表、流水表、回调日志表。看懂这五张表的关系源码就通了一半。2.2 订单状态机与幂等设计支付类系统最怕的不是并发高而是状态乱。同一笔订单被回调两次、被重复发货、被人工改状态都是血泪经验。一个可靠的订单状态机应该满足三点状态只能单向流转、每次流转写流水、外部回调必须幂等。下面是一个简化的状态定义用 PHP 数组表达Java 或 Python 思路一致。?php // 订单状态常量只允许按箭头方向流转 // 待支付 - 支付中 - 已支付 - 已发货 - 已完成 // 任意状态 - 已关闭仅未支付时允许 const ORDER_STATUS [ PENDING 0, // 待支付 PAYING 1, // 支付中已请求通道 PAID 2, // 通道确认收款 DELIVERED 3, // 卡密已发放或代付已确认 FINISHED 4, // 终态 CLOSED 9, // 关闭 ]; // 合法流转表key 是当前状态value 是允许到达的状态 const STATUS_FLOW [ ORDER_STATUS[PENDING] [ORDER_STATUS[PAYING], ORDER_STATUS[CLOSED]], ORDER_STATUS[PAYING] [ORDER_STATUS[PAID], ORDER_STATUS[CLOSED]], ORDER_STATUS[PAID] [ORDER_STATUS[DELIVERED]], ORDER_STATUS[DELIVERED] [ORDER_STATUS[FINISHED]], ];这段代码的逻辑是任何状态变更前先查STATUS_FLOW不在允许列表里就直接拒绝。参数说明PENDING是订单刚创建PAYING是已经向支付通道发起请求但还没收到确认这两个状态之间必须有一次通道请求记录否则会出现「没请求却变成支付中」的脏数据。幂等的实现通常靠一张唯一索引表。回调进来时用「通道号 通道订单号」做唯一键插入回调日志插入成功才继续处理业务插入冲突说明已经处理过直接返回成功。这个做法比在订单表上加锁更轻也更适合聚合支付多通道场景。提示状态机不要用字符串直接比较统一用整型常量避免「paid」和「PAID」这种玄学问题。2.3 卡密池的锁定与释放油卡卡密系统最容易翻车的地方是超卖。两个订单同时取到同一条卡密或者卡密被锁定后订单关闭却没释放。常见做法是「先锁定再发放」。取卡密时用一条带条件的更新语句把状态从「未使用」改成「锁定中」同时写入绑定订单号。如果更新影响行数为 0说明被别人抢了换下一条。-- 锁定一条可用卡密LIMIT 1 配合状态条件避免超卖 UPDATE card_secret SET status LOCKED, order_no :order_no, lock_time NOW() WHERE status UNUSED AND batch_id :batch_id ORDER BY id ASC LIMIT 1;参数说明batch_id用于按批次取卡方便后续对账order_no写入后释放时才能精确回滚。执行完这条语句后再查一次order_no :order_no AND status LOCKED确认拿到的是哪条卡密。如果订单在支付超时后关闭必须有定时任务把LOCKED且超过 15 分钟的记录改回UNUSED否则卡密池会越用越少。这里有个细节锁定和发放要分开。支付成功后才把LOCKED改成USED支付失败或超时则改回UNUSED。不要一锁定就直接标记已使用否则用户没付款卡密就废了。3. 聚合支付通道适配层怎么写才不乱3.1 统一支付接口的参数设计聚合支付源码里通道适配层是最值得细看的部分。如果每个通道都写一套 if-else后面加通道会痛不欲生。正确做法是定义一个统一接口每个通道实现自己的签名、请求、验签、解析。统一接口的输入参数一般包括商户订单号、金额分、通道编码、异步通知地址、同步跳转地址、附加参数。输出统一为是否成功发起、通道订单号、请求报文、跳转链接或二维码内容。下面是一个 PHP 接口定义用抽象类表达。?php abstract class PayChannel { // 发起支付返回统一结构 abstract public function pay(array $order): array; // 验证异步通知返回统一结构 abstract public function verifyNotify(array $post): array; // 查询订单用于对账补偿 abstract public function query(string $orderNo): array; } // 统一返回结构约定 // pay 返回[okbool, channel_nostring, payloadstring] // verifyNotify 返回[okbool, order_nostring, amountint, channel_nostring]逻辑说明pay负责组装通道要求的参数并签名payload可能是跳转 URL 或二维码字符串。verifyNotify必须做验签和金额比对金额不一致直接拒绝。query用于定时对账防止异步通知丢失导致订单一直停在PAYING。参数说明金额统一用「分」为单位避免浮点误差order_no是商户侧唯一订单号通道侧订单号单独存字段两者不要混用。3.2 异步回调的验签与防重放回调是支付系统里最容易被攻击的入口。常见问题有三个不验签、不校验金额、不防重放。验签的逻辑是按通道规则把参数排序拼接加上商户密钥做 MD5 或 RSA 验证。不同通道规则不同但都要在适配层里实现。防重放则靠前面提到的回调日志唯一索引。?php // 回调处理伪代码先验签再幂等再改状态 public function notify($channelCode) { $channel ChannelFactory::make($channelCode); $result $channel-verifyNotify($_POST); if (!$result[ok]) { exit(sign error); // 验签失败直接拒绝 } // 幂等通道号 通道订单号 唯一 $inserted CallbackLog::insertIgnore([ channel $channelCode, channel_no $result[channel_no], raw json_encode($_POST), ]); if (!$inserted) { exit(success); // 已处理过直接返回成功 } // 金额比对回调金额必须等于订单金额 $order Order::findByNo($result[order_no]); if ($order[amount] ! $result[amount]) { exit(amount error); } OrderService::markPaid($order[id], $result[channel_no]); exit(success); }逻辑说明先验签再插回调日志插入失败说明重复通知直接返回成功让通道停止重试。金额比对是防止篡改。最后才改订单状态。参数说明insertIgnore依赖唯一索引MySQL 里用INSERT IGNORE或ON DUPLICATE KEY UPDATE都可以但不要用「先查再插」并发下会漏。注意回调接口必须返回通道要求的成功标识通常是纯文本success不要返回 JSON否则通道会一直重试。3.3 对账与补单任务异步通知会丢这是常态。所以聚合支付系统必须有对账任务。常见做法是每天凌晨拉取通道对账单和本地流水逐笔比对发现「通道已成功、本地未成功」的订单就补单。补单不是直接改状态而是走和回调一样的markPaid逻辑保证幂等。对账结果要落表记录差异类型本地多、通道多、金额不一致、状态不一致。差异单需要人工介入不要自动改金额。# 定时任务示例每天 02:00 执行对账 0 2 * * * /usr/bin/php /www/pay/cron/reconcile.php --channelwechat /var/log/reconcile.log 21参数说明--channel指定通道避免一次拉太多日志单独落文件方便排查。对账脚本要加锁防止上一次没跑完下一次又启动。4. 代付与卡密发货链路的落地细节4.1 代付订单的认领与超时释放代付和普通支付最大的区别是「认领」。订单创建后处于待认领付款方通过口令或链接认领认领后进入待支付。如果 30 分钟内没人认领订单自动关闭。认领动作要加锁防止两个人同时认领同一笔订单。常见做法是用 Redis 分布式锁key 是订单号过期时间略大于业务处理时间。?php $lockKey daifu:claim: . $orderNo; $locked Redis::set($lockKey, $uid, [nx, ex 10]); if (!$locked) { return [ok false, msg 订单已被认领]; } // 再次查库确认状态仍是待认领 $order Order::findByNo($orderNo); if ($order[status] ! ORDER_STATUS[PENDING]) { Redis::del($lockKey); return [ok false, msg 状态不允许认领]; } OrderService::claim($orderNo, $uid); Redis::del($lockKey);逻辑说明先抢锁再查状态两个动作缺一不可。只抢锁不查状态可能出现锁释放后状态已变只查状态不抢锁并发下两个请求都查到待认领。参数说明ex 10是锁自动过期防止死锁业务处理完主动删除锁。超时释放用定时任务扫描PENDING且创建时间超过 30 分钟的订单改成CLOSED。如果订单已经进入PAYING不要直接关闭要先查通道确认未支付再关。4.2 卡密自动发货的触发时机卡密发货的触发点只有一个订单状态变成PAID。不要在支付回调里直接发货而是回调改状态后由状态变更事件触发发货。这样补单、人工改状态也能触发发货逻辑统一。发货流程是锁定卡密、标记订单已发货、把卡密内容写入发货记录、返回给用户。如果锁定失败卡密池空了订单进入「待补货」状态通知运营。?php // 状态变更为 PAID 后触发 public function onPaid($orderId) { $order Order::find($orderId); if ($order[type] ! CARD) { return; // 非卡密订单不处理 } $card CardService::lockOne($order[batch_id], $order[order_no]); if (!$card) { OrderService::markWaitStock($orderId); return; } CardService::markUsed($card[id], $orderId); Delivery::write($orderId, $card[card_no], $card[card_secret]); OrderService::markDelivered($orderId); }逻辑说明lockOne返回空说明没库存标记待补货而不是失败方便后续补发。markUsed和write要在一个事务里避免卡密标记已用但发货记录没写。参数说明batch_id来自订单创建时选定的批次不要发货时再随机选批次否则对账对不上。4.3 发货失败的重试与人工介入发货失败分两种暂时失败数据库超时和永久失败卡密池空。暂时失败可以重试三次永久失败必须人工。重试要用队列不要在主流程里 sleep 重试。队列消费失败后写死信表运营在后台看到死信可以手动补发。补发时同样走onPaid逻辑保证幂等。提示发货记录表要有唯一索引order_id防止同一订单重复发货。这是最后一道防线。5. 这类源码部署与二次开发的避坑清单5.1 环境与依赖的常见翻车点第一个坑是 PHP 版本。很多老源码写的是 PHP 5.6 风格放到 PHP 8 上会报一堆废弃警告尤其是each()、mysql_*函数。解决方式是先看composer.json或入口文件的版本声明没有声明就本地用 PHP 7.4 跑再逐步升级。第二个坑是扩展缺失。支付类源码常依赖bcmath、openssl、redis、gd。部署前用php -m对一遍缺哪个装哪个。RSA 验签失败很多时候不是代码问题是openssl没开。第三个坑是时区。订单时间、回调时间、对账时间必须统一时区否则对账时会出现「本地时间比通道时间早 8 小时」的差异。在入口文件里显式设置date_default_timezone_set(Asia/Shanghai)。5.2 数据库与缓存的配置陷阱数据库方面订单表和流水表一定要建联合索引常见查询是「用户 时间」「状态 时间」。没有索引时订单量到十万级就会明显变慢。缓存方面不要把订单状态只放 Redis。Redis 重启或过期后状态就丢了。正确做法是数据库为准Redis 只做锁和热点缓存。锁的过期时间要大于业务最长处理时间否则会出现「业务没处理完锁先过期」的并发问题。还有一个隐蔽的坑MySQL 的utf8和utf8mb4。卡密内容里如果有特殊字符utf8存不进去会截断。建库时直接用utf8mb4。5.3 安全与合规的边界源码里常见的危险写法包括回调不验签、金额从客户端传、SQL 拼接、后台弱口令。二次开发时回调必须服务端验签金额必须从数据库读SQL 用预处理后台强制改默认密码。另外代付和卡密业务涉及资金和虚拟商品上线前要确认业务本身合法合规不要用来做灰色场景。源码只是工具怎么用取决于人。5.4 日志与排查手段支付系统没有日志等于黑匣子。必须记录请求日志通道、参数、响应、回调日志原始报文、验签结果、处理结果、状态变更日志订单号、旧状态、新状态、操作人。排查订单问题时按「订单号 → 流水 → 回调日志 → 通道请求日志」的顺序查基本能定位到是哪一步断了。如果通道请求日志没有说明请求根本没发出去如果有请求没回调去通道后台查订单状态。注意日志里不要打印完整卡密和密钥脱敏后再落盘。6. 用一笔测试订单验证整条链路部署完别急着接真实通道先用沙箱或 mock 通道跑一笔完整订单。我的习惯是写一个测试脚本模拟「创建订单 → 发起支付 → 模拟回调 → 触发发货 → 查发货记录」五步每一步断言状态。?php // 链路自测脚本不依赖真实通道 $orderNo TEST . time(); OrderService::create([order_no $orderNo, amount 100, type CARD]); // 模拟通道回调 $_POST [ order_no $orderNo, amount 100, channel_no MOCK123, sign md5($orderNo . 100 . MOCK123 . MOCK_KEY), ]; $channel new MockChannel(); $result $channel-verifyNotify($_POST); assert($result[ok] true); OrderService::markPaid($orderNo, MOCK123); $order Order::findByNo($orderNo); assert($order[status] ORDER_STATUS[DELIVERED]); $delivery Delivery::findByOrder($orderNo); assert(!empty($delivery[card_secret])); echo 链路通过\n;逻辑说明这个脚本把回调验签、状态流转、卡密发货串起来任何一步失败都会断言中断。参数说明MOCK_KEY是 mock 通道的密钥真实环境换成通道密钥。跑通后再接真实通道能省掉大量「到底是代码问题还是通道问题」的扯皮。进阶一点的做法是把这笔测试订单的对账也跑一遍确认对账任务不会把它当成差异单。如果对账脚本把测试单标成差异说明对账的过滤条件没排除测试类型。我自己的习惯是每次改完支付相关代码先跑这个脚本再跑对账最后才上真实环境。这个习惯帮我挡过好几次「回调验签改坏了」的事故。希望帮到你。本文还有配套的精品资源点击获取
返回列表