ARTICLE DETAIL

资讯详情

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

ThinkPHP收卡系统实践:卡密表设计、并发锁卡与对账机制

ThinkPHP收卡系统实践:卡密表设计、并发锁卡与对账机制 简介新版运营版收卡网源码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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里两个索引是刻意设计的。idx_category_status是联合索引发卡动作的查询条件永远是「按分类找未售卡密」即WHERE category_id? AND status0这个索引能让查询直接命中不需要回表过滤状态。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拿到的是同一批status0的卡密然后各自执行update第二批会把第一批刚锁定的卡再次改成已售最终两个订单拿到同一串卡号。这个问题平时不出现一旦有活动流量涌进来就炸而且不会报错是静默的重复发货用户表现为「两个人买到同一串卡密」。排查这类问题不要只盯 SQL 语句要看查询和更新之间是否留了可被并发插足的空档。3.3 事务内用行锁选卡密的标准写法正确的发卡逻辑必须把「选卡」和「改状态」放进同一个事务并且对选卡查询加排他锁。ThinkPHP 的链式查询里用lock(true)生成SELECT ... FOR UPDATEDb::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.2lock(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_debugfalse开启时堆栈和 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_stringPHP 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 21凌晨三点跑有两个考虑此时支付回调几乎不会进来数据处于相对静止状态查出来的差异不会被在途事务干扰同时也不会和半夜的备份脚本抢 IO。上线第一周建议把脚本输出的差异摘要推送到企业微信群或者钉钉群让负责运营的人每天都能看到三个计数连续三天全为零之后再把通知收敛为只报异常。等到某天脚本报出第一条记录时你已经有了七天基线数据可以对照。对账脚本本身没有技术含量难的是把「每张卡都有归属」这条业务规则固化成可重复执行的检查。补上这一环收卡系统的闭环才算完整——从导卡入库、并发售出到每日校验每一步都有据可查。本文还有配套的精品资源点击获取
返回列表