ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

活码系统源码实战:短链跳转、动态码管理与302缓存排错

活码系统源码实战:短链跳转、动态码管理与302缓存排错

简介:面向需要搭建动态二维码管理平台的开发者与运营人员,这套 PHP 活码网站源码围绕一次生成、后台随时改内容的需求,提供活码创建、参数配置、扫描跳转与内容更新的完整实现,可用于广告营销、产品追踪、会员服务等场景的二次开发与学习。压缩包为 rar 格式,共 2000 个文件、约 17.79MB;PHP 文件 761 个承载主要业务逻辑,HTML、JavaScript 与 CSS 构成前后台界面和交互,GIF、PNG、JPG 等图像资源用于图标与页面展示,SQL、config、tpl 及字体文件辅助安装部署与页面渲染,目录中可见安装与清理 SQL、引导配置以及 Nginx、web.config 部署示例。已有 1049 人学习下载。读者可梳理活码标识与数据库记录的对应关系、后台更新指向内容的流程、扫描请求的实时反馈机制,以及营销统计、产品追踪等模块的组织方式,并结合模板与静态资源搭建演示站点,掌握动态码管理系统的整体结构与关键实现思路。

1. 活码不是“能改的二维码”,是一套短链跳转服务加一个动态码管理后台

市场部印好一批海报发下去,落地页要换域名,几百张二维码全部作废——这种事只要遇到一次,就会有人提出“做个活码”。活码的原理很朴素:二维码里印的不是目标地址,而是一串固定短链,扫码瞬间由服务端决定跳去哪。所以二维码图案本身永远不变,变的只是后台里那条目标配置。很多人以为活码就是一个能编辑内容的二维码生成器,真做一遍才发现,二维码那段是死的,活的是一整套短链跳转服务、缓存策略和动态码管理后台。这套源码骨架其实不复杂:一个发号器、一张码表、一个 302 端点、一套统计,加起来两三千行。难的是让它在被 CDN 缓存、被浏览器记住、被爬虫遍历的情况下,还能按你按下的那个按钮准时生效。下面按我实际搭过的一套方案,从链路拆到建表、从代码拆到翻车现场。

2. 从扫码到落地的那几百毫秒:活码跳转链路拆开看

用户举起手机到页面打开,中间大概两百到八百毫秒,这期间请求穿过了六层。绝大多数“活码不生效”“扫码没反应”的问题,都能在这六层里定位到具体一层,而不是笼统地怀疑代码写错了。先把链路画清楚,后面所有排错才有坐标。

2.1 一次扫码要经过哪六层,每层能出什么错

第一层是二维码内容本身,也就是印在纸上的那串https://s.example.com/c/8f3Kq2。这一层出错通常是码的物理问题:尺寸太小、对比度不够、贴在反光材质上,跟源码无关。第二层是域名解析,短链域名必须是独立的,不能和目标站同域,否则换目标就等于换域名,活码白做。第三层是 CDN 或边缘节点,这一层最容易出事——它会把 302 响应缓存下来,你后台改了目标,边缘节点还在吐旧地址。

第四层是网关,负责把/c/前缀的请求转给跳转服务,同时透传真实客户端 IP;如果这里没配X-Forwarded-For,后面所有统计的地域和 UV 全是错的。第五层是跳转服务本体,查缓存、查库、按规则选目标、写访问日志。第六层是目标服务器,它慢不慢跟你的活码没关系,但如果它超时,用户感知到的就是“这个码扫不开”。分清楚这六层,你在排查时才能一句话定位:是码的问题、缓存的问题,还是目标站的问题。

2.2 301 省下的那点流量,会让你永远改不动码

新手最容易犯的错,是把跳转写成 301。理由听起来很正当:永久跳转,浏览器和 CDN 都能缓存,省服务器资源。问题是这个“永久”你真的收不回来。301 会被 Chrome 写进磁盘缓存,用户不清缓存就一直跳旧地址;CDN 侧更麻烦,你连清哪台边缘节点都不知道。我见过一次翻车,一个批次三千张码,改完目标后有一半用户三天后还在访问老页面,最后靠换短码域名才收场。

