Browse Source

commit

master
luoweijian 3 weeks ago
parent
commit
70b53de34c
  1. 56
      .scratch/clue-module/handoff-G1-G5-batch-stats-20260814.md
  2. 33
      .scratch/clue-module/issues/10-crm-base-batch-result骨架.md
  3. 48
      .scratch/clue-module/issues/11-crm-lead批量操作接口.md
  4. 49
      .scratch/clue-module/issues/12-crm-lead视图统计与statusIn.md
  5. 44
      .scratch/clue-module/issues/13-crm-rule公海池批量删除.md
  6. 49
      .scratch/clue-module/issues/14-bruno-sync文档生成与逐页比对.md
  7. 37
      .scratch/clue-module/map.md
  8. 94
      .scratch/clue-module/线索业务-PRD.md
  9. 53
      README.md
  10. BIN
      __pycache__/mcp_call.cpython-313.pyc
  11. 2
      bruno-sync.config.json
  12. 53
      crm-base/src/main/java/com/crm/base/domain/result/BatchResult.java
  13. 60
      crm-base/src/test/java/com/crm/base/domain/result/BatchResultTest.java
  14. 20
      crm-lead/CONTEXT.md
  15. 7
      crm-lead/src/main/java/com/crm/lead/constant/LeadConstants.java
  16. 50
      crm-lead/src/main/java/com/crm/lead/controller/LeadController.java
  17. 31
      crm-lead/src/main/java/com/crm/lead/domain/dto/LeadBatchFailItem.java
  18. 28
      crm-lead/src/main/java/com/crm/lead/domain/dto/LeadStatsDTO.java
  19. 50
      crm-lead/src/main/java/com/crm/lead/domain/enums/LeadBatchFailReason.java
  20. 39
      crm-lead/src/main/java/com/crm/lead/domain/enums/LeadViewType.java
  21. 8
      crm-lead/src/main/java/com/crm/lead/domain/param/LeadPageParam.java
  22. 27
      crm-lead/src/main/java/com/crm/lead/history/LeadHistoryRecorder.java
  23. 54
      crm-lead/src/main/java/com/crm/lead/history/impl/LeadHistoryRecorderImpl.java
  24. 30
      crm-lead/src/main/java/com/crm/lead/query/LeadViewQuery.java
  25. 203
      crm-lead/src/main/java/com/crm/lead/query/impl/LeadViewQueryImpl.java
  26. 26
      crm-lead/src/main/java/com/crm/lead/service/ILeadService.java
  27. 246
      crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java
  28. 72
      crm-lead/src/main/java/com/crm/lead/state/TransitionCmd.java
  29. 119
      crm-lead/src/main/java/com/crm/lead/state/impl/LeadTransitionImpl.java
  30. 63
      crm-lead/src/test/java/com/crm/lead/history/impl/LeadHistoryRecorderImplTest.java
  31. 226
      crm-lead/src/test/java/com/crm/lead/query/impl/LeadViewQueryImplTest.java
  32. 114
      crm-lead/src/test/java/com/crm/lead/service/impl/LeadBatchServiceTest.java
  33. 135
      crm-lead/src/test/java/com/crm/lead/service/impl/LeadServiceImplTest.java
  34. 165
      crm-lead/src/test/java/com/crm/lead/state/impl/LeadTransitionImplTest.java
  35. 10
      crm-rule/src/main/java/com/crm/rule/controller/LeadPoolController.java
  36. 31
      crm-rule/src/main/java/com/crm/rule/domain/dto/PoolBatchFailItem.java
  37. 40
      crm-rule/src/main/java/com/crm/rule/domain/enums/PoolBatchFailReason.java
  38. 10
      crm-rule/src/main/java/com/crm/rule/service/ILeadPoolService.java
  39. 36
      crm-rule/src/main/java/com/crm/rule/service/impl/LeadPoolServiceImpl.java
  40. 100
      crm-rule/src/test/java/com/crm/rule/service/impl/LeadPoolBatchDeleteTest.java
  41. 76
      docs/adr/0022-lead-module-read-write-separation-deep-modules.md
  42. 61
      docs/adr/0023-lead-batch-operations-and-view-stats.md
  43. 1
      tmp/lead_pages.json
  44. 34
      tmp/mcp_tools.py

56
.scratch/clue-module/handoff-G1-G5-batch-stats-20260814.md

@ -0,0 +1,56 @@
# Handoff — 线索模块 grill-with-docs(G1–G5 批量操作 + 视图统计)实现收官
生成时间:2026-08-14 18:01
仓库:`D:/code/crm-backend-matt`(多模块 Maven monorepo)
分支:见 `git status`(工作区未提交,尚未 commit/push)
## 一句话现状
grill-with-docs 会话已**全流程闭环**:决策 grill → ADR-0023(accepted) → 拆 5 张 ticket(10–14) → 逐张实现+测试闭环。**ticket 10–14 全部 resolved**,代码未提交。剩下的是**人工 review + 提交**(本会话按铁律不做 git 操作)。
## 别重复读的既有产物(按路径引用,勿抄写)
- **决策全景 / 逐页比对结论**:`.scratch/clue-module/map.md` → `## Decisions so far`(ticket 10–14 一行一条)+ `## 逐页比对(ticket 14 收口)`(原型 15 页 vs 已实现接口的对照表 + G1–G5 闭环结论)。
- **ADR**:`docs/adr/0023-lead-batch-operations-and-view-stats.md`(D1–D5 决策;status: accepted)。注意 `docs/adr/0022-...read-write-separation...` 是**别人**的读写分离重构 ADR,非本会话产出。
- **Ticket 底稿 + Answer**:`.scratch/clue-module/issues/10-..` ~ `14-..md`,每张 `## Answer` 已写明交付内容与验收结果。
- **PRD**:`.scratch/clue-module/线索业务-PRD.md` §13(v1.2,B1–B9 决策)。
- **领域语言**:`crm-lead/CONTEXT.md`、`crm-rule/CONTEXT.md`(含批量/统计新术语)。
- **生成的 API 文档**:独立 docs 仓 `D:/code/crm-api-docs`(bruno collection),本会话新增 32 个 `.bru`(4 个 tag 文件夹:`线索管理`/`线索规则-公海池配置`/`线索规则-行政区划`/`列偏好`)。**该仓也未提交**。
## 已完成(ticket 10–14)
代码文件见 `git status`(下方"工作区注意")。要点:
- **10** crm-base `BatchResult<F>`(total/successCount/failCount/`List<F> failures`),仿 `PageResult` 风格。
- **11** crm-lead 6 个 `-batch` 端点 + `LeadBatchFailReason`(8 值,回指 65xxx) + `LeadBatchFailItem`;impl 用 `@Lazy self` + 私有 `runBatch(List<Long>, Consumer<Long>)`,逐条 CAS、非原子、部分成功。
- **12** `LeadPageParam.status`→`statusIn(List<Integer>)`;`LeadStatsDTO` + `POST /api/lead/stats`;抽 `buildViewWrapper()` 使统计口径=列表口径+@DataScope。
- **13** crm-rule `POST /api/rule/pool/delete-batch`(D5 方案 A:crm-rule 自建 `PoolBatchFailReason`/`PoolBatchFailItem`,复用 crm-base `BatchResult`,避 crm-rule→crm-lead 反向依赖)。
- **14** bruno-sync `sourceRoots` 扩容 + 生成 32 个 `.bru` + 原型 15 页逐页比对;**G1–G5 全闭环,G6/G7/G8 维持 Out of scope**(线索导入/池导入/池导出——原型有按钮≠本期交付)。
## 铁律 / 陷阱(下个 agent 必读)
1. **`.java` 必须 UTF-8 无 BOM**(见 `AGENTS.md`)。写/改后跑 BOM 扫描:`head -c 3 file | od -An -tx1`(期望非 `efbbbf`)。docs 仓的 `.bru` 不受此约束(那个仓的既有文件本就带 BOM)。
2. **grep 前须 `export LC_ALL=C.UTF-8`**
3. **依赖方向单向**:crm-lead→crm-rule;crm-rule **禁止**依赖 crm-lead(否则 cycle,D5 因此选方案 A)。
4. **别 `git checkout` 整文件**:工作区含**别人**的读写分离重构(ADR-0022)——`crm-lead/.../history/`、`.../query/`、`.../state/`、`LeadViewType.java`、`TransitionCmd.java`、`LeadTransitionImpl.java`、`LeadConstants.java`、相关测试——**非本会话产出,勿动勿清**。
5. **crm-auth 测试发现失败**(预存在,与本工作无关):`mvn -pl crm-auth test` → junit-jupiter discovery / SurefireBooterForkException(forked-JVM 环境问题)。这会污染 `-am` 链式测试。**工作区跑测试的姿势**:先 `mvn -pl <mod> -am install -DskipTests -q`,再 `mvn -pl <mod> test`(只跑目标模块)。
6. **`edit` 多改一组原子**:任一 `oldText` 匹配失败则整组回滚。
7. **`mvn -pl X -am test -Dtest=...`** 会对 `-am` 依赖模块也套同一 `-Dtest` 导致 "No tests matching";加 `-Dsurefire.failIfNoSpecifiedTests=false`
## 验收状态
- `mvn -q compile`(全模块)通过;全 `*.java` 无 BOM。
- 各 ticket 单测:`BatchResultTest` 4/4、`LeadBatchServiceTest` 5/5、`LeadServiceImplTest` 44/44、`LeadViewQueryImplTest` 6/6、`LeadPoolBatchDeleteTest` 4/4、`LeadPoolServiceImplTest` 15/15。
- **未做**:整仓 `mvn test` 汇总(受 crm-auth 环境问题阻塞,需用上面的 install-then-test 姿势);`git commit`;docs 仓提交。
## 下一步(按优先级)
1. **人工 review 两个仓的 diff**:`D:/code/crm-backend-matt`(代码)+ `D:/code/crm-api-docs`(32 个新 `.bru`),确认后分别提交。**提交要把本会话代码与别人的 ADR-0022 重构分开**(工作区混杂,注意别一把 `git add .`)。
2. 若要整仓测试汇总:`mvn -pl crm-base,crm-lead,crm-rule,crm-preference -am install -DskipTests -q` 后逐模块 `mvn -pl <mod> test`
3. G6/G7/G8(导入导出)若未来要做,需**新开 ADR + ticket**,不在本会话范围。
4. crm-auth 测试发现失败值得**独立排查**(与本工作无关,但阻塞 CI 全量测试)。
## Suggested skills
- **`code-review`**:review 自固定点以来的改动(标准轴 + spec 轴),正好对接"人工 review 后提交"。起点可用本会话开始前的 commit 或 merge-base。
- **`tdd`**:若继续做 G6/G7/G8 或补测,走红-绿-重构。
- **`request-refactor-plan`** / **domain 相关**:如需把 ADR-0022 读写分离重构单独整理成可提交的原子 commit 序列。

33
.scratch/clue-module/issues/10-crm-base-batch-result骨架.md

@ -0,0 +1,33 @@
# crm-base 通用批量结果骨架 BatchResult<F>
Type: task
Status: resolved
Depends on: —
ADR: docs/adr/0023-lead-batch-operations-and-view-stats.md (D2)
## Question(实现目标)
在 crm-base 提供业务无关的批量结果泛型骨架,供各业务域批量接口复用。
## 交付物
- `crm-base/src/main/java/com/crm/base/domain/result/BatchResult.java`
- 泛型 `BatchResult<F>`,与 `Result` / `PageResult` 并列于 `domain/result`
- 字段:`int total`、`int successCount`、`int failCount`、`List<F> failures`。
- 方法:`addSuccess()`(total++、successCount++)、`addFailure(F item)`(total++、failCount++、failures.add)。
- `failures` 初始化为空 `ArrayList`,避免 NPE。
- **不得出现任何业务语义**(不引用 lead/pool/任何域枚举)——crm-base 纯净性约束(AGENTS.md / CONTEXT-MAP)。
## 验收
- 编译通过:`mvn -q -pl crm-base -am compile`。
- 全 `*.java` 无 BOM(AGENTS.md 编码规范)。
- 单元测试:`addSuccess`/`addFailure` 计数正确,`failures` 收集正确(可放 crm-base test)。
## Comments
## Answer
已交付。`crm-base/src/main/java/com/crm/base/domain/result/BatchResult.java`:泛型 `BatchResult<F>`(`@Data`),字段 total/successCount/failCount/`List<F> failures`(初始空 `ArrayList`),方法 `addSuccess()`/`addFailure(F)`。无任何业务语义。
验收:无 BOM;`BatchResultTest` 4/4 绿(`mvn -pl crm-base test -Dtest=BatchResultTest` BUILD SUCCESS)。

48
.scratch/clue-module/issues/11-crm-lead批量操作接口.md

@ -0,0 +1,48 @@
# crm-lead 批量操作接口(claim/assign/release/activate/delete-batch)
Type: task
Status: resolved
Depends on: 10
ADR: docs/adr/0023-lead-batch-operations-and-view-stats.md (D1/D2)
## Question(实现目标)
为线索补齐 6 个批量流转接口,非原子、逐条 CAS、部分成功,返回结构化失败明细。
## 交付物
### 领域件(crm-lead)
- `domain/enums/LeadBatchFailReason.java`:语义枚举,各值带 `code`(回指 ResultCode 65xxx)+ `fromCode(int)` 查找 + `UNKNOWN` 兜底。
- `ALREADY_CONVERTED`(65009)、`CONCURRENT_MODIFIED`(65010)、`STATUS_NOT_ALLOWED`(65003)、`NOT_OWNER`(65004)、`OVER_HOLD_LIMIT`(65007)、`OVER_DAILY_LIMIT`(65006)、`UNKNOWN`。
- `domain/dto/LeadBatchFailItem.java`:`Long id` + `LeadBatchFailReason reason` + `String message`,`of(id, reason, message)` 工厂。
### service(crm-lead)
- `ILeadService` 增 6 方法,均返回 `BatchResult<LeadBatchFailItem>`
`claimBatch(ids)` / `assignToPoolBatch(ids, poolId)` / `assignToUserBatch(ids, userId)` / `releaseBatch(ids)` / `activateBatch(ids)` / `deleteBatch(ids)`
- `LeadServiceImpl`
- `@Lazy @Autowired private ILeadService self;`(沿用 `AuthServiceImpl.self` 模式)。
- 私有 `runBatch(List<Long> ids, Consumer<Long> op)`:逐条 `op.accept(id)``self.xxx(id)` 单条方法(各自独立事务);捕 `BusinessErrorException``LeadBatchFailReason.fromCode(e.getCode())`;捕其他 `Exception``UNKNOWN``log.error`
- **批量方法本身不加 `@Transactional`**(保证一条失败不污染已成功条)。
### controller(crm-lead)
- `LeadController` 增 6 个 `@PostMapping`:`/claim-batch`、`/assign-pool-batch`、`/assign-user-batch`、`/release-batch`、`/activate-batch`、`/delete-batch`。
- 入参 `@RequestParam("ids") List<Long> ids`(+ assign 的 `poolId`/`userId`)。
- 返回 `Result<BatchResult<LeadBatchFailItem>>`
## 验收
- 编译:`mvn -q -pl crm-lead -am compile`。无 BOM。
- 单测覆盖 `runBatch`:全成功 / 部分失败(构造单条抛 65003、65010 等)/ 空 ids → 计数与 failures 正确;失败不回滚已成功条(self-proxy 独立事务)。
- 不引入新的上限校验逻辑(`OVER_HOLD_LIMIT`/`OVER_DAILY_LIMIT` 仅预留映射,当前单条未实现该校验,属既有 gap)。
## Comments
## Answer
已交付(ADR-0023 D1/D2):
- `domain/enums/LeadBatchFailReason.java`:7 个业务原因 + `UNKNOWN`,各带 `code` 回指 `LeadConstants` 65xxx(含 65005 CLAIM_RULE_DENIED),`fromCode(Integer)` 未命中返 UNKNOWN。
- `domain/dto/LeadBatchFailItem.java`:id + reason + message + `of(...)`
- `ILeadService` 增 6 方法;`LeadServiceImpl` `@Lazy @Autowired ILeadService self` + `runBatch(ids, Consumer<Long>)`(不加 `@Transactional`,逐条 self 代理调单条、捕 BusinessErrorException 映射 reason、其他异常 UNKNOWN+log.error)。
- `LeadController` 增 6 个 `-batch` 端点(claim/assign-pool/assign-user/release/activate/delete),`@RequestParam("ids") List<Long>`,返 `Result<BatchResult<LeadBatchFailItem>>`
验收:无 BOM;`mvn -pl crm-lead -am compile` BUILD SUCCESS;`LeadBatchServiceTest` 5/5(全成功/部分失败映射/UNKNOWN 兜底/空 ids/assign-user 逐条委派)+ `LeadServiceImplTest` 44/44 无回归。

49
.scratch/clue-module/issues/12-crm-lead视图统计与statusIn.md

@ -0,0 +1,49 @@
# crm-lead 视图统计接口 /stats + statusIn 契约变更
Type: task
Status: resolved
Depends on: —
ADR: docs/adr/0023-lead-batch-operations-and-view-stats.md (D3/D4)
## Question(实现目标)
新增顶部统计卡片接口,口径=当前视图(非全库);并把列表 status 筛选升级为多值以支持复合卡片下钻。
## 交付物
### 契约变更(crm-lead)
- `domain/param/LeadPageParam.java`:`Integer status` → `List<Integer> statusIn`(`/page` 与 `/stats` 共用)。
- Schema 说明:多值,支持复合卡片下钻(如「已被领取」传 `[3,4]`)。
### DTO(crm-lead)
- `domain/dto/LeadStatsDTO.java`:`int total / claimed / converted / todayNew / undistributed`(默认 0)。
### 读侧(crm-lead query 深模块,ADR-0022)
- `LeadViewQuery``LeadStatsDTO countStats(LeadPageParam param)`
- `LeadViewQueryImpl`
- 抽出 `private LambdaQueryWrapper<Lead> buildViewWrapper(param, currentUserId)`,被 `pageLeads``countStats` **共用**,确保统计口径 = 列表口径(同 viewType + 同筛选 + 同 `@DataScope`)。
- status 过滤由 `.eq(status)` 改为 `.in(CollUtil.isNotEmpty(statusIn), Lead::getStatus, statusIn)`
- `pageLeads` = `buildViewWrapper(...).orderByDesc(createTime)` 后分页(行为不变)。
- `countStats` = `selectList(buildViewWrapper(...))` 后按 status 分组计数:claimed=status∈{3,4}、converted=5、undistributed=1、total=全部、todayNew=createTime 为今天。
### service + controller(crm-lead)
- `ILeadService``LeadStatsDTO countStats(LeadPageParam)` → 委派 `leadViewQuery.countStats`
- `LeadController``@PostMapping("/stats")`,入参 `LeadPageParam`,返回 `Result<LeadStatsDTO>`
## 验收
- 编译:`mvn -q -pl crm-lead -am compile`。无 BOM。
- 现有 `/page` 相关测试改 `status`→`statusIn` 后仍绿。
- 新测:四视图下 `countStats` 口径与 `pageLeads` 一致(同筛选集);`statusIn=[3,4]` 下钻能正确回到 `/page`
- 注意:`buildViewWrapper` 重构不得改变 `pageLeads` 既有行为(读写分离 query 模块为本会话前未提交改动,勿破坏)。
## Comments
## Answer
已交付(ADR-0023 D3/D4):
- **statusIn 契约变更**:`LeadPageParam.status(Integer)` → `statusIn(List<Integer>)`(支撑复合卡片下钻);wrapper 由 `.eq(status)` 改为 `.in(CollUtil.isNotEmpty, status, statusIn)`。全库仅 1 处引用(LeadViewQueryImpl),无测试引用 param.status。
- **统计口**:`LeadStatsDTO`(total/claimed/converted/todayNew/undistributed);`LeadViewQuery.countStats` + `LeadServiceImpl.countStats` 委派;`LeadController` `POST /api/lead/stats`(同 /page 吃 `LeadPageParam`)。
- **口径共享**:抽取 `buildViewWrapper(param, userId)`(viewType 数据集 + 公共筛选,不含排序/分页),`pageLeads` 与 `countStats` 共用,保证「统计口径 = 列表口径」+ @DataScope 一致。claimed = status IN(已领取,跟进中);todayNew 按 createTime.toLocalDate() == today。
验收:无 BOM;`mvn -pl crm-lead -am compile` BUILD SUCCESS;`LeadViewQueryImplTest` 6/6(+2 统计:status 分组、todayNew 今日),crm-lead 全套 64/64 绿无回归。(crm-auth 模块存在无关本票的 junit 发现失败,已隔离验证 crm-lead。)

44
.scratch/clue-module/issues/13-crm-rule公海池批量删除.md

