ThinkPHP收卡网二开实战:订单设计、安全加固与PHP8兼容
2026/9/16 4:43:04 网站建设 项目流程

简介:百分百收卡网礼品卡兑换二手礼品卡回收网站源码是一套基于Thinkphp框架开发的礼品卡在线回收交易平台,适用于想快速搭建礼品卡转让、回收、变现网站的开发者与企业运营者,可解决礼品卡闲置浪费、资金回流慢等痛点。源码支持用户提交卡号卡密完成线上回收交易,提供线下回收、API接口对接及自定义交易方式,帮助平台实现交易多样化;包含后台配置、伪静态规则等关键模块,基于Apache+Win环境部署,导入数据库、修改conf/config.php即可运行,适合二次开发或直接商用。资源包大小约46.96MB,内置Thinkphp工程代码、数据库导入文件及站点配置文件,目录结构清晰,便于开发者快速定位功能模块。目前已有655人浏览学习,适合具备一定PHP基础、希望低成本获取完整回收站解决方案的读者参考。

1. 拿到 Thinkphp 收卡网源码,先分清它解决的业务问题

二手礼品卡回收是很实的生意:用户手里有京东E卡、天猫超市卡这类闲置卡券,平台按当日折扣收进,再转售或核销赚差价。「百分百收卡网」,核心就是让用户自助提交卡号卡密、按卡种面值实时出价、运营后台审核打款。这类 PHP 源码包大多基于 ThinkPHP 3.2.x,前台下单、会员中心、后台管理三件套齐全。

流传源码有个共同点:业务不复杂,复杂的是安全和兼容。ThinkPHP 历史公开漏洞不少,卡密要加密存储,订单折扣必须下单时锁定,老框架在新版 PHP 上还会报一串兼容错误,这些才决定源码能不能真正上线。下面按最常见的落地路径捋:定数据模型、写主流程、安全加固、PHP 8 兼容与每日对账。

2. 收卡网数据模型:卡种、卡密与回收订单的三张核心表

2.1 先分清「回收」和「兑换」共用一张订单表的理由

标题把「礼品卡兑换」和「二手礼品卡回收」放在一起,真实业务往往是两条线:回收是把卡折现打给用户,兑换是用户拿卡换平台上的话费、油卡等虚拟商品。做二开时最省事的做法不是建两套订单表,而是在一张回收订单表上加order_type字段,0 表示回收折现,1 表示兑换商品。

这样设计的好处是审核、状态流转、对账逻辑完全共用一套后台。差别只出现在订单创建后的处理分支:回收单后续走打款,兑换单后续走发货核销。如果流传源码里已经是两套表,我会先评估订单量再决定是否合并——量小完全没必要动表结构,避免引入无谓的迁移风险。

2.2 卡种表和订单表的建表 SQL 与字段约定

卡种表承载的是「这个站能收什么、按什么折扣收」。折扣和可收面值都放配置表,不要散落在代码常量里,运营才能在后台随时调价而不碰代码:

