# 数据字典独立成 crm-dict,锁定两级平铺 数据字典(系统设置 > 数据字典)不放进 `crm-base`(其定位为"通用能力,非业务域",只装原语,无 controller/业务表),也不寄生在 `crm-auth`/`crm-rule`,而是新建独立模块 `crm-dict`。它是一个被商机、客户、规则等多方引用的独立能力,依赖方向单向流入:其他模块读字典,字典不反向依赖它们。字典模型锁定为**两级平铺**——字典分组(group)与字典项(item),`dict_item` **不加 `parent_id`**、不做自引用树。 ## Considered Options - **放 crm-base**:因"字典被所有模块引用"而误当共享基础设施。否决——"被引用" ≠ "通用原语",且直接违反 crm-base"非业务域"的定位。 - **放 crm-auth / crm-rule**:会制造别扭的反向依赖(字典本应被规则引用,而非住在规则里),且客户来源、行业等与"规则"无关的字典塞进 crm-rule 会稀释其领域含义。否决。 - **dict_item 预留 parent_id(先留字段不启用)**:看似便宜的"预留",实为语义地雷。spec 的核心约束——"同分组内字典值唯一""每分组最多一个启用默认项""字典数量统计""排序规则"——全部是为平铺定义的;一旦 parent_id 有值,这些规则语义(全组唯一还是同父唯一?默认项每组一个还是每父一个?)全部失效待重定义。留字段却留一堆未定义约束,将来填坑比现在加更贵。 - **直接做成树**:违反原型(170 页无任何多级信号),YAGNI。否决。 ## Uniqueness & Reference Contract - **分组编码全局唯一**;**字典编码分组内唯一**(联合唯一 `(group_id, code)`)。字典项从属于分组,编码在分组内区分即可;`OPP_CLOSE_*` 类前缀是产品可读性习惯,不作 DB 全局约束。 - **字典值** 同分组内唯一(`(group_id, value)`),空值不参与唯一校验。 - **业务侧引用契约**:业务数据只保存**字典编码**(不存主键 id,保可读性;不存字典名称,防改名错乱)。定位字典项需 `(分组编码, 字典编码)` 两坐标——其中分组由**字段-分组绑定**在设计期隐式提供,故运行时只存编码一列。 - **一个业务字段只绑定一个字典分组**,禁止跨分组并集下拉。需合并多组选项时,在字典侧另建合并分组,而非让字段跨组取值——否则单凭编码无法反推分组(如 `01` 无法区分 `OPP_CLOSE_01` 与 `OPP_PAUSE_01`)。 - 这是对 spec 字面"保存字典编码"的**采纳**(非偏离):编码承担运行时引用与初始化锚点双重身份,可读且各环境可移植。字典项换分组仅在"未被引用"时允许(spec 约束),故不会击穿字段绑定的旧分组假设——前提是"被引用"判定可靠。 ## Soft-Delete & Code Reuse - **逻辑删除位 `deleted`(`BaseEntity` 内置 Boolean)保持不动**,继续负责查询过滤。全局约定不受污染。 - `crm-dict` 的分组表与字典项表各加一个字段 **`delete_key`**:存活时固定 `0`,逻辑删除时业务代码将其写为该行主键 id(雪花 Long,全局唯一)。 - 唯一索引一律挂 `delete_key` 而非 `deleted`:`unique(code, delete_key)`(分组编码)、`unique(group_id, code, delete_key)`(字典编码)、`unique(group_id, value, delete_key)`(字典值,空值不参与)。这样同一编码/字典值可**无限次删除+重建**,永不撞索引。 - 实现约束:删除操作必须**同时**写 `deleted=1` 与 `delete_key=id`,两者在一个事务内一起完成,不能仅依赖 MyBatis-Plus 的 `@TableLogic` 自动填充。 ## Reference Tracking - **引用判定需求**:删除与编辑字典项时,服务端必须强制校验"是否被引用"(spec 硬约束)。不能反向扫业务表(会造成 crm-dict 反向依赖全部业务模块,违反单向依赖)。 - **引用计数表 `dict_ref_count(group_code, item_code, ref_count)`**:业务侧存入/清除字典编码时对应行 ref_count 增减。判定引用 = `ref_count > 0`。只记"有没有",不记"被谁引用"(放弃可审计副产品,换取数据量降 3-4 个数量级:从上百万行降到与字典项总数同量级的几千行)。 - **同步 + 同库事务**:业务写入与 ref_count 增减在**同一个 JDBC 事务**内完成,非异步、不走 MQ。异步方案存在无法消除的判定窗口漏洞(T1 业务写入发消息 → T2 删字典查 ref=0 允许删 → T3 消息后到 → 业务数据指向已删字典),直接击穿 spec 保护,故拒绝。 crm-dict 与业务模块同 JVM 同数据库,"字典服务挂了"不成立。 - **并发热点 = 行锁热点**(导入 10 万客户、行业=制造业占 60% → 6 万事务抢同一行)。**对策:批量/导入场景强制走"应用层聚合 delta + 单条 UPDATE"**,不允许在循环里逐条 upsert。单笔业务写入不优化,直到实测出现热点再补。 - **不引入分片计数/异步写入**,除非压测数据支撑。 - **计数飘色兼底**:业务代码万一漏写 `ref_count--` 导致计数永久偏高、字典项永远不可删 → 提供**管理员强制删除**入口(绕过 ref_count 校验)。不引入离线对账任务,因为对账需知道"哪些表哪些字段引用了哪个分组",反而把反向依赖问题请回来。 ## Default Item as Structural Constraint - **默认项关系不放在 `dict_item` 上**(不设 `is_default` 字段),另建一张 `dict_group_default(group_id PK, item_id)` 专门承载每个分组当前的默认项。`group_id` 作主键 → **"每组最多一个默认项"从 spec 业务规则升级为 DB 结构性约束**,并发下重复设默认直接撞主键而失败,不依赖应用层"再校验"。 - 变更默认项 = `INSERT ... ON DUPLICATE KEY UPDATE item_id = ?`(同库事务)。取消默认 = 删对应行。字典项停用→同事务删 `dict_group_default` 行(实现 spec:停用时取消默认状态)。 - **spec 那句"必须一次事务完成 + 提交时再校验"的话术,是文档在补 schema 表达力的信号**。MySQL 不支持条件唯一索引,无法在 `dict_item.is_default` 上直接表达"每组至多一个 `true`"。正确做法不是在应用层反复“再校验 + 行锁",而是升级 schema 让约束进 DB。 ## Status Semantics - **"字典项是否可选" 是复合状态**:`effective_selectable = group.status == enabled AND item.status == enabled`。DB 上不决组合字段,服务层按需联查两个状态。 - **管理员字典列表页**:列展示 `item.status`(保持简洁),但当字典项处于"自身启用但分组停用"时,行上需加**视觉标记**(角标/灰化)提示失活,避免管理员困惑"为何业务侧选不到"。 - **默认项与分组停用的不对称解**:字典项停用→同事务删 `dict_group_default` 行(取消默认,spec);分组停用→**保留 `dict_group_default`** 行,成为"休眠默认",启用后自动恢复(spec)。查询"当前默认项"的服务方法内部必须**联查 group.status**,分组停用则返回 null。**分组状态是权威真相**,`dict_group_default` 仅是"若启用,默认是谁"的记录。 - **并发 "停用分组 vs 改默认项"**:无需额外锁。两者写入不冲突,即使终态为"分组停用 + `dict_group_default` 残留旧数据",也被上一条 group.status 联查自然拦下,语义无害。 ## Permission - **不引入"平台超级管理员"概念**。spec 中"仅平台超级管理员可用"作废:现有权限体系无此角色,所有控制走**权限点**(菜单权限、API 权限),角色配了就有、没配就没。默认只把权限点授予内置管理员角色,但这是初始化选择而非代码硬制。 - **`crm-dict` 只依赖 `crm-base`**,不 import `crm-auth` 的任何类。接口鉴权用声明式注解(如 `@PreAuthorize("hasAuthority('dict:group:manage')")`),权限码字符串是与 auth 体系的唯一契约。这保持依赖单向、权限体系可换。 ## Item Identity is Immutable - **字典项的身份 = `{分组, 编码}`**,两者创建后均不可修改。spec 4.3 字面允许"未被引用时可换分组",本 ADR **主动收窄**为"任何时候都不可换分组"。真需换分组→ 删旧项 + 新建。 - **偏离 spec 的理由**:“未被引用’判定靠 `dict_ref_count`,而计数可能因业务代码漏写 `--` 而飘低。一旦飘低就会错误放行换分组 → 业务侧按字段-分组绑定反查字典时查不到,历史数据回显错乱(数据损坏,比删不掉严重)。删不掉可靠管理员强制删除兼底,换分组错放无兵底。 - **对齐字典编码/分组编码都不可改的哲学**:身份从不应该可改。“建错了’的修复路径 = 删除 + 重建,而非“原地改属性”。 - **影响面**:字典项编辑接口的可改字段仅余:名称、字典值(未被引用时)、排序号、描述、状态。`dict_ref_count` 仅服务于删除判定,不再承担“换分组”这种数据完整性关键操作。 ## Initialization & Builtin Layering - **字典分两层**:分组与字典项均携 `builtin` 布尔标记(沿用 `SysRole` 已有的同名模式)。`builtin=true` 为**平台内置字典**(产品随版本发布、业务代码硬依赖);`builtin=false` 为**用户字典**(管理员自由新建)。 - **初始化脚本幂等且只管 `builtin=true`**:按 `(group_code, item_code)` upsert,任何时候可重跑。**绝不触碰 `builtin=false`** 的数据(管理员手建的字典不会被覆盖)。 - **Excel 只加/改不删**:Excel 中消失的字典项,初始化脚本**不删也不标废弃**,保持现状。删除必须走管理界面、受引用校验与人工确认抦开——“Excel 里没了”不作为删除依据,避免批量误删。 - **内置字典的可变面**:管理员可 **改名/改描述/调排序/启停用**。**禁止删除**(业务代码硬依赖,删了会崩)。停用允许——“暂时不用”不破坏引用,启用后可恢复。 - **内置与用户字典不能互相升降级**:`builtin` 创建后不可改,避免“把管理员建的项提升为内置”或“把内置项降级为用户项”这种带安全隐患的操作。 ## Read Cache - **采用 Caffeine 本地缓存 + 短 TTL(10 秒)**。字典读流量高且变更极低频,主动上缓存而非等到压测出瞋颈。不引入 Redis 缓存层——本地缓存 + 短 TTL 在多副本部署下造成的内恶一致窗口(⚤10 秒)可接受,避免引入分布式失效机制。 - **可缓存**:字典分组元数据、字典项元数据(名称/编码/值/状态/排序)、"某分组下启用字典项列表"、"分组当前默认项"。 - **绝不缓存**:`dict_ref_count`。引用判定必须读实时值(前面论证的窗口漏洞)。实现上**靠 API 分层**守住: - `DictQueryService`(业务侧读元数据、带缓存) - `DictReferenceService`(字典管理删除校验用、不带缓存) 两个服务方法签名级分开,业务侧根本读不到 ref_count;不靠"记得别缓存"的人约守。 - **失效策略**:只用 `expire-after-write=10s`,不用 refresh-after-write,避免引入后台刷新任务副作用。**变更写入不主动 evict**,接受 10 秒最终一致(字典一天可能不改一次,无需为实时性付出失效广播的复杂度)。 - **穿透保护**:空结果缓存短 TTL(2 秒),防止反复查不存在的编码打穿到 DB。 - **升级预留**:若将来发现"改名后 10 秒不刷新"被用户投诉,再引入 Redis pub/sub 广播失效,缓存层内部升级,服务接口不变。 ## Consequences - "多级/联动"需求(如行业二级联动:制造业→轻工业/重工业)用**两个平铺分组 + 字典项 value 承载父引用**表达,或将来另建独立"字典关联表"实现。灵活性体现在"字典之间可建立关联"这个**加法能力**上,而非把单表改成自引用树。此路可逆、便宜;改单表模型不可逆、污染全部约束。 - A7 后台管理按能力拆细:`crm-dict`(数据字典)、`crm-rule`(业务规则)、`crm-audit`(审计日志)平级,认证+用户+权限留在 `crm-auth`。不设大而全的 `crm-system`——"系统设置"是原型的 UI 菜单归类,不是领域边界。 - 数据字典菜单、页面与接口仅平台超级管理员可用;所有写操作服务端强制校验超管身份与唯一性/引用/状态合法性,前端隐藏入口不可替代。