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.

81 lines
9.5 KiB

3 weeks ago
# ADR-0022: 线索模块读写分离与深模块抽取——LeadServiceImpl 瘦身
## Status
Accepted
## Context
`LeadServiceImpl` 在线索业务持续叠加后长成「上帝类」:**530 行、11 个构造依赖、16 个 public 方法**,一个类同时承载了状态迁移、历史留痕、四视图查询、展示字段拼装、写侧命令编排。具体摩擦:
1. **重复**:状态守卫(起始态校验 + 持有人校验)散落在 `LeadServiceImpl``LeadTransitionImpl` 两处共 19 处判定;`writeHistory` / `buildDetail`(组装 `LeadHistory` + kv 序列化为 JSON + 落库)在两个类里**逐字复制**,唯一差异是 transition 版失败时 `log.warn` 而 service 版静默 `return null`
2. **依赖过载**:11 个依赖里混着读侧(`sysRegionService`、`leadHistoryMapper` 读)、写侧(`leadFeedbackMapper`、`historyRecorder`)、以及两个**死依赖**(`leadAttachmentMapper`、`sysDeptService`,声明了从不调用)。
3. **测试面模糊**:读逻辑(四视图 wrapper、`fillDisplayFields`)从未被测过,混在写侧命令测试的同一个类里无从下手。
约束(继承自既有 ADR):`LeadTransition` 必须保持对 crm-rule / crm-auth 的**零依赖契约**(ADR-0020/0021 语境);controller 只认 `ILeadService` 契约,抽取不得改变对外接口。
## Considered Options
- **按操作类型横切三分**(`LeadQueryService` / `LeadCommandService` / `LeadTransitionService`)——被否:每块都是「校验+委派」的薄转发层,接口和实现一样厚,只是把 530 行摊成三个 180 行,依赖没减、深度没增。这是「为拆而拆」的 shallow module。
- **一次性大重构**——被否:风险集中、难验证。
- **按「深度」抽取证据最硬的接缝,逐个 grill + 验证(采纳)**:只抽那些能把一整块机制藏在窄接口后、让调用方变简单的接缝;每抽一个跑全量测试 + BOM 扫描;抽完用瘦身后的数据决定是否继续。
## Decision
分三轮抽出三个**深模块**,每个吃掉一整块机制、收走对应依赖:
### 1. LeadTransition 强化——状态机守卫下沉(候选 1)
散落两处的状态守卫收进 `LeadTransition` 内的统一 `guard(lead, cmd)`,在 `execute` 分派前跑一次。每条 `TransitionCmd` **声明式**自带 `allowedFromStatuses()`(合法起始态集合)与 `requiresOwner()`(是否要求操作人=领取人,纯代码常量、**不建表**)。
- 非法起始态 → `CODE_STATUS_NOT_ALLOWED`;非领取人 → `CODE_NOT_OWNER`。守卫在 CAS(ADR-0021)之上给出**友好前置**,CAS 仍是真正的并发防线。
- 「起始态 × 命令内容」的组合子规则(作废+反馈无效、作废自环换 owner)**仍留在各 `applyXxx`**,与目标态计算保持 locality。
- ADMIN_ONLY 池规则、转商机端口调用时序等需要查 crm-rule / port 的前置,**留在 `LeadServiceImpl`**,守住 `LeadTransition` 的零依赖契约。
### 2. LeadHistoryRecorder——历史留痕深模块(候选 2)
新建 `com.crm.lead.history.LeadHistoryRecorder`(接口)+ `impl.LeadHistoryRecorderImpl`(`@Component`)。单方法藏起整条机制:
```java
void record(Long leadId, HistoryType type, Long userId, Object... detailKv);
```
- `detailKv` 为 k1,v1,k2,v2… 序列化为 JSON;**空 kv 存 `null`**(保持历史行为,不写 `{}`);序列化失败统一 `log.warn` 并存 null(吞并原本一处记日志一处静默的分歧)。
- 系统触发的回收/失效传 `userId = 0L`
- 仅依赖 crm-lead 内部 mapper/实体/枚举,注入 `LeadTransition` 不破坏其零依赖契约。
- `LeadServiceImpl``LeadTransition` 各自的 `writeHistory` / `buildDetail` 删除;`LeadTransition` 由此移除 `leadHistoryMapper` + `objectMapper` 两个依赖,`LeadServiceImpl` 移除 `objectMapper`
### 3. LeadViewQuery——线索读侧深模块(候选 3)
新建 `com.crm.lead.query.LeadViewQuery`(接口)+ `impl.LeadViewQueryImpl`(`@Component`),吃下整个读路径:
```java
PageResult<LeadDTO> pageLeads(LeadPageParam param); // 四视图分页 + 展示拼装
LeadDTO getLeadDetail(Long id); // 详情 + 展示拼装
List<LeadHistoryDTO> listHistory(Long leadId); // 历史时间线(VISIBLE_TYPES)
```
- **独占读依赖**:`sysRegionService`(省市 code→name)、`leadHistoryMapper`(历史读)、`leadFollowMapper`(关注数/是否关注读,与写侧共享同一 mapper bean)。
- **自持 `LeadMapper`**:`pageLeads` 从继承基类的 `this.page()` 改为 `leadMapper.selectPage(...)`——读模块不再借 `BaseServiceImpl` 的能力。
- `LeadServiceImpl` 的三个查询方法瘦成一行委派,`ILeadService` 契约不变,controller 无感。
### 4. 停在三个深模块,不再拆
拆完后 `LeadServiceImpl` 只剩**写侧命令编排**。剩余命令若强按 command 三分只会造 shallow 转发层。唯一还算深的候选 `convertToOpportunity`(端口协调 + 事务边界 + 幂等前置)目前只有单一调用点、约 45 行,抽独立协调器 earns 不到 keep——**推迟到商机模块落地、事务/补偿变复杂时再抽**(届时它才够深)。
> **重评(2026-08-25,`/improve-codebase-architecture` 候选 #4 + `/grilling`):仍不抽。** 触发条件(商机模块已落地)虽至,但 [ADR-0030](0030-opportunity-intake-deep-module.md) 将建档机制收进商机侧深模块 `OpportunityIntake` 后,`convertToOpportunity` 的对端(outbound port 实现)已瘦成薄 adapter,当初预期的「事务/补偿变复杂」未在 crm-lead 侧出现:异常翻译只一层、事务就是方法上一个 `@Transactional`。依旧单一调用者(无杠杆)、隔离价值不足,抽出主要是搬家而非建深模块。方法内两处手搓守卫(状态 3/4 + 持有人)**非坏味道**:转商机是「先建商机、后迁状态」两阶段时序,守卫必须在 port 调用**前**拦截(避免商机已建而线索状态未改),而 `ConvertCmd` 的声明式 guard 在 port **后**才跑、挡不住此处;口径已由 `LeadConstants` 常量单一来源,手搓的只是判断、非复制口径。若将来真出现多调用者或跨库补偿事务,本条再重开。
> **重评(2026-08-25,`/improve-codebase-architecture` 候选 #3 + `/grilling`):不抽 `DisplayNameEnricher`。** 两个读模块 `LeadViewQueryImpl` / `OpportunityViewQueryImpl` 的 code→name 回显中,真逐字复制的只有 `dictNames(groupCode, codes)` 那 10 行(循环调 `DictQueryService.getItem` + 攒 Map);dept/region 的 `listByIds`/`listByCodes`→`toMap` 是 MyBatis-Plus/stream 惯用法,无抽取价值。统管 dict+dept+region 三源的 enricher 会是**宽接口 + 薄实现**(每个 caller 需描述「哪些字段→哪个组码/哪种源」)——正是 shallow module 的定义,与本 ADR 追求的深模块相悖。seam 唯一合法落点也只有 crm-rule(聚齐 dict/auth/region 三源、被两读模块依赖不成环),但落点合法不等于值得建。维持现状;若将来第三个视图模块再次复制 `dictNames`,则优先把那 10 行收进 `DictQueryService.batchNames`(零新增 seam,两侧本已依赖 crm-dict)而非建 enricher。
3 weeks ago
## Consequences
- **LeadServiceImpl 瘦身**:530 → **437 行**,11 → **8 依赖**(移走 5:`objectMapper`/`leadHistoryMapper`/`sysRegionService` + 2 死依赖 `leadAttachmentMapper`/`sysDeptService`;新增 `leadViewQuery`),职责收敛为单一的「写侧命令编排」。
- **零依赖契约保持**:三个抽取都未让 `LeadTransition` 触达 crm-rule/crm-auth;`LeadHistoryRecorder`/`LeadViewQuery` 只碰 crm-lead 内部或既有 crm-rule 读服务。
- **对外契约不变**:controller 仍只认 `ILeadService`,三个查询方法签名不动,仅内部委派。
- **测试面清晰化**(replace, don't layer):状态守卫真测迁至 `LeadTransitionImplTest`(7),历史留痕真测在 `LeadHistoryRecorderImplTest`(2),读侧真测新建 `LeadViewQueryImplTest`(4);`LeadServiceImplTest` 只留写侧命令 + 三个「委派验证」用例(44)。全模块 **57 测试通过**
- **可换性更好**:换历史存储/换读实现只需换对应 `*Impl`,`LeadServiceImpl` 一行不改;接口+实现分离与既有 `LeadTransition` 风格一致,便于 mock。
- **术语沉淀**:`crm-lead/CONTEXT.md` 新增三条 —— 状态机守卫 / 历史记录器 / 线索读侧,各带 `_Avoid_` 反例。
- **读侧魔法值清理(抽取后跟进)**:`LeadViewQueryImpl` 四视图 `switch` 原用字符串字面量(`"PUBLIC_POOL"` 等),新建 `com.crm.lead.domain.enums.LeadViewType` 枚举承载(与 `HistoryType` 同处),含 `DEFAULT=MANAGE``fromValue(String)` 兜底(空/非法值回落 MANAGE),`switch` 改为 switch-on-enum 全视图覆盖。**wire 契约不变**——`LeadPageParam.viewType` 仍为 `String`,前端传值方式不动。
3 weeks ago
- **预留 scope 常量澄清**:`LeadConstants.SCOPE_*`(四视图列偏好 `scope_key`)是为「线索列表接入列偏好」**预留但尚未接线**的域常量。保留不删:归属正确(scope_key 由业务方 crm-lead 约定,crm-preference 仅作不透明存储,见 crm-preference/CONTEXT.md)。**依赖方向锁死处理方式**:`crm-lead → crm-preference` 单向,故不得让 crm-preference 反向引用本常量(否则成环);仅在常量处添注释讲清预留意图与此约束。
- **决策来源**:`improve-codebase-architecture` skill 三轮 grill(2026-01);深度判据见 `codebase-design` skill。