正确做法是 302(或语义更严谨的 307),并且显式关掉缓存:

# 改完目标后,第一件事是验证响应头,而不是刷新页面 curl -sI https://s.example.com/c/8f3Kq2 | grep -iE 'HTTP/|^location|cache-control|age'

看三样东西:状态码必须是 302,Location必须是你刚改的新地址,Cache-Control必须含no-store。如果出现Age: 3600这种头,说明中间有节点在缓存,得去 CDN 配置里把/c/路径的缓存规则关掉。反过来的经验是:二维码图片本身(/qr/8f3Kq2.png)应该长缓存,因为图案永远不变,可以放心给一年immutable。同两个端点,缓存策略完全相反,这一点想明白了,活码的缓存问题就解决了一大半。

提示:验证时别只用浏览器,浏览器有自己的缓存。用curl -I或者手机飞行模式切换网络后再扫,能看到真实链路行为。

2.3 直跳、中间页、微信内提示页:三种落地方式的取舍

302 直跳体验最好,用户几乎无感,缺点是你在跳转前没有任何机会做二次确认,目标地址一旦有问题,用户直接看到报错页。中间页(先渲染一个“正在跳转”的 HTML,再用 JS 或 meta 跳)能加统计、能做风险提示、能兜住目标站挂掉的情况,代价是多一次请求,转化大概掉 3% 到 8%,弱网下更明显。微信内提示页是第三种,专门对付内置浏览器对陌生域名的拦截:检测到 UA 含MicroMessenger且目标域名不在白名单里,就先给一个“点击右上角在浏览器打开”的引导页。

我的取舍是默认直跳,只在两个条件下切中间页:目标域名是新接入的(前 7 天观察期),或者当前小时该码的跳转失败率超过阈值。这个判断逻辑不要写死在代码里,做成码表上的一个字段,运营自己能改,比每次发版强。

3. 动态码管理的数据模型:一张码表撑不住三种业务

活码系统上线第一个月通常只有“改目标”一个需求,第二个月会加“按渠道分不同落地页”,第三个月会加“双十一期间 A 页面、平时 B 页面”。如果一开始把目标 URL 直接塞在码表里,这三个需求会把表结构改得面目全非。动态码管理的核心不是那张码表,是码、目标、规则三者的拆分方式。

3.1 为什么目标必须独立成表

一张码对应一个目标,听起来一对一就够了。但真实业务里,同一个码往往要挂多个目标:按权重做 A/B 测试、按 UA 分流(微信里跳一个、安卓应用市场跳一个)、按时间段切换(活动期间跳活动页)。这三种需求本质上是同一个结构——一对多,加一个“选哪个”的决策函数。把目标拆成独立表,决策逻辑就变成了纯粹的查询加过滤,新增一种分流维度只需要加一个字段,不用动表结构。

扫描日志更不要跟业务表放一起。一个中等规模的活码系统,单日扫描量到几十万很常见,写进 MySQL 的业务库会把主库 IO 打满。常见做法是跳转服务只往消息队列扔一条精简记录(码、时间、IP 哈希、UA、来源),由消费端批量落到 ClickHouse 或按天分表的日志库。

3.2 三张核心表的 DDL 与索引取舍

