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
- 作为 crm-auth 维护者,我希望「签发/验签 JWT」独立成
JwtCodec,以便用不依赖 Redis 的纯单测覆盖密码学行为。 - 作为维护者,我希望
JwtCodec.sign(jti, userId)只接收调用方给定的 jti,以便会话身份的生成权归SessionStore,密码学模块保持无状态。 - 作为维护者,我希望
JwtCodec.parseJti(token)对非法/过期签名的 token 统一返回 null(保留现有限频告警),以便调用方用单一 null 判定处理所有验签失败。 - 作为维护者,我希望「单端在线」不变量(每个 (userId, clientType) 至多一个活会话)由
SessionStore独家负责,以便这条核心安全不变量有一个明确的家和专属测试。 - 作为维护者,我希望
SessionStore.openSession(loginUser)生成 jti、写 token 真相键 + 反向索引线索键(TTL 对齐 ttlDays),返回 jti,以便登录编排拿 jti 去签 token。 - 作为维护者,我希望存量登录态(loginUser 无 clientType)在 openSession 时只写 token 键、不建索引,以便老会话行为与现状一致。
- 作为维护者,我希望
SessionStore.resolveSession(jti)查 token 真相键返回AuthLoginUser,token 键不存在时返回 null,以便每请求热路径用 null 判定区分「活会话 / 已下线过期被顶」。 - 作为维护者,我希望
resolveSession读时顺带滑动续期(剩余 TTL 不足一半时 token 键 + 索引键一并续满,存量无 clientType 会话只续 token 键),且这一副作用对调用方透明,以便续期这项 SessionStore 内政不泄漏成 filter 的显式步骤。 - 作为维护者,我希望
SessionStore.isOnline(userId, clientType)以索引为线索命中后回查 token 真相键确认活着才算在线,token 已死时顺手删脏索引自愈,以便在线检测遵循「真相/线索」一致性风格。 - 作为维护者,我希望
SessionStore.kick(userId, clientType)埋被顶下线标记(TTL 对齐 ttlDays)+ 删旧 token 键,索引留待随后 openSession 覆写,以便顶号踢旧的语义原样保留。 - 作为维护者,我希望
SessionStore.isKicked(jti)报告被顶下线标记是否仍在,以便 filter 在 resolveSession 返回 null 时区分「被顶下线」与「普通过期」。 - 作为维护者,我希望
SessionStore.closeSession(jti)删 token 键 + 用 CAS 删索引(仅当索引仍指向本次 jti),以便登出不误删已被顶号覆写指向新会话的索引。 - 作为维护者,我希望预登录票的签发/消费独立成
PreLoginTicketStore(issue/consume),以便它与 JWT、会话索引解耦(独立 key、独立生命周期)。 - 作为维护者,我希望
PreLoginTicketStore.consume(ticket)用 GETDEL 原子读删(拿到即烧),对空白入参不碰 Redis 直接返回 null,以便防并发双签/重放的语义原样保留。 - 作为登录编排的调用方(
AuthServiceImpl),我希望登录成功路径先openSession拿 jti、再jwtCodec.sign(jti, userId)拿 token,以便会话与 token 的连体生成显式且有序。 - 作为调用方,我希望冲突路径改用
preLoginTicketStore.issue(...)签发预登录票、isOnline检测在线,以便顶号决策链路行为不变。 - 作为调用方,我希望确认顶号路径用
preLoginTicketStore.consume(...)读取、sessionStore.kick(...)踢旧、再openSession+sign签新,以便顶号踢旧签新的顺序与现状一致。 - 作为调用方,我希望登出路径
jwtCodec.parseJti(token)取 jti 后sessionStore.closeSession(jti),以便注销的索引 CAS 删除语义原样保留。 - 作为每请求认证热路径(
JwtAuthenticationFilter),我希望jwtCodec.parseJti(token)→sessionStore.resolveSession(jti),为 null 时sessionStore.isKicked(jti)区分被顶下线,以便被顶设备下次请求仍得到专门的错误码。 - 作为调试端点(
DebugTokenController),我希望复用与正式登录一致的 openSession + sign 编排,以便调试 token 行为与真登录完全一致。 - 作为维护者,我希望重构后
TokenService类被完全移除(其职责由三个新模块承接),以便不留下宽接口门面造成的复杂度残留。 - 作为维护者,我希望对外 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。
- 编排上移:登录/登出/校验的「先建会话再签 / 先验签再解析」顺序移到
AuthServiceImpl、JwtAuthenticationFilter、DebugTokenController。AuthServiceImpl现已在编排 isOnline → kick → createToken,顺理成章。 - 滑动续期归属:埋进
SessionStore.resolveSession,作为读的副作用,对调用方透明(deep module:简单接口藏「查 + 判 TTL + 续两个 key」)。不设独立的renew公开方法。 - replace, don't layer:
JwtCodec不是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 / spec:
single-device-login/spec.md的一致性风格 B(真相/线索)、CAS 删索引、GETDEL 即烧、滑动续期对齐等语义在重构后逐条保留,不引入新业务决策。
Testing Decisions
- 好测试只验外部行为:验各模块通过接口可观察的效果(返回值、对
StringRedisTemplate的交互契约),不验私有 helper。 - 测试基建复用现状:现有
TokenServiceTest用 mockStringRedisTemplate+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。
- 上层编排测试:
AuthServiceImplTest、JwtAuthenticationFilterTest现 mockTokenService;改为 mockJwtCodec+SessionStore(+PreLoginTicketStore),重写编排桩(登录:openSession→sign;校验:parseJti→resolveSession→isKicked;顶号:consume→kick→openSession→sign)。塞内存/mock 假实现,不碰真 Redis。 - prior art:
TokenServiceTest(mock-Redis 契约测试)、候选 1 的OwnerSnapshotResolverImplTest(接口 + impl 深模块的分层单测与「假实现替换整模块」模式)。 - 删除:
TokenServiceTest随TokenService类一并删除,其用例已按概念分流到三个新单测。
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的包位置本次不迁移,避免扩大改动面。