# 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`(见 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` | ### 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`(见 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` | 合计 **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 忽略。 |