Browse Source

feat(dict): 票09 步2 字典项两级树父子校验+删除拦截+子项随父停用

- saveItem 父项校验 (Q2): parentId 非空须同组+父为一级(parentId=null)+禁自引用, 父不存在63012/跨组或超两级63001
- doDeleteItem 拦截 (Q3-b): 删一级项若有存活子项则拒 (对齐删分组需组内无项)
- enrich effectiveSelectable 回溯父状态 (Q3-a): 二级项父停用则不可选, 批量取父状态无N+1
- 新增3测试: 父子校验/删除拦截/子随父停用; 48 全绿 (+3)
master
luoweijian 2 weeks ago
parent
commit
94effd1ce5
  1. 40
      crm-dict/src/main/java/com/crm/dict/service/impl/DictItemServiceImpl.java
  2. 91
      crm-dict/src/test/java/com/crm/dict/service/impl/DictItemServiceTest.java

40
crm-dict/src/main/java/com/crm/dict/service/impl/DictItemServiceImpl.java

@ -104,6 +104,25 @@ public class DictItemServiceImpl extends BaseServiceImpl<DictItemMapper, DictIte
item.setValue(null);
}
// 父项校验(两级树形 Q2):非空时必须同组、父项自身为一级(parentId=null)、不得自引用
if (item.getParentId() != null) {
Long ownerGroupId = item.getGroupId() != null ? item.getGroupId()
: (item.getId() != null ? requireItem(item.getId()).getGroupId() : null);
if (item.getId() != null && item.getParentId().equals(item.getId())) {
throw new BusinessErrorException(DictConstants.CODE_DICT_INVALID, "字典项不能以自身为父项");
}
DictItem parent = dictItemMapper.selectById(item.getParentId());
if (parent == null) {
throw new BusinessErrorException(DictConstants.CODE_ITEM_NOT_EXIST, "父字典项不存在或已被删除");
}
if (ownerGroupId != null && !parent.getGroupId().equals(ownerGroupId)) {
throw new BusinessErrorException(DictConstants.CODE_DICT_INVALID, "父字典项必须与本项同属一个分组");
}
if (parent.getParentId() != null) {
throw new BusinessErrorException(DictConstants.CODE_DICT_INVALID, "字典仅支持两级树形,父项必须是一级项");
}
}
if (item.getId() == null) {
DictGroup group = requireGroup(item.getGroupId());
if (group.getStatus() == null || DictConstants.STATUS_ENABLED != group.getStatus()) {
@ -218,6 +237,15 @@ public class DictItemServiceImpl extends BaseServiceImpl<DictItemMapper, DictIte
throw new BusinessErrorException(DictConstants.CODE_ITEM_REFERENCED,
"该字典已被引用,无法删除");
}
// 两级树形 Q3-b:删一级项前若其下有存活子项则拦截(不级联软删,对齐删分组需组内无项的保守语义)
if (item.getParentId() == null) {
Long childCount = dictItemMapper.selectCount(new LambdaQueryWrapper<DictItem>()
.eq(DictItem::getParentId, id));
if (childCount > 0) {
throw new BusinessErrorException(DictConstants.CODE_ITEM_REFERENCED,
"该字典项下还有子项,请先删除子项");
}
}
// 软删除 + 复用键(deleted=1 + delete_key=id)
this.update(null, new LambdaUpdateWrapper<DictItem>()
.eq(DictItem::getId, id)
@ -292,17 +320,27 @@ public class DictItemServiceImpl extends BaseServiceImpl<DictItemMapper, DictIte
.stream()
.collect(Collectors.toMap(DictGroupDefault::getGroupId, DictGroupDefault::getItemId));
Set<String> referencedKeys = queryReferencedKeys(vos, groups);
// 两级树形 Q3-a:二级项有效可选需回溯父项状态(父停用则子项不可选)
Set<Long> parentIds = vos.stream().map(DictItemDTO::getParentId)
.filter(java.util.Objects::nonNull).collect(Collectors.toSet());
Map<Long, Integer> parentStatus = parentIds.isEmpty() ? Map.of()
: dictItemMapper.selectBatchIds(parentIds).stream()
.collect(Collectors.toMap(DictItem::getId, DictItem::getStatus));
vos.forEach(v -> {
DictGroup g = groups.get(v.getGroupId());
v.setGroupName(g == null ? null : g.getName());
v.setIsDefault(defaultMap.containsKey(v.getGroupId())
&& defaultMap.get(v.getGroupId()).equals(v.getId()));
v.setReferenced(referencedKeys.contains(pairKey(g == null ? "" : g.getCode(), v.getCode())));
boolean parentEnabled = v.getParentId() == null
|| (parentStatus.get(v.getParentId()) != null
&& DictConstants.STATUS_ENABLED == parentStatus.get(v.getParentId()));
v.setEffectiveSelectable(g != null
&& g.getStatus() != null
&& DictConstants.STATUS_ENABLED == g.getStatus()
&& v.getStatus() != null
&& DictConstants.STATUS_ENABLED == v.getStatus());
&& DictConstants.STATUS_ENABLED == v.getStatus()
&& parentEnabled);
});
}

