8.0 KiB
| 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):
initOwnerOnCreate(Customer entity, Long ownerUserId):mutate-only(insert 留调用方)。ownerUserId 空 = 直接入公海分支(清四列快照 + enterPoolTime=now + 锚点 NULL,D25);非空 = 守卫 + 快照 + 锚点 = now。消费方:create/quickCreate(经 create)/ 导入建档。changeOwner(Customer c, Long targetUserId):在职守卫(67012)→ 同主拒绝(67012)→ 快照四列 → 锚点空则 now、非空保留(统一规则)→ enterPoolTime wrapper 显式清空 → CAS 落库(失败 67005)。消费方:claim/assign/ 交割initiate/assignOne四处。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 形态命名 / 双层测试 / 单票一体)。