3.1 把注册流程的"验证码分支"留了空(先查重再哈希再落库,requireSmsOnRegister=true 时走 sms_->verifyCode);3.2 把"登录成功写 Redis 会话"说了。这一篇把这两件事的接缝焊死:验证码怎么校验、Token 怎么持久化、退出怎么吊销,以及它们背后的 key 约定与配置开关。

配套代码:src/service/UserService.cc(registerUser 的 verifyCode 分支、login 的 sessions_->save、logout 的 sessions_->revoke)、src/service/SessionStore.{h,cc}、配置项 requireSmsOnRegister / requireSmsOnLogin。

一、本篇在链路里的位置:是什么、坑在哪、本质一句话

是什么:把"验证码校验"接到注册/登录流程里(挡脚本批量注册),把"登录签发的 JWT"的 jti 持久化进 Redis(sess:{jti},可吊销权威态),并把"退出登录"实现为删这条 key。

坑在哪:

  • 验证码只校验不消费:同一码被用 N 次,等于没验证码。
  • Token 只签发不持久化:那就退回纯无状态 JWT,无法吊销。
  • 持久化与签发不是原子:签发成功但写 Redis 失败,客户端拿到的 Token 永远验不过——必须显式报错而非返回"成功"。

本质一句话:本篇是接缝——验证码"校验即消费",Token"签发即持久化、退出即吊销",三者都靠 Redis 单一态兜底。

二、验证码校验接入注册(校验即消费)

3.1 的 registerUser 在 requireSmsOnRegister=true 时,先调 sms_->verifyCode,通过才执行 doRegister 闭包。校验结果直接映射成业务码:

1
2
3
4
5
6
7
8
9
10
11
// src/service/UserService.cc(节选)
if (!cfg_.requireSmsOnRegister) { doRegister(); return; }
sms_->verifyCode(phone, code, [cb, doRegister](SmsService::VerifyResult r) {
switch (r) {
case SmsService::VerifyResult::Ok: doRegister(); return; // 通过才注册
case SmsService::VerifyResult::EmptyCode: cb(false, "CODE_EXPIRED"); return; // 不存在/过期
case SmsService::VerifyResult::WrongCode: cb(false, "CODE_WRONG"); return; // 还能再试
case SmsService::VerifyResult::TooManyAttempts: cb(false, "CODE_TOO_MANY_ATTEMPTS"); return; // 已作废
default: cb(false, "CODE_INVALID"); return;
}
});

关键点:校验与消费在 SmsService 的 Lua 里原子完成(见 3.3/3.5),所以这里拿到的 Ok 意味着"这个码已经被删掉"——同一验证码第二次来就是 EmptyCode,杜绝"一码多用"。

三、Token 持久化:登录写 sess:{jti}

登录成功时,UserService::login 先 genJti() 生成 32 字符唯一 ID,再 jwt_->sign(userId, jti) 签发,最后 sessions_->save(jti, userId) 把权威态写进 Redis。save 用 SETEX sess:{jti} ttl userId,TTL 与 JWT 的 exp 对齐。

1
2
3
4
5
6
7
8
// src/service/UserService.cc(登录成功节选)
const std::string jti = genJti();
const std::string token = jwt_->sign(userId, jti); // 凭证
sessions_->save(jti, userId, // 权威态
[cb, token](bool ok) {
if (!ok) { cb(false, "", "REDIS_ERROR:session save failed"); return; } // 写失败≠成功
cb(true, token, "OK");
});

Redis key 全景(排障时直接查):

1
2
3
4
5
6
7
sess:{jti}              会话(value=userId,TTL 与 JWT exp 对齐)—— 权威态
sms:code:{phone} 验证码(校验通过即删除,一次性)
sms:cd:{phone} 重发冷却标记
sms:try:{phone} 该验证码已试错次数
sms:day:{phone}:{yyyyMMdd} 当日已发条数(TTL 到次日 0 点)
login:fail:{phone} 密码失败计数(TTL = lockSeconds,滑动窗口)
login:lock:{phone} 账号锁定标记(TTL = 剩余锁定时长)

四、退出即吊销(revoke 预览)

退出登录 = 校验 Token → 删 Redis 会话。JWT 本身无法被"改",但此后每次鉴权都会因 EXISTS sess:{jti} 失败而被拒——旧 Token 立即失效。

1
2
3
4
5
6
7
8
// src/service/UserService.cc(logout 节选)
void UserService::logout(const std::string &token, std::function<void(bool, const std::string &)> &&cb) {
JwtClaims claims;
if (!jwt_->verify(token, claims)) { cb(false, "TOKEN_INVALID"); return; } // 验签失败 401
sessions_->revoke(claims.jti, [cb](bool deleted) {
cb(true, deleted ? "OK" : "ALREADY_LOGGED_OUT"); // DEL 即吊销
});
}

五、配置开关:验证码在哪里强制

config.json 的 custom_config.auth 段控制验证码的强制范围,默认"注册强制、登录不强制"——因为登录已有 3.6 的错误次数锁定兜底,登录态加验证码会牺牲体验。

键 默认 说明
require_sms_on_register true 注册要验证码:防脚本批量注册
require_sms_on_login false 登录默认不要:有错误次数锁定兜底
min_password_length 6 密码最小长度

六、对比表(无验证码/无持久化 vs 本文)

维度 不接验证码 + 纯无状态 JWT 本文(校验即消费 + 持久化)
批量注册 脚本可狂注 注册强制验证码挡住
验证码复用 一码多用 Lua 原子消费,一次性
Token 吊销 改密/封号后仍有效到 exp DEL sess:{jti} 立即失效
写 Redis 失败 — 显式 REDIS_ERROR,不返回假成功

功能抉择(本篇核心权衡)

① 为什么验证码"校验即消费"要在 Lua 里做,而不是应用层先 GET 再 DEL?
应用层三步分开,并发下两个请求同时 GET 到同一正确码、都通过、都 DEL——注册场景同一手机号绕过验证码重复注册。SmsService 的 kVerifyScript 把"比对 + 删除"放进 Redis 单线程,一次性消费,从根上消除。

② 为什么 Token 持久化与签发必须同一次成功才算成功?
若签发成功但 save 失败,客户端拿到的 Token 每次鉴权都会因 EXISTS 失败被拒,等于发了个废品。这里选择显式返回 REDIS_ERROR:session save failed,让客户端知道"这次登录没生效",而不是回 {code:0} 骗它——否则用户会困惑"为什么刚登录就被踢"。

③ 为什么登录默认不强制验证码,注册强制?
注册是"建身份",一旦被脚本批量注册,后续一人一单、风控全崩,所以必须验证码。登录是"验身份",已有 3.6 的错误次数 + 临时锁定兜底,再加验证码只是牺牲体验、收益边际。开关化让两者可独立调整。

小结

  • 验证码接入注册:校验通过才执行 doRegister,且校验在 Lua 里"比对即消费",一次性。
  • Token 持久化:登录 genJti → sign → save(SETEX sess:{jti}),TTL 与 exp 对齐;写失败显式报错,不返回假成功。
  • 退出登录 = verify → revoke(DEL),旧 Token 立即失效。
  • Redis key 全景七类(会话/验证码/冷却/试错/每日/失败计数/锁定),排障直接查。
  • 验证码强制范围开关化:注册强制、登录默认不强制(锁定兜底)。🐾

配套源码

本文对应代码已开源:Hespethorn/seckill-cpp

C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:

  • 阶段一(直打数据库):QPS≈440,0 超卖
  • 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS

如果对你有帮助,欢迎 star ⭐