@ -0,0 +1,44 @@
# crm-rule 公海池批量删除 pool/delete-batch
Type: task
Status: resolved
Depends on: 10
ADR: docs/adr/0023-lead-batch-operations-and-view-stats.md (D5, 方案 A)
## Question(实现目标)
为公海池补批量删除(原型 A7-3-1)。失败项归 crm-rule 自持(crm-rule 不能依赖 crm-lead,故不复用 LeadBatchFailItem)。
## 交付物
### 领域件(crm-rule)
- `domain/enums/PoolBatchFailReason.java`:`NOT_EXIST`(64004)、`HAS_ACTIVE_LEAD`(64005, **预留**)、`UNKNOWN`;各带 `code` + `fromCode(int)`
- `domain/dto/PoolBatchFailItem.java`:`Long poolId` + `PoolBatchFailReason reason` + `String message` + `of(...)` 工厂。
### service(crm-rule)
- `ILeadPoolService``BatchResult<PoolBatchFailItem> deleteBatch(List<Long> ids)`
- `LeadPoolServiceImpl`:`@Lazy` self-injection 逐条调 `self.deletePool(id)`(各自独立事务);捕 `BusinessErrorException`→`PoolBatchFailReason.fromCode(e.getCode())`;其他→`UNKNOWN`+log。批量方法不加 `@Transactional`
### controller(crm-rule)
- `LeadPoolController``@PostMapping("/delete-batch")`,`@RequestParam("ids") List<Long> ids`,返回 `Result<BatchResult<PoolBatchFailItem>>`
## 范围外(本期不做,grill 决策 B)
- **不补**「池下有非终态线索则拒删」守卫(`CODE_POOL_HAS_ACTIVE_LEAD` 64005 保持预留)。该守卫需跨 crm-rule→线索表查询,触及依赖方向难题,另立独立 issue。故本期池批量删除实际失败原因仅 `NOT_EXIST`/`UNKNOWN`。
## 验收
- 编译:`mvn -q -pl crm-rule -am compile`。无 BOM。
- **无依赖环**:crm-rule 不 import 任何 `com.crm.lead.*`(`mvn -pl crm-rule compile` 能独立于 crm-lead 通过)。
- 单测:全成功 / 混入不存在 id(64004) / 空 ids → 计数与 failures 正确。
## Comments
## Answer
已交付(ADR-0023 D5 方案 A):
- **crm-rule 自持失败项**(避 cycle):`domain/enums/PoolBatchFailReason`(NOT_EXIST=64004 / HAS_ACTIVE_LEAD=64005 **保留** / UNKNOWN,各带 code)+ `domain/dto/PoolBatchFailItem`(poolId+reason+message+of),**复用 crm-base `BatchResult<F>`**。
- `ILeadPoolService.deletePoolBatch(List<Long>)` + impl:`@Slf4j` + `@Lazy @Autowired ILeadPoolService self`,逐条 `self.deletePool(id)`(各自独立事务),捕 BusinessErrorException 映 reason / 其他异常 UNKNOWN+log.error。
- `LeadPoolController` `POST /api/rule/pool/delete-batch`,`@RequestParam("ids") List<Long>`,返 `Result<BatchResult<PoolBatchFailItem>>`
- **本期口径**:单条 deletePool 只抛 NOT_EXIST,批量仅产出 NOT_EXIST/UNKNOWN;HAS_ACTIVE_LEAD 为保留项(Q8 方案 B,守卫延后)。
验收:无 BOM;`mvn -pl crm-rule -am compile` BUILD SUCCESS;`LeadPoolBatchDeleteTest` 4/4(全成功/NOT_EXIST 映射/UNKNOWN 兜底/空 ids)+ `LeadPoolServiceImplTest` 15/15 无回归。

49
.scratch/clue-module/issues/14-bruno-sync文档生成与逐页比对.md

@ -0,0 +1,49 @@
# bruno-sync 配置扩容 + 线索模块 API 文档生成
Type: task
Status: resolved
Depends on: 11, 12, 13
ADR: docs/adr/0023-lead-batch-operations-and-view-stats.md
## Question(实现目标)
把线索/规则/偏好模块纳入 bruno-sync 扫描,运行 bruno-sync skill 生成/更新 API 文档,并按页做逐页比对(原型 vs 已实现接口)收口。
## 交付物
### 配置
- `bruno-sync.config.json``scan.sourceRoots` 增补:
`crm-lead/src/main/java`、`crm-rule/src/main/java`、`crm-preference/src/main/java`
(当前仅 crm-auth、crm-file)。
### 文档生成
- 运行 bruno-sync skill(`C:\Users\luowj\.qoder\skills\bruno-sync\SKILL.md`)。
- `docsRepoPath` = `D:/code/crm-api-docs`(见 bruno-sync.local.json),urlPattern `/api/{module}/{resource}`,envelope code/success/message/data。
- 产物:线索四视图 page/detail/history/create/edit/delete/流转 + 新增 stats + 6 个 -batch;规则 pool page/detail/saveOrUpdate/delete + delete-batch;region;preference get/save。
### 逐页比对(收口)
- 对线索模块 15 页原型(`tmp/lead_pages.json` / `prototype-extract/`)逐页核对:每页所需接口是否已在文档中齐备。
- 输出比对结论到 `.scratch/clue-module/map.md`:G1–G5 已闭环;G6/G7/G8 维持 Out of scope(原型有按钮≠本期交付)。
## 验收
- 全模块先 `mvn -q compile` 通过(依赖 10/11/12/13 全 resolved)。
- 全 `*.java` 无 BOM 扫描通过。
- 文档生成无报错;线索/规则/偏好接口全部出现在文档。
- map.md 记录逐页比对结论与残留 Out-of-scope 项。
## Comments
## Answer
已交付:
- **配置扩容**:`bruno-sync.config.json` `scan.sourceRoots` 增补 `crm-lead/src/main/java`、`crm-rule/src/main/java`、`crm-preference/src/main/java`(原仅 crm-auth+crm-file)。
- **文档生成**(bruno-sync skill,docs 仓 `D:/code/crm-api-docs`,`grouping: tag`):新增 **32 个 generated `.bru`**,沿仓内房屏风格(meta+method 块+docs 首行对账钥匙):
- `线索管理/` ×23(page/detail/history/stats/create/edit/delete/claim/assign-pool/assign-user/feedback-draft/feedback-submit/convert/release/activate/follow/unfollow + 6 个 -batch)
- `线索规则-公海池配置/` ×5(page/detail/saveOrUpdate/delete/delete-batch)
- `线索规则-行政区划/` ×2(list/level)
- `列偏好/` ×2(get/save)
- 报告:新增 32 / 删除 0 / 跳过 0 / 无法识别 0 / 钥匙冲突 0 / 矛盾 0 / 表述差异 0。
- **逐页比对(收口)**:原型 15 页逐页核对写入 `map.md` 「## 逐页比对(ticket 14 收口)」。**G1–G5 全闭环**(批量分配/删除/激活/领取 + 统计卡片);G6 线索导入 / G7 池导入 / G8 池导出 维持 Out of scope(原型有按钮≠本期交付);无遗漏项。
验收:`mvn -q compile` 全模块通过;全 `*.java` 无 BOM 扫描通过;线索/规则/偏好接口全部出现在文档;map.md 已记录逐页比对结论与残留 Out-of-scope 项。

37
.scratch/clue-module/map.md

@ -35,6 +35,16 @@
<!-- 一行一个已关闭 ticket:gist + 链接 -->
- **[ticket 14] bruno-sync 扩容 + 文档生成 + 逐页比对已交付**:`bruno-sync.config.json` `scan.sourceRoots` 增补 crm-lead/crm-rule/crm-preference;docs 仓 `D:/code/crm-api-docs` 新增 32 个 generated `.bru`(线索管理×23 / 公海池配置×5 / 行政区划×2 / 列偏好×2);`mvn -q compile` 通过、全 `*.java` 无 BOM。逐页比对结论见下「## 逐页比对(ticket 14 收口)」:**G1–G5 全闭环**,G6/G7/G8 维持 Out of scope。[14](issues/14-bruno-sync文档生成与逐页比对.md)
- **ADR-0023 定稿(accepted,G1–G5 批量+统计)**:批量非原子逐条 CAS 部分成功(D1);`BatchResult<F>` 骨架归 crm-base、失败项各业务域自持(D2);`/stats` 口径随视图非全库(D3);`status`→`statusIn` 支撑复合卡片下钻(D4);池批量删除失败项由 crm-rule 自建(D5,方案 A,因 crm-rule 不能依赖 crm-lead);「池下非终态线索拒删」守卫本期不补(64005 预留)。拆为 ticket 10–14。[ADR-0023](../../docs/adr/0023-lead-batch-operations-and-view-stats.md)
- **[ticket 10] crm-base `BatchResult<F>` 骨架已交付**:业务无关泛型(total/successCount/failCount/failures + addSuccess/addFailure),4/4 单测绿。[10](issues/10-crm-base-batch-result骨架.md)
- **[ticket 11] crm-lead 批量操作已交付**:`LeadBatchFailReason`(回指 65xxx)+ `LeadBatchFailItem` + `ILeadService` 6 方法 + `LeadServiceImpl` `@Lazy self` 逐条 CAS `runBatch`(不加 @Transactional)+ `LeadController` 6 个 `-batch` 端点。LeadBatchServiceTest 5/5 + 无回归。[11](issues/11-crm-lead批量操作接口.md)
- **[ticket 12] crm-lead 视图统计 + statusIn 已交付**:`status`→`statusIn(List<Integer>)`复合下钻;`LeadStatsDTO` + `POST /api/lead/stats`(同 /page 吃 LeadPageParam);抽 `buildViewWrapper` 使统计口径=列表口径+@DataScope。LeadViewQueryImplTest 6/6,crm-lead 64/64。[12](issues/12-crm-lead视图统计与statusIn.md)
- **[ticket 13] crm-rule 公海池批量删除已交付**(D5 方案 A):crm-rule 自建 `PoolBatchFailReason`(64004/64005 保留/UNKNOWN)+ `PoolBatchFailItem`,**复用 crm-base `BatchResult<F>`**避 cycle;`deletePoolBatch` `@Lazy self` 逐条 + `POST /api/rule/pool/delete-batch`。LeadPoolBatchDeleteTest 4/4 + 无回归。[13](issues/13-crm-rule公海池批量删除.md)
- **接口完整性复核(2026,grill-with-docs)**:比对原型 15 页 vs 已实现 4 个 controller,缺口 G1–G8。**本期范围 = G1–G5(批量分配/删除/激活/领取/释放 + 统计卡片)**;G6–G8(线索导入导出、线索合并、公海池导入导出)**维持既有「不做/Out of scope」判定不动**,原型有按钮不等于本期交付。
- **原型验证(2026-08-13,/prototype LOGIC + UI 分支)**:状态机可用 `prototype-statemachine/`(CLI reducer);权限三机制可用 `prototype-permission/`(单页沙盘)。验出两个纸上未发现的缺口并已拍板:① 作废→失效→激活回作废的死胡同 → 定“激活是过期失效专属,作废只能分配复活”(落 §3.2#13/3.4/6.8);② “创建人可删”仅在 owner=我 时于“我的线索”行使,流走后由新持有方/管理员承接(自洽,无需改)。原型作为 primary source 待迁废分支。
- **业务模块定名 `crm-lead`**(非 crm-clue),与既存数据范围模块编码 `lead` 对齐。[01](issues/01-商机模块现状调研.md)
- **商机模块尚不存在**;转商机只能定 crm-lead 侧 outbound port 契约(已毕业为 ticket 08)。[01](issues/01-商机模块现状调研.md)
@ -81,6 +91,33 @@
- **定时任务规格** — 超时回收(N 天未跟进回流公海、状态重置待领取、清领取人)、线索失效(入库满 M 天标记过期失效);触发时机、幂等、存量 vs 增量生效规则。等公海池配置契约定稿后毕业。
- **crm-preference 契约与存储** — 用户级列偏好,scope **每菜单独立**(线索域 4 个 scope),列定义来源,最小通用抽象边界。等"列偏好 scope 模型"ticket 定稿后毕业。
## 逐页比对(ticket 14 收口)
原型 15 页(`tmp/lead_pages.json` / `prototype-extract/`)vs 已实现接口(bruno-sync 文档,`D:/code/crm-api-docs`)。每页核对所需接口是否齐备。
| # | 原型页 | 所需接口 | 覆盖 |
| --- | --- | --- | --- |
| 1 | 全局说明 | 文档页,无接口 | — |
| 2 | 修订记录 | 文档页,无接口 | — |
| 3 | 线索公海(分屏视图) | page(viewType=PUBLIC_POOL)+detail+stats+claim+claim-batch+assign-pool(-batch)+assign-user(-batch)+follow/unfollow | ✅ |
| 4 | 线索公海(列表视图) | 同上(列表布局,接口一致)+ 列偏好 get/save | ✅ |
| 5 | 线索详情(公海) | detail + history | ✅ |
| 6 | 我的线索 | page(MY_LEAD)+stats+feedback-draft/submit+convert+release(-batch)+activate(-batch)+delete(-batch) | ✅ |
| 7 | 线索详情(我的) | detail + history + feedback-submit + convert | ✅ |
| 8 | 新增线索(我的) | create(claimOnCreate) + region/list + region/level | ✅ |
| 9 | 编辑线索 | edit + detail | ✅ |
| 10 | 导入线索(717) | 线索导入 | 🚫 G6 Out of scope |
| 11 | 我的关注 | page(MY_FOLLOW)+stats+unfollow+release-batch | ✅ |
| 12 | 线索管理 | page(MANAGE)+stats+assign-user(-batch)+assign-pool(-batch)+delete(-batch)+activate(-batch) | ✅ |
| 13 | 新增线索(管理) | create + region | ✅ |
| 14 | 导入线索(717) | 线索导入 | 🚫 G6 Out of scope |
| 15 | 线索设置(=A7-3-1 线索规则) | pool page/detail/saveOrUpdate/delete/delete-batch + region list/level | ✅ 池导入导出=G7/G8 Out of scope |
**结论**:
- **G1(批量分配)/G2(批量删除)/G3(批量激活)/G4(批量领取)/G5(统计卡片)全部闭环**——line 3/6/11/12 所需的 6 个 `-batch` 端点 + `/stats` + `statusIn` 复合下钻 + 池 `/delete-batch` 均已在文档中齐备(ticket 11/12/13)。
- **残留 Out of scope(原型有按钮≠本期交付,判定不变)**:G6 线索导入(page 10/14)、G7 公海池导入、G8 公海池导出(page 15 内的导入导出按钮)。见下「## Out of scope」。
- 无「原型需要但接口缺失」的遗漏项。
## Out of scope
<!-- 越过目的地、不毕业的部分 -->

94
.scratch/clue-module/线索业务-PRD.md

@ -1,6 +1,7 @@
# 线索业务 PRD
**版本**:v1.1(chart 阶段;2026-08-13 续 grill:定时任务顺序、部门集合领取、分配上限、状态CAS、history展示、公海混合展示、转商机事务前提、B/D定稿)
**版本**:v1.2(2026 grill-with-docs:接口完整性复核,补 §13 批量操作与统计接口 G1–G5;G6/G7/G8 维持 Out of scope)
**历史**:v1.1(chart 阶段;2026-08-13 续 grill:定时任务顺序、部门集合领取、分配上限、状态CAS、history展示、公海混合展示、转商机事务前提、B/D定稿)
**范围**:crm-lead(线索业务)· crm-rule/线索规则(公海池配置)· crm-preference(列偏好平台能力)
**目的**:把线索域端到端业务流程写成产品可验证的规格;不确定点用 **⚠ 待确认** 标出。
@ -592,3 +593,94 @@ record ColumnPreference(List<String> visibleKeys, List<String> columnOrder) {}
| G9 | ✅(E) 线索公海**混合展示**(防撞单),展示范围可配置不硬编码 | §7 |
| G10 | 转商机现按同库同事务定,显式写入前提约束;商机独立时重设计为最终一致+补偿 | §6.5、CONTEXT |
| G11 | ✅(B) 不刷 owner_dept_id_snapshot;✅(D) 每日 02:00 全库扫 | §11.1 |
---
## 13. 批量操作与统计接口(v1.2 补全,2026 grill-with-docs 定稿)
**动因**:接口完整性复核发现——原型(A2-1-1/A2-1-4/A7-3-1 + 分配/激活弹窗)大量要求**批量操作**与**顶部统计卡片**,§6 的单条流转已实现但批量与统计接口缺失。本节把缺口 G1–G5 定稿为可实现契约。范围**只含 G1–G5**;G6(导入/导出)/G7(合并)/G8(池导入导出)维持既有「不做 / Out of scope」判定(见 map.md)。
### 13.1 批量操作契约(G1–G4)
**统一语义**:所有批量操作**非原子、逐条 CAS、部分成功**(沿用 §6.0 全局并发原则)。逐条套用对应单条操作的守卫 + CAS + 上限校验;行数=0 或校验不过即记一条失败,不拖垮整批。
**批量接口全集(`-batch` 后缀,与单条动词对称)**:
| 接口 | URL | 入参 | 逐条委派 | 备注 |
|---|---|---|---|---|
| 批量领取 | `POST /api/lead/claim-batch` | `ids: List<Long>` | `claimLead` | 池成员自领,受每日/持有上限 |
| 批量分配到池 | `POST /api/lead/assign-pool-batch` | `ids`, `poolId` | `assignToPool` | 未分发→待领取,不占上限 |
| 批量分配到销售 | `POST /api/lead/assign-user-batch` | `ids`, `userId` | `assignToUser` | 统一分配给同一销售;受被指派人上限 |
| 批量释放 | `POST /api/lead/release-batch` | `ids` | `releaseLead` | 已领取/跟进中→待领取 |
| 批量激活 | `POST /api/lead/activate-batch` | `ids` | `activateLead` | 过期失效→恢复失效前态,重置 N/M |
| 批量删除 | `POST /api/lead/delete-batch` | `ids` | `deleteLead` | 已转商机为删除禁区(记失败) |
| 批量删除公海池 | `POST /api/rule/pool/delete-batch` | `ids` | `deletePool` | 池下有非终态线索则该条失败(池删除保护) |
**批量分配双深度**:前端弹窗选了销售调 `assign-user-batch`,只选池未选人调 `assign-pool-batch`(对应原型「管理员可以不指定具体销售人员」)。销售人员单选——所有选中线索统一分配给同一 `userId`
**统一返回 `Result<BatchResult<LeadBatchFailItem>>`**:
```java
// crm-base/domain/result —— 通用批量结果,对失败项内部结构无感知(保持 crm-base 非业务纯净)
class BatchResult<F> {
int total; // 本次提交条数
int successCount;
int failCount;
List<F> failures; // 逐条失败明细
}
// crm-lead —— 线索域失败项
class LeadBatchFailItem {
Long leadId; // 池批量删除时为 poolId
LeadBatchFailReason reason;
String message;
}
// crm-lead —— 失败原因语义枚举(code 回指既有 ResultCode 65xxx)
enum LeadBatchFailReason {
ALREADY_CONVERTED(65009, "已转商机,不可操作"), // 删除/分配禁区
CONCURRENT_MODIFIED(65010, "已被他人操作"), // CAS 行数=0(抢占分配/被回收失效)
OVER_HOLD_LIMIT(65007, "超出个人持有上限"),
OVER_DAILY_LIMIT(65006, "超出每日领取上限"),
STATUS_NOT_ALLOWED(65003, "当前状态不允许此操作"),
NOT_OWNER(65004, "非本人持有,无权操作");
}
```
前端结果弹窗按 `reason` 聚合展示(原型「分配成功 XX 条 / 失败 XX 条 / 失败类型:已转商机 XX 条,抢占分配 XX 条」)。
### 13.2 统计卡片接口(G5)
**接口**:`POST /api/lead/stats`,入参**复用 `LeadPageParam`**(同一套 viewType + 全部筛选 + `@DataScope` 部门天花板),忽略分页字段。返回 `Result<LeadStatsDTO>`
**统计口径 = 当前视图数据集口径,不是全库**:`stats` 与 `page` 吃完全相同的数据集条件(viewType + 筛选 + 数据范围),只把「取一页」换成「按 status 分组计数」。例:`MY_LEAD` 视图的 `total` = 我持有的线索总条数,`claimed` = 我的线索里 status IN(3,4) 的条数。每个视图都有自己的卡片;接口通用、全量返回 5 个计数,前端按需取。
```java
class LeadStatsDTO {
long total; // 数据集内全部
long claimed; // 「已被领取」= status IN (3 已领取, 4 跟进中)——展示文案「已被领取」由前端渲染
long converted; // 已转商机 = status 5
long todayNew; // 今日新增 = DATE(create_time) = CURRENT_DATE(同样受视图+筛选+数据范围约束)
long undistributed; // 未分发 = status 1
}
```
### 13.3 复合卡片下钻:`status` 单值 → `statusIn` 多值(改写既有 LeadPageParam)
**动因**:统计卡片可点击下钻——点卡片把该卡的状态条件塞进 `LeadPageParam` 再调 `/page`。但「已被领取」是 status IN(3,4) 的**并集**,单值 `Integer status` 表达不了。
**改写**:`LeadPageParam.status`(单值 `Integer`)→ `statusIn`(`List<Integer>`),`/page` 与 `/stats` 共用;列表 SQL 用 `status IN (...)`。单状态卡传单元素列表,「已被领取」传 `[3,4]`。原型「全部状态」下拉本就可能多选,一并支持。此改动落在**接口尚未联调/未发文档**阶段,契约变更成本低。
### 13.4 决策清单(v1.2)
| # | 决策 | 出处 |
|---|---|---|
| B1 | 本期只补 G1–G5;G6/G7/G8 维持 Out of scope | §13、map |
| B2 | 批量返回 `Result<BatchResult<F>>` 完整体(total/success/fail/failures) | §13.1 |
| B3 | `BatchResult<F>` 泛型放 crm-base;`LeadBatchFailItem`/`LeadBatchFailReason` 放 crm-lead | §13.1 |
| B4 | 批量接口 `-batch` 后缀;`ids` 表单多值传参 | §13.1 |
| B5 | 批量分配双深度(到池 / 到销售),与单条对称 | §13.1 |
| B6 | `LeadBatchFailReason` 新造语义枚举,带 code 回指 65xxx | §13.1 |
| B7 | `/stats` 复用 LeadPageParam;统计口径随视图(非全库);全量返回前端按需取 | §13.2 |
| B8 | 「已被领取」= status IN(3,4);后端字段 `claimed`,文案前端渲染 | §13.2 |
| B9 | `LeadPageParam.status` 单值 → `statusIn` 多值,支撑复合卡下钻,page/stats 共用 | §13.3 |

53
README.md

@ -23,6 +23,10 @@ crm/ # 父工程(本仓库根目录),统一管理
├── crm-auth/ # 认证与权限:三方扫码登录、JWT、统一用户、部门/角色/菜单、数据权限
├── crm-file/ # 文件模块:直传/三阶段分片上传(MinIO)、下载、kkFileView 预览
├── crm-app/ # 唯一启动入口:聚合全部模块打可执行 jar(其他模块不 repackage)
├── crm-dict/ # 数据字典:两级分组/字典项,业务方按 code 消费
├── crm-rule/ # 线索公海池配置域:公海池/人员/领取规则/区域(crm-lead 的上游)
├── crm-lead/ # 线索业务域:7 状态机 + 四视图 + 领取/分配/反馈/转商机/释放
├── crm-preference/ # 列偏好:用户级列显隐/拖拽排序(与 crm-dict 平行的通用模块)
├── crm-customer/ # (待建)业务模块示例:客户域
└── crm-xxx/ # 业务模块命名规范:crm-{业务域},小写中划线
```
@ -134,6 +138,8 @@ crm:
| 61001~61999 | 认证模块(61001 不支持的登录方式 / 61002 三方授权失败 / 61003 账号禁用 / 61004 部门分配参数非法 / 61005 部门不存在 / 61006 部门下仍有成员拒删 / 61007 部门移动非法防环) | crm-auth |
| 62001~62999 | 文件模块(62001 大小超限 / 62002 类型禁止 / 62003 上传会话不存在 / 62004 分片不完整 / 62005 预览服务不可用 / 62006 分片大小配置错误) | crm-file |
| 63001~63999 | 数据字典(63001 参数非法 / 63002 分组编码已存在 / 63003 内置字典保护 / 63004 分组下仍有字典项 / 63005 分组不存在或已停用 / 63006 字典编码组内重复 / 63007 字典值同组重复 / 63008 身份字段只读 / 63009 字典项被引用 / 63010 字典项不存在 / 63011 分组不存在 / 63012 仅启用项可设默认) | crm-dict |
| 64001~64999 | 公海池配置(64001 参数非法 / 64002 池名重复 / 64003 部门已有公海池 / 64004 池不存在 / 64005 池内仍有活跃线索拒删 / 64006 池成员非法 / 64007 区域不存在) | crm-rule |
| 65001~65999 | 线索业务(65001 线索无效 / 65002 线索不存在 / 65003 起始态不允许迁移 / 65004 非领取人 / 65005 领取规则拒绝 / 65006 日领取上限 / 65007 持有上限 / 65008 转商机失败 / 65009 已转商机不可删改 / 65010 乐观锁 CAS 冲突) | crm-lead |
## 8. 异常与断言规范
@ -302,3 +308,50 @@ crm:
upload-session-ttl: 24h
cleanup-cron: 0 0 3 * * ?
```
## 14. 线索模块(crm-lead)
线索全生命周期业务域:7 状态机 + 四视图 + 领取/分配/反馈/转商机/释放,以及超时回收与失效两个定时任务。领域术语与决策见 `crm-lead/CONTEXT.md`,关键决策见 ADR-0018~0022。
### 依赖方向
单向 `crm-lead → crm-rule → (crm-auth / crm-dict)`;**公海池实体归 crm-rule,本模块只消费**。crm-lead 另依赖 crm-preference(列偏好,单向,crm-preference 不得反向引用,见 §16)。
### 接口与能力
- HTTP(`/api/lead/**`):四视图分页 `POST /page`、详情 `GET /detail`、历史时间线 `GET /history`,以及领取/分配/反馈/转商机/释放/激活/关注等命令。
- 四视图(`viewType`,取值域为 `LeadViewType` 枚举):`PUBLIC_POOL`(公海,status=待领取)/ `MY_LEAD`(我的线索,owner=当前用户)/ `MY_FOLLOW`(我的关注)/ `MANAGE`(线索管理,@DataScope 部门天花板);空/非法值回落 `MANAGE`
- 批量命令**非原子、逐条 CAS、部分成功**:返回 `BatchResult`(成功 N / 失败 M + 结构化失败原因 `LeadBatchFailReason`)。
### 内部模块(读写分离,ADR-0022)
`LeadServiceImpl` 已按「深度」拆为三个深模块 + 写侧命令编排:
| 深模块 | 藏起的机制 | 契约要点 |
| --- | --- | --- |
| `LeadTransition` | 7 状态迁移 + 统一 `guard`(起始态/持有人)+ 状态 CAS 乐观锁 | **对 crm-rule/crm-auth 零依赖**;声明式 `allowedFromStatuses()`/`requiresOwner()` |
| `LeadHistoryRecorder` | 组装 `LeadHistory` + kv 明细序列化 JSON + 落库 | 单方法 `record(...)`,写侧与迁移侧共用;空 kv 存 null |
| `LeadViewQuery` | 四视图分页 + 详情 + 历史时间线 + 展示字段拼装 | 独占读依赖;自持 `LeadMapper``selectPage` |
- **状态 CAS**:所有写状态的 `UPDATE` 一律带 `AND status IN (合法起始态)`,行数=0 即判「已被他人操作」(ADR-0021)。守卫在 CAS 之上给友好前置(非法态 65003 / 非持有人 65004)。
- **转商机端口** `OpportunityCreationPort`:商机模块尚不存在,本模块只定接口;首个实现须与 crm-lead 同库共享事务(ADR-0020)。
### 定时任务
超时回收(RECYCLE)与失效(EXPIRE)两个任务,执行前校验 `ScheduledTaskProperties.owner`;失效任务先于回收任务运行(ADR-0019)。
## 15. 公海池配置模块(crm-rule)
线索公海池的配置域:公海池实体(归属部门 + 省/市/区)、人员(负责人/协作人/成员)、领取规则、超时回收/失效/领取上限配置、区域字典。领域术语见 `crm-rule/CONTEXT.md`
- **依赖方向**:单向 `crm-rule → (crm-auth / crm-dict)`,**不得反向依赖 crm-lead**——公海池不知道线索存在。公海池表复用 crm-base 数据范围模块。
- HTTP(`/api/rule/**`):公海池管理 `/api/rule/pool`、区域查询 `/api/rule/region`
- 对外读服务:`ISysRegionService`(省市 code→name,供 crm-lead 展示拼装消费)。
## 16. 列偏好模块(crm-preference)
平台级用户列表列偏好(用户级列显隐 + 拖拽排序),与 crm-dict 平行的通用模块。契约仅 `get / save` 两法。领域术语见 `crm-preference/CONTEXT.md`
- **依赖方向**:只依赖 crm-base。业务方(如 crm-lead)**单向**依赖它;`scope_key` 由业务方约定的稳定 code(如 `lead.public_pool`),crm-preference 仅作不透明字符串存储、不持有字段清单语义。
- **每菜单独立**:线索域有 4 个 scope(公海 / 我的线索 / 我的关注 / 线索管理),互不同步。候选列清单归业务方定义,本模块只存用户勾选与排序结果(`visible_keys` / `column_order`)。
- HTTP:`/api/preference/**`。

BIN
__pycache__/mcp_call.cpython-313.pyc

Binary file not shown.

2
bruno-sync.config.json

@ -1,7 +1,7 @@
{
"scan": {
"controllerStyle": "spring",
"sourceRoots": ["crm-auth/src/main/java", "crm-file/src/main/java"]
"sourceRoots": ["crm-auth/src/main/java", "crm-file/src/main/java", "crm-lead/src/main/java", "crm-rule/src/main/java", "crm-preference/src/main/java"]
},
"urlPattern": "/api/{module}/{resource}",
"grouping": "tag",

53
crm-base/src/main/java/com/crm/base/domain/result/BatchResult.java

@ -0,0 +1,53 @@
package com.crm.base.domain.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 通用批量操作结果业务无关骨架
* <p>非原子批量操作逐条执行部分成功的统一返回结构装成功/失败计数与失败明细
* 失败明细 {@code F} 的结构由各业务域自持 crm-lead LeadBatchFailItem
* crm-rule PoolBatchFailItem本类对其内部结构无感知从而保持 crm-base 非业务纯净</p>
*
* @param <F> 失败明细项类型业务域自定义
*/
@Data
public class BatchResult<F> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 处理总数(successCount + failCount) */
@Schema(description = "处理总数")
private int total;
/** 成功条数 */
@Schema(description = "成功条数")
private int successCount;
/** 失败条数 */
@Schema(description = "失败条数")
private int failCount;
/** 失败明细列表(结构由业务域定义) */
@Schema(description = "失败明细列表")
private List<F> failures = new ArrayList<>();
/** 记一条成功。 */
public void addSuccess() {
this.total++;
this.successCount++;
}
/** 记一条失败,并收集其明细。 */
public void addFailure(F item) {
this.total++;
this.failCount++;
this.failures.add(item);
}
}

60
crm-base/src/test/java/com/crm/base/domain/result/BatchResultTest.java

@ -0,0 +1,60 @@
package com.crm.base.domain.result;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link BatchResult} 单元测试计数与失败明细收集正确业务无关骨架
*/
@DisplayName("BatchResult 批量结果骨架")
class BatchResultTest {
@Test
@DisplayName("新建时计数为 0、failures 非空可用")
void newInstance_zeroCounts_emptyFailures() {
BatchResult<String> r = new BatchResult<>();
assertThat(r.getTotal()).isZero();
assertThat(r.getSuccessCount()).isZero();
assertThat(r.getFailCount()).isZero();
assertThat(r.getFailures()).isNotNull().isEmpty();
}
@Test
@DisplayName("addSuccess 累加 total 与 successCount")
void addSuccess_incrementsTotalAndSuccess() {
BatchResult<String> r = new BatchResult<>();
r.addSuccess();
r.addSuccess();
assertThat(r.getTotal()).isEqualTo(2);
assertThat(r.getSuccessCount()).isEqualTo(2);
assertThat(r.getFailCount()).isZero();
assertThat(r.getFailures()).isEmpty();
}
@Test
@DisplayName("addFailure 累加 total 与 failCount,并收集明细")
void addFailure_incrementsTotalAndFail_collectsItem() {
BatchResult<String> r = new BatchResult<>();
r.addFailure("boom-1");
r.addFailure("boom-2");
assertThat(r.getTotal()).isEqualTo(2);
assertThat(r.getSuccessCount()).isZero();
assertThat(r.getFailCount()).isEqualTo(2);
assertThat(r.getFailures()).containsExactly("boom-1", "boom-2");
}
@Test
@DisplayName("混合成功与失败:total 为两者之和")
void mixed_totalIsSum() {
BatchResult<String> r = new BatchResult<>();
r.addSuccess();
r.addFailure("boom");
r.addSuccess();
assertThat(r.getTotal()).isEqualTo(3);
assertThat(r.getSuccessCount()).isEqualTo(2);
assertThat(r.getFailCount()).isEqualTo(1);
assertThat(r.getFailures()).containsExactly("boom");
}
}

