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
248 lines
13 KiB
|
1 month ago
|
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 替换口,见类注释)--------*/
|
||
|
|
|
||
|
|
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<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) {
|
||
|
|
}
|
||
|
|
}
|