|
|
|
|
# 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——**推迟到商机模块落地、事务/补偿变复杂时再抽**(届时它才够深)。
|
|
|
|
|
|
|
|
|
|
## 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`,前端传值方式不动。
|
|
|
|
|
- **预留 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。
|