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
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<GroupSeed> 即可,其余流程不变。</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<GroupSeed> 后调用本方法即可)。
|
|
*/
|
|
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) {
|
|
}
|
|
}
|
|
|