简介:面向需要快速搭建在线礼品卡回收与兑换平台的开发者或站长,这份PHP源码提供了完整的收卡网功能,涵盖用户提交礼品卡、后台验证与交易处理等环节。资源包共2000个文件,大小47.23MB,以1355个PHP核心文件为主,辅以HTML、CSS、JS前端页面及大量PNG/JPG图片素材,配置文件和SQL文件也一并包含,部署后即可用于个人或商业项目。已有1046人学习下载。对于想了解PHP实战项目的人来说,这套源码不只是可直接运行的站点,还涉及数据库设计、支付接口逻辑、防SQL注入等安全处理,适合结合Laravel等框架知识进行二次开发。压缩包目录结构清晰,前后端资源分离,便于根据业务需求自定义回收价格、兑换规则和页面样式。
1. 收卡网源码解压后,先搞懂礼品卡回收交易的核心模型
从搜索引擎拿到一个叫“收卡网,礼品卡兑换 二手礼品卡回收的网站源码_pass.zip”的压缩包,解压后大概率看到一堆 PHP 文件、后台模板和一个 README。这类礼品卡回收站的源码包并不神秘:它本质是一条“用户交卡密,平台验卡、出价、打款”的交易链路,和普通电商最大的区别是商品不可见、不可退换,钱和卡密都是数字资产。很多人把这套网站源码当 CMS 装上就跑,结果上线第一周就被批量刷单、回调重复入账、订单状态错乱折磨。这篇博文会把礼品卡回收交易中从卡密提交到结算的技术模型、验卡接口时序、资金安全和上线前验证讲透,适合准备二次开发的工程师、做聚合回收的业务方,以及想自建类似卡券交易系统的人。这里不讨论如何部署某个具体 zip 包,而是讲清楚这类系统真正值钱的部分:状态机、幂等、防重和风控。
2. 礼品卡回收兑换系统的数据模型:从卡密到结算的落库设计
2.1 礼品卡回收订单表的字段设计与状态机
二手礼品卡回收的第一件正事不是写接口,而是把订单状态定清楚。常见做法是给订单定义一条单向流转链:待验卡 → 验卡中 → 已出价 → 待打款 → 已结算,分支有驳回和退款中。状态不能设计成简单的“成功/失败”两个值,因为后端需要区分“卡密无效”“卡在平台已被兑换”“汇率变动超时”“用户主动撤回”等不同情况。
我一般会用一张订单主表存储业务过程,卡密和敏感信息尽量不落主表。下面是一份可以直接改用的建表结构:
CREATE TABLE `order_info` ( `order_id` BIGINT UNSIGNED AUTO_INCREMENT COMMENT '订单号', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '提交用户ID', `card_brand` VARCHAR(32) NOT NULL COMMENT '卡种: appstore/steam/amazon', `card_face_value` DECIMAL(12,2) NOT NULL COMMENT '卡面值,单位按卡种约定', `card_code_hash` CHAR(64) NOT NULL COMMENT '卡密SHA-256哈希,用于防重复提交', `card_secret` VARBINARY(512) NOT NULL COMMENT '卡密AES密文', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待验卡 1验卡中 2已出价 3待打款 4已结算 5驳回 6退款中', `quote_amount` DECIMAL(12,2) DEFAULT NULL COMMENT '回收报价金额', `channel_id` INT NOT NULL DEFAULT 0 COMMENT '验卡渠道ID', `callback_no` VARCHAR(64) DEFAULT NULL COMMENT '验卡方回调流水号', `settle_version` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '结算乐观锁版本号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `finish_time` DATETIME DEFAULT NULL COMMENT '完成或驳回时间', PRIMARY KEY (`order_id`), KEY `idx_status_time` (`status`, `create_time`), KEY `idx_card_hash` (`card_code_hash`), KEY `idx_callback_no` (`callback_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='礼品卡回收订单表';这里的card_code_hash是为了快速查出同一张卡是否被重复提交,card_secret单独用 AES 加密存储,避免数据库泄露后卡密被直接消费。card_face_value存的是卡面标示金额,和quote_amount是两个概念,报价是面值乘以汇率后的结果,两边不能混。settle_version是乐观锁版本号,打款时会用到,后面第 4 章会具体说。
还有一个容易遗漏的字段是channel_id。同一个卡种可能对接多个验卡渠道,比如 A 渠道对美区 iTunes 卡验得准,B 渠道对日区更稳,订单落库时必须记住这次验卡走了谁,否则退款或申诉时查无实据。
2.2 收卡网验卡流程的表驱动:卡种、面值、汇率怎么配
验卡流程最忌把卡种配置写死在代码里。这类礼品卡回收站通常同时接 iTunes、Steam、Amazon、Google Play 等卡种,每种卡的面值范围、汇率、验卡方式都不同。我的做法是建一张卡种配置表和一张面值汇率表,用数据驱动流程。
卡种配置表负责定义“这个卡怎么验”:
CREATE TABLE `card_brand_config` ( `brand_code` VARCHAR(32) PRIMARY KEY COMMENT '卡种编码', `brand_name` VARCHAR(64) NOT NULL COMMENT '展示名称', `verify_mode` TINYINT NOT NULL COMMENT '1同步API 2异步回调 3人工复核', `denoms_json` JSON NOT NULL COMMENT '支持面值数组,如[25,50,100]', `api_type` VARCHAR(32) DEFAULT NULL COMMENT '接口类型标识,决定调用哪个适配器', `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用该卡种回收', `sort_order` INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收卡网卡种配置';面值汇率表则按面值单独配回收汇率,小额卡的汇率通常低于大额卡,这是行业的常态做法:
| 卡种 | 面值 | 回收汇率 | 说明 |
|---|---|---|---|
| appstore | 50 | 0.82 | 小额卡流通性差,折扣低 |
| appstore | 100 | 0.86 | 常见面值,流动性好 |
| steam | 50 | 0.80 | Steam 余额变现渠道窄 |
| amazon | 100 | 0.90 | 美亚礼品卡需求稳定 |
前端展示报价时,根据用户选择的卡种和面值实时查汇率表;用户提交卡密后,后端再根据订单里的card_brand和card_face_value重新锁汇率。这里要注意:报价时用的汇率和验卡成功后结算时用的汇率必须一致,否则用户截图报价是 0.86,结算时变成 0.82,投诉率会非常高。常见做法是生成报价单时把汇率rate也冻结进订单表,而不是在订单表里只存最终报价金额。
2.3 为什么订单和卡密要分表,不能塞进同一个字段
很多从网上下载的经典网站源码喜欢把卡密、卡号、订单备注全部塞进一张表,甚至用 text 字段存 JSON。这在礼品卡回收场景里是隐患。订单表是业务数据,客服、财务、日志系统都要查,而卡密是敏感数据,只有验卡和退款两个环节需要明文。把两者分开,订单表可以放心给数据组同步到数仓做分析,卡密密文则只暴露给专门的解密服务。
另一个原因是商品属性不同。一张礼品卡面值 100,用户提交后可能验出实际余额只有 80,或者卡已经被部分消费,这种时候卡密本身成了“证据”,需要和订单分开留存,避免后续复查时被误改。具体的分组策略我建议拆三块:order_info存业务流转,card_secret_box存明文加密信息和哈希,callback_log存验卡渠道返回的原始报文。三张表用order_id关联,card_secret_box的查询权限单独管控,连后台管理员列表都不直接暴露这个表。
3. 二手礼品卡回收的验卡接口对接:同步、异步与回调时序
3.1 礼品卡验卡的两种模式:同步验卡与异步回调
验卡是收卡网最核心的外部依赖。Steam 钱包码、iTunes 兑换码这类标准化卡,一般走同步接口:用户提交后,服务端将卡密 POST 给上游查询接口,上游立即返回卡状态和面额。亚马逊礼品卡和部分地区卡种则经常走异步:上游先返回“已受理”,之后通过回调告知验卡结果。两种方式的取舍不在“哪个先进”,而在上游提供什么能力。
| 对比项 | 同步验卡 | 异步回调 |
|---|---|---|
| 用户等待时间 | 3-10 秒 | 30 秒到数小时 |
| 接口实现 | 简单,发起后等响应 | 需要暴露回调地址,处理重推 |
| 超时处理 | 调上游查单接口确认 | 定期轮询或依赖回调 |
| 适合卡种 | Steam、iTunes 等标准化卡 | 部分高面额卡、人工复核卡 |
同步模式最大的坑是超时后不能直接判失败。上游可能已经收到卡密且验卡成功,只是网络回包超时,如果这边直接驳回,用户会拿回一张已被标记使用的卡。处理办法是超时后调用查询接口确认卡状态,查询也失败才进入人工复核队列。异步模式则要特别注意回调地址稳定,nginx 层不能把回调请求随手 504,建议回调入口单独分离,不走到常规 Web 服务链路里。
3.2 用状态机代码把订单从“待验”推到“待打款”
状态机的价值在于防止非法跳转。比如“驳回”状态不能直接跳到“已结算”,“待验卡”不能直接到“待打款”,每一条路径都必须经过验卡确认。我习惯把状态流转收敛到一个类里:
final class OrderStateMachine { public const PENDING = 0; public const VERIFYING = 1; public const QUOTED = 2; public const WAITING_PAY = 3; public const SETTLED = 4; public const REJECTED = 5; public const REFUNDING = 6; private const ALLOWED = [ self::PENDING => [self::VERIFYING], self::VERIFYING => [self::QUOTED, self::REJECTED], self::QUOTED => [self::WAITING_PAY, self::REFUNDING], self::WAITING_PAY => [self::SETTLED, self::REFUNDING], self::REFUNDING => [self::REJECTED], ]; public static function can(int $from, int $to): bool { return in_array($to, self::ALLOWED[$from] ?? [], true); } public static function transition(int $from, int $to): void { if (!self::can($from, $to)) { throw new InvalidStateTransition($from, $to); } } }ALLOWED数组定义了每个状态允许到达的下一个状态。比如VERIFYING只能去QUOTED或REJECTED,这就杜绝了代码里直接写UPDATE order_info SET status=4导致的越权流转。transition方法在 Service 层调更新语句之前先校验,非法流转直接抛异常。参数$from是数据库读出的旧状态,$to是本次业务动作期望的新状态,两个参数都不从客户端拿,而是由服务端逻辑决定。
状态机配合数据库事务使用,先SELECT ... FOR UPDATE锁行,再走transition校验,最后执行更新。这样可以防止两个人同时处理同一个订单,一个推成“已结算”,另一个推成“驳回”,并发下状态错乱。实际项目里还可以给订单状态加一个 user_id 维度,客服只能在本人操作记录范围内做状态变更。
验卡提交的入口代码大致是这样,核心是先防重、再入队、最后响应:
public function submit(SubmitRequest $req): Order { $hash = hash('sha256', $req->cardSecret); $this->guardNotDuplicate($hash); $this->guardFreqLimit($req->userId, $req->ip); $order = Order::create([ 'user_id' => $req->userId, 'card_brand' => $req->cardBrand, 'card_face_value' => $req->faceValue, 'card_code_hash' => $hash, 'card_secret' => encrypt($req->cardSecret), 'status' => OrderStateMachine::PENDING, ]); $order->save(); Queue::push(new VerifyCardJob($order->orderId)); return $order; }guardNotDuplicate负责查card_code_hash是否已存在,防的是同一张卡被重复提交骗两次报价;guardFreqLimit走的是下一章要说的频控策略。验卡任务放到队列里,是为了避免用户请求直接卡在上游接口的耗时上,提交接口能快速返回“受理成功”,异步 worker 再去调上游验卡。
3.3 回调防重与验签:收卡网最容易被薅的一环
验卡回调接口如果只校验“order_id 存在”就更新状态,等于把打款开关递给攻击者。伪造一个回调让订单直接变成“已出价”甚至“待打款”,接下来就能等着收钱。我一般会给回调加两层防护。
第一层是签名验证。上游回调用 HMAC-SHA256 对原始报文签名,服务端用共享密钥验签,比较时用hash_equals而不是==,避免时序攻击:
public function callback(Request $req): JsonResponse { $payload = $req->post('payload'); $sign = $req->header('X-Callback-Sign'); $expect = hash_hmac('sha256', $payload, getenv('CALLBACK_SECRET')); if (!hash_equals($expect, $sign)) { logger()->warning('callback.sign_invalid', [ 'order_id' => $req->post('order_id'), 'ip' => $req->ip(), ]); return response()->json(['code' => 403, 'message' => 'invalid sign']); } // 业务处理... }CALLBACK_SECRET是上游约定好的密钥,只放在服务端环境变量里,绝对不进数据库。第二层是幂等处理,callback_no字段在表上有唯一索引,重复回调在插入回调日志时会失败,业务更新则用WHERE status = 期望的当前状态的方式保证不会把已结算订单又推一遍。收到重复回调不要当成异常,正常记录日志并返回成功,这样上游不会无限重推。
4. 收卡网的钱款安全:幂等、限流与对账
4.1 结算幂等设计:同一个订单打两次款是事故
用户提交二手礼品卡,平台确认收卡后要打款,这个环节一旦重复执行就是实打实的资损。常见做法是打款前用乐观锁 + 唯一业务号双重保证。乐观锁更新 SQL 带上settle_version,影响行数是 0 说明订单已经被结算过,直接终止:
UPDATE order_info SET status = 4, finish_time = NOW(), quote_amount = CASE WHEN quote_amount IS NULL THEN ? ELSE quote_amount END, settle_version = settle_version + 1 WHERE order_id = ? AND settle_version = ? AND status = 3;这条 SQL 的WHERE条件是三层保障:settle_version保证版本一致,status = 3保证只有“待打款”状态能结算,order_id锁死目标订单。执行后判断affected_rows,为 0 就说明订单已经不是待打款,要么打过了,要么被驳回,绝不能继续往下发支付请求。
打款流水表用batch_no做唯一键,上游支付通道也传同一个业务号,这样即使请求超时重发,通道也会根据业务号自动去重。表格是:
| 字段 | 说明 |
|---|---|
| batch_no | 全局唯一业务号,格式SETTLE_订单号_版本号 |
| order_id | 关联订单 |
| amount | 打款金额 |
| channel | 打款通道编码 |
| status | 处理中/成功/失败/未知 |
| raw_response | 通道原始返回 |
超时后永远走“查单”而不是“重发”,查不到结果时才进入人工介入,这是和第二手交易平台打交道的基本守则。
4.2 防刷与频控:同卡、同IP、同设备的三层限制
收卡网防刷的第一个重点是“同卡反复提交”。用户提交某张卡密被驳回后,换个壳再提交一次,或者拿别人泄露的卡密批量撞库试汇率,都会造成平台损失。我会在提交入口做三重限制。
第一重,同卡哈希 8 小时窗口内不允许重复提交,命中直接提示“该卡正在处理或已被提交”。第二重,同 IP 每分钟提交订单数限制,写成一个 Redis Lua 脚本,保证原子性:
-- 收卡网频控脚本 freq_limit.lua -- KEYS[1] 是频控键,如 freq:user:1001 或 freq:ip:1.2.3.4 -- ARGV[1] 是窗口内最大次数,ARGV[2] 是窗口秒数 local cur = redis.call('INCR', KEYS[1]) if cur == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end if cur > tonumber(ARGV[1]) then return 0 end return 1调用方式redis-cli --eval freq_limit.lua freq:ip:1.2.3.4 , 5 60,含义是键freq:ip:1.2.3.4在 60 秒内最多 5 次。脚本先INCR再判断,首次加 1 后设置过期时间,逻辑上不算复杂,但比自带INCR + EXPIRE两条命令在并发下更安全。第三重是设备维度,前端上报设备指纹,后端只记录不明文存设备号,做风控分析用;纯后端方案可以用 User-Agent 加 IP 的哈希做一个弱维度,够用于拦截同一台机器批量开号。
频控拒绝时要返回一个业务码,让前端正确提示,不要直接 403,否则用户会以为是网站故障。所有被频控拦截的请求都要记录原始参数和 IP,方便事后分析是不是集中套利。
4.3 每日对账SQL:验证流水和订单对得上
礼品卡回收系统上线一周后最容易发现的问题,是某个渠道回调延迟导致订单处于“验卡中”超过 24 小时,或者打款通道回调丢了,订单状态是“待打款”但钱其实已经扣了。我每天会用两个 SQL 做对账。
第一个是核对当日已结算金额和打款流水总额是否一致:
SELECT DATE(finish_time) AS biz_date, COUNT(*) AS settled_cnt, SUM(quote_amount) AS settled_amount FROM order_info WHERE status = 4 AND finish_time >= CURDATE() - INTERVAL 1 DAY GROUP BY DATE(finish_time);第二个是找出状态和流水不一致的孤儿订单,这类订单往往是打款通道回调丢失造成的:
SELECT o.order_id, o.quote_amount, o.user_id, o.status, p.batch_no FROM order_info o LEFT JOIN payment_record p ON p.order_id = o.order_id WHERE o.status = 3 AND o.finish_time IS NOT NULL AND p.id IS NULL;这个查询对“待打款”且已标记完成时间但没有打款流水的订单做了交叉验证。如果发现status = 3但finish_time有值,说明代码路径里状态更新和打款动作没有放在同一个事务边界,需要回到状态机代码里检查是不是有人直接改库。
5. 上线前必做的三件事:压测、日志链路与金额核对
5.1 先打穿回调接口
回调接口最容易成为性能瓶颈,因为上游在高峰时可能会集中重推回调。我常用的办法是拿 wrk 模拟高频回调,观察服务端吞吐:
wrk -t4 -c32 -d60s -s callback.lua http://127.0.0.1:8080/gateway/callbackcallback.lua里根据共享密钥生成正确的签名,保证压测请求能通过验签走到真正业务逻辑。关注点不是 QPS 数字本身,而是该接口在并发下是否出现连接池耗尽、Redis 阻塞或慢 SQL。回调接口虽然是异步上游调用,但它同步占着 Web 进程,任何一个上游超时都可能拖垮整个接口,必要时单独给它开一个进程池。
5.2 用同一请求ID把链路串起来
从提交卡密、验卡、状态流转到打款结算,中间隔着队列、HTTP 调用、外部回调,排查一个问题要翻好几个服务日志。我习惯在入口生成req_id,通过 Logger 上下文一路透传,每行日志都带同一个 ID:
[2025-01-01 12:00:01] [INFO] [req_id=8f0a3c] verify_card.task_started order_id=1001 channel=appstore [2025-01-01 12:00:05] [INFO] [req_id=8f0a3c] verify_card.task_finished order_id=1001 status=success face_value=100 [2025-01-01 12:00:06] [INFO] [req_id=8f0a3c] order.quoted order_id=1001 quote=86.00grep 同一个req_id就能看到这个订单到底走到哪一步丢失的。它不能直接解决问题,但能把排查时间从小时级压缩到分钟级。
5.3 接一个金额抽检脚本
汇率是收卡网利润的生命线,一个位数填错就是全站资损。我上线后会在调度平台挂一个整点抽检任务,取最近一小时的已结算订单,把quote_amount / card_face_value算出的实际汇率和配置表里的汇率比对:
SELECT o.order_id, o.card_brand, o.card_face_value, o.quote_amount, ROUND(o.quote_amount / o.card_face_value, 4) AS actual_rate, c.rate AS expected_rate FROM order_info o JOIN card_denom_config c ON c.brand_code = o.card_brand AND c.denom = o.card_face_value WHERE o.status = 4 AND o.finish_time >= NOW() - INTERVAL 1 HOUR AND ROUND(o.quote_amount / o.card_face_value, 4) <> c.rate;结果集为空说明汇率配置一致,有数据就立刻把订单号推给风控群。注意JOIN的关联条件要同时带上卡种和面值,避免一张面值 50 的卡匹配到面值 100 的汇率。这个抽检脚本在上线第一周每天跑,之后改成每周跑一次就能兜住手动改配置的失误。
本文还有配套的精品资源,点击获取