44 changed files with 2346 additions and 344 deletions
@ -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 序列。 |
|||
@ -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)。 |
|||
@ -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 无回归。 |
|||
@ -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。) |
|||
@ -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 无回归。 |
|||
@ -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 项。 |
|||
Binary file not shown.
@ -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); |
|||
} |
|||
} |
|||
@ -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"); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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-lead(crm-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; |
|||
} |
|||
} |
|||
@ -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_EXIST(64004)。</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); |
|||
} |
|||
} |
|||
@ -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。 |
|||
@ -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 各自持有本域失败项/失败原因枚举,避免跨业务域依赖成环——此模式作为后续任何模块批量接口的范式。 |
|||
File diff suppressed because one or more lines are too long
@ -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…
Reference in new issue