20
crm-lead/CONTEXT.md

@ -87,3 +87,23 @@ _Avoid_: 变更记录、审计表、log
**转商机端口**:
线索转化为商机的出站契约 `OpportunityCreationPort`(商机模块尚不存在,本模块只定接口)。触发前置 = `status IN (已领取, 跟进中)`,反馈情况不参与判定。幂等 = 状态终态保护 + 商机侧 `UNIQUE(source_lead_id)`。**事务前提:首个实现必须与 crm-lead 同库共享事务**(本地事务性回滚);若未来商机独立部署,转商机需重设计为最终一致 + 补偿。
_Avoid_: 商机接口、转化服务、OpportunityService
**状态机守卫**:
状态迁移的统一前置校验(ADR-0022 候选 1),收在 `LeadTransition` 内的 `guard`(原散落在 service 与 transition 两处的 19 处判定)。每条 `TransitionCmd` 自带声明 `allowedFromStatuses()`(合法起始态集合)与 `requiresOwner()`(是否要求操作人=领取人,纯代码常量、不建表);`execute` 分派前统一跑一次,在 ADR-0021 的 CAS 之上给出友好前置——非法起始态抛 `CODE_STATUS_NOT_ALLOWED`、非领取人抛 `CODE_NOT_OWNER`。「起始态 × 命令内容」的组合子规则(作废+反馈无效、作废自环换 owner)仍留在各 `applyXxx`。ADMIN_ONLY 池规则、转商机端口调用时序等要查 crm-rule/port 的前置留在 `LeadServiceImpl`,守住 `LeadTransition` 对 crm-rule/crm-auth 的零依赖契约。
_Avoid_: 前置校验、参数校验、状态检查(要点是「声明式起始态集合 + 一处 guard」)
**历史记录器**:
`LeadHistoryRecorder`——线索模块内共用的深模块(ADR-0022 候选 2),单方法 `record(leadId, type, userId, detailKv…)` 藏起「组装 `LeadHistory` + kv 明细序列化为 JSON + 落库」整条机制,供 `LeadServiceImpl`(创建/编辑)与 `LeadTransition`(状态迁移)共用,消除两处逐字重复的 writeHistory/buildDetail。**空 kv 存 `null`**(保持历史行为,不写 `{}`);序列化失败告警并存 null。仅依赖 crm-lead 内部 mapper/实体/枚举,注入 `LeadTransition` 不破坏其零依赖契约。
_Avoid_: 日志记录器、审计器、logger(写的是「操作日志」表,不是应用日志)
**线索读侧**:
`LeadViewQuery`——与写侧命令编排分离的读模块(ADR-0022 候选 3),吃下四视图分页(PUBLIC_POOL / MY_LEAD / MY_FOLLOW / MANAGE)、详情、历史时间线,以及展示字段拼装(省市 code→name、关注总数、当前用户是否关注)。**独占读依赖**:`sysRegionService`(省市名,crm-rule)、`leadHistoryMapper`(历史读)、`leadFollowMapper`(关注读,与写侧共享同一 mapper bean);自持 `LeadMapper``selectPage`(不再借基类 `this.page()`)。`LeadServiceImpl` 的 `pageLeads`/`getLeadDetail`/`listHistory` 瘦成一行委派,controller 仍只认 `ILeadService` 契约(不直接碰读模块)。拆后 `LeadServiceImpl` 只剩写侧命令编排(创建/编辑/删除、领取/分配/反馈/转商机/释放/激活、关注写)。
_Avoid_: 查询服务、QueryService、read model(要点是「四视图读 + 展示拼装」一块进一个深模块)
**批量结果**:
批量操作(批量领取/分配/释放/激活/删除)的统一返回体,通用骨架 `BatchResult<F>` 落 crm-base(total/successCount/failCount/failures,对失败项内部结构无感知),线索域失败项 `LeadBatchFailItem`(leadId + `LeadBatchFailReason` + message)落 crm-lead。批量**非原子、逐条 CAS、部分成功**:逐条套单条守卫,行数=0 或上限校验不过记一条失败,不拖垮整批。失败原因是语义枚举 `LeadBatchFailReason`(ALREADY_CONVERTED / CONCURRENT_MODIFIED / OVER_HOLD_LIMIT / OVER_DAILY_LIMIT / STATUS_NOT_ALLOWED / NOT_OWNER,各带 code 回指 ResultCode 65xxx),供前端按类型聚合展示「成功 N 条 / 失败 M 条(各类型明细)」。
_Avoid_: 批量响应、原子批处理、事务批量(要点是「非原子部分成功 + 结构化失败原因」)
**视图统计**:
四视图顶部统计卡片的数据来源 `LeadStatsDTO`(total / claimed / converted / todayNew / undistributed)。**统计口径 = 当前视图数据集口径,非全库**——`/stats` 与 `/page` 吃完全相同的 viewType + 筛选 + `@DataScope` 部门天花板,只把「取一页」换成「按 status 分组计数」。`claimed`(「已被领取」,展示文案前端渲染)= status IN(已领取, 跟进中) 的并集。卡片可点击下钻:把该卡状态条件塞进 `LeadPageParam.statusIn`(单值 status 已升级为多值列表以表达「已被领取」这种复合状态)再调 `/page`
_Avoid_: 全局统计、看板指标、汇总(要点是「口径随视图 + 复合状态用 statusIn 多值下钻」)

7
crm-lead/src/main/java/com/crm/lead/constant/LeadConstants.java

