多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PHP自动发货虚拟商城源码全解析:从支付回调到库存扣减

PHP自动发货虚拟商城源码全解析:从支付回调到库存扣减 简介这是一套基于PHP开发的虚拟商品自动发货系统源码面向需要搭建免人工值守在线交易平台的站长、虚拟产品卖家或独立开发者能够解决自动发货、文章付费阅读、会员管理等常见营收场景。系统内置缺货提醒、快捷登录QQ/微信、免登录购买、积分转换、VIP会员、在线支付支付宝/微信等功能并支持移动端与各端小程序适配。压缩包内共686个文件包括PHP后端代码、JS交互脚本、CSS样式、图片素材、字体图标等类型整体约15.06MB支持根目录或子目录安装目录结构清晰便于按模块理解与二次开发。目前已有365人学习下载。下载后可获得一套响应式默认模板、一键安装流程说明以及基于MySQL的完整部署方案系统还内置回收站、全站搜索、模板切换等实用功能能帮助有PHP基础的用户快速搭建虚拟卡密销售、文章付费阅读或会员积分商城等线上场景是一份可直接参考上手的建站源码。1. 虚拟商城自动发货一套能跑通全流程的 PHP 源码长什么样如果你卖过卡密、激活码或者任何需要付款后即时交付的虚拟商品一定经历过半夜爬起来手动发货的绝望。买家付款后迟迟收不到东西客服消息一条接一条最后还得退款赔钱。我拆过不少自动发货源码这套 PHP 版虚拟商城是我目前见过把「支付回调 → 订单校验 → 自动发卡 → 异常补发」链路做得最完整的一套内置在线 100 自动发货逻辑支持 API 接口对接适合做发卡网、虚拟商品自助购买或对接第三方平台的交付中转。文章后面我会把它拆开从订单状态机一路讲到数据库设计、支付回调验签、部署踩坑最后给出一个可以照抄的验收清单。无论你是想用它搭一个发卡站还是想参考它的订单流转做自己的交付系统这篇都能让你少走弯路。源码是 PHP 写的对 LNMP 环境友好改造成本很低。2. 下单到发货先把这套源码的完整业务链路跑明白2.1 虚拟商城和普通电商的本质区别交付是瞬时的普通电商的订单状态是「待付款 → 已付款 → 已发货 → 签收」整个过程以天为单位。虚拟商城完全不同买家在付款成功的那一秒系统就需要把卡密、激活码、网盘链接或者 API 返回的数据推送到买家面前。这就是为什么很多通用电商系统没法直接改造成发卡网——订单状态机的设计目标根本不一样。这套 PHP 源码的业务链路是这样的买家选商品 → 下单创建订单 → 跳转支付 → 支付平台异步回调 → 系统验签 → 扣减库存 → 提取卡密 → 更新订单状态 → 页面展示/短信通知买家。注意这里用的是异步回调驱动不是页面跳转驱动。页面只是把支付结果返回给浏览器真正让订单流转起来的是支付平台往服务端发的那条 POST 请求。这两者的差别非常关键后面讲配置时你就能体会到。源码把这个链路拆成了三个相对独立的模块商品与库存管理、订单与支付模块、卡密与自动交付模块。三个模块之间通过数据库订单状态字段解耦好处是你可以单独替换某个模块而不影响整体逻辑。比如你可以把支付模块从支付宝换成易支付只需要重写第三方回调和验签部分卡密交付部分完全不用动。2.2 核心功能拆解哪些是自动发货的命根子哪些是装饰我拆完这套源码后把它的功能分成「不可省」和「锦上添花」两类区分标准很简单少了它会不会导致买家付了钱拿不到东西。不可省的是这四项支付回调验签、库存原子扣减、卡密提取与交付、订单状态持久化。支付回调验签保证付款信息不可伪造库存原子扣减防止超卖——虚拟商品超卖很可怕因为每个卡密都是唯一且消耗型的卡密提取和交付是自动发货的落点订单状态持久化则是整个系统的记账本。这四项任何一项挂了你的发卡网都会出生产事故。锦上添花的是邮件/短信通知、订单查询页面、API 对外接口、商品分类与搜索、后台统计报表。这些不直接影响「收钱发货」这条主链路但决定了你能不能把规模做大。特别是 API 对外接口——我见过好几个做虚拟商品分发的团队前期手动在后台发卡密后来对接第三方平台时发现源码没留接口推倒重来。这套源码在这块做得比较完整后面专门用一节讲。2.3 单笔订单的完整生命周期从 pending 到 finished你可以把买家的一次购买当成一条状态流转链这套源码定义了五种订单状态pending、paid、delivering、finished、closed。pending 是创建订单但还未收到支付回调paid 是回调已到、验签通过、库存已扣减但卡密还没成功提取delivering 是正在提取卡密或等待第三方 API 响应finished 是买家已成功获取到卡密信息closed 是订单超时关闭或支付后库存不足导致退款关闭。为什么中间要拆出 paid 和 delivering因为虚拟商品交付不一定总是瞬间完成的。碰到卡密池为空、网盘链接生成慢、API 供应商响应超时的时候你应该把「钱已收到」和「货已交付」分开处理。很多简化版发卡系统把这两者合并成一个状态看似没什么一旦交付失败你就会陷入「订单到底是成功还是失败」的尴尬局面用户查不到卡密你又不敢自动退款只能人工介入。这套源码把状态拆开之后补发与退款策略就非常清晰了后面避坑章节我会展开讲。3. 数据库设计与订单状态机这套源码最值得抄的部分3.1 建表结构六张表怎么撑起整个商城我拆完这套源码的建表 SQL 后不得不承认它的表结构设计很收敛一共六张核心表goods商品表、goods_attr商品属性表、card_pool卡密池表、orders订单表、order_delivery交付记录表、pay_log支付回调日志表。没有一张冗余表每张表都在订单生命周期里有明确职责。商品表 goods 的关键字段包括 goods_id、goods_name、goods_price、goods_type卡密自动发货 / API对接发货 / 手动发货、stock_type固定库存 / 无限库存、status上下架。卡密池表 card_pool 通过 goods_id 关联到具体商品card_content 存卡密原文card_status 维护卡密状态salt 字段用于对卡密做加密存储——这个设计我后面单独讲。orders 表是核心中的核心字段有 order_sn订单号、goods_id、buyer_email、pay_amount、pay_type、status五种状态枚举、created_at、paid_at、finished_at。order_delivery 表则记录每次交付尝试的详细信息。这六张表的关联关系很清晰goods 一对多 card_poolorders 一对多 order_deliverypay_log 与 orders 通过 order_sn 关联。下面是 goods 和 orders 的建表语句核心部分我从源码里摘出来做了精简CREATE TABLE goods ( goods_id INT UNSIGNED NOT NULL AUTO_INCREMENT, goods_name VARCHAR(120) NOT NULL COMMENT 商品名称如 CF 激活码 / 季卡, goods_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 销售价格, goods_type TINYINT NOT NULL DEFAULT 1 COMMENT 1卡密发货 2API发货 3手动处理, stock_type TINYINT NOT NULL DEFAULT 1 COMMENT 1固定卡密库存 2无限库存, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, PRIMARY KEY (goods_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; CREATE TABLE orders ( order_id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_sn VARCHAR(32) NOT NULL COMMENT 订单号唯一索引, goods_id INT UNSIGNED NOT NULL COMMENT 购买的商品ID, buyer_email VARCHAR(120) NOT NULL COMMENT 买家接收卡密的邮箱, pay_amount DECIMAL(10,2) NOT NULL COMMENT 实际支付金额, pay_type VARCHAR(20) NOT NULL DEFAULT alipay, status TINYINT NOT NULL DEFAULT 0 COMMENT 0pending 1paid 2delivering 3finished 4closed, created_at INT UNSIGNED NOT NULL COMMENT 创建时间戳, paid_at INT UNSIGNED DEFAULT NULL COMMENT 支付时间戳, PRIMARY KEY (order_id), UNIQUE KEY uniq_order_sn (order_sn), KEY idx_status_paid (status, paid_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;这段建表 SQL 有三个你可能容易忽略的细节。第一order_sn 做了唯一索引这在支付回调场景里是防止重复创建的兜底保障——如果同一笔支付回调被通知两次insert 会因为唯一索引冲突而失败而不是产生两笔脏订单。第二orders 表没有直接存 buyer_name 或手机号只存 buyer_email因为虚拟商品的交付介质就是邮箱手机号可有可无。第三status 字段用的是 TINYINT 枚举而不是字符串这样索引更小、查询更快代价是排查问题时需要对照注释看数字含义没有直接存字符串直观。3.2 库存扣减与状态流转SQL 层面的原子操作怎么写虚拟商城最忌讳的就是超卖。假设库存里有 100 个卡密两个买家同时付款成功如果你的扣库存逻辑是「先查库存 → 有余量 → 减库存」那并发下就可能出现两个请求都查到剩余 1 个、然后各自减了一次、把库存扣成负数的情况。这个问题在这套源码里是通过一条带条件的 UPDATE 解决的看核心代码// 从卡密池中锁定一张未使用的卡密原子操作 $sql UPDATE card_pool SET card_status 2, lock_order_sn :order_sn, locked_at :now WHERE goods_id :goods_id AND card_status 1 ORDER BY card_id ASC LIMIT 1; $stmt $pdo-prepare($sql); $stmt-execute([ :order_sn $orderSn, :goods_id $goodsId, :now time(), ]); // 影响行数为 0 说明没有可用卡密触发补货或退款流程 $lockedCount $stmt-rowCount(); if ($lockedCount 0) { // 进入缺货处理逻辑标记订单为 closed 并触发退款 }这段代码是这套源码里含金量最高的一处。它把「找到一张可用卡密、把状态改成已锁定、记录锁定归属订单」这三步压缩成一条 UPDATE利用 InnoDB 的行锁特性让并发请求串行化。第 2 个请求进来时第一张卡密的 card_status 已经是 2会被条件过滤掉所以拿到的必然是另一张卡密或者一张都没有。我见过不少发卡系统栽在这里——拆成三步操作后一次并发测试就直接超卖补卡密补到怀疑人生。这里的锁定机制是给卡密加了一个 lock_order_sn 字段表示「这张卡密正在为哪笔订单服务」。这样设计有一个好处如果订单最终超时关闭后台可以按 lock_order_sn 把卡密解绑回可用池这张卡密不会被浪费。注意常见错误做法是直接从卡密池里 delete 这条记录一旦订单支付成功但交付失败你很难找回「该交付哪条卡密」。顺序应该是「锁定 → 支付确认 → 正式消耗」而不是「直接删除 → 出事了再说」。3.3 卡密加密存储为什么用盐值而不是直接明文这套源码的 card_pool 表里有一个 salt 字段这在我拆过的源码里算是比较讲究的。很多发卡网直接把卡密明文存在数据库里一旦数据库被拖库全部卡密瞬间泄露损失的是整个商品池。这套源码的做法是先对卡密原文做加密再入库把解密码和订单交付逻辑绑定。// 加密卡密并写入卡密池后台导入卡密时调用 function encrypt_card($rawCard, $goodsId) { $salt bin2hex(random_bytes(16)); // 每张卡密独立盐值 $key hash(sha256, $salt . $goodsId . CRYPT_KEY); // CRYPT_KEY 为应用级密钥 $encrypted openssl_encrypt($rawCard, AES-128-CBC, $key, 0, substr($salt, 0, 16)); return [ salt $salt, cipher $encrypted, ]; } // 交付卡密时解密买家支付成功后调用 function decrypt_card($row) { $key hash(sha256, $row[salt] . $row[goods_id] . CRYPT_KEY); return openssl_decrypt($row[card_content], AES-128-CBC, $key, 0, substr($row[salt], 0, 16)); }注意这里每个卡密都有独立的盐值目的是让两张相同内容的卡密加密后密文不同避免攻击者通过密文比对推断出商品池的重复度。解密时需要的四个要素是密文、盐值、商品 ID、应用级密钥缺一不可。CRYPT_KEY 在源码的配置文件中统一维护我在实际部署时会建议改成环境变量而不是写在 PHP 文件里这样即使源码被传到公网仓库也不会泄露密钥。这套加密方案的性能开销很小AES-128-CBC 在普通 VPS 上一秒能跑几千次解密不会成为系统瓶颈。但从安全角度的收益却是实实在在的尤其当你的发卡网被拖库或者备份文件泄露时卡密数据不会跟着裸奔。4. 支付回调与 API 发货接入第三方服务时的关键对接点4.1 回调验签一眼识别伪通知支付回调是所有自动发货系统的生命线。回调接口如果验签不严格攻击者可以直接伪造一条「支付成功」的 POST 请求你的系统就会给一个没付钱的用户发卡密。这套源码对主流的支付宝、微信支付、易支付都做了适配核心验签逻辑是这样的// 异步通知处理伪代码路径在 /notify/alipay.php function verify_alipay_notify($postData) { // 第一步剔除 sign 和 sign_type 字段 $signData $postData; unset($signData[sign], $signData[sign_type]); // 第二步按参数名 ASCII 码升序排序拼接成待签名字符串 ksort($signData); $signStr urldecode(http_build_query($signData)); // 第三步用支付宝公钥做 RSA2 验签 $pubKey file_get_contents(ALIPAY_PUBLIC_KEY_PATH); $result openssl_verify($signStr, base64_decode($postData[sign]), $pubKey, OPENSSL_ALGO_SHA256); return $result 1; } // 验签通过后必须校验金额与订单号是否和数据库一致 if (verify_alipay_notify($_POST)) { $orderSn $_POST[out_trade_no]; $paidAmount (float)$_POST[total_amount]; $order getOrderBySn($orderSn); if ($order abs($order[pay_amount] - $paidAmount) 0.01) { // 金额匹配进入发货流程 deliverGoods($orderSn); } }你可能注意到验签之后还有一道金额比对这步非常重要它防止的是「金额替换攻击」——攻击者用自己的订单号伪造一笔小额支付通知。验签只能保证通知是支付宝发的不能保证通知里说的金额就是你要收的金额所以必须把回调里的金额和数据库里订单的应付金额做比对。我一般会用abs($order[pay_amount] - $paidAmount) 0.01来比较浮点数而不是直接因为数据库和 JSON 转换过程中可能出现精度误差。这套源码的支付模块已经封装好了主流程你只需要在配置文件里填上 app_id、商户私钥、支付宝公钥三个参数就能跑支付宝。易支付类第三方支付平台的接入逻辑也类似差别在验签时用的是 MD5 拼接而不是 RSA2签名规则以平台文档为准。4.2 API 发货那些不直接给卡密的虚拟商品该怎么交付不是所有虚拟商品都能用「卡密池」来承载的。我拆这套源码时发现它还支持 API 发货模式也就是买家付款后系统调用一个外部接口去开通服务或获取凭证再把接口返回的数据交付给买家。常见场景包括代充平台对接、账号购买后自动改密、短链接生成服务、实名认证接口等。API 发货的核心配置有三个维度API 地址、请求参数模板、响应解析规则。这套源码里每种商品可以绑定一条 API 模板模板里用占位符代替买家信息和订单信息{ api_url: https://supplier.example.com/api/create, request_method: POST, request_params: { order_id: {order_sn}, product_id: {goods_api_id}, account: {buyer_email}, key: 你的接口密钥 }, response_rule: { success_field: code, success_value: 200, data_field: data.card_id } }你可以把这段 JSON 配置理解为订单上下文和外部接口之间的翻译层花括号里的占位符在发货前会被源码替换成真实值。响应解析规则告诉系统如何判断接口调用成功以及从哪里提取交付内容。这里有一个容易踩坑的地方调用 API 是同步的还是异步的直接影响「交付状态」怎么流转。如果外部接口在 3 秒内返回结果订单可以直接走到 finished如果接口是异步处理的比如提交后需要轮询结果就应该把订单状态设为 delivering并写一个计划任务去轮询外部接口。4.3 部署参数清单这套源码跑起来需要哪些配置所有参数配置都集中在源码的 config/ 目录下我建议你在部署前过一遍这几个关键项参数项默认值我的建议DB_HOST / DB_NAME127.0.0.1 / shops生产环境必须用内网 IP不要暴露公网端口CRYPT_KEY随机字符串长度至少 32 位和数据库密码分开管理ALIPAY_PUBLIC_KEY_PATH证书文件路径注意区分支付宝公钥和商户公钥ORDER_EXPIRE_TIME900 秒保留默认即可太短会导致支付流程中订单过期DELIVERY_RETRY_TIMES3 次首次交付失败会自动重试超过则进入人工队列伪静态规则需要配置Nginx 下要设重写到 index.php订单过期时间这里多说一句很多人以为虚拟商城秒级支付订单过期时间设短点无所谓。但实际支付流程中用户可能在跳转页面停留、输密码、发验证码如果过期时间低于 5 分钟用户还没付款完订单就关了支付成功后回调找不到订单非常尴尬。我一般都保留默认的 900 秒。伪静态规则也是个常见坑。这套源码的 URL 结构是?actionproductid1如果你不做重写页面也能访问但部分支付平台的回调会严格校验 URL 格式而且搜索引擎抓取路径混乱会让后台统计失真。Nginx 下的标准重写规则是location / { if (!-e $request_filename) { rewrite ^/(.*)$ /index.php?route$1 last; } }curl 测试一下curl http://你的域名/index.php?actionproductid1能正常返回商品页就说明伪静态没拦截 PHP 请求。如果你是 Apache 环境.htaccess 同样适用规则写法一致。5. 避坑手册自动发货系统的六个血泪经验5.1 支付回调丢失导致订单卡在 pending现象买家支付宝显示扣款成功但商城订单一直停留在待付款状态卡密没发出去客服开始炸锅。原因支付平台在回调失败时会重试 5 到 9 次但如果你服务器防火墙把回调 IP 封了、回调地址返回了非 2xx 状态码、或接口里某个依赖组件挂了重试也会全部失败订单就永久卡住。解决按这套源码的设计你应该做两件事。第一确保回调接口在验签失败时返回fail而不是500——虽然都是非 2xx但显式返回 fail 可以让支付平台知道「你理解了通知只是验签没过」不会继续刷重试日志。第二写一个补单队列用计划任务扫描「订单状态 pending 且创建时间超过 30 分钟」的订单比对支付平台的交易查询接口确认已支付就手动推进状态。我一般会配置一个每隔 5 分钟的 cron 任务做补单。*/5 * * * * /usr/bin/php /www/wwwroot/shop/cron/order_sync.php /var/log/order_sync.log 215.2 Redis 缓存失效后雪崩式查询现象页面打开很慢数据库 CPU 飙到 100%后台响应要十几秒。原因商品列表和秒杀热门商品的库存查询走了 Redis 缓存但缓存设置了同一时间点集体过期导致瞬间大量 SQL 直接打到底层数据库。解决给缓存过期时间加随机偏移不要所有 key 同时过期。$ttl 600 mt_rand(0, 120); // 基础 10 分钟附加 0~2 分钟随机偏移 $redis-setex($cacheKey, $ttl, $cachedData);5.3 卡密池库存显示有货下单却提示无卡密现象后台明明导入了 100 条卡密前台商品库存也显示 100但买家下单后系统提示「暂无可用的卡密」订单直接关闭。原因这大概率是卡密池里存在脏数据——你导入卡密时某几条的 card_status 字段写入了非法值比如 0 或 99或者之前测试时有卡密被锁定但订单已关闭没有定时任务把这些锁定的卡密释放出来。解决先跑一遍 SQL 体检看锁定状态是否堆积SELECT card_status, COUNT(*) FROM card_pool GROUP BY card_status; SELECT card_id, lock_order_sn FROM card_pool WHERE card_status 2 AND locked_at UNIX_TIMESTAMP() - 3600;正常情况下只有两种状态1 可用、2 已锁定。如果有多于两种的状态或者锁定状态的存在时间超过 30 分钟且关联订单已关闭就说明释放逻辑有问题。5.4 伪静态配置错误导致回调地址 404现象支付宝异步通知显示「通知失败请稍后重试」日志里全是 404 错误。原因Nginx 的伪静态规则没有生效支付平台发起回调时请求的是/notify/alipay.php这个路径被解析成了某个不存在的路由或者你的站点根目录配置指错了。解决这个排查很简单直接在浏览器访问一次回调地址看返回什么。如果是 404检查 Nginx 配置里 root 路径是否正确以及伪静态规则有没有 include 进去。配置好之后手测一次curl -X POST http://你的域名/notify/alipay.php -d out_trade_noTEST123total_amount0.01signtest能返回fail或success的响应就算通路了。5.5 库存超卖并发压力测试没跑就直接上线现象做一次秒杀活动100 份订单同时进来最后卖出去 130 单有 30 个买家付了钱但拿不到卡密。原因库存扣减不是原子的。上游代码里「查询库存 → 判断余额 → 扣减库存」这三步之间存在时间窗口并发高了就超卖。解决改成原子操作这条我在 3.2 节里已经给了示例代码。核心就是用 UPDATE 条件筛选来加行锁而不是先查后改。5.6 日志磁盘写满导致订单状态无法更新现象订单突然全部卡住没有任何报错重启 PHP-FPM 后短暂恢复又卡。原因这套源码默认把日志写在同一个目录访问日志、支付回调日志、错误日志都没有按天分割跑一段时间后磁盘满了数据库写操作超时订单更新自然失败。解决用 logrotate 做日志轮转。/var/log/shop/*.log { daily rotate 30 compress missingok notifempty create 0644 www www }这六条是我在实际部署中最常遇见的前三条是业务逻辑问题后三条是工程问题。你可以把它们当作一套体检清单每次改完代码就从头到脚过一遍。6. 验收清单与自动化测试上线前必跑的六条验证路径在没有测试脚本的情况下验证一套自动发货系统靠的是耐心和细致——手动完整跑完支付流程每一步都记录数据变化。我习惯在每次部署新环境或改完代码之后强制自己走一遍下面这套验收步骤把每个环节的日志留存下来作为下次排错的余量和依据。第一走一遍「卡密商品正常购买」全流程。用沙箱或小额真实支付买一张最低价商品从下单、支付、回调、发货到展示卡密全程记录订单状态变化时间。正常应该在支付后 10 秒内看到订单进入 finished20 秒内收到卡密内容。第二验证「库存耗尽」场景。挑一个库存为 1 的商品故意买两次第二次必须提示无货、订单自动关闭且关闭后卡密池的第一张卡密仍然保持可用状态。第三验证「回调重复通知」。手动用接口工具向回调地址重复发送同一条支付通知订单不能重复发货、不能重复扣减库存。这是幂等性的关键验证。第四验证「API 发货商品」的失败重试。把供应商接口地址改成一个不存在的域名触发发货失败观察订单是否自动进入 delivering 状态并在重试 3 次后正确进入人工处理队列。第五验证后台手动补发。找一笔历史订单在后台执行「补发卡密」确认买家邮箱能重新收到一条完整的提取链接或卡密内容并且卡密池中不产生新的消耗记录。第六验证账户权限。用普通管理员账号登录后台确认只能管理商品和查看订单不能修改配置文件和支付密钥。这六条跑完剩下的就是交给时间和流量来验证了。这套源码我在测试环境里跑了两个多月前后经历了三次重构最深的体会是自动发货系统的核心不在代码写得有多花哨而在异常路径处理得够不够周全。从那以后我每次改完代码都强制走一遍这六条验收路径算是一种自我救赎不想再体验半夜爬起来给买家发卡密的日子了。希望帮到你。本文还有配套的精品资源点击获取
返回列表