/improve-codebase-architecture · 深化机会扫描

架构审查报告:热点集中在 crm-customer 返工区

扫描范围由提交历史决定:上次审查(2026-08-17,候选 #1/#2 已落地为 ADR-0030/0031,#3/#4/#5 已否决并记录在 ADR-0022 §4 / ADR-0030,本次不再重提)之后的全部改动。 热点 = 客户模块返工(票 01-12)、商机 board / 建档字段、字典两级树、crm-rule 客户规则子域。领域词汇取自各模块 CONTEXT.md, 架构词汇按 module / interface / depth / seam / adapter / leverage / locality 口径使用。

2 × Strong 1 × Worth exploring 1 × Speculative(与既有拍板冲突)

#1 客户三 workspace 查询口径三处复制 Strong

同一份 WHERE(workspace × 内置视图 × 筛选)在 CustomerMapper 里手写了三份,board 与列表已静默分叉。商机侧同场景已有单一事实源范式可对照。

#2 H2 测试 schema 的 12 份手工副本 Strong

12 个集成测试类各自内联整套 DDL + MyBatis 自举,其中一份已漂移。加一列要改 12 个文件,crm-opportunity 的表结构变更也会波及客户侧测试。

#3 crm-rule 权限种子 6 个同构 Initializer Worth exploring

6 个 45 行的 CommandLineRunner 各包装一条纯数据(菜单名/路径/排序)。@Order 全局手工编号已出现两处撞号。

#4 crm-preference 三同构偏好栈 Speculative

列偏好 / 自定义视图 / 形态偏好三套平行 entity+mapper+service+controller。与 CONTEXT.md 既有拍板「不做全平台通用化」冲突,仅在前端出现第 4 种偏好时才值得重开。

候选 #1

客户三 workspace 查询:一份口径,三处手写,已经漂移

Strong

涉及文件

  • crm-customer/.../mapper/CustomerMapper.java
    pageWorkspace L152-313 · boardSummary L323-423
  • crm-customer/.../CustomerWorkspaceServiceImpl.java
  • 对照:crm-opportunity/.../OpportunityViewFilter.java

问题(deletion test:会集中复杂度)

「客户总览 / 我的客户 / 客户公海」三个 workspace 的数据集条件(内置视图 ASSIGNED/COLLABORATING/FOLLOW_UP_DUE/FOCUSED/RECENT × 公共筛选 × saved-view 条件)在 同一张 Mapper 里逐字写三遍pageWorkspace 的 overview 分支、mine/pool 分支、以及 boardSummary。 javadoc 自述「改动须两处同步」——实际是三处。删掉三份换成一份共享片段,复杂度会集中而不是搬家。

⚠ 已发生的口径漂移(本次审查实测)

boardSummary 相对 pageWorkspace 静默缺少 3 个条件FOLLOW_UP_DUE(待跟进视图 EXISTS)、 industryCodeprovinceCode。而 boardCards 走的是 pageWorkspace(带全这些条件)。 结果:看板列头汇总数 ≠ 同参数下卡片数。javadoc 声称「WHERE 口径与 pageWorkspace 完全一致」——代码 disagrees。 无任何测试覆盖该一致性(返工票 03 只测了 pool 拒绝)。这正是「同一份列表数据三种渲染、同一查询」约定(商机侧票 06 D-15 明文化:summary 与 cards 必须同口径,视图语义不得各写各的)在客户侧的违例。

Before / After

BEFORE · 三份手写 WHERE(同色块 = 逐字相同)
pageWorkspace / overview 分支
✓ FOLLOW_UP_DUE ✓ industry ✓ province ✓ savedView
pageWorkspace / mine+pool 分支
✓ FOLLOW_UP_DUE ✓ industry ✓ province ✓ savedView
boardSummary(看板汇总)
✗ FOLLOW_UP_DUE ✗ industry ✗ province — savedView(有意不接)
↑ 同一约定,第 3 份已漂移且 javadoc 与代码矛盾
AFTER · 一份共享 SQL 片段(XML mapper)
<sql id="workspaceCriteria">
内置视图 × 筛选 × savedView 唯一事实源
pageWorkspace
<include> ×2 分支
boardSummary
<include>(排除 groupValue)
↑ 新增筛选条件 = 改一处;漂移在结构上不可能

方案(plain English)

pageWorkspaceboardSummary 两条注解 SQL 迁入同 namespace 的 XML mapper 文件, 公共 WHERE 提成 <sql id> 片段,各处 <include>。 手写 SQL 本身是正当的(子查询包裹绕 DataScopeInterceptor、RECENT 标量子查询列、saved-view 白名单列 ${} 都需要它)——要收敛的不是「写 SQL」,是「写三遍」。 顺手裁决(grilling 议题):/api/customer/page(基础分页,wrapper 版)与 /api/customer/workspace/page 同挂三个页面 tag 的双轨是否还需要。

收益(locality / leverage / 测试面)

  • locality:workspace 语义住一处。改口径不再需要「找到三份并保持一致」。
  • leverage:下一个筛选字段(如行业二级联动)一处生效于列表 + 看板汇总 + 取卡。
  • 测试面 = interface:H2 集成测试直接打唯一口径;可新增「boardSummary 总数 == pageWorkspace 同参数计数」的守恒断言,把 D-15 约定变成可执行契约。
  • 顺手修复一个潜伏缺陷(FOLLOW_UP_DUE / industry / province 看板计数失真)。
不与任何 ADR 冲突;反而落实商机侧已明文化的对称约定(票 06 D-15)。风险低:纯 SQL 组织方式变化,方法签名与调用方不变。
候选 #2

H2 测试 schema:12 份手工副本,一份已经漂移

Strong

涉及文件

  • crm-customer/src/test/**/ 12 个集成测试类
    每类自带 @BeforeAll 自举 + 内联 DDL(合计 6155 行测试代码)
  • 漂移证据:CustomerWorkspaceSavedViewIntegrationTest
    customer 表缺 is_biz_negotiated 列(D-05 新增),其余 11 份都有
  • 跨模块复制:CustomerWorkspaceOwnershipIntegrationTest
    把 opportunity / opportunity_customer 表 DDL 也复制进客户测试

问题(浅模块:脚手架比业务断言还厚)

每个集成测试类都手搓同一套 130 行自举(JdbcDataSource + MybatisConfiguration + 分页/乐观锁拦截器 + MetaObjectFillHandler + execute DDL), 再各自内联一份 CREATE TABLE customer(55+ 列)及各子表。这份自举本身是一个没人拥有的 module:12 个副本、零测试(它是测试)、无单一事实源。 deletion test:把 12 份脚手架删掉换成 1 个测试基建类,复杂度大幅集中——这正是信号本身。

代价实测

客户表加一列(如 D-05 的 is_biz_negotiated)要同步改 12 个测试文件——本次已漏改 1 个。 更隐蔽的是反向依赖:CustomerMapper.pageWorkspace 的 opportunity_count 标量子查询 join 了 crm-opportunity 的表,商机表结构变更时 客户侧测试的复制 DDL 必须同步改(javadoc 已自认「商机表结构变更须同步本处与 H2 测试 SCHEMA」)。测试面不是深 module 的 interface,而是 12 个各说各话的影子 schema。

Before / After

BEFORE · 每类一份全套脚手架
DDL+自举
DDL+自举
DDL+自举
已漂移
×12
加一列 = 改 12 处,漏一处 = 假绿或半夜炸
AFTER · 一个测试基建 module
schema.sql(单一事实源)
+ H2Harness(自举)
测试类只写断言
加一列 = 改 1 处;漂移在结构上不可能

方案(plain English)

crm-customer/src/test/resources/ 落一份 schema.sql(customer 域全部表 + 按 H2 方言), 加一个 CustomerH2Harness 测试基建类持有自举(DataSource / MybatisConfiguration / 拦截器 / 建表执行),12 个测试类改为继承/组合它。 只做客户域,不动 lead/opportunity 的既有测试形态(那边没有内联 DDL 问题)。crm-opportunity 表的 DDL 是否也抽进 harness(跨域耦合已既成事实)作为 grilling 议题。

收益(locality / leverage / 测试面)

  • locality:客户域表结构的测试投影住一个文件。
  • leverage:下次加列(D-05 式迁移必然再来)改一处;顺带修复 SavedView 测试的隐性漂移。
  • 测试面:测试类的 interface 收敛为「业务断言 + 需要哪些表」,新集成测试的边际成本从 ~130 行降到 ~0。
  • 顺手消灭一个类 bug:漂移副本在某些插入路径下会假绿/假红。
不与任何 ADR 冲突。注:脚手架代码量大但机械,适合一次票清掉;与候选 #1 修完口径后再做此件收益更直观(守恒断言直接写进新 harness 的测试)。
候选 #3

crm-rule 权限种子:6 个同构 CommandLineRunner 包装 6 条数据

Worth exploring

涉及文件

  • crm-rule/config/:RulePermissionInitializer(@Order 11)
    OpportunityRulePermissionInitializer(13) · OpportunityStageTemplatePermissionInitializer(14)
    CustomerReminderRulePermissionInitializer(14 ⚠撞号) · OpportunitySchemeCardTemplatePermissionInitializer(16)
    CustomerDedupRulePermissionInitializer(19)
  • 另撞号:crm-lead RulePermissionInitializer(12) vs crm-rule RegionDataInitializer(12)

问题(浅模块的教科书形态)

每个类 45 行,其中 40 行是 javadoc/样板,信息量 = 一个 PermissionModuleDescriptor 常量(菜单名/路径/组件/排序/角色)。 interface 与 implementation 几乎一样宽——典型的 shallow module。新增一个规则菜单 = 再抄一个类 + 手挑一个全局 @Order 号。 @Order 编号是全工程手工分配的隐式协议,现已两处撞号(14×2、12×2)——撞号后种子顺序不确定。

Before / After

BEFORE · 6 类 × 45 行
@Order(11) 线索池配置 → seedModule(公海池配置)
@Order(13) 商机规则 → seedModule(公海规则)
@Order(14) 阶段模板 → seedModule(阶段模板)
@Order(14) 客户提醒 → seedModule(超期提醒) ⚠与上一行撞号
@Order(16) 方案卡模板 → seedModule(方案卡)
@Order(19) 查重设置 → seedModule(查重设置)
每类 = 5 行数据 + 40 行仪式
AFTER · 一个 Initializer + N 行声明
RulePermissionSeedInitializer(唯一种子 runner)
List.of(descriptor(线索池…), descriptor(商机规则…), …)
顺序 = 列表顺序(不再有全局 @Order 撞号面)
新菜单 = 加一行数据

方案(plain English)

crm-rule 内 6 个种子类合并为一个 RulePermissionSeedInitializer,持有 List<PermissionModuleDescriptor>, 顺序即列表顺序。各域 javadoc 里的决策注释随数据归位。可顺带把撞号的 @Order 清掉(合并后 crm-rule 只占 1 个号)。 dict / auth / lead 各自的 Initializer 形态不同(有真实逻辑),不动。

收益 / 保留意见

  • leverage:第 7 个规则菜单(客户规则族还会长)边际成本从 45 行降到 1 行。
  • 消灭全局 @Order 撞号这个隐式协议的局部实例。
  • 保留意见:每个类的 javadoc 承载了「为什么菜单挂这个目录」的领域决策——合并时注释须跟着数据走,不能丢。
  • 不解决跨模块 @Order 问题本身(那是另一个更大的话题,此处不展开)。
候选 #4

crm-preference 三套同构偏好栈(列偏好 / 自定义视图 / 形态偏好)

Speculative
⚠ 与既有拍板冲突 — 不足以重开

crm-preference/CONTEXT.md 明文:「本次只做到够用的最小通用深度,不做全平台通用化打磨」。本候选与该拍板冲突, 但摩擦尚未真实化(三栈各自稳定、消费方按 scope 接入无改动诉求)。列出仅为完整性 + 标注重开条件:前端出现第 4 种偏好(如卡片密度/排序方案)时再议

现状事实(rule of three 计数器:2)

  • user_column_preference → ColumnPreferenceServiceImpl(81 行)
  • user_saved_view → SavedViewServiceImpl(146 行)
  • user_view_form → ViewFormServiceImpl(59 行)
  • 三套 entity/mapper/service/controller 平行,行为差异真实存在:
    列偏好无校验 · 形态白名单校验(68xxx) · 自定义视图 filter_json + 默认互斥

为什么现在不做

三栈的「同构」只到 CRUD 骨架层;各自领域规则(保存才落库 / 值域白名单 / 默认视图互斥 + 翻译器归业务方)不同且已各自内聚。 抽公共泛型基座会把三种差异塞进一个宽 interface——正是上次审查否决 DisplayNameEnricher 的同款理由(宽接口薄实现 = shallow module)。 deletion test 不通过:删掉任何一栈,复杂度只是搬家到另外两栈的特判里。

TOP RECOMMENDATION

先做 #1:客户 workspace 查询口径收敛

理由:(a) 它已经是一个活的缺陷(看板汇总 vs 卡片计数不一致,且 javadoc 撒谎说一致);(b) 修复面窄、风险低(SQL 重组,签名不变); (c) 商机侧 OpportunityViewFilter 已验证同款深化路径,等于有参照实现;(d) 它是 #2 的前置——口径唯一后,#2 的 harness 里可以直接写 「boardSummary == pageWorkspace 计数」守恒断言,把「三种渲染同一数据」从注释变成可执行契约。做完 #1 顺手补 D-15 守恒测试,再清 #2。 #3 独立小票随时可做;#4 挂起重开条件,不投入。

建议顺序:#1(含守恒断言)→ #2(测试基建收敛)→ #3(独立小票)· #4(挂起)