
简介一套基于PHP的虚拟商品自动发货系统源码专为需要搭建虚拟物品自动售卖、文章付费阅读及会员成长体系的中小站长和开发者设计。系统覆盖自动发货、免登录购买、缺货提醒、支付宝/微信在线支付、QQ/微信快捷登录、VIP会员、积分转换、全站搜索等能力并带响应式模板支持根目录或子目录部署环境配置要求明确。资源包含686个文件大小约15.06MB其中有84个PHP核心文件、93个JS交互脚本、42个CSS样式以及大量GIF/JPG/PNG图片素材、字体图标和SQL数据库备份文件涵盖前后端完整逻辑目录结构清晰部署即可运行。已有365人学习浏览既能帮助快速上线在线自动发货平台也可作为学习PHP二次开发、支付接口集成以及商城模板定制的实用案例尤其适合中小型电商项目的源码参考。1. 虚拟商城在线自动发货源码先把付款后几秒内发货的链路想明白做虚拟商品生意的朋友都体会过卖货五分钟发货两小时的尴尬买家付款后从 Excel 或记事本里翻出卡密、激活码、下载链接一条条复制粘贴发过去。几十单还能扛一到促销上百单漏发、错发、重复发全来了。虚拟商城在线自动发货源码就是把买家付款 → 系统确认 → 自动发货写成代码让支付完成后的几秒内自动跑完不靠人工。这篇按最常见的 PHP MySQL 方案从订单状态、库存表设计讲到支付回调、卡密扣减、超时回滚和发货队列最后是踩坑记录。适合正在选型源码、或发货链路不稳的开发者照着复现。2. 自动发货的业务模型订单、支付、库存三者的状态机市面上的虚拟商城自动发货源码大半是 PHP MySQL少数用 Go 或 Java 重写。换语言不换骨架买家选商品、付款系统收到支付成功的通知后从库存里取一份货交给买家。难点从来不在页面好不好看而在订单、支付、库存三个角色怎么在状态上对齐。状态对不齐就会出现钱付了货没发货发了卡密用了两次这类事故。所以动手写代码之前先把状态机定死。2.1 一条订单从下单到交付要经历哪些状态我把订单状态拆成六个这张表直接写在建表语句的注释里防止后来接手的人自己发挥status含义触发动作0待支付创建订单时锁定库存1已支付待发货支付回调验签通过后写入2发货中异步交付已开始等待结果3已发货交付结果已写入订单4发货失败置入重试队列或转人工5已取消超时未支付或主动取消释放库存新手最容易踩的误区是认为发货是一个瞬间动作。实际上对卡密型商品发货是选一条未售卡密 → 标记已售 → 把内容写回订单三步任何一步失败都会造成库存和订单不一致对 API 回调型商品发货是一次远程请求超时和返回格式错误都常见。所以状态机里必须给发货中和发货失败留位置否则系统根本没有自我修复的余地。流转规则一句话只有待支付能取消只有已支付能进入发货中只有发货中能变成已发货或发货失败。我习惯把状态变更收敛到一个 setStatus 方法里禁止业务代码直接 update 订单表的状态字段这样后面加日志、加 webhook 埋点都方便追查。2.2 商品、卡密库存与订单的数据表设计自动发货的骨架就三张表product、card_stock、orders再加一张 delivery_log 留审计痕迹。核心设计点是 card_stock 里冗余了 order_id 和 sold_at这不是多余字段退款收回卡密、日对账、排查重复发货都靠它。product 表字段id、name、price、delivery_type1 卡密 / 2 链接 / 3 API 回调、status。card_stock 表是自动发货的心脏字段如下字段类型说明idINT UNSIGNED主键product_idINT UNSIGNED所属商品card_contentTEXT卡密、激活码或链接内容statusTINYINT0 未售、1 锁定、2 已售order_idINT UNSIGNED锁定或售出时的订单 idsold_atDATETIME售出时间orders 表主要字段id、order_no唯一、product_id、price、pay_status、delivery_status、card_content冗余一份交付内容、pay_trade_no第三方支付单号、created_at、paid_at、expires_at。标题里提到的在线 100常见理解是支持一次导入上百条卡密并全部走在线自动发货那 card_stock 的导入就是一个批量 INSERT 动作库存表设计不变够几百上千条卡的场景了。2.3 交付方式决定库存结构卡密型、链接型、回调型交付方式不同库存语义完全不同发货模块要按类型分支处理。卡密型最典型库存表每行一条卡密每份货只能卖一次发货等于取一条未售记录并标记已售。链接型则分为可重复卖和限量卖两种网盘链接、软件下载地址可复用库存表存的是链接本身加一个总销量限制限量兑换码则退化成卡密型。API 回调型最特殊常见于发卡平台或充值接口下单付款后调用第三方接口返回的结果就是交付内容本地不需要库存表但要记录第三方返回的凭证否则对账时拿不出证据。我一般会在发货层做统一出口而不是在支付回调里堆 if else。用一个 switch (delivery_type) 分发到三个 deliver 方法每个方法只负责一种交付方式的取货 标记 写回逻辑。这样后面加新的交付方式只要新增一个方法不动既有链路。3. 用 PHP MySQL 跑通最小自动发货闭环这套逻辑看着简单真正落地时坑全在细节里。下面给一个可以直接复现的最小闭环建表、下单锁库存、支付回调触发发货、发货结果写回订单。3.1 工程结构与数据库初始化一个够用的工程目录四个文件加一个 cronshop/ ├── config.php # PDO 连接、支付参数 ├── order.php # 下单接口锁库存 ├── notify.php # 支付异步通知入口 ├── delivery.php # 发货逻辑三种交付方式 └── cron/ └── release_timeout_orders.php # 超时释放库存数据库初始化先建两张核心表。订单表我在上一章列过字段这里只给 card_stock 和订单表的关键 SQLCREATE TABLE card_stock ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, product_id INT UNSIGNED NOT NULL, card_content TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未售 1锁定 2已售, order_id INT UNSIGNED DEFAULT NULL, sold_at DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY idx_product_status (product_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, product_id INT UNSIGNED NOT NULL, price DECIMAL(10,2) NOT NULL, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付, delivery_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未发货 1发货中 2已发货 3失败 4取消, card_content TEXT DEFAULT NULL, pay_trade_no VARCHAR(64) DEFAULT NULL, created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;card_content 用 TEXT 而不是 VARCHAR是因为卡密带账号密码、链接带参数时长度经常超 255而且交付内容里可能有换行和特殊符号TEXT 省心。连接串建议把 charset 设为 utf8mb4卡密内容一旦出现 emoji 或生僻字符utf8 会直接报编码错误。新手从拉下源码到本地跑通快的半天慢的两三天差别基本都在回调环境能不能收到通知、定时任务有没有配这两个点上。3.2 下单与库存锁定一行 SELECT ... FOR UPDATE下单接口做的事事务里锁定一张未售卡密把它标记为锁定同时创建待支付订单。锁定卡密用 SELECT ... FOR UPDATE这是防止并发抢同一张卡的命根子// order.php 下单入口 $pdo-beginTransaction(); try { $sql SELECT id, card_content FROM card_stock WHERE product_id :pid AND status 0 ORDER BY id ASC LIMIT 1 FOR UPDATE; $stmt $pdo-prepare($sql); $stmt-execute([:pid $productId]); $card $stmt-fetch(PDO::FETCH_ASSOC); if (!$card) { throw new RuntimeException(库存不足); } // 标记为锁定并记录占用它的订单号 $lockSql UPDATE card_stock SET status 1, order_id :oid WHERE id :id AND status 0; $lockStmt $pdo-prepare($lockSql); $lockStmt-execute([:oid $orderId, :id $card[id]]); // 创建待支付订单expires_at 给 15 分钟支付超时 $createSql INSERT INTO orders (order_no, product_id, price, expires_at) VALUES (:order_no, :pid, :price, DATE_ADD(NOW(), INTERVAL 15 MINUTE)); $pdo-prepare($createSql)-execute([ :order_no $orderNo, :pid $productId, :price $price, ]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); throw $e; }FOR UPDATE 的作用是把命中的那一行卡密加排他锁另一个请求同时执行同样语句时会被阻塞直到前一个事务提交或回滚然后它再往下查就只能查到下一条未售卡密。锁的范围要尽量小只锁一张卡不要锁整表。卡密取用我默认按 id ASC 拿最早入库的想让买家拿到的卡更随机就把排序换成 ORDER BY RAND() LIMIT 1几千条库存无所谓上了十万条 RAND() 全表扫描会拖慢下单到时候再权衡。3.3 支付回调入口验签通过后才允许触发发货支付平台异步通知是自动发货的扳机。回调入口的原则是验签失败直接拒收验签通过先查幂等再改支付状态然后调发货// notify.php 支付异步通知入口 public function onNotify(array $params): void { if (!$this-verifySign($params)) { http_response_code(400); exit(bad sign); } $order $this-findOrderByTradeNo($params[trade_no]); if (!$order) { // 支付单号查不到订单订单号被篡改或通知带错记日志后退出 exit(order not found); } // 幂等已经处理过这笔通知直接返回成功 if ($order[pay_status] 1) { exit(success); } // 改支付状态再去发货 $this-markPaid($order[id], $params[trade_no]); $this-deliver($order[id]); exit(success); }verifySign 里的参数拼接顺序、签名算法要和支付平台文档完全一致哪怕少拼一个字段验签也会失败。注意这里用 pay_trade_no 反查订单而不是用自己生成的 order_no 去匹配因为有些支付平台的异步通知里 trade_no 才是全局唯一的支付凭证order_no 只是商户订单号。回调接口必须返回平台约定的成功标识否则平台会按失败处理、反复重推通知配合幂等逻辑反而成了压力测试。3.4 发货执行三种交付方式的统一出口发货层的统一出口按 delivery_type 分支每种类型只负责自己的取货 标记 写回// delivery.php public function deliver(int $orderId): void { $order $this-getOrder($orderId); $this-setDeliveryStatus($orderId, 1); // 置为发货中 switch ($order[delivery_type]) { case 1: // 卡密型 $payload $this-deliverCard($order); break; case 2: // 链接型 $payload $this-deliverLink($order); break; case 3: // API 回调型 $payload $this-deliverApi($order); break; default: $this-setDeliveryStatus($orderId, 4); return; } $this-setDelivered($orderId, $payload); }卡密型发货方法单独看private function deliverCard(array $order): string { $sql UPDATE card_stock SET status 2, sold_at NOW() WHERE order_id :oid AND status 1 LIMIT 1; // 执行成功且影响行数为 1说明这张卡确实被当前订单锁定 // 影响行数为 0说明锁定关系已被破坏抛异常走发货失败 $row $this-fetchCardByOrder($order[id]); return $row[card_content]; }更新条件里带 order_id 和 status 1是防止把别人锁定的卡发出去。发货结果无论成功失败都要写进订单表并记 delivery_log后面排查到底发没发、发了什么全靠这份日志。发货异常不要吞掉抛到调用层让订单进入发货失败状态而不是假装成功。4. 把支付回调接到发货链路验签、幂等与超时释放上一章的闭环能跑但离能上线还差三件事验签的可靠性、重复通知的拦截、超时订单的库存释放。这三件事不做系统就是一颗定时炸弹。4.1 验签在前、发货在后顺序不能反回调参数先验签再处理业务顺序反了等于裸奔。签名验证通常是拼接参数加密钥做哈希对比平台传回的 sign。需要特别注意参数要按平台指定的规则排序并去除空值拼接时的键值对顺序错了或者带了空串签名永远对不上。见过不少人在这上面折腾一下午最后发现是数组里混进了一个 NULL 字段没过滤。验签代码定位在 notify.php 最前面失败必须返回非成功标识并记录告警日志。我见过一种隐蔽问题验签只在测试环境打开上线时被注释掉了理由是本地调试方便结果支付通知直接裸奔。验签的开销只有一次哈希计算不值得为省这点开销去赌安全性。4.2 幂等同一笔支付通知来一百次也只能发一次货支付平台的重推机制很执着网络抖动、你的回调超时、返回格式不对它都会反复推送同一笔通知最长能持续几天。幂等做不好后果就是同一笔订单发出两张卡库存悄悄就少了。第一层幂等靠业务判断订单 pay_status 已经是 1直接返回成功不再执行发货。第二层靠数据库唯一约束在 orders 表给 pay_trade_no 加唯一索引支付成功回调里先尝试把 pay_trade_no 写入违反唯一约束说明这笔支付已被处理过。两层一起用能挡住同一毫秒内的并发重复通知ALTER TABLE orders ADD UNIQUE KEY uk_pay_trade_no (pay_trade_no);写入第三方支付单号的动作要和置已支付状态放在同一个事务里避免出现状态已支付但单号没写进去的中间态。如果发现同一条支付单号对应到本地两个不同订单基本可以判断是创建订单时幂等没做好需要回到下单接口查 order_no 的生成和查重逻辑。4.3 超时未支付订单的库存回滚定时任务释放锁定卡密买家下单锁定了卡密但不付款如果不处理半小时后这张卡就永远卡在 status1库存越来越少。释放逻辑用 cron 定时扫// cron/release_timeout_orders.php $sql SELECT id FROM orders WHERE pay_status 0 AND expires_at NOW() AND delivery_status 0 LIMIT 100; $rows $pdo-query($sql)-fetchAll(); foreach ($rows as $row) { $pdo-beginTransaction(); try { // 卡密回到未售状态同时清掉 order_id 占用标记 $releaseSql UPDATE card_stock SET status 0, order_id NULL WHERE order_id :oid AND status 1; $pdo-prepare($releaseSql)-execute([:oid $row[id]]); // 订单置为取消 $cancelSql UPDATE orders SET delivery_status 4 WHERE id :id AND pay_status 0; $pdo-prepare($cancelSql)-execute([:id $row[id]]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); } }这里有一个决定成败的细节释放卡密的 UPDATE 语句里 WHERE 带 status 1。如果在释放前买家恰好完成了支付这笔订单的 paid_at 已经写入、发货流程已在事务里把卡密改成 status 2释放语句的影响行数就是 0不会把已售出的卡放回去。cron 的执行频率建议每 5 到 10 分钟一次LAMP 环境直接加到 crontab 就行。释放动作必须和订单取消放同一个事务不能先取消订单再释放库存中间崩溃会让两边对不上。5. 自动发货最容易翻车的五个点踩坑记录这些都是我实打实踩过的坑每一条都按现象 → 原因 → 解决写清楚给正在上线的你一个后悔药。5.1 卡密被重复发给两个人并发抢同一张库存现象促销期间两张订单拿到同一张卡密。原因发货时没有对卡密行加锁两个请求同时查到 status 0 的同一行先后都标记成了自己订单的。解决下单锁库存用 SELECT ... FOR UPDATE发货更新卡密状态时 WHERE 条件带 order_id 和 status 1并且检查 UPDATE 影响行数为 0 立即停止发货走失败重试。5.2 同一笔支付通知处理两次库存对不上账现象订单只有一单card_stock 里却多了两条已售记录。原因支付平台重推通知回调里没做幂等判断第二次通知又把发货流程跑了一遍。解决回调入口先查 pay_status 是否已为 1是则直接返回成功再给 pay_trade_no 加唯一索引做兜底。两层都上了这个坑才算填平。5.3 卡密扣了但发货失败钱货两空现象订单显示已支付库存里卡密变成已售买家却没收到卡。原因发货流程在标记已售和写回订单之间抛异常异常被吞掉订单状态停在发货中。解决发货全程不吞异常卡密标记已售和写回订单的交付内容要保证要么都成功要么都回滚。回滚实现就用本章前段的事务写法标记和写回放同一事务。5.4 超时订单的库存一直不释放库存越卖越少现象没卖多少单库存却少了后台看不到原因。原因下单锁了卡密买家没付款没有任何任务把这些卡释放回 status 0。解决配 cron 跑超时释放脚本频率 5 到 10 分钟一次。上线后第一次跑完解放出来的锁定卡会自动进入可售池不用手工改数据库。5.5 特殊字符把交付内容弄成乱码发货成功但货不对板现象买家收到卡密是一串乱码其实是 HTML 把 之类的符号吃掉了。原因卡密内容直接拼进了 HTML 或 JSON 响应没有做转义有的卡密带换行输出到页面时换行也丢了。解决展示层用 htmlspecialchars 转义存数据库统一 utf8mb4卡密里的换行在展示时用 nl2br 还原。这一条属于不注意就很隐蔽、一注意就花不了十分钟的问题但买家体验差别巨大。6. 进阶给发货加一道保障——交付队列、重试与日对账6.1 用一张表做交付队列重试才不靠玄学同步发货的问题在于支付回调里直接调用第三方 API第三方超时五秒你的回调就要等五秒更糟的是回调进程一崩这次发货就永远丢了。我现在的做法是加一张 delivery_queue 表回调里只负责把订单置为已支付、丢进队列真正发货由独立 worker 消费CREATE TABLE delivery_queue ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_id INT UNSIGNED NOT NULL, retry_count TINYINT NOT NULL DEFAULT 0, next_run_at DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待处理 1成功 2失败, PRIMARY KEY (id), KEY idx_status_time (status, next_run_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;worker 每十秒拉一次到期任务MySQL 8.0 可以用 SELECT ... FOR UPDATE SKIP LOCKED 实现多 worker 互不抢占5.7 不支持 SKIP LOCKED我退而求其次用先 UPDATE 一个 running 状态再 SELECT或直接单 worker 加锁。发货失败的任务 retry_count 加一next_run_at 按指数退避往后推重试超过五次转人工。这一套下来发货的成功率从看运气变成可以度量月底对账单时能拿出确切的失败数和重试次数。6.2 日对账脚本和我的收尾习惯最后加一个每天凌晨跑的对账脚本找出支付状态是已支付但交付结果为空、或交付状态卡在发货中超过一小时的所有订单。这类订单数量少但每一条都对应一个可能来投诉的买家。脚本不加自动补发只告警补发动作我留着人工确认避免误判造成二次发货。做自动发货这几年我养成的习惯是任何一次发货动作都必须能回答发给了谁、发的是什么、什么时候发的这三个问题交付日志表就是答案。宁可多写两行日志也不要在出问题时对着一张黑洞洞的数据库发呆。希望这份从状态机到队列的完整思路能帮你把自动发货这一步走稳。本文还有配套的精品资源点击获取