91
crm-dict/src/test/java/com/crm/dict/service/impl/DictItemServiceTest.java

@ -288,4 +288,95 @@ class DictItemServiceTest extends AbstractDictH2Test {
PageResult<DictItemDTO> after = dictItemService.pageItems(param);
assertThat(after.getContent()).allMatch(v -> !v.getEffectiveSelectable());
}
@Test
@DisplayName("树形:二级项 parent_id 必须指向同组一级项;跨组/非一级父/自引用被拒")
void saveItem_treeParentValidation() {
DictGroup g = newGroup("industry", "所属行业");
DictGroup other = newGroup("other_g", "其他组");
DictItem lv1 = newItem(g.getId(), "gov", "gov"); // 一级(parentId=null)
DictItem otherLv1 = newItem(other.getId(), "x", "x"); // 异组一级
// 正常:二级挂同组一级
DictItemDTO lv2 = new DictItemDTO();
lv2.setGroupId(g.getId());
lv2.setCode("gov_ncpol");
lv2.setName("人大政协");
lv2.setParentId(lv1.getId());
dictItemService.saveItem(lv2);
DictItem savedLv2 = dictItemMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DictItem>()
.eq(DictItem::getCode, "gov_ncpol")).get(0);
assertThat(savedLv2.getParentId()).isEqualTo(lv1.getId());
// 父不存在 → 63012
DictItemDTO badParent = new DictItemDTO();
badParent.setGroupId(g.getId());
badParent.setCode("gov_x");
badParent.setName("坏父");
badParent.setParentId(888888L);
assertDictError(() -> dictItemService.saveItem(badParent), DictConstants.CODE_ITEM_NOT_EXIST);
// 跨组父 → 63001
DictItemDTO crossParent = new DictItemDTO();
crossParent.setGroupId(g.getId());
crossParent.setCode("gov_cross");
crossParent.setName("跨组父");
crossParent.setParentId(otherLv1.getId());
assertDictError(() -> dictItemService.saveItem(crossParent), DictConstants.CODE_DICT_INVALID);
// 父自身是二级(超两级)→ 63001
DictItemDTO lv3 = new DictItemDTO();
lv3.setGroupId(g.getId());
lv3.setCode("gov_ncpol_sub");
lv3.setName("三级");
lv3.setParentId(savedLv2.getId());
assertDictError(() -> dictItemService.saveItem(lv3), DictConstants.CODE_DICT_INVALID);
}
@Test
@DisplayName("树形:删一级项若有存活子项被拦;先删子项后可删")
void deleteItem_blockedWhenHasChildren() {
DictGroup g = newGroup("industry", "所属行业");
DictItem lv1 = newItem(g.getId(), "gov", "gov");
DictItemDTO lv2 = new DictItemDTO();
lv2.setGroupId(g.getId());
lv2.setCode("gov_ncpol");
lv2.setName("人大政协");
lv2.setParentId(lv1.getId());
dictItemService.saveItem(lv2);
DictItem child = dictItemMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DictItem>()
.eq(DictItem::getCode, "gov_ncpol")).get(0);
// 有子项 → 删一级被拦 63010
assertDictError(() -> dictItemService.deleteItem(lv1.getId()), DictConstants.CODE_ITEM_REFERENCED);
// 先删子项,再删一级→ 通过
dictItemService.deleteItem(child.getId());
dictItemService.deleteItem(lv1.getId());
assertThat(dictItemMapper.selectCount(null)).isZero();
}
@Test
@DisplayName("树形:停用一级项 → 其二级子项 effectiveSelectable=false(Q3-a 回溯父状态)")
void effectiveSelectable_childFollowsParentDisabled() {
DictGroup g = newGroup("industry", "所属行业");
DictItem lv1 = newItem(g.getId(), "gov", "gov");
DictItemDTO lv2 = new DictItemDTO();
lv2.setGroupId(g.getId());
lv2.setCode("gov_ncpol");
lv2.setName("人大政协");
lv2.setParentId(lv1.getId());
dictItemService.saveItem(lv2);
ItemPageParam param = new ItemPageParam();
param.setGroupId(g.getId());
// 父启用时子可选
assertThat(dictItemService.pageItems(param).getContent())
.filteredOn(v -> "gov_ncpol".equals(v.getCode()))
.allMatch(DictItemDTO::getEffectiveSelectable);
// 停用一级父 → 子项不可选
dictItemService.updateItemStatus(lv1.getId(), DictConstants.STATUS_DISABLED);
assertThat(dictItemService.pageItems(param).getContent())
.filteredOn(v -> "gov_ncpol".equals(v.getCode()))
.allMatch(v -> !v.getEffectiveSelectable());
}
}

Loading…
Cancel
Save