--- status: accepted --- # 线索双计时器深模块:LeadDeadlines(收拢 N/M 规则的唯一家) ## Context 双计时器(N=回收天数 / M=失效天数)的规则知识散布在 4 个生产文件、10 个场景: 1. **公式重复**:`plusDays(pool.getRecycleDays())` 重复 5 处(creation ×2、`LeadServiceImpl` ×4 方法体,其中 3 处公式恒等 `now + N`)、`plusDays(pool.getExpireDays())` 3 处。 2. **锚点等价性无处记载**:创建归池写 `now + M`、assignToPool 写 `createTime + M`——两处写法不同但语义相同(M 从创建起跑,创建时 now ≈ createTime),等价性只活在读代码的人脑子里。 3. **条件规则只活在调用方**:激活的「失效前态是否私海决定 N、无池保留原 M」十几行条件计算躺在 `activateLead`。 4. **批量与单行两套投影**:池参数变更重算公式内联在 `PoolChangedEventListener` 的 SQL 字符串拼接里(`DATE_ADD(claim_time, INTERVAL N DAY)` ×3 段),与单行 Java 版是同一份锚点知识的两个投影,互不引用。 5. **缺陷模式**:改一条计时规则要人肉排查 4 个文件——P1-6 类缺陷(规则两处实现漂移)的诞生结构。 ## Considered Options (grilling 2026-08-20,七问七钉) - **痛点定位**:主治「规则无家」(locality,选中)/公式重复(轻量值对象工厂)/调用方心智(计算彻底下沉状态机)。用删除测试衡量:删掉公式工厂复杂度几乎不回流;删掉规则模块,10 场景的锚点/条件规则立刻散回 4 文件。 - **模块边界**:收写入侧单行预算 + 池参数变更批量刷新**含执行**(选中);停止规则留 `LeadTransition`(清 recycleDeadline 与清 owner/claimTime 是同一 CAS 语句的同一批字段侧效);到期扫描不动(ADR-0019 失效先/回收后 + ADR-0021 属地,搬家零收益)。 - **interface 形状**:按锚点语义 4 方法(选中)/按业务事件 6 方法(其中 3 个实现恒等 `now + N`——shallow 信号,为「未来事件分化」预付接口税;将来真分化时再从锚点方法裂开)/单方法 + ctx(各事件输入天然异构,ctx 成 nullable 堆,且与状态机 cmd 分发重复)。 - **值对象穿越**:`TransitionCmd` 保持 `LocalDateTime` 散字段(选中——`DeadlineBudget` 生命周期止于调用方,解构后填 cmd,状态机零改动)/cmd 改携带 `DeadlineBudget`(动 7 个 record + 6 处 applyXxx + 全部状态机测试,纯美学代价)。 - **测试分工**:模块测试锁规则真值矩阵 + 调用方 mock 接线(选中,interface 即 test surface)/调用方注入真实实现(构造器带 mapper,纯计算场景传 null 味道差)。 ## Decision 新建 `com.crm.lead.deadline`:`LeadDeadlines` 接口(4 方法)+ `DeadlineBudget` record(`recycle` / `expire` 两字段)在包根,`impl/LeadDeadlinesImpl` 下沉: - `recycleOnPrivateEntry(pool, anchor)`——进入/维持私海的 N 预算(领取、分配、作废恢复、有效反馈共用;pool null 无池防御)。 - `expireFromCreation(createTime, pool)`——M 锚创建时刻(创建时 now 与 assignToPool 的 createTime 是同一规则的方法名显式化)。 - `budgetForActivation(lead, pool, now)`——激活条件全收(失效前态是否私海决定 N、无池保留原 M、statusBeforeExpire null 默认待领取)。 - `refreshForPoolChange(poolId, pool)`——§5.3 三段 `DATE_ADD` 批量重算(CLAIMED 锚 claim_time、FOLLOWING 锚 feedback_time、非已转商机锚 create_time),返回合计行数。 **边界判据**:调用方本来就要做的判断不收(assignToUser 按起始态走分支——状态机语言);只有计时器才要做的判断收(锚点选择、激活条件、无池防御)。 **ADR-0021 的 cmd 预计算契约不变**:错的只是预计算无家,不是契约本身——`DeadlineBudget` 解构后填 `TransitionCmd` 既有散字段,`TransitionCmd` / `LeadTransitionImpl` / `LeadMaintenanceJob` 零改动。 ## Consequences - 5 处 `plusDays` 调用点全部改走新模块(replace 不 layer);`activateLead` 的十几行条件计算缩成一行;`PoolChangedEventListener` 瘦成纯编排(快照 + dept 两段留下,deadline 三段委派)。 - 锚点规则表落在 `LeadDeadlines` javadoc(N:锚最近持有活动时刻、存在性=私海态、停止=释放/回收/作废清 null 转商机冻结;M:锚创建时刻唯一例外激活重锚 now、存在性=非终态、作废态继续跑)——改一条计时规则只动一处。 - 测试 replace 不 layer:P1-6 三用例(DATE_ADD 锚点断言)自 `PoolChangedEventListenerTest` 原样平移至 `LeadDeadlinesImplTest`(12 用例:8 预算矩阵 + 4 批量重算);调用方测试 mock 接线,激活用例从 `isNotNull` 升级为 stub 等值断言。crm-lead 106 → 116 用例。 - interface 混合「纯预算 + 写路径」两种笔法(`recycle/expire/budget` vs `refresh`)——方法命名泾渭分明,语义同属「计时器的一生」。 - 商机模块计时器(`last_valid_follow_time` 实时算、无 `recycle_deadline` 字段)与线索的口径统一不在本 ADR 范围(另案)。 - 决策来源:`/improve-codebase-architecture` 第三轮审查(2026-08-20,候选 ② Strong)+ grilling 七问;实现票据见 `.scratch/lead-deadline-module/spec.md`。