architecture review

crm-backend-matt

2026-08-17 · 热点区域:crm-auth(资源树 / 登录态 / 数据权限)、crm-file

module seam leakage deep module

1 · 拆分 TokenService:三个概念从宽接口里各归其位

Strong local-substitutable

crm-auth/.../security/TokenService.java(212 行,12 个公开方法)

crm-auth/.../service/impl/AuthServiceImpl.java · security/JwtAuthenticationFilter.java · controller/DebugTokenController.java

Before — 一个宽接口扛三个概念

interface
12 方法

implementation
212 行

JWT 密码学 + 单端在线会话索引 + 预登录票 + 被顶下线标记同居一类;
接口几乎与实现等宽 —— shallow

After — 三个深模块

sign / parseJti

JwtCodec

单端在线不变量独占于此

SessionStore

issue / consume

TicketStore

橙色 = interface,深色 = implementation;接口收窄,实现变深

Problem

「每个 (userId, 端类型) 至多一个活会话」这条核心安全不变量没有一个专属的 module 负责——它分散在 12 个平铺方法之间,靠调用方的调用顺序维系。

Solution

按概念拆成三个深模块:无状态 JwtCodec、独占单端在线不变量的 SessionStore、独立的预登录票 store;编排上移到本就该知道顺序的调用方。

  • • locality:单端在线不变量有一个明确的家和专属测试
  • • JwtCodec 无状态不碰 Redis,纯密码学单测即可覆盖
  • • 每请求热路径(JwtAuthenticationFilter)只依赖它真正需要的那一小片 interface
  • • 登录编排测试不再 mock 一个 12 方法的门面
已有成稿 spec:.scratch/token-service-split/spec.md(status: ready-for-agent),纯重构、零行为变更——本卡片是确认它的优先级,而非重新提案。

2 · 收拢权限资源树:变更路径与规则失效收进一个深模块

Strong in-process

crm-auth/.../service/impl/ResourceServiceImpl.java(229 行)· service/IResourceService.java

crm-auth/.../security/ApiPermissionCache.java · security/ApiPermissionInterceptor.java · service/impl/PermissionSeederImpl.java

Before — 失效义务挂在调用方

flowchart TB
  RC[ResourceController] --> RS[ResourceServiceImpl]
  SD[PermissionSeederImpl
启动种子化] --> SM[(sys_menu)] RS --> SM RS -->|"手动 invalidate()"| AC[ApiPermissionCache] AC --> AI[ApiPermissionInterceptor] SD -.->|"不失效 ⚠ 靠启动顺序侥幸"| AC RS --> FA[FileApi
图标上传] classDef leak stroke:#dc2626,stroke-width:2px,color:#dc2626; class SD leak

After — 变更收进深模块,失效藏在 seam 后

flowchart TB
  RC[ResourceController] --> RT
  SD[PermissionSeederImpl] --> RT
  subgraph RT["权限资源树 module(深)"]
    direction TB
    M1[节点校验 / 层级约束]
    M2[sys_menu 读写]
    M3[规则缓存失效]
  end
  RT --> AI[ApiPermissionInterceptor]
  IC[图标上传校验] --> FAPI[FileApi]
  classDef deep fill:#0f172a,color:#e2e8f0,stroke:#0f172a;
  class RT deep
              

Problem

「资源树变更 → API 权限规则缓存失效」这条不变量由调用方手动履行:ResourceServiceImpl 记得调 invalidate(),PermissionSeederImpl 写 sys_menu 却不调——目前只因种子化跑在首个请求之前而没出事。图标上传(扩展名白名单、大小上限)也挂错了 module,撑宽了资源树的 interface。

Solution

把所有 sys_menu 写路径(save / delete / seed)收进一个权限资源树深模块,规则缓存失效藏在它的 seam 之后;图标上传挪到它该在的地方(FileApi 侧的上传策略或独立小 module)。

  • • locality:缓存失效义务从 N 个调用方收进 1 个 module,新增写路径不再可能漏失效
  • • interface 收窄:资源树 module 只剩「树 CRUD」,不再混入文件上传机制
  • • 测试打在资源树的 interface 上:save/delete 用例顺带断言规则一致性
  • • 「enabled」过滤谓词现在散落三处(Resolver / Cache / Seeder),收拢后只写一遍
须守住 ADR-0016 的 PermissionSeeder seam(跨模块种子化的唯一出口)——深模块化的是 crm-auth 内部的写路径,不改变 seed 契约。

3 · 抽出「用户定位器」:登录流程的定位规则与组织准入归一

Worth exploring in-process

crm-auth/.../service/impl/AuthServiceImpl.java(249 行,self-injection + 两次身份查询)

Before — 定位规则横跨事务边界两次执行