-- 码表:一行就是一个对外发布的活码 CREATE TABLE lc_code ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code VARCHAR(12) NOT NULL COMMENT '对外短码,Base62,唯一', scene VARCHAR(32) NOT NULL DEFAULT 'default' COMMENT '批次/渠道标识', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用 2过期', expire_at DATETIME NULL COMMENT '为空表示永不过期', qr_style JSON NULL COMMENT '容错等级/logo/尺寸等出图参数', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_code (code), KEY idx_scene_status (scene, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 目标表:一个码挂 N 个目标,靠 weight 和 ua_rule 决定选谁 CREATE TABLE lc_target ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code_id BIGINT UNSIGNED NOT NULL, url VARCHAR(1024) NOT NULL, weight SMALLINT NOT NULL DEFAULT 100 COMMENT '同码内相对权重,0 表示不参与', ua_rule VARCHAR(16) NOT NULL DEFAULT 'all' COMMENT 'all/wechat/ios/android', start_at DATETIME NULL COMMENT '生效时间窗起', end_at DATETIME NULL, enabled TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_code_enabled (code_id, enabled, weight) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 扫描日志:只存哈希,不存原始 IP,按天分表 CREATE TABLE lc_scan_20240601 ( code VARCHAR(12) NOT NULL, ts DATETIME(3) NOT NULL, ip_hash CHAR(16) NOT NULL COMMENT 'IP+UA+日期 的截断哈希,用于 UV 去重', ua_type TINYINT NOT NULL COMMENT '1微信 2iOS 3安卓 4其他', target_id BIGINT UNSIGNED NOT NULL, KEY idx_code_ts (code, ts) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_code是唯一索引,它同时承担了两个职责:查得快,以及在并发发号时兜住碰撞。idx_code_enabled的顺序不能随便调,跳转服务的热查询是“给定 code_id,取所有 enabled=1 的目标并按 weight 排序”,把 weight 放进索引第三位能让排序走索引。日志表的ip_hash用 IP 加 UA 加日期做哈希再截断,既满足 UV 去重,又避免明文存 IP 带来的合规负担——这个字段设计我改过两次,第一次存明文,第二次合规评审直接打回来。

3.3 短码别用纯自增:Base62 混淆发号的写法

短码是整个系统唯一暴露给外部的标识。用纯自增转 Base62 有两个致命问题:一是可遍历,别人从000001顺序爬就能把全站码扫一遍,日志一夜涨几十 G 的场面我见过;二是暴露业务量,竞品看你码值增长速度就知道你发了多少物料。

