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.
 
 
 
 
 

7.5 KiB

ADR-0022: 线索模块读写分离与深模块抽取——LeadServiceImpl 瘦身

Status

Accepted

Context

LeadServiceImpl 在线索业务持续叠加后长成「上帝类」:530 行、11 个构造依赖、16 个 public 方法,一个类同时承载了状态迁移、历史留痕、四视图查询、展示字段拼装、写侧命令编排。具体摩擦:

  1. 重复:状态守卫(起始态校验 + 持有人校验)散落在 LeadServiceImplLeadTransitionImpl 两处共 19 处判定;writeHistory / buildDetail(组装 LeadHistory + kv 序列化为 JSON + 落库)在两个类里逐字复制,唯一差异是 transition 版失败时 log.warn 而 service 版静默 return null
  2. 依赖过载:11 个依赖里混着读侧(sysRegionServiceleadHistoryMapper 读)、写侧(leadFeedbackMapperhistoryRecorder)、以及两个死依赖leadAttachmentMappersysDeptService,声明了从不调用)。
  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)。单方法藏起整条机制:

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 不破坏其零依赖契约。
  • LeadServiceImplLeadTransition 各自的 writeHistory / buildDetail 删除;LeadTransition 由此移除 leadHistoryMapper + objectMapper 两个依赖,LeadServiceImpl 移除 objectMapper

3. LeadViewQuery——线索读侧深模块(候选 3)

新建 com.crm.lead.query.LeadViewQuery(接口)+ impl.LeadViewQueryImpl@Component),吃下整个读路径:

PageResult<LeadDTO> pageLeads(LeadPageParam param);   // 四视图分页 + 展示拼装
LeadDTO getLeadDetail(Long id);                        // 详情 + 展示拼装
List<LeadHistoryDTO> listHistory(Long leadId);         // 历史时间线(VISIBLE_TYPES)
  • 独占读依赖sysRegionService(省市 code→name)、leadHistoryMapper(历史读)、leadFollowMapper(关注数/是否关注读,与写侧共享同一 mapper bean)。
  • 自持 LeadMapperpageLeads 从继承基类的 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 测试通过
  • 可换性更好:换历史存储/换读实现只需换对应 *ImplLeadServiceImpl 一行不改;接口+实现分离与既有 LeadTransition 风格一致,便于 mock。
  • 术语沉淀crm-lead/CONTEXT.md 新增三条 —— 状态机守卫 / 历史记录器 / 线索读侧,各带 _Avoid_ 反例。
  • 读侧魔法值清理(抽取后跟进)LeadViewQueryImpl 四视图 switch 原用字符串字面量("PUBLIC_POOL" 等),新建 com.crm.lead.domain.enums.LeadViewType 枚举承载(与 HistoryType 同处),含 DEFAULT=MANAGEfromValue(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。