Architecture review — crm-backend-matt

第二轮 · 2026-09-06 · /improve-codebase-architecture
实心盒 = module 虚线 = seam 红 = 泄漏 / 重复 深色厚盒 = deep module
本轮扫描范围:热点 = crm-opportunity(票 01–06 整改 / 新建字段补齐)与 crm-customer(收尾 + 上午刚落地的两项深化)。 今晨报告的 #1(workspace 口径)/ #2(H2 schema 单一事实源)已落地在工作区(未提交),本轮不再列入;#4(preference 三栈)维持挂起。 词汇表:module / interface / seam / adapter / deep / shallow / locality / leverage(/codebase-design)。
候选 #1 · 热点模块 · 对称缺口

商机 oplog:四处手写构造,缺一个 writer module

Strong in-process

涉及文件

  • crm-opportunity/.../intake/impl/OpportunityIntakeImpl.java · writeInitialOplog(初始 ROW_ADD)
  • crm-opportunity/.../service/impl/OpportunitySubServiceImpl.java · L284 / L616 writeRowLog(子表 ROW_* / FIELD_CHANGE)
  • crm-opportunity/.../state/impl/OpportunityTransitionImpl.java · L334(STATUS_FLOW / STAGE_FLOW)
  • crm-opportunity/.../domain/entity/OpportunityOplog.java · 实体注释自认「写入点由统一审计切面驱动(接线归后续)」
  • crm-opportunity/.../service/impl/OpportunityDetailServiceImpl.java · edit —— 0 处 oplog(字段级审计未接线)

Before / After

BEFORE · 4 个写入点各持一份构造规则
IntakeImpl
手写 new + 5 行 setter
SubServiceImpl ×2
bizRef 截断 50 字 + MANUAL
TransitionImpl
第三份 kind/logType 搭配知识
OpportunityOplogMapper(4 个入口直插)
格式规则 = interface 之外的隐式知识 × 4 份
edit 路径漏接(审计断档)
AFTER · 一个 deep module 收编
Intake
Sub ×N
Transition
↓ 一行调用 record(...)
deep module
OpportunityOplogWriter
小 interface:rowAdd / rowDelete / fieldChange / flow(cmd)
实现吸收:构造、kind/logType 搭配、bizRef 截断、opTime/opUser/opSource、mapper
edit 字段级 diff 接线 = 加一个调用点,不再加一份构造知识

问题(deletion test:通过)

商机审计日志的构造与格式规则散在 4 个写入点。lead 有 LeadHistoryRecorder、customer 有 CustomerOplogWriter(票 04 从 writeOplog 抽公共)——三域同款概念,唯独商机缺 writer module。假想删掉它,构造规则会散回 4 处 → 复杂度会集中,说明这个 seam 值得存在。且主表 edit 完全不写 oplog:字段级审计是实体注释里登记过的欠账,每多一个写入点,欠账的接线成本就涨一截。

方案(plain English)

OpportunityOplogWriter:小 interface(按记录形态 3–4 个方法),实现吸收全部构造/格式规则与 mapper 细节;4 个写入点变薄调用。edit 路径的字段级 diff 是否本期接线是产品拍板,另立票——writer 先立 seam,接线只是加调用点。

收益

locality:格式规则一处改,四处生效 leverage:1 个 interface,4+ 个调用点 interface 即测试面:格式规则一次单测 调用点测试改 verify(writer),不再 captor 全字段 三域对称齐平(lead / customer 已验证路径)
候选 #2 · 平台契约漂移

自定义视图操作符:一份三方契约,两个模块各写一套,已漂移

Strong seam + adapter

涉及文件

  • crm-opportunity/.../enums/SavedViewOperator.java(6 操作符)+ query/impl/OpportunitySavedViewFilter.java
  • crm-customer/.../enums/CustomerSavedViewOperator.java(8 操作符,多 in / notIn)+ query/CustomerSavedViewFilter.java
  • crm-preference/.../dto/SavedViewCondition.java(平台只存不校验,两模块均已依赖)
  • .scratch 追踪项:「自定义检索推广到其他菜单」→ 线索 = 第 3 个消费方,在路上

Before / After