CREATE TABLE `card_type` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '卡种名称,如京东E卡', `face_values` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '可收面值,逗号分隔:50,100,200,500', `base_rate` DECIMAL(5,4) NOT NULL DEFAULT '0.0000' COMMENT '基准回收折扣,0.9200 表示 92 折收', `pwd_regex` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '卡密格式正则,提交时预校验', `status` TINYINT(1) NOT NULL DEFAULT '1' COMMENT '1收卡 0停收', `update_time` INT(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卡种配置表';

base_rate用 DECIMAL(5,4),折扣要精确到小数点后四位,比如 0.9245,float 在计算金额时容易出现 0.30000000000000004 这类误差。face_values存成逗号分隔字符串即可,量级不会大到需要单独建面值档位表,但提交订单时必须校验面值属于档位内,否则用户填个 88 元就能绕过比价。

回收订单表是整站最核心的表,字段设计直接决定审核和打款好不好写:

CREATE TABLE `recycle_order` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务单号,展示给用户', `user_id` INT NOT NULL DEFAULT 0, `card_type_id` INT NOT NULL, `order_type` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '0回收折现 1兑换商品', `face_value` DECIMAL(10,2) NOT NULL COMMENT '卡面值', `rate` DECIMAL(5,4) NOT NULL COMMENT '成交折扣,下单时锁定,禁止审核时重查', `pay_amount` DECIMAL(10,2) NOT NULL COMMENT '应付金额 = 面值 × rate', `card_no` VARCHAR(64) NOT NULL COMMENT '卡号', `card_pwd` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '卡密,AES 加密后入库', `status` TINYINT(1) NOT NULL DEFAULT '0', `reject_reason` VARCHAR(255) NOT NULL DEFAULT '', `create_time` INT(11) NOT NULL DEFAULT 0, `audit_time` INT(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`), KEY `idx_card_no` (`card_no`), KEY `idx_user_status` (`user_id`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回收订单表';

网上有些源码在card_no上加唯一索引,意图是防同一张卡重复提交,但一旦加上,用户提交错卡密被驳回后,想重提同一张卡会被数据库直接挡死。所以这里只保留普通索引,防重用应用层判断。card_pwd用 VARCHAR(255),给加密后的密文留足空间,别按明文长度设计。

2.3 订单状态机的约定与折扣快照

订单状态是收卡系统的灵魂,前后台所有操作都是在改这个字段:

status含义谁触发后续动作
0待审核用户提交运营核验卡密
1已回收审核通过进入打款队列
2打款中运营发起等待第三方打款结果
3已完成打款确认归档,卡密置空
4已驳回审核驳回用户可修改重提
5已取消超时自动释放占用

绝大多数踩坑发生在两个地方。一是审核时重查折扣:审核页从card_type表实时取base_rate计算应付金额,结果运营改过价之后,用户下单时看到的金额和实际到手不一致。正确做法是只信任recycle_order.rate这个下单时的快照,pay_amount也在下单时算好固化。二是状态只能前进不能回跳,比如已回收不允许直接改回已完成,必须经过打款中间态,否则对账会乱。

3. Thinkphp 收卡主流程实现:询价、提交订单与后台审核

3.1 询价接口:查卡种配置并校验面值档位

前台「我要卖卡」页面,用户先选卡种、再选面值,页面通过 AJAX 调询价接口拿回收金额。这个接口只做读操作,不产生任何写库行为,即使用户反复点也不会产生脏数据:

<?php namespace Home\Controller; use Think\Controller; class CardController extends Controller { public function price() { $typeId = I('get.card_type_id', 0, 'intval'); $face = I('get.face_value', 0, 'floatval'); $cardType = M('card_type')->where(['id' => $typeId, 'status' => 1])->find(); if (!$cardType) { $this->ajaxReturn(['code' => 1, 'msg' => '该卡种暂未开通回收']); } $faces = array_map('intval', explode(',', $cardType['face_values'])); if (!in_array((int)$face, $faces, true)) { $this->ajaxReturn(['code' => 1, 'msg' => '当前面值不在回收范围']); } $amount = round($face * $cardType['base_rate'], 2); $this->ajaxReturn(['code' => 0, 'data' => [ 'rate' => $cardType['base_rate'], 'amount' => $amount ]]); } }

三段逻辑各管一件事:卡种存在且收卡中才继续;面值必须在档位内,挡掉大部分乱填请求;金额用round保留两位小数,前端直接展示。注意这里不写 session、不记日志、不做限流,限流放在提交接口,读接口放开即可。

3.2 提交订单:卡号去重、折扣快照、卡密加密

提交接口是整站风险最高的入口,因为它接收卡号和卡密。顺序很重要:先查重、再查卡种、最后落库:

public function submit() { if (!session('?user_id')) { $this->ajaxReturn(['code' => 1, 'msg' => '请先登录']); } $cardTypeId = I('post.card_type_id', 0, 'intval'); $face = I('post.face_value', 0, 'floatval'); $cardNo = trim((string)I('post.card_no')); $cardPwd = trim((string)I('post.card_pwd')); if ($cardNo === '' || $cardPwd === '') { $this->ajaxReturn(['code' => 1, 'msg' => '卡号与卡密不能为空']); } // 同一卡号存在处理中的订单则拒绝,防止反复提交同一张卡 $exists = M('recycle_order')->where(['card_no' => $cardNo])->find(); if ($exists && in_array($exists['status'], [0, 1, 2], true)) { $this->ajaxReturn(['code' => 1, 'msg' => '该卡号已有处理中的订单,请勿重复提交']); } // 下单前再查一次卡种,确认没有停收、面值是否在档位内 $cardType = M('card_type')->where(['id' => $cardTypeId, 'status' => 1])->find(); if (!$cardType) { $this->ajaxReturn(['code' => 1, 'msg' => '该卡种已停收']); } $faces = array_map('intval', explode(',', $cardType['face_values'])); if (!in_array((int)$face, $faces, true)) { $this->ajaxReturn(['code' => 1, 'msg' => '当前面值不在回收范围']); } // 订单号:日期时间 + 用户ID + 随机数,保证唯一且能反查用户 $orderNo = date('YmdHis') . sprintf('%04d', session('user_id')) . mt_rand(1000, 9999); $data = [ 'order_no' => $orderNo, 'user_id' => session('user_id'), 'card_type_id' => $cardTypeId, 'order_type' => 0, 'face_value' => $face, 'rate' => $cardType['base_rate'], 'pay_amount' => round($face * $cardType['base_rate'], 2), 'card_no' => $cardNo, 'card_pwd' => encrypt_card_pwd($cardPwd), // 加密函数定义见 4.3 节 'status' => 0, 'create_time' => time(), ]; $orderId = M('recycle_order')->add($data); if (!$orderId) { $this->ajaxReturn(['code' => 1, 'msg' => '提交失败,请稍后重试']); } $this->ajaxReturn(['code' => 0, 'msg' => '提交成功', 'order_no' => $orderNo]); }

这里的查重比数据库唯一索引更合理:已驳回、已取消的旧单不拦新提交,只有处理中的订单才会挡住。有些源码在用户重提时会先「关联删除」旧单再插新行,我一般只做逻辑作废、不物理删除,保留历史记录方便日后对账。

提示:in_array($exists['status'], [0, 1, 2], true)的判断是刻意的——状态 4(已驳回)、5(已取消)不拦截,允许用户修正后重新提交。如果一刀切查所有状态,用户就再也没有重提机会。

rate取的是下单那一刻的base_rate并直接存进订单表,后续审核、打款都只读这个字段。订单号里带上用户 ID,运营在后台看到单号就能反查归属,不用多一次联表查询。

3.3 后台审核与状态流转:人工核验卡密

后台审核是典型的列表加操作页。运营看到订单后,先按pwd_regex校验卡密格式,再去对应平台核销,最后回到后台点通过或驳回。审核接口代码很短,难的是约束好能到达这里的操作:

public function audit() { if (!session('?admin_id')) { $this->ajaxReturn(['code' => 1, 'msg' => '未登录']); } $orderId = I('post.id', 0, 'intval'); $action = I('post.action', '', 'trim'); $order = M('recycle_order')->where(['id' => $orderId, 'status' => 0])->find(); if (!$order) { $this->ajaxReturn(['code' => 1, 'msg' => '订单不存在或已处理']); } if ($action === 'pass') { // 核销成功才允许置为已回收;核销失败必须走 reject 分支 $save = ['status' => 1, 'audit_time' => time(), 'auditor' => session('admin_name')]; } else { $save = [ 'status' => 4, 'reject_reason' => I('post.reason', '', 'htmlspecialchars'), 'audit_time' => time(), ]; } M('recycle_order')->where(['id' => $orderId])->save($save); $this->ajaxReturn(['code' => 0, 'msg' => '处理完成']); }

where(['id' => $orderId, 'status' => 0])这一行是防重复审核的关键:先锁定目标状态,只有待审核的单子能走到更新逻辑。两个运营同时操作同一单时,后到的那个会拿到「订单不存在或已处理」,避免状态被覆盖。

后台操作前置状态目标状态必须满足的条件
通过01卡密核销成功
驳回04已填写驳回原因
发起打款12金额已二次确认
打款确认23第三方打款结果成功

4. 收卡网上线前必查的 Thinkphp 漏洞面与接口防刷

4.1 ThinkPHP 老版本漏洞清单与最小加固

凡是标题带 Thinkphp 的老源码,打开ThinkPHP/Library/Think/目录基本都能看到 3.2.3 左右的版本。这个版本历史上被公开过的漏洞集中在路由解析和 SQL 注入,漏洞库都有收录,这里只讲面向这类业务的最小加固动作:

风险点常见表现最小处理
路由 RCE构造特定 URL 触发命令执行升级到 3.2.x 修复版;关闭不用的路由模式
SQL 注入I()参数未过滤直接拼 SQL统一走M()链式查询,禁止query()拼字符串
调试页泄露报错时输出完整堆栈和 SQLAPP_DEBUG设为 false
日志泄露Runtime 日志含请求参数DB_SQL_LOG设为 false,Runtime 禁止外部访问
源码泄露.git、备份文件可访问上线前删除 .git,.sql 备份不放 web 目录

其中日志泄露最容易被忽略。ThinkPHP 3.2 开启 SQL 日志后,会在Application/Runtime/Logs里记录带参数的 SQL,如果卡密在写入前没有加密,就等于是明文躺在服务器上。所以生产环境第一件事就是把'DB_SQL_LOG' => false写进配置,并且确认 Nginx 不会把 Runtime 目录当静态文件暴露出去。

4.2 卡密提交接口的频率限制与表单令牌

收卡网的提交接口天然会被脚本盯上:脚本批量提交卡密试探有效性,或者拿接口刷价格。防刷我一般做两层,第一层是表单令牌,第二层是接口频率控制:

// 进入提交页时生成令牌,随表单带回 public function token() { $t = md5(uniqid(mt_rand(), true)); session('submit_token', $t); $this->ajaxReturn(['code' => 0, 'token' => $t]); } // submit() 开头校验令牌,通过后立即销毁,令牌一次一用 $token = I('post.token', '', 'trim'); if ($token === '' || $token !== session('submit_token')) { $this->ajaxReturn(['code' => 1, 'msg' => '页面已过期,请刷新后重试']); } session('submit_token', null);

令牌防的是 CSRF 和重复提交,但拦不住直接 POST 接口的脚本,所以要叠加频率控制。用 ThinkPHP 的S()缓存做轻量计数,不引额外组件:

$key = 'submit_limit_' . session('user_id'); $count = S($key); if ($count !== false && $count >= 5) { $this->ajaxReturn(['code' => 1, 'msg' => '提交过于频繁,请稍后再试']); } S($key, $count === false ? 1 : $count + 1, 300);

这里 300 秒内最多 5 次,针对的是正常用户节奏。如果团队做活动,阈值建议做成后台配置项,而不是硬编码在控制器里。注意S()默认存文件缓存,多台 Web 服务器部署时要换成 Redis 驱动,否则限流计数各算各的。

4.3 卡密加密存储与最小可见性

卡密必须加密后入库,这是收卡平台的底线。常见做法是用应用密钥做 AES 对称加密,密钥放在 web 目录之外的配置文件中:

function encrypt_card_pwd($pwd) { $key = C('CARD_SECRET_KEY'); // 配置在 Application/Common/Conf/extra.php $iv = substr(md5($key), 0, 16); return base64_encode(openssl_encrypt($pwd, 'AES-128-CBC', $key, 0, $iv)); } function decrypt_card_pwd($encrypted) { $key = C('CARD_SECRET_KEY'); $iv = substr(md5($key), 0, 16); return openssl_decrypt(base64_decode($encrypted), 'AES-128-CBC', $key, 0, $iv); }

解密函数只允许在后台审核页调用,任何前端接口和列表页都不应返回卡密明文。另外建议在订单进入完成状态后把card_pwd置空,账已经打完,卡密没有继续保留的必要,数据库被拖库时也能少一列敏感数据。

extra.php要注意两点:一是不要提交进版本库,二是文件权限不要 777,chmod 600更合适。密钥定期轮换时,旧数据要用旧密钥解密后重新加密,所以配置文件里建议预留一个CARD_SECRET_KEY_PRE字段做双密钥兼容。

5. 从 zip 到可用站点:Thinkphp 3.2 兼容 PHP 8 的最小改造与每日对账

5.1 解压部署与两个典型坑

拿到 zip 后先解压到站点目录。包含中文文件名的包,用 Linux 默认 unzip 解出来容易乱码,unzip -O gbk是常见解法。另一类「error read zip archive」报错,通常是 zip 包通过 FTP 上传时被当作文本文件传输导致损坏,重新用二进制模式传一遍即可,不用怀疑源码本身。

unzip -O gbk Thinkphp收卡网源码.zip -d /var/www/html/card-site chown -R www:www /var/www/html/card-site chmod -R 755 /var/www/html/card-site chmod -R 777 /var/www/html/card-site/Application/Runtime

Runtime 必须可写,模板编译、日志、缓存都写在这里。Nginx 下要配好 PATHINFO 重写,否则除了首页全是 404:

location / { if (!-e $request_filename) { rewrite ^/(.*)$ /index.php?s=$1 last; } }

5.2 ThinkPHP 3.2 兼容 PHP 8 的最小补丁

老框架跑新 PHP 的报错通常集中在each()被移除、create_function被移除、花括号字符串偏移失效这几个点。能选环境的话,PHP 7.4 最省事;必须用 PHP 8 时,在入口文件加一段兼容层是最小改法:

// index.php 顶部,PHP 8 环境自动加载 if (PHP_VERSION_ID >= 80000) { if (!function_exists('each')) { function each(&$array) { $key = key($array); $value = current($array); next($array); return $key === null ? false : [$key, $value]; } } }

each()在 TP 3.2 的核心类里还有少量使用,这个 shim 能顶过大部分报错。数据库驱动也要注意,PHP 8 环境必须改成 mysqli:

'DB_TYPE' => 'mysqli', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => 3306,

老 mysql 驱动在 PHP 7 就已经移除,这个配置项不改,站点会直接白屏。

5.3 每天跑一次的对账脚本:校验实付与毛利率

上线后运营最怕的是折扣改错导致亏损。我习惯加一个每日对账任务,凌晨把昨天所有完成订单拉出来,逐单核对实付金额与「面值 × 锁定折扣」是否一致,再按卡种算毛利率:

// Application/Home/Command/ReconcileController.class.php class ReconcileController extends Controller { public function index() { $start = strtotime(date('Y-m-d', strtotime('-1 day'))); $end = $start + 86400; $orders = M('recycle_order') ->where(['status' => 3, 'audit_time' => ['between', [$start, $end]]]) ->select(); foreach ($orders as $o) { $expect = round($o['face_value'] * $o['rate'], 2); if (abs($expect - $o['pay_amount']) > 0.01) { // 金额对不上,写入对账异常表并通知运营 } } } }

挂在 crontab 里每天早上执行:

0 8 * * * /usr/bin/php /var/www/html/card-site/index.php Home/Command/Reconcile/index

脚本跑完把异常订单发到钉钉或企业微信群。这样折扣调整、字段被误改、人工审核出错,都会在每天开盘前暴露,而不是月底对账时才发现钱不对。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询