You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

13 KiB

拆分 TokenService:JwtCodec + SessionStore + PreLoginTicketStore Spec

Status: ready-for-agent

把承载三个概念的宽接口 TokenService(12 方法级、JWT 密码学 + 单端在线会话索引 + 预登录票 + 被顶下线标记同居一类)拆成三个按概念划分的深模块:无状态的 JwtCodec(签发/验签)、独占「单端在线」不变量的 SessionStore(会话生命周期 + 真相/线索索引 + 被顶下线标记)、以及独立的 PreLoginTicketStore(短命预登录票)。纯重构,不改任何对外 HTTP 行为、错误码、Redis 数据结构。

域:crm-auth。前置功能已由 single-device-login/spec.md 落地(本 spec 只重构其内部实现,不新增业务)。


Problem Statement

作为维护 crm-auth 的开发者,TokenService 一个 @Component 里塞了三件互不相干的事:JWT 签发/验签(纯密码学)、单端在线会话索引(token:{jti} 真相 + session:{userId}:{clientType} 线索 + 滑动续期 + 脏索引自愈 + CAS 删索引)、预登录票(GETDEL 即烧)、被顶下线标记。这是一个宽接口:接口方法数 ≈ 实现复杂度,没有一个模块专门为「每个 (userId, clientType) 至多一个活会话」这条核心不变量负责。后果:

  • 想给「单端在线」不变量写专属测试,只能对着一个什么都做的类,测试意图被稀释。
  • 每请求认证热路径(JwtAuthenticationFilter.verifyToken)把「验签」和「查 Redis 会话 + 续期」焊死在一个方法里,filter 无法只依赖它真正需要的那一小片。
  • 登录/校验的上层编排测试必须 mock 一个 12 方法的门面,牵一发动全身。

Solution

作为开发者,我希望 TokenService 按概念拆成三个深模块(小接口、大实现),使:

  • JwtCodec 无状态、不碰 Redis,只做「签一个带 jti 的 JWT」和「验签并取出 jti」,可用纯密码学单测覆盖。
  • SessionStore 独占所有会话 Redis key(token 真相键、反向索引线索键、被顶下线标记)与「单端在线」不变量、真相/线索一致性关系、滑动续期、脏索引自愈、CAS 删索引——这些实现细节全部落在它内部,不再横跨两个类。
  • PreLoginTicketStore 独立承载短命预登录票的签发与 GETDEL 消费。

登录/登出/校验的编排(先建会话拿 jti、再签 token;先验签取 jti、再解析会话)上移到本就该知道顺序的调用方(AuthServiceImpl / JwtAuthenticationFilter / DebugTokenController)。对外 HTTP 契约、错误码、Redis 数据结构、JWT payload 结构全部保持不变——存量 token 不失效。

分两步落地,先摘风险最低的预登录票,再做会话拆分,各步独立编译、测试、评审。

