Architecture review — crm-backend-matt
商机 oplog:四处手写构造,缺一个 writer module
涉及文件
- 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
手写 new + 5 行 setter
bizRef 截断 50 字 + MANUAL
第三份 kind/logType 搭配知识
edit 路径漏接(审计断档)
问题(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,接线只是加调用点。
收益
自定义视图操作符:一份三方契约,两个模块各写一套,已漂移
涉及文件
- 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
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"]
flowchart TD POOL1["商机字段池 Map(业务知识)"] --> KIT POOL2["客户字段池 Map(业务知识)"] --> KIT POOL3["线索字段池 Map(未来)"] --> KIT KIT["crm-preference · SavedViewOperatorKit(deep module)
操作符词汇表 + 语义翻译 + 白名单注入防护 + 未知跳过"] KIT --> AD1["adapter:wrapper 片段(商机)"] KIT --> AD2["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 该待的位置)。
收益
待发通知:两套同构 pending-notice 栈,口径靠注释对齐
涉及文件
- 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(横剖:同一条领域规则带,两处各织一遍)
只剩推导 + 薄 adapter
只剩链推导 + 薄 adapter
问题
同一套持久化语义(幂等占位、失效、payload 兜底)在两个 Job 里各写一遍,一致性靠注释互相点名维持——两个真实实现已经证明 seam 存在,而第三个域(如线索提醒)再长出来时是第三份手写。
方案(plain English)与保留意见
crm-base 抽 PendingNoticeWriter deep module,两个 Job 各持薄 adapter(自家 mapper + notice 类型码)。收敛面必须克制:只收持久化语义,不收推导(链式 vs 回收推导是真差异,留域内)。表结构不同 → adapter 承载差异,机制只有一份。
收益
IOpportunitySubService:21 方法宽 interface,七个 Tab 子域一锅
涉及文件
- 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 面积)
问题(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,拆分时各域薄调用它,避免拆分过程再复制一份构造知识。
收益
crm-rule 六个权限种子 Initializer 合并为声明式单 runner
6 个 *PermissionInitializer(含一组 @Order 撞号)各 ~45 行仪式调 PermissionSeeder.seedModule(ADR-0016 已立的 seam)。合并为一个 Initializer + List<descriptor>,顺序即列表顺序;第 7 个规则菜单边际成本从 45 行降到 1 行。保留意见不变:javadoc 里的「为什么挂这个目录」决策注释须随数据走。
今晨 #4(preference 三栈)维持挂起、重开条件(第 4 种偏好)未触发,本轮不重列。
先做 #1:商机 OpportunityOplogWriter
理由:(a) 深化路径已在两个兄弟模块各验证一次(LeadHistoryRecorder / CustomerOplogWriter 各 ~55 行,抽完后调用点全部变薄)——这是照抄已赢的棋,不是新设计;(b) 改动面窄、零产品决策(4 处收编即可收工,edit 字段级审计接线另立产品票,立了 seam 之后它只是一个新调用点);(c) 商机是当前最热的整改区,审计写入点还会继续增多——seam 每早立一天,欠账的接线成本就少涨一截;(d) 测试面立竿见影:格式规则一次单测,调用点从 captor 全字段断言换成 verify(writer)。 随后顺序:#2(先拍「操作符机制归属」这一条,再做)、#3(设计收敛面后做)、#4 在 #1 之后拆(避免拆分复制构造知识)、#5 随时小票。