返回探索
指南VibeFix 编辑部更新于 2026年10月18日

vibe 项目的二维码生态实战:扫码登录、渠道活码与核销码

扫码登录六步链路、动态分享码与渠道统计、一码一用核销与防重放、生成选型、微信生态限制——附真实跑通过的 Node/Express ticket 轮询 demo。

用 Python segno 库以 H 级纠错生成的真实可扫二维码题图

先划两条界,免得和站内已有的指南撞车:站内《账号体系与会话指南》(vibe-project-auth-login-permissions-guide)讲的是"用户是谁、会话怎么管",本文只讲"扫码"这一种登录方式的完整工程实现;站内《分享链接指南》(vibe-project-share-links-guide)讲的是"链接的权限与撤回",本文讲"码"的生成、核销与统计。登录、分享、核销——二维码在这三个场景里是三种完全不同的工程问题。

很多 vibe 项目第一次用到二维码,都是"画个码出来"就交差了:npm 装个库,前端一画,扫出来能跳链接,完事。真正上线后才发现,扫码登录的 ticket 被重放了、渠道活码统计对不上、优惠券被一张码核销了八次、微信里码扫出来打不开。这些坑不在"生成"这一步,而在生成之后。本文把二维码在 vibe 项目里的完整生态拆开:扫码登录链路、动态分享与渠道活码、核销码、生成选型、微信生态,每节都给可落地的实现要点,最后附一个真实跑通过的 Node/Express 扫码登录 demo。

二维码在 vibe 项目里的四种角色

同一个黑白方块,在不同场景里承担的职责完全不同,设计时先分清你在做哪一种:

  • 身份凭证(扫码登录):码里是一个一次性的 ticket,扫码动作把"手机上已登录的身份"搬到"电脑前的浏览器"。核心是时效与一次性,码本身不含任何敏感信息。
  • 流量入口(动态分享码 / 渠道活码):码里是一个短链接,扫码即跳转。核心是参数追踪与统计:这个码被扫了多少次、哪个渠道带来的、转化如何。
  • 权益凭证(核销码):码里是一张券、一张票。核心是一码一用、防重放,必要时还要支持离线核销。
  • 信息载体(展示码):Wi-Fi 密码、联系方式、纯文本。核心是容错与美观,一次生成长期有效。

四种角色的安全要求天差地别:展示码可以静态生成、永久有效;核销码必须服务端验签、一次一销。把登录码当成展示码来做(比如码里直接放 userId),是上线后最常见的事故源。

扫码登录完整链路:六步,一步都不能少

扫码登录的本质,是用"手机扫一下"替代"在键盘上输账号密码"。完整链路有六步:

扫码登录六步链路时序图:ticket 生成、二维码展示与轮询、App 扫码、App 确认、token 领取、过期与一次性规则

第 1 步:ticket 生成(Web 端)

用户在电脑浏览器打开登录页,前端请求后端生成 ticket。后端做三件事:生成一个随机 ticketId(至少 128 bit 熵,crypto.randomBytes(16) 起步);在服务端存一条记录 {ticketId, status: 'pending', createdAt};返回 ticketId 和扫码 URL。注意:二维码里放的是 URL,不是 token。URL 形如 https://yourapp.com/scan?t=<ticketId>,App 扫码后打开这个页面(或被 App 拦截走原生扫码结果页)。

ticket 有效期一般设 2 分钟。太短用户扫不完,太长增加被盗扫窗口。过期 ticket 的状态机要能在任何一次查询时惰性过渡到 expired,避免堆积。

第 2 步:二维码展示(Web 端)

前端拿到 scanUrl 后用二维码库画出来。展示期间,前端开始轮询 ticket 状态。轮询间隔 2 秒是经验值:1 秒太密(无效请求多),5 秒太慢(用户扫完要等)。生产环境建议升级为长轮询、SSE 或 WebSocket,把"扫码成功"的通知延迟压到 1 秒内。

第 3 步:App 扫码(手机端)

用户用已登录的 App 扫码。App 解析出 ticketId,带着用户身份凭证请求后端 POST /api/qr/scan/:ticketId。后端校验:ticket 存在、状态是 pending、未过期,然后把状态置为 scanned。这里的关键是扫码和确认是两个动作:扫码只表示"这台手机认领了这个 ticket",Web 端此时显示"已扫码,请在手机上确认",给用户一次反悔机会——万一码是被别人诱导扫的呢。