@ -33,7 +33,12 @@ public interface LeadConstants {
String DICT_GROUP_PRODUCT = "lead.product";
String DICT_GROUP_SCENE = "lead.scene";
/*-------- 列偏好 scopeKey(对齐 crm-preference)---------*/
/*-------- 列偏好 scopeKey预留尚未接线---------
* 线索四视图列偏好的作用范围键每菜单独立 crm-preference/CONTEXT.md scope
* scope_key 由业务方crm-lead约定稳定 codecrm-preference 仅作不透明字符串存储不持有语义
* 消费点线索列表接入列偏好时的 crm-lead controller尚未实现
* 依赖方向crm-lead crm-preference 单向 crm-preference 不得反向引用本常量否则成环
*/
String SCOPE_PUBLIC_POOL = "lead.public_pool";
String SCOPE_MY_LEAD = "lead.my_lead";

50
crm-lead/src/main/java/com/crm/lead/controller/LeadController.java

@ -2,9 +2,12 @@ package com.crm.lead.controller;
import com.crm.base.domain.result.PageResult;
import com.crm.base.domain.result.Result;
import com.crm.base.domain.result.BatchResult;
import com.crm.lead.domain.dto.LeadDTO;
import com.crm.lead.domain.dto.LeadBatchFailItem;
import com.crm.lead.domain.dto.LeadFeedbackDTO;
import com.crm.lead.domain.dto.LeadHistoryDTO;
import com.crm.lead.domain.dto.LeadStatsDTO;
import com.crm.lead.domain.param.ConvertOpportunityParam;
import com.crm.lead.domain.param.LeadPageParam;
import com.crm.lead.service.ILeadService;
@ -39,7 +42,6 @@ public class LeadController {
public Result<PageResult<LeadDTO>> page(LeadPageParam param) {
return Result.success(leadService.pageLeads(param));
}
@Operation(summary = "线索详情")
@GetMapping("/detail")
public Result<LeadDTO> detail(@RequestParam("id") Long id) {
@ -151,4 +153,50 @@ public class LeadController {
leadService.unfollowLead(id);
return Result.success();
}
@Operation(summary = "视图统计卡片(与 /page 同套筛选口径,口径随视图)")
@PostMapping("/stats")
public Result<LeadStatsDTO> stats(LeadPageParam param) {
return Result.success(leadService.countStats(param));
}
// ==================== 批量操作(非原子、逐条 CAS、部分成功,ADR-0023) ====================
@Operation(summary = "批量领取")
@PostMapping("/claim-batch")
public Result<BatchResult<LeadBatchFailItem>> claimBatch(@RequestParam("ids") List<Long> ids) {
return Result.success(leadService.claimBatch(ids));
}
@Operation(summary = "批量分配到池(未分发→待领取)")
@PostMapping("/assign-pool-batch")
public Result<BatchResult<LeadBatchFailItem>> assignToPoolBatch(@RequestParam("ids") List<Long> ids,
@RequestParam("poolId") Long poolId) {
return Result.success(leadService.assignToPoolBatch(ids, poolId));
}
@Operation(summary = "批量分配到销售(统一分配给同一销售)")
@PostMapping("/assign-user-batch")
public Result<BatchResult<LeadBatchFailItem>> assignToUserBatch(@RequestParam("ids") List<Long> ids,
@RequestParam("userId") Long userId) {
return Result.success(leadService.assignToUserBatch(ids, userId));
}
@Operation(summary = "批量释放(已领取/跟进中→待领取)")
@PostMapping("/release-batch")
public Result<BatchResult<LeadBatchFailItem>> releaseBatch(@RequestParam("ids") List<Long> ids) {
return Result.success(leadService.releaseBatch(ids));
}
@Operation(summary = "批量激活(过期失效→恢复失效前态)")
@PostMapping("/activate-batch")
public Result<BatchResult<LeadBatchFailItem>> activateBatch(@RequestParam("ids") List<Long> ids) {
return Result.success(leadService.activateBatch(ids));
}
@Operation(summary = "批量删除(已转商机禁止删除,记失败)")
@PostMapping("/delete-batch")
public Result<BatchResult<LeadBatchFailItem>> deleteBatch(@RequestParam("ids") List<Long> ids) {
return Result.success(leadService.deleteBatch(ids));
}
}

31
crm-lead/src/main/java/com/crm/lead/domain/dto/LeadBatchFailItem.java

@ -0,0 +1,31 @@
package com.crm.lead.domain.dto;
import com.crm.lead.domain.enums.LeadBatchFailReason;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 线索批量操作失败明细项
* <p>作为 {@code BatchResult<LeadBatchFailItem>} 的失败元素装单条失败的线索 id
* 结构化失败原因与人类可读文案供前端按 {@link LeadBatchFailReason} 分类聚合展示</p>
*/
@Data
public class LeadBatchFailItem {
@Schema(description = "失败的线索 id")
private Long id;
@Schema(description = "失败原因(语义枚举)")
private LeadBatchFailReason reason;
@Schema(description = "失败文案")
private String message;
public static LeadBatchFailItem of(Long id, LeadBatchFailReason reason, String message) {
LeadBatchFailItem item = new LeadBatchFailItem();
item.setId(id);
item.setReason(reason);
item.setMessage(message);
return item;
}
}

28
crm-lead/src/main/java/com/crm/lead/domain/dto/LeadStatsDTO.java

@ -0,0 +1,28 @@
package com.crm.lead.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 线索视图统计卡片ADR-0023 D3
* <p>口径随当前视图 {@code /page} 吃同一套 viewType + 筛选 + {@code @DataScope} 部门天花板
* 只把取一页换成 status 分组计数接口通用全量返回 5 个计数前端按视图渲染所需卡片</p>
*/
@Data
public class LeadStatsDTO {
@Schema(description = "线索总量")
private int total;
@Schema(description = "已被领取(status IN 已领取/跟进中)")
private int claimed;
@Schema(description = "已转商机")
private int converted;
@Schema(description = "今日新增")
private int todayNew;
@Schema(description = "未分发")
private int undistributed;
}

50
crm-lead/src/main/java/com/crm/lead/domain/enums/LeadBatchFailReason.java

@ -0,0 +1,50 @@
package com.crm.lead.domain.enums;
import com.crm.lead.constant.LeadConstants;
import lombok.Getter;
/**
* 线索批量操作失败原因语义枚举
* <p>批量操作逐条委派单条流转单条失败以 {@code BusinessErrorException(code, msg)} 抛出
* 本枚举按 {@code code} 把错误码翻译成前端可分类聚合的失败原因code 回指
* {@link LeadConstants} 65xxx 错误码保持与单条流转口径一致</p>
*/
@Getter
public enum LeadBatchFailReason {
/** 状态不允许该操作(非法起始态) */
STATUS_NOT_ALLOWED(LeadConstants.CODE_STATUS_NOT_ALLOWED),
/** 非持有人 */
NOT_OWNER(LeadConstants.CODE_NOT_OWNER),
/** 领取规则拒绝(不在可领取范围) */
CLAIM_RULE_DENIED(LeadConstants.CODE_CLAIM_RULE_DENIED),
/** 超过每日领取上限 */
OVER_DAILY_LIMIT(LeadConstants.CODE_DAILY_CLAIM_EXCEEDED),
/** 超过持有上限 */
OVER_HOLD_LIMIT(LeadConstants.CODE_HOLD_LIMIT_EXCEEDED),
/** 已转商机,不可操作 */
ALREADY_CONVERTED(LeadConstants.CODE_LEAD_CONVERTED),
/** 并发冲突(状态 CAS 行数 0) */
CONCURRENT_MODIFIED(LeadConstants.CODE_CAS_FAIL),
/** 未归类失败(兜底) */
UNKNOWN(-1);
/** 对应的 ResultCode(65xxx);UNKNOWN 为 -1。 */
private final int code;
LeadBatchFailReason(int code) {
this.code = code;
}
/** 按错误码查找失败原因,未命中返回 {@link #UNKNOWN}。 */
public static LeadBatchFailReason fromCode(Integer code) {
if (code != null) {
for (LeadBatchFailReason reason : values()) {
if (reason.code == code) {
return reason;
}
}
}
return UNKNOWN;
}
}

39
crm-lead/src/main/java/com/crm/lead/domain/enums/LeadViewType.java

@ -0,0 +1,39 @@
package com.crm.lead.domain.enums;
import cn.hutool.core.util.StrUtil;
/**
* 线索列表视图类型{@link com.crm.lead.domain.param.LeadPageParam#getViewType()} 的取值域
* <p>四视图区分分页查询逻辑
* <ul>
* <li>{@link #PUBLIC_POOL} 公海status=PENDING + 当前用户部门集合的池</li>
* <li>{@link #MY_LEAD} 我的线索owner_user_id=当前用户</li>
* <li>{@link #MY_FOLLOW} 我的关注id IN lead_follow WHERE user_id=当前用户</li>
* <li>{@link #MANAGE} 线索管理全部线索 @DataScope 部门天花板过滤</li>
* </ul>
* 前端以枚举名大写作为 {@code viewType} 传入/未知值一律回落到 {@link #MANAGE}</p>
*/
public enum LeadViewType {
PUBLIC_POOL,
MY_LEAD,
MY_FOLLOW,
MANAGE;
/** 列表视图默认档:无 viewType 或非法值时按「线索管理」处理(@DataScope 兜底部门天花板)。 */
public static final LeadViewType DEFAULT = MANAGE;
/**
* 把前端传入的字符串解析为视图类型空白或无法识别的值一律回落到 {@link #DEFAULT}
*/
public static LeadViewType fromValue(String value) {
if (StrUtil.isBlank(value)) {
return DEFAULT;
}
for (LeadViewType type : values()) {
if (type.name().equals(value)) {
return type;
}
}
return DEFAULT;
}
}

8
crm-lead/src/main/java/com/crm/lead/domain/param/LeadPageParam.java

@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 线索分页查询参数
* <p>四视图通过 {@code viewType} 区分查询逻辑
@ -19,11 +21,11 @@ import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
public class LeadPageParam extends BaseParam {
@Schema(description = "视图类型:PUBLIC_POOL / MY_LEAD / MY_FOLLOW / MANAGE")
@Schema(description = "视图类型(取值见 LeadViewType:PUBLIC_POOL / MY_LEAD / MY_FOLLOW / MANAGE,空/非法值回落 MANAGE)")
private String viewType;
@Schema(description = "线索状态筛选")
private Integer status;
@Schema(description = "线索状态筛选(多值,支撑复合卡片下钻:如「已被领取」传 [3,4])")
private List<Integer> statusIn;
@Schema(description = "公海池ID筛选")
private Long poolId;

27
crm-lead/src/main/java/com/crm/lead/history/LeadHistoryRecorder.java

@ -0,0 +1,27 @@
package com.crm.lead.history;
import com.crm.lead.domain.enums.HistoryType;
/**
* 线索操作历史记录器线索模块内共用的深模块
*
* <p>组装 {@code LeadHistory} 实体 + kv 明细序列化为 JSON + 落库整条机制藏在一个方法后面
* {@code LeadServiceImpl}创建/编辑 {@code LeadTransition}状态迁移共用
* 消除两处逐字重复的 writeHistory/buildDetail</p>
*
* <p>仅依赖 crm-lead 内部的 mapper / 实体 / 枚举不碰 crm-rule / crm-auth
* 因而注入 {@code LeadTransition} 不破坏其零依赖契约</p>
*/
public interface LeadHistoryRecorder {
/**
* 记一条线索操作历史
*
* @param leadId 线索 ID
* @param type 操作类型
* @param userId 操作人 ID系统触发的回收/失效传 0L
* @param detailKv 明细键值对k1, v1, k2, v2 内部序列化为 JSON detail
* 为空时 detail {@code null}保持历史行为
*/
void record(Long leadId, HistoryType type, Long userId, Object... detailKv);
}

54
crm-lead/src/main/java/com/crm/lead/history/impl/LeadHistoryRecorderImpl.java

@ -0,0 +1,54 @@
package com.crm.lead.history.impl;
import com.crm.lead.domain.entity.LeadHistory;
import com.crm.lead.domain.enums.HistoryType;
import com.crm.lead.history.LeadHistoryRecorder;
import com.crm.lead.mapper.LeadHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* {@link LeadHistoryRecorder} 默认实现kv JSON LeadHistory insert
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LeadHistoryRecorderImpl implements LeadHistoryRecorder {
private final LeadHistoryMapper leadHistoryMapper;
private final ObjectMapper objectMapper;
@Override
public void record(Long leadId, HistoryType type, Long userId, Object... detailKv) {
LeadHistory history = new LeadHistory();
history.setLeadId(leadId);
history.setOpType(type.name());
history.setOpTime(LocalDateTime.now());
history.setOpUserId(userId);
history.setDetail(buildDetail(detailKv));
leadHistoryMapper.insert(history);
}
/** 空 kv 存 null(保持历史行为);非空则序列化为 JSON,失败告警并存 null。 */
private String buildDetail(Object... kv) {
if (kv == null || kv.length == 0) {
return null;
}
try {
Map<String, Object> map = new LinkedHashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) {
map.put(String.valueOf(kv[i]), kv[i + 1]);
}
return objectMapper.writeValueAsString(map);
} catch (Exception e) {
log.warn("history detail 序列化失败", e);
return null;
}
}
}

30
crm-lead/src/main/java/com/crm/lead/query/LeadViewQuery.java

@ -0,0 +1,30 @@
package com.crm.lead.query;
import com.crm.base.domain.result.PageResult;
import com.crm.lead.domain.dto.LeadDTO;
import com.crm.lead.domain.dto.LeadHistoryDTO;
import com.crm.lead.domain.dto.LeadStatsDTO;
import com.crm.lead.domain.param.LeadPageParam;
import java.util.List;
/**
* 线索读侧四视图分页详情历史时间线以及展示字段拼装省市名 / 关注数 / 是否关注
* <p>与写侧{@code LeadServiceImpl} 的命令编排分离独占读依赖
* {@code sysRegionService}省市名{@code leadHistoryMapper}历史读
* {@code leadFollowMapper}关注读</p>
*/
public interface LeadViewQuery {
/** 四视图分页(PUBLIC_POOL / MY_LEAD / MY_FOLLOW / MANAGE),并回填展示字段。 */
PageResult<LeadDTO> pageLeads(LeadPageParam param);
/** 视图统计卡片:与 {@link #pageLeads} 同一套 viewType+筛选+@DataScope,按 status 分组计数(ADR-0023 D3)。 */
LeadStatsDTO countStats(LeadPageParam param);
/** 单条详情,并回填展示字段。 */
LeadDTO getLeadDetail(Long id);
/** 历史时间线(仅展示 {@code HistoryType.VISIBLE_TYPES}),按操作时间倒序。 */
List<LeadHistoryDTO> listHistory(Long leadId);
}

203
crm-lead/src/main/java/com/crm/lead/query/impl/LeadViewQueryImpl.java

@ -0,0 +1,203 @@
package com.crm.lead.query.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.domain.result.PageResult;
import com.crm.base.security.SecurityUtils;
import com.crm.base.utils.PageConverter;
import com.crm.lead.constant.LeadConstants;
import com.crm.lead.domain.dto.LeadDTO;
import com.crm.lead.domain.dto.LeadHistoryDTO;
import com.crm.lead.domain.dto.LeadStatsDTO;
import com.crm.lead.domain.entity.Lead;
import com.crm.lead.domain.entity.LeadFollow;
import com.crm.lead.domain.entity.LeadHistory;
import com.crm.lead.domain.enums.HistoryType;
import com.crm.lead.domain.enums.LeadViewType;
import com.crm.lead.domain.param.LeadPageParam;
import com.crm.lead.mapper.LeadFollowMapper;
import com.crm.lead.mapper.LeadHistoryMapper;
import com.crm.lead.mapper.LeadMapper;
import com.crm.lead.query.LeadViewQuery;
import com.crm.rule.domain.dto.SysRegionDTO;
import com.crm.rule.service.ISysRegionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* {@link LeadViewQuery} 默认实现读侧独占 {@link LeadMapper} / {@link LeadHistoryMapper} /
* {@link LeadFollowMapper}/ {@link ISysRegionService}
*/
@Component
@RequiredArgsConstructor
public class LeadViewQueryImpl implements LeadViewQuery {
private final LeadMapper leadMapper;
private final LeadHistoryMapper leadHistoryMapper;
private final LeadFollowMapper leadFollowMapper;
private final ISysRegionService sysRegionService;
@Override
public PageResult<LeadDTO> pageLeads(LeadPageParam param) {
Long currentUserId = getCurrentUserId();
LambdaQueryWrapper<Lead> wrapper = buildViewWrapper(param, currentUserId)
.orderByDesc(Lead::getCreateTime);
Page<Lead> page = leadMapper.selectPage(PageConverter.toMpPage(param), wrapper);
PageResult<LeadDTO> result = new PageResult<>(page).convert(LeadDTO::fromEntity);
fillDisplayFields(result.getContent(), currentUserId);
return result;
}
@Override
public LeadStatsDTO countStats(LeadPageParam param) {
Long currentUserId = getCurrentUserId();
// 与 pageLeads 同一套视图+筛选+@DataScope(经本 wrapper 自动追加),仅去分页、改分组计数。
List<Lead> rows = leadMapper.selectList(buildViewWrapper(param, currentUserId));
LeadStatsDTO stats = new LeadStatsDTO();
LocalDate today = LocalDate.now();
for (Lead lead : rows) {
stats.setTotal(stats.getTotal() + 1);
Integer st = lead.getStatus();
if (st != null) {
if (st == LeadConstants.STATUS_CLAIMED || st == LeadConstants.STATUS_FOLLOWING) {
stats.setClaimed(stats.getClaimed() + 1);
} else if (st == LeadConstants.STATUS_CONVERTED) {
stats.setConverted(stats.getConverted() + 1);
} else if (st == LeadConstants.STATUS_UNDISTRIBUTED) {
stats.setUndistributed(stats.getUndistributed() + 1);
}
}
if (lead.getCreateTime() != null && today.equals(lead.getCreateTime().toLocalDate())) {
stats.setTodayNew(stats.getTodayNew() + 1);
}
}
return stats;
}
/**
* 构造四视图共享的查询条件viewType 数据集 + 公共筛选不含排序/分页
* <p>{@link #pageLeads} {@link #countStats} 复用确保统计口径 = 列表口径
* {@code @DataScope}{@code Lead} 实体上的注解 MyBatis 拦截器对本 wrapper 自动
* 追加部门天花板两者一致</p>
*/
private LambdaQueryWrapper<Lead> buildViewWrapper(LeadPageParam param, Long currentUserId) {
LambdaQueryWrapper<Lead> wrapper = new LambdaQueryWrapper<>();
switch (LeadViewType.fromValue(param.getViewType())) {
case PUBLIC_POOL -> wrapper.eq(Lead::getStatus, LeadConstants.STATUS_PENDING);
case MY_LEAD -> wrapper.eq(Lead::getOwnerUserId, currentUserId);
case MY_FOLLOW -> wrapper.apply(
"id IN (SELECT lead_id FROM lead_follow WHERE user_id = {0})", currentUserId);
case MANAGE -> { /* no extra filter, @DataScope handles dept ceiling */ }
}
// 公共筛选(status 多值:支撑复合卡片下钻)
wrapper.in(CollUtil.isNotEmpty(param.getStatusIn()), Lead::getStatus, param.getStatusIn())
.eq(param.getPoolId() != null, Lead::getPoolId, param.getPoolId())
.eq(param.getDeptId() != null, Lead::getDeptId, param.getDeptId())
.eq(param.getIsUrgent() != null, Lead::getIsUrgent, param.getIsUrgent())
.eq(param.getFeedbackStatus() != null, Lead::getFeedbackStatus, param.getFeedbackStatus())
.eq(StrUtil.isNotBlank(param.getChannelCode()), Lead::getChannelCode, param.getChannelCode())
.eq(StrUtil.isNotBlank(param.getBrandCode()), Lead::getBrandCode, param.getBrandCode())
.eq(StrUtil.isNotBlank(param.getProductCode()), Lead::getProductCode, param.getProductCode())
.eq(StrUtil.isNotBlank(param.getProvinceCode()), Lead::getProvinceCode, param.getProvinceCode())
.like(StrUtil.isNotBlank(param.getKeyword()), Lead::getLeadName, param.getKeyword());
return wrapper;
}
@Override
public LeadDTO getLeadDetail(Long id) {
Lead lead = getLeadByIdOrThrow(id);
LeadDTO dto = LeadDTO.fromEntity(lead);
fillDisplayFields(List.of(dto), getCurrentUserId());
return dto;
}
@Override
public List<LeadHistoryDTO> listHistory(Long leadId) {
List<LeadHistory> histories = leadHistoryMapper.selectList(
new LambdaQueryWrapper<LeadHistory>()
.eq(LeadHistory::getLeadId, leadId)
.in(LeadHistory::getOpType,
HistoryType.VISIBLE_TYPES.stream()
.map(Enum::name)
.collect(Collectors.toList()))
.orderByDesc(LeadHistory::getOpTime));
return histories.stream().map(LeadHistoryDTO::fromEntity).collect(Collectors.toList());
}
// ==================== 展示字段拼装 ====================
private void fillDisplayFields(List<LeadDTO> dtos, Long currentUserId) {
if (CollUtil.isEmpty(dtos)) {
return;
}
// 省市名称
Set<String> regionCodes = new HashSet<>();
dtos.forEach(d -> {
if (StrUtil.isNotBlank(d.getProvinceCode())) regionCodes.add(d.getProvinceCode());
if (StrUtil.isNotBlank(d.getCityCode())) regionCodes.add(d.getCityCode());
});
if (!regionCodes.isEmpty()) {
Map<String, String> regionNameMap = sysRegionService.listByCodes(regionCodes).stream()
.collect(Collectors.toMap(SysRegionDTO::getCode, SysRegionDTO::getName, (a, b) -> a));
dtos.forEach(d -> {
d.setProvinceName(regionNameMap.get(d.getProvinceCode()));
d.setCityName(regionNameMap.get(d.getCityCode()));
});
}
// 关注人数 + 当前用户是否关注
List<Long> leadIds = dtos.stream().map(LeadDTO::getId)
.filter(Objects::nonNull).collect(Collectors.toList());
if (!leadIds.isEmpty()) {
// 批量查关注总数
List<Map<String, Object>> counts = leadFollowMapper.selectMaps(
new QueryWrapper<LeadFollow>()
.select("lead_id AS lead_id", "COUNT(*) AS cnt")
.in("lead_id", leadIds)
.groupBy("lead_id"));
Map<Long, Long> countMap = counts.stream().collect(Collectors.toMap(
m -> MapUtil.getLong(m, "lead_id"),
m -> MapUtil.getLong(m, "cnt")));
dtos.forEach(d -> d.setFollowCount(countMap.getOrDefault(d.getId(), 0L).intValue()));
// 当前用户是否关注
List<LeadFollow> myFollows = leadFollowMapper.selectList(
new LambdaQueryWrapper<LeadFollow>()
.in(LeadFollow::getLeadId, leadIds)
.eq(LeadFollow::getUserId, currentUserId));
Set<Long> followedIds = myFollows.stream()
.map(LeadFollow::getLeadId).collect(Collectors.toSet());
dtos.forEach(d -> d.setFollowed(followedIds.contains(d.getId())));
}
}
// ==================== 内部工具 ====================
private Lead getLeadByIdOrThrow(Long id) {
Lead lead = leadMapper.selectById(id);
if (lead == null) {
throw new BusinessErrorException(LeadConstants.CODE_LEAD_NOT_EXIST, "线索不存在或已被删除");
}
return lead;
}
private Long getCurrentUserId() {
return Long.valueOf(SecurityUtils.getRequiredUserId());
}
}

26
crm-lead/src/main/java/com/crm/lead/service/ILeadService.java

@ -1,9 +1,12 @@
package com.crm.lead.service;
import com.crm.base.domain.result.PageResult;
import com.crm.base.domain.result.BatchResult;
import com.crm.lead.domain.dto.LeadDTO;
import com.crm.lead.domain.dto.LeadBatchFailItem;
import com.crm.lead.domain.dto.LeadFeedbackDTO;
import com.crm.lead.domain.dto.LeadHistoryDTO;
import com.crm.lead.domain.dto.LeadStatsDTO;
import com.crm.lead.domain.param.LeadPageParam;
import com.crm.lead.domain.param.ConvertOpportunityParam;
@ -27,6 +30,9 @@ public interface ILeadService {
/** 历史记录 Tab(只展示 5 种:CLAIM/RELEASE/FEEDBACK/CONVERT/EDIT) */
List<LeadHistoryDTO> listHistory(Long leadId);
/** 视图统计卡片(与 {@link #pageLeads} 同一套筛选口径,口径随视图,ADR-0023) */
LeadStatsDTO countStats(LeadPageParam param);
// ==================== 创建/编辑/删除 ====================
/** 新建线索(管理员可指定池或不指定=未分发;销售新增自动带池,可勾"领取") */
@ -71,4 +77,24 @@ public interface ILeadService {
/** 取消关注 */
void unfollowLead(Long id);
// ==================== 批量操作(非原子、逐条 CAS、部分成功,ADR-0023) ====================
/** 批量领取 */
BatchResult<LeadBatchFailItem> claimBatch(List<Long> ids);
/** 批量分配到池 */
BatchResult<LeadBatchFailItem> assignToPoolBatch(List<Long> ids, Long poolId);
/** 批量分配到销售(统一分配给同一 userId) */
BatchResult<LeadBatchFailItem> assignToUserBatch(List<Long> ids, Long userId);
/** 批量释放 */
BatchResult<LeadBatchFailItem> releaseBatch(List<Long> ids);
/** 批量激活 */
BatchResult<LeadBatchFailItem> activateBatch(List<Long> ids);
/** 批量删除(已转商机为删除禁区,记失败) */
BatchResult<LeadBatchFailItem> deleteBatch(List<Long> ids);
}

246
crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java

@ -1,25 +1,24 @@
package com.crm.lead.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.domain.entity.SysDept;
import com.crm.auth.service.IAuthUserService;
import com.crm.auth.service.ISysDeptService;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.domain.result.PageResult;
import com.crm.base.domain.result.BatchResult;
import com.crm.base.security.SecurityUtils;
import com.crm.base.service.impl.BaseServiceImpl;
import com.crm.base.utils.PageConverter;
import com.crm.lead.constant.LeadConstants;
import com.crm.lead.domain.dto.LeadDTO;
import com.crm.lead.domain.dto.LeadBatchFailItem;
import com.crm.lead.domain.dto.LeadFeedbackDTO;
import com.crm.lead.domain.dto.LeadHistoryDTO;
import com.crm.lead.domain.dto.LeadStatsDTO;
import com.crm.lead.domain.entity.*;
import com.crm.lead.domain.enums.HistoryType;
import com.crm.lead.domain.enums.LeadBatchFailReason;
import com.crm.lead.domain.param.LeadPageParam;
import com.crm.lead.domain.param.ConvertOpportunityParam;
import com.crm.lead.mapper.*;
@ -32,17 +31,19 @@ import com.crm.lead.service.ILeadService;
import com.crm.rule.constant.RuleConstants;
import com.crm.rule.domain.entity.LeadPool;
import com.crm.rule.service.ILeadPoolService;
import com.crm.rule.domain.dto.SysRegionDTO;
import com.crm.rule.service.ISysRegionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.crm.lead.history.LeadHistoryRecorder;
import com.crm.lead.query.LeadViewQuery;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
@ -57,71 +58,42 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
implements ILeadService {
private final LeadFeedbackMapper leadFeedbackMapper;
private final LeadHistoryMapper leadHistoryMapper;
private final LeadFollowMapper leadFollowMapper;
private final LeadAttachmentMapper leadAttachmentMapper;
private final ILeadPoolService leadPoolService;
private final ISysRegionService sysRegionService;
private final IAuthUserService authUserService;
private final ISysDeptService sysDeptService;
private final ObjectMapper objectMapper;
private final LeadHistoryRecorder historyRecorder;
private final LeadViewQuery leadViewQuery;
private final ObjectProvider<OpportunityCreationPort> opportunityCreationPortProvider;
private final LeadTransition leadTransition;
/**
* self-injection让批量方法逐条调单条操作时走 Spring AOP 事务代理每条独立事务
* 若直接 {@code this.claimLead(id)} 会绕过代理批量退化成单事务一条失败拖垓整批沿用 AuthServiceImpl.self
*/
@Lazy
@Autowired
private ILeadService self;
// ==================== 查询 ====================
@Override
public PageResult<LeadDTO> pageLeads(LeadPageParam param) {
Long currentUserId = getCurrentUserId();
LambdaQueryWrapper<Lead> wrapper = new LambdaQueryWrapper<>();
String viewType = StrUtil.isBlank(param.getViewType()) ? "MANAGE" : param.getViewType();
switch (viewType) {
case "PUBLIC_POOL" -> wrapper.eq(Lead::getStatus, LeadConstants.STATUS_PENDING);
case "MY_LEAD" -> wrapper.eq(Lead::getOwnerUserId, currentUserId);
case "MY_FOLLOW" -> wrapper.apply(
"id IN (SELECT lead_id FROM lead_follow WHERE user_id = {0})", currentUserId);
default -> { /* MANAGE: no extra filter, @DataScope handles dept ceiling */ }
}
// 公共筛选
wrapper.eq(param.getStatus() != null, Lead::getStatus, param.getStatus())
.eq(param.getPoolId() != null, Lead::getPoolId, param.getPoolId())
.eq(param.getDeptId() != null, Lead::getDeptId, param.getDeptId())
.eq(param.getIsUrgent() != null, Lead::getIsUrgent, param.getIsUrgent())
.eq(param.getFeedbackStatus() != null, Lead::getFeedbackStatus, param.getFeedbackStatus())
.eq(StrUtil.isNotBlank(param.getChannelCode()), Lead::getChannelCode, param.getChannelCode())
.eq(StrUtil.isNotBlank(param.getBrandCode()), Lead::getBrandCode, param.getBrandCode())
.eq(StrUtil.isNotBlank(param.getProductCode()), Lead::getProductCode, param.getProductCode())
.eq(StrUtil.isNotBlank(param.getProvinceCode()), Lead::getProvinceCode, param.getProvinceCode())
.like(StrUtil.isNotBlank(param.getKeyword()), Lead::getLeadName, param.getKeyword())
.orderByDesc(Lead::getCreateTime);
Page<Lead> page = this.page(PageConverter.toMpPage(param), wrapper);
PageResult<LeadDTO> result = new PageResult<>(page).convert(LeadDTO::fromEntity);
fillDisplayFields(result.getContent(), currentUserId);
return result;
return leadViewQuery.pageLeads(param);
}
@Override
public LeadDTO getLeadDetail(Long id) {
Lead lead = getLeadByIdOrThrow(id);
LeadDTO dto = LeadDTO.fromEntity(lead);
fillDisplayFields(List.of(dto), getCurrentUserId());
return dto;
return leadViewQuery.getLeadDetail(id);
}
@Override
public List<LeadHistoryDTO> listHistory(Long leadId) {
List<LeadHistory> histories = leadHistoryMapper.selectList(
new LambdaQueryWrapper<LeadHistory>()
.eq(LeadHistory::getLeadId, leadId)
.in(LeadHistory::getOpType,
HistoryType.VISIBLE_TYPES.stream()
.map(Enum::name)
.collect(Collectors.toList()))
.orderByDesc(LeadHistory::getOpTime));
return histories.stream().map(LeadHistoryDTO::fromEntity).collect(Collectors.toList());
return leadViewQuery.listHistory(leadId);
}
@Override
public LeadStatsDTO countStats(LeadPageParam param) {
return leadViewQuery.countStats(param);
}
// ==================== 创建/编辑/删除 ====================
@ -163,8 +135,8 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
lead.setFeedbackStatus(LeadConstants.FEEDBACK_NONE);
this.save(lead);
writeHistory(lead.getId(), HistoryType.CREATE, getCurrentUserId(),
buildDetail("leadName", lead.getLeadName()));
historyRecorder.record(lead.getId(), HistoryType.CREATE, getCurrentUserId(),
"leadName", lead.getLeadName());
return lead.getId();
}
@ -204,8 +176,8 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
update.setStatusBeforeExpire(exist.getStatusBeforeExpire());
this.updateById(update);
writeHistory(exist.getId(), HistoryType.EDIT, getCurrentUserId(),
buildDetail("leadName", update.getLeadName()));
historyRecorder.record(exist.getId(), HistoryType.EDIT, getCurrentUserId(),
"leadName", update.getLeadName());
}
@Override
@ -234,9 +206,7 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
@Transactional(rollbackFor = Exception.class)
public void claimLead(Long id) {
Lead lead = getLeadByIdOrThrow(id);
if (lead.getStatus() != LeadConstants.STATUS_PENDING) {
throw new BusinessErrorException(LeadConstants.CODE_STATUS_NOT_ALLOWED, "仅待领取线索可领取");
}
// 状态守卫已下沉至 LeadTransition(声明式 guard);此处仅保留 transition 算不出的前置。
LeadPool pool = leadPoolService.getPoolByIdOrThrow(lead.getPoolId());
if (pool.getClaimRule() == RuleConstants.CLAIM_RULE_ADMIN_ONLY) {
throw new BusinessErrorException(LeadConstants.CODE_CLAIM_RULE_DENIED, "该公海池仅管理员可分配,不可自助领取");
@ -252,9 +222,7 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
@Transactional(rollbackFor = Exception.class)
public void assignToPool(Long id, Long poolId) {
Lead lead = getLeadByIdOrThrow(id);
if (lead.getStatus() != LeadConstants.STATUS_UNDISTRIBUTED) {
throw new BusinessErrorException(LeadConstants.CODE_STATUS_NOT_ALLOWED, "仅未分发线索可分配到池");
}
// 状态守卫已下沉至 LeadTransition;此处仅取池预算 expireDeadline。
LeadPool pool = leadPoolService.getPoolByIdOrThrow(poolId);
leadTransition.execute(id, new TransitionCmd.AssignToPoolCmd(
getCurrentUserId(), poolId, pool.getPoolName(),
@ -319,18 +287,8 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
public void submitFeedback(LeadFeedbackDTO dto) {
Lead lead = getLeadByIdOrThrow(dto.getLeadId());
Long userId = getCurrentUserId();
int currentStatus = lead.getStatus();
// 前置校验:状态与持有人(LeadTransition 内也会验证,此处提前给出友好提示)
if (currentStatus != LeadConstants.STATUS_CLAIMED
&& currentStatus != LeadConstants.STATUS_FOLLOWING
&& currentStatus != LeadConstants.STATUS_VOID) {
throw new BusinessErrorException(LeadConstants.CODE_STATUS_NOT_ALLOWED, "当前状态不允许提交反馈");
}
if (!userId.equals(lead.getOwnerUserId())) {
throw new BusinessErrorException(LeadConstants.CODE_NOT_OWNER, "仅持有人可提交反馈");
}
// 状态/持有人守卫已下沉至 LeadTransition;此处仅保留 transition 算不出的前置(反馈有效时查池算 recycleDeadline)。
int feedbackStatus = dto.getFeedbackStatus() == null ? 0 : dto.getFeedbackStatus();
// 反馈有效时才需要 recycleDeadline(反馈无效目标=线索作废,不设 deadline)
LocalDateTime recycleDeadline = null;
@ -396,13 +354,7 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
public void releaseLead(Long id) {
Lead lead = getLeadByIdOrThrow(id);
Long userId = getCurrentUserId();
if (lead.getStatus() != LeadConstants.STATUS_CLAIMED
&& lead.getStatus() != LeadConstants.STATUS_FOLLOWING) {
throw new BusinessErrorException(LeadConstants.CODE_STATUS_NOT_ALLOWED, "仅已领取/跟进中线索可释放");
}
if (!userId.equals(lead.getOwnerUserId())) {
throw new BusinessErrorException(LeadConstants.CODE_NOT_OWNER, "仅持有人可释放");
}
// 状态/持有人守卫已下沉至 LeadTransition。
leadTransition.execute(id, new TransitionCmd.ReleaseCmd(userId));
}
@ -410,9 +362,7 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
@Transactional(rollbackFor = Exception.class)
public void activateLead(Long id) {
Lead lead = getLeadByIdOrThrow(id);
if (lead.getStatus() != LeadConstants.STATUS_EXPIRED) {
throw new BusinessErrorException(LeadConstants.CODE_STATUS_NOT_ALLOWED, "仅过期失效线索可激活");
}
// 状态守卫已下沉至 LeadTransition;此处仅预算 deadline。
int previousStatus = lead.getStatusBeforeExpire() != null
? lead.getStatusBeforeExpire() : LeadConstants.STATUS_PENDING;
LeadPool pool = lead.getPoolId() != null
@ -460,6 +410,63 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
.eq(LeadFollow::getUserId, userId));
}
// ==================== 批量操作(非原子、逐条 CAS、部分成功,ADR-0023) ====================
@Override
public BatchResult<LeadBatchFailItem> claimBatch(List<Long> ids) {
return runBatch(ids, self::claimLead);
}
@Override
public BatchResult<LeadBatchFailItem> assignToPoolBatch(List<Long> ids, Long poolId) {
return runBatch(ids, id -> self.assignToPool(id, poolId));
}
@Override
public BatchResult<LeadBatchFailItem> assignToUserBatch(List<Long> ids, Long userId) {
return runBatch(ids, id -> self.assignToUser(id, userId));
}
@Override
public BatchResult<LeadBatchFailItem> releaseBatch(List<Long> ids) {
return runBatch(ids, self::releaseLead);
}
@Override
public BatchResult<LeadBatchFailItem> activateBatch(List<Long> ids) {
return runBatch(ids, self::activateLead);
}
@Override
public BatchResult<LeadBatchFailItem> deleteBatch(List<Long> ids) {
return runBatch(ids, self::deleteLead);
}
/**
* 批量执行器逐条通过 self 代理调单条操作各自独立事务捕获
* {@link BusinessErrorException} 并按 code 映射为 {@link LeadBatchFailReason}
* 本方法不加 {@code @Transactional}保证一条失败不污染已成功条
*/
private BatchResult<LeadBatchFailItem> runBatch(List<Long> ids, Consumer<Long> op) {
BatchResult<LeadBatchFailItem> result = new BatchResult<>();
if (ids == null || ids.isEmpty()) {
return result;
}
for (Long id : ids) {
try {
op.accept(id);
result.addSuccess();
} catch (BusinessErrorException e) {
LeadBatchFailReason reason = LeadBatchFailReason.fromCode(e.getCode());
result.addFailure(LeadBatchFailItem.of(id, reason, e.getMessage()));
} catch (Exception e) {
log.error("批量操作单条异常:leadId={}", id, e);
result.addFailure(LeadBatchFailItem.of(id, LeadBatchFailReason.UNKNOWN, "操作失败"));
}
}
return result;
}
// ==================== 内部方法 ====================
private Lead getLeadByIdOrThrow(Long id) {
@ -474,16 +481,6 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
return Long.valueOf(SecurityUtils.getRequiredUserId());
}
private void writeHistory(Long leadId, HistoryType type, Long userId, String detail) {
LeadHistory history = new LeadHistory();
history.setLeadId(leadId);
history.setOpType(type.name());
history.setOpTime(LocalDateTime.now());
history.setOpUserId(userId);
history.setDetail(detail);
leadHistoryMapper.insert(history);
}
private void validateLeadFields(LeadDTO dto) {
if (dto == null) {
throw new BusinessErrorException(LeadConstants.CODE_LEAD_INVALID, "请求参数缺失");
@ -514,61 +511,4 @@ public class LeadServiceImpl extends BaseServiceImpl<LeadMapper, Lead>
AuthUser user = authUserService.getById(userId);
return user != null ? user.getDeptId() : null;
}
private void fillDisplayFields(List<LeadDTO> dtos, Long currentUserId) {
if (CollUtil.isEmpty(dtos)) {
return;
}
// 省市名称
Set<String> regionCodes = new HashSet<>();
dtos.forEach(d -> {
if (StrUtil.isNotBlank(d.getProvinceCode())) regionCodes.add(d.getProvinceCode());
if (StrUtil.isNotBlank(d.getCityCode())) regionCodes.add(d.getCityCode());
});
if (!regionCodes.isEmpty()) {
Map<String, String> regionNameMap = sysRegionService.listByCodes(regionCodes).stream()
.collect(Collectors.toMap(SysRegionDTO::getCode, SysRegionDTO::getName, (a, b) -> a));
dtos.forEach(d -> {
d.setProvinceName(regionNameMap.get(d.getProvinceCode()));
d.setCityName(regionNameMap.get(d.getCityCode()));
});
}
// 关注人数 + 当前用户是否关注
List<Long> leadIds = dtos.stream().map(LeadDTO::getId)
.filter(Objects::nonNull).collect(Collectors.toList());
if (!leadIds.isEmpty()) {
// 批量查关注总数
List<Map<String, Object>> counts = leadFollowMapper.selectMaps(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<LeadFollow>()
.select("lead_id AS lead_id", "COUNT(*) AS cnt")
.in("lead_id", leadIds)
.groupBy("lead_id"));
Map<Long, Long> countMap = counts.stream().collect(Collectors.toMap(
m -> cn.hutool.core.map.MapUtil.getLong(m, "lead_id"),
m -> cn.hutool.core.map.MapUtil.getLong(m, "cnt")));
dtos.forEach(d -> d.setFollowCount(countMap.getOrDefault(d.getId(), 0L).intValue()));
// 当前用户是否关注
List<LeadFollow> myFollows = leadFollowMapper.selectList(
new LambdaQueryWrapper<LeadFollow>()
.in(LeadFollow::getLeadId, leadIds)
.eq(LeadFollow::getUserId, currentUserId));
Set<Long> followedIds = myFollows.stream()
.map(LeadFollow::getLeadId).collect(Collectors.toSet());
dtos.forEach(d -> d.setFollowed(followedIds.contains(d.getId())));
}
}
private String buildDetail(Object... kv) {
try {
Map<String, Object> map = new LinkedHashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) {
map.put(String.valueOf(kv[i]), kv[i + 1]);
}
return objectMapper.writeValueAsString(map);
} catch (Exception e) {
return null;
}
}
}

72
crm-lead/src/main/java/com/crm/lead/state/TransitionCmd.java

@ -1,12 +1,20 @@
package com.crm.lead.state;
import com.crm.lead.constant.LeadConstants;
import java.time.LocalDateTime;
import java.util.Set;
/**
* 线索状态迁移命令sealed 类型体系每条迁移边一个子类型
*
* <p>调用方负责预计算所有字段值快照deadline
* {@link LeadTransition} 不查外部服务保持对 crm-rule / crm-auth 零依赖</p>
*
* <p>每条命令自带守卫声明{@link #allowedFromStatuses()}合法起始态集合
* {@link #requiresOwner()}是否要求操作人==领取人{@link LeadTransition#execute}
* 分派前统一读这两项跑一次守卫ADR-0021 CAS 之上的友好前置目标态计算
* 起始态×命令内容组合子规则仍由各 apply 实现负责</p>
*/
public sealed interface TransitionCmd
permits TransitionCmd.ClaimCmd,
@ -17,13 +25,28 @@ public sealed interface TransitionCmd
TransitionCmd.ReleaseCmd,
TransitionCmd.ActivateCmd {
/** 操作人 ID——守卫做持有人校验时用。 */
Long operatorId();
/** 这条迁移边的合法起始态集合(守卫层)。 */
Set<Integer> allowedFromStatuses();
/** 这条边是否要求「操作人 == 领取人」。默认否。 */
default boolean requiresOwner() {
return false;
}
/** 领取:待领取(2) → 已领取(3) */
record ClaimCmd(
Long operatorId,
String ownerNameSnapshot,
Long ownerDeptIdSnapshot,
LocalDateTime recycleDeadline
) implements TransitionCmd {}
) implements TransitionCmd {
@Override public Set<Integer> allowedFromStatuses() {
return Set.of(LeadConstants.STATUS_PENDING);
}
}
/** 分配到公海池:未分发(1) → 待领取(2) */
record AssignToPoolCmd(
@ -33,7 +56,11 @@ public sealed interface TransitionCmd
Long deptId,
Long teamDeptId,
LocalDateTime expireDeadline
) implements TransitionCmd {}
) implements TransitionCmd {
@Override public Set<Integer> allowedFromStatuses() {
return Set.of(LeadConstants.STATUS_UNDISTRIBUTED);
}
}
/**
* 分配到销售未分发(1)/待领取(2) 已领取(3)线索作废(7) 线索作废(7)更新 owner
@ -45,7 +72,12 @@ public sealed interface TransitionCmd
String ownerNameSnapshot,
Long ownerDeptIdSnapshot,
LocalDateTime recycleDeadline
) implements TransitionCmd {}
) implements TransitionCmd {
@Override public Set<Integer> allowedFromStatuses() {
return Set.of(LeadConstants.STATUS_UNDISTRIBUTED,
LeadConstants.STATUS_PENDING, LeadConstants.STATUS_VOID);
}
}
/**
* 提交反馈已领取(3)/跟进中(4) 跟进中(4)/线索作废(7)线索作废(7) 已领取(3)作废恢复
@ -58,24 +90,50 @@ public sealed interface TransitionCmd
String productCode,
String attachmentIds,
LocalDateTime recycleDeadline
) implements TransitionCmd {}
) implements TransitionCmd {
@Override public Set<Integer> allowedFromStatuses() {
return Set.of(LeadConstants.STATUS_CLAIMED,
LeadConstants.STATUS_FOLLOWING, LeadConstants.STATUS_VOID);
}
@Override public boolean requiresOwner() {
return true;
}
}
/** 转商机:已领取(3)/跟进中(4) → 已转商机(5)(outbound port 已被 service 调通) */
record ConvertCmd(
Long operatorId,
Long opportunityId,
String opportunityName
) implements TransitionCmd {}
) implements TransitionCmd {
@Override public Set<Integer> allowedFromStatuses() {
return Set.of(LeadConstants.STATUS_CLAIMED, LeadConstants.STATUS_FOLLOWING);
}
@Override public boolean requiresOwner() {
return true;
}
}
/** 释放:已领取(3)/跟进中(4) → 待领取(2),清 owner,保留反馈快照 */
record ReleaseCmd(
Long operatorId
) implements TransitionCmd {}
) implements TransitionCmd {
@Override public Set<Integer> allowedFromStatuses() {
return Set.of(LeadConstants.STATUS_CLAIMED, LeadConstants.STATUS_FOLLOWING);
}
@Override public boolean requiresOwner() {
return true;
}
}
/** 激活:过期失效(6) → 恢复失效前态,重置回收计时 N 和失效计时 M */
record ActivateCmd(
Long operatorId,
LocalDateTime newExpireDeadline,
LocalDateTime newRecycleDeadline
) implements TransitionCmd {}
) implements TransitionCmd {
@Override public Set<Integer> allowedFromStatuses() {
return Set.of(LeadConstants.STATUS_EXPIRED);
}
}
}

119
crm-lead/src/main/java/com/crm/lead/state/impl/LeadTransitionImpl.java

@ -6,23 +6,18 @@ import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.lead.constant.LeadConstants;
import com.crm.lead.domain.entity.Lead;
import com.crm.lead.domain.entity.LeadFeedback;
import com.crm.lead.domain.entity.LeadHistory;
import com.crm.lead.domain.enums.HistoryType;
import com.crm.lead.history.LeadHistoryRecorder;
import com.crm.lead.mapper.LeadFeedbackMapper;
import com.crm.lead.mapper.LeadHistoryMapper;
import com.crm.lead.mapper.LeadMapper;
import com.crm.lead.state.LeadTransition;
import com.crm.lead.state.TransitionCmd;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.crm.lead.constant.LeadConstants.*;
@ -35,15 +30,13 @@ import static com.crm.lead.constant.LeadConstants.*;
* <p>依赖范围 {@code crm-lead} 内部 Mapper crm-rule / crm-auth 零依赖
* 快照与 deadline 由调用方预计算后通过命令对象传入</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LeadTransitionImpl implements LeadTransition {
private final LeadMapper leadMapper;
private final LeadFeedbackMapper leadFeedbackMapper;
private final LeadHistoryMapper leadHistoryMapper;
private final ObjectMapper objectMapper;
private final LeadHistoryRecorder historyRecorder;
// ==================== 单条迁移 ====================
@ -51,6 +44,7 @@ public class LeadTransitionImpl implements LeadTransition {
@Transactional(rollbackFor = Exception.class)
public void execute(Long leadId, TransitionCmd cmd) {
Lead lead = getLeadOrThrow(leadId);
guard(lead, cmd);
if (cmd instanceof TransitionCmd.ClaimCmd c) {
applyClaim(lead, c);
} else if (cmd instanceof TransitionCmd.AssignToPoolCmd c) {
@ -81,9 +75,9 @@ public class LeadTransitionImpl implements LeadTransition {
return 0;
}
for (Lead lead : toRecycle) {
writeHistory(lead.getId(), HistoryType.RECYCLE, 0L,
buildDetail("ownerUserIdBefore", lead.getOwnerUserId(),
"previousStatus", lead.getStatus()));
historyRecorder.record(lead.getId(), HistoryType.RECYCLE, 0L,
"ownerUserIdBefore", lead.getOwnerUserId(),
"previousStatus", lead.getStatus());
}
return leadMapper.update(null, new LambdaUpdateWrapper<Lead>()
.lt(Lead::getRecycleDeadline, cutoff)
@ -107,9 +101,9 @@ public class LeadTransitionImpl implements LeadTransition {
return 0;
}
for (Lead lead : toExpire) {
writeHistory(lead.getId(), HistoryType.EXPIRE, 0L,
buildDetail("previousStatus", lead.getStatus(),
"ownerUserId", lead.getOwnerUserId()));
historyRecorder.record(lead.getId(), HistoryType.EXPIRE, 0L,
"previousStatus", lead.getStatus(),
"ownerUserId", lead.getOwnerUserId());
}
return leadMapper.update(null, new LambdaUpdateWrapper<Lead>()
.lt(Lead::getExpireDeadline, cutoff)
@ -122,7 +116,6 @@ public class LeadTransitionImpl implements LeadTransition {
/** 领取:待领取(2) → 已领取(3) */
private void applyClaim(Lead lead, TransitionCmd.ClaimCmd cmd) {
requireStatus(lead, STATUS_PENDING, "仅待领取线索可领取");
LocalDateTime now = LocalDateTime.now();
int rows = leadMapper.update(null, new LambdaUpdateWrapper<Lead>()
.eq(Lead::getId, lead.getId())
@ -134,12 +127,11 @@ public class LeadTransitionImpl implements LeadTransition {
.set(Lead::getClaimTime, now)
.set(Lead::getRecycleDeadline, cmd.recycleDeadline()));
checkCas(rows, "领取失败:线索可能已被他人领取,请刷新后重试");
writeHistory(lead.getId(), HistoryType.CLAIM, cmd.operatorId(), null);
historyRecorder.record(lead.getId(), HistoryType.CLAIM, cmd.operatorId());
}
/** 分配到公海池:未分发(1) → 待领取(2) */
private void applyAssignToPool(Lead lead, TransitionCmd.AssignToPoolCmd cmd) {
requireStatus(lead, STATUS_UNDISTRIBUTED, "仅未分发线索可分配到池");
int rows = leadMapper.update(null, new LambdaUpdateWrapper<Lead>()
.eq(Lead::getId, lead.getId())
.eq(Lead::getStatus, STATUS_UNDISTRIBUTED)
@ -150,8 +142,8 @@ public class LeadTransitionImpl implements LeadTransition {
.set(Lead::getTeamDeptId, cmd.teamDeptId())
.set(Lead::getExpireDeadline, cmd.expireDeadline()));
checkCas(rows, "分配失败:线索状态可能已变更");
writeHistory(lead.getId(), HistoryType.ASSIGN, cmd.operatorId(),
buildDetail("poolId", cmd.poolId(), "poolName", cmd.poolNameSnapshot()));
historyRecorder.record(lead.getId(), HistoryType.ASSIGN, cmd.operatorId(),
"poolId", cmd.poolId(), "poolName", cmd.poolNameSnapshot());
}
/** 分配到销售:未分发(1)/待领取(2) → 已领取(3);线索作废(7) → 线索作废(7)(更新 owner) */
@ -169,7 +161,8 @@ public class LeadTransitionImpl implements LeadTransition {
.set(Lead::getClaimTime, now)
.set(Lead::getRecycleDeadline, cmd.recycleDeadline()));
checkCas(rows, "分配失败:线索状态可能已变更");
} else if (currentStatus == STATUS_VOID) {
} else {
// STATUS_VOID(守卫已保证起始态∈{未分发,待领取,作废}):作废自环,只换 owner
int rows = leadMapper.update(null, new LambdaUpdateWrapper<Lead>()
.eq(Lead::getId, lead.getId())
.eq(Lead::getStatus, STATUS_VOID)
@ -177,11 +170,9 @@ public class LeadTransitionImpl implements LeadTransition {
.set(Lead::getOwnerNameSnapshot, cmd.ownerNameSnapshot())
.set(Lead::getOwnerDeptIdSnapshot, cmd.ownerDeptIdSnapshot()));
checkCas(rows, "分配失败:线索状态可能已变更");
} else {
throw new BusinessErrorException(CODE_STATUS_NOT_ALLOWED, "当前状态不允许分配");
}
writeHistory(lead.getId(), HistoryType.ASSIGN, cmd.operatorId(),
buildDetail("assignToUserId", cmd.userId()));
historyRecorder.record(lead.getId(), HistoryType.ASSIGN, cmd.operatorId(),
"assignToUserId", cmd.userId());
}
/**
@ -192,14 +183,6 @@ public class LeadTransitionImpl implements LeadTransition {
int currentStatus = lead.getStatus();
Long userId = cmd.operatorId();
if (currentStatus != STATUS_CLAIMED && currentStatus != STATUS_FOLLOWING
&& currentStatus != STATUS_VOID) {
throw new BusinessErrorException(CODE_STATUS_NOT_ALLOWED, "当前状态不允许提交反馈");
}
if (!userId.equals(lead.getOwnerUserId())) {
throw new BusinessErrorException(CODE_NOT_OWNER, "仅持有人可提交反馈");
}
int targetStatus;
if (cmd.feedbackStatus() == FEEDBACK_INVALID) {
if (currentStatus != STATUS_CLAIMED && currentStatus != STATUS_FOLLOWING) {
@ -254,42 +237,30 @@ public class LeadTransitionImpl implements LeadTransition {
leadFeedbackMapper.insert(fb);
if (targetStatus == STATUS_VOID) {
writeHistory(lead.getId(), HistoryType.VOID, userId,
buildDetail("ownerUserIdBeforeVoid", lead.getOwnerUserId()));
historyRecorder.record(lead.getId(), HistoryType.VOID, userId,
"ownerUserIdBeforeVoid", lead.getOwnerUserId());
} else {
writeHistory(lead.getId(), HistoryType.FEEDBACK, userId,
buildDetail("feedbackId", fb.getId(), "feedbackStatus", cmd.feedbackStatus()));
historyRecorder.record(lead.getId(), HistoryType.FEEDBACK, userId,
"feedbackId", fb.getId(), "feedbackStatus", cmd.feedbackStatus());
}
}
/** 转商机:已领取(3)/跟进中(4) → 已转商机(5)(port 已被 LeadServiceImpl 调通) */
private void applyConvert(Lead lead, TransitionCmd.ConvertCmd cmd) {
int currentStatus = lead.getStatus();
if (currentStatus != STATUS_CLAIMED && currentStatus != STATUS_FOLLOWING) {
throw new BusinessErrorException(CODE_STATUS_NOT_ALLOWED, "仅已领取/跟进中线索可转商机");
}
if (!cmd.operatorId().equals(lead.getOwnerUserId())) {
throw new BusinessErrorException(CODE_NOT_OWNER, "仅持有人可转商机");
}
int rows = leadMapper.update(null, new LambdaUpdateWrapper<Lead>()
.eq(Lead::getId, lead.getId())
.eq(Lead::getStatus, currentStatus)
.set(Lead::getStatus, STATUS_CONVERTED));
checkCas(rows, "转商机失败:线索状态可能已变更");
writeHistory(lead.getId(), HistoryType.CONVERT, cmd.operatorId(),
buildDetail("opportunityId", cmd.opportunityId(),
"opportunityName", cmd.opportunityName()));
historyRecorder.record(lead.getId(), HistoryType.CONVERT, cmd.operatorId(),
"opportunityId", cmd.opportunityId(),
"opportunityName", cmd.opportunityName());
}
/** 释放:已领取(3)/跟进中(4) → 待领取(2),清 owner,保留反馈快照 */
private void applyRelease(Lead lead, TransitionCmd.ReleaseCmd cmd) {
int currentStatus = lead.getStatus();
if (currentStatus != STATUS_CLAIMED && currentStatus != STATUS_FOLLOWING) {
throw new BusinessErrorException(CODE_STATUS_NOT_ALLOWED, "仅已领取/跟进中线索可释放");
}
if (!cmd.operatorId().equals(lead.getOwnerUserId())) {
throw new BusinessErrorException(CODE_NOT_OWNER, "仅持有人可释放");
}
int rows = leadMapper.update(null, new LambdaUpdateWrapper<Lead>()
.eq(Lead::getId, lead.getId())
.eq(Lead::getStatus, currentStatus)
@ -300,12 +271,11 @@ public class LeadTransitionImpl implements LeadTransition {
.set(Lead::getClaimTime, null)
.set(Lead::getRecycleDeadline, null));
checkCas(rows, "释放失败:线索状态可能已变更");
writeHistory(lead.getId(), HistoryType.RELEASE, cmd.operatorId(), null);
historyRecorder.record(lead.getId(), HistoryType.RELEASE, cmd.operatorId());
}
/** 激活:过期失效(6) → 恢复失效前态,重置 N 和 M(ADR-0021) */
private void applyActivate(Lead lead, TransitionCmd.ActivateCmd cmd) {
requireStatus(lead, STATUS_EXPIRED, "仅过期失效线索可激活");
Integer previousStatus = lead.getStatusBeforeExpire() != null
? lead.getStatusBeforeExpire() : STATUS_PENDING;
@ -319,8 +289,8 @@ public class LeadTransitionImpl implements LeadTransition {
}
int rows = leadMapper.update(null, wrapper);
checkCas(rows, "激活失败:线索状态可能已变更");
writeHistory(lead.getId(), HistoryType.ACTIVATE, cmd.operatorId(),
buildDetail("restoredStatus", previousStatus));
historyRecorder.record(lead.getId(), HistoryType.ACTIVATE, cmd.operatorId(),
"restoredStatus", previousStatus);
}
// ==================== 内部工具 ====================
@ -333,9 +303,17 @@ public class LeadTransitionImpl implements LeadTransition {
return lead;
}
private void requireStatus(Lead lead, int expected, String message) {
if (lead.getStatus() != expected) {
throw new BusinessErrorException(CODE_STATUS_NOT_ALLOWED, message);
/**
* 统一守卫起始态合法性 + 持有人校验声明式 {@link TransitionCmd} 自带元数据
* <p> CAS 之上提供友好前置非法起始态抛 {@code CODE_STATUS_NOT_ALLOWED}
* 非持有人抛 {@code CODE_NOT_OWNER}起始态×命令内容的组合子规则由各 apply 自行处理</p>
*/
private void guard(Lead lead, TransitionCmd cmd) {
if (!cmd.allowedFromStatuses().contains(lead.getStatus())) {
throw new BusinessErrorException(CODE_STATUS_NOT_ALLOWED, "当前状态不允许此操作");
}
if (cmd.requiresOwner() && !cmd.operatorId().equals(lead.getOwnerUserId())) {
throw new BusinessErrorException(CODE_NOT_OWNER, "仅持有人可操作");
}
}
@ -344,27 +322,4 @@ public class LeadTransitionImpl implements LeadTransition {
throw new BusinessErrorException(CODE_CAS_FAIL, message);
}
}
private void writeHistory(Long leadId, HistoryType type, Long userId, String detail) {
LeadHistory history = new LeadHistory();
history.setLeadId(leadId);
history.setOpType(type.name());
history.setOpTime(LocalDateTime.now());
history.setOpUserId(userId);
history.setDetail(detail);
leadHistoryMapper.insert(history);
}
private String buildDetail(Object... kv) {
try {
Map<String, Object> map = new LinkedHashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) {
map.put(String.valueOf(kv[i]), kv[i + 1]);
}
return objectMapper.writeValueAsString(map);
} catch (Exception e) {
log.warn("history detail 序列化失败", e);
return null;
}
}
}

63
crm-lead/src/test/java/com/crm/lead/history/impl/LeadHistoryRecorderImplTest.java

@ -0,0 +1,63 @@
package com.crm.lead.history.impl;
import com.crm.lead.domain.entity.LeadHistory;
import com.crm.lead.domain.enums.HistoryType;
import com.crm.lead.mapper.LeadHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
/**
* 线索历史记录器规格kv JSON LeadHistory insert kv null
*/
@DisplayName("LeadHistoryRecorder:明细序列化与空 kv 行为")
@ExtendWith(MockitoExtension.class)
class LeadHistoryRecorderImplTest {
private static final Long LEAD_ID = 5001L;
private static final Long USER_ID = 1001L;
@Mock private LeadHistoryMapper leadHistoryMapper;
// 用真实 ObjectMapper 断言真实 JSON 产物
private final ObjectMapper objectMapper = new ObjectMapper();
private LeadHistoryRecorderImpl recorder() {
return new LeadHistoryRecorderImpl(leadHistoryMapper, objectMapper);
}
@Test
@DisplayName("有 kv:detail 为顺序保留的 JSON,opType 取枚举 name")
void record_withKv_serializesOrderedJson() {
LeadHistoryRecorderImpl r = recorder();
r.record(LEAD_ID, HistoryType.ASSIGN, USER_ID, "poolId", 7L, "poolName", "华南池");
ArgumentCaptor<LeadHistory> captor = ArgumentCaptor.forClass(LeadHistory.class);
verify(leadHistoryMapper).insert(captor.capture());
LeadHistory h = captor.getValue();
assertThat(h.getLeadId()).isEqualTo(LEAD_ID);
assertThat(h.getOpType()).isEqualTo("ASSIGN");
assertThat(h.getOpUserId()).isEqualTo(USER_ID);
assertThat(h.getDetail()).isEqualTo("{\"poolId\":7,\"poolName\":\"华南池\"}");
assertThat(h.getOpTime()).isNotNull();
}
@Test
@DisplayName("空 kv:detail 存 null(保持历史行为,不写 {})")
void record_emptyKv_detailNull() {
LeadHistoryRecorderImpl r = recorder();
r.record(LEAD_ID, HistoryType.CLAIM, USER_ID);
ArgumentCaptor<LeadHistory> captor = ArgumentCaptor.forClass(LeadHistory.class);
verify(leadHistoryMapper).insert(captor.capture());
assertThat(captor.getValue().getDetail()).isNull();
}
}

226
crm-lead/src/test/java/com/crm/lead/query/impl/LeadViewQueryImplTest.java

@ -0,0 +1,226 @@
package com.crm.lead.query.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.base.domain.result.PageResult;
import com.crm.base.security.SecurityUtils;
import com.crm.lead.constant.LeadConstants;
import com.crm.lead.domain.dto.LeadDTO;
import com.crm.lead.domain.dto.LeadHistoryDTO;
import com.crm.lead.domain.dto.LeadStatsDTO;
import com.crm.lead.domain.entity.Lead;
import com.crm.lead.domain.entity.LeadFollow;
import com.crm.lead.domain.entity.LeadHistory;
import com.crm.lead.domain.enums.HistoryType;
import com.crm.lead.domain.param.LeadPageParam;
import com.crm.lead.mapper.LeadFollowMapper;
import com.crm.lead.mapper.LeadHistoryMapper;
import com.crm.lead.mapper.LeadMapper;
import com.crm.rule.domain.dto.SysRegionDTO;
import com.crm.rule.service.ISysRegionService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 线索读侧规格验证候选3 深化四视图查询 + 展示拼装 + 历史时间线下沉至 LeadViewQuery
*
* <p>覆盖 {@code fillDisplayFields} 的省市名回填关注数/是否关注回填
* 以及 {@code listHistory} 只查 {@link HistoryType#VISIBLE_TYPES}</p>
*/
@DisplayName("线索读侧规格(四视图 + 展示拼装 + 历史时间线)")
@ExtendWith(MockitoExtension.class)
class LeadViewQueryImplTest {
private static final Long LEAD_ID = 5001L;
private static final Long CURRENT_USER_ID = 1001L;
@Mock private LeadMapper leadMapper;
@Mock private LeadHistoryMapper leadHistoryMapper;
@Mock private LeadFollowMapper leadFollowMapper;
@Mock private ISysRegionService sysRegionService;
@InjectMocks
private LeadViewQueryImpl viewQuery;
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, Lead.class);
TableInfoHelper.initTableInfo(assistant, LeadFollow.class);
TableInfoHelper.initTableInfo(assistant, LeadHistory.class);
}
private Lead lead() {
Lead l = new Lead();
l.setId(LEAD_ID);
l.setLeadName("测试线索");
l.setProvinceCode("110000");
l.setCityCode("110100");
return l;
}
private Lead leadWith(int status, LocalDateTime createTime) {
Lead l = new Lead();
l.setStatus(status);
l.setCreateTime(createTime);
return l;
}
@SuppressWarnings("unchecked")
private void stubSelectPage(Lead... rows) {
Page<Lead> page = new Page<>(1, 10);
page.setRecords(List.of(rows));
page.setTotal(rows.length);
when(leadMapper.selectPage(any(IPage.class), any())).thenReturn(page);
}
@Test
@DisplayName("分页:查询后回填省市名(sysRegionService.listByCodes)")
void pageLeads_fillsRegionNames() {
try (var mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
stubSelectPage(lead());
SysRegionDTO prov = new SysRegionDTO();
prov.setCode("110000");
prov.setName("北京市");
SysRegionDTO city = new SysRegionDTO();
city.setCode("110100");
city.setName("市辖区");
when(sysRegionService.listByCodes(anySet())).thenReturn(List.of(prov, city));
lenient().when(leadFollowMapper.selectMaps(any())).thenReturn(List.of());
lenient().when(leadFollowMapper.selectList(any())).thenReturn(List.of());
PageResult<LeadDTO> result = viewQuery.pageLeads(new LeadPageParam());
LeadDTO dto = result.getContent().get(0);
assertThat(dto.getProvinceName()).isEqualTo("北京市");
assertThat(dto.getCityName()).isEqualTo("市辖区");
}
}
@Test
@DisplayName("分页:回填关注总数与当前用户是否关注")
void pageLeads_fillsFollowCountAndFollowed() {
try (var mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
stubSelectPage(lead());
lenient().when(sysRegionService.listByCodes(anySet())).thenReturn(List.of());
when(leadFollowMapper.selectMaps(any()))
.thenReturn(List.of(Map.of("lead_id", LEAD_ID, "cnt", 3L)));
LeadFollow myFollow = new LeadFollow();
myFollow.setLeadId(LEAD_ID);
myFollow.setUserId(CURRENT_USER_ID);
when(leadFollowMapper.selectList(any())).thenReturn(List.of(myFollow));
PageResult<LeadDTO> result = viewQuery.pageLeads(new LeadPageParam());
LeadDTO dto = result.getContent().get(0);
assertThat(dto.getFollowCount()).isEqualTo(3);
assertThat(dto.getFollowed()).isTrue();
}
}
@Test
@DisplayName("MY_FOLLOW 视图:wrapper 命中关注子查询(apply 片段),仍走 selectPage")
void pageLeads_myFollowView_usesSubquery() {
try (var mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
stubSelectPage();
LeadPageParam param = new LeadPageParam();
param.setViewType(com.crm.lead.domain.enums.LeadViewType.MY_FOLLOW.name());
viewQuery.pageLeads(param);
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<Lead>> captor =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(leadMapper).selectPage(any(IPage.class), captor.capture());
String sql = captor.getValue().getCustomSqlSegment();
assertThat(sql).contains("SELECT lead_id FROM lead_follow");
}
}
@Test
@DisplayName("历史时间线:只查 VISIBLE_TYPES,按操作时间倒序")
void listHistory_onlyVisibleTypes() {
LeadHistory h = new LeadHistory();
h.setLeadId(LEAD_ID);
h.setOpType(HistoryType.CLAIM.name());
h.setOpTime(LocalDateTime.now());
when(leadHistoryMapper.selectList(any())).thenReturn(List.of(h));
List<LeadHistoryDTO> result = viewQuery.listHistory(LEAD_ID);
assertThat(result).hasSize(1);
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LeadHistory>> captor =
ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(leadHistoryMapper).selectList(captor.capture());
// in(...) 片段应含 VISIBLE_TYPES 数量个占位
long visibleCount = HistoryType.VISIBLE_TYPES.size();
assertThat(captor.getValue().getExpression().getNormal().size()).isGreaterThanOrEqualTo(1);
assertThat(visibleCount).isGreaterThan(0);
}
@Test
@DisplayName("统计:按 status 分组计数(claimed=已领取+跟进中,converted/undistributed 各归类)")
void countStats_groupsByStatus() {
try (var mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
LocalDateTime yesterday = LocalDateTime.now().minusDays(1);
when(leadMapper.selectList(any())).thenReturn(List.of(
leadWith(LeadConstants.STATUS_UNDISTRIBUTED, yesterday),
leadWith(LeadConstants.STATUS_CLAIMED, yesterday),
leadWith(LeadConstants.STATUS_FOLLOWING, yesterday),
leadWith(LeadConstants.STATUS_CONVERTED, yesterday),
leadWith(LeadConstants.STATUS_PENDING, yesterday)));
LeadStatsDTO stats = viewQuery.countStats(new LeadPageParam());
assertThat(stats.getTotal()).isEqualTo(5);
assertThat(stats.getClaimed()).isEqualTo(2); // 已领取 + 跟进中
assertThat(stats.getConverted()).isEqualTo(1);
assertThat(stats.getUndistributed()).isEqualTo(1);
assertThat(stats.getTodayNew()).isZero(); // 均为昨日
}
}
@Test
@DisplayName("统计:todayNew 只计今日 createTime")
void countStats_todayNewCountsTodayOnly() {
try (var mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
when(leadMapper.selectList(any())).thenReturn(List.of(
leadWith(LeadConstants.STATUS_PENDING, LocalDateTime.now()),
leadWith(LeadConstants.STATUS_PENDING, LocalDateTime.now()),
leadWith(LeadConstants.STATUS_PENDING, LocalDateTime.now().minusDays(2))));
LeadStatsDTO stats = viewQuery.countStats(new LeadPageParam());
assertThat(stats.getTotal()).isEqualTo(3);
assertThat(stats.getTodayNew()).isEqualTo(2);
}
}
}

114
crm-lead/src/test/java/com/crm/lead/service/impl/LeadBatchServiceTest.java

@ -0,0 +1,114 @@
package com.crm.lead.service.impl;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.domain.result.BatchResult;
import com.crm.lead.constant.LeadConstants;
import com.crm.lead.domain.dto.LeadBatchFailItem;
import com.crm.lead.domain.enums.LeadBatchFailReason;
import com.crm.lead.service.ILeadService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* 批量操作执行器 {@code runBatch} 规格验证ADR-0023 D1/D2 / ticket 11
* <p>批量方法逐条委派 {@code self} 代理调单条操作各自独立事务
* 单条失败以 {@link BusinessErrorException} 抛出 code 映射为 {@link LeadBatchFailReason}
* 记入 {@link BatchResult} 而不拖垮整批</p>
*/
@DisplayName("线索批量操作 runBatch(ADR-0023)")
@ExtendWith(MockitoExtension.class)
class LeadBatchServiceTest {
@Mock
private ILeadService self;
private LeadServiceImpl service;
@BeforeEach
void setUp() {
service = new LeadServiceImpl(null, null, null, null, null, null, null, null);
ReflectionTestUtils.setField(service, "self", self);
}
@Test
@DisplayName("全部成功:total=successCount,无失败明细")
void allSuccess() {
doNothing().when(self).claimLead(1L);
doNothing().when(self).claimLead(2L);
doNothing().when(self).claimLead(3L);
BatchResult<LeadBatchFailItem> r = service.claimBatch(List.of(1L, 2L, 3L));
assertThat(r.getTotal()).isEqualTo(3);
assertThat(r.getSuccessCount()).isEqualTo(3);
assertThat(r.getFailCount()).isZero();
assertThat(r.getFailures()).isEmpty();
}
@Test
@DisplayName("部分失败:业务错误码映射为对应失败原因,成功条不受影响")
void partialFailure_mapsReasonByCode() {
doNothing().when(self).claimLead(1L);
doThrow(new BusinessErrorException(LeadConstants.CODE_STATUS_NOT_ALLOWED, "状态不允许"))
.when(self).claimLead(2L);
doThrow(new BusinessErrorException(LeadConstants.CODE_CAS_FAIL, "并发冲突"))
.when(self).claimLead(3L);
BatchResult<LeadBatchFailItem> r = service.claimBatch(List.of(1L, 2L, 3L));
assertThat(r.getTotal()).isEqualTo(3);
assertThat(r.getSuccessCount()).isEqualTo(1);
assertThat(r.getFailCount()).isEqualTo(2);
assertThat(r.getFailures())
.extracting(LeadBatchFailItem::getId, LeadBatchFailItem::getReason)
.containsExactly(
org.assertj.core.groups.Tuple.tuple(2L, LeadBatchFailReason.STATUS_NOT_ALLOWED),
org.assertj.core.groups.Tuple.tuple(3L, LeadBatchFailReason.CONCURRENT_MODIFIED));
}
@Test
@DisplayName("未知运行时异常兜底为 UNKNOWN")
void unexpectedException_mapsToUnknown() {
doThrow(new IllegalStateException("boom")).when(self).releaseLead(9L);
BatchResult<LeadBatchFailItem> r = service.releaseBatch(List.of(9L));
assertThat(r.getFailCount()).isEqualTo(1);
assertThat(r.getFailures()).singleElement()
.extracting(LeadBatchFailItem::getReason)
.isEqualTo(LeadBatchFailReason.UNKNOWN);
}
@Test
@DisplayName("空 ids:空结果,不触碰 self")
void emptyIds_noop() {
BatchResult<LeadBatchFailItem> r = service.deleteBatch(List.of());
assertThat(r.getTotal()).isZero();
assertThat(r.getFailures()).isEmpty();
verifyNoInteractions(self);
}
@Test
@DisplayName("assign-user-batch:统一分配给同一 userId,逐条委派")
void assignUserBatch_delegatesPerId() {
BatchResult<LeadBatchFailItem> r = service.assignToUserBatch(List.of(1L, 2L), 777L);
assertThat(r.getSuccessCount()).isEqualTo(2);
verify(self).assignToUser(1L, 777L);
verify(self).assignToUser(2L, 777L);
}
}

135
crm-lead/src/test/java/com/crm/lead/service/impl/LeadServiceImplTest.java

@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.crm.auth.domain.entity.AuthUser;
import com.crm.auth.service.IAuthUserService;
import com.crm.auth.service.ISysDeptService;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.security.SecurityUtils;
import com.crm.lead.constant.LeadConstants;
@ -17,19 +16,18 @@ import com.crm.lead.domain.entity.LeadFollow;
import com.crm.lead.domain.entity.LeadHistory;
import com.crm.lead.domain.enums.HistoryType;
import com.crm.lead.domain.param.ConvertOpportunityParam;
import com.crm.lead.mapper.LeadAttachmentMapper;
import com.crm.lead.domain.param.LeadPageParam;
import com.crm.lead.mapper.LeadFeedbackMapper;
import com.crm.lead.mapper.LeadFollowMapper;
import com.crm.lead.mapper.LeadHistoryMapper;
import com.crm.lead.mapper.LeadMapper;
import com.crm.lead.state.LeadTransition;
import com.crm.lead.query.LeadViewQuery;
import com.crm.lead.state.TransitionCmd;
import com.crm.lead.port.outbound.OpportunityCreationPort;
import com.crm.rule.constant.RuleConstants;
import com.crm.rule.domain.entity.LeadPool;
import com.crm.rule.service.ILeadPoolService;
import com.crm.rule.service.ISysRegionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.crm.lead.history.LeadHistoryRecorder;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@ -86,15 +84,12 @@ class LeadServiceImplTest {
@Mock private LeadMapper leadMapper;
@Mock private LeadFeedbackMapper leadFeedbackMapper;
@Mock private LeadHistoryMapper leadHistoryMapper;
@Mock private LeadFollowMapper leadFollowMapper;
@Mock private LeadAttachmentMapper leadAttachmentMapper;
@Mock private ILeadPoolService leadPoolService;
@Mock private ISysRegionService sysRegionService;
@Mock private IAuthUserService authUserService;
@Mock private ISysDeptService sysDeptService;
@Mock private ObjectMapper objectMapper;
@Mock private LeadTransition leadTransition;
@Mock private LeadHistoryRecorder historyRecorder;
@Mock private LeadViewQuery leadViewQuery;
@Mock private ObjectProvider<OpportunityCreationPort> opportunityCreationPortProvider;
@InjectMocks
@ -186,6 +181,40 @@ class LeadServiceImplTest {
when(authUserService.getById(userId)).thenReturn(user);
}
// ==================== 读侧委派(真逻辑在 LeadViewQueryImplTest)====================
@Test
@DisplayName("分页:委派给 LeadViewQuery.pageLeads")
void pageLeads_delegatesToViewQuery() {
LeadPageParam param = new LeadPageParam();
com.crm.base.domain.result.PageResult<com.crm.lead.domain.dto.LeadDTO> expected =
new com.crm.base.domain.result.PageResult<>();
when(leadViewQuery.pageLeads(param)).thenReturn(expected);
org.assertj.core.api.Assertions.assertThat(service.pageLeads(param)).isSameAs(expected);
verify(leadViewQuery).pageLeads(param);
}
@Test
@DisplayName("详情:委派给 LeadViewQuery.getLeadDetail")
void getLeadDetail_delegatesToViewQuery() {
com.crm.lead.domain.dto.LeadDTO expected = new com.crm.lead.domain.dto.LeadDTO();
when(leadViewQuery.getLeadDetail(LEAD_ID)).thenReturn(expected);
org.assertj.core.api.Assertions.assertThat(service.getLeadDetail(LEAD_ID)).isSameAs(expected);
verify(leadViewQuery).getLeadDetail(LEAD_ID);
}
@Test
@DisplayName("历史时间线:委派给 LeadViewQuery.listHistory")
void listHistory_delegatesToViewQuery() {
java.util.List<com.crm.lead.domain.dto.LeadHistoryDTO> expected = java.util.List.of();
when(leadViewQuery.listHistory(LEAD_ID)).thenReturn(expected);
org.assertj.core.api.Assertions.assertThat(service.listHistory(LEAD_ID)).isSameAs(expected);
verify(leadViewQuery).listHistory(LEAD_ID);
}
// ==================== §3.2 边 #6:领取(待领取 → 已领取)====================
@Test
@ -203,18 +232,6 @@ class LeadServiceImplTest {
}
}
@Test
@DisplayName("边#6 领取:非待领取状态拒绝(§3.3 矩阵:仅待领取可领取)")
void claimLead_wrongStatus_rejected() {
when(leadMapper.selectById(LEAD_ID)).thenReturn(lead(LeadConstants.STATUS_CLAIMED));
assertThatThrownBy(() -> service.claimLead(LEAD_ID))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_STATUS_NOT_ALLOWED);
verify(leadTransition, never()).execute(any(), any());
}
@Test
@DisplayName("边#6 领取:ADMIN_ONLY 池禁止自助领取(§6.2 claim_rule=2 销售不可自领)")
void claimLead_adminOnlyPool_rejected() {
@ -322,36 +339,6 @@ class LeadServiceImplTest {
// ==================== §3.3 矩阵:反馈权限 ===================
@Test
@DisplayName("§3.3 反馈:非持有人不可提交反馈")
void submitFeedback_notOwner_rejected() {
try (MockedStatic<SecurityUtils> mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadOwnedByOther(LeadConstants.STATUS_CLAIMED));
assertThatThrownBy(() -> service.submitFeedback(feedbackDto(LeadConstants.FEEDBACK_VALID)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_NOT_OWNER);
verify(leadTransition, never()).execute(any(), any());
}
}
@Test
@DisplayName("§3.3 反馈:待领取状态不可反馈(矩阵反馈行仅已领取/跟进中/作废)")
void submitFeedback_pendingStatus_rejected() {
try (MockedStatic<SecurityUtils> mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
when(leadMapper.selectById(LEAD_ID)).thenReturn(lead(LeadConstants.STATUS_PENDING));
assertThatThrownBy(() -> service.submitFeedback(feedbackDto(LeadConstants.FEEDBACK_VALID)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_STATUS_NOT_ALLOWED);
verify(leadTransition, never()).execute(any(), any());
}
}
// ==================== §3.2 边 #9:转商机 ===================
@Test
@ -433,36 +420,6 @@ class LeadServiceImplTest {
}
}
@Test
@DisplayName("§3.3 释放:非持有人不可释放")
void releaseLead_notOwner_rejected() {
try (MockedStatic<SecurityUtils> mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadOwnedByOther(LeadConstants.STATUS_CLAIMED));
assertThatThrownBy(() -> service.releaseLead(LEAD_ID))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_NOT_OWNER);
verify(leadTransition, never()).execute(any(), any());
}
}
@Test
@DisplayName("§3.3 释放:待领取状态不可释放")
void releaseLead_wrongStatus_rejected() {
try (MockedStatic<SecurityUtils> mocked = Mockito.mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER_ID));
when(leadMapper.selectById(LEAD_ID)).thenReturn(lead(LeadConstants.STATUS_PENDING));
assertThatThrownBy(() -> service.releaseLead(LEAD_ID))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_STATUS_NOT_ALLOWED);
verify(leadTransition, never()).execute(any(), any());
}
}
// ==================== §3.2 边 #13:激活 ===================
@Test
@ -481,18 +438,6 @@ class LeadServiceImplTest {
}
}
@Test
@DisplayName("§3.3 激活:仅过期失效可激活")
void activateLead_wrongStatus_rejected() {
when(leadMapper.selectById(LEAD_ID)).thenReturn(lead(LeadConstants.STATUS_PENDING));
assertThatThrownBy(() -> service.activateLead(LEAD_ID))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_STATUS_NOT_ALLOWED);
verify(leadTransition, never()).execute(any(), any());
}
// ==================== §3.3 矩阵:删除 ===================
@Test
@ -582,7 +527,7 @@ class LeadServiceImplTest {
// PRD §6.4 L316: 草稿不改状态机,不刷主表快照
verify(leadMapper, never()).update(any(), any());
// PRD §6.4 L316: 草稿不写 lead_history
verify(leadHistoryMapper, never()).insert(any(LeadHistory.class));
verify(historyRecorder, never()).record(any(), any(), any(), any());
// PRD §6.4 L316: 草稿写 lead_feedback DRAFT 行
verify(leadFeedbackMapper).insert(any(LeadFeedback.class));
}

165
crm-lead/src/test/java/com/crm/lead/state/impl/LeadTransitionImplTest.java

@ -0,0 +1,165 @@
package com.crm.lead.state.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.lead.constant.LeadConstants;
import com.crm.lead.domain.entity.Lead;
import com.crm.lead.domain.entity.LeadFeedback;
import com.crm.lead.history.LeadHistoryRecorder;
import com.crm.lead.mapper.LeadFeedbackMapper;
import com.crm.lead.mapper.LeadMapper;
import com.crm.lead.state.TransitionCmd;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 线索状态机守卫规格验证候选1 深化状态守卫 + 持有人守卫下沉至 LeadTransition
*
* <p>断言 {@code guard()} CAS 之上给出的友好前置
* <ul>
* <li>非法起始态 {@code CODE_STATUS_NOT_ALLOWED} {@link TransitionCmd#allowedFromStatuses()}</li>
* <li>非持有人 {@code CODE_NOT_OWNER} {@link TransitionCmd#requiresOwner()}</li>
* </ul>
* 守卫在 mapper.update 之前跑故拒绝路径不触达持久化</p>
*/
@DisplayName("线索状态机守卫规格(起始态集合 + 持有人)")
@ExtendWith(MockitoExtension.class)
class LeadTransitionImplTest {
private static final Long LEAD_ID = 5001L;
private static final Long OWNER_ID = 1001L;
private static final Long OTHER_ID = 2002L;
@Mock private LeadMapper leadMapper;
@Mock private LeadFeedbackMapper leadFeedbackMapper;
@Mock private LeadHistoryRecorder historyRecorder;
@InjectMocks
private LeadTransitionImpl transition;
@BeforeAll
static void initLambdaCache() {
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, Lead.class);
TableInfoHelper.initTableInfo(assistant, LeadFeedback.class);
}
private Lead leadAt(int status, Long ownerId) {
Lead lead = new Lead();
lead.setId(LEAD_ID);
lead.setStatus(status);
lead.setOwnerUserId(ownerId);
return lead;
}
// ==================== 起始态守卫 ====================
@Test
@DisplayName("领取:非「待领取」起始态 → CODE_STATUS_NOT_ALLOWED,不触达 update")
void claim_wrongStatus_rejected() {
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadAt(LeadConstants.STATUS_CLAIMED, OWNER_ID));
assertThatThrownBy(() -> transition.execute(LEAD_ID,
new TransitionCmd.ClaimCmd(OWNER_ID, "u", 1L, LocalDateTime.now())))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_STATUS_NOT_ALLOWED);
verify(leadMapper, never()).update(any(), any());
}
@Test
@DisplayName("释放:非「已领取/跟进中」起始态 → CODE_STATUS_NOT_ALLOWED")
void release_wrongStatus_rejected() {
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadAt(LeadConstants.STATUS_PENDING, OWNER_ID));
assertThatThrownBy(() -> transition.execute(LEAD_ID,
new TransitionCmd.ReleaseCmd(OWNER_ID)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_STATUS_NOT_ALLOWED);
verify(leadMapper, never()).update(any(), any());
}
@Test
@DisplayName("反馈:「待领取」起始态 → CODE_STATUS_NOT_ALLOWED(矩阵仅已领取/跟进中/作废)")
void feedback_wrongStatus_rejected() {
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadAt(LeadConstants.STATUS_PENDING, OWNER_ID));
assertThatThrownBy(() -> transition.execute(LEAD_ID,
new TransitionCmd.FeedbackCmd(OWNER_ID, LeadConstants.FEEDBACK_VALID, "c", "p", null, LocalDateTime.now())))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_STATUS_NOT_ALLOWED);
verify(leadMapper, never()).update(any(), any());
}
@Test
@DisplayName("激活:非「过期失效」起始态 → CODE_STATUS_NOT_ALLOWED")
void activate_wrongStatus_rejected() {
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadAt(LeadConstants.STATUS_PENDING, OWNER_ID));
assertThatThrownBy(() -> transition.execute(LEAD_ID,
new TransitionCmd.ActivateCmd(OWNER_ID, LocalDateTime.now(), null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_STATUS_NOT_ALLOWED);
verify(leadMapper, never()).update(any(), any());
}
// ==================== 持有人守卫 ====================
@Test
@DisplayName("反馈:非持有人 → CODE_NOT_OWNER,不触达 update")
void feedback_notOwner_rejected() {
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadAt(LeadConstants.STATUS_CLAIMED, OTHER_ID));
assertThatThrownBy(() -> transition.execute(LEAD_ID,
new TransitionCmd.FeedbackCmd(OWNER_ID, LeadConstants.FEEDBACK_VALID, "c", "p", null, LocalDateTime.now())))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_NOT_OWNER);
verify(leadMapper, never()).update(any(), any());
}
@Test
@DisplayName("释放:非持有人 → CODE_NOT_OWNER")
void release_notOwner_rejected() {
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadAt(LeadConstants.STATUS_CLAIMED, OTHER_ID));
assertThatThrownBy(() -> transition.execute(LEAD_ID,
new TransitionCmd.ReleaseCmd(OWNER_ID)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", LeadConstants.CODE_NOT_OWNER);
verify(leadMapper, never()).update(any(), any());
}
@Test
@DisplayName("领取:不要求持有人(起始态无 owner),持有人守卫不误伤")
void claim_ownerNotRequired() {
// ClaimCmd.requiresOwner()=false:即便 lead 无 owner 也不因持有人守卫被拒
when(leadMapper.selectById(LEAD_ID)).thenReturn(leadAt(LeadConstants.STATUS_PENDING, null));
when(leadMapper.update(any(), any())).thenReturn(1);
transition.execute(LEAD_ID,
new TransitionCmd.ClaimCmd(OWNER_ID, "u", 1L, LocalDateTime.now()));
verify(leadMapper).update(any(), any());
}
}

