17 KiB
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 |
文档缺口/漂移:
- menuBindings 缺 dict 条目 → 示例值无 viewType/scopeKey 可依(ADR-0024 菜单粒度);folder.bru 仅 name/seq 无绑定。
字典项分页.bru文档表:keyword 写「匹配名称/编码」,代码实际匹配 name/code/value(DictItemServiceImpl.java:62-67)。字典项分页.bru响应表缺parentId、children字段(DictItemDTO.java:22, 52)。- 文档示例仍列
orderBy/asc参数且说明可排序,但服务端强制置 null(§2.8)——语义为「收了不用」。 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 忽略。 |