login() 前置校验 + 端类型解析
authCode → 三方用户信息(adapter)
事务外探测:查身份 ① + locateUser + 组织准入
事务内 resolveUser:查身份 ② + locateUser(重复)
@Lazy self 注入绕 AOP 代理进事务
单端在线检测 → 签发 / 预登录票

身份绑定 → 手机号匹配 → 首登注册这条定位规则,为事务边界切成了两段互相参照的代码。

After — 定位规则收进一个深模块

login() 编排
用户定位器 module 身份绑定 → 手机号匹配 → 组织准入 → 首登注册 孤儿身份重定向 · 事务边界在内部处理
单端在线检测 → 签发 / 预登录票

AuthServiceImpl 只剩登录编排;定位规则一处写、一处测。

Problem

「身份绑定 → 手机号匹配 → 首登注册」被事务边界切成事务外探测与事务内重放两段(身份查询执行两次、locateUser 被迫共用防漂移),还靠 @Lazy self-injection 才能走进事务代理。

Solution

把定位规则连同组织准入门禁收进一个用户定位器深模块,自持事务边界;login() 只消费定位结果,孤儿身份重定向也藏进 seam 之后。

  • • locality:孤儿身份、组织准入、首登注册的 bug 集中在一个 module
  • • 身份查询从两次降为一次,探测与定位不再各自维护一份规则
  • • self-injection 消失,事务边界由定位器自己声明
  • • 测试打在定位器 interface 上,不必再对整条登录链路做 mock

4 · 数据权限机制:理解一个档位要跳五个地方

Speculative in-process

crm-base/.../security/(DataVisibility · DataScopeLevel · DataVisibilityContext · VisibilityScope)

crm-auth/.../security/PermissionResolverImpl.java · DataScopeInterceptor.java · service/impl/RoleScopeStoreImpl.java

Before — 一条机制横跨两个 module 五处代码

flowchart TB
  RS[RoleScopeStore
档位持久化] --> DB[(sys_role_data_scope)] DB --> PR[PermissionResolverImpl
按模块取最宽] PR --> DV[DataVisibility
crm-base] DV --> CTX[DataVisibilityContext
请求线程传递] CTX --> DI[DataScopeInterceptor
翻译成 SQL 条件]

After — 假设性收拢

数据权限 module

档位存储 · 折算(多角色每模块取最宽)· 部门集合 · SQL 翻译

但 crm-base 必须提供注解与上下文给所有业务模块——seam 怎么摆是真正的难题。

Problem

「一个角色的数据范围档位如何变成 SQL 条件」要依次读 RoleScopeStore → PermissionResolverImpl → DataVisibility → DataVisibilityContext → DataScopeInterceptor,且横跨 crm-base / crm-auth 两个 module。

Solution

评估把「档位折算 + 可见范围判定」收进 crm-auth 的一个深模块,crm-base 只留注解与上下文这两样真正的跨模块契约。

  • • locality:档位折算规则集中一处,「一律不可见」的永假判定不再分散
  • • AI 可导航性:理解数据权限模块不必跨 module 跳读
ADR-0018 刚落地、DataScopeIntegrationTest 覆盖良好——除非后续模块(商机 / 客户)接入时再次感到摩擦,否则不值得现在动它。

5 · FileApi 门面里的透传方法

Speculative in-process

crm-file/.../api/FileApi.java(12 方法)· service/impl/FileApiImpl.java · service/MultipartUploader.java · service/ThumbnailResolver.java

Before — 12 方法门面,其中 4 个纯透传

upload / uploadDirect / getInfo / download / delete / getPreviewUrl
initMultipart → MultipartUploader(一行透传)
uploadChunk → MultipartUploader(一行透传)
completeMultipart → MultipartUploader(一行透传)
getThumbnail → ThumbnailResolver(一行透传)

After — 门面对准「fileId 生命周期」

FileApi:上传 / 详情 / 下载 / 删除 / 预览

分片三段式与缩略图由各自的深模块直接暴露给 Controller——但删除测试:透传消失后,MultipartUploader 成了第二个对外 seam,值不值取决于它是否真会独立演化。

Problem

FileApi 把「文件生命周期」和「上传会话」两套概念装进同一个 12 方法 interface,其中 4 个方法一行透传、不增不减——调用方学完整张门面才能用其中一小片。

Solution

门面只留 fileId 生命周期;分片上传会话与缩略图各自成为独立 seam(若它们确有独立演化)。

  • • interface 收窄,leverage 按概念各归各位
  • • 风险:一个 adapter 只是假设性 seam——若分片协议不再演化,拆了反而多一层

top recommendation

先做 候选 1 · 拆分 TokenService

spec 已 ready-for-agent、纯重构零行为变更、且 TokenService 是最近提交里最热的文件区域(单端在线 / 顶号刚落地,改动还在持续)。 拆完之后再回头看候选 2:资源树收拢需要动到的调用方会更少、seam 更干净。