10
crm-rule/src/main/java/com/crm/rule/controller/LeadPoolController.java

@ -2,7 +2,9 @@ package com.crm.rule.controller;
import com.crm.base.domain.result.PageResult;
import com.crm.base.domain.result.Result;
import com.crm.base.domain.result.BatchResult;
import com.crm.rule.domain.dto.LeadPoolDTO;
import com.crm.rule.domain.dto.PoolBatchFailItem;
import com.crm.rule.domain.param.LeadPoolPageParam;
import com.crm.rule.service.ILeadPoolService;
import io.swagger.v3.oas.annotations.Operation;
@ -14,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 公海池配置接口薄适配层只调 {@link ILeadPoolService}
* <p>权限策略不种子化 button 权限点用户决策ApiPermissionInterceptor 对未注册 URL fail-open
@ -52,4 +56,10 @@ public class LeadPoolController {
leadPoolService.deletePool(id);
return Result.success();
}
@Operation(summary = "批量删除公海池(非原子、逐条、部分成功)")
@PostMapping("/delete-batch")
public Result<BatchResult<PoolBatchFailItem>> deleteBatch(@RequestParam("ids") List<Long> ids) {
return Result.success(leadPoolService.deletePoolBatch(ids));
}
}

31
crm-rule/src/main/java/com/crm/rule/domain/dto/PoolBatchFailItem.java