第 4 步:App 确认(手机端)

用户在 App 上看到"是否允许在 Chrome(IP/地点)登录"的确认页,点确认。后端校验状态必须是 scanned(防止跳过扫码直接调确认接口),然后生成真正的登录 token(JWT 或 session),把 ticket 状态置为 confirmed。这里 token 的生成和你项目已有的会话体系复用即可——扫码登录只是"发 token"的一种新前置流程,不必另起一套会话机制(这也是本文和账号体系指南的分工)。

第 5 步:轮询 / 回调(Web 端)

Web 端的轮询命中 confirmed,后端在响应里带上 token,然后立即销毁 ticket。前端拿到 token 后按正常登录流程存起来(httpOnly cookie 或内存),跳首页。

第 6 步:过期与一次性(贯穿始终)

三条铁律:ticket 2 分钟过期,过期即废;token 只下发一次,取走即销毁 ticket;确认接口幂等性靠状态机保证——confirmed 的 ticket 再次调确认接口直接 404/409。demo 验证里我们实际测过:第二次轮询返回 404,重放确认返回 404,链路是闭合的。

状态机一共五个状态:pending → scanned → confirmed →(销毁),pending → expired。任何不在这张图里的跳转都是 bug,写代码时先画状态机再写接口,能避掉一半的扫码登录 bug。

可运行示例:Node/Express ticket + 轮询 demo

下面是一个完整可运行的最小实现(已在 Node 上真实跑通全链路:生成→轮询 pending→扫码→确认→一次性取 token→重放 404)。生产使用时把内存 Map 换成 Redis(多实例部署),把演示 token 换成你的 JWT 签发逻辑即可。

// 运行:npm i express && node qr-login-demo.js
// 打开 http://localhost:3000/ 看演示页;用 curl 模拟 App 扫码确认
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());

const TICKET_TTL_MS = 2 * 60 * 1000; // 二维码 2 分钟过期
const tickets = new Map(); // ticketId -> { status, createdAt, token }

function newTicket() {
  const id = crypto.randomBytes(16).toString('hex');
  tickets.set(id, { status: 'pending', createdAt: Date.now(), token: null });
  return id;
}
function getTicket(id) {
  const t = tickets.get(id);
  if (!t) return null;
  if (t.status === 'pending' && Date.now() - t.createdAt > TICKET_TTL_MS) {
    t.status = 'expired'; // 惰性过期:查到才过渡,不用定时扫
  }
  return t;
}

// 1) Web 端:生成 ticket(二维码内容就是这个 URL)
app.post('/api/qr/ticket', (req, res) => {
  const id = newTicket();
  res.json({ ticketId: id, scanUrl: `https://example.com/scan?t=${id}`,
             expiresIn: TICKET_TTL_MS / 1000 });
});

// 2) Web 端:轮询 ticket 状态
app.get('/api/qr/ticket/:id', (req, res) => {
  const t = getTicket(req.params.id);
  if (!t) return res.status(404).json({ error: 'ticket not found' });
  const out = { status: t.status };
  if (t.status === 'confirmed') {
    out.token = t.token;            // token 只下发一次
    tickets.delete(req.params.id);  // 一次性:取走即销毁
  }
  res.json(out);
});

// 3) App 端:扫码(pending -> scanned)
app.post('/api/qr/scan/:id', (req, res) => {
  const t = getTicket(req.params.id);
  if (!t) return res.status(404).json({ error: 'ticket not found' });
  if (t.status !== 'pending') return res.status(409).json({ error: `ticket ${t.status}` });
  t.status = 'scanned';
  res.json({ ok: true, status: 'scanned' });
});

// 4) App 端:确认登录(scanned -> confirmed,下发 token)
app.post('/api/qr/confirm/:id', (req, res) => {
  const t = getTicket(req.params.id);
  if (!t) return res.status(404).json({ error: 'ticket not found' });
  if (t.status !== 'scanned') return res.status(409).json({ error: 'must scan first' });
  t.token = 'tok_' + crypto.randomBytes(24).toString('hex'); // 生产:换成你的 JWT
  t.status = 'confirmed';
  res.json({ ok: true });
});

app.listen(3000, () => console.log('demo on http://localhost:3000/'));

前端演示页用 qrcodejs(CDN)在浏览器画码,2 秒轮询一次。真实跑通的 curl 验证序列:

