☰
PHP+MySQL油卡回收商城系统源码:数据库设计与流程实现
2026/10/8 5:05:32 网站建设 项目流程

简介:油卡回收商城系统源码,是一套基于 PHP+MySQL 开发的完整电商项目,适合具备一定后端基础、希望快速搭建废卡/油卡回收或积分抵扣类商城的开发者参考,整体结构完整,前后台分离。系统对运行环境要求不高,兼容 Windows/Linux,内置后台管理、会员中心、支付密码校验等常见模块,并附带数据库还原文件、后台入口以及测试账号说明,便于本地部署与二次开发;部署时注意放开 PHP 禁用函数,避免功能异常。资源包共含2005个文件,主要由 PHP 业务逻辑、HTML 页面、JS 交互脚本、CSS 样式表构成,另有大量 PNG/JPG/GIF 图片素材与 SQL 数据库文件,压缩包约174.45MB,目录分层清晰,方便按功能定位代码。目前已有151人学习下载,适合用来研究商城项目的订单流程、后台配置方式及模板改动思路,也可在合法合规前提下自由扩展和调整。

1. 油卡回收商城系统源码:PHP+MySQL 扛起的卡券回收生意

收油卡这行,十个往里走的九个栽在“单子一多就乱”上:卡密躺在聊天记录里,打款凭截图,账目用 Excel 记。油卡回收商城系统源码,就是把这套“用户提交油卡→自动估价→审核→打款→二次转售”的闭环做成 PHP+MySQL 网站程序,让收卡平台具备前台提交、后台审核、会员余额、卡密转售这些完整能力。它适合两类人:一类是手里有加油卡渠道、想正经做回收站的个人创业者;另一类是接外包或者想搞副业的 PHP 开发,想拿这套源码撬开本地生活服务市场。动手之前得先把业务模型搞清楚——它不是普通商城,是“先收后卖”的类金融系统,设计错一张表后面全部翻车。

2. 回收不是普通商城:数据库设计先立住三张核心表

油卡回收和普通商城最大的区别在于:商城是用户付钱买东西,回收是用户把卡卖给你、平台先垫钱再转售。业务链路决定表设计,它必须同时支撑“收”和“卖”两条流水。我一般会先把三张表画清楚:用户账户表、油卡回收订单表、价格配置表。订单表是中间枢纽,左边接用户,右边接价格,后边挂着转售状态,一张表承担了整套系统的黑匣子角色。

2.1 用户账户表:余额、实名、推荐关系怎么落

用户表不能只存用户名和密码。油卡回收涉及打款,所以余额、冻结余额、实名信息、推荐人关系都得进表。冻结余额是为了配合“审核中预占金额”的场景,比如用户提交了 1000 元油卡,风控审核期间这笔钱要显示为冻结,审核驳回再解冻,不能直接进可用余额。

常见设计:余额和冻结余额用 DECIMAL(10,2),不要用 FLOAT。MySQL 里 FLOAT 是近似值,做金额聚合时会出现 0.01 的偏差,这种误差在财务对账时非常难查。手机号和身份证是打款核验用的,建议单独加唯一索引,防止一个用户注册一堆小号反复薅首充补贴。

字段类型说明
idINT UNSIGNED AUTO_INCREMENT主键
usernameVARCHAR(32)登录名,唯一索引
password_hashVARCHAR(255)password_hash() 输出,不能存明文
real_nameVARCHAR(32)实名姓名,打款用
id_cardVARCHAR(18)身份证,打款核验用
phoneVARCHAR(20)手机号
balanceDECIMAL(10,2) DEFAULT 0.00可用余额
frozen_balanceDECIMAL(10,2) DEFAULT 0.00审核中冻结金额
referrer_idINT DEFAULT 0推荐人 ID,0 表示无
roleTINYINT DEFAULT 00 普通用户,1 管理员
statusTINYINT DEFAULT 11 正常,0 禁用
create_timeDATETIME注册时间

对应的建表 SQL:

CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL, `password_hash` VARCHAR(255) NOT NULL, `real_name` VARCHAR(32) DEFAULT '', `id_card` VARCHAR(18) DEFAULT '', `phone` VARCHAR(20) DEFAULT '', `balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00, `frozen_balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00, `referrer_id` INT DEFAULT 0, `role` TINYINT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1, `create_time` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), KEY `idx_referrer` (`referrer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户账户表';

字段注释里已经标了每列的用途。引擎必须用 InnoDB,因为后续打款要在事务里同时更新用户余额和订单状态,MyISAM 不支持事务,一旦扣款中途失败就是脏数据。字符集用 utf8mb4,兼容表情符号。如果你下载的源码里带的是 utf8 旧库,导入前先在编辑器全局替换为 utf8mb4,不然用户昵称里带个 emoji 就写入失败。

2.2 油卡回收订单表:卡号、卡密、面额、状态机

oil_order 表是整套系统的数据核心。它的特殊性在于:除了常规订单字段,还必须存卡号和卡密。卡号是用户手里的实体卡编号,卡密是充值密码,后者属于敏感信息,不能明文落库,后面避坑章节专门说。这里先把字段结构立住。

二次销售字段也要提前设计好。油卡回收平台的利润来自“低价收、原价卖”的差价,订单收进来之后还要经历“待转售、已售出”的过程。所以订单表里除了回收侧字段,还要有 sold_status、sold_price、sold_at 这些转售字段。如果等系统上线后再加字段,MySQL 大表 ALTER 会锁表,高峰期直接把业务锁死。

字段类型说明
idINT UNSIGNED AUTO_INCREMENT主键
order_noVARCHAR(32)业务订单号,唯一
user_idINT提交用户 ID
card_typeTINYINT1 中石油,2 中石化
card_noVARCHAR(32)油卡卡号,加唯一索引
card_pwdVARBINARY(255)AES 加密后的卡密
face_valueDECIMAL(10,2)卡面额
recycle_rateDECIMAL(5,2)回收折扣率,如 95.00 表示 95 折
service_feeDECIMAL(10,2)服务费
recycle_priceDECIMAL(10,2)实付回收价
statusTINYINT状态机:0 待审核,1 已通过待打款,2 已打款,3 已转售,-1 已驳回
audit_byINT审核管理员 ID
audit_remarkVARCHAR(255)审核备注
paid_atDATETIME打款时间
sold_statusTINYINT DEFAULT 00 未售,1 已售
sold_priceDECIMAL(10,2) DEFAULT 0.00转售价格
sold_atDATETIME转售时间
create_timeDATETIME提交时间

建表 SQL 里唯一索引是重点:

CREATE TABLE `oil_order` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` INT NOT NULL, `card_type` TINYINT NOT NULL, `card_no` VARCHAR(32) NOT NULL, `card_pwd` VARBINARY(255) NOT NULL, `face_value` DECIMAL(10,2) NOT NULL, `recycle_rate` DECIMAL(5,2) NOT NULL, `service_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00, `recycle_price` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, `audit_by` INT DEFAULT NULL, `audit_remark` VARCHAR(255) DEFAULT '', `paid_at` DATETIME DEFAULT NULL, `sold_status` TINYINT NOT NULL DEFAULT 0, `sold_price` DECIMAL(10,2) DEFAULT 0.00, `sold_at` DATETIME DEFAULT NULL, `create_time` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_card_no_active` (`card_no`), KEY `idx_user_status` (`user_id`, `status`), KEY `idx_status_create` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='油卡回收订单表';

uk_card_no_active这个唯一索引是防重复提交的第一道闸门。同一张卡号在表里只能存在一条记录,用户反复提交同一张卡会直接报唯一键冲突,从数据库层杜绝了重复收卡。但这只是第一层,业务层的并发处理在第 5 章展开。idx_status_create索引是给后台审核列表用的,管理员每次打开后台都要查 status=0 的待审核单,这个组合索引能让查询走索引而不是全表扫描。

2.3 价格配置表:折扣率、服务费与状态机设计

回收价格不能写死在代码里。油价波动、区域差异、卡品牌差异都会影响收购折扣,必须做成后台可配置。recycle_config 表存规则,按卡类型和面额区间匹配折扣率、服务费。常见结构:一个 card_type 一行,配置最低回收折扣、服务费比例、今日是否开放回收,后台管理员每天调整。

油卡回收价的计算公式是:回收价 = 面额 × 回收折扣率 - 服务费。比如用户提交一张面额 1000 元的中石化卡,当日折扣率 95.00,服务费 5 元,回收价就是 1000 × 0.95 - 5 = 945 元。折扣率是这套系统最核心的业务参数,直接决定平台是赚钱还是赔钱,后台最好加操作日志,谁改了折扣率、改了多少,留痕备查。

订单状态机用整数枚举而不是字符串,原因有两点:一是 MySQL 对整数索引更友好,几十万张订单后字符串枚举会让统计查询变慢;二是代码里用常量定义,写OrderStatus::AUDITING比到处写'pending'更不容易拼错。常见状态流转是:0 待审核 → 1 已通过待打款 → 2 已打款 → 3 已转售;0 待审核也可以直接流转到 -1 已驳回。状态机不允许跳转,比如 1 不能直接到 3,必须先打款才能转售,这是后续所有业务逻辑的基础。如果你拿到的源码里状态是自由跳转的,建议第一时间改成强制流转,不然后台运营误操作点错按钮,订单就会出现“未打款却已售出”的混乱局面。

3. 用 PHP 把回收主流程跑通:提交、审核、打款三件套

数据库立住了,接下来做业务主流程。油卡回收的核心链路是一条直线:用户提交油卡 → 系统根据价格配置自动估价 → 后台管理员审核 → 打款给用户 → 平台转售。前三步是日常使用频率最高的部分,代码质量直接决定系统能不能稳定跑下去。

3.1 前台提交油卡:表单校验、卡密加密、重复单拦截

提交油卡的前台页面一般长这样:用户选择卡品牌(中石油/中石化),填卡号、卡密、面额,系统实时算出预估回收价,用户确认后提交。后端真正要做的事比前端看到的复杂得多:校验卡号格式是否合法、判断卡密是否为空、检查这张卡是否已经提交过、把卡密加密后再入库。

<?php // submit_card.php 用户提交油卡 declare(strict_types=1); require_once 'config.php'; require_once 'lib/Db.php'; require_once 'lib/OrderStatus.php'; $pdo = Db::getPdo(); $uid = (int)($_SESSION['user_id'] ?? 0); if ($uid <= 0) { exit(json_encode(['code' => 401, 'msg' => '请先登录'])); } $cardType = (int)($_POST['card_type'] ?? 0); $cardNo = trim($_POST['card_no'] ?? ''); $cardPwd = trim($_POST['card_pwd'] ?? ''); $faceValue = (float)($_POST['face_value'] ?? 0); // 校验卡号格式:中石油/中石化卡号一般是 16 到 19 位数字 if (!preg_match('/^\d{16,19}$/', $cardNo)) { exit(json_encode(['code' => 400, 'msg' => '卡号格式不正确'])); } if (strlen($cardPwd) < 6 || strlen($cardPwd) > 32) { exit(json_encode(['code' => 400, 'msg' => '卡密长度不正确'])); } if ($faceValue <= 0) { exit(json_encode(['code' => 400, 'msg' => '面额必须大于 0'])); } // 从价格配置表读取当日折扣率 $stmt = $pdo->prepare('SELECT rate, service_fee FROM recycle_config WHERE card_type = ? AND status = 1 LIMIT 1'); $stmt->execute([$cardType]); $cfg = $stmt->fetch(PDO::FETCH_ASSOC); if (!$cfg) { exit(json_encode(['code' => 400, 'msg' => '该卡种今日暂停回收'])); } // 回收价 = 面额 × 折扣率 - 服务费,金额用分整数运算,避免浮点精度丢失 $recyclePriceFen = (int)(round($faceValue * $cfg['rate'] / 100, 2) * 100) - (int)($cfg['service_fee'] * 100); if ($recyclePriceFen <= 0) { exit(json_encode(['code' => 400, 'msg' => '折扣后回收价异常,请重新提交'])); } // 卡密 AES-256-CBC 加密后再落库,密钥在 config.php 中定义 $encryptedPwd = openssl_encrypt( $cardPwd, 'AES-256-CBC', CARD_PWD_KEY, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, CARD_PWD_IV );

这段代码把提交接口的服务端逻辑一次性说清楚了。几个关键设计点:

第一,严格类型声明declare(strict_types=1)是 PHP 7 之后的规范,能让 (float) 强转和 PDO 绑定参数时少踩类型坑。第二,卡密用 AES-256-CBC 加密后才落库,且密钥和 IV 单独放在 config.php 里,不写进数据库、不写进会话。第三,回收价计算先转成分整数运算,round(..., 2) * 100再转 int,最后相减,这样才能彻底避开浮点数精度问题。运行这段代码前,记得确认 PHP 装了 openssl 扩展,没装的话openssl_encrypt会直接报 undefined function,php -m | grep openssl查一下最稳妥。

3.2 后台审核:人工核验与订单状态推进

油卡回收为什么必须有人工审核?因为油卡这东西存在“已消费、挂失、空卡”三类风险,系统程序看不到卡内余额,只能靠人工拿着卡号和卡密去所属油站 APP 或小程序里查询余量。所以后台审核页面的交互设计通常是:列表页展示待审核订单,管理员点进详情,看到卡号和脱敏后的卡密,核实余量后选择“通过并打款”或“驳回并填写原因”。

<?php // admin_audit.php 后台审核订单 declare(strict_types=1); require_once 'config.php'; require_once 'lib/Db.php'; require_once 'lib/AdminAuth.php'; // 只允许登录后的管理员访问 $adminId = AdminAuth::check(); if ($adminId <= 0) { exit(json_encode(['code' => 401, 'msg' => '未登录或已超时'])); } $orderId = (int)($_POST['order_id'] ?? 0); $action = $_POST['action'] ?? ''; // pass 通过 / reject 驳回 $remark = trim($_POST['remark'] ?? ''); if (!in_array($action, ['pass', 'reject'], true)) { exit(json_encode(['code' => 400, 'msg' => '非法操作'])); } $pdo = Db::getPdo(); $pdo->beginTransaction(); try { // 加行锁锁定这条订单,防止审核过程中被并发修改 $stmt = $pdo->prepare('SELECT id, status FROM oil_order WHERE id = ? FOR UPDATE'); $stmt->execute([$orderId]); $order = $stmt->fetch(PDO::FETCH_ASSOC); if (!$order || $order['status'] != 0) { throw new RuntimeException('订单不存在或已被处理'); } $newStatus = $action === 'pass' ? 1 : -1; $stmt = $pdo->prepare( 'UPDATE oil_order SET status = ?, audit_by = ?, audit_remark = ?, update_time = NOW() WHERE id = ?' ); $stmt->execute([$newStatus, $adminId, $remark, $orderId]); // 写操作日志,改动留痕 $stmt = $pdo->prepare('INSERT INTO admin_log (admin_id, action, target_id, remark, create_time) VALUES (?, ?, ?, ?, NOW())'); $stmt->execute([$adminId, 'audit_' . $action, $orderId, $remark]); $pdo->commit(); exit(json_encode(['code' => 0, 'msg' => '审核成功'])); } catch (Throwable $e) { $pdo->rollBack(); exit(json_encode(['code' => 500, 'msg' => '处理失败:' . $e->getMessage()])); }

这段审核代码的核心是SELECT ... FOR UPDATE行级锁,它的作用是:管理员点击“通过”或“驳回”的瞬间,把这条订单锁住,其他管理员的并发操作必须等锁释放后才能执行。如果没有这把锁,两个管理员同时打开同一个待审核订单,A 点通过、B 点驳回,后写入的会覆盖先写入的,订单状态就错了。业务上还有个细节:驳回时 remark 必填,因为用户端需要看到驳回原因,比如“卡内余额不足”“卡已挂失”。

审核通过后订单进入 status=1 待打款状态。这里不直接更新用户余额,而是留出人工复核的缓冲时间。有些源码在审核通过时就自动加余额,虽然用户体验好,但一旦管理员审核出错、油卡其实是空卡,钱就付出去了,后悔药都没得吃。

3.3 打款与状态机推进:事务里更新余额和订单

打款是油卡回收里最容易出财务事故的环节。如果代码逻辑分开执行“更新用户余额”和“更新订单状态”两步,中途任何一步失败都会造成数据不一致——钱扣了状态没改,或者状态改了钱没到账。所以打款必须是一次数据库事务,要么全部成功,要么全部回滚。

<?php // admin_pay.php 审核通过后执行打款 declare(strict_types=1); require_once 'config.php'; require_once 'lib/Db.php'; $adminId = AdminAuth::check(); $orderId = (int)($_POST['order_id'] ?? 0); $pdo = Db::getPdo(); $pdo->beginTransaction(); try { // 锁定订单并确认状态为“已通过待打款” $stmt = $pdo->prepare('SELECT user_id, recycle_price, status FROM oil_order WHERE id = ? FOR UPDATE'); $stmt->execute([$orderId]); $order = $stmt->fetch(PDO::FETCH_ASSOC); if (!$order || $order['status'] != 1) { throw new RuntimeException('订单不在可打款状态'); } // 给用户加余额 $stmt = $pdo->prepare('UPDATE user SET balance = balance + ? WHERE id = ?'); $stmt->execute([$order['recycle_price'], $order['user_id']]); // 同步更新订单状态为“已打款” $stmt = $pdo->prepare('UPDATE oil_order SET status = 2, paid_at = NOW() WHERE id = ?'); $stmt->execute([$orderId]); $pdo->commit(); exit(json_encode(['code' => 0, 'msg' => '打款成功'])); } catch (Throwable $e) { $pdo->rollBack(); exit(json_encode(['code' => 500, 'msg' => '打款失败:' . $e->getMessage()])); }

这段代码里余额更新用的是balance = balance + ?而不是先 SELECT 再 UPDATE。这属于 UPDATE 的原子性写法,避免两个订单同时给一个用户打款时出现“读后写”覆盖。比如用户同时提交了两张油卡,两个打款请求并发执行,如果先读 balance 再写回去,第二次读到的可能是旧值,用户少拿一笔钱。直接加法操作让数据库自己处理并发,单行更新天然有行锁保护。

打款完成后,这套系统在“收”这一侧的主流程就跑通了:用户提交 → 管理员审核 → 打款进余额。至于用户提现到银行卡或支付宝,那是另一套提现模块,常见做法是用户申请提现后管理员线下打款并在后台确认,跟油卡审核流程逻辑一致,这里不展开。

4. 用宝塔把源码跑起来:LNMP 环境和三个必改配置

系统代码本身写得再稳,部署环节一旦忽略 PHP 版本、MySQL 配置、目录权限这些基础项,上线第一天就会翻车。油卡回收系统属于典型的 PHP+MySQL 传统栈,最常见跑法是宝塔面板 + LNMP 组合。这里说清楚三个最容易出问题的配置点。

4.1 PHP 版本与扩展:先从 php -m 查起

这套代码面向 PHP 7.4/8.0+ 写的。如果你拿到手的源码是老项目,大概率兼容 PHP 5.6,但安全性和性能都很差;如果是 PHP 8 语法的项目,就必须用 PHP 8.0 及以上跑。有个常见的翻车点:源码用了match表达式或构造器属性提升这种 PHP 8 特性,部署在 7.4 环境直接报语法错误,页面白屏。所以拿到源码先看composer.json或者index.php头部有没有版本声明,没有的话用最原始的探针方法:

# 在网站根目录放一个 phpinfo 探针,确认 PHP 版本和扩展 echo '<?php phpinfo();' > /www/wwwroot/youzi.com/check.php

浏览器打开http://你的域名/check.php,确认三件事:PHP 版本是不是 7.4+、有没有 openssl 扩展、有没有 PDO_MYSQL 驱动。油卡系统的卡密加密用到 openssl_encrypt,没有这个扩展后台提交油卡会直接报错;PDO_MYSQL 是数据库连接的前提。如果用的是宝塔,在 PHP 设置页面把这两个扩展勾上,然后重载 PHP-FPM:

# 查看 PHP 已加载的扩展,确认 openssl 和 pdo_mysql 都在 php -m | grep -E 'openssl|pdo_mysql'

PHP 版本和扩展没问题后,还有一个容易被忽略的参数:post_max_size和upload_max_filesize。油卡回收后台偶尔会上传用户提交的油卡照片佐证,默认 2M 太小,改成 8M 够用。宝塔 PHP 设置页面直接改,改完重载 PHP-FPM,不用重启整个服务。

4.2 数据库导入与 config.php:MySQL 8 的认证插件坑

数据库导入看起来是小事,其实坑不少。油卡系统的 SQL 文件一般按表结构、初始数据、可选项分三个文件,导入顺序别乱。用宝塔的 phpMyAdmin 导入时,如果 SQL 里有DEFINER这类语句,普通账号执行会报权限错误,最稳的方式是命令行导入:

# 先建库,再导入表结构和数据 mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS youka DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p youka < /home/wwwroot/db/oil_recycle_structure.sql mysql -uroot -p youka < /home/wwwroot/db/oil_recycle_data.sql

导入完成后打开 config.php,把数据库连接信息改成本机实际值。重点检查数据库端口,宝塔的 MySQL 默认 3306,但也有改过端口的机器。连接信息写在 DB_PORT 常量里,别漏。

MySQL 8 有个让很多人卡住的坑:默认认证插件是caching_sha2_password,老版本的 PHP (7.4) PDO 连不上,报The server requested authentication method unknown to the client。解决方式有两种:把 MySQL 8 的默认认证改回mysql_native_password,或者 PHP 升级到 8.1+。推荐后者,因为前者治标不治本,PHP 8 的 mysqlnd 驱动完整支持 MySQL 8 的默认认证。

-- 如果坚持用 PHP 7.4 + MySQL 8,给应用账号改回旧认证 ALTER USER 'youka_user'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;

配置文件还有一个容易漏的项:站点 URL。油卡系统里拼接图片地址、生成支付回调链接、后台跳转都用它,如果配置成 localhost,前端页面会有一堆资源 404。改成你的实际域名,带不带 https 取决于是否配了 SSL证书。

4.3 伪静态、定时任务与目录权限

油卡回收系统如果用了 ThinkPHP、Laravel 这类框架,伪静态规则是必须配的。不配的话,访问http://你的域名/index.php/Home/index也能跑,但 URL 丑不说,搜索引擎收录和分享都不好。宝塔 Nginx 里添加伪静态规则时,选择对应框架的模板,或者手写几行:

# Nginx 伪静态:ThinkPHP 风格,所有不存在文件交给入口文件 location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; break; } }

配完伪静态要记得重载 Nginx 配置。验证方法很简单:访问一个实际业务 URL,比如/index.php?s=/Home/Order/index,如果地址栏能自动重写简洁样式,说明规则生效。

油卡回收系统还要挂一条定时任务。常见场景是:用户提交的油卡超过 24 小时没审核自动驳回,以及已审核通过但超过 48 小时没打款的订单提醒管理员。这类任务不能靠用户访问时触发,因为没人访问就不执行。宝塔面板的“计划任务”里添加 Shell 脚本,每分钟跑一次:

# crontab 每分钟检查一次待处理订单,超过时限自动处理 * * * * * cd /www/wwwroot/youka && php think Order:autoRejectExpired >> /var/log/youka_cron.log 2>&1

注意看源码里的 CLI 入口是think文件还是cli.php,路径写对。定时任务脚本要能写日志,方便排查为什么自动驳回没生效。最后是目录权限,这是新手最容易埋雷的地方。油卡系统的运行目录(常见是 Runtime 或 Storage)需要写入权限,但上传目录绝对不能允许执行 PHP:

# 上传目录禁止执行 PHP,防止卡密照片目录被上传 webshell find /www/wwwroot/youka/public/uploads -type f -name "*.php" -delete # Nginx location 层加上对 uploads 目录的 php 访问拦截

目录权限的原则是:可读可写的目录绝不能可执行,可执行的目录绝不能开放写入。把这句记住,上线后能挡住一大半黑产扫描攻击。

5. 油卡回收系统的避坑清单:五条血泪排错经验

部署完的主流程跑得通,只代表骨架正常。线上真正狠咬一口的,全是安全和并发细节。这里把最常见的五个坑按“现象 → 原因 → 解决”展开写,每条都是真金白银换来的教训。

5.1 卡密明文存储等于裸奔

现象:数据库泄露后,攻击者拿着卡密直接去油站系统充走余额,平台赔到闭站。

原因:源码里card_pwd字段直接存了明文。卡密是敏感凭证,跟银行卡密码同级别。有些开发者图方便,想着“反正后台要看到卡密来完成转售”,就一路明文存、明文读取,等于把所有用户的油卡密码集中锁在同一个数据库里,一个注入漏洞就全交出去了。

解决:卡密必须加密存储。前文已给出 AES-256-CBC 的加密写法,密钥和 IV 单独放在服务器配置文件里,不跟数据库走。转售时后台展示也是先解密再脱敏显示,比如6222 **** 8810,管理员核对前几位和卡类型就够了,不需要完整卡密。另外,日志里禁止打印完整卡密,打印到日志时同样脱敏。

提示:如果拿到的源码里卡密是明文,第一时间改加密逻辑再上线,这不是优化项,是安全底线。

5.2 后台越权:修改 URL 里的 id 就能看别人订单

现象:普通用户登录后,把浏览器地址栏/order/detail?id=1024改成id=1,能直接看到别人的油卡卡号和卡密。

原因:接口只校验了“是否登录”,没校验“是否有权限访问这条订单”。这是典型的 IDOR(越权访问)漏洞,油卡系统里后果尤其严重——用户之间能互相看到加密卡密,配合解密端直接变成盗刷渠道。

解决:每个订单详情接口,先取订单的 user_id,再跟当前登录用户的 uid 比对:

$stmt = $pdo->prepare('SELECT * FROM oil_order WHERE id = ? AND user_id = ?'); $stmt->execute([$orderId, $uid]); $order = $stmt->fetch(); if (!$order) { exit(json_encode(['code' => 403, 'msg' => '无权访问该订单'])); }

后台管理接口同理,AdminAuth 校验要按角色区分,而不是只要登录了就放行。最简单的做法是 role 字段单独定义管理员角色,每次进后台都校验角色 & 权限,不要只在 header 里藏一个 is_admin 的 cookie,那等于把大门钥匙挂门口。

5.3 金额浮点精度:95.55 变成 95.5499999

现象:对账时发现当月总额比实际少几分甚至几毛,单看一单不对,汇总后差异凭空变大。

原因:PHP 的float是 IEEE 754 双精度浮点数,0.1 + 0.2 的结果是 0.30000000000000004。计算回收价时用了face_value * rate / 100,中间结果一定会有浮点尾巴。单独记录95.55看着没问题,但几百笔累加后偏差就出来了。

解决:金额计算别碰浮点。前端提交时把金额转成分为单位,后端全部用整数加减;或者坚持用 DECIMAL 字段 + PHP 的 BCMath 扩展。BcMath 是 PHP 内置的任意精度数学扩展,需要注意安装和使用方式:

// 用 bcmath 计算回收价,第二个参数 2 表示保留两位小数 $price = bcsub( bcmul((string)$faceValue, (string)$rate, 2), (string)$serviceFee, 2 );

BcMath 返回的是字符串"945.00",数据库写入时直接写 DECIMAL 字段,天然对齐。改完记得把代码里所有涉及 money 的地方统一走 BcMath,不能部分用浮点部分用 BcMath,混用等于没改。

5.4 卡号注入与 XSS 弹窗

现象:用户在卡号输入框填了一段1'; DROP TABLE oil_order;--,后台列表页直接弹出一串奇怪 HTML 或脚本,管理员点开订单详情时页面卡死。

原因:拼 SQL 时直接字符串拼接,以及后台列表页回显卡号时没做 HTML 转义。油卡系统的卡号被用户完全控制,是天然的注入入口。

解决:查询全部改 PDO 预处理,永远不用字符串拼接参数。提交油卡的代码示例里$stmt->prepare(...)->execute([...])就是正确姿势。回显侧统一htmlspecialchars转义:

// 列表页输出卡号前转义,防止 XSS echo htmlspecialchars($order['card_no'], ENT_QUOTES, 'UTF-8');

额外建议给后台加一层 HTTP 基础认证或者限制 IP 白名单,油卡回收后台只应该对外开放给少数管理员,暴露在公网上即使代码没问题,也经不起脚本扫描。

5.5 并发重复提交同一张卡

现象:用户同一张油卡在两个浏览器标签页同时提交,系统生成了两笔订单,后台审核了两笔,平台打了两笔回收款。

原因:代码层面先查订单表再插入,两个请求同时查,都没查到记录,就都插进去了。唯一索引uk_card_no_active在单条插入时能拦截,但有些老源码没建这个索引,业务层又没有事务保护,并发窗口就这样漏过去了。

解决:两件事一起做。第一,数据库必须有卡号的唯一索引,这是物理闸门;第二,插入前用事务包住“查重+插入”,并且查重和插入之间用INSERT ... ON DUPLICATE KEY或者干脆依赖唯一索引的报错来识别重复:

$pdo->beginTransaction(); try { $stmt = $pdo->prepare('INSERT INTO oil_order (order_no, user_id, card_no, ...) VALUES (?, ?, ?, ...)'); $stmt->execute([...]); $pdo->commit(); } catch (PDOException $e) { $pdo->rollBack(); if ($e->getCode() == 23000) { // 唯一约束冲突 exit(json_encode(['code' => 400, 'msg' => '该卡号已提交,请勿重复操作'])); } throw $e; }

MySQL 唯一索引冲突的错误码是23000,捕获后返回友好提示。这种“先查再插”的逻辑,在并发下查这一步永远不可靠,只有数据库唯一约束才是真正的防线。业务层还要配合一个 Redis 分布式锁或者短时间内的重复提交缓存,双保险更稳。

6. 让系统真正能赚钱:二次销售、对账 SQL 与验证方法

回收流程能跑通只是开始,油卡平台的利润在于把收进来的卡卖出去。转售逻辑简单粗暴:后台把已打款、未售出的订单拎出来,管理员手动卖给下游渠道或充值商,标记已售出并记录售价。核心是卡密如何安全出库——既要让购买方看到完整卡密用于充值,又不能在前端明文暴露给所有人。

6.1 卡密出库与自动充值接口

常见做法是转售页面单独开一个“卡密查看”权限,只有管理员和一个特定的下游代理账号能调用接口,并且每次查看都会写入操作日志。这不是多此一举,卡密一旦泄露,查日志能定位到具体是谁、什么时间看过。加密卡密出库时先解密再回显:

$decrypted = openssl_decrypt( $order['card_pwd'], 'AES-256-CBC', CARD_PWD_KEY, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, CARD_PWD_IV ); echo json_encode(['card_no' => $order['card_no'], 'card_pwd' => trim($decrypted)]);

转售完成后订单状态推进到 3(已转售),同步写 sold_price 和 sold_at,这样盈亏统计就有据可依。

6.2 用三句 SQL 做日终对账

系统上线后最该养成的习惯是每天对账。我一般日终跑三句 SQL:看当天回收总额、看当天转售总额、看余额变动是否吻合。

-- 1. 当日回收订单总额(status=2 已打款) SELECT COUNT(*) AS cnt, SUM(recycle_price) AS total_pay FROM oil_order WHERE status = 2 AND DATE(paid_at) = CURDATE(); -- 2. 当日转售订单总额 SELECT COUNT(*) AS cnt, SUM(sold_price) AS total_sold FROM oil_order WHERE sold_status = 1 AND DATE(sold_at) = CURDATE(); -- 3. 当日用户余额总额(对不上就去查明细) SELECT IFNULL(SUM(balance), 0) AS total_fen FROM user;

对账的原则是:当日回收支出 + 当日提现支出 + 平台余额变动 = 当日转售收入。如果左边不等于右边,先查有没有未打款却已售出的订单,再查有没有冻结余额没解冻。对账脚本挂到宝塔定时任务里每天凌晨跑,结果发到管理员邮箱。油卡回收赚的是差价和资金周转的钱,账目乱掉,利润会被细节吞干净。

6.3 上线前必须做的三件事

第一,把数据库里的卡密明文改成密文再上线,这是安全底线。第二,清空初始化的管理员默认密码,后台路径从 admin 改成不规则的目录名。第三,对真实用户做一次小额回收测试,走完“提交→审核→打款→提现→转售”全流程再开放注册。如果让我重做一次,我会把卡密加密密钥的轮换机制提前设计好,而不是等泄露再补救。这套系统值不值得做,取决于你有没有线下卡源和回收渠道,技术只能把流程规范起来,渠道活水得靠业务自己跑。希望帮到你。

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

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

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

立即咨询