--- status: accepted --- # 客户归属写入器:owner 列族唯一写入口 CustomerOwnershipWriter(收五处手搓换主协议) ## Context 架构审查第四轮(`/improve-codebase-architecture`,2026-09-07,候选 #1):客户域「换主」是一次**协议**而非一次赋值——owner 四列快照(ownerUserId / ownerUserNameSnapshot / ownerDeptId / ownerDeptNameSnapshot)+ 部门名查表 + 在职守卫(enabled + employmentStatus=active)+ D25 锚点规则(last_valid_follow_time)+ enterPoolTime 清空 + MyBatis-Plus NOT_NULL 更新策略的 CAS wrapper 陷阱 + 编号续号——散在 **5 个文件手搓 5 份**: | 调用点 | 文件 | 手搓内容 | | --- | --- | --- | | 新建指派 | `CustomerServiceImpl.create`(applyOwner) | 快照 + 守卫 + 公海分支 + 编号 | | 领取/分配 | `CustomerOwnershipServiceImpl`(applyOwnerSnapshot / validateTargetUser / casUpdate) | 快照 + 守卫 + 锚点 + CAS 空列 | | 交割发起 | `CustomerTransferServiceImpl.initiate` 内联循环 | 快照 + 锚点 + 同主拒绝 | | 交割分配 | `CustomerTransferServiceImpl.applyAssigneeSnapshot` | 快照(锚点靠时间链不变量) | | 导入建档 | `CustomerImportExecutor.createCustomer` 内联 | 快照 + 锚点 + 编号(第二份) | **审查发现的现存 bug(本 ADR 一并修复)**:`assign` / 交割 `initiate` / `assignOne` 三处换主后 **`enter_pool_time` 实际未清空**——代码把实体列置 null 后走平式 `updateById`,被 MP 默认 NOT_NULL 更新策略跳过(`claim` 早已用 wrapper `set(column, null)` 显式清空并留注释,但该知识只活在 `CustomerOwnershipServiceImpl` 一个文件里)。实体 javadoc 明文契约「领取/**分配**清空」违约至今;测试只断言 claim 场景(`CustomerWorkspaceOwnershipIntegrationTest` L388),assign/交割零断言,故漏网。脏值只在下次抛公海时被覆盖,线上不可见,但「分配出公海的客户还挂着旧 enter_pool_time」是真实脏状态。 **锚点规则可无损统一**:现状 4 种形态(create 恒 now / claim·assign 空则 now / initiate 空则 now / assignOne 不设)实为同一条规则——**设主时锚点空则赋当下、非空保留**。create 时实体锚点为 null 等价「恒 now」;assignOne 时锚点必非空(initiate 刚设过),「非空保留」恰好就是现状「不设」。`applyAssigneeSnapshot` 只活在注释里的时间链不变量被统一规则自然吸收。 **失败模式静默**:新增换主点忘设锚点 → `CustomerReminderJob` 的 `isNotNull(last_valid_follow_time)` 扫描**静默剔除**该客户(不报错、不留日志,客户永远不被提醒)。协议无单一事实源时,此漂移无红灯。 ## Considered Options - **A(选中):立 `CustomerOwnershipWriter`(customer 表 owner 列族唯一写入口)+ `CustomerNoGenerator`(编号续号独立小组件),五调用点全部接线。** 对称域内 Writer 家族(`CustomerOplogWriter` / `CustomerPendingNoticeWriter`——具体 `@Component` 零接口、唯一写入口语义)与 lead 侧先行样本 `OwnerSnapshotResolver`(2026-08-20 审查轮)。 - **B:只收快照+守卫(照抄 OwnerSnapshotResolver 原样薄度)。** 锚点、CAS 空列、编号留五调用方——两个实证病灶(nextCustomerNo 分叉、enter_pool_time 违约)修不到,静默失败模式继续存在,否。 - **C:连动作语义一起收(客户侧 LeadTransition 量级)。** claim 的公海守卫、archive 的商机守卫、交割两段式事务边界全得跟着搬,interface 涨到 8+ 方法——动作语义是真差异(各前置守卫不同),收进来是宽接口 shallow module,否。 - **D:维持现状。** 漂移已发生且失败静默,A5 项目模块 owner 同步、客户合并等新换主点在路上,协议每多活一天多复制一份,否。 ## Decision crm-customer 新增两个域内组件(`com.crm.customer.service`,与 OplogWriter 同包): **`CustomerOwnershipWriter`**(具体 `@Component` 零接口)——三动词,mutate + 落库一体(事务边界留调用方外层 `@Transactional`): 1. **`initOwnerOnCreate(Customer entity, Long ownerUserId)`**:mutate-only(insert 留调用方)。ownerUserId 空 = 直接入公海分支(清四列快照 + enterPoolTime=now + 锚点 NULL,D25);非空 = 守卫 + 快照 + 锚点 = now。消费方:`create` / `quickCreate`(经 create)/ 导入建档。 2. **`changeOwner(Customer c, Long targetUserId)`**:在职守卫(67012)→ 同主拒绝(67012)→ 快照四列 → **锚点空则 now、非空保留**(统一规则)→ **enterPoolTime wrapper 显式清空** → CAS 落库(失败 67005)。消费方:`claim` / `assign` / 交割 `initiate` / `assignOne` 四处。 3. **`releaseToPool(Customer c)`**:清 owner 两列 + enterPoolTime=now + 锚点保留(D25 现状语义:抛公海不清锚点)+ CAS 落库。协同人清空(`teamMemberMapper.releaseAllByCustomerId`)与进行中商机守卫留调用方(业务前置)。消费方:`releasePool`。 守卫口径与现状一处差异(grilling 拍板):交割发起人本人 = 部门 leader(director = from)时,现状静默「换主给自己」;统一守卫的同主拒绝会挡掉(更严谨)。 **`CustomerNoGenerator`**(具体 `@Component` 零接口)——`nextCustomerNo()` 单方法:KH+yyyyMMdd+4 位序(按当日 MAX 续号),合并两份分叉各自更防御的分支——**脏序号容错重起 1**(取 CustomerServiceImpl 版 catch NumberFormatException;customer_no 无唯一约束仅展示用)+ **封顶 9999**(取 ImportExecutor 版)。消费方:`create` / 导入建档。 **错误码**:守卫失败与同主拒绝**统一直抛 `BusinessErrorException(67012, "…")`**(对称 `CustomerDedupService.guardCreditCodeForWrite` 的域内直抛惯例;「专用异常 + 调用方翻译」是跨域 seam 口径 ADR-0030/0033,域内组件不搬)。行为变化仅一处:**create / quickCreate 端点的 owner 守卫失败 67001 → 67012**(bruno 文档同步);claim/assign/assignOne 本就 67012;initiate 的 director 校验走 `resolveDirector`(67011,调用方先跑,module 同主分支实际不可达);导入建档行级软处置(离职 submitter 从不设防变该行 fail,更对)。 **顺手修复**:`assign` / `initiate` / `assignOne` 的 enter_pool_time 清空由 `changeOwner` 的 wrapper 显式清空恢复契约(实体 javadoc「领取/分配清空」重新成立)。 **测试**:新增 `CustomerOwnershipWriterIntegrationTest`(H2,与全客户域 18 个集成测试同风格)钉契约——设主锚点空则 now / 非空保留、enterPoolTime 清空(**含 assign 场景防回归断言**)、公海分支、同主拒绝 67012、离职守卫 67012、CAS 冲突 67005。既有 4 个流程集成测试**原样保留**作端到端回归锚(抓「调用方真的接了 Writer」这层接线)。 ## Consequences - 换主协议(快照/锚点/守卫/CAS 空列/公海分支)从 5 份手搓收成 1 份单一事实源;NOT_NULL wrapper 陷阱被实现吸收,不再依赖「某个文件里的注释活着」。D25 锚点语义从 javadoc 升格为 interface 方法契约。 - **行为修复**:assign / 交割两段换主后 enter_pool_time 真正清空(现状违约);create/quickCreate owner 守卫错误码 67001→67012(bruno 同步);交割发起人=leader 的自分配被同主拒绝挡掉;导入建档离职 submitter 行级 fail(原先不设防静默写快照)。 - 未来换主点(A5 项目 owner 同步、客户合并、批量换主)从「复刻 30 行协议」变一行调用。 - 五个调用方服务构造器加参(Writer / NoGenerator 注入)——实现时全模块 grep 手动 `new` 这些构造器的测试并同步修复;批量编辑后跑 BOM 扫描(AGENTS.md 编码条款)。 - 决策来源:`/improve-codebase-architecture` 第四轮审查(2026-09-07,候选 #1)+ grilling 八项拍板(边界 / 落库 / 三动词 / 67012 / NoGenerator 独立 / Writer 形态命名 / 双层测试 / 单票一体)。