简介这是一套面向PHP开发者的二维码防重防错系统源码主要解决二维码重复、错发及难以追溯等问题。系统由首页扫码、每日二维码生成、批量打印、数据统计与基础设置等模块组成覆盖扫码页面、生成规则、打印输出和数量查询等核心链路适合有PHP部署基础的中级开发者以及需要内部二维码管控的中小团队参考。压缩包为ZIP格式大小5.24MB文件总数与类型明细暂未标注预计包含PHP源码、前端页面及相关配置脚本可直接部署学习。目前已吸引232人学习浏览。通过本资源可了解二维码防重校验与编号规则的设计思路掌握按天生成、打印数量统计、扫码次数查询等功能的实现技巧同时项目预留了前缀位数、默认日打印量、年月日序号等基础设置项便于二次开发打造符合自身业务流程的二维码管理平台。1. 二维码防重防错系统在管什么复制、翻拍与一次性验证做过活动核销的人都被同一件事折磨过二维码印在纸质券上能被复印截图发群里能被反复扫明明只发了一张券后台却收到三四次核销请求。二维码防重防错系统也叫二维码生成系统核心就做一件事——让发出去的每一个码在生命周期内只被成功核销一次同时把“伪造码”“改参数码”“过期码”挡在门外。它不是靠“码不可复制”这种伪命题而是靠签发时绑定业务属性、存储层记录状态、验证时做多条件判定。适合做一物一码、签到核销、工单追踪、票务验真的从业者尤其是用 PHP 维护整套业务链路的人。2. 状态机与令牌设计让二维码只能活一次的底层逻辑2.1 从“码不可复制”到“码只能活一次”换个思路管二维码二维码本身可以被无限复制这是图像识别技术决定的和容错等级、加密算法都没有关系。防重的真正模型是服务端在签发时给每个码生成一条独立记录并把这个码的唯一标识、过期时间、允许使用次数写进存储层。验证端的核心逻辑不是“识别这个码是不是原图”而是“这条码记录当前处于什么状态”。我一般把码的生命周期定义为四个阶段待核销、已核销、已过期、已作废。数据库里就是一个status字段0 代表待核销1 代表已核销2 代表过期未用3 代表运营手动作废。状态流转的规则是死的只能在特定条件下跳转待核销 - 已核销用户核销时且当前时间在过期时间之前待核销 - 已过期到达过期时间点仍未被核销待核销 - 已作废运营后台主动作废比如发现某个活动场次的码误发了已核销、已过期、已作废均为终态不能转回这个设计避免了修改一个字段就能“复活”码的可能。任何状态下扫码请求进来先查状态不在“待核销”就拒绝。这里必须强调一个容易被新手忽略的细节不要把业务状态直接拼在二维码内容里。有的开发者会把内容生成成https://example.com/verify?statusVALIDcodexxx这种码扫码时服务端哪怕校验了 code攻击者也能把 status 改成 VALID 再配合一个合法 code 绕过。正确做法是二维码内容只携带码本身和少量防伪参数服务端只有查到自己的存储记录才认这个码的状态。2.2 令牌生成短码校验位把“手输错误”挡在第一层二维码防重的第一步是设计一套“不好猜、能自查”的令牌格式。纯数字自增 ID 一定不行扫一个码就能枚举出相邻码批量伪造直接秒破。随机 UUID 可以但 32 位太长打印在实物券上字太小扫码场景体验会很差。我实际项目中更常用的方案是业务类型前缀 时间因子 随机因子 自增 ID 混淆 校验位。核心原则是机器读起来毫不费力人眼输错时本地就能发现问题而不是每次都打后端接口。?php /** * 生成一次性二维码令牌 * 格式: T 4位时间因子(36进制) 8位随机因子(16进制) 4位ID因子 1位校验字符 */ function generateTicketCode(int $ticketId): string { $prefix T; // 业务前缀T代表票券类 $timePart strtoupper(base_convert((string)time(), 10, 36)); // 当前时间戳转36进制 $randPart strtoupper(bin2hex(random_bytes(4))); // 4字节密码学随机数 $idPart str_pad(base_convert((string)$ticketId, 10, 36), 4, 0, STR_PAD_LEFT); $payload $prefix . $timePart . $randPart . $idPart; $checksum checksumChar($payload); // 校验字符 return $payload . $checksum; } /** * 校验位算法加权和取模模数31映射为单字符 * 字符集避开 0O1IL 易混淆字符 */ function checksumChar(string $payload): string { $charset 23456789ABCDEFGHJKMNPQRSTUVWXYZ; $sum 0; for ($i 0; $i strlen($payload); $i) { $sum ord($payload[$i]) * ($i 1); } return $charset[$sum % 31]; }逻辑说明time()转 36 进制是为了让码在时间维度上不完全可预测同时不影响后续校验random_bytes(4)生成 8 位十六进制随机串保证同一个秒内并发生成多个码也不会冲突ticketId参与编码但不直接暴露攻击者拿不到原始 ID 就无法回推规则。参数说明校验位用的模数是 31字符集只有 31 个字符这样模数和字符集一一对应。我特意剔除了0、O、1、I、L这类光学识别容易混淆的字符打印在纸质券上后用户手动输入时识别错误率会低很多。校验位的核心作用不是防攻击而是防“转录错误”——拍照识别、手工抄写、跨系统粘贴时如果码结构坏了本地直接就能判断不用浪费一次 HTTP 请求。2.3 防篡改签名时间戳、随机数和服务端密钥缺一不可扫码场景最常见的小动作是用户拿到码之后改 URL 参数改活动 ID 想兑换其他场次、改数量、改渠道来源。单凭令牌本身的信息不够因为令牌里的 ID 因子是隐式的参数位置是显式的。所以验证接口所有对外参数必须做签名。?php /** * 生成接口签名 * param array $params 业务参数必须包含 code、scene_id、timestamp、nonce * param string $secret 服务端保存的单码密钥 */ function genSign(array $params, string $secret): string { ksort($params); // 先按键名升序排序避免拼接顺序不一致 $str http_build_query($params); // 拼接为 codexxxnoncexxxscene_idxxx... $str . key . $secret; // 尾部拼上密钥 return strtoupper(hash_hmac(sha256, $str, $secret)); }逻辑说明ksort保证客户端和服务端拼接原始字符串的顺序完全一致这是签名校验能不能通过的第一前提。http_build_query会把数组转成规范的 URL 查询串避免手写拼接时漏掉某些字段。使用hash_hmac而不是简单md5($str)因为 HMAC 的密钥参与计算攻击者在不知道密钥的情况下无法通过改参数重新生成合法签名。参数说明timestamp建议精确到秒允许的服务端时间偏移窗口我一般设在 300 秒以内超过这个窗口直接拒绝防止同一个请求体被无限重放。nonce是一个随机字符串每次请求唯一建议至少 16 位不过要提醒的是光有 nonce 还不够需要服务端对 nonce 做短暂去重比如 5 分钟内相同 nonce 只放行一次否则攻击者截获请求后重放时间窗口内依然有效。scene_id必须参与签名防止用户把 A 活动的码改签到 B 活动。secret不要在客户端代码里存PHP 服务端放配置文件扫码页只做跳转不承担签名生成职责。3. 生成端落地PHP 生成防重二维码的完整实现3.1 表结构设计一张码表加一张核验记录表就够了很多入行没多久的开发者会把防重信息全部塞进二维码内容里后端只用一个数组做校验项目上线一个月老板要统计数据时根本拿不出可靠报表。正确的做法是把码的定义和码的使用行为分开存一张码表存每个码的当前状态一张核验记录表存每一次扫码请求的痕迹。-- 码表一个码一行状态在这里推进 CREATE TABLE qr_ticket ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, code VARCHAR(32) NOT NULL COMMENT 令牌码唯一索引, scene_id INT UNSIGNED NOT NULL COMMENT 业务场景ID区分活动/工单/签到, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待核销 1已核销 2已过期 3已作废, sign_key VARCHAR(32) NOT NULL COMMENT 单码逻辑密钥签名用, expire_at DATETIME NULL COMMENT 过期时间NULL表示长期有效, scanned_at DATETIME NULL COMMENT 实际核销时间, scanner_user_id INT UNSIGNED NULL COMMENT 核销操作人ID, created_at DATETIME NOT NULL, UNIQUE KEY uk_code (code), KEY idx_scene_status (scene_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci; -- 核销记录表每次扫码请求都落一条不管成不成功 CREATE TABLE qr_verify_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, code VARCHAR(32) NOT NULL COMMENT 被扫的码, result TINYINT NOT NULL COMMENT 0失败 1成功, reason_code VARCHAR(16) NULL COMMENT 失败原因码如 E1003, ip VARCHAR(45) NULL COMMENT 扫码客户端IP, user_agent TEXT NULL COMMENT 设备信息, created_at DATETIME NOT NULL, KEY idx_code_time (code, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;逻辑说明code在码表里建唯一索引从存储层面先保证一个码只有一条记录。sign_key单独存而不在代码里写死一个全局密钥是为了支持不同批次、不同渠道的码使用不同密钥泄露一批不影响全量。qr_verify_log表只做追加写入不管核销成功还是失败都记录这是后续审计和排查并发问题的重要依据。参数说明scanner_user_id存的是核销人而不是扫码人因为 B 端核销场景通常是工作人员扫用户出示的码这个字段能帮你看出是哪个网点、哪个操作员完成的核销。reason_code建议直接用固定枚举前端拿到能立刻映射成“已核销”“已过期”“签名错误”等提示避免把含糊的数据库异常抛给用户。3.2 PHP 二维码生成选库、参数与出图检查PHP 生态里生成二维码的两个主流方案一是老牌的phpqrcode单文件导入即可用适合轻量项目二是endroid/qr-code基于GD或Imagick支持更细的参数控制。两者在实现上都是调用QrCode类输出图像本项目的选型建议是只生成标准 QR 码、不叠加复杂 logo 的话用endroid/qr-code就够了维护更省心。?php require_once vendor/autoload.php; use Endroid\QrCode\Builder\Builder; use Endroid\QrCode\Writer\PngWriter; use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; use Endroid\QrCode\Encoding\Encoding; /** * 生成二维码文件 * param string $content 二维码内容一般就是跳转URL带上code和sign参数 * param string $filePath 保存路径 */ function buildQr(string $content, string $filePath): void { $result Builder::create() -writer(new PngWriter()) -data($content) -encoding(new Encoding(UTF-8)) -errorCorrectionLevel(new ErrorCorrectionLevelHigh()) -size(600) -margin(20) -build(); file_put_contents($filePath, $result-getString()); }逻辑说明data()传入的是前端扫码后要跳转的完整 URL这个 URL 上必须带code和签名参数而不能只放一个码值。ErrorCorrectionLevelHigh()把容错等级设为 H这意味着二维码即使有 30% 面积被遮挡或者被折叠、磨损仍然可以正常识别——打印出来的实体券经常会遇到这些情况容错等级过低会直接导致扫不出来。参数说明size(600)是输出图像边长 600 像素这个值建议打印前先在真实扫码设备上验证600 像素打印成 5 厘米见方的券基本清晰但如果你只做屏幕展示300 像素就够了文件体积更小、加载更快。margin(20)是静区宽度单位是像素不要设成 0否则把图贴到深色背景上时识别率会明显下降。3.3 发码前的三步自检扫一扫、改一改、过期试一次生成脚本写得再漂亮不做出图验证就直接批量发码必然会在上线当天翻车。我一般会在开发环境强制走这三步第一步是“扫一扫验证”。把生成的 PNG 打印到 A4 纸上用微信、支付宝、系统自带相机三种工具各扫一次确认都能正确解析出 URL。这一步能暴露容错等级过低、对比度不足、静区被裁掉三类问题。第二步是“改一改验证”。拿到二维码内容后手动改动一个字符再拿去扫。正常情况下扫码结果会跳到一个错误提示页而不是直接打开业务页面。如果没有做校验位和签名校验改了参数的码依然能走到核销逻辑这是绝对不允许的。第三步是“过期验证”。临时把某个测试码的expire_at改到过去时间扫码确认系统返回“已过期”的明确提示而不是把用户卡在一个空白页。响应文案要具体比如“二维码已于 2024-06-01 10:00 过期”让用户知道问题出在时间上而不是误以为系统坏了。4. 验证端落地扫码接口、并发锁与状态流转4.1 扫码参数清洗先校验位、时间窗、签名再查库扫码请求到达服务端后参数清洗的顺序决定了安全性和性能。我的顺序是先做本地校验位检查再做时间窗检查然后做签名检查最后才查数据库状态。前面任何一步失败都直接返回错误码省一次数据库查询的同时也避免攻击者通过接口响应时间猜测码的合法性。?php /** * 核销接口核心逻辑 * 入参: code, scene_id, timestamp, nonce, sign */ function verifyQrCode(array $input): array { $code $input[code] ?? ; $timestamp intval($input[timestamp] ?? 0); $sceneId intval($input[scene_id] ?? 0); $nonce $input[nonce] ?? ; $sign $input[sign] ?? ; // 第一步本地校验位码本身结构不对的绝不查询数据库 if (!checkChecksum($code)) { return [code E1001, message 二维码格式不正确]; } // 第二步时间窗校验超过 300 秒直接拒绝 if (abs(time() - $timestamp) 300) { return [code E1004, message 二维码已过期请刷新后重试]; } // 第三步从码表取出该码自己的 sign_key生成签名比对 $ticket DB::table(qr_ticket)-where(code, $code)-first(); if (!$ticket) { return [code E1002, message 二维码不存在]; } $expected genSign([ code $code, scene_id $sceneId, timestamp $timestamp, nonce $nonce, ], $ticket-sign_key); if (!hash_equals($expected, $sign)) { return [code E1002, message 二维码参数校验失败]; } // 第四步状态检查注意这里用行锁锁定当前记录 DB::beginTransaction(); try { $locked DB::table(qr_ticket) -where(code, $code) -lockForUpdate() -first(); if (intval($locked-status) ! 0) { DB::rollBack(); $reason intval($locked-status) 1 ? E1003 : E1005; $message $reason E1003 ? 该二维码已被核销 : 该二维码已作废; return [code $reason, message $message]; } if ($locked-expire_at strtotime($locked-expire_at) time()) { DB::rollBack(); return [code E1004, message 该二维码已过期]; } // 原子更新状态必须带 and status0 防止并发穿透 $affected DB::table(qr_ticket) -where(code, $code) -where(status, 0) -update([ status 1, scanned_at date(Y-m-d H:i:s), scanner_user_id $input[user_id] ?? null, ]); if ($affected ! 1) { DB::rollBack(); return [code E1003, message 该二维码已被核销]; } DB::commit(); // 写核销日志成功失败都写 writeVerifyLog($code, 1, , $input[ip] ?? , $input[user_agent] ?? ); return [code SUCCESS, message 核销成功]; } catch (Throwable $e) { DB::rollBack(); writeVerifyLog($code, 0, E5000, $input[ip] ?? , $input[user_agent] ?? ); return [code E5000, message 系统繁忙请稍后重试]; } }逻辑说明这段代码把“查状态”和“改状态”放在同一个事务里并且加了lockForUpdate()行锁。lockForUpdate()会在事务期间锁住这行记录其他会话对同一行的写操作必须等当前事务结束。update语句里又叠加了where(status, 0)这就是第二道保险即使行锁因为某些特殊原因没生效更新条件本身也会挡住重复标记已核销。这里的非重复逻辑核心是状态从 0 到 1 的变迁是一条不可逆的线任何一次成功的修改都会把“待核销”这个唯一入口关掉。4.2 并发控制Redis 锁与行锁的正确姿势行锁能解决数据库层面的并发但在高并发场景下有一个隐患大量扫码请求同时进来事务排队时间变长数据库连接池容易被耗尽。另一个问题是业务需要先读后写时行锁的粒度和事务边界很难控制。业界常见做法是在数据库之前加一层 Redis 分布式锁让并发请求在更早的位置错开。?php function acquireLock(Redis $redis, string $code, int $ttl 5): array { $lockKey qr:lock: . $code; $lockValue bin2hex(random_bytes(8)); $ok $redis-set($lockKey, $lockValue, [NX, EX $ttl]); return $ok ? [locked true, value $lockValue] : [locked false, value ]; } function releaseLock(Redis $redis, string $code, string $lockValue): void { $script LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end LUA; $redis-eval($script, [qr:lock: . $code, $lockValue], 1); }逻辑说明SET key value NX EX 5的意思是只有当 key 不存在时才写入且 5 秒后自动过期。两个并发请求同时来只有一个能拿到锁拿不到的请求直接返回“系统繁忙”不再打数据库。释放锁时用了一段 Lua 脚本脚本里先比较 value 再删除这一步非常关键如果直接del($lockKey)当前线程的锁可能已经被其他请求在极端情况下覆盖直接删掉的是别人的锁。参数说明TTL 值 5 秒是我在核销场景里的经验值。正常情况下一次核销的业务逻辑耗时约 20-80 毫秒5 秒足够但如果数据库或 Redis 出现瞬时抖动锁会自动过期后续请求能正常进来最多牺牲一点并发一致性换服务可用性。不要设 30 秒因为一旦业务处理线程挂掉整个系统的核销入口会被卡死。4.3 错误码定义把“为什么不行”准确地告诉调用方防重系统的错误码必须做得比普通业务系统更细因为扫码用户拿到的反馈直接影响下一步行为。比如“已核销”和“已过期”用户的反应完全不同前者会去找发券方理论后者会刷新页面或者找客服重发。错误码含义提示语示例业务处理E1001码格式错误二维码无效请重新扫描不查库直接拒绝E1002签名或参数校验失败二维码校验不通过疑似篡改记日志E1003码已被核销该码已在 2024-06-01 08:12 使用终态不可逆E1004码已过期该码已超过使用期限终态不可逆E1005码已作废该码已被商家作废终态不可逆E2001系统繁忙请稍后重试幂等可重试E5000服务端异常系统开小差了排查日志这样设计的价值在排障时最能体现接线客服把一个错误码截图发给技术侧技术人员扫一眼就知道问题在哪个环节而不是让前端把“网络错误”这种黑匣子答案抛给用户。5. 避坑/常见问题并发、缓存、时钟偏移这五个坑5.1 现象同一码两个窗口同时扫两个都提示成功这是防重系统最经典的翻车现场。核销接口刚上线时某开发者用了“先 SELECT 查状态再 UPDATE 改状态”的写法没有锁也没有原子条件。两个请求同时查到的都是“待核销”然后各自执行自己的 UPDATE由于 UPDATE 没有带status0条件两个请求都更新成功了。原因查询和更新不是原子操作丢失了其中一次状态变更的约束条件。解决把“查状态”和“改状态”合并为一个原子操作用UPDATE ... WHERE code? AND status0并且用受影响行数判断是否抢到核销资格。如果affected rows为 0说明状态已经不是 0直接返回已核销。这是最底线的保命手段即使不加行锁、不加 Redis 锁这个写法也能挡住大部分并发问题。从那以后我每次做防重逻辑第一个动作就是检查 UPDATE 语句的 WHERE 条件里是否包含status0没有的话一律打回重写。5.2 现象Redis 锁加了还是重复锁失效翻车某个项目在验证接口里加了 Redis 锁但压测时仍然出现同一码被核销两次。排查代码发现释放锁用的直接是$redis-del($lockKey)没有判断持有者A 请求拿到锁后处理时间稍长锁 5 秒自动过期B 请求立刻拿到锁并成功核销A 请求处理完删锁时把 B 请求的锁删掉了紧接着 C 请求又进来拿锁再次核销。原因锁的自动过期和手动释放之间存在竞态窗口释放锁时没有确认“锁还是我自己的”。解决释放锁前必须用 Lua 脚本比对持有者 value 再删除也就是上面releaseLock里的写法。同时把业务逻辑尽量压缩在锁内处理避免持有锁期间做远程调用、写大字段日志这类耗时操作。5.3 现象码刚发出去十分钟就扫不动了一个票务场景里用户领到二维码后 10 分钟再扫提示“已过期”。后台一看expire_at确实还没到问题出在时间判断上。代码里用的是date(Y-m-d)比较当天日期但签发时存的是带时分秒的完整时间比较时只比到了天把当天日期和完整时间混着比较自然出现了“当天过期”的错误判定。原因时间精度不一致。签发时用DATETIME精确到秒验证时却用date(Y-m-d)比较到了天或者服务器时区与业务配置时区不一致比如服务器是 UTC业务期望是 UTC8。解决统一时间口径。数据库全量使用DATETIME精确到秒PHP 侧统一用time()时间戳和strtotime($expire_at)比较不要混用日期函数时区在项目入口统一date_default_timezone_set(Asia/Shanghai)并确认 MySQL 连接time_zone与会话一致。5.4 现象翻拍的二维码照样能用不少客户在做防伪体系时会有一个误解认为二维码本身应该具备“不能被拍照复制”的能力。实际技术上做不到二维码是静态图像拍照、截图、打印都等价于原图复制。防重系统能防的是“同一个码被重复使用”不能防“物理上复制码”。原因把“防重”误当成“防伪”。解决明确边界后在业务上加入首扫留痕机制——首次核销时记录被扫设备的 IP、User-Agent、地理位置和核销时间二次扫码时直接提示“该码已于某时某地被使用”让渠道方和用户都能看到复制行为被发现了。对于高价值场景可以进一步增加动态二维码方案即码内容每 30 秒变化一次但那是另一套系统不适合普通票券场景。5.5 现象升级后老版本二维码全部变成“非法码”某次系统升级把签名算法从md5换成sha256密钥也换了新的一批结果存量二维码全部核销失败。原因是测试环境只验证了新生成的码没有用老码做回归。原因签名算法升级时旧码的签名是用旧密钥和老算法生成的新验证逻辑无法重新计算并匹配。解决写一个兼容层。验证签名时先按新逻辑算一次不通过再按旧逻辑算一次同时准备一个“算法切换开关”灰度期间新老算法并行等旧码全部过期后再移除兼容代码。这个兼容层代码量不大但能避免一次严重的线上故障。6. 进阶从单次验证到审计闭环的两步改造基础防重跑通之后下一个值得投入的改造方向是审计闭环。所谓闭环不是只记录“这个码被扫成功了”而是记录下每一次失败的扫码动作、每一次参数修改的痕迹、每一个运营后台的状态变更操作。这些数据平时没人看但出了纠纷时是唯一能说话的证据。第一步是给核销记录表加“来源设备”和“操作前状态”两个字段。来源设备不只存 User-Agent最好把扫码入口微信内置浏览器、支付宝、独立 App单独解析出来这样能识别出“同一个码在微信里扫了一次、又在支付宝里扫了一次”这类异常路径。操作前状态字段则记录业务动作发生前的状态值配合日志表的时间线可以把一个码从签发到终态的完整生命周期拉出来。某次活动核销出现“用户说没用过、后台却显示已核销”的纠纷时我凭借日志里的设备类型和扫码时间直接定位到是某个店员用自己手机先扫了码问题立刻闭环。第二步是增加人工复核入口。核销记录表里加一个review_status字段0 表示无需复核1 表示可疑2 表示已确认异常。当同一个 IP 在短时间内扫不同码失败超过 5 次或者同一个码被扫后 10 分钟内又出现状态变更请求时自动标为可疑。运营在后台看到可疑记录后可以查当时的核销现场照片或者调监控人工确认。这一步听起来不像技术活但在真实业务里的价值极高它把防重系统从“被动拒绝”升级成了“主动举证”。这套改造做完之后防重系统才真正从“能用”变成“可信”。从那以后我每次上线这类系统都会强制自己走一遍完整的冒烟流程生成一批测试码、打印出来、扫码核销、改参数重扫、两个终端同时扫、过期为重扫五个用例全部按预期返回才敢批量发码。希望这份实现思路能帮你少踩几个坑把防重防错系统一次做稳。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?