# 数据字典模块(crm-dict) Status: ready-for-agent ## Problem Statement CRM 业务模块里散落着大量枚举取值——商机阶段/状态、关闭原因、暂缓原因、客户来源、行业分类等。目前这些取值要么硬编码在代码里,要么散落在各业务表的枚举字段中,改一个文案要发版、加一个选项要改代码。平台超级管理员无法在运行时统一维护这些"字典",业务规则模块和外部接口也没有一个稳定的编码体系可以取值。用户需要一套可视化的、两级结构(分组 → 字典项)的数据字典管理,让业务取值与展示文案解耦、稳定编码供各方引用、运行时可维护而无需发版。 ## Solution 新建独立模块 `crm-dict`,实现"系统设置 > 数据字典"功能。提供两个页签:**字典分组**(第一级容器)和**字典列表**(第二级键值项)。管理员可对分组和字典项做增删改查、启用/停用、设默认、排序。业务模块通过稳定的**字典编码**引用字典项(不存名称、不存主键),页面展示时按编码取最新名称。所有写操作服务端强制校验唯一性、引用关系、状态合法性;访问控制走权限点(`dict:*`)。字典读路径走 Caffeine 本地缓存(10 秒 TTL)扛高频读,引用计数走实时查询保证删除判定准确。 架构决策全部记录在 **ADR-0015**(`docs/adr/0015-data-dictionary-crm-dict-two-level.md`);领域术语见 **`crm-dict/CONTEXT.md`**。本 spec 是 ADR 的下位实现说明,不重复论证已定架构,直接引用。 ## User Stories ### 字典分组管理 1. 作为管理员,我希望分页查看字典分组列表,以便浏览系统中所有分组 2. 作为管理员,我希望看到每个分组的字典数量(该分组下未删除的字典项数,含启用+停用),以便了解分组规模 3. 作为管理员,我希望按分组名称/编码搜索分组,以便快速定位 4. 作为管理员,我希望按状态(启用/停用)筛选分组,以便聚焦有效分组 5. 作为管理员,我希望新建字典分组并填写名称(必填)、编码(必填)、排序号(选填)、描述(选填),以便定义一类字典 6. ~~作为管理员,我希望分组编码未填时系统自动生成,以便省去手工命名~~(**作废,Q1 决策:编码不自动生成,管理员必填**) 7. 作为管理员,我希望分组编码创建后不可修改,以便作为稳定标识不被破坏 8. 作为管理员,我希望分组编码全局唯一被强制校验,以便两个分组不会共享编码 9. 作为管理员,我希望编辑分组的名称、排序号、描述,以便维护分组信息 10. 作为管理员,我希望启用/停用分组(需二次确认),以便控制分组是否对业务生效 11. 作为管理员,我希望分组停用后,其下所有字典项都不再进入业务选择器(即使字典项自身是启用状态),以便临时下线整组取值 12. 作为管理员,我希望删除分组仅在该分组下无任何未删除字典项时才允许,否则被阻断并提示,以便不会误删仍有内容的分组 13. 作为管理员,我希望内置分组(builtin=true)不可删除,但可改名/改描述/调排序/启停,以便平台字典不被误删而仍可本地化 ### 字典项管理 14. 作为管理员,我希望切到"字典列表"页签查看字典项,以便管理具体键值条目 15. 作为管理员,我希望按分组筛选字典项列表,以便查看某分组下的所有项 16. 作为管理员,我希望按字典名称/编码/值搜索字典项,以便快速定位 17. 作为管理员,我希望按状态筛选字典项,以便聚焦有效项 18. 作为管理员,我希望新建字典项并选择所属分组(必填,只能选启用状态的分组)、填写名称(必填)、编码(选填自动生成)、值(选填)、排序号(选填)、描述(选填),以便定义一条取值 19. 作为管理员,我希望字典编码未填时按 `dict_` + 32 位小写十六进制 UUID 自动生成,以便省去命名 20. 作为管理员,我希望字典编码在其所属分组内唯一被强制校验,以便同组内编码不重复 21. 作为管理员,我希望字典编码创建后不可修改,以便作为业务引用的稳定标识 22. 作为管理员,我希望字典项创建后所属分组也不可修改,以便身份稳定、历史引用不错乱(比 spec 更严,见 ADR-0015) 23. 作为管理员,我希望字典值在同一分组内唯一被校验(空值不参与校验),以便同组取值不冲突 24. 作为管理员,我希望编辑字典项名称,且改名后所有历史业务数据展示最新名称,以便文案调整无需改历史数据 25. 作为管理员,我希望未被引用的字典项其字典值可以修改,被引用后字典值只读并提示,以便被业务依赖的取值不被随意改动 26. 作为管理员,我希望把某字典项设为分组默认项,系统自动取消同组原默认项(一次事务完成),以便每组至多一个默认 27. 作为管理员,我希望字典项停用时若它是默认项则自动取消默认状态,以便停用项不再作为新建数据的初始值 28. 作为管理员,我希望分组停用时其默认项关系被保留(休眠),分组重新启用后原默认项继续生效(若其自身仍启用),以便临时下线不丢默认配置 29. 作为管理员,我希望删除未被引用的字典项可直接删除(不要求先停用),以便清理无用项 30. 作为管理员,我希望删除被引用的字典项时被阻断并提示"该字典已被引用,无法删除",以便不破坏历史数据 31. 作为管理员,我希望内置字典项(builtin=true)不可删除,但可改名/改描述/调排序/启停,以便平台硬依赖的字典不被误删 32. 作为管理员,我希望字典项列表中对"自身启用但所属分组已停用"的失活项有视觉标记(角标/灰化),以便一眼看出它当前不可用 ### 业务侧取值 33. 作为业务模块开发者,我希望通过 `(分组编码, 字典编码)` 反查唯一字典项,以便业务字段按编码取值 34. 作为业务模块开发者,我希望查询某分组下的启用字典项列表(供业务选择器渲染),只返回分组和项都启用的项,以便下拉只出现有效选项 35. 作为业务模块开发者,我希望编辑历史业务数据时原字典值回显,若该项已停用则回显并标注"已停用"、清除后不可再选,以便历史数据可见但不误导 36. 作为业务模块开发者,我希望在保存业务数据(引用字典编码)的同一事务里登记引用计数(+1),以便字典侧能准确判定"被引用" 37. 作为业务模块开发者,我希望在清除/改动业务字段的字典编码时同事务扣减引用计数(-1),以便计数反映真实引用 38. 作为业务模块开发者,我希望批量导入业务数据时走"应用层聚合 delta + 单条 UPDATE"更新引用计数,以便不产生行锁热点导致吞吐塌方 ### 权限与初始化 39. 作为平台,我希望数据字典所有接口走权限点(`dict:*`)控制,配了权限的角色才能访问,以便权限统一由 RBAC 管理 40. 作为部署者,我希望首批内置字典由产品提供的 Excel 通过幂等初始化脚本导入,任何时候可重跑,以便各环境字典一致 41. 作为部署者,我希望初始化脚本只 upsert builtin=true 的字典、绝不触碰 builtin=false 的用户字典,以便管理员手建的数据不被覆盖 42. 作为部署者,我希望初始化脚本只做加/改不做删,Excel 里消失的字典项不被自动删除,以便删除决策始终走人工界面 43. 作为部署者,我希望导入前校验编码唯一、同组字典值不重、引用分组存在、每组至多一个启用默认项、字段格式合法,以便脏数据不进库 ### 运维兜底 44. 作为管理员,我希望在引用计数因故飘高、导致实际无引用的字典项无法删除时,有一个"强制删除"入口绕过 ref_count 校验,以便手工判定后仍能清理 45. 作为开发者,我希望字典读路径走本地缓存(10 秒 TTL)扛高频读,以便不给数据库造成压力 46. 作为开发者,我希望引用计数永不进缓存,删除判定读实时值,以便不因缓存导致误删/误阻断 ## Implementation Decisions > 全部架构决策见 **ADR-0015**。以下为落地实现要点,不重复论证。 ### 模块与依赖 - **新建 Maven 模块 `crm-dict`**,`packaging=jar`,仅依赖 `crm-base`(不 import `crm-auth` 任何类)。在 `crm-app/pom.xml` 注册依赖,在根 `pom.xml` 注册模块,在 `CONTEXT-MAP.md` 已登记。 - 包结构对齐现有模块:`controller/`、`domain/{dto,entity,enums,param}/`、`mapper/`、`service/impl/`、`config/`。 - 实体继承 `BaseEntity`(自带雪花 id、审计字段、`@TableLogic deleted`)。 ### 数据模型(Schema) - **`dict_group`**(字典分组):`name`、`code`(全局唯一)、`sort_no`(可空)、`status`、`description`、`builtin`、`delete_key`。唯一索引 `unique(code, delete_key)`。 - **`dict_item`**(字典项):`group_id`、`name`、`code`、`value`(可空)、`sort_no`(可空)、`status`、`description`、`builtin`、`delete_key`。唯一索引 `unique(group_id, code, delete_key)`、`unique(group_id, value, delete_key)`(空值不参与)。**不含 `is_default`、不含 `parent_id`**。 - **`dict_group_default`**(分组默认项):`group_id`(主键)、`item_id`。以主键约束保证每组最多一个默认项——"每组一默认"从业务规则升级为 DB 结构性约束。 - **`dict_ref_count`**(引用计数):`(group_code, item_code)` + `ref_count`。判定被引用 = `ref_count > 0`。只记"有没有",不记"被谁"。 - **软删除可复用**:`delete_key` 存活时固定 `0`,逻辑删除时写为该行主键 id;删除操作同事务写 `deleted=1` + `delete_key=id`,不能只靠 `@TableLogic` 自动填充。 ### 唯一性与引用契约(ADR-0015) - 分组编码**全局唯一**;字典编码**分组内唯一**;字典值**同组唯一**(空值不校验)。 - 业务数据只存**字典编码**(不存 id、不存名称);定位需 `(分组编码, 字典编码)`,分组由**字段-分组绑定**在设计期隐式提供,运行时只存编码一列。 - **一个业务字段只绑定一个字典分组**,禁止跨分组并集下拉;需合并选项时另建合并分组。 ### 默认项与状态语义(ADR-0015) - 设默认 = `dict_group_default` 的 `INSERT ... ON DUPLICATE KEY UPDATE item_id=?`(同库事务),无需应用层"再校验+行锁"。 - 查"当前默认项"的服务方法内部**联查 group.status**,分组停用返回 null(分组状态是权威真相源,`dict_group_default` 只是记录)。 - 有效可选 = `group.status==enabled AND item.status==enabled`;服务层按需联查两状态。 - 字典项停用 → 同事务删 `dict_group_default` 行;分组停用 → 保留该行(休眠默认)。 ### 引用计数(ADR-0015) - **同步 + 同库事务**:业务写入与 ref_count 增减在同一 JDBC 事务内,非异步、不走 MQ。 - **批量/导入场景强制"应用层聚合 delta + 单条 UPDATE"**,不允许循环里逐条 upsert(防行锁热点)。 - **计数飘高兜底**:提供管理员**强制删除**入口绕过 ref_count 校验;不引入离线对账任务(对账需知"哪些表哪些字段引用哪分组",会请回反向依赖)。 ### 缓存(ADR-0015) - Caffeine 本地缓存 + `expire-after-write=10s`,可缓存分组/字典项元数据、启用项列表、当前默认项;空结果缓存 2 秒防穿透。 - **`dict_ref_count` 绝不缓存**——靠 **API 分层**守护:`DictQueryService`(带缓存、业务侧读元数据)与 `DictReferenceService`(不带缓存、删除校验读实时计数)方法签名级分开,业务侧根本读不到 ref_count。 - 变更写入**不主动 evict**,接受多副本 10 秒最终一致。 ### 权限(ADR-0015) - 所有接口走权限点:`@PreAuthorize("hasAuthority('dict:...')")`,权限码字符串是与 auth 体系的唯一契约。不引入"超级管理员"概念。 - 权限码建议:`dict:group:list`、`dict:group:save`、`dict:group:delete`、`dict:group:status`、`dict:item:list`、`dict:item:save`、`dict:item:delete`、`dict:item:status`、`dict:item:default`、`dict:item:force-delete`。权限点通过幂等初始化器种子化到权限资源树并授予管理员角色(参照 role-management 的做法)。 ### 初始化(ADR-0015) - 幂等初始化脚本按 `(group_code, item_code)` upsert,只管 `builtin=true`,绝不触碰 `builtin=false`。 - Excel 只加/改不删;`builtin` 创建后不可改(不能升降级)。 - 导入前校验:编码唯一、同组值不重、引用分组存在、每组≤1个启用默认项、字段格式合法。 ### API 契约 遵循全局接口契约(非严格 RESTful,POST 用 `application/x-www-form-urlencoded`,GET 参数走 query,id 按字符串收发)。详细请求/响应见前端集成文档 `docs/frontend-integration-data-dictionary.md`。端点清单: | 端点 | 方法 | 权限码 | 说明 | |------|------|--------|------| | `/api/dict/group/page` | POST | `dict:group:list` | 分组分页,`current`/`size`/`keyword`/`status` | | `/api/dict/group/saveOrUpdate` | POST | `dict:group:save` | 分组保存(新建编码必填且全局唯一;编辑 code 只读) | | `/api/dict/group/delete` | POST | `dict:group:delete` | 删分组(有项则阻断;builtin 阻断) | | `/api/dict/group/status` | POST | `dict:group:status` | 分组启停(二次确认在前端) | | `/api/dict/group/enabled-list` | GET | `dict:group:list` | 启用分组列表(新建字典项选分组用) | | `/api/dict/item/page` | POST | `dict:item:list` | 字典项分页,`groupId`/`keyword`/`status`/分页 | | `/api/dict/item/saveOrUpdate` | POST | `dict:item:save` | 字典项保存(分组/编码创建后只读;值被引用后只读) | | `/api/dict/item/delete` | POST | `dict:item:delete` | 删字典项(被引用则阻断;builtin 阻断) | | `/api/dict/item/force-delete` | POST | `dict:item:force-delete` | 强制删除(绕过 ref_count 校验) | | `/api/dict/item/status` | POST | `dict:item:status` | 字典项启停(停用连带取消默认) | | `/api/dict/item/set-default` | POST | `dict:item:default` | 设为分组默认项(同事务清原默认) | | `/api/dict/item/enabled-list` | GET | `dict:item:list` | 某分组启用项列表(业务选择器渲染用) | ### 对业务模块暴露的内部 API(非 HTTP) - `DictQueryService`(带缓存):按 `(groupCode, itemCode)` 查项、查分组启用项列表、查分组当前默认项。业务侧渲染/校验用。 - `DictReferenceService`(不带缓存):`increment(groupCode, itemCode)` / `decrement(...)` / `batchApply(Map delta)` / `isReferenced(groupCode, itemCode)`。业务侧写路径 + 字典删除校验用。**这是业务模块与 crm-dict 的写路径契约**。 ## Testing Decisions 好的测试只验证**外部行为**,不绑定实现细节。断言"跳过 Controller 也拦不住的规则确实被服务端强制",不断言"某方法被调了几次"。 ### Seam 1(主):HTTP 层集成测试(MockMvc) - `@SpringBootTest` + `MockMvc`,请求从 HTTP 进 → service → MyBatis → 测试数据源 → 出参。 - 覆盖:分组/字典项 CRUD、编码全局/组内唯一、字典值同组唯一、编码/分组创建后只读、被引用阻断删除、被引用后字典值只读、默认项互斥(设一个自动取消原默认)、分组停用后其项不进 enabled-list、字典项停用连带取消默认、分组停用保留休眠默认+重启恢复、builtin 不可删可改、软删后同编码可复用、权限点缺失返回无权限。 - **Prior art**:`crm-auth/service/impl/UserListIntegrationTest.java`、`crm-auth/security/scope/DataScopeIntegrationTest.java`。 ### Seam 2(辅):DictReferenceService 单元/集成测试 - 独立测引用计数写路径契约:increment/decrement 事务原子性、被引用判定、batchApply 聚合、强制删除绕过校验。 - 用同 seam 1 的测试基础设施,只测该 service。 - **为何独立**:这条路径业务模块也要调,是对外写路径 API,需独立契约测试;并发/事务语义在 Controller 层不好构造。 ### 明确不做 - 不单测 Mapper(SQL 正确性由 seam 1 顺带覆盖)。 - 不单测 Caffeine 本身(黑盒);缓存"改后重查看到新值(可能 10s 延迟)"由 seam 1 借时钟抽象验证。 ## Out of Scope - **多级/树形字典**:明确不做(ADR-0015)。字典项不加 parent_id。行业等"联动"需求用两个平铺分组 + value 承载父引用,或将来另建关联表,属另一 spec。 - **字典项换分组**:主动收窄为不支持(ADR-0015),换分组=删旧建新。 - **审计日志**(谁改了什么字典):属 `crm-audit` 模块职责,不在本 spec。 - **字典名称国际化/多语言**:spec 未要求,不实现。 - **拖拽排序**:后端只接受手填排序号;拖拽是前端功能,前端可自行在保存前重算所有排序号批量提交。 - **导出功能**:若原型有"导出全部字典",作为后续增量,不在本期。 - **Redis 分布式缓存失效广播**:本期只用 Caffeine 本地缓存 + 短 TTL;多副本秒级一致性升级为后续(ADR-0015 已预留升级路径)。 - **前端 UI 实现**:见独立的前端集成文档 `docs/frontend-integration-data-dictionary.md`。 ## Further Notes - 需求来源:蓝湖 Axure 原型「itc信息化业务中台 web端 V1.0-202607」页面 `A7-4-1 数据字典`(pageId `22f643af80fb452fa396b9ee166587e4`)。 - 领域语言以 `crm-dict/CONTEXT.md` 为准;本 spec 与前端文档中的术语(字典分组/字典项/字典编码/字典值/默认项/被引用/业务选择器/字段-分组绑定/内置字典/引用计数/强制删除)均引用该 glossary。 - 架构取舍全部记录在 ADR-0015,实现时若发现 ADR 与 spec 冲突,以 ADR 为准并回来修订本 spec。 - 前后端两份文档分工:本 spec 面向后端接口实现;`docs/frontend-integration-data-dictionary.md` 面向前端做原型与联调,含完整请求/响应示例、数据模型、交互态。