package com.crm.dict.config; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.crm.base.domain.exception.BusinessErrorException; import com.crm.dict.constant.DictConstants; import com.crm.dict.domain.entity.DictGroup; import com.crm.dict.domain.entity.DictGroupDefault; import com.crm.dict.domain.entity.DictItem; import com.crm.dict.domain.entity.DictRefCount; import com.crm.dict.mapper.DictGroupDefaultMapper; import com.crm.dict.mapper.DictGroupMapper; import com.crm.dict.mapper.DictItemMapper; import com.crm.dict.mapper.DictRefCountMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import java.util.HashSet; import java.util.List; import java.util.Set; /** * 内置字典幂等初始化(T08,ADR-0015) *

部署时把首批内置字典(builtin=true)幂等导入:按 (group_code, item_code) upsert, * 只加/改不删(Excel 消失的项不自动删);builtin=false 的用户字典绝不触碰(分组或项任一非内置即跳过); * 更新只覆盖名称/描述/排序号,不碰编码/状态/内置标记/字典值(值属业务契约,管理员本地化后不被种子覆盖)。 * 种子默认项仅在分组尚无默认且默认项自身启用时设置,不覆盖管理员已设默认(含停用默认项后不复活)。 * 导入前校验种子表合法性,失败 fail-fast。

*

数据源替换口:首批以代码内 {@link #SEED_GROUPS} 为准;产品提供 Excel 后, * 将解析逻辑替换为读取 Excel 生成同构的 List<GroupSeed> 即可,其余流程不变。

*/ @Slf4j @Component @RequiredArgsConstructor public class DictDataInitializer implements CommandLineRunner { private final DictGroupMapper dictGroupMapper; private final DictItemMapper dictItemMapper; private final DictGroupDefaultMapper dictGroupDefaultMapper; private final DictRefCountMapper dictRefCountMapper; /*-------- 首批内置字典种子(Excel 替换口,见类注释)--------*/ private static final List SEED_GROUPS = List.of( new GroupSeed("lead_source", "客户来源", 1, "客户首次触达渠道", List.of( new ItemSeed("ad", "广告投放", "ad", 1, null, false), new ItemSeed("referral", "转介绍", "referral", 2, null, false), new ItemSeed("exhibition", "展会活动", "exhibition", 3, null, false), new ItemSeed("website", "官网咨询", "website", 4, null, false), new ItemSeed("offline", "线下拜访", "offline", 5, null, false), new ItemSeed("other", "其他", "other", 6, null, false))), new GroupSeed("customer_status", "客户状态", 2, "客户生命周期状态", List.of( 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, "客户所属行业", 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)))); @Override public void run(String... args) { run(SEED_GROUPS); } /** * 包私有参数化入口:接收同构种子列表执行导入,供测试注入坏种子验证 fail-fast; * 也是未来 Excel 解析逻辑的接入点(解析出 List<GroupSeed> 后调用本方法即可)。 */ void run(List seeds) { log.info("执行内置字典幂等初始化检查..."); validateSeeds(seeds); for (GroupSeed group : seeds) { DictGroup persisted = upsertGroup(group); for (ItemSeed item : group.items()) { upsertItem(persisted, item); } for (ItemSeed item : group.items()) { if (item.defaultItem()) { setDefaultIfAbsent(persisted, item); } } } log.info("内置字典幂等初始化检查完成:{} 个分组", seeds.size()); } // ==================== 种子表校验(fail-fast,脏数据不进库) ==================== private void validateSeeds(List seeds) { Set groupCodes = new HashSet<>(); for (GroupSeed group : seeds) { failFast(group.code() == null || !group.code().matches(DictConstants.CODE_PATTERN), "种子分组编码非法(仅字母/数字/下划线):" + group.code()); failFast(group.name() == null || group.name().isBlank(), "种子分组名称不能为空:" + group.code()); failFast(!groupCodes.add(group.code()), "种子分组编码重复:" + group.code()); failFast(group.sortNo() != null && group.sortNo() < 0, "种子分组排序号必须为非负整数:" + group.code()); Set itemCodes = new HashSet<>(); Set values = new HashSet<>(); long defaultCount = 0; for (ItemSeed item : group.items()) { failFast(item.code() == null || !item.code().matches(DictConstants.CODE_PATTERN), "种子字典项编码非法(仅字母/数字/下划线):" + group.code() + "/" + item.code()); failFast(item.name() == null || item.name().isBlank(), "种子字典项名称不能为空:" + group.code() + "/" + item.code()); failFast(!itemCodes.add(item.code()), "种子字典项编码组内重复:" + group.code() + "/" + item.code()); if (item.value() != null && !item.value().isBlank()) { failFast(!values.add(item.value()), "种子字典项值同组重复:" + group.code() + "/" + item.value()); } failFast(item.sortNo() != null && item.sortNo() < 0, "种子字典项排序号必须为非负整数:" + group.code() + "/" + item.code()); if (item.defaultItem()) { defaultCount++; } } failFast(defaultCount > 1, "种子分组默认项超过一个:" + group.code()); } } private void failFast(boolean invalid, String message) { if (invalid) { throw new BusinessErrorException(DictConstants.CODE_DICT_INVALID, "内置字典种子校验失败:" + message); } } // ==================== upsert 实现 ==================== private DictGroup upsertGroup(GroupSeed seed) { DictGroup exist = dictGroupMapper.selectOne( new LambdaQueryWrapper().eq(DictGroup::getCode, seed.code())); if (exist == null) { DictGroup created = new DictGroup(); created.setCode(seed.code()); created.setName(seed.name()); created.setSortNo(seed.sortNo()); created.setDescription(seed.description()); created.setStatus(DictConstants.STATUS_ENABLED); created.setBuiltin(true); created.setDeleteKey(0L); dictGroupMapper.insert(created); log.info("创建内置字典分组:{} ({})", seed.name(), seed.code()); return created; } if (Boolean.TRUE.equals(exist.getBuiltin())) { exist.setName(seed.name()); exist.setSortNo(seed.sortNo()); exist.setDescription(seed.description()); dictGroupMapper.updateById(exist); } else { log.warn("分组 {} 非内置(builtin=false),跳过种子更新(绝不触碰用户字典)", seed.code()); } return exist; } private void upsertItem(DictGroup group, ItemSeed seed) { // US-41 绝不触碰 builtin=false:种子项只允许落在内置分组下,用户分组(含同编码占用)整个跳过 if (!Boolean.TRUE.equals(group.getBuiltin())) { log.warn("分组 {} 非内置(builtin=false),跳过其下种子项导入(绝不触碰用户字典)", group.getCode()); return; } DictItem exist = dictItemMapper.selectOne(new LambdaQueryWrapper() .eq(DictItem::getGroupId, group.getId()) .eq(DictItem::getCode, seed.code())); if (exist == null) { DictItem created = new DictItem(); created.setGroupId(group.getId()); created.setCode(seed.code()); created.setName(seed.name()); created.setValue(seed.value()); created.setSortNo(seed.sortNo()); created.setDescription(seed.description()); created.setStatus(DictConstants.STATUS_ENABLED); created.setBuiltin(true); created.setDeleteKey(0L); dictItemMapper.insert(created); // 与新建接口同口径:同事务预建引用计数行(ref_count=0),insert 而非 addRefCount(后者是 UPDATE 语义,行不存在不生效) DictRefCount ref = new DictRefCount(); ref.setGroupCode(group.getCode()); ref.setItemCode(created.getCode()); ref.setRefCount(0); dictRefCountMapper.insert(ref); log.info("创建内置字典项:{} ({}/{})", seed.name(), group.getCode(), seed.code()); return; } if (Boolean.TRUE.equals(exist.getBuiltin())) { // 只加/改不删:更新名称/描述/排序号;不碰编码/状态/内置标记/字典值(值属业务契约,管理员本地化后不被种子覆盖) exist.setName(seed.name()); exist.setSortNo(seed.sortNo()); exist.setDescription(seed.description()); dictItemMapper.updateById(exist); } else { log.warn("字典项 {}/{} 非内置(builtin=false),跳过种子更新(绝不触碰用户字典)", group.getCode(), seed.code()); } } /** * 种子默认项:仅分组尚无默认且默认项自身启用时设置。 *

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

*/ private void setDefaultIfAbsent(DictGroup group, ItemSeed seed) { // US-41:用户分组(builtin=false)不设默认,种子默认语义仅适用于内置分组 if (!Boolean.TRUE.equals(group.getBuiltin())) { return; } if (dictGroupDefaultMapper.selectById(group.getId()) != null) { return; } DictItem item = dictItemMapper.selectOne(new LambdaQueryWrapper() .eq(DictItem::getGroupId, group.getId()) .eq(DictItem::getCode, seed.code())); if (item == null || item.getStatus() == null || DictConstants.STATUS_ENABLED != item.getStatus()) { log.warn("种子默认项 {}/{} 不存在或已停用,跳过设置默认(不覆盖管理员操作)", group.getCode(), seed.code()); return; } DictGroupDefault def = new DictGroupDefault(); def.setGroupId(group.getId()); def.setItemId(item.getId()); dictGroupDefaultMapper.insert(def); log.info("设置内置默认项:{}/{}", group.getCode(), seed.code()); } /*-------- 种子结构(Excel 替换口的数据契约,包私有供测试注入)--------*/ record ItemSeed(String code, String name, String value, Integer sortNo, String description, boolean defaultItem) { } record GroupSeed(String code, String name, Integer sortNo, String description, List items) { } }