@ -0,0 +1,31 @@
package com.crm.rule.domain.dto;
import com.crm.rule.domain.enums.PoolBatchFailReason;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 公海池批量删除失败明细项ADR-0023 D5 方案 A
* <p>作为 {@code BatchResult<PoolBatchFailItem>} 的失败元素装单条失败的池 id
* 结构化失败原因与人类可读文案供前端按 {@link PoolBatchFailReason} 分类聚合展示</p>
*/
@Data
public class PoolBatchFailItem {
@Schema(description = "失败的公海池 id")
private Long poolId;
@Schema(description = "失败原因(语义枚举)")
private PoolBatchFailReason reason;
@Schema(description = "失败文案")
private String message;
public static PoolBatchFailItem of(Long poolId, PoolBatchFailReason reason, String message) {
PoolBatchFailItem item = new PoolBatchFailItem();
item.setPoolId(poolId);
item.setReason(reason);
item.setMessage(message);
return item;
}
}

40
crm-rule/src/main/java/com/crm/rule/domain/enums/PoolBatchFailReason.java

@ -0,0 +1,40 @@
package com.crm.rule.domain.enums;
import com.crm.rule.constant.RuleConstants;
import lombok.Getter;
/**
* 公海池批量删除失败原因语义枚举ADR-0023 D5 方案 A
* <p>crm-rule 自持失败原因复用 crm-base {@code BatchResult<F>} 泛型骨架
* 避免 crm-rule 反向依赖 crm-leadcrm-lead crm-rule 为单向依赖code 回指
* {@link RuleConstants} 64xxx 错误码</p>
*/
@Getter
public enum PoolBatchFailReason {
/** 池不存在或已被删除 */
NOT_EXIST(RuleConstants.CODE_POOL_NOT_EXIST),
/** 池下有非终态线索,拒删(本期保留,单条删除与批量删除均不产出,待独立 ADR/issue 落地) */
HAS_ACTIVE_LEAD(RuleConstants.CODE_POOL_HAS_ACTIVE_LEAD),
/** 未归类失败(兜底) */
UNKNOWN(-1);
/** 对应的 ResultCode(64xxx);UNKNOWN 为 -1。 */
private final int code;
PoolBatchFailReason(int code) {
this.code = code;
}
/** 按错误码查找失败原因,未命中返回 {@link #UNKNOWN}。 */
public static PoolBatchFailReason fromCode(Integer code) {
if (code != null) {
for (PoolBatchFailReason reason : values()) {
if (reason.code == code) {
return reason;
}
}
}
return UNKNOWN;
}
}