# 生成 ticket
T=$(curl -s -XPOST localhost:3000/api/qr/ticket | python3 -c "import sys,json;print(json.load(sys.stdin)['ticketId'])")
curl -s localhost:3000/api/qr/ticket/$T            # {"status":"pending"}
curl -s -XPOST localhost:3000/api/qr/scan/$T       # {"ok":true,"status":"scanned"}
curl -s -XPOST localhost:3000/api/qr/confirm/$T    # {"ok":true}
curl -s localhost:3000/api/qr/ticket/$T            # {"status":"confirmed","token":"tok_..."}
curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/api/qr/ticket/$T  # 404(一次性)
curl -s -o /dev/null -w "%{http_code}\n" -XPOST localhost:3000/api/qr/confirm/$T  # 404(防重放)

从 demo 升级到生产的 checklist:内存 Map → Redis(设 TTL 让过期自动清理);scan/confirm 接口加用户身份鉴权(App 的登录态);确认页展示登录环境信息(设备、IP、大致地点)防社工;轮询换成 WebSocket/SSE;全链路 HTTPS,ticketId 熵足够就不需要再签名。

动态分享码与渠道活码:码是流量的账本

分享场景的码,内容永远是一个短链接,而不是最终目标 URL。形如 https://yourapp.com/s/abc123,服务端 302 跳到真实落地页。为什么多这一跳?三个理由:第一,可更换目标——码已经印在海报上了,落地页改版只需改服务端的映射,不用重印;第二,参数追踪——短链 id 关联渠道(哪个 KOL、哪场活动、哪个城市),扫码时记录 UA、IP、时间;第三,可停用——活动结束把短链下线,扫码显示"活动已结束",而不是跳到一个 404 的旧页面。

渠道活码是更进一步的形态:对外只印一个码,后台可以随时切换它指向的客服/群/落地页,常用于"一个码长期有效、背后内容常换"的场景(展会易拉宝、产品包装)。实现上就是短链 + 可编辑的目标映射 + 扫码统计。统计口径要注意去重:同一设备/IP 短时间内重复扫只计一次 UV,PV 另计;还要过滤明显是"测试扫"的数据(比如你自己)。

数据模型最小可用版:一张 qr_links 表(id、目标 URL、渠道标签、状态、创建时间),一张 qr_scans 表(link_id、扫码时间、IP 哈希、UA、城市)。vibe 项目初期不用上 BI,这两张表 + 一个按天 group by 的查询就是渠道看板。

核销码:优惠券与票务,一码一用是底线

核销码是安全要求最高的一种。 threat model 很具体:用户截图把券发给八个人、核销员重复扫同一张码、网络不好时重复提交。一码一用的实现分三层:

  • 码内容不可伪造:码里放 {couponId, nonce},服务端用密钥做 HMAC 签名,核销时先验签。不要把"是否已核销"这种状态放在码里,状态永远在服务端。
  • 核销原子化:验签通过后,在数据库事务里把券状态从 unused 改为 used,改成功的才算核销。并发两个请求同时扫同一张码,靠"状态变更的原子性"保证只有一个成功——这是防重放的核心,不要靠"先查后改"。
  • 离线核销:票务检票口经常没网。方案是预签发:活动前服务端给每台检票设备下发一批"已签名且未使用"的券 id 白名单(或带服务端签名的券包),设备离线时验签名 + 查本地已核销集合,联网后回传核销记录做对账。离线期的代价是"一张券在 A 口和 B 口同时被用"无法实时拦截——接受这个 trade-off 的活动才用离线,券值高的场合宁可要求检票设备保持联网。

一个容易踩的坑:核销码用"自增 id + 无签名"的裸码。攻击者把 id 挨个试一遍就能伪造出一堆有效券。码内容要么带服务端签名,要么是足够长的随机串(128 bit),二选一,不能裸奔。

生成选型:qrcode.js、segno、云 API 怎么选

  • 前端即时生成(qrcode.js / qrcodejs):davidshimjs 的 qrcodejs 是最常用的纯前端库,无依赖、CDN 一引即用,适合"扫码登录页现场画码"这种场景。注意它年久少维护,复杂需求(高容错、自定义样式)会吃力,但画普通跳转码完全够用。
  • 后端批量生成(segno):Python 的 segno 是目前口碑最好的二维码库,API 干净,支持按需调容错等级、输出 SVG/PNG。本文题图那张真实可扫的二维码就是用 segno 生成的。Node 后端同类需求可以用 qrcode(npm)包。
  • 云 API:适合"不想在服务端装图形库"的场景,按调用量计费。选型时确认三件事:是否支持 HTTPS、生成速度(扫码登录页是实时链路,API 延迟直接加到用户等待上)、服务商的数据政策(码内容会不会被记录)。

