架构审查 · 第四轮

2026-09-07 09:26 · /improve-codebase-architecture

扫描范围:昨晚第三轮两候选(CustomerCatalogPort / OpportunitySubService 按 Tab 拆分)的落地验讫 + 最近两周最热区深扫—— crm-customer 返工票 09-13 收尾与缺陷修复链、客户交割(票 07)、归属流转(票 04)、导入执行器、商机拆分后的缝合处。 本轮沿用 codebase-design 词汇:module / interface / depth / seam / adapter / leverage / locality / deletion test

新候选 ×1 承接候选 ×1(昨晚 #2 未动) 已核验无恙 ×6 ADR 冲突:无

昨轮候选落地验讫(2026-09-06 第三轮 → 今晨)

已落地 ✓ 候选 #1 → ADR-0033 CustomerCatalogPort

port/CustomerCatalogPort(3 方法 + 随包 record + CustomerCatalogException)与 CustomerCatalogPortImplIntegrationTest(含「裸宽服务返全部、走 port 只返有效」对照断言)均已就位; 商机侧 OpportunityCustomerServiceImpl 只注入 port。落地形状与 ADR 决策一致,归档口径由端口钉死。

已落地 ✓ 候选 #3 → OpportunitySubService 按 Tab 拆分

sub/ 七子包(客户/跟进/勘察/附件/团队/日志/工作计划)各持小 interface + 自家 impl; 聚合接口已删,OpportunitySubController 306 行薄适配层注入七服务,端点未动。拆分干净、无跨子域泄漏。

候选 #1 · 客户「换主协议」五处手搓 —— 归属流转无单一事实源
Strong

FILES

crm-customer/service/impl/CustomerServiceImpl — applyOwner(L319)、在职守卫(L330)、nextCustomerNo(L358)
crm-customer/service/impl/CustomerOwnershipServiceImpl — applyOwnerSnapshot(L213)、validateTargetUser(L230)、casUpdate(L244)
crm-customer/service/impl/CustomerTransferServiceImpl — initiate 内联换主循环(L167)、applyAssigneeSnapshot(L336)、在职守卫 ×3(L301/320/399)、nextTransferNo(L387)
crm-customer/task/CustomerImportExecutor — createCustomer 内联快照(L240)、锚点(L253)、nextCustomerNo(L330)
crm-customer/job/CustomerReminderJob — 锚点消费方(L79 isNotNull 过滤静默剔除无锚点客户)

PROBLEM

客户域的「换主」是一次协议而非一次赋值:owner 四列快照(userId/nameSnapshot/deptId/deptNameSnapshot)+ 部门名查表 + 在职守卫(enabled + employmentStatus=active)+ D25 锚点规则(last_valid_follow_time 空则赋当下、 enter_pool_time 清空)+ CAS 空列清空(MyBatis-Plus NOT_NULL 策略需 wrapper set null)+ 编号续号。 这串协议在 5 个文件手搓了 5 份(新建 / 领取·分配 / 交割发起 / 交割分配 / 导入建档),且漂移已经发生

漂移实证 ①:nextCustomerNo 两份分叉

CustomerServiceImpl 版 catch NumberFormatException → 脏序号重起 1; CustomerImportExecutor 版不 catch(脏数据直接炸行)且封顶 9999。同一编号规则,两种边界行为。

漂移实证 ②:锚点规则 4 种形态

create 恒 now;claim/assign「空则 now」;initiate「空则 now」; applyAssigneeSnapshot 不设锚点——依赖「initiate 必先设过」的时间链不变量,仅存在于注释里。

静默失败模式

新增换主点忘设锚点 → CustomerReminderJob 的 isNotNull(last_valid_follow_time) 扫描过滤静默剔除该客户。不报错、不留日志——客户只是永远不被提醒。

同一「在职守卫」还长出 4 个错误码族(CODE_CUST_INVALID / CODE_ASSIGN_INVALID / CODE_TRANSFER_FORBIDDEN / CODE_MEMBER_INVALID), 5 份部门名查表 deptService.getDeptNames(Set.of(...)).get(...)Deletion test:若删掉一个假想的归属流转 module,快照+锚点+守卫+编号协议必须重新各找住处——复杂度收敛而非转移,测试成立。

SOLUTION

crm-customer 立「归属流转」深 module(对称线索域既有形状 crm-lead/owner/OwnerSnapshotResolver, 2026-08-20 审查轮落地):换主动作(快照 + 锚点 + 守卫 + CAS 空列收一处)、编号续号收一处,五个调用点各缩成一行; D25 锚点语义从 javadoc 注释升格为 interface 契约。预设形态是 grilling 议题(守卫错误码归一 vs 保留语义分族、编号是否并入同 module、 导入执行器的影子建档路径接法),本报告不预设 interface。

BENEFITS

Locality

锚点 + 快照 + 守卫协议住一个文件;「已归档/已离职还能不能换主」这类口径从隐式变一行契约(同 ADR-0033 requireLive 的赢法)。

Leverage

A5 项目模块的 owner 同步、客户合并、未来批量换主——每个新换主点从「复刻 30 行协议」变一行调用。seam 每早立一天,协议就少复制一份。

Test surface

锚点契约一份 module 级测试,替代今天散在 4 个流程测试里的 per-flow 断言(WorkspaceOwnership L400/425、Transfer L282、Import L722);「忘设锚点」从静默变红。

BEFORE / AFTER

BEFORE — 协议平铺 5 份,锚点消费方靠运气
graph TD
  subgraph 手搓协议×5
    A1[CustomerServiceImpl
create→applyOwner] A2[OwnershipServiceImpl
claim/assign→applyOwnerSnapshot] A3[TransferServiceImpl
initiate 内联循环] A4[TransferServiceImpl
applyAssigneeSnapshot
锚点靠时间链不变量] A5[ImportExecutor
createCustomer 内联] end A1 & A2 & A3 & A4 & A5 -->|各写各的| C[(customer 表
owner 4列 + 锚点)] C -->|isNotNull 过滤| J[CustomerReminderJob
无锚点=静默不提醒] style A4 fill:#7f1d1d style J fill:#7f1d1d
AFTER — 一个深 module,接口即契约
graph TD
  A1[create] --> M
  A2[claim/assign] --> M
  A3[交割两段] --> M
  A5[导入建档] --> M
  M[归属流转 module
接口 2-3 方法
快照+锚点+守卫+CAS+编号
全收实现] M -->|一行调用| C[(customer 表)] M -.->|契约钉死锚点| J[ReminderJob] T[module 契约测试
锚点/快照/守卫] -.-> M style M fill:#065f46 style T fill:#064e3b
「换主时锚点动不动」从 5 份注释 + 1 个时间链不变量,变成一个 interface 的契约 + 一份测试
ADR 关系(非冲突):与 ADR-0022(线索读写分离深模块)同向——线索侧 OwnerSnapshotResolver 正是本候选的先行样本; 与 ADR-0033(归档口径收进 port)同族——都是「把隐式口径升格为显式契约」。 同形修法在本仓已赢两次:lead 的 OwnerSnapshotResolver(2026-08-20)与客户自己的 CustomerPendingNoticeWriter(薄写入器收写口径)。 顺序耦合:与候选 #2 在 ImportExecutor 交汇——先立本候选,#2 收影子建档路径时才有单一事实源可接。
候选 #2 · 客户导入「同口径」靠复制与字符串前缀维持 (昨晚 #2 承接,未动)
Worth exploring

FILES

crm-customer/service/impl/CustomerImportServiceImpl — upload(L100-140):EasyExcel 双 sheet 解析 + verdict 计数 switch
crm-customer/task/CustomerImportExecutor — doImport(L89-97):同一解析逐字复制 + 计数第二份;L193 reason.startsWith("疑似重复")
crm-customer/service/impl/CustomerImportAnalyzer — 判定语义单点(已对,deletion test 通过)

PROBLEM

判定语义已正确单点化(analyzer 深模块成立),但机制四周边缘仍是约定接缝:workbook 解析两处逐字复制(sheet0 必读 + sheet1 缺席容错,改一处另一处必漂);verdict 计数两份 switch(upload 的 insert/update/fail/suspect vs executor 再加 unchanged); 「他客户电话软提示」靠 reason 字符串前缀跨方法传协议(startsWith("疑似重复")——改文案即断功能)。 模板演进(加列、加 sheet)须同时改对四处。昨晚已报,今晨验讫:原样未动。

SOLUTION / BENEFITS

CustomerImportReader(bytes → 两 sheet 行 + 模板表头校验单点),upload 与 executor 共用; ContactPlan 加显式 softSuspect 结构化契约替代字符串前缀;计数 tally 单点。收敛面克制:不收判定语义(analyzer 已对)、 不收行落库(executor 的真差异)。Benefits:预览与执行「同口径」从 javadoc 变同一份代码;模板演进只改一处; CustomerImportIntegrationTest(40KB)的守恒断言获得单一被测面。 与 #1 的交汇:executor 的 createCustomer 是影子建档路径(手搓 owner 快照 + 锚点 + 编号)——先落 #1 再收此处,导入建档接归属流转 module 即可。

BEFORE / AFTER

BEFORE — 机制边缘四处约定
graph TD
  U[upload] --> P1[EasyExcel 解析
副本1] E[executor] --> P2[EasyExcel 解析
副本2 逐字复制] U --> T1[计数 switch 1] E --> T2[计数 switch 2] AN[Analyzer 判定
单点 ✓] -.->|reason 字符串前缀
疑似重复…| E style P2 fill:#7f1d1d style T2 fill:#7f1d1d
AFTER — 机制单点,判定仍归 analyzer
graph TD
  U[upload] --> R[CustomerImportReader
bytes→两sheet行+表头校验] E[executor] --> R U --> T[tally 单点] E --> T AN[Analyzer 判定单点] -->|softSuspect 布尔
结构化契约| E style R fill:#065f46 style T fill:#064e3b
ADR 关系:无冲突。昨晚报告原文承接,无新证据改变评级。

已核验无恙(本轮扫过、不立票)

商机拆分后缝合处OpportunitySubController 注入七小服务,薄适配无泄漏;sub/customer 走 CustomerCatalogPort。
CustomerWorkspaceServiceImpl — 深 module:公共 WHERE 在 CustomerMapper 常量单源,saved-view 翻译、公海部门折算、看板分组白名单各就各位。
四情形可见性平行实现 — CustomerScopeEvaluator ↔ DataScopeInterceptor 口径平行,但 exhaustive switch 编译期互锁(Kind 加枚举值即编译红),且有文档锚点。类型系统看住了,不立票。
SavedViewFilter 双样本 — 商机/客户两个翻译器,代码内明记「rule of three:商机样本 1 + 客户样本 2」,自觉管理中,等第三样本再议。
CustomerReminderJob — 正确使用深引擎 PendingNoticeWriter;提醒链推导与锚点失效判定留域内(真差异),三层结构清爽。
线索写侧编排 — LeadServiceImpl 25KB 但编排委托齐整(Transition/Deadlines/CreationPlanner/HistoryRecorder/OwnerSnapshotResolver 各就各位),ADR-0022 形态保持。
TOP RECOMMENDATION

候选 #1 · 客户归属流转深 module

理由:(a) 同形修法在本仓已赢两次——线索 OwnerSnapshotResolver(2026-08-20 三件套之一)与客户 CustomerPendingNoticeWriter, 都是「散落写路径协议收一处」的照抄棋;(b) 漂移已发生——两份 nextCustomerNo 对脏序号行为分叉、锚点规则 4 形态, 这不是理论风险而是现存分叉;(c) 失败模式静默——忘设锚点的客户被提醒 Job 无声剔除,比报错难抓一个量级; (d) 消费方还在长——A5 项目模块的 owner 同步、客户合并都在路上,seam 每早立一天协议就少复制一份; (e) 顺序红利——与 #2 在导入执行器交汇,先立 #1,#2 收影子建档路径时直接接 seam,两票变成接力而非交叉。

下一步:选定后进入 /grilling 走决策树(interface 形状 / 守卫错误码归一 / 编号归属 / 导入接法 / 测试面存亡),决策落 ADR + CONTEXT.md 术语同步。