做法是用一个大质数做乘法混淆,保证映射是双射(不碰撞),同时把连号打散:

ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ" # 去掉 0O1lI BASE = len(ALPHABET) # 58 WIDTH = 7 # 58^7 ≈ 2.2 万亿,够用很久 MOD = BASE ** WIDTH MULT = 1_000_003 # 质数,与 MOD 互质,保证双射 def encode(n: int) -> str: """自增 id -> 对外短码,连号会被打散成离散值""" v = (n * MULT) % MOD chars = [] for _ in range(WIDTH): v, r = divmod(v, BASE) chars.append(ALPHABET[r]) return "".join(reversed(chars)) def decode(s: str) -> int: """短码 -> 自增 id,客服核对时用得上""" v = 0 for ch in s: v = v * BASE + ALPHABET.index(ch) return (v * pow(MULT, -1, MOD)) % MOD

三个参数各有讲究。字母表去掉0O1lI,是因为客服和运营经常要人工念叨、手抄短码,形近字符的误读成本远高于多出来的那点空间。WIDTH=7是个平衡点,6 位在百万级码量时空间开始紧张,8 位印在海报上偏长。MULT必须和MOD互质,否则映射会出现多对一,也就是碰撞——decode也就还原不回去了。真正入库时仍然要靠uk_code唯一索引兜底,捕获DuplicateKeyError后 id 自增重试一次即可,别指望数学上绝对无碰撞。

4. 跳转服务最小可运行版:一个 302 端点加两层缓存

跳转服务是整个系统里唯一被高频调用的东西,它的性能目标很明确:单次跳转不碰数据库。做到这一点只需要两层缓存——Redis 存完整配置,进程内再放一个秒级的本地缓存挡热点 key。剩下的事情就是把这几十行代码写对。

4.1 端点代码:查缓存、选目标、异步入队、302

from fastapi import FastAPI, Request, Response import redis.asyncio as aioredis, json, random, time, hashlib app = FastAPI() r = aioredis.from_url("redis://127.0.0.1:6379/0", decode_responses=True) @app.get("/c/{code}") async def jump(code: str, request: Request): cfg = await load_config(code) if cfg is None or cfg["status"] != 1: return Response(status_code=404, content="码不存在或已停用") if cfg["expire_at"] and cfg["expire_at"] < time.time(): return Response(status_code=410, content="码已过期") ua = request.headers.get("user-agent", "") target = pick_target(cfg["targets"], ua, int(time.time())) if target is None: return Response(status_code=503, content="暂无可用目标") # 只入队不落库,消费端批量写日志表 await r.lpush("scan_q", json.dumps({ "code": code, "ts": time.time() * 1000, "ip_hash": ip_hash(request), "ua_type": ua_type(ua), "target_id": target["id"]})) return Response(status_code=302, headers={ "Location": target["url"], "Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache", })

这段代码的关键不在功能,在几个容易被忽略的位。status_code=302和Cache-Control必须写在返回里,不能指望网关补。日志用lpush扔队列而不是await db.insert,因为跳转路径上任何一次数据库写都会把 P99 拉高十倍以上。ip_hash要在服务侧算,网关透传的X-Forwarded-For可能是一串 IP,取第一个才是客户端真实地址,取错了 UV 统计会离谱到没法看。参数上唯一需要调的是超时:Redis 连接超时建议 50ms 以内,超时就直接走降级路径读库,宁可慢一点也不能让用户看到 500。

4.2 缓存键带版本号:改码时的“后悔药”

缓存一致性是所有活码系统绕不过去的坎。直接del单个 key 看着简单,实际上在并发场景下会出现“删了又被人写回旧值”的经典问题。更稳的做法是给缓存键加一个全局版本号:

SCHEMA = "v3" async def load_config(code: str): ver = await r.get("lc:ver") or "0" # 全局版本,改码时 INCR key = f"lc:{SCHEMA}:{ver}:{code}" # 版本进 key,旧 key 自然过期 raw = await r.get(key) if raw is not None: return json.loads(raw) if raw != "__nil__" else None cfg = await fetch_from_db(code) # 回源 if cfg is None: await r.set(key, "__nil__", ex=60) # 空值缓存,挡穿透 return None await r.set(key, json.dumps(cfg), ex=3600) return cfg

lc:ver一 INCR,所有旧版本的 key 立刻变成孤儿,靠 TTL 自然淘汰,不需要遍历删除。代价是每次改码会让全站缓存冷一遍,所以别把版本号做得太细——按“改动批次”而非“单个码”来 INCR。__nil__那个哨兵值是防穿透的,短码被人拿随机字符串刷的时候,没有这层兜底每个请求都会打到数据库上。

4.3 权重分流、时间窗与频次限制的实现边界

选目标是个纯函数,输入是目标列表和当前时间,输出一条目标:

def pick_target(targets, ua, now): ua_t = ua_type(ua) ok = [t for t in targets if t["enabled"] and t["weight"] > 0 and (t["start_at"] is None or t["start_at"] <= now) and (t["end_at"] is None or t["end_at"] > now) and t["ua_rule"] in ("all", ua_t)] if not ok: return None total = sum(t["weight"] for t in ok) hit = random.randint(1, total) # 权重按整数抽 for t in ok: hit -= t["weight"] if hit <= 0: return t

权重分流的坑在于“看起来随机,统计上抖动很大”。100 对 100 的两条目标,头一百次扫码里 70 比 30 是常态,别急着怀疑代码。想减少抖动,要么用平滑加权轮询,要么在统计页上直接展示置信区间。频次限制则要格外克制:同一办公楼里几十个人共用一个出口 IP,按 IP 限流会误伤真人,所以频次限制只应该用在风控维度(拦截明显的机器刷量),绝不能拿来当正常的访问门槛。

5. 排查现场:改码不生效、扫不出来、数据对不上

前面三章讲的是怎么搭起来,这一章讲的是搭起来之后最常接到的五类报障。每一条我都按“看到什么现象、根子在哪、怎么修”写,能对号入座就不用重新推一遍链路。

5.1 改完目标十分钟了,手机扫还是旧地址

现象:后台显示保存成功,电脑上curl -I拿到的是新地址,但用户手机扫出来还是老的。原因通常有两层叠加:一是响应头缺no-store,手机浏览器或微信 X5 内核把上一次的 302 结果记住了;二是 CDN 对/c/路径做了默认缓存,边缘节点还留着一份。修法是先把响应头补齐,再去 CDN 控制台把/c/的缓存规则改成“不缓存”或“遵循源站”,最后让运营换一批短码做灰度,别指望能清掉全部终端缓存。经验是:活码上线第一天就要把这两个配置做对,事后补的成本是事前的十倍。

5.2 微信里扫提示“已停止访问该网页”,浏览器里正常

现象:同一个码,安卓自带浏览器打得开,微信里扫出来是一张拦截页。原因不是你的跳转服务坏了,而是目标域名在微信侧的信誉分不够,或者目标页里有诱导分享类的内容。修法分两步:先在跳转服务里识别MicroMessenger的 UA,把这类流量导到一个中立的引导页,提示用户改用浏览器打开;同时让目标站那边去处理内容合规问题。这条链路必须做成可配置的,因为拦截规则会变,你今天写死的判断明天可能就不适用。

5.3 后台统计的扫码量是真实 UV 的三倍

现象:后台显示某张码今天扫了 3000 次,实际物料只发了 1000 份。原因是很多扫码器和微信在真正跳转前会先发一次探测请求,有的还带HEAD,你的服务把它当成了一次正常扫码。修法有两处:一是跳转端点只对GET记日志,HEAD直接返回 200 空响应;二是ip_hash里必须带上日期和 UA,同一人 5 分钟内重复扫码只计一次。别用 IP 单独去重,NAT 环境下一整栋楼都是一个 IP,去重过头反而少算。

5.4 同一张图,安卓能扫出来,iOS 扫不出来

现象:出图后自测正常,苹果机型上怎么对都对不上。原因多半在出图参数上:容错等级设成了 L(7%),又叠了一个占面积 25% 的 logo,剩下的有效模块太少。修法是把容错等级提到 H(30%),logo 面积压到 20% 以内,同时把码的最小边长定在 2 厘米以上——低于这个尺寸,任何旗舰机的对焦都吃力。出图参数建议存进qr_style字段,不同材质(纸、亚克力、屏幕)用不同的参数组,别全站一套。

5.5 短码被人从头遍历,日志一夜涨了几十 G

现象:凌晨流量暴涨,全是 404,短码呈现明显的连续特征。原因就是发号用了纯自增。修法前面讲过,用乘法混淆把连号打散;同时加一层应用级防护,对同一 IP 在 1 分钟内超过 50 次 404 的直接返回 429,并且这类请求不写入日志队列。这里要留个心眼:限流阈值不能设得太低,正常用户扫码失败重试、或者办公室多人共用一个出口,都可能踩到线。

6. 上线前用这四组数据验收,再决定要不要继续投人

活码系统做出来容易,判断它能不能扛住一场线下活动,得看具体数据。我在交付前固定跑四组测试,每组都有明确的合格线,达不到就不签字。

指标合格线具体测法
跳转 P99 延迟小于 80ms压测工具只打/c/{code},把目标站耗时排除在外
缓存命中率大于 95%看 Redis 的命中数除以总请求数,低于这个值说明键设计或 TTL 有问题
改码生效延迟小于 5s后台改目标后,脚本每 200ms 请求一次比对 Location,记录到变化的时间
扫码识别率大于 98%打 100 张不同尺寸和材质的样张,三款机型各扫一遍,记录失败张数

压测那一步有个细节值得单独说:不要用固定短码压,固定码会被本地缓存挡住,测出来的数字好看但没意义。用 5000 个不同短码轮询打,才能压出真实的回源比例。改码生效延迟这项最容易被跳过,但它是运营最在意的指标——活动开始前十分钟改落地页是常态,5 秒和 5 分钟对他们来说是两种产品。

我自己踩得最深的一次,是把lc:ver的 INCR 写在了单个码的编辑接口里。测试环境只有几十张码,完全看不出问题;上线后运营批量导入两千张码,每导入一条就 INCR 一次,全站缓存被反复击穿,数据库连接池直接打满。后来改成按批次 INCR,一次导入只失效一次,问题消失。所以我的习惯是:任何跟缓存版本相关的写操作,先在脑子里过一遍“如果这个接口被批量调用一千次会怎样”,过不去就别上线。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进