# 拆分 TokenService:JwtCodec + SessionStore + PreLoginTicketStore Spec Status: resolved > 2026-08-20 执行完毕:两步交付均已落地并验证(crm-auth 205 测试全绿、全模块 9 模块 BUILD SUCCESS、BOM 0、变更全部限定在 crm-auth)。 > `TokenService` 已删除;三个深模块落在 `com.crm.auth.security`(接口)+ `com.crm.auth.security.impl`(实现); > 编排上移至 `AuthServiceImpl` / `JwtAuthenticationFilter` / `DebugTokenController`。对外 HTTP 契约、错误码、Redis key、JWT payload 零变化。 > 测试分流:TokenServiceTest(18 用例)按概念拆为 PreLoginTicketStoreImplTest(4) / SessionStoreImplTest(15) / JwtCodecImplTest(5), > 另新增 filter「验签即败不查会话」、AuthServiceImpl「issueToken 编排顺序」「logout 垃圾 token 不关会话」三组编排级用例。 > 把承载三个概念的宽接口 `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. 作为维护者,我希望预登录票的签发/消费独立成 `PreLoginTicketStore`(`issue` / `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`。 - **编排上移**:登录/登出/校验的「先建会话再签 / 先验签再解析」顺序移到 `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` 用 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。 - **上层编排测试**:`AuthServiceImplTest`、`JwtAuthenticationFilterTest` 现 mock `TokenService`;改为 mock `JwtCodec` + `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` 的包位置本次不迁移,避免扩大改动面。