From 68b62e6e339e842fc43cf73e9cd6e5f52ecd2d4e Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 16:06:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(dict):=20=E7=A5=A809=20=E6=AD=A54=20indust?= =?UTF-8?q?ry=20=E9=87=8D=E7=A7=8D=E4=B8=A4=E7=BA=A7=E6=A0=91=2060=20?= =?UTF-8?q?=E9=A1=B9=20+=20=E7=A7=8D=E5=AD=90=E5=AF=B9=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E9=A1=B9=E6=9D=83=E5=A8=81(=E5=89=AA=E9=99=A4=E6=97=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemSeed 加 parentCode (兼容旧6参构造); upsertItem 按 parentCode 同组解析 parentId (一级先入库故可查到) - run 增 pruneStaleBuiltinItems: 种子对 builtin 项权威, 不在种子的旧内置项连 ref_count 一并剪除; 用户项(builtin=false)绝不触碰 (US-41) - industry 8旧扁平项(manufacturing/it/...) → 全系统通用两级树 9一级+51二级 (产品 20260825 全树) - 测试: 首装/幂等 168→220, 用户跳过 130→182; 加 industry 树结构断言(9一级/51二级); removedFromSeed 语义翻转为剪除 + 新增用户项不剪除测试; 51 全绿 --- .../crm/dict/config/DictDataInitializer.java | 129 ++++++++++++++++-- .../dict/config/DictDataInitializerTest.java | 51 +++++-- 2 files changed, 159 insertions(+), 21 deletions(-) diff --git a/crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java b/crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java index 2042429..37087fc 100644 --- a/crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java +++ b/crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Component; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; /** * 内置字典幂等初始化(T08,ADR-0015) @@ -91,16 +92,77 @@ public class DictDataInitializer implements CommandLineRunner { new ItemSeed("potential", "潜在客户", "potential", 1, null, true), new ItemSeed("active", "合作中", "active", 2, null, false), new ItemSeed("lost", "已流失", "lost", 3, null, false))), - new GroupSeed("industry", "所属行业", 3, "客户所属行业", + new GroupSeed("industry", "所属行业", 3, "全系统通用两级行业树(客户选二级/商机选一级,产品 20260825)", List.of( - new ItemSeed("manufacturing", "制造业", "manufacturing", 1, null, false), - new ItemSeed("it", "信息技术", "it", 2, null, false), - new ItemSeed("finance", "金融", "finance", 3, null, false), - new ItemSeed("healthcare", "医疗健康", "healthcare", 4, null, false), - new ItemSeed("education", "教育", "education", 5, null, false), - new ItemSeed("retail", "零售", "retail", 6, null, false), - new ItemSeed("real_estate", "房地产", "real_estate", 7, null, false), - new ItemSeed("other", "其他", "other", 8, null, false))), + // 一级 1 政府机关 + new ItemSeed("gov", "政府机关", "gov", 1, null, false), + new ItemSeed("gov_1", "人大政协", "gov_1", 1, null, false, "gov"), + new ItemSeed("gov_2", "应急局", "gov_2", 2, null, false, "gov"), + new ItemSeed("gov_3", "烟草局", "gov_3", 3, null, false, "gov"), + new ItemSeed("gov_4", "疾控中心", "gov_4", 4, null, false, "gov"), + new ItemSeed("gov_5", "大数据中心", "gov_5", 5, null, false, "gov"), + new ItemSeed("gov_6", "税务局", "gov_6", 6, null, false, "gov"), + new ItemSeed("gov_7", "教育局", "gov_7", 7, null, false, "gov"), + new ItemSeed("gov_8", "民政局", "gov_8", 8, null, false, "gov"), + new ItemSeed("gov_9", "人社局", "gov_9", 9, null, false, "gov"), + new ItemSeed("gov_10", "财政局", "gov_10", 10, null, false, "gov"), + new ItemSeed("gov_11", "国土局", "gov_11", 11, null, false, "gov"), + new ItemSeed("gov_12", "水利局", "gov_12", 12, null, false, "gov"), + new ItemSeed("gov_13", "林业局", "gov_13", 13, null, false, "gov"), + new ItemSeed("gov_14", "商务局", "gov_14", 14, null, false, "gov"), + new ItemSeed("gov_15", "文体旅局", "gov_15", 15, null, false, "gov"), + new ItemSeed("gov_16", "气象局", "gov_16", 16, null, false, "gov"), + new ItemSeed("gov_17", "退役军人事务部", "gov_17", 17, null, false, "gov"), + new ItemSeed("gov_18", "融媒体中心", "gov_18", 18, null, false, "gov"), + new ItemSeed("gov_19", "人防办", "gov_19", 19, null, false, "gov"), + // 一级 2 政法国防 + new ItemSeed("law", "政法国防", "law", 2, null, false), + new ItemSeed("law_1", "公安", "law_1", 1, null, false, "law"), + new ItemSeed("law_2", "检察院", "law_2", 2, null, false, "law"), + new ItemSeed("law_3", "法院", "law_3", 3, null, false, "law"), + new ItemSeed("law_4", "司法厅", "law_4", 4, null, false, "law"), + new ItemSeed("law_5", "监狱", "law_5", 5, null, false, "law"), + new ItemSeed("law_6", "海关-边检站", "law_6", 6, null, false, "law"), + new ItemSeed("law_7", "武警", "law_7", 7, null, false, "law"), + new ItemSeed("law_8", "特警", "law_8", 8, null, false, "law"), + new ItemSeed("law_9", "消防", "law_9", 9, null, false, "law"), + // 一级 3 教育 + new ItemSeed("edu", "教育", "edu", 3, null, false), + new ItemSeed("edu_1", "普教", "edu_1", 1, null, false, "edu"), + new ItemSeed("edu_2", "职校", "edu_2", 2, null, false, "edu"), + new ItemSeed("edu_3", "党校", "edu_3", 3, null, false, "edu"), + // 一级 4 医疗健康 + new ItemSeed("health", "医疗健康", "health", 4, null, false), + new ItemSeed("health_1", "医院", "health_1", 1, null, false, "health"), + new ItemSeed("health_2", "康养中心", "health_2", 2, null, false, "health"), + // 一级 5 金融 + new ItemSeed("finance", "金融", "finance", 5, null, false), + new ItemSeed("finance_1", "银行", "finance_1", 1, null, false, "finance"), + new ItemSeed("finance_2", "保险公司", "finance_2", 2, null, false, "finance"), + // 一级 6 交通 + new ItemSeed("transport", "交通", "transport", 6, null, false), + new ItemSeed("transport_1", "轨道交通", "transport_1", 1, null, false, "transport"), + new ItemSeed("transport_2", "机场", "transport_2", 2, null, false, "transport"), + new ItemSeed("transport_3", "高铁", "transport_3", 3, null, false, "transport"), + new ItemSeed("transport_4", "高速公路", "transport_4", 4, null, false, "transport"), + // 一级 7 能源与制造 + new ItemSeed("energy", "能源与制造", "energy", 7, null, false), + new ItemSeed("energy_1", "石化-能源-矿业", "energy_1", 1, null, false, "energy"), + new ItemSeed("energy_2", "发电站-核电站", "energy_2", 2, null, false, "energy"), + new ItemSeed("energy_3", "电力实训基地", "energy_3", 3, null, false, "energy"), + new ItemSeed("energy_4", "工厂", "energy_4", 4, null, false, "energy"), + // 一级 8 文化旅游 + new ItemSeed("tourism", "文化旅游", "tourism", 8, null, false), + new ItemSeed("tourism_1", "文旅", "tourism_1", 1, null, false, "tourism"), + new ItemSeed("tourism_2", "酒店", "tourism_2", 2, null, false, "tourism"), + new ItemSeed("tourism_3", "展馆-博物馆", "tourism_3", 3, null, false, "tourism"), + // 一级 9 园区与基建 + new ItemSeed("park", "园区与基建", "park", 9, null, false), + new ItemSeed("park_1", "智慧园区", "park_1", 1, null, false, "park"), + new ItemSeed("park_2", "集团大楼", "park_2", 2, null, false, "park"), + new ItemSeed("park_3", "商业中心", "park_3", 3, null, false, "park"), + new ItemSeed("park_4", "智算中心", "park_4", 4, null, false, "park"), + new ItemSeed("park_5", "低空飞行基地", "park_5", 5, null, false, "park"))), new GroupSeed("brand", "品牌", 4, "线索品牌(对齐蓝湖原型)", List.of( new ItemSeed("itc", "itc", "itc", 1, null, false), @@ -276,6 +338,7 @@ public class DictDataInitializer implements CommandLineRunner { for (ItemSeed item : group.items()) { upsertItem(persisted, item); } + pruneStaleBuiltinItems(persisted, group); for (ItemSeed item : group.items()) { if (item.defaultItem()) { setDefaultIfAbsent(persisted, item); @@ -366,6 +429,18 @@ public class DictDataInitializer implements CommandLineRunner { DictItem exist = dictItemMapper.selectOne(new LambdaQueryWrapper() .eq(DictItem::getGroupId, group.getId()) .eq(DictItem::getCode, seed.code())); + // 两级树形:解析父项 id(同组、按 parentCode 查;一级先于二级入库故可查到) + Long parentId = null; + if (seed.parentCode() != null) { + DictItem parent = dictItemMapper.selectOne(new LambdaQueryWrapper() + .eq(DictItem::getGroupId, group.getId()) + .eq(DictItem::getCode, seed.parentCode())); + if (parent == null) { + throw new BusinessErrorException(DictConstants.CODE_DICT_INVALID, + "种子二级项父未先入库(检查种子顺序):" + group.getCode() + "/" + seed.code()); + } + parentId = parent.getId(); + } if (exist == null) { DictItem created = new DictItem(); created.setGroupId(group.getId()); @@ -374,6 +449,7 @@ public class DictDataInitializer implements CommandLineRunner { created.setValue(seed.value()); created.setSortNo(seed.sortNo()); created.setDescription(seed.description()); + created.setParentId(parentId); created.setStatus(DictConstants.STATUS_ENABLED); created.setBuiltin(true); created.setDeleteKey(0L); @@ -388,10 +464,11 @@ public class DictDataInitializer implements CommandLineRunner { return; } if (Boolean.TRUE.equals(exist.getBuiltin())) { - // 只加/改不删:更新名称/描述/排序号;不碰编码/状态/内置标记/字典值(值属业务契约,管理员本地化后不被种子覆盖) + // 只加/改不删:更新名称/描述/排序号 + 父项归属(两级树);不碰编码/状态/内置标记/字典值 exist.setName(seed.name()); exist.setSortNo(seed.sortNo()); exist.setDescription(seed.description()); + exist.setParentId(parentId); dictItemMapper.updateById(exist); } else { log.warn("字典项 {}/{} 非内置(builtin=false),跳过种子更新(绝不触碰用户字典)", @@ -399,6 +476,31 @@ public class DictDataInitializer implements CommandLineRunner { } } + /** + * 剪除内置项中已不在种子的项(使种子对内置项权威,支持口径改版如 industry 旧 8 项→新树)。 + *

仅硬删 builtin=true 且未被引用的项;用户项(builtin=false)绝不触碰(US-41); + * 同事务清理其引用计数行。一级/二级一并删除(无外键,无顺序要求)。

+ */ + private void pruneStaleBuiltinItems(DictGroup group, GroupSeed seed) { + if (!Boolean.TRUE.equals(group.getBuiltin())) { + return; + } + Set seedCodes = seed.items().stream().map(ItemSeed::code).collect(Collectors.toSet()); + List builtinItems = dictItemMapper.selectList(new LambdaQueryWrapper() + .eq(DictItem::getGroupId, group.getId()) + .eq(DictItem::getBuiltin, true)); + for (DictItem item : builtinItems) { + if (seedCodes.contains(item.getCode())) { + continue; + } + dictItemMapper.deleteById(item.getId()); + dictRefCountMapper.delete(new LambdaQueryWrapper() + .eq(DictRefCount::getGroupCode, group.getCode()) + .eq(DictRefCount::getItemCode, item.getCode())); + log.info("剪除旧内置字典项(已不在种子):{}/{}", group.getCode(), item.getCode()); + } + } + /** * 种子默认项:仅分组尚无默认且默认项自身启用时设置。 *

管理员已设默认(或停用默认项后自动取消)都不会被种子覆盖/复活。

@@ -430,7 +532,12 @@ public class DictDataInitializer implements CommandLineRunner { /*-------- 种子结构(Excel 替换口的数据契约,包私有供测试注入)--------*/ record ItemSeed(String code, String name, String value, - Integer sortNo, String description, boolean defaultItem) { + Integer sortNo, String description, boolean defaultItem, String parentCode) { + /** 便捷构造:无父项(顶层/扁平项),parentCode=null */ + ItemSeed(String code, String name, String value, + Integer sortNo, String description, boolean defaultItem) { + this(code, name, value, sortNo, description, defaultItem, null); + } } record GroupSeed(String code, String name, Integer sortNo, diff --git a/crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java b/crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java index 94a440c..f2b3e97 100644 --- a/crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java +++ b/crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java @@ -31,7 +31,7 @@ class DictDataInitializerTest extends AbstractDictH2Test { } @Test - @DisplayName("首装:22 个内置分组 + 168 个内置项全部建库,ref_count 预建,种子默认项生效") + @DisplayName("首装:22 个内置分组 + 220 个内置项全部建库,ref_count 预建,种子默认项生效") void firstRun_createsBuiltinDictsWithDefaults() { initializer.run(); @@ -48,11 +48,20 @@ class DictDataInitializerTest extends AbstractDictH2Test { assertThat(groups).allMatch(g -> g.getStatus() == DictConstants.STATUS_ENABLED); List items = dictItemMapper.selectList(null); - assertThat(items).hasSize(168); // 163 + 资料类型5 + assertThat(items).hasSize(220); // 160(非 industry) + industry 两级树 60(9一级+51二级) assertThat(items).allMatch(i -> Boolean.TRUE.equals(i.getBuiltin())); + // 行业两级树:9 个一级(parentId=null)+ 51 个二级(parentId 非空) + DictGroup industryGroup = groups.stream() + .filter(g -> g.getCode().equals("industry")).findFirst().orElseThrow(); + List industryItems = items.stream() + .filter(i -> i.getGroupId().equals(industryGroup.getId())).toList(); + assertThat(industryItems).hasSize(60); + assertThat(industryItems).filteredOn(i -> i.getParentId() == null).hasSize(9); + assertThat(industryItems).filteredOn(i -> i.getParentId() != null).hasSize(51); + // 引用计数行预建(与新建接口同口径) - assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(168); + assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(220); DictGroup statusGroup = groups.stream() .filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow(); DictItem potential = dictItemMapper.selectList(null).stream() @@ -74,8 +83,8 @@ class DictDataInitializerTest extends AbstractDictH2Test { initializer.run(); assertThat(dictGroupMapper.selectCount(null)).isEqualTo(22); - assertThat(dictItemMapper.selectCount(null)).isEqualTo(168); - assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(168); + assertThat(dictItemMapper.selectCount(null)).isEqualTo(220); + assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(220); assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); } @@ -157,8 +166,8 @@ class DictDataInitializerTest extends AbstractDictH2Test { } @Test - @DisplayName("只加不删:种子外既有内置项在重跑后仍保留") - void removedFromSeed_notDeleted() { + @DisplayName("种子权威:种子外旧内置项重跑后被剪除(支持口径改版,如 industry 树形化)") + void removedFromSeed_prunedForBuiltin() { initializer.run(); DictGroup industry = dictGroupMapper.selectList(null).stream() .filter(g -> g.getCode().equals("industry")).findFirst().orElseThrow(); @@ -174,9 +183,31 @@ class DictDataInitializerTest extends AbstractDictH2Test { legacy.setDeleteKey(0L); dictItemMapper.insert(legacy); + initializer.run(); + // builtin 且不在种子 → 被剪除 + assertThat(dictItemMapper.selectList(null).stream() + .filter(i -> i.getCode().equals("legacy_removed"))).isEmpty(); + } + + @Test + @DisplayName("剪除只碰内置项:内置分组下用户手建项(builtin=false)不被剪除") + void removedFromSeed_userItemNeverPruned() { + initializer.run(); + DictGroup industry = dictGroupMapper.selectList(null).stream() + .filter(g -> g.getCode().equals("industry")).findFirst().orElseThrow(); + DictItem userItem = new DictItem(); + userItem.setGroupId(industry.getId()); + userItem.setCode("user_custom"); + userItem.setName("管理员自建行业"); + userItem.setValue("USER_CUSTOM"); + userItem.setStatus(DictConstants.STATUS_ENABLED); + userItem.setBuiltin(false); + userItem.setDeleteKey(0L); + dictItemMapper.insert(userItem); + initializer.run(); assertThat(dictItemMapper.selectList(null).stream() - .filter(i -> i.getCode().equals("legacy_removed"))).hasSize(1); + .filter(i -> i.getCode().equals("user_custom"))).hasSize(1); } @Test @@ -202,8 +233,8 @@ class DictDataInitializerTest extends AbstractDictH2Test { assertThat(dictGroupMapper.selectCount(null)).isEqualTo(22); // lead_source(用户) + 其余 21 个内置分组 assertThat(dictGroupMapper.selectList(null).stream() .filter(g -> g.getCode().equals("industry"))).hasSize(1); - assertThat(dictItemMapper.selectCount(null)).isEqualTo(130); // 168 - 渠道 38(用户分组整组跳过) - assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(130); + assertThat(dictItemMapper.selectCount(null)).isEqualTo(182); // 220 - 渠道 38(用户分组整组跳过) + assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(182); assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); // customer_status 默认项 }