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.
12 KiB
12 KiB
数据字典独立成 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,不 importcrm-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 菜单归类,不是领域边界。 - 数据字典菜单、页面与接口仅平台超级管理员可用;所有写操作服务端强制校验超管身份与唯一性/引用/状态合法性,前端隐藏入口不可替代。