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.

248 lines
13 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;
/**
* 内置字典幂等初始化T08ADR-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 替换口,见类注释)--------*/
private static final List<GroupSeed> 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&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) {
}
}