简介:新版运营版收卡网源码(ThinkPHP收卡系统)专为卡券回收平台搭建者设计,面向需要快速上线礼品卡、电子券、卡密回收业务的开发者和运营者。系统直接面对用户提交卡号卡密完成回收交易,可解决礼品卡闲置、资金回流缓慢等运营痛点,并提供线上回收、线下交易API等多样化成交方式。资源包大小约57.99MB,共2000个文件,其中包含898个PHP业务逻辑文件、348个JS交互脚本、300个HTML页面、155个CSS样式表,以及SQL数据库文件、配置文件与说明文档,目录结构清晰,便于二次开发与部署。目前已有125人学习下载,源码支持PC与WAP多端访问、多管理员后台、前端多登录方式配置,覆盖商超卡、旅游卡、视频卡、餐券卡等多种卡类回收,内置文章系统、公告系统、卡密回收模式和多种提现渠道,可快速搭建完整运营闭环。需注意资源自带短信接口无法直接使用,需重新对接第三方短信平台后再上线运营。
1. 收卡网建站需求下,ThinkPHP 收卡系统解决的是哪件事
虚拟商品交易里,「收卡网」这个需求从来没断过。它卖的不是实物,而是卡号卡密这类纯数字资产:游戏点卡、视频会员、话费卡、软件激活码。页面上长得像商城,业务核心却只有一个——把库里的卡密按订单准确地交出去,多卖一张或少发一张都算事故。ThinkPHP 在这类 PHP 源码建站项目里长期占主流,从 3.2 到 6.x 都有人用,因为它三件事做得顺:模型层省事、模板渲染直接、对宿主环境要求低。市面标注「运营版」的收卡系统源码,差别基本集中在卡库管理、订单发卡、后台批量操作这三块是否完整。下面按我接手这类项目时的思路,把表结构、并发锁卡、批量导卡和最后的对账兜底依次拆开讲。
2. 收卡系统数据模型:分类、卡密、订单的表设计与状态机
2.1 收卡系统的三张核心表与建表 SQL
先把实体理清。自动发卡业务有且只有三个核心实体:商品分类、卡密、订单。分类相当于货架,卡密是货架上的具体商品,订单记录谁在什么时间买走了多少张。判断一份收卡网源码算不算「运营版」,就看后台对这三个实体管得全不全:分类能上下架,卡密能批量导入导出,订单能查发卡记录。表结构决定了系统能撑多大流量,也决定了后面加功能要不要动表。
分类表和订单表相对简单,关键字段如下:
CREATE TABLE `card_category` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '商品名,如 视频会员周卡', `price` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '售价', `stock` INT NOT NULL DEFAULT 0 COMMENT '剩余卡密数', `sales` INT NOT NULL DEFAULT 0 COMMENT '累计销量', `sort` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '订单号', `category_id` INT NOT NULL DEFAULT 0 COMMENT '商品分类ID', `num` INT NOT NULL DEFAULT 1 COMMENT '购买数量', `amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '实付金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已退款', `pay_time` DATETIME DEFAULT NULL, `contact` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '买家联系方式', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;card_category.stock是一个冗余字段,分类列表页直接读它,不用每次COUNT(*)扫卡密表。代价是每次发卡、退卡都必须同步维护它,这也是后面第三章事务代码里要重点处理的地方。order表用唯一索引uk_order_no挡住重复订单号,支付回调里同样的通知到达两次时,靠它避免建出重复订单。
卡密表是整个系统的核心,字段设计直接决定发卡逻辑怎么写:
CREATE TABLE `card` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `category_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属分类ID', `card_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '卡号', `card_key` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '卡密/附加信息', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未售 1锁定 2已售 3作废', `order_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '售出后的订单ID', `sold_at` DATETIME DEFAULT NULL COMMENT '售出时间', `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注,如回收、异常', PRIMARY KEY (`id`), UNIQUE KEY `uk_card_no` (`card_no`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里两个索引是刻意设计的。idx_category_status是联合索引,发卡动作的查询条件永远是「按分类找未售卡密」,即WHERE category_id=? AND status=0,这个索引能让查询直接命中,不需要回表过滤状态。uk_card_no唯一索引保证导入时重复卡号直接被数据库拦下来,而不是靠应用层先查一遍再插入——两个并发导入任务同时跑时,应用层判断一定会漏。
2.2 卡密状态机的三条流转路径
收卡系统的卡密生命周期只有四种状态,把状态定清楚,后面所有业务逻辑都能落在状态判断上:
| status | 含义 | 进入该状态的触发动作 |
|---|---|---|
| 0 | 未售,可被锁定或售出 | 批量导入、退款回收 |
| 1 | 锁定,订单预占中 | 用户下单但未完成支付 |
| 2 | 已售,完成交付 | 支付回调后发货成功 |
| 3 | 作废,永不可售 | 后台单张作废、退卡异常 |
三条流转路径分别是:导入时写入状态 0;下单时把未售改成锁定,支付成功后把锁定改成已售;退款或发现坏卡时,把已售或未售改成作废。锁定状态是并发控制的第一道闸门,它的存在意义是:一个订单预占了卡密但还没付款,另一笔订单不能把这批卡抢走。有些老源码只有「未售/已售」两个状态,下单逻辑直接改已售,结果用户不付款卡也回不来,这不是功能缺陷,是状态机设计缺失。
2.3 用 ThinkPHP 模型管理关联与分类删除的联动处理
ThinkPHP 6 的模型写法比较干净,收卡系统里卡密与分类是典型的belongsTo关系:
<?php namespace app\common\model; use think\Model; class Card extends Model { protected $name = 'card'; public const STATUS_UNSOLD = 0; public const STATUS_LOCKED = 1; public const STATUS_SOLD = 2; public const STATUS_INVALID = 3; public function category() { return $this->belongsTo(CardCategory::class, 'category_id'); } public function scopeUnsold($query) { return $query->where('status', self::STATUS_UNSOLD); } }这里scopeUnsold是一个查询作用域,发卡时直接Card::unsold()->where('category_id', $id)->limit($num)->select()就能拼出固定条件,避免到处手写where('status', 0)导致漏条件。模型关联还有一个容易被忽略的坑:后台删除分类时,卡密表里的数据不会自动跟着删。ThinkPHP 的关联删除通常指在模型事件里写联动,比如在CardCategory的deleting事件里处理存量卡密——要么禁止删除还有库存的分类,要么把该分类下未售卡密批量标记作废。实际运营里我建议前者:分类下还有未售卡密时直接抛异常,提示运营先清空库存再删分类,误删后的数据恢复比什么都麻烦。
3. 并发发卡与事务边界:锁卡逻辑是收卡系统的命门
3.1 两种发卡时机:支付回调发卡与查询式发卡
收卡网的交付时机决定了代码结构。最常见的做法是「回调即时发卡」:用户下单后跳转支付,支付网关异步通知到达时,在回调里调用发货服务,把卡密写入订单并展示给用户。这个模式的优点是用户体验好,付款后几秒内看到卡密;缺点是回调通知可能延迟、重复甚至丢失,发货代码必须保证幂等。另一种是「查询式发卡」,用户付款后点击查看卡密,系统实时从卡池里取卡交付,卡密压力被分散到用户操作上,体验稍差但实现更简单。两种模式底层用的都是同一套锁卡事务,差别只在触发入口。
3.2 经典事故:先查后改导致同一批卡密被卖两次
很多收卡系统源码的发卡代码长这样,问题非常典型:
// 反例:先查出未售卡密,再更新状态 $cards = Db::name('card') ->where('category_id', $categoryId) ->where('status', 0) ->limit($num) ->select(); Db::name('card') ->where('id', 'in', array_column($cards, 'id')) ->update(['status' => 1, 'order_id' => $orderId]);这段代码在并发场景下必现超卖:两个请求同时执行select,拿到的是同一批status=0的卡密,然后各自执行update,第二批会把第一批刚锁定的卡再次改成已售,最终两个订单拿到同一串卡号。这个问题平时不出现,一旦有活动流量涌进来就炸,而且不会报错,是静默的重复发货,用户表现为「两个人买到同一串卡密」。排查这类问题不要只盯 SQL 语句,要看查询和更新之间是否留了可被并发插足的空档。
3.3 事务内用行锁选卡密的标准写法
正确的发卡逻辑必须把「选卡」和「改状态」放进同一个事务,并且对选卡查询加排他锁。ThinkPHP 的链式查询里用lock(true)生成SELECT ... FOR UPDATE:
Db::transaction(function () use ($categoryId, $num, $orderId) { // 1. 锁住分类行,防止并发下 stock 判断失效 $category = Db::name('card_category') ->lock(true) ->where('id', $categoryId) ->find(); if (!$category || $category['stock'] < $num) { throw new \Exception('库存不足'); } // 2. 在事务内锁定未售卡密行 $cards = Db::name('card') ->lock(true) ->where('category_id', $categoryId) ->where('status', 0) ->order('id') ->limit($num) ->select(); if (count($cards) < $num) { throw new \Exception('可售卡密不足,请补卡'); } // 3. 更新卡密状态 $ids = array_column($cards, 'id'); Db::name('card') ->where('id', 'in', $ids) ->update([ 'status' => 2, 'order_id' => $orderId, 'sold_at' => date('Y-m-d H:i:s'), ]); // 4. 联动更新分类库存 Db::name('card_category') ->where('id', $categoryId) ->dec('stock', $num) ->inc('sales', $num) ->update(); return $cards; });这段代码的逻辑分四步:先锁分类行,FOR UPDATE会让其他事务对这个分类的锁等待,直到当前事务提交;再锁卡密行,同样加排他锁,第二个并发的发货事务此时会阻塞在这里,等第一个提交后才能继续查;第三步批量更新卡密状态,order_id和sold_at一起写入,保证审计可追溯;第四步更新分类冗余库存。整个事务提交后锁才释放,并发请求会一个一个排队通过,不会出现超卖。
这里需要着重说明锁的顺序:所有发货事务都先锁分类行、再锁卡密行,顺序一致就不会死锁。如果 A 事务先锁卡密再锁分类,B 事务先锁分类再锁卡密,两边互相等待对方持有的锁,InnoDB 只能靠死锁检测中止其中一个事务,业务上表现为随机失败。ThinkPHP 的lock(true)在底层是给主查询语句追加FOR UPDATE,所以它必须配合Db::transaction使用,脱离事务的锁没有意义。如果用的是 ThinkPHP 3.2,lock(true)的写法一致,但要注意 3.2 的transaction闭包在抛异常后需要手动Db::rollback()的版本差异,建议统一在入口处包一层try/catch。
3.4 事务回滚后的库存校准
即使事务写得对,也会遇到分类stock字段与卡密表实际数量不一致的情况。最常见的原因是:发卡事务提交后,支付回调的后半段逻辑出错,订单被标记失败但卡密已经卖出;或者开发阶段调试时手动改了卡密表,忘了同步分类库存。这时候不能靠人工去数,直接执行一次重算 SQL 把分类库存修正为实际未售数量:
UPDATE card_category c LEFT JOIN ( SELECT category_id, COUNT(*) AS cnt FROM card WHERE status = 0 GROUP BY category_id ) t ON t.category_id = c.id SET c.stock = IFNULL(t.cnt, 0);这段 SQL 把每个分类的库存重算为「状态为未售的卡密条数」。卡密表量级通常在十万行以内,全表扫描加分组完全没问题;如果单分类卡密超过百万,建议在WHERE里加c.id IN (最近有订单的分类ID)缩小重算范围。这个脚本我会放进每天凌晨的定时任务里,输出有差异的分类列表,而不是直接覆盖写入,先人工确认差异来源。
4. 运营后台实战:批量导卡、库存联动与 ThinkPHP 安全适配
4.1 大批量导入卡密的实现与去重策略
运营版和 demo 版的第一个分水岭就是导卡。运营拿到的卡密通常是文本文件,每行一个,格式可能是「卡号」或「卡号----卡密」。导入接口要处理三种情况:分隔符不确定、空行和注释行、重复卡号。下面是完整的导入实现:
public function import(string $raw, int $categoryId): array { $lines = preg_split('/\r\n|\r|\n/', $raw); $success = 0; $failed = 0; $batch = []; foreach ($lines as $line) { $line = trim($line); if ($line === '' || strpos($line, '#') === 0) { continue; } $sep = strpos($line, '----') !== false ? '----' : '###'; $parts = explode($sep, $line); $batch[] = [ 'category_id' => $categoryId, 'card_no' => trim($parts[0]), 'card_key' => trim($parts[1] ?? ''), 'status' => 0, ]; if (count($batch) >= 500) { [$success, $failed] = $this->flushBatch($batch, $success, $failed); $batch = []; } } if ($batch) { [$success, $failed] = $this->flushBatch($batch, $success, $failed); } Db::name('card_category') ->where('id', $categoryId) ->inc('stock', $success) ->update(); return ['success' => $success, 'failed' => $failed]; } private function flushBatch(array $batch, int $success, int $failed): array { try { Db::name('card')->insertAll($batch); return [$success + count($batch), $failed]; } catch (\PDOException $e) { $ok = 0; foreach ($batch as $row) { try { Db::name('card')->insert($row); $ok++; } catch (\PDOException $ignored) { // 唯一索引冲突,跳过重复卡号 } } return [$success + $ok, $failed + count($batch) - $ok]; } }导入逻辑的关键参数值得说一下:每 500 条一批插入,insertAll在 ThinkPHP 6 里是真正的多值插入,500 条一批既能压住单条 SQL 的包体大小,又不会频繁触发网络往返。分批时先整体插入,撞到uk_card_no唯一索引抛出PDOException后,再退化成逐条插入跳过重复行。这种「先批量后逐条兜底」的策略比一开始就逐条插入快一到两个数量级,同时容量足够大时不会因为一条脏数据让整批失败。导入完成后用inc('stock', $success)联动增加分类库存,这里只加成功数,失败数要展示给运营确认是否为重复数据。
4.2 运营首页的聚合统计与库存警戒
运营后台需要一眼看清卡库状态,最直接的查询是按分类和状态做分组统计:
$stats = Db::name('card') ->field('category_id, status, COUNT(*) AS cnt') ->group('category_id, status') ->select(); // 在 PHP 里组装成二维结构,key 为 category_id 和 status $map = []; foreach ($stats as $row) { $map[$row['category_id']][$row['status']] = $row['cnt']; }这个查询扫全表做分组,卡密量到几十万行时还能接受,再大就要按分类单独缓存计数。库存警戒用分类表自己的条件就行:
$lowStock = Db::name('card_category') ->where('status', 1) ->where('stock', '<=', 50) ->order('stock', 'asc') ->select();50 是预警阈值,实际按补卡周期算:日均销量乘补货提前天数再乘 2,得到的就是安全阈值。低于这个值就提醒运营补卡,等库存完全为零再补,热卖分类会持续处于「有销量无卡可发」的状态,流失的全是真实订单。
4.3 收卡源码部署时的安全配置与 PHP 8 兼容改造
收卡系统是交易类网站,安全性要比普通展示站高一个等级。老生常谈但必须确认的四项:
| 检查项 | 推荐配置 | 说明 |
|---|---|---|
| 调试模式 | app_debug=false | 开启时堆栈和 SQL 会直接暴露给访客 |
| 安装目录 | 删除install目录 | 源码建站最常见的重装攻击入口 |
| 后台入口 | 修改默认 admin 文件名 | 降低被扫描工具命中的概率 |
| 框架版本 | 升级到带安全补丁的版本 | 早期 ThinkPHP 3.2 的远程命令执行漏洞影响面很大 |
早期 ThinkPHP 3.2 爆出过远程命令执行漏洞,修复方式是升级到打补丁的版本,或者把对外路由严格锁死在index模块,禁止直接访问think命名空间下的类。现在再拿老源码部署,我一般直接看它能不能完整跑在 PHP 8 上。ThinkPHP 3.2 源码迁移到 PHP 8 有几个必改点:each()函数被移除,之前用来便利循环的写法要换成foreach;字符串大括号偏移$s{0}语法被移除,改成$s[0];个别扩展包还在用mysql_real_escape_string,PHP 8 里不存在这个函数,要统一换成PDO::quote或参数绑定。改造完用php -l逐文件检查语法,跑一遍下单发卡主流程再上线。
5. 收卡系统的每日对账脚本:订单与卡密的关系校验
5.1 对账脚本要查的四类异常
发卡事务写得再严谨,也保不住运营操作和支付回调里的意外。每日对账的核心不是算钱,而是校验业务规则:每张已售卡密必须对应一笔已支付订单,每个已支付订单必须发出正确数量的卡密。我用一个 ThinkPHP 命令实现,按天跑批:
public function daily(string $date): void { // 1. 已售卡密找不到对应订单 $orphanCards = Db::name('card') ->where('status', 2) ->where('sold_at', 'between', [$date . ' 00:00:00', $date . ' 23:59:59']) ->whereNotExists(function ($query) { $query->table('order')->where('order.id = card.order_id'); }) ->count(); // 2. 已支付订单没有发出任何卡密 $unDelivered = Db::name('order') ->where('status', 1) ->where('pay_time', 'between', [$date . ' 00:00:00', $date . ' 23:59:59']) ->whereNotExists(function ($query) { $query->table('card')->where('card.order_id = order.id'); }) ->count(); // 3. 单笔订单发出的卡密数量与订单购买数量不一致 $numMismatch = Db::name('card') ->where('status', 2) ->where('sold_at', 'between', [$date . ' 00:00:00', $date . ' 23:59:59']) ->field('order_id, COUNT(*) AS cnt') ->group('order_id') ->having('cnt', '!=', 'order.num') // 需要联表,实际用子查询 ->select(); }第三种情况在 ThinkPHP 里可以直接联order表用whereColumn对比,也可以在 SQL 里写子查询。对账脚本的输出我建议做成「只报告差异,不自动修复」,因为修复动作本身也可能出错,人工判断后再处理更稳妥。发现orphanCards大于零,优先查是不是手动改过卡密状态或误删订单;发现unDelivered大于零,立即补发卡密给用户,这是最严重的资损场景。
5.2 挂到定时任务里,让对账成为日常动作
对账脚本要跑得可靠,我把它做成think命令,便于复用 ThinkPHP 的日志和数据库连接:
0 3 * * * php /www/wwwroot/card/think reconcile --date=$(date -d "yesterday" +%F) >> /www/wwwroot/card/runtime/reconcile.log 2>&1凌晨三点跑有两个考虑:此时支付回调几乎不会进来,数据处于相对静止状态,查出来的差异不会被在途事务干扰;同时也不会和半夜的备份脚本抢 IO。上线第一周建议把脚本输出的差异摘要推送到企业微信群或者钉钉群,让负责运营的人每天都能看到三个计数;连续三天全为零之后,再把通知收敛为只报异常。等到某天脚本报出第一条记录时,你已经有了七天基线数据可以对照。
对账脚本本身没有技术含量,难的是把「每张卡都有归属」这条业务规则固化成可重复执行的检查。补上这一环,收卡系统的闭环才算完整——从导卡入库、并发售出到每日校验,每一步都有据可查。
本文还有配套的精品资源,点击获取