You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

204 lines
17 KiB

5 hours ago
# crm-dict 代码功能与接口现状盘点(票 02 资产)
日期:2026-09-07。所有行号基于当前工作区代码。路径缩写:`dict/` = `crm-dict/src/main/java/com/crm/dict/`
## 1. 端点表
### 1.1 DictGroupController(`dict/controller/DictGroupController.java`,`/api/dict/group`)
`@Tag(name = "A7 后台管理/数据字典-分组")`(DictGroupController.java:24)
| 端点 | 方法 | 权限码 | 入参 | 出参 |
|---|---|---|---|---|
| `/page` | POST | `dict:group:list` | `GroupPageParam`(表单绑定,见 1.3) | `PageResult<DictGroupDTO>`(见 1.4) |
| `/saveOrUpdate` | POST | `dict:group:save` | `DictGroupDTO`(表单绑定,双向 DTO) | `Void` |
| `/delete` | POST | `dict:group:delete` | `id: Long`(@RequestParam,必填) | `Void` |
| `/status` | POST | `dict:group:status` | `id: Long`、`status: Integer`(1/0) | `Void` |
| `/enabled-list` | GET | 无 @PreAuthorize(登录即可读) | 无参 | `List<DictGroupDTO>` |
### 1.2 DictItemController(`dict/controller/DictItemController.java`,`/api/dict/item`)
`@Tag(name = "A7 后台管理/数据字典-字典项")`(DictItemController.java:25)
| 端点 | 方法 | 权限码 | 入参 | 出参 |
|---|---|---|---|---|
| `/page` | POST | `dict:item:list` | `ItemPageParam`(表单绑定,见 1.3) | `PageResult<DictItemDTO>`(见 1.5) |
| `/saveOrUpdate` | POST | `dict:item:save` | `DictItemDTO`(表单绑定,双向 DTO) | `Void` |
| `/delete` | POST | `dict:item:delete` | `id: Long` | `Void` |
| `/force-delete` | POST | `dict:item:force-delete` | `id: Long` | `Void` |
| `/status` | POST | `dict:item:status` | `id: Long`、`status: Integer`(1/0) | `Void` |
| `/set-default` | POST | `dict:item:default` | `groupId: Long`、`itemId: Long` | `Void` |
| `/enabled-list` | GET | 无 @PreAuthorize | `groupCode: String`(@RequestParam,必填) | `List<DictItemDTO>` |
合计 **12 个端点**(分组 5 + 字典项 7),全部扁平动作动词风格、无 `@PathVariable`/`@PutMap`/`@DeleteMap`/`@RequestBody`,符合 ADR-0017。
### 1.3 分页 Param 字段
通用父类 `BaseParam`(crm-base `domain/param/BaseParam.java:14-27`):`current: Integer=1`、`size: Integer=10`(上限 500)、`keyword: String`、`orderBy: String`、`asc: Boolean=false`。**注意:分组/字典项两个分页实现均把 orderBy/asc 强制置 null(见 §2.8),即这两个参数收了但不用。**
| Param | 自有字段 | 说明 |
|---|---|---|
| `GroupPageParam`(dict/domain/param/GroupPageParam.java:16) | `status: Integer` | 1=启用 0=停用,空=全部 |
| `ItemPageParam`(dict/domain/param/ItemPageParam.java:15-19) | `groupId: Long`、`status: Integer` | groupId 空=全部 |
**现状过滤参数缺口**:无按 `builtin`、`isDefault`、`parentId`(层级)过滤的参数。
### 1.4 DictGroupDTO 字段(dict/domain/dto/DictGroupDTO.java:16-31)
继承 `BaseDTO`(id/createTime/updateTime,NON_NULL 序列化)。自有:`name`、`code`、`sortNo`、`status`、`description`、`builtin: Boolean`、`itemCount: Long`(未删除字典项数,含启用+停用,Service 聚合填充)。
### 1.5 DictItemDTO 字段(dict/domain/dto/DictItemDTO.java:18-52)
继承 `BaseDTO`。自有:`groupId`、`parentId`(两级树:顶层 null)、`name`、`code`、`value`、`sortNo`、`status`、`description`、`builtin`、`groupName`、`isDefault`、`referenced`(实时引用计数>0)、`effectiveSelectable`(分组+项+父项均启用)、`children`(仅 listTree 填充)。
## 2. 服务规则
### 2.1 分组 saveOrUpdate(`dict/service/impl/DictGroupServiceImpl.java:67-108`)
| 校验 | 位置 | 规则 |
|---|---|---|
| dto 非空 | :69 | null 抛 63001 |
| name 必填 | :75 | 空抛 63001 |
| sortNo 非负 | :78 | <0 63001 |
| 新建 code 必填 | :84 | 空抛 63001 |
| code 格式 | :87 | `^[A-Za-z0-9_]+$`(DictConstants.java:22) |
| code 全局唯一 | :90 | count 重复抛 63002;`deleted` 带 @TableLogic(BaseEntity.java:65)自动过滤软删行,且唯一索引挂 `(code, delete_key)`(DictGroup.java:20),软删后编码可复用 |
| 新建默认值 | :95-97 | status=1、builtin 由 InitBinder strip→DB false、deleteKey=0 |
| 编辑只读 | :102-105 | code/status 强制保留原值;builtin/createTime/creatorId null 跳过自动保留 |
### 2.2 字典项 saveOrUpdate(`dict/service/impl/DictItemServiceImpl.java:76-173`)
| 校验 | 位置 | 规则 |
|---|---|---|
| groupId 必选(新建) | :85-88 | 新建空抛 63001;编辑可省略(身份只读) |
| 换组拦截 | :91-94, :157-160 | 创建后改组抛 63008 |
| name 必填 | :96 | 空抛 63001 |
| sortNo 非负 | :99 | <0 63001 |
| value 空白归一 null | :103-105 | 空值不参与同组唯一 |
| parentId 自引用 | :111-113 | 抛 63001 |
| 父项存在 | :114-117 | 不存在抛 63010 |
| 父项同组 | :118-120 | 跨组抛 63001 |
| 父项必须一级 | :121-123 | parent.parentId 非空抛 63001(仅两级) |
| 新建分组可用 | :127-131 | 分组不存在抛 63011;停用抛 63005 |
| code 自动生成 | :133-135 | 空 → `dict_`+32 位 hex UUID |
| code 格式+组内唯一 | :137-142 | 抛 63001 / 63006(跨组可重复) |
| value 组内唯一 | :143, :170 | 抛 63007 |
| 新建默认值 | :144-146 | status=1、deleteKey=0 |
| 同事务预建计数行 | :148-153 | insert dict_ref_count(ref_count=0),保证 increment 有行可加 |
| 编辑 code 只读 | :161-164 | 改 code 抛 63008 |
| value 被引用只读 | :169, :262-271 | 值变化且 isReferenced 抛 63009(US-25) |
### 2.3 默认项处理
| 场景 | 行为 | 位置 |
|---|---|---|
| 设默认 `setDefault` | groupId/itemId 必填(63012);分组启用(63005)、项属同组(63012)、项启用(63012);`INSERT ... ON DUPLICATE KEY UPDATE item_id`(dict_group_default 主键=group_id),同事务覆盖旧默认 | DictItemServiceImpl.java:202-226;DictGroupDefaultMapper.java:15-17 |
| **停用字典项时对默认项** | **自动取消默认(同事务删除 dict_group_default 行),不阻断停用** | DictItemServiceImpl.java:196-199 → removeDefaultIfPresent(:299-304);US-27 |
| 停用**分组**时对默认项 | **保留默认记录(休眠),不取消**;重新启用后自动恢复;`getDefaultItem` 读时以分组状态为权威:停用分组返回 null | DictGroupServiceImpl.java:142(休眠注释);DictQueryServiceImpl.java:156-182(:166-172 权威真相源);US-28 |
| 删除字典项 | 同事务清默认记录(若是默认) | DictItemServiceImpl.java:255 |
| 删除分组 | 同事务清默认记录 | DictGroupServiceImpl.java:129-130 |
### 2.4 删除与 force-delete
| 操作 | 阻断条件 | 位置 |
|---|---|---|
| 删分组 | builtin(63003);组下有未删除字典项(63004) | DictGroupServiceImpl.java:110-122 |
| 删分组附带 | 软删(deleted=1+delete_key=id,编码可复用)+ 清默认 + 清 dict_ref_count(按 group_code) | DictGroupServiceImpl.java:124-132 |
| 删字典项 | builtin(63003);被引用(63009,走 `dictReferenceService.isReferenced``selectRefCount` 实时 SQL,绝不进缓存);一级项下有存活子项(63009「还有子项」分支,Q3-b 保守不级联) | DictItemServiceImpl.java:230-248;DictRefCountMapper.java:32-35 |
| 删字典项附带 | 软删 + 清默认 + 清计数行(同 group_code+item_code) | DictItemServiceImpl.java:250-258 |
| force-delete | `doDeleteItem(id, true)`:**仅跳过引用计数校验**,builtin/子项阻断仍生效,清计数行照做 | DictItemServiceImpl.java:181-185, :236 |
引用判定读取点:`isReferenced`(DictReferenceServiceImpl.java:54-57)→ `DictRefCountMapper.selectRefCount`(实时 SELECT dict_ref_count WHERE group_code/item_code,ref_count>0 即被引用)。列表 `referenced` 标记走 `queryReferencedKeys` 一次 OR 配对查询(DictItemServiceImpl.java:348-371)。
### 2.5 enabled-list 过滤语义
- 分组 `/enabled-list`:仅 `status=1` 分组,sort_no 空排最后再按创建时间(DictGroupServiceImpl.java:146-154)。
- 字典项 `/enabled-list`(`DictQueryService.listEnabledItems`):**分组启用 + 项启用 双条件**;分组不存在/停用返回空列表(US-11);排序同上(DictQueryServiceImpl.java:86-115)。
- `effectiveSelectable`(列表 enrich):分组启用 ∧ 项启用 ∧ (二级项时)父项启用(Q3-a 回溯父状态)(DictItemServiceImpl.java:311-345,:323-328 父状态批量查,:338-43 计算)。
- `listTree` / `listChildren`:复用启用扁平列表内存组树/过滤(DictQueryServiceImpl.java:118-153,Q4 不新建缓存键)。**注意:listTree/listChildren/getDefaultItem 目前无 Controller 端点暴露,仅 Java 契约供模块内调用。**
### 2.6 引用计数写路径(DictReferenceServiceImpl.java)
- `increment`/`decrement`/`batchApply` 均 `@Transactional(rollbackFor)`(:26, :32, :38)。
- `addDelta`(:62-79):原子 `UPDATE dict_ref_count SET ref_count = GREATEST(ref_count + delta, 0)`(DictRefCountMapper.java:23-27);行不存在且 delta>0 → 补插(DuplicateKeyException 时重试 UPDATE);delta<0 无行则忽略下限 0 不为负
- 接口契约要求业务写入与计数增减**同一 JDBC 事务**(DictReferenceService.java:9-10)。
### 2.7 Caffeine 缓存(DictQueryServiceImpl.java:39-49)
| 缓存 | 键 | TTL | 容量 |
|---|---|---|---|
| valueCache(正结果) | `item:{groupCode}:{itemCode}` / `enabledItems:{groupCode}` / `defaultItem:{groupCode}` | 10s(CACHE_TTL_SECONDS) | 10_000 |
| emptyCache(空结果防穿透) | 同上键,EMPTY 哨兵 | 2s(CACHE_EMPTY_TTL_SECONDS) | 10_000 |
变更写入**不主动 evict**,接受多副本 10s 最终一致(类注释 :25-27)。DictReferenceService 一律实时查询不进缓存(DictReferenceService.java:7-8)。
### 2.8 分页排序实现
分组/字典项分页均:先把 `param.setOrderBy(null); param.setAsc(null)`(DictGroupServiceImpl.java:49-50;DictItemServiceImpl.java:56-57),再 Wrapper `.last("ORDER BY sort_no IS NULL, sort_no ASC, create_time ASC")`(DictGroupServiceImpl.java:58;DictItemServiceImpl.java:68)——**排序固定,前端传排序参数被静默忽略**。keyword 匹配:分组 name/code(DictGroupServiceImpl.java:53-56);字典项 name/code/**value**(DictItemServiceImpl.java:62-67,注意 bruno 文档只写了名称/编码,见 §5)。
## 3. 消费方接线
依赖方向:crm-lead / crm-opportunity / crm-customer 的 pom 均直接依赖 crm-dict(如 crm-lead/pom.xml 注释「依赖方向单向」),**无 port/适配层,直接注入 `com.crm.dict.service.DictQueryService`**。
| 模块 | 调用点 | 业务动作 | 方法 | 分组码 |
|---|---|---|---|---|
| crm-lead | `query/impl/LeadViewQueryImpl.java:62`(字段)、:281-296 `fillDictNames`,由 :227 `fillDisplayFields` 串联 | 列表/详情**读路径回显**(无 @Transactional) | `getNames` ×4 | lead_source / brand / product / scene |
| crm-opportunity | `query/impl/OpportunityViewQueryImpl.java:75`、:223-234 `fillDictNames` | 列表/详情读路径回显 | `getNames` ×2 | opp_source / industry |
| crm-customer | `service/impl/CustomerServiceImpl.java:64`、:185 / :196(detail 内) | 客户详情读路径回显 | `getNames` ×2 | customer_type / industry |
**关键发现:ref_count 增减在业务侧 0 调用**。全仓 grep `increment(`/`decrement(`/`batchApply(` 与 `DictReferenceService`,除 crm-dict 自身(DictItemServiceImpl 的 isReferenced 删除校验、测试)外无任何业务模块调用。即:
- dict_ref_count 行只在字典项新建时预建为 0(DictItemServiceImpl.java:148-153);
- 业务保存/清除字典编码时**不**加减计数 → `referenced` 标记恒 false、被引用阻断(63009)与 value 只读(US-25)实际从不触发;
- 「同事务」接线点目前不存在,是消费方缺口而非 dict 侧缺口。
## 4. SEED_GROUPS 分组编码清单(`dict/config/DictDataInitializer.java:49-335`)
**24** 个内置分组(test 断言:24 组 + 229 项,DictDataInitializerTest.java:34):
| # | group_code | 名称 | sortNo | | # | group_code | 名称 | sortNo |
|---|---|---|---|---|---|---|---|---|
| 1 | lead_source | 渠道 | 1 | | 13 | pause_reason | 暂缓原因 | 13 |
| 2 | customer_status | 客户状态 | 2 | | 14 | pool_reason | 进入公海原因 | 14 |
| 3 | industry | 所属行业(两级树) | 3 | | 15 | project_role | 商机项目角色 | 15 |
| 4 | brand | 品牌 | 4 | | 16 | close_reason | 关闭原因 | 16 |
| 5 | product | 需求产品 | 5 | | 17 | locality_type | 项目属地 | 17 |
| 6 | scene | 需求场景 | 6 | | 18 | follow_way | 跟进方式 | 18 |
| 7 | opp_stage | 商机阶段 | 7 | | 19 | survey_seq | 勘察次数 | 19 |
| 8 | opp_source | 商机来源 | 8 | | 20 | apply_way | 申请方式 | 20 |
| 9 | opp_type | 商机类型 | 9 | | 21 | customer_role | 客户角色 | 21 |
| 10 | bid_form | 招标形式 | 10 | | 22 | attachment_type | 商机资料类型 | 22 |
| 11 | result_tag | 跟进结果标签 | 11 | | 23 | customer_type | 客户类型 | 23 |
| 12 | design_config | 设计配置 | 12 | | 24 | transfer_reason | 交接原因 | 24 |
种子幂等:builtin 分组按种子刷新、种子外旧内置项剪除、builtin=false 用户数据/同 code 用户分组绝不触碰(DictDataInitializer.java:409-476)。权限侧另有 `DictPermissionInitializer.java:35-59`:10 个权限码种子化到「系统管理/数据字典」菜单(/system/dict)。
## 5. 接口文档现状
| 项 | 现状 | 位置 |
|---|---|---|
| bruno-sync sourceRoots | **已含** `crm-dict/src/main/java` | bruno-sync.config.json:4 |
| menuBindings | **无「A7 后台管理/数据字典」条目**(A7 只有「客户管理设置」) | bruno-sync.config.json:8-19 |
| Controller @Tag | 分组/字典项各一个 tag(`A7 后台管理/数据字典-分组` / `-字典项`),非「数据字典」单一菜单 | DictGroupController.java:24;DictItemController.java:25 |
| 文档仓 | 外部仓 `D:/code/crm-api-docs`(bruno-sync.local.json:1) | — |
| 生成物 | `A7 后台管理/数据字典-分组/`(5 个 .bru:分组分页/新建编辑分组/删除分组/分组启停/启用分组列表)+ `数据字典-字典项/`(7 个 .bru:字典项分页/新建编辑字典项/删除字典项/强制删除字典项/字典项启停/设为默认项/启用项列表)——**12/12 端点全覆盖**,2026-08-31 提交(456cd21) | D:/code/crm-api-docs/A7 后台管理/ |
| 生成方式 | `.scratch/bruno-coldstart/gen_bruno.py` + `.scratch/bru_lib.py` 幂等脚本写外部仓;曾有「crm-dict 暂缓纳入」待办(2026-08-14 用户决定),后已补 | .scratch/bruno-coldstart/PENDING-review-and-crm-dict.md 待办2 |
文档缺口/漂移:
1. menuBindings 缺 dict 条目 → 示例值无 viewType/scopeKey 可依(ADR-0024 菜单粒度);folder.bru 仅 name/seq 无绑定。
2. `字典项分页.bru` 文档表:keyword 写「匹配名称/编码」,代码实际匹配 name/code/**value**(DictItemServiceImpl.java:62-67)。
3. `字典项分页.bru` 响应表缺 `parentId`、`children` 字段(DictItemDTO.java:22, 52)。
4. 文档示例仍列 `orderBy`/`asc` 参数且说明可排序,但服务端强制置 null(§2.8)——语义为「收了不用」。
5. `enabled-list`(业务选择器)与 listTree/listChildren 语义只存在于 Java 契约,端点文档仅覆盖 `/item/enabled-list`;树形/级联契约无文档承载。
## 6. 测试清单(crm-dict/src/test/java/com/crm/dict/)
| 文件 | 覆盖面(一句话) |
|---|---|
| `AbstractDictH2Test.java` | H2 MySQL 模式手工装配 MyBatis-Plus 集成测试基类:每方法新建服务实例(缓存隔离)+ TRUNCATE 清表。 |
| `config/DictDataInitializerTest.java`(T08,9 测试) | 种子幂等初始化:首装 24 组 229 项、重跑不重复、builtin 刷新不碰用户数据、默认项不覆盖、种子外剪除、同 code 用户组跳过、坏种子 fail-fast。 |
| `config/DictPermissionInitializerTest.java`(T07,2 测试) | 权限种子描述符正确性 + Controller 端点 @PreAuthorize 契约(enabled-list 放行除外)。 |
| `service/impl/DictGroupServiceTest.java`(T02,12 测试) | 分组分页(itemCount/keyword/status)、新建校验(必填/格式/唯一/默认值)、编辑保 code、删除阻断(有项/内置)、软删复用、启停与启用列表排序。 |
| `service/impl/DictItemServiceTest.java`(T04+T05,16 测试) | 字典项新建(自动编码/预建计数行/组内唯一/分组可用)、身份只读、值引用只读、删除规则、强删、停用取消默认、设默认互斥、分页 enrich、两级树校验、effectiveSelectable 父回溯。 |
| `service/impl/DictQueryServiceTest.java`(T06,9 测试) | 读路径与缓存:getItem 命中/未命中、10s 快照、启用列表过滤排序、分组停用返空、默认项基本/休眠恢复、2s 空负缓存、listTree、listChildren。 |
| `service/impl/DictReferenceServiceTest.java`(T03,6 测试) | 引用计数写路径:增减、累计、下限 0、batchApply 跨组聚合、无行 increment 补行、无行 decrement 忽略。 |