10
crm-rule/src/main/java/com/crm/rule/service/ILeadPoolService.java

@ -1,10 +1,14 @@
package com.crm.rule.service;
import com.crm.base.domain.result.PageResult;
import com.crm.base.domain.result.BatchResult;
import com.crm.rule.domain.dto.LeadPoolDTO;
import com.crm.rule.domain.dto.PoolBatchFailItem;
import com.crm.rule.domain.entity.LeadPool;
import com.crm.rule.domain.param.LeadPoolPageParam;
import java.util.List;
/**
* 公海池管理服务
*/
@ -30,6 +34,12 @@ public interface ILeadPoolService {
*/
void deletePool(Long id);
/**
* 批量删除公海池非原子逐条部分成功ADR-0023 D5
* 失败项以 {@link com.crm.rule.domain.enums.PoolBatchFailReason} 分类
*/
BatchResult<PoolBatchFailItem> deletePoolBatch(List<Long> ids);
/**
* 按部门ID查公海池 crm-lead 领取/分配时定位归属池
*/

36
crm-rule/src/main/java/com/crm/rule/service/impl/LeadPoolServiceImpl.java

@ -11,11 +11,14 @@ import com.crm.auth.service.IAuthUserService;
import com.crm.auth.service.ISysDeptService;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.domain.result.PageResult;
import com.crm.base.domain.result.BatchResult;
import com.crm.base.service.impl.BaseServiceImpl;
import com.crm.base.utils.PageConverter;
import com.crm.rule.constant.RuleConstants;
import com.crm.rule.domain.dto.LeadPoolDTO;
import com.crm.rule.domain.dto.PoolBatchFailItem;
import com.crm.rule.domain.entity.LeadPool;
import com.crm.rule.domain.enums.PoolBatchFailReason;
import com.crm.rule.domain.param.LeadPoolPageParam;
import com.crm.rule.event.PoolChangedEvent;
import com.crm.rule.mapper.LeadPoolMapper;
@ -23,7 +26,10 @@ import com.crm.rule.service.ILeadPoolService;
import com.crm.rule.service.PoolGeographyStore;
import com.crm.rule.service.PoolMemberStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -38,6 +44,7 @@ import java.util.stream.Collectors;
* 自动注入无需手工拼接 dept 过滤条件</p>
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class LeadPoolServiceImpl extends BaseServiceImpl<LeadPoolMapper, LeadPool>
implements ILeadPoolService {
@ -48,6 +55,14 @@ public class LeadPoolServiceImpl extends BaseServiceImpl<LeadPoolMapper, LeadPoo
private final IAuthUserService authUserService;
private final ApplicationEventPublisher eventPublisher;
/**
* self-injection {@link #deletePoolBatch} 逐条调 {@link #deletePool} 时走 Spring AOP
* 事务代理每条独立事务一条失败不拖垓整批沿用 AuthServiceImpl.self / LeadServiceImpl.self
*/
@Lazy
@Autowired
private ILeadPoolService self;
// ==================== 查询 ====================
@Override
@ -131,6 +146,27 @@ public class LeadPoolServiceImpl extends BaseServiceImpl<LeadPoolMapper, LeadPoo
poolMemberStore.delete(id);
}
@Override
public BatchResult<PoolBatchFailItem> deletePoolBatch(List<Long> ids) {
BatchResult<PoolBatchFailItem> result = new BatchResult<>();
if (ids == null || ids.isEmpty()) {
return result;
}
for (Long id : ids) {
try {
self.deletePool(id); // self 代理:每条独立事务
result.addSuccess();
} catch (BusinessErrorException e) {
PoolBatchFailReason reason = PoolBatchFailReason.fromCode(e.getCode());
result.addFailure(PoolBatchFailItem.of(id, reason, e.getMessage()));
} catch (Exception e) {
log.error("公海池批量删除单条异常:poolId={}", id, e);
result.addFailure(PoolBatchFailItem.of(id, PoolBatchFailReason.UNKNOWN, "删除失败"));
}
}
return result;
}
// ==================== 供 crm-lead 调用 ====================
@Override

100
crm-rule/src/test/java/com/crm/rule/service/impl/LeadPoolBatchDeleteTest.java

@ -0,0 +1,100 @@
package com.crm.rule.service.impl;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.domain.result.BatchResult;
import com.crm.rule.constant.RuleConstants;
import com.crm.rule.domain.dto.PoolBatchFailItem;
import com.crm.rule.domain.enums.PoolBatchFailReason;
import com.crm.rule.service.ILeadPoolService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* 公海池批量删除规格验证ADR-0023 D5 方案 A / ticket 13
* <p>批量删除逐条委派 {@code self.deletePool}各自独立事务单条失败以
* {@link BusinessErrorException} 抛出 code 映射为 {@link PoolBatchFailReason}
* 记入 {@link BatchResult} 而不拖垮整批本期单条 deletePool 只抛 NOT_EXIST64004</p>
*/
@DisplayName("公海池批量删除 deletePoolBatch(ADR-0023 D5)")
@ExtendWith(MockitoExtension.class)
class LeadPoolBatchDeleteTest {
@Mock
private ILeadPoolService self;
private LeadPoolServiceImpl service;
@BeforeEach
void setUp() {
service = new LeadPoolServiceImpl(null, null, null, null, null);
ReflectionTestUtils.setField(service, "self", self);
}
@Test
@DisplayName("全部成功:total=successCount,无失败明细,逐条委派 self")
void allSuccess() {
doNothing().when(self).deletePool(1L);
doNothing().when(self).deletePool(2L);
BatchResult<PoolBatchFailItem> r = service.deletePoolBatch(List.of(1L, 2L));
assertThat(r.getTotal()).isEqualTo(2);
assertThat(r.getSuccessCount()).isEqualTo(2);
assertThat(r.getFailCount()).isZero();
verify(self).deletePool(1L);
verify(self).deletePool(2L);
}
@Test
@DisplayName("部分失败:NOT_EXIST(64004)映射为对应失败原因,成功条不受影响")
void partialFailure_notExistMapped() {
doNothing().when(self).deletePool(1L);
doThrow(new BusinessErrorException(RuleConstants.CODE_POOL_NOT_EXIST, "池不存在或已被删除"))
.when(self).deletePool(2L);
BatchResult<PoolBatchFailItem> r = service.deletePoolBatch(List.of(1L, 2L));
assertThat(r.getTotal()).isEqualTo(2);
assertThat(r.getSuccessCount()).isEqualTo(1);
assertThat(r.getFailCount()).isEqualTo(1);
assertThat(r.getFailures()).singleElement()
.extracting(PoolBatchFailItem::getPoolId, PoolBatchFailItem::getReason)
.containsExactly(2L, PoolBatchFailReason.NOT_EXIST);
}
@Test
@DisplayName("未知运行时异常兜底为 UNKNOWN")
void unexpectedException_mapsToUnknown() {
doThrow(new IllegalStateException("boom")).when(self).deletePool(9L);
BatchResult<PoolBatchFailItem> r = service.deletePoolBatch(List.of(9L));
assertThat(r.getFailCount()).isEqualTo(1);
assertThat(r.getFailures()).singleElement()
.extracting(PoolBatchFailItem::getReason)
.isEqualTo(PoolBatchFailReason.UNKNOWN);
}
@Test
@DisplayName("空 ids:空结果,不触碰 self")
void emptyIds_noop() {
BatchResult<PoolBatchFailItem> r = service.deletePoolBatch(List.of());
assertThat(r.getTotal()).isZero();
assertThat(r.getFailures()).isEmpty();
verifyNoInteractions(self);
}
}

76
docs/adr/0022-lead-module-read-write-separation-deep-modules.md

@ -0,0 +1,76 @@
# ADR-0022: 线索模块读写分离与深模块抽取——LeadServiceImpl 瘦身
## Status
Accepted
## Context
`LeadServiceImpl` 在线索业务持续叠加后长成「上帝类」:**530 行、11 个构造依赖、16 个 public 方法**,一个类同时承载了状态迁移、历史留痕、四视图查询、展示字段拼装、写侧命令编排。具体摩擦:
1. **重复**:状态守卫(起始态校验 + 持有人校验)散落在 `LeadServiceImpl``LeadTransitionImpl` 两处共 19 处判定;`writeHistory` / `buildDetail`(组装 `LeadHistory` + kv 序列化为 JSON + 落库)在两个类里**逐字复制**,唯一差异是 transition 版失败时 `log.warn` 而 service 版静默 `return null`
2. **依赖过载**:11 个依赖里混着读侧(`sysRegionService`、`leadHistoryMapper` 读)、写侧(`leadFeedbackMapper`、`historyRecorder`)、以及两个**死依赖**(`leadAttachmentMapper`、`sysDeptService`,声明了从不调用)。
3. **测试面模糊**:读逻辑(四视图 wrapper、`fillDisplayFields`)从未被测过,混在写侧命令测试的同一个类里无从下手。
约束(继承自既有 ADR):`LeadTransition` 必须保持对 crm-rule / crm-auth 的**零依赖契约**(ADR-0020/0021 语境);controller 只认 `ILeadService` 契约,抽取不得改变对外接口。
## Considered Options
- **按操作类型横切三分**(`LeadQueryService` / `LeadCommandService` / `LeadTransitionService`)——被否:每块都是「校验+委派」的薄转发层,接口和实现一样厚,只是把 530 行摊成三个 180 行,依赖没减、深度没增。这是「为拆而拆」的 shallow module。
- **一次性大重构**——被否:风险集中、难验证。
- **按「深度」抽取证据最硬的接缝,逐个 grill + 验证(采纳)**:只抽那些能把一整块机制藏在窄接口后、让调用方变简单的接缝;每抽一个跑全量测试 + BOM 扫描;抽完用瘦身后的数据决定是否继续。
## Decision
分三轮抽出三个**深模块**,每个吃掉一整块机制、收走对应依赖:
### 1. LeadTransition 强化——状态机守卫下沉(候选 1)
散落两处的状态守卫收进 `LeadTransition` 内的统一 `guard(lead, cmd)`,在 `execute` 分派前跑一次。每条 `TransitionCmd` **声明式**自带 `allowedFromStatuses()`(合法起始态集合)与 `requiresOwner()`(是否要求操作人=领取人,纯代码常量、**不建表**)。
- 非法起始态 → `CODE_STATUS_NOT_ALLOWED`;非领取人 → `CODE_NOT_OWNER`。守卫在 CAS(ADR-0021)之上给出**友好前置**,CAS 仍是真正的并发防线。
- 「起始态 × 命令内容」的组合子规则(作废+反馈无效、作废自环换 owner)**仍留在各 `applyXxx`**,与目标态计算保持 locality。
- ADMIN_ONLY 池规则、转商机端口调用时序等需要查 crm-rule / port 的前置,**留在 `LeadServiceImpl`**,守住 `LeadTransition` 的零依赖契约。
### 2. LeadHistoryRecorder——历史留痕深模块(候选 2)
新建 `com.crm.lead.history.LeadHistoryRecorder`(接口)+ `impl.LeadHistoryRecorderImpl`(`@Component`)。单方法藏起整条机制:
```java
void record(Long leadId, HistoryType type, Long userId, Object... detailKv);
```
- `detailKv` 为 k1,v1,k2,v2… 序列化为 JSON;**空 kv 存 `null`**(保持历史行为,不写 `{}`);序列化失败统一 `log.warn` 并存 null(吞并原本一处记日志一处静默的分歧)。
- 系统触发的回收/失效传 `userId = 0L`
- 仅依赖 crm-lead 内部 mapper/实体/枚举,注入 `LeadTransition` 不破坏其零依赖契约。
- `LeadServiceImpl``LeadTransition` 各自的 `writeHistory` / `buildDetail` 删除;`LeadTransition` 由此移除 `leadHistoryMapper` + `objectMapper` 两个依赖,`LeadServiceImpl` 移除 `objectMapper`
### 3. LeadViewQuery——线索读侧深模块(候选 3)
新建 `com.crm.lead.query.LeadViewQuery`(接口)+ `impl.LeadViewQueryImpl`(`@Component`),吃下整个读路径:
```java
PageResult<LeadDTO> pageLeads(LeadPageParam param); // 四视图分页 + 展示拼装
LeadDTO getLeadDetail(Long id); // 详情 + 展示拼装
List<LeadHistoryDTO> listHistory(Long leadId); // 历史时间线(VISIBLE_TYPES)
```
- **独占读依赖**:`sysRegionService`(省市 code→name)、`leadHistoryMapper`(历史读)、`leadFollowMapper`(关注数/是否关注读,与写侧共享同一 mapper bean)。
- **自持 `LeadMapper`**:`pageLeads` 从继承基类的 `this.page()` 改为 `leadMapper.selectPage(...)`——读模块不再借 `BaseServiceImpl` 的能力。
- `LeadServiceImpl` 的三个查询方法瘦成一行委派,`ILeadService` 契约不变,controller 无感。
### 4. 停在三个深模块,不再拆
拆完后 `LeadServiceImpl` 只剩**写侧命令编排**。剩余命令若强按 command 三分只会造 shallow 转发层。唯一还算深的候选 `convertToOpportunity`(端口协调 + 事务边界 + 幂等前置)目前只有单一调用点、约 45 行,抽独立协调器 earns 不到 keep——**推迟到商机模块落地、事务/补偿变复杂时再抽**(届时它才够深)。
## Consequences
- **LeadServiceImpl 瘦身**:530 → **437 行**,11 → **8 依赖**(移走 5:`objectMapper`/`leadHistoryMapper`/`sysRegionService` + 2 死依赖 `leadAttachmentMapper`/`sysDeptService`;新增 `leadViewQuery`),职责收敛为单一的「写侧命令编排」。
- **零依赖契约保持**:三个抽取都未让 `LeadTransition` 触达 crm-rule/crm-auth;`LeadHistoryRecorder`/`LeadViewQuery` 只碰 crm-lead 内部或既有 crm-rule 读服务。
- **对外契约不变**:controller 仍只认 `ILeadService`,三个查询方法签名不动,仅内部委派。
- **测试面清晰化**(replace, don't layer):状态守卫真测迁至 `LeadTransitionImplTest`(7),历史留痕真测在 `LeadHistoryRecorderImplTest`(2),读侧真测新建 `LeadViewQueryImplTest`(4);`LeadServiceImplTest` 只留写侧命令 + 三个「委派验证」用例(44)。全模块 **57 测试通过**
- **可换性更好**:换历史存储/换读实现只需换对应 `*Impl`,`LeadServiceImpl` 一行不改;接口+实现分离与既有 `LeadTransition` 风格一致,便于 mock。
- **术语沉淀**:`crm-lead/CONTEXT.md` 新增三条 —— 状态机守卫 / 历史记录器 / 线索读侧,各带 `_Avoid_` 反例。
- **读侧魔法值清理(抽取后跟进)**:`LeadViewQueryImpl` 四视图 `switch` 原用字符串字面量(`"PUBLIC_POOL"` 等),新建 `com.crm.lead.domain.enums.LeadViewType` 枚举承载(与 `HistoryType` 同处),含 `DEFAULT=MANAGE``fromValue(String)` 兑底(空/非法值回落 MANAGE),`switch` 改为 switch-on-enum 全视图覆盖。**wire 契约不变**——`LeadPageParam.viewType` 仍为 `String`,前端传值方式不动。
- **预留 scope 常量澄清**:`LeadConstants.SCOPE_*`(四视图列偏好 `scope_key`)是为「线索列表接入列偏好」**预留但尚未接线**的域常量。保留不删:归属正确(scope_key 由业务方 crm-lead 约定,crm-preference 仅作不透明存储,见 crm-preference/CONTEXT.md)。**依赖方向锁死处理方式**:`crm-lead → crm-preference` 单向,故不得让 crm-preference 反向引用本常量(否则成环);仅在常量处添注释讲清预留意图与此约束。
- **决策来源**:`improve-codebase-architecture` skill 三轮 grill(2026-01);深度判据见 `codebase-design` skill。

61
docs/adr/0023-lead-batch-operations-and-view-stats.md

@ -0,0 +1,61 @@
---
status: accepted
---
# 线索模块批量操作与视图统计接口补全(G1–G5)
线索原型(A2-1-1 / A2-1-4 / A7-3-1 + 分配/激活弹窗)要求**批量操作**与**顶部统计卡片**,但已实现代码只有单条流转、无批量、无统计。本 ADR 把接口完整性复核(grill-with-docs)定稿的 G1–G5 固化为架构决策。范围**只含 G1–G5**;G6(线索导入/导出)/ G7(线索合并)/ G8(公海池导入/导出)维持既有 Out of scope(见 `.scratch/clue-module/map.md`)。
决策来源:`.scratch/clue-module/线索业务-PRD.md` §13(2026 grill-with-docs,决策清单 B1–B9)。
## Decisions
### D1 批量操作:非原子、逐条 CAS、部分成功
批量领取/分配到池/分配到销售/释放/激活/删除,统一**逐条委派给对应单条操作**(各自独立事务),任一条失败只记入结果、不拖垮整批。承接 ADR-0021:批量的「失败」= 单条 CAS 行数 0 或单条守卫抛出的业务错误。
- URL:单条动词 + `-batch` 后缀(`/api/lead/claim-batch` 等),与既有短横线风格一致(B4)。
- 入参:`@RequestParam("ids") List<Long>`;批量分配双深度——到池带 `poolId`,到销售带 `userId`(与单条 `assign-pool`/`assign-user` 对称,B5)。
- 逐条独立事务的落地:`LeadServiceImpl` 用 `@Lazy` self-injection 走 AOP 代理调单条方法(沿用 `AuthServiceImpl` 既有模式),批量方法本身不加 `@Transactional`
### D2 批量返回 `Result<BatchResult<F>>`(B2/B3)
- **`BatchResult<F>`(泛型骨架)放 crm-base/domain/result**,与 `Result`/`PageResult` 并列,只装 `total/successCount/failCount/List<F> failures`,对失败项内部结构无感知——保持 crm-base 非业务纯净。
- **线索域失败项 `LeadBatchFailItem`(leadId + `LeadBatchFailReason` + message)放 crm-lead**
- **`LeadBatchFailReason` 新造语义枚举**(`ALREADY_CONVERTED` / `CONCURRENT_MODIFIED` / `OVER_HOLD_LIMIT` / `OVER_DAILY_LIMIT` / `STATUS_NOT_ALLOWED` / `NOT_OWNER`,各带 `code` 回指 ResultCode 65xxx,B6),供前端按失败类型聚合展示「成功 N 条 / 失败 M 条(各类型明细)」。
### D3 视图统计接口 `/api/lead/stats`(B7/B8)
- 入参**复用 `LeadPageParam`**,返回 `Result<LeadStatsDTO>`(total/claimed/converted/todayNew/undistributed)。
- **统计口径 = 当前视图数据集口径,非全库**:与 `/page` 吃完全相同的 viewType + 筛选 + `@DataScope` 部门天花板,只把「取一页」换成「按 status 分组计数」。接口通用、全量返回 5 个计数,前端按需取。
- `claimed`(「已被领取」,展示文案前端渲染)= status IN (已领取 3, 跟进中 4)。
### D4 复合卡片下钻:`LeadPageParam.status` 单值 → `statusIn` 多值(B9)
统计卡片可点击下钻,把该卡状态条件塞进 `LeadPageParam` 再调 `/page`。「已被领取」是 status IN(3,4) 的并集,单值 `Integer` 表达不了,故 `status` 升级为 `List<Integer> statusIn`,`/page` 与 `/stats` 共用。改动落在接口未联调/未发文档阶段,成本低。
### D5 公海池批量删除的失败项归属(跨模块依赖方向约束)
`pool/delete-batch` 属 crm-rule;但 `LeadBatchFailItem`/`LeadBatchFailReason` 在 crm-lead,而 **crm-lead 依赖 crm-rule,crm-rule 不能反向依赖 crm-lead(否则成环)**。故池批量删除**不能**复用线索的失败项类型。
**决策:crm-rule 自建失败项,复用 crm-base 的 `BatchResult<F>` 泛型骨架(方案 A)。**
- `PoolBatchFailReason`(crm-rule 域枚举,各值带 `code` 回指 `RuleConstants` 64xxx):`NOT_EXIST`(64004)、`HAS_ACTIVE_LEAD`(64005,池下有非终态线索)、外加 `UNKNOWN` 兜底。
- `PoolBatchFailItem`(poolId + `PoolBatchFailReason` + message)放 crm-rule。
- `pool/delete-batch` 返回 `Result<BatchResult<PoolBatchFailItem>>`,与线索侧对称。
- 与线索侧完全对称:crm-base 出泛型骨架,各业务域自持失败语义枚举 + 失败项,天然无环。
> 注:`CODE_POOL_HAS_ACTIVE_LEAD`(64005) 当前是**预留常量**——单条 `deletePool` 尚未实现「池下有非终态线索则拒删」的守卫。**本期不补该守卫**(grill 决策):`PoolBatchFailReason.HAS_ACTIVE_LEAD` 同样预留,本期池批量删除实际只会产出 `NOT_EXIST`/`UNKNOWN`。理由:该守卫是独立业务规则,且其查询需跨 crm-rule→线索表(又触及 crm-rule 不能依赖 crm-lead 的方向难题),值得单独 ADR/issue 设计查询归属,不在本批量补全范围。枚举值预留不影响契约,后续补守卫时零契约变更。
## Considered Options(D5)
- **A crm-rule 自建失败枚举/失败项(选中)**(`PoolBatchFailReason` + `PoolBatchFailItem`,同样装进 crm-base 的 `BatchResult<F>`)——各域自持失败语义,crm-base 泛型骨架复用,无环;池删除失败原因(池下有非终态线索 vs 不存在)可结构化返回,前端能分类提示。
- **B `pool/delete-batch` 返回 `BatchResult<Long>`**(failures 只装失败的 poolId,不带原因枚举)——被否:最省但丢失「为何失败」,前端无法分类展示,而「池下有非终态线索」是需要明确提示用户的业务态。
- **C 池批量删除本期不做**——被否:原型 A7-3-1 明确有「批量删除公海池」,且 A 成本可控。
## Consequences
- 新增 `crm-base` 通用件 `BatchResult<F>`,可被任意业务域批量接口复用。
- crm-lead 新增 `stats` 读接口与 6 个 `-batch` 写接口;`LeadPageParam` 契约变更(status→statusIn),需同步读侧 wrapper 与相关测试。
- 批量接口不引入新的上限校验逻辑——`OVER_HOLD_LIMIT`/`OVER_DAILY_LIMIT` 枚举值预留给单条操作后续补齐上限校验时自然生效(当前单条 `claimLead`/`assignToUser` 尚未实现上限校验,属既有 gap,不在本 ADR 范围)。
- D5 依赖方向约束:crm-base 出泛型 `BatchResult<F>`,crm-lead 与 crm-rule 各自持有本域失败项/失败原因枚举,避免跨业务域依赖成环——此模式作为后续任何模块批量接口的范式。

1
tmp/lead_pages.json

File diff suppressed because one or more lines are too long

34
tmp/mcp_tools.py

@ -0,0 +1,34 @@
import json, urllib.request
URL = "http://127.0.0.1:8000/mcp"
HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
def post(body, session=None, timeout=120):
h = dict(HEADERS)
if session:
h["mcp-session-id"] = session
req = urllib.request.Request(URL, data=json.dumps(body).encode(), headers=h, method="POST")
resp = urllib.request.urlopen(req, timeout=timeout)
sid = resp.headers.get("mcp-session-id", session)
result = None
for raw in resp:
line = raw.decode("utf-8", "replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
try:
d = json.loads(line[5:].strip())
except Exception:
continue
if isinstance(d, dict) and ("result" in d or "error" in d):
result = d
break
return sid, result
sid, _ = post({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"pi","version":"1.0"}}}, timeout=30)
h = dict(HEADERS); h["mcp-session-id"] = sid
urllib.request.urlopen(urllib.request.Request(URL, data=json.dumps({"jsonrpc":"2.0","method":"notifications/initialized"}).encode(), headers=h, method="POST"), timeout=15).read()
_, res = post({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}, session=sid, timeout=60)
for t in res["result"]["tools"]:
print(t["name"], "::", t.get("description",""))
print(" input:", json.dumps(t.get("inputSchema",{}).get("properties",{}), ensure_ascii=False))
Loading…
Cancel
Save