美化码与 logo 嵌入的坑

二维码标准(ISO/IEC 18004)定义了四档纠错等级:L(约 7%)、M(约 15%)、Q(约 25%)、H(约 30%)。往码中间放 logo,本质是"故意遮挡一部分码",靠纠错能力兜底。所以铁律是:带 logo 的码必须用 H 级纠错,logo 面积不超过码的 15-20%,且避开三个定位角。很多"扫不出来"的美化码,病因都是用了默认 M 级纠错又放了个大 logo。

另外两个实测经验:深色 logo 配浅色码是安全区,反过来(浅 logo 压深码)识别率断崖;打印物料上的码,尺寸别小于 2×2cm,太小的码手机对焦都困难。美化之前先用两三款主流扫码 App 实测,别只在设计稿上看效果。

微信生态注意:如实写的三条

国内 vibe 项目绕不开微信,三个限制如实写,写错一条上线就翻车:

1. 域名随时可能被封,且申诉走官方通道。微信对"诱导分享"类内容(分享得红包、集赞领券这类文案)会封禁域名或 IP,页面打开显示"已停止访问该网页"。合规做法:用自有备案域名、文案避开诱导分享话术、一个业务尽量独立域名(别让论坛ugc 和支付共用一个域名,一封全死)。被封后走微信公众平台的申诉入口,不要信任何"防封技术"话术——那基本是灰产。

2. 微信内 H5 跳转受限。iOS 微信里点击应用下载链接会被拦截,提示"请在浏览器打开";跳转外部 App 需要走微信开放平台的规范方式。具体限制随微信版本变化,以微信开放平台最新文档为准,上线前用真机(iOS + Android 各一台)实测你的跳转链路,不要只在开发者工具里看。

3. 微信扫码登录 ≠ 通用扫码登录。如果你的 App 本身就是微信生态内的(公众号/小程序),优先用微信开放平台的官方扫码登录能力(开放平台网站应用的微信登录),不要自己造一套 ticket 体系再套微信壳——官方链路处理了授权、unionId 打通等脏活。只有"自有 App + Web 端"这种本文 demo 的场景,才需要自己实现 ticket 体系。

安全清单:上线前逐条打勾

  • 登录码:ticket 随机 128bit+、2 分钟过期、token 一次性下发、扫码与确认分两步、确认页展示登录环境信息。
  • 分享码:短链可停用、统计去重、目标 URL 服务端可换。
  • 核销码:内容带签名或长随机串、核销原子化、离线场景有对账机制。
  • 通用:全链路 HTTPS;二维码内容永不直接放 userId、手机号等敏感信息;生成接口限流(防被刷出海量码)。

二维码看着是"画个图"的小事,工程上它是"身份、流量、权益"三条线的交汇点。登录场景做错了是安全事故,分享场景做错了是数据事故,核销场景做错了是资损事故。按本文的链路一步步实现,至少能保证:码扫得出来、数对得上、券只用一次。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

城市高空俯拍照片:暖色暮光下的密集街区与高楼,隐喻城市位置服务
指南
“附近的咖啡馆”怎么做:vibe 项目的位置与地图功能实战

从“附近的咖啡馆”到骑手实时位置:入库只存 WGS84、展示层再转 GCJ-02/BD-09,附近搜索走粗排精排两阶段,地图 SDK 先看合规再看价格,逆地理编码按 geohash 缓存,精确位置从采集侧就当敏感个人信息对待。

移动应用后端工程独立开发
视频剪辑时间线特写照片:显示器上可见剪辑轨道、调色轮与时间码
指南
用户传了个 4K 视频,服务器直接爆了:vibe 项目的视频处理管线实战

视频上传完只是开始:转码、多码率、HLS 切片、异步队列,缺一不可才能流畅播放。这篇给你完整管线架构图、FFmpeg 命令模板、HLS 参数甜点、BullMQ 状态机设计、缩略图/字幕/审核三个并行分支,以及上线前必须算的三笔成本账。

部署上线性能优化后端工程