User Stories

  1. 作为 crm-auth 维护者,我希望「签发/验签 JWT」独立成 JwtCodec,以便用不依赖 Redis 的纯单测覆盖密码学行为。
  2. 作为维护者,我希望 JwtCodec.sign(jti, userId) 只接收调用方给定的 jti,以便会话身份的生成权归 SessionStore,密码学模块保持无状态。
  3. 作为维护者,我希望 JwtCodec.parseJti(token) 对非法/过期签名的 token 统一返回 null(保留现有限频告警),以便调用方用单一 null 判定处理所有验签失败。
  4. 作为维护者,我希望「单端在线」不变量(每个 (userId, clientType) 至多一个活会话)由 SessionStore 独家负责,以便这条核心安全不变量有一个明确的家和专属测试。
  5. 作为维护者,我希望 SessionStore.openSession(loginUser) 生成 jti、写 token 真相键 + 反向索引线索键(TTL 对齐 ttlDays),返回 jti,以便登录编排拿 jti 去签 token。
  6. 作为维护者,我希望存量登录态(loginUser 无 clientType)在 openSession 时只写 token 键、不建索引,以便老会话行为与现状一致。
  7. 作为维护者,我希望 SessionStore.resolveSession(jti) 查 token 真相键返回 AuthLoginUser,token 键不存在时返回 null,以便每请求热路径用 null 判定区分「活会话 / 已下线过期被顶」。
  8. 作为维护者,我希望 resolveSession 读时顺带滑动续期(剩余 TTL 不足一半时 token 键 + 索引键一并续满,存量无 clientType 会话只续 token 键),且这一副作用对调用方透明,以便续期这项 SessionStore 内政不泄漏成 filter 的显式步骤。
  9. 作为维护者,我希望 SessionStore.isOnline(userId, clientType) 以索引为线索命中后回查 token 真相键确认活着才算在线,token 已死时顺手删脏索引自愈,以便在线检测遵循「真相/线索」一致性风格。
  10. 作为维护者,我希望 SessionStore.kick(userId, clientType) 埋被顶下线标记(TTL 对齐 ttlDays)+ 删旧 token 键,索引留待随后 openSession 覆写,以便顶号踢旧的语义原样保留。
  11. 作为维护者,我希望 SessionStore.isKicked(jti) 报告被顶下线标记是否仍在,以便 filter 在 resolveSession 返回 null 时区分「被顶下线」与「普通过期」。
  12. 作为维护者,我希望 SessionStore.closeSession(jti) 删 token 键 + 用 CAS 删索引(仅当索引仍指向本次 jti),以便登出不误删已被顶号覆写指向新会话的索引。
  13. 作为维护者,我希望预登录票的签发/消费独立成 PreLoginTicketStoreissue / consume),以便它与 JWT、会话索引解耦(独立 key、独立生命周期)。
  14. 作为维护者,我希望 PreLoginTicketStore.consume(ticket) 用 GETDEL 原子读删(拿到即烧),对空白入参不碰 Redis 直接返回 null,以便防并发双签/重放的语义原样保留。
  15. 作为登录编排的调用方(AuthServiceImpl),我希望登录成功路径先 openSession 拿 jti、再 jwtCodec.sign(jti, userId) 拿 token,以便会话与 token 的连体生成显式且有序。
  16. 作为调用方,我希望冲突路径改用 preLoginTicketStore.issue(...) 签发预登录票、isOnline 检测在线,以便顶号决策链路行为不变。
  17. 作为调用方,我希望确认顶号路径用 preLoginTicketStore.consume(...) 读取、sessionStore.kick(...) 踢旧、再 openSession + sign 签新,以便顶号踢旧签新的顺序与现状一致。
  18. 作为调用方,我希望登出路径 jwtCodec.parseJti(token) 取 jti 后 sessionStore.closeSession(jti),以便注销的索引 CAS 删除语义原样保留。
  19. 作为每请求认证热路径(JwtAuthenticationFilter),我希望 jwtCodec.parseJti(token)sessionStore.resolveSession(jti),为 null 时 sessionStore.isKicked(jti) 区分被顶下线,以便被顶设备下次请求仍得到专门的错误码。
  20. 作为调试端点(DebugTokenController),我希望复用与正式登录一致的 openSession + sign 编排,以便调试 token 行为与真登录完全一致。
  21. 作为维护者,我希望重构后 TokenService 类被完全移除(其职责由三个新模块承接),以便不留下宽接口门面造成的复杂度残留。
  22. 作为维护者,我希望对外 HTTP 契约、错误码(61013–61016)、Redis 数据结构、JWT payload(仍只 jti + userId)全部不变,以便存量 token 不失效、老前端不受影响。

Implementation Decisions

  • 拆分维度按概念,不按方法数。三个概念各成一个深模块,接口小、实现深。
  • 新增模块与接口
    • JwtCodec(接口 + impl):String sign(Long userId /* 或 String */, String jti) 语义上「用给定 jti 签一枚带 userId 的 JWT」;String parseJti(String token) 验签成功返回 jti,失败返回 null。无状态、不注入 Redis,仅依赖 AuthProperties(密钥、限频告警计数器留在 impl)。
    • SessionStore(接口 + impl):String openSession(AuthLoginUser)AuthLoginUser resolveSession(String jti)(读时滑动续期)、void closeSession(String jti)boolean isOnline(String userId, ClientTypeEnum)void kick(String userId, ClientTypeEnum)boolean isKicked(String jti)。注入 StringRedisTemplate + AuthProperties。独占 token 真相键、反向索引线索键、被顶下线标记三类 key 及其一致性关系。CAS 删索引的 Lua 脚本随 impl 迁移。
    • PreLoginTicketStore(接口 + impl):String issue(String userId, ClientTypeEnum)PreLoginSession consume(String ticket)。注入 StringRedisTemplate + AuthProperties
  • 编排上移:登录/登出/校验的「先建会话再签 / 先验签再解析」顺序移到 AuthServiceImplJwtAuthenticationFilterDebugTokenControllerAuthServiceImpl 现已在编排 isOnline → kick → createToken,顺理成章。
  • 滑动续期归属:埋进 SessionStore.resolveSession,作为读的副作用,对调用方透明(deep module:简单接口藏「查 + 判 TTL + 续两个 key」)。不设独立的 renew 公开方法。
  • replace, don't layerJwtCodec 不是 TokenService 的门面转发层;TokenService 类整体删除,职责真正迁移到三个模块,不保留宽接口。
  • 不变项:Redis key 前缀(crm:auth:token/session/prelogin/kicked)、TTL 策略、JWT payload、AuthConstants、错误码、LoginResultDTO/AuthLoginUser 结构、HTTP 契约。
  • 分两步交付
    • 第一步:抽 PreLoginTicketStore,从 TokenService 删除 prelogin 相关方法与 helper,AuthServiceImpl 改用新模块。独立编译 + 测试 + BOM 扫描。
    • 第二步:抽 JwtCodec + SessionStore,删除 TokenService,上移编排,改 AuthServiceImpl/JwtAuthenticationFilter/DebugTokenController。独立编译 + 全量 crm-auth 测试 + BOM 扫描。
  • 尊重既有 ADR / specsingle-device-login/spec.md 的一致性风格 B(真相/线索)、CAS 删索引、GETDEL 即烧、滑动续期对齐等语义在重构后逐条保留,不引入新业务决策。

Testing Decisions

  • 好测试只验外部行为:验各模块通过接口可观察的效果(返回值、对 StringRedisTemplate 的交互契约),不验私有 helper。
  • 测试基建复用现状:现有 TokenServiceTest 用 mock StringRedisTemplate + ValueOperations、以真密钥 mintToken 铸合法 JWT,不用嵌入式 Redis。新模块单测沿用这套 mock-Redis 基建。
  • 分层测试
    • JwtCodecImplTest:纯单测,签→解 jti 往返、验签失败/垃圾 token 返回 null、限频告警不刷屏。无 Redis。
    • SessionStoreImplTest:承接 TokenServiceTest 中会话相关用例——openSession 写 token 键 + 索引(含无 clientType 只写 token 键)、resolveSession 滑动续期(不足一半续、超过一半不续、存量只续 token 键)、isOnline 索引命中/未命中/脏索引自愈、kick 埋标记 + 删旧 token、closeSession CAS 删索引(含无 clientType 只删 token 键)、isKicked。锁住「单端在线」不变量与真相/线索一致性。
    • PreLoginTicketStoreImplTest:承接 TokenServiceTest 中 prelogin 相关用例——issue 写 payload(TTL 取配置)、consume GETDEL 读删并解析、缺失返回 null、空白入参不碰 Redis。
  • 上层编排测试AuthServiceImplTestJwtAuthenticationFilterTest 现 mock TokenService;改为 mock JwtCodec + SessionStore(+ PreLoginTicketStore),重写编排桩(登录:openSession→sign;校验:parseJti→resolveSession→isKicked;顶号:consume→kick→openSession→sign)。塞内存/mock 假实现,不碰真 Redis。
  • prior artTokenServiceTest(mock-Redis 契约测试)、候选 1 的 OwnerSnapshotResolverImplTest(接口 + impl 深模块的分层单测与「假实现替换整模块」模式)。
  • 删除TokenServiceTestTokenService 类一并删除,其用例已按概念分流到三个新单测。

Out of Scope

  • 任何对外 HTTP 契约、错误码、返回结构、Redis 数据结构、JWT payload 的改动。
  • 单端在线业务逻辑本身的变更(顶号决策、被顶下线码等由 single-device-login/spec.md 定义,不动)。
  • 引入嵌入式 Redis 或集成测试基建(继续用 mock-Redis 单测)。
  • SessionStore / JwtCodec 跨模块下沉到 crm-base 之类的共享层。
  • 候选清单中的其他架构改进(owner 快照已单独完成;display 名称回显、opportunity port 收敛等属另案)。

Further Notes

  • 风险集中在第二步:它触及每请求认证热路径与「单端在线」安全不变量。分两步正是为了先把零风险的预登录票落袋,再单独面对会话拆分。
  • 领域词沿用 single-device-login/spec.md:端类型 clientType、在线会话、真相/线索、被顶下线、预登录票、单端在线不变量。
  • 收尾统一:BOM 扫描(所有 *.java)→ mvn compile → 跑 crm-auth 测试。
  • AuthLoginUser / PreLoginSession 的包位置本次不迁移,避免扩大改动面。