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.
194 lines
18 KiB
194 lines
18 KiB
|
1 month ago
|
# 数据字典模块(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` 面向前端做原型与联调,含完整请求/响应示例、数据模型、交互态。
|