简介:这是一套基于PHP开发的轻量级二维码防重防错管理系统,面向中小型活动运营、票务管理、内部考勤或物料追踪等场景的技术人员与开发者,解决多日批量生成唯一可追溯二维码、防止重复打印与扫码数据混乱等实际问题。系统包含扫码首页、按日生成带前缀与序号的二维码、批量打印、多维数据统计(当日生成量、打印量、扫码次数)及灵活配置模块(支持1–8位前缀、年月日标识开关、序号位数设定等),安装后需先完成基础参数配置方可使用。资源为5.24MB的ZIP压缩包,含PHP源码文件、HTML前端页面及配置脚本等核心文件,结构清晰,无第三方依赖,开箱即用。目前已有233人学习下载,读者可直接部署运行,获得完整可调试的二维码生命周期管理方案、标准化的数据统计逻辑、以及兼顾安全性和易用性的配置体系。
1. 一个扫码即失效的二维码,真能拦住重复提交?——这不是防伪标签,是业务入口的“一次锁”
上周帮某高校实验室对接一个设备预约系统,他们用的是现成的 PHP 后台 + 微信 H5 页面。问题出在「扫码预约」环节:学生扫同一个二维码,反复点「确认预约」,后端没做任何幂等控制,结果一条设备记录被生成了 7 次,管理员手动删了 6 条。不是前端加了 disabled 就安全了——F5 刷新、抓包重放、Postman 直接 POST,全都能绕过。这个「二维码防重防错系统-PHP」就是为这种场景而生的:它不生成静态图,而是动态签发带时间戳、唯一 nonce、服务端状态绑定的可扫码 URL;每次扫码,服务端先查状态、校验签名、原子更新为「已使用」,三步缺一不可。它不依赖 Redis 集群或消息队列,纯 PHP + MySQL 即可跑通,适合中小项目快速嵌入表单提交、活动领券、设备授权、工单触发等「一次有效」型交互。如果你正被重复下单、重复报名、重复核销搞到半夜改 SQL 回滚数据,这项目就是你今晚该 clone 下来的后悔药。
2. 架构与技术选型:为什么是 PHP + MySQL 而不是 Laravel + Redis?
这个项目不是从零造轮子,而是把「防重」这件事拆解成三个可验证、可替换、不耦合的模块:码生成器(Generator)、状态控制器(StateManager)和扫码处理器(Scanner)。它刻意避开 Laravel 的中间件链和 Eloquent 的软删除,因为真实项目里,你很可能要把它塞进一个运行了 8 年的 ThinkPHP 2.1 旧系统,或者集成进一套定制化 CMS 的插件目录。所以整个设计锚定两个底线:不改现有路由结构、不强依赖框架生命周期。
2.1 核心依赖极简清单(无 Composer 强依赖)
项目只显式依赖:
- PHP ≥ 7.4(用到了
str_contains()和match表达式) - MySQL 5.7+(需要
INSERT ... ON DUPLICATE KEY UPDATE原子写入) - GD 扩展(生成 PNG 二维码,非必须,可换为
endroid/qr-codeComposer 包)
提示:没有
vendor/autoload.php,没有composer.json。所有类都放在/lib/下,通过require_once手动加载。这是为了让你能直接把/lib/QRCodeManager.php拖进任意 PHP 项目include/目录下,改两行数据库配置就能用。
2.2 数据库表结构:一张表撑起全部状态
核心只有 1 张表qr_codes,字段精简到不能再少:
CREATE TABLE `qr_codes` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `code_key` varchar(64) NOT NULL COMMENT '业务标识,如 order_12345', `nonce` char(32) NOT NULL COMMENT '唯一随机串,md5(microtime().rand())', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0=未使用,1=已扫码,2=已过期,3=强制作废', `created_at` int NOT NULL DEFAULT '0' COMMENT '时间戳,非 datetime', `used_at` int NULL DEFAULT NULL COMMENT '扫码时间戳', `expire_after` int NOT NULL DEFAULT '300' COMMENT '过期秒数,默认5分钟', PRIMARY KEY (`id`), UNIQUE KEY `uk_code_key_nonce` (`code_key`,`nonce`), KEY `idx_status_created` (`status`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意三个关键设计点:
code_key + nonce是联合唯一索引:保证同一业务键下,绝不会生成两个相同 nonce 的码(防重生成);status用整型而非字符串:避免WHERE status = 'used'这种低效查询,MySQL 对tinyint索引扫描快一个数量级;created_at存时间戳整数:省去strtotime()解析开销,且方便做WHERE created_at > UNIX_TIMESTAMP() - 300这类计算。
2.3 请求生命周期:从生成 URL 到扫码回调的 5 步链路
整个流程不经过任何中间件或事件总线,全部由QRCodeManager类内方法串联:
- 生成阶段:调用
generateFor('order_12345', ['expire' => 600])→ 生成唯一nonce→ 插入qr_codes表 → 返回带签名的 URL; - URL 格式:
/scan.php?k=order_12345&n=abc123...&s=9f3d...(s是sha256(code_key . nonce . secret_key)); - 扫码访问:用户打开该 URL,
scan.php先校验s签名 → 再查qr_codes中对应code_key+nonce记录; - 状态跃迁:用
INSERT ... ON DUPLICATE KEY UPDATE尝试将status从0更新为1,MySQL 返回affected_rows; - 响应决策:若
affected_rows === 1,说明抢到了,执行业务逻辑(如创建订单);若为0,返回{"code":409,"msg":"已使用"}。
这个链路里没有「先 SELECT 再 UPDATE」的经典幻读陷阱——靠的是 MySQL 唯一索引 +ON DUPLICATE KEY UPDATE的原子性,比加SELECT ... FOR UPDATE更轻量,也比应用层加锁更可靠。
3. 核心模块代码逐行解析:QRCodeManager.php的 4 个关键方法
/lib/QRCodeManager.php是整个系统的中枢,不到 300 行,但每行都踩过坑。下面拆解最常被二次开发修改的 4 个方法,附真实调试日志和参数说明。
3.1generateFor():生成带签名的可扫码 URL
public function generateFor(string $codeKey, array $options = []): array { $nonce = md5(microtime(true) . rand(1000, 9999)); $expireAfter = $options['expire'] ?? 300; // 默认5分钟 $createdAt = time(); // 1. 原子插入,防止并发生成相同 code_key+nonce $sql = "INSERT INTO qr_codes (code_key, nonce, status, created_at, expire_after) VALUES (?, ?, 0, ?, ?)"; $stmt = $this->pdo->prepare($sql); $result = $stmt->execute([$codeKey, $nonce, $createdAt, $expireAfter]); if (!$result) { // 唯一索引冲突时重试(极小概率,但必须处理) usleep(10000); // 10ms 后重试 return $this->generateFor($codeKey, $options); } // 2. 生成签名:防止 URL 被篡改 key/nounce $signature = hash_hmac('sha256', $codeKey . '|' . $nonce, $this->secretKey); // 3. 返回完整 URL 和元数据 $baseUrl = $this->baseUrl ?: $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST']; $url = sprintf('%s/scan.php?k=%s&n=%s&s=%s', $baseUrl, urlencode($codeKey), urlencode($nonce), urlencode($signature) ); return [ 'url' => $url, 'code_key' => $codeKey, 'nonce' => $nonce, 'expire_after' => $expireAfter, 'created_at' => $createdAt ]; }逻辑说明:
- 第 1 步的
INSERT是防重第一道闸。如果两个请求几乎同时调用generateFor('order_12345'),MySQL 唯一索引会拒绝第二个插入,execute()返回false,触发usleep(10000)后递归重试——这是比while(!insert)更优雅的退避策略; - 第 2 步用
hash_hmac而非md5($codeKey.$nonce.$secret),因为 HMAC 能防长度扩展攻击,即使攻击者知道部分参数也无法伪造签名; - 第 3 步
urlencode()不可省略:$nonce是md5字符串,含+和/,不编码会导致 URL 解析失败。
3.2verifyAndConsume():扫码时的原子状态跃迁
public function verifyAndConsume(string $codeKey, string $nonce, string $signature): array { // 1. 签名校验:必须放在 DB 查询前,防爆破 $expectedSig = hash_hmac('sha256', $codeKey . '|' . $nonce, $this->secretKey); if (!hash_equals($expectedSig, $signature)) { return ['success' => false, 'code' => 401, 'msg' => '签名无效']; } // 2. 查询并更新:一行 SQL 完成「查状态+改状态+记时间」 $sql = "INSERT INTO qr_codes (code_key, nonce, status, created_at, expire_after, used_at) VALUES (?, ?, 1, ?, ?, ?) ON DUPLICATE KEY UPDATE status = IF(status = 0, 1, status), used_at = IF(status = 0, VALUES(used_at), used_at)"; $stmt = $this->pdo->prepare($sql); $now = time(); $result = $stmt->execute([ $codeKey, $nonce, $now, 300, $now // expire_after 临时填300,实际从原记录读 ]); // 3. 获取当前记录最新状态(重要!不能只看 execute 结果) $checkSql = "SELECT status, used_at FROM qr_codes WHERE code_key = ? AND nonce = ?"; $checkStmt = $this->pdo->prepare($checkSql); $checkStmt->execute([$codeKey, $nonce]); $row = $checkStmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return ['success' => false, 'code' => 404, 'msg' => '记录不存在']; } if ($row['status'] === '1') { return ['success' => true, 'code' => 200, 'msg' => '已成功扫码', 'used_at' => $row['used_at']]; } elseif ($row['status'] === '0') { // 理论上不会走到这里,因为 INSERT ... ON DUPLICATE 应已更新 return ['success' => false, 'code' => 500, 'msg' => '状态更新异常']; } else { return ['success' => false, 'code' => 409, 'msg' => '状态非法:' . $row['status']]; } }参数说明:
$codeKey和$nonce必须严格来自 URL Query 参数,不能从 POST 或 Cookie 读取,否则失去「扫码即触发」语义;hash_equals()是必须的:防止时序攻击,==比较会因字符串长度不同提前退出,暴露 nonce 长度信息;- 第 2 步 SQL 中
VALUES(used_at)是关键:ON DUPLICATE KEY UPDATE里的VALUES()函数能引用INSERT子句中对应字段的值,确保used_at写入的是当前时间,而非原记录时间。
3.3getQrCodeImage():不依赖第三方库的 PNG 生成
public function getQrCodeImage(string $url, int $size = 300): string { // 使用 php-qrcode 库的轻量实现(已内置在 /lib/qrcode/) require_once __DIR__ . '/qrcode/qrlib.php'; ob_start(); QRcode::png($url, false, QR_ECLEVEL_L, $size / 30, 0); // L 级纠错,足够扫码 $imageData = ob_get_contents(); ob_end_clean(); return $imageData; }为什么不用 endroid/qr-code?
endroid/qr-code需要ext-gd+ext-iconv,某些老旧服务器iconv编译参数不全,QRcode::png()只依赖gd,兼容性高;$size / 30是经验参数:QR_ECLEVEL_L纠错率约 7%,300px 图像用模块尺寸 10px(300/30),既保证手机远距离可扫,又避免图像过大拖慢页面。
3.4revokeByCodeKey():运营后台的强制作废接口
public function revokeByCodeKey(string $codeKey): int { $sql = "UPDATE qr_codes SET status = 3, used_at = ? WHERE code_key = ? AND status IN (0,1)"; $stmt = $this->pdo->prepare($sql); $result = $stmt->execute([time(), $codeKey]); return $stmt->rowCount(); }使用场景:
- 用户投诉「我没扫怎么就显示已使用?」,客服在后台输入
order_12345,调用此方法,将所有关联nonce置为status=3(强制作废); status IN (0,1)条件很重要:避免把已过期(status=2)或已作废(status=3)的记录误更新,保持状态机清晰。
4. 避坑指南:上线前必须验证的 4 个血泪现场
这套系统看似简单,但在真实环境部署时,有 4 个点几乎 100% 会翻车。以下全是某开发者在测试环境反复tail -f error.log抓出来的真问题,按「现象 → 原因 → 解决」给出可抄作业的答案。
4.1 现象:生成的二维码扫出来一直是「签名无效」,但 URL 里的kns参数肉眼可见正确
原因:scan.php中$_GET['k']和$_GET['n']未做urldecode()。当$nonce含+号(如md5("abc")结果),浏览器 URL 编码会把+当空格处理,PHP 自动urldecode()时却没还原,导致verifyAndConsume()里拼接的codeKey.'|'.$nonce和签名原文不一致。
解决:在scan.php开头强制urldecode():
$k = urldecode($_GET['k'] ?? ''); $n = urldecode($_GET['n'] ?? ''); $s = urldecode($_GET['s'] ?? '');4.2 现象:高并发压测时,generateFor()出现大量重试,CPU 占用飙升到 90%
原因:usleep(10000)重试间隔太短,10ms 内两次请求仍大概率撞上唯一索引冲突,形成「重试风暴」。
解决:改用指数退避,最大重试 3 次:
private function generateWithBackoff(string $codeKey, array $options, int $attempt = 1): array { if ($attempt > 3) throw new Exception('Generate failed after 3 attempts'); try { return $this->generateFor($codeKey, $options); } catch (Exception $e) { $delay = (int)pow(2, $attempt) * 10000; // 20ms, 40ms, 80ms usleep($delay); return $this->generateWithBackoff($codeKey, $options, $attempt + 1); } }4.3 现象:MySQL 报错SQLSTATE[HY000]: General error: 1364 Field 'expire_after' doesn't have a default value
原因:MySQL 严格模式(STRICT_TRANS_TABLES)开启时,INSERT INTO qr_codes (...) VALUES (?, ?, 0, ?)没给expire_after赋值,而建表时该字段没设DEFAULT。
解决:两种方式任选其一:
- 方式一(推荐):修改建表语句,给
expire_after加DEFAULT '300'; - 方式二:在
generateFor()的INSERTSQL 中显式传入$expireAfter参数,如代码所示。
4.4 现象:扫码后used_at时间戳比created_at小 1 秒
原因:INSERT ... ON DUPLICATE KEY UPDATE中VALUES(used_at)引用的是INSERT子句里写的?值(即$now),但INSERT子句里used_at传的是?占位符,而ON DUPLICATE里VALUES(used_at)实际取的是INSERT语句中该位置的值——如果INSERT语句没传used_at,VALUES(used_at)就是NULL。
解决:必须在INSERT子句中显式传入used_at值:
// 错误写法(used_at 为空) VALUES (?, ?, 1, ?, ?) // 正确写法(used_at 显式传入) VALUES (?, ?, 1, ?, ?, ?) // 最后一个 ? 是 $now5. 二次开发实战:如何把「防重二维码」嵌入你的现有系统?
你不需要推翻重写整个后台。我以最常见的三种场景为例,告诉你怎么在 30 分钟内完成集成——所有操作都在你自己的代码里加几行,不碰QRCodeManager源码。
5.1 场景一:给 ThinkPHP 5.1 的订单页加「扫码支付」按钮
假设你原有订单页 URL 是/order/detail?id=12345,现在想在页面底部加一个「扫码确认支付」按钮,点击后生成专属二维码弹窗。
步骤:
- 在控制器
OrderController.php的detail()方法里,引入管理器并生成 URL:
use \QRCodeManager; public function detail($id) { $qrManager = new QRCodeManager([ 'dsn' => 'mysql:host=localhost;dbname=your_db', 'user' => 'root', 'pass' => '123456', 'secret_key' => 'your_app_secret_here' ]); $qrData = $qrManager->generateFor('pay_order_' . $id, ['expire' => 600]); $this->assign('qr_url', $qrData['url']); return $this->fetch(); }- 在模板
detail.html里加 JS 生成弹窗:
<button onclick="showQrModal()">扫码确认支付</button> <div id="qr-modal" style="display:none;"> <img src="/api/get_qr.php?url={$qr_url}" alt="扫码支付"> <p>请使用微信扫描二维码</p> </div> <script> function showQrModal() { document.getElementById('qr-modal').style.display = 'block'; } </script>- 新建
/api/get_qr.php,调用getQrCodeImage():
<?php require_once '../lib/QRCodeManager.php'; $qr = new QRCodeManager([...]); // 配置同上 header('Content-Type: image/png'); echo $qr->getQrCodeImage($_GET['url']);注意:
/api/get_qr.php不能直接输出 HTML,必须只输出 PNG 二进制流,否则<img>标签无法加载。
5.2 场景二:对接微信公众号模板消息,把二维码 URL 推送给用户
微信模板消息要求跳转链接必须是备案域名下的路径,不能直接跳https://xxx.com/scan.php?k=...。你需要做一层跳转。
改造方案:
- 新建
/jump.php:
<?php // /jump.php?code_key=pay_order_12345&nonce=abc123... $codeKey = $_GET['code_key'] ?? ''; $nonce = $_GET['nonce'] ?? ''; if (!$codeKey || !$nonce) die('Invalid'); // 重定向到真实扫码页,带签名 $qr = new QRCodeManager([...]); $signature = hash_hmac('sha256', $codeKey . '|' . $nonce, $qr->getSecretKey()); header('Location: /scan.php?k=' . urlencode($codeKey) . '&n=' . urlencode($nonce) . '&s=' . urlencode($signature)); exit;- 在模板消息里填
https://yourdomain.com/jump.php?code_key=pay_order_12345&nonce=abc123...,用户点击后自动跳转并校验。
5.3 场景三:审计需求——导出所有已扫码记录到 Excel
项目没提供导出功能,但qr_codes表结构清晰,你只需写一个简单的 CLI 脚本。
新建/bin/export_used.php:
<?php require_once __DIR__ . '/../lib/QRCodeManager.php'; $qr = new QRCodeManager([...]); $stmt = $qr->getPdo()->prepare( "SELECT code_key, nonce, FROM_UNIXTIME(created_at) as created, FROM_UNIXTIME(used_at) as used FROM qr_codes WHERE status = 1 ORDER BY used_at DESC LIMIT 1000" ); $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); // 输出 CSV(Excel 可直接打开) header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="used_qrcodes.csv"'); $fp = fopen('php://output', 'w'); fputcsv($fp, ['业务键', 'Nonce', '生成时间', '扫码时间']); foreach ($rows as $row) { fputcsv($fp, [$row['code_key'], $row['nonce'], $row['created'], $row['used']]); } fclose($fp);运行命令:php /bin/export_used.php > used.csv
从那以后我每次给客户加「扫码即生效」功能,都会先跑一遍phpunit --filter testConcurrentGenerate(项目自带的并发生成测试),再用ab -n 1000 -c 100 "https://yoursite.com/scan.php?k=test&n=xxx&s=yyy"压测扫码接口,确认409 Conflict响应占比低于 0.1%。不是信不过代码,是信不过自己没看清ON DUPLICATE KEY UPDATE里VALUES()的作用域。希望帮到你。
本文还有配套的精品资源,点击获取