BEFORE · 词汇表两处定义,已经分叉
flowchart TD
  FE["前端检索面板(operator code 的渲染方)"] --> PREF["crm-preference · filter_json 存储(不校验)"]
  PREF --> OPF["OpportunitySavedViewFilter
SavedViewOperator ×6"] PREF --> CUF["CustomerSavedViewFilter
CustomerSavedViewOperator ×8(多 in/notIn)"] PREF -.->|"spin-out 在案:第 3 个消费方(线索)"| LEAD["?(将要复制哪一套?)"] OPF --> W1["wrapper SQL 片段"] CUF --> W2["结构化条件 → XML SQL"]
同一契约 6 vs 8 操作符 —— 漂移已发生,且各持一份注入防护
AFTER · 机制下沉,字段池与输出 adapter 留在业务方
flowchart TD
  POOL1["商机字段池 Map(业务知识)"] --> KIT
  POOL2["客户字段池 Map(业务知识)"] --> KIT
  POOL3["线索字段池 Map(未来)"] --> KIT
  KIT["crm-preference · SavedViewOperatorKit(deep module)
操作符词汇表 + 语义翻译 + 白名单注入防护 + 未知跳过"] KIT --> AD1["adapter:wrapper 片段(商机)"] KIT --> AD2["adapter:结构化条件(客户)"]
第 3 个消费方 = 供一份字段池 + 一个输出 adapter

问题

operator 词汇表(eq/ne/contains/notContains/isEmpty/isNotEmpty/in/notIn)是三方契约:前端渲染、crm-preference 存储、业务模块翻译成 SQL。现在它由两个业务模块各自定义——客户侧已比商机侧多出 in/notIn,漂移是事实不是假设;注入防护(列名白名单 + 值参数化)也各持一份。两个真实实现 + 一个在途 = seam 已坐实。

方案(plain English)

操作符机制下沉为 crm-preference 内的 deep module(条件模型的家,业务模块已依赖它):词汇表、语义翻译、valueless 处理、白名单注入防护、未知跳过全部收进实现;业务方只保留字段池 Map(列名知识)与各自的输出 adapter(wrapper 片段 vs 结构化条件——输出形态差异真实存在,正是 adapter 该待的位置)。

拍板提示(非冲突,需确认):crm-preference CONTEXT 有「本次只做到够用的最小通用深度」的拍板,SavedViewService javadoc 亦载明「字段池合法性归业务方」。本候选不动三栈 CRUD、不移动字段池归属,只收操作符机制;上轮 #4 的重开条件(第 4 种偏好)未触发,但「同一偏好的第 3 个消费方已排队」是另一回事——建议在 grilling 里先拍这一条。

收益

词汇表单一定义,漂移止血 注入防护一处加固,全平台生效 leverage:第 3 消费方边际成本 ≈ 一份字段池 两套操作符单测合并为一个测试面
候选 #3 · 两实现 = 真 seam,但收敛面要克制

待发通知:两套同构 pending-notice 栈,口径靠注释对齐

Worth exploring in-process

涉及文件

  • crm-opportunity/.../job/OpportunityMaintenanceJob.java · writePendingNotices / buildNotice / buildPayload / invalidatePendingReminds / isOn(≈100 行)
  • crm-customer/.../job/CustomerReminderJob.java · writeNextDueNotice / buildNotice / buildPayload / invalidateStaleAnchorNotices / matchesCurrentAnchor / isOn(≈120 行)
  • 两张同构表 opportunity_pending_notice / customer_pending_notice + 各自 Mapper

Before / After(横剖:同一条领域规则带,两处各织一遍)

BEFORE
OpportunityMaintenanceJob
回收推导(域内,该留)
幂等占位(biz,plan)任意状态永不重写
pending 批量置失效
payload Jackson 失败告警存 null · isOn
CustomerReminderJob(注释:「对称商机 writePendingNotices 幂等口径」「对齐商机侧」)
提醒链推导(域内,该留)
幂等占位 + anchor 保守比对
pending 置失效
payload 失败存 null · isOn
AFTER
商机 Job
只剩推导 + 薄 adapter
客户 Job
只剩链推导 + 薄 adapter
↓ 小 interface
deep module(crm-base)
PendingNoticeWriter
writeIfAbsent(biz, type, plan, payload) / invalidatePending(ids, type)
实现吸收:占位幂等 / 失效语义 / payload 序列化告警兜底
「任意状态占位永不重写」从注释约定变成代码

问题

同一套持久化语义(幂等占位、失效、payload 兜底)在两个 Job 里各写一遍,一致性靠注释互相点名维持——两个真实实现已经证明 seam 存在,而第三个域(如线索提醒)再长出来时是第三份手写。

方案(plain English)与保留意见

crm-base 抽 PendingNoticeWriter deep module,两个 Job 各持薄 adapter(自家 mapper + notice 类型码)。收敛面必须克制:只收持久化语义,不收推导(链式 vs 回收推导是真差异,留域内)。表结构不同 → adapter 承载差异,机制只有一份。

收益

口径从注释变代码 locality:幂等规则一处修 第 3 域边际成本降 两套口径守恒断言合一个测试面
候选 #4 · 诚实标注:拆分换 locality,不是深化

IOpportunitySubService:21 方法宽 interface,七个 Tab 子域一锅

Worth exploring in-process

涉及文件

  • crm-opportunity/.../service/IOpportunitySubService.java · 21 个方法 / 7 个子域
  • crm-opportunity/.../service/impl/OpportunitySubServiceImpl.java · 599 行
  • crm-opportunity/src/test/.../OpportunitySubServiceImplTest.java · 613 行
  • crm-opportunity/.../controller/OpportunitySubController.java · 全部 Tab 端点

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

BEFORE · interface 几乎与 implementation 一样宽(shallow 极端形态)
interface · 21 方法
impl · 599 行 / 测试 613 行
改「工作计划」要在客户关联 / 团队 / 跟进的代码与测试里穿行
AFTER · 按子域拆:每个小 interface 背一小块内聚实现
客户关联
跟进
勘察
附件
团队
工作计划
日志读
每片仍是小实现 —— 赢的是 locality,不是 depth

问题(deletion test:不过,所以它不是 deep module)

删掉这个聚合 interface,21 个签名只会搬家不会消失——它是 grab-bag,不是 deep module。真正的摩擦是 navigability:商机是当前最热的整改区(票 01–06 / 新建字段),每次动一个 Tab 都要面对 599 行 impl、613 行全子域 mock 的测试文件;合并冲突面也是全模块共享的。

方案(plain English)

按 Tab 子域拆 service(每域 2–4 方法的小 interface + 自家 impl),测试按域分文件;controller 端点不动(或随后按 Tab 拆)。可先拆最活跃的两个域(客户关联 / 工作计划)验证收益再推广。注意与候选 #1 的顺序:先立 OplogWriter seam,拆分时各域薄调用它,避免拆分过程再复制一份构造知识。

收益

locality:子域改动封闭 测试按域分文件,mock 面缩到域内 热点模块合并冲突面缩小
候选 #5 · 上轮遗留(今晨 #3,未动,仍然成立)

crm-rule 六个权限种子 Initializer 合并为声明式单 runner

Worth exploring

6 个 *PermissionInitializer(含一组 @Order 撞号)各 ~45 行仪式调 PermissionSeeder.seedModule(ADR-0016 已立的 seam)。合并为一个 Initializer + List<descriptor>,顺序即列表顺序;第 7 个规则菜单边际成本从 45 行降到 1 行。保留意见不变:javadoc 里的「为什么挂这个目录」决策注释须随数据走。

今晨 #4(preference 三栈)维持挂起、重开条件(第 4 种偏好)未触发,本轮不重列。

TOP RECOMMENDATION

先做 #1:商机 OpportunityOplogWriter

理由:(a) 深化路径已在两个兄弟模块各验证一次(LeadHistoryRecorder / CustomerOplogWriter 各 ~55 行,抽完后调用点全部变薄)——这是照抄已赢的棋,不是新设计;(b) 改动面窄、零产品决策(4 处收编即可收工,edit 字段级审计接线另立产品票,立了 seam 之后它只是一个新调用点);(c) 商机是当前最热的整改区,审计写入点还会继续增多——seam 每早立一天,欠账的接线成本就少涨一截;(d) 测试面立竿见影:格式规则一次单测,调用点从 captor 全字段断言换成 verify(writer)。 随后顺序:#2(先拍「操作符机制归属」这一条,再做)、#3(设计收敛面后做)、#4 在 #1 之后拆(避免拆分复制构造知识)、#5 随时小票。

建议顺序:#1 → #2(需拍板)→ #3 → #4(在 #1 后)· #5 独立小票