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.
 
 
 
 
 

330 lines
21 KiB

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)
* <p>部署时把首批内置字典(builtin=true)幂等导入:按 (group_code, item_code) upsert,
* 只加/改不删(Excel 消失的项不自动删);builtin=false 的用户字典绝不触碰(分组或项任一非内置即跳过);
* 更新只覆盖名称/描述/排序号,不碰编码/状态/内置标记/字典值(值属业务契约,管理员本地化后不被种子覆盖)。
* 种子默认项仅在分组尚无默认且默认项自身启用时设置,不覆盖管理员已设默认(含停用默认项后不复活)。
* 导入前校验种子表合法性,失败 fail-fast。</p>
* <p><b>数据源替换口</b>:首批以代码内 {@link #SEED_GROUPS} 为准;产品提供 Excel 后,
* 将解析逻辑替换为读取 Excel 生成同构的 List&lt;GroupSeed&gt; 即可,其余流程不变。</p>
*/
@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 替换口,见类注释)
* 渠道/品牌/需求产品/需求场景选项取自蓝湖原型 A2-1-2-2 新增线索表单(frontend-integration 票据 02);
* 原型渠道清单中的占位文本「列表项1」不入库。--------*/
private static final List<GroupSeed> SEED_GROUPS = List.of(
new GroupSeed("lead_source", "渠道", 1, "线索来源渠道(对齐蓝湖原型)",
List.of(
new ItemSeed("exhibition", "展会", "exhibition", 1, null, false),
new ItemSeed("digital_av", "数字音视", "digital_av", 2, null, false),
new ItemSeed("jd", "京东", "jd", 3, null, false),
new ItemSeed("netease_news", "网易新闻", "netease_news", 4, null, false),
new ItemSeed("tencent_sogou", "腾讯搜狗", "tencent_sogou", 5, null, false),
new ItemSeed("zhihu", "知乎", "zhihu", 6, null, false),
new ItemSeed("edu_procurement", "教育采购", "edu_procurement", 7, null, false),
new ItemSeed("baidu_20146", "百度20146", "baidu_20146", 8, null, false),
new ItemSeed("edu_equipment", "教育装备采购网", "edu_equipment", 9, null, false),
new ItemSeed("1688", "1688", "1688", 10, null, false),
new ItemSeed("accessory_cable", "配件线材", "accessory_cable", 11, null, false),
new ItemSeed("ifeng", "凤凰网", "ifeng", 12, null, false),
new ItemSeed("junqiao", "军桥", "junqiao", 13, null, false),
new ItemSeed("soft_article", "软文", "soft_article", 14, null, false),
new ItemSeed("baidu_broadcast", "百度广播", "baidu_broadcast", 15, null, false),
new ItemSeed("baidu_pro_sound", "百度专业扩声", "baidu_pro_sound", 16, null, false),
new ItemSeed("baidu_edu", "百度教育", "baidu_edu", 17, null, false),
new ItemSeed("search_360", "360搜索", "search_360", 18, null, false),
new ItemSeed("shunqi", "顺企网", "shunqi", 19, null, false),
new ItemSeed("security_net", "安防网", "security_net", 20, null, false),
new ItemSeed("huicong", "慧聪", "huicong", 21, null, false),
new ItemSeed("baidu_video_meeting", "百度视频会议", "baidu_video_meeting", 22, null, false),
new ItemSeed("wechat_moments", "微信朋友圈", "wechat_moments", 23, null, false),
new ItemSeed("message", "留言", "message", 24, null, false),
new ItemSeed("phone", "电话", "phone", 25, null, false),
new ItemSeed("kuaishou", "快手", "kuaishou", 26, null, false),
new ItemSeed("video_account", "视频号", "video_account", 27, null, false),
new ItemSeed("official_account", "公众号", "official_account", 28, null, false),
new ItemSeed("douyin", "抖音号", "douyin", 29, null, false),
new ItemSeed("xiaoke_ai", "销氪AI", "xiaoke_ai", 30, null, false),
new ItemSeed("baidu_meeting", "百度会议", "baidu_meeting", 31, null, false),
new ItemSeed("baiying_ai", "百应AI", "baiying_ai", 32, null, false),
new ItemSeed("xiaohongshu", "小红书", "xiaohongshu", 33, null, false),
new ItemSeed("baidu_lighting", "百度亮化", "baidu_lighting", 34, null, false),
new ItemSeed("short_video", "短视频", "short_video", 35, null, false),
new ItemSeed("baidu_aicaigou", "百度爱采购", "baidu_aicaigou", 36, null, false),
new ItemSeed("media", "媒体", "media", 37, null, false),
new ItemSeed("toutiao", "头条", "toutiao", 38, 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))),
new GroupSeed("brand", "品牌", 4, "线索品牌(对齐蓝湖原型)",
List.of(
new ItemSeed("itc", "itc", "itc", 1, null, false),
new ItemSeed("shichang", "世畅", "shichang", 2, null, false),
new ItemSeed("lisheng", "丽声", "lisheng", 3, null, false),
new ItemSeed("crx", "CRX", "crx", 4, null, false),
new ItemSeed("three_a", "3A", "three_a", 5, null, false),
new ItemSeed("beihangxing", "北航星", "beihangxing", 6, null, false),
new ItemSeed("tekong", "特控", "tekong", 7, null, false),
new ItemSeed("sooann", "SOOANN", "sooann", 8, null, false),
new ItemSeed("jinye_chengxing", "金业诚兴", "jinye_chengxing", 9, null, false))),
new GroupSeed("product", "需求产品", 5, "线索需求产品(对齐蓝湖原型)",
List.of(
new ItemSeed("led_display", "LED显示屏", "led_display", 1, null, false),
new ItemSeed("conference_system", "会议系统", "conference_system", 2, null, false),
new ItemSeed("paperless_meeting", "无纸化会议", "paperless_meeting", 3, null, false),
new ItemSeed("stage_lighting", "舞台灯光", "stage_lighting", 4, null, false),
new ItemSeed("tourism_lighting", "文旅亮化", "tourism_lighting", 5, null, false),
new ItemSeed("record_broadcast", "录播系统", "record_broadcast", 6, null, false),
new ItemSeed("broadcast_system", "广播系统", "broadcast_system", 7, null, false),
new ItemSeed("conference_machine", "会议一体机", "conference_machine", 8, null, false),
new ItemSeed("video_conference", "视频会议", "video_conference", 9, null, false),
new ItemSeed("sound_system", "扩声系统", "sound_system", 10, null, false),
new ItemSeed("teaching_system", "教学系统", "teaching_system", 11, null, false),
new ItemSeed("command_center", "指挥中心", "command_center", 12, null, false),
new ItemSeed("medical_system", "医疗系统", "medical_system", 13, null, false),
new ItemSeed("info_distribution", "信发系统", "info_distribution", 14, null, false),
new ItemSeed("software_platform", "软件平台", "software_platform", 15, null, false),
new ItemSeed("security_surveillance", "安防监控", "security_surveillance", 16, null, false))),
new GroupSeed("scene", "需求场景", 6, "线索需求场景(对齐蓝湖原型)",
List.of(
new ItemSeed("meeting_room", "会议室", "meeting_room", 1, null, false),
new ItemSeed("auditorium", "报告厅", "auditorium", 2, null, false),
new ItemSeed("command_center", "指挥中心", "command_center", 3, null, false),
new ItemSeed("sports_venue", "文体场馆", "sports_venue", 4, null, false),
new ItemSeed("theater", "剧场剧院", "theater", 5, null, false),
new ItemSeed("bar_ktv", "酒吧KTV", "bar_ktv", 6, null, false),
new ItemSeed("higher_vocational", "高职教", "higher_vocational", 7, null, false),
new ItemSeed("basic_edu", "普教", "basic_edu", 8, null, false),
new ItemSeed("justice", "公检法司", "justice", 9, null, false),
new ItemSeed("medical", "医疗", "medical", 10, null, false),
new ItemSeed("military", "国防部队", "military", 11, null, false),
new ItemSeed("government", "政府单位", "government", 12, null, false),
new ItemSeed("transportation", "交通", "transportation", 13, null, false),
new ItemSeed("finance", "金融", "finance", 14, null, false),
new ItemSeed("energy", "能源", "energy", 15, null, false),
new ItemSeed("park", "园区", "park", 16, null, false),
new ItemSeed("cultural_tourism", "文旅", "cultural_tourism", 17, null, false),
new ItemSeed("exhibition_hall", "展厅", "exhibition_hall", 18, null, false))));
@Override
public void run(String... args) {
run(SEED_GROUPS);
}
/**
* 包私有参数化入口:接收同构种子列表执行导入,供测试注入坏种子验证 fail-fast;
* 也是未来 Excel 解析逻辑的接入点(解析出 List&lt;GroupSeed&gt; 后调用本方法即可)。
*/
void run(List<GroupSeed> 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<GroupSeed> seeds) {
Set<String> 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<String> itemCodes = new HashSet<>();
Set<String> 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<DictGroup>().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<DictItem>()
.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());
}
}
/**
* 种子默认项:仅分组尚无默认且默认项自身启用时设置。
* <p>管理员已设默认(或停用默认项后自动取消)都不会被种子覆盖/复活。</p>
*/
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<DictItem>()
.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<ItemSeed> items) {
}
}