Architecture review — crm-backend-matt

2026-09-06 · 第三轮(当日)· 覆盖 13:30 报告之后的增量
扫描范围:票 11 商机侧切客户真源、客户导入返工(票 09-13 + audit-fixes)、票 12 工作计划、以及今晨两轮候选的落地验讫—— 已落地不再列:OpportunityOplogWriter / CustomerPendingNoticeWriter / crm-base PendingNoticeWriter(13:30 #1/#3,工作树已收编,CONTEXT.md 已记)、 CustomerH2Schema / CustomerMapper 单一事实源 / 客户三 workspace 口径(晨报 #1/#2)。 ADR-0030 / 0031 / 0029 决策不重开。
module seam 泄漏(跨 seam 的非契约知识) deep module
候选 #1 · 宽 interface 泄漏复发(ADR-0031 同形病灶,客户侧)

商机读客户:三个只读需求,拖进两套宽 service + 实体 + param

Strong seam + adapter

涉及文件

  • crm-opportunity/.../service/impl/OpportunitySubServiceImpl.java · 票 11 切真源:注入 ICustomerService + ICustomerContactService
  • crm-customer/.../service/ICustomerService.java · searchForOpportunity(javadoc 自述「商机侧客户搜索」)
  • crm-customer/.../service/ICustomerContactService.java · listByCustomer / search(javadoc 均点名「商机字段17级联」「商机关键联系人数据源」)
  • crm-customer/.../port/ · 已有 6 个 port(含 2 个 outbound 商机 port),唯独缺商机要用的 inbound 目录 port

Before / After

BEFORE · 无 seam 直连宽 service
flowchart LR
  SUB["OpportunitySubServiceImpl
(615 行 grab-bag)"] CS["ICustomerService
8 方法 + MP 全套 CRUD"] CT["ICustomerContactService
8 方法 + MP 全套 CRUD"] E1["Customer 实体(30+ 字段)
CustomerSearchParam 手工构造"] E2["CustomerSearchItemDTO / ContactDTO
逐字段映射"] SUB -->|"getById(取快照)"| CS SUB -->|"searchForOpportunity"| CS SUB -->|"listByCustomer"| CT CS --> E1 CS --> E2 CT --> E2 classDef leak stroke:#dc2626,stroke-width:2px; class E1,E2 leak
3 个只读调用点 → 必须理解 16+ 方法、2 个实体、2 组 param/DTO
AFTER · 窄目录 port(住 provider 侧,对称 ADR-0031)
crm-opportunity:OpportunitySubServiceImpl(薄 adapter)
↓ 小 interface(3 方法,brief 视图)
deep module(crm-customer · port/inbound)
CustomerCatalogPort
search(query) → Page<CustomerBrief>
contactsOf(customerId) → List<ContactBrief>
requireLive(customerId) → CustomerBrief
实现吸收:Customer 实体读取 / 归档与删除口径 / param 构造与 DTO 映射
「已归档客户能不能被关联」从隐式口径变成 port 的一行契约

问题

商机侧只做三件事——存在性校验取名快照、候选池搜索、联系人级联——却要越过 seam 理解两套宽 service 外加 MyBatis-Plus 全套 CRUD、Customer 实体 30+ 字段、两个 param/DTO 的构造与映射。口径还藏在实现里:getById 只滤 @TableLogic deleted、不滤 archive_status(归档=删除语义),已归档客户照样通过「关联客户存在」校验——消费方从 interface 上无从知道。provider 侧同样受害:宽 service 上长出 searchForOpportunity / listByCustomer / search 三个「为商机而生」的方法,消费方的词汇进了 provider 的 interface。这正是 ADR-0031 修掉的病灶(当时的对象是 crm-lead),在客户侧原样复发。

方案(plain English)

crm-customer 的 port/ 目录新增一个窄 inbound 目录 port(照抄 ConvertibleLeadCatalogPort 的形状:provider 定义、消费方只见 brief 视图):搜索、按客户取联系人、取存活客户摘要三个方法;归档/删除口径、param 构造、实体→brief 映射全部收进实现;宽 service 上的三个商机词汇方法收编(转发或删除)。商机侧注入 port,删掉对 ICustomerService / ICustomerContactService / Customer 实体的全部 import。

ADR 关系(非冲突):与 ADR-0031 同形而非相抵——那次修 crm-lead 宽接口泄漏的结论就是「收窄跨域契约」;D14 依赖方向不变(port 住 provider 侧,crm-customer 不反向依赖)。另外它与候选 #3 有顺序耦合:先立此 seam 再拆 SubService,避免拆分把客户映射复制七份。

收益

interface:16+ 方法 + 2 实体 → 3 方法 + 2 brief 归档/删除口径收进一处实现 leverage:A5 项目选客户 = 第二个消费方只抄 port 测试面:mock 一个 port,不再造 Customer 实体 provider 宽 service 摘掉商机词汇
候选 #2 · 「同口径」靠复制与字符串前缀维持

客户导入:判定机制已单点,但解析/计数/软提示仍是约定接缝

Worth exploring in-process

涉及文件

  • crm-customer/.../impl/CustomerImportServiceImpl.java · upload():Excel 双 sheet 解析 + 判定计数 + 预览组装(361 行)
  • crm-customer/.../task/CustomerImportExecutor.java · doImport():同一解析再写一遍 + 再计数一遍(355 行)
  • crm-customer/.../impl/CustomerImportAnalyzer.java · 判定本体单一实现 ✓(320 行,deletion test 通过,不动)
  • .scratch/customer-e2e/_import-heavy-r2.xlsx / _import-incr-r2.xlsx · 测试被迫造真实 xlsx 走 HTTP e2e,行级单元无法直接喂入

Before / After(横剖:同一机制带,两条路径各织一遍)

BEFORE · upload 与 confirm 两条路径各持一份「解析 + 计数」
upload() 预览路径
EasyExcel 双 sheet 解析(逐字复制品 A)
analyzer.analyzeCustomers/Contacts ✓
verdict 计数 switch(副本 A)
executor.doImport() 执行路径
EasyExcel 双 sheet 解析(逐字复制品 B)
analyzer.analyzeCustomers/Contacts ✓
verdict 计数 switch(副本 B)
字符串前缀协议(泄漏)
executor:p.getReason().startsWith("疑似重复")
analyzer 用展示文案向 executor 传「本行 INSERT 且带软提示」——文案一改,疑似重复明细静默消失
另:isBlank / trimOrNull / clip500 三个文件各一份私有拷贝
AFTER · 导入底座收编解析与契约,analyzer 不动
upload()
只剩守门 + 任务落库
executor
只剩行落库 + 计数回写
CustomerImportReader(新)
bytes → CustomerSheet + ContactSheet + 模板表头校验(单点)
CustomerImportAnalyzer(不动)
ContactPlan 新增显式 softSuspect 字段 → 字符串协议废除
tally(plans) 单点计数(预览与执行共用)
预览与执行「同口径」从注释约定变成同一份代码

问题

判定语义已正确单点化(analyzer 过 deletion test:删掉它,匹配/查重/冲突规则会在 upload 与 executor 各复活一份),但机制的四周边缘仍是约定接缝:workbook 解析两处逐字复制(改一处另一处必然漂移)、verdict 计数两份 switch、软提示靠 reason 字符串前缀跨模块传递。模板演进(加列、加 sheet)时要同时改对四处。

方案(plain English)与保留意见

抽一个 CustomerImportReader module:bytes→两 sheet 行 + 模板表头校验单点,upload 与 executor 共用;ContactPlan 加显式 softSuspect boolean(结构化契约替代字符串前缀);计数 tally 单点。收敛面克制:不收判定语义(analyzer 已对)、不收行落库(executor 的真差异)。

收益

locality:模板演进只改一处 字符串协议 → 结构化字段,文案改动不丢数据 测试面:直接喂 rows/plans,不再造 xlsx 「同口径」从 javadoc 注释变成同一份代码
候选 #3 · 13:30 遗留(#4)且持续加重 —— 诚实标注:拆分换 locality,不是深化

IOpportunitySubService:23 方法 / 7 子域 grab-bag,仍是每张新 Tab 的落点

Worth exploring in-process

涉及文件(数字自 13:30 后继续上涨)

  • crm-opportunity/.../service/IOpportunitySubService.java · 21 → 23 方法(+searchCustomers +listCustomerContacts)
  • crm-opportunity/.../impl/OpportunitySubServiceImpl.java · 599 → 615 行,且新增跨模块客户依赖
  • OpportunitySubServiceImplTest.java · 613+ 行全子域 mock
  • 近两周每张新 Tab 票都落进这个文件:票 11 切真源 ✓、票 12 工作计划 ✓ —— 引力仍在

Before / After(质量图:interface 面积 vs implementation 面积)

BEFORE · grab-bag:interface 几乎与 implementation 一样宽
interface · 23 方法 / 7 子域
impl · 615 行 / 测试 613+ 行
deletion test 不过:删掉它 23 个签名只会搬家
AFTER · 按子域拆:小 interface 背一小块内聚实现
客户关联
(先过 #1 port)
跟进
勘察
附件
团队
工作计划
日志读
每片小实现 —— 赢 locality,不冒称 depth

问题

Grab-bag 不是 deep module(deletion test 不过),真正的摩擦是 navigability 与冲突面:商机是持续最热的整改区,每动一个 Tab 都面对 615 行 impl + 613 行全子域 mock 的测试文件;现在还背着跨模块客户知识(候选 #1 的病灶就住在这个文件里),两个候选在此叠加。

方案(plain English)与顺序

按 Tab 子域拆 service(每域 2–4 方法小 interface + 自家 impl + 自家测试文件),controller 端点不动。顺序:先做候选 #1(客户知识收进 port),再拆——否则拆分会把客户映射与归档口径复制进每个新 impl。

收益

locality:子域改动封闭 测试按域分文件,mock 面缩到域内 热点模块合并冲突面缩小
前轮在案 · 未动 · 仍然成立

两项遗留,不另画图

自定义视图操作符 kit(13:30 #2,Strong 未动):operator 词汇表仍 6 vs 8 两处各持(商机/客户),注入防护各一份;线索作为第 3 个消费方仍在途。做前需先拍「机制归属 crm-preference」这一条。

crm-rule 六个权限种子 Initializer 合并为声明式单 runner(晨报 #3 / 13:30 #5,未动):随时小票,第 7 个规则菜单边际成本 45 行 → 1 行。

TOP RECOMMENDATION

先做 #1:客户目录 port(CustomerCatalogPort)

理由:(a) 同形修法在本仓库已赢两次——ADR-0031 ConvertibleLeadCatalogPort(同样修宽接口泄漏)与 crm-customer port/ 目录六个既有 port,这是照抄已赢的棋;(b) 摩擦刚发生且消费方还在长:票 11 刚把宽 service 接进来,A5 项目选客户、商机关键联系人真源化都会成为下一个调用点——seam 每早立一天,宽 interface 的债就少涨一截;(c) 顺手把「已归档客户可被关联」这类口径问题从隐式变显式(requireLive 一行契约);(d) 测试面立竿见影:OpportunitySubServiceImplTest 从 mock 两套宽 service + 构造 Customer 实体,换成 mock 一个 3 方法 port。
随后顺序:#2(小票,随时)→ #3(必须在 #1 之后拆)· 遗留两项独立。

建议顺序:#1 → #2 → #3(在 #1 后)· 操作符 kit 先拍归属再动 · Initializer 随时小票