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.

98 lines
13 KiB

3 weeks ago
# 拆分 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. 作为维护者,我希望预登录票的签发/消费独立成 `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` 的包位置本次不迁移,避免扩大改动面。