feat(spec58): 模块注册表后端与模块硬编码收敛(#60)

- 新增 gxmu/module 包:ModuleRegistry 实体/Mapper/Service,listEnabled 按 sort_number 升序,titleOf 未注册回退 module 原串
- 新增 GET /api/gxmu/module-registry/list(需登录),返回启用模块元数据
- ReviewFlowConfigServiceImpl / ReviewListServiceImpl 的模块名映射改读注册表,查不到回退原串
- DeclareGuardServiceImpl 的模块集合改为注册表驱动,含 enabled=0(停用模块存量仍可撤回/作废)
- 启动期一致性校验:table_name≠module 或业务表缺失即启动失败,gxmu.module-registry.strict=false 可关闭
- 单测 13 例:注册表加载与排序、titleOf 回退、启动校验、module 名回退
This commit is contained in:
2026-11-03 22:29:27 +08:00
parent 1f2a837609
commit 11f0f90f7a
13 changed files with 758 additions and 46 deletions
@@ -0,0 +1,96 @@
package com.gxwebsoft.gxmu.module;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 模块注册表启动期一致性校验(spec #58 用户故事 21)。
*
* <p>两件事不满足就在启动期直接失败,而不是等运行到详情页才发现空白:
* <ol>
* <li>注册表 table_name 与 module 一致(module 同时是业务表名);</li>
* <li>注册表引用的业务表在库里真实存在。</li>
* </ol>
*
* <p>允许 gxmu.module-registry.strict=false 关闭校验,便于无库本地启动;
* 生产环境必须保持默认的 true。
*
* @author Codex
* @since 2026-11-10
*/
@Component
public class ModuleRegistryIntegrityChecker implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(ModuleRegistryIntegrityChecker.class);
@Value("${gxmu.module-registry.strict:true}")
private boolean strict;
@Resource
private ModuleRegistryService moduleRegistryService;
@Resource
private ModuleRegistryMapper moduleRegistryMapper;
@Override
public void run(ApplicationArguments args) {
check();
}
/**
* 执行校验;不一致时抛 {@link IllegalStateException} 让启动失败。
*/
public void check() {
if (!strict) {
log.warn("gxmu.module-registry.strict=false,跳过模块注册表一致性校验(仅限本地无库启动)");
return;
}
List<ModuleRegistry> modules = moduleRegistryService.listAll();
if (modules == null || modules.isEmpty()) {
log.warn("模块注册表为空,跳过一致性校验;请确认 gxmu_module_registry 已初始化");
return;
}
List<String> mismatches = new ArrayList<>();
Set<String> tableNames = new LinkedHashSet<>();
for (ModuleRegistry item : modules) {
String module = item.getModule();
String tableName = item.getTableName();
if (tableName == null || !tableName.equals(module)) {
mismatches.add(module + " -> " + tableName);
} else {
tableNames.add(tableName);
}
}
if (!mismatches.isEmpty()) {
throw new IllegalStateException("模块注册表 table_name 与 module 不一致:" + String.join(", ", mismatches)
+ "。module 同时是业务表名,必须与 table_name 相同(见 gxmu_module_registry 表注释);"
+ "请修正注册表数据后重启,或本地无库启动时设置 gxmu.module-registry.strict=false");
}
Set<String> existing = new HashSet<>(moduleRegistryMapper.selectExistingTableNames(tableNames));
List<String> missing = new ArrayList<>();
for (String tableName : tableNames) {
if (!existing.contains(tableName)) {
missing.add(tableName);
}
}
if (!missing.isEmpty()) {
throw new IllegalStateException("模块注册表引用的业务表不存在:" + String.join(", ", missing)
+ "。请先执行建表脚本或修正注册表数据;本地无库启动时可设置 gxmu.module-registry.strict=false");
}
log.info("模块注册表一致性校验通过:{} 个模块,{} 张业务表", modules.size(), tableNames.size());
}
}
@@ -0,0 +1,39 @@
package com.gxwebsoft.gxmu.module.controller;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
/**
* 模块注册表控制器
*
* <p>前端启动时一次性拉取启用模块清单并缓存,模块下拉、详情页、导出模板都从这里驱动。
*
* @author Codex
* @since 2026-11-10
*/
@Api(tags = "模块注册表")
@RestController
@RequestMapping("/api/gxmu/module-registry")
public class ModuleRegistryController extends BaseController {
@Resource
private ModuleRegistryService moduleRegistryService;
@ApiOperation("启用模块清单")
@PreAuthorize("isAuthenticated()")
@GetMapping("/list")
public ApiResult<List<ModuleRegistry>> list() {
return success(moduleRegistryService.listEnabled());
}
}
@@ -0,0 +1,69 @@
package com.gxwebsoft.gxmu.module.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 模块注册表:业务模块元数据的唯一来源,见 docs/adr/0006-approval-chain-convergence.md。
*
* <p>module 同时是业务表名,因此 tableName 必须与它一致,
* 不一致会在启动期直接失败(见 ModuleRegistryIntegrityChecker)。
*
* @author Codex
* @since 2026-11-10
*/
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value = "ModuleRegistry对象", description = "模块注册表")
@TableName("gxmu_module_registry")
public class ModuleRegistry implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "模块标识,同时是业务表名")
private String module;
@ApiModelProperty(value = "业务表名,必须与 module 一致")
private String tableName;
@ApiModelProperty(value = "模块标题")
private String title;
@ApiModelProperty(value = "详情页配置键(moduleDetailConfig.ts)")
private String detailKey;
@ApiModelProperty(value = "导出模板标识,空表示走默认导出")
private String exportTemplate;
@ApiModelProperty(value = "是否启用:0否 1是")
private Integer enabled;
@ApiModelProperty(value = "排序号,小的在前")
private Integer sortNumber;
@ApiModelProperty(value = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@ApiModelProperty(value = "租户id")
private Integer tenantId;
@ApiModelProperty(value = "创建时间")
private LocalDateTime createTime;
@ApiModelProperty(value = "修改时间")
private LocalDateTime updateTime;
private Integer userId;
}
@@ -0,0 +1,31 @@
package com.gxwebsoft.gxmu.module.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
/**
* 模块注册表 Mapper
*
* @author Codex
* @since 2026-11-10
*/
public interface ModuleRegistryMapper extends BaseMapper<ModuleRegistry> {
/**
* 查询当前库中真实存在的表名,用于启动期校验注册表引用的业务表。
*
* @param names 待校验的表名集合
* @return 存在的表名
*/
@Select("<script>"
+ "SELECT TABLE_NAME FROM information_schema.TABLES "
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN "
+ "<foreach collection='names' item='name' open='(' separator=',' close=')'>#{name}</foreach>"
+ "</script>")
List<String> selectExistingTableNames(@Param("names") Collection<String> names);
}
@@ -0,0 +1,58 @@
package com.gxwebsoft.gxmu.module.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 模块注册表服务:模块元数据的唯一读取入口。
*
* <p>调用方不要再维护模块名常量或标题映射,一律从这里取;
* 注册表里查不到的历史 module 由调用方回退 module 原串,不抛错。
*
* @author Codex
* @since 2026-11-10
*/
public interface ModuleRegistryService extends IService<ModuleRegistry> {
/**
* 启用模块清单,按 sort_number 升序。
*/
List<ModuleRegistry> listEnabled();
/**
* 全部未删除模块(含停用),按 sort_number 升序。
*/
List<ModuleRegistry> listAll();
/**
* 按 module 查注册行,未注册返回 null。
*/
ModuleRegistry getByModule(String module);
/**
* 模块标题;未注册或标题为空时回退 module 原串(module 为 null 时返回 null)。
*/
String titleOf(String module);
/**
* 全部模块的 module -> title 映射(含停用),供列表页一次性填充标题。
*/
Map<String, String> titleMap();
/**
* 注册表里存在的全部 module(含停用)。
*/
Set<String> allModules();
/**
* module 是否在注册表里存在(含停用)。
*
* <p>口径:停用只影响入口与列表,不影响存量记录的撤回/作废能力,
* 所以停用模块也必须返回 true。
*/
boolean containsModule(String module);
}
@@ -0,0 +1,105 @@
package com.gxwebsoft.gxmu.module.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 模块注册表 Service 实现
*
* @author Codex
* @since 2026-11-10
*/
@Service
public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper, ModuleRegistry>
implements ModuleRegistryService {
private static final int ENABLED = 1;
@Override
public List<ModuleRegistry> listEnabled() {
return listAll().stream()
.filter(item -> item.getEnabled() != null && item.getEnabled() == ENABLED)
.collect(Collectors.toList());
}
@Override
public List<ModuleRegistry> listAll() {
// 用字符串列名而非 Lambda:注册表服务是纯读路径,且要在无 MyBatis 上下文的单测里可用
List<ModuleRegistry> list = baseMapper.selectList(new QueryWrapper<ModuleRegistry>()
.orderByAsc("sort_number")
.orderByAsc("id"));
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
return list.stream().sorted(sortOrder()).collect(Collectors.toList());
}
@Override
public ModuleRegistry getByModule(String module) {
if (module == null || module.isEmpty()) {
return null;
}
return baseMapper.selectOne(new QueryWrapper<ModuleRegistry>()
.eq("module", module)
.last("limit 1"));
}
@Override
public String titleOf(String module) {
if (module == null) {
return null;
}
ModuleRegistry item = getByModule(module);
if (item == null || item.getTitle() == null || item.getTitle().isEmpty()) {
return module;
}
return item.getTitle();
}
@Override
public Map<String, String> titleMap() {
Map<String, String> titleMap = new LinkedHashMap<>();
for (ModuleRegistry item : listAll()) {
if (item.getModule() == null || item.getTitle() == null || item.getTitle().isEmpty()) {
continue;
}
titleMap.putIfAbsent(item.getModule(), item.getTitle());
}
return titleMap;
}
@Override
public Set<String> allModules() {
Set<String> modules = new LinkedHashSet<>();
for (ModuleRegistry item : listAll()) {
if (item.getModule() != null) {
modules.add(item.getModule());
}
}
return modules;
}
@Override
public boolean containsModule(String module) {
return module != null && !module.isEmpty() && allModules().contains(module);
}
private Comparator<ModuleRegistry> sortOrder() {
return Comparator.comparingInt((ModuleRegistry item) ->
item.getSortNumber() == null ? Integer.MAX_VALUE : item.getSortNumber())
.thenComparingInt(item -> item.getId() == null ? Integer.MAX_VALUE : item.getId());
}
}
@@ -6,6 +6,7 @@ import com.gxwebsoft.gxmu.entity.Declare;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
import com.gxwebsoft.gxmu.model.DeclareCheckResult; import com.gxwebsoft.gxmu.model.DeclareCheckResult;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import com.gxwebsoft.gxmu.service.DeclareService; import com.gxwebsoft.gxmu.service.DeclareService;
import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.service.ReviewListService;
@@ -17,7 +18,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -40,12 +40,6 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
public static final int STATUS_REJECTED = 3; public static final int STATUS_REJECTED = 3;
public static final int STATUS_VOID = 4; public static final int STATUS_VOID = 4;
/** 走申报项目判重的 9 个模块,模块名同时是表名 */
private static final Set<String> MODULES = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(
"gxmu_sjqn", "gxmu_sjtbzbsj", "gxmu_wshqtw", "gxmu_wshqtzb",
"gxmu_yxgqtdgb", "gxmu_yxgqty", "gxmu_tzbcy_form", "gxmu_qmgc_form",
"gxmu_wxxzx_project")));
private static final String TZBCY_MODULE = "gxmu_tzbcy_form"; private static final String TZBCY_MODULE = "gxmu_tzbcy_form";
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@@ -57,10 +51,18 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
private DeclareService declareService; private DeclareService declareService;
@Resource @Resource
private ReviewListService reviewListService; private ReviewListService reviewListService;
@Resource
private ModuleRegistryService moduleRegistryService;
/**
* 注册表驱动的模块集合:注册表里存在的 module 一律支持(含 enabled=0)。
*
* <p>口径:停用只影响入口与列表,不影响存量记录的撤回/作废能力;
* 若只认启用模块,停用模块的历史数据会失去撤回、作废与状态同步。
*/
@Override @Override
public boolean supports(String module) { public boolean supports(String module) {
return module != null && MODULES.contains(module); return module != null && moduleRegistryService.containsModule(module);
} }
@Override @Override
@@ -12,12 +12,11 @@ import com.gxwebsoft.common.system.entity.Organization;
import com.gxwebsoft.common.system.service.OrganizationService; import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -31,27 +30,13 @@ import java.util.stream.Collectors;
*/ */
@Service @Service
public class ReviewFlowConfigServiceImpl extends ServiceImpl<ReviewFlowConfigMapper, ReviewFlowConfig> implements ReviewFlowConfigService { public class ReviewFlowConfigServiceImpl extends ServiceImpl<ReviewFlowConfigMapper, ReviewFlowConfig> implements ReviewFlowConfigService {
private static final Map<String, String> MODULE_NAME_MAP;
static {
Map<String, String> moduleNameMap = new LinkedHashMap<>();
moduleNameMap.put("cms_manuscript", "稿件");
moduleNameMap.put("gxmu_sjqn", "十佳青年岗位能手");
moduleNameMap.put("gxmu_sjtbzbsj", "十佳团支部书记");
moduleNameMap.put("gxmu_wshqtw", "五四红旗团委");
moduleNameMap.put("gxmu_wshqtzb", "五四红旗团支部");
moduleNameMap.put("gxmu_yxgqtdgb", "优秀共青团干部");
moduleNameMap.put("gxmu_yxgqty", "优秀共青团员");
moduleNameMap.put("gxmu_tzbcy_form", "挑战杯");
moduleNameMap.put("gxmu_qmgc_form", "青马工程");
moduleNameMap.put("gxmu_wxxzx_project", "未来学术之星");
MODULE_NAME_MAP = Collections.unmodifiableMap(moduleNameMap);
}
@Resource @Resource
private ReviewFlowService reviewFlowService; private ReviewFlowService reviewFlowService;
@Resource @Resource
private OrganizationService organizationService; private OrganizationService organizationService;
@Resource
private ModuleRegistryService moduleRegistryService;
@Override @Override
public PageResult<ReviewFlowConfig> pageRel(ReviewFlowConfigParam param) { public PageResult<ReviewFlowConfig> pageRel(ReviewFlowConfigParam param) {
@@ -110,11 +95,21 @@ public class ReviewFlowConfigServiceImpl extends ServiceImpl<ReviewFlowConfigMap
flowNameMap = reviewFlowService.listByIds(flowIds).stream() flowNameMap = reviewFlowService.listByIds(flowIds).stream()
.collect(Collectors.toMap(ReviewFlow::getId, ReviewFlow::getTitle, (left, right) -> left)); .collect(Collectors.toMap(ReviewFlow::getId, ReviewFlow::getTitle, (left, right) -> left));
} }
// 模块标题以注册表为唯一来源;注册表里查不到的历史 module 回退原串,不让列表报错
Map<String, String> moduleTitleMap = moduleRegistryService.titleMap();
for (ReviewFlowConfig item : list) { for (ReviewFlowConfig item : list) {
item.setFlowName(flowNameMap.get(item.getFlowId())); item.setFlowName(flowNameMap.get(item.getFlowId()));
item.setModuleName(MODULE_NAME_MAP.get(item.getModule())); item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule()));
item.setOrganizationName(organizationNameMap.get(item.getOrganizationId())); item.setOrganizationName(organizationNameMap.get(item.getOrganizationId()));
} }
} }
private String moduleTitleOf(Map<String, String> moduleTitleMap, String module) {
if (module == null) {
return null;
}
String title = moduleTitleMap.get(module);
return title == null || title.isEmpty() ? module : title;
}
} }
@@ -7,13 +7,12 @@ import com.gxwebsoft.gxmu.mapper.ReviewListMapper;
import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.service.ReviewListService;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.param.ReviewListParam; import com.gxwebsoft.gxmu.param.ReviewListParam;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -25,25 +24,11 @@ import java.util.Map;
*/ */
@Service @Service
public class ReviewListServiceImpl extends ServiceImpl<ReviewListMapper, ReviewList> implements ReviewListService { public class ReviewListServiceImpl extends ServiceImpl<ReviewListMapper, ReviewList> implements ReviewListService {
private static final Map<String, String> MODULE_NAME_MAP;
static {
Map<String, String> moduleNameMap = new LinkedHashMap<>();
moduleNameMap.put("cms_manuscript", "稿件");
moduleNameMap.put("gxmu_sjqn", "十佳青年岗位能手");
moduleNameMap.put("gxmu_sjtbzbsj", "十佳团支部书记");
moduleNameMap.put("gxmu_wshqtw", "五四红旗团委");
moduleNameMap.put("gxmu_wshqtzb", "五四红旗团支部");
moduleNameMap.put("gxmu_yxgqtdgb", "优秀共青团干部");
moduleNameMap.put("gxmu_yxgqty", "优秀共青团员");
moduleNameMap.put("gxmu_tzbcy_form", "挑战杯");
moduleNameMap.put("gxmu_qmgc_form", "青马工程");
moduleNameMap.put("gxmu_wxxzx_project", "未来学术之星");
MODULE_NAME_MAP = Collections.unmodifiableMap(moduleNameMap);
}
@Resource @Resource
private UserService userService; private UserService userService;
@Resource
private ModuleRegistryService moduleRegistryService;
@Override @Override
public PageResult<ReviewList> pageRel(ReviewListParam param) { public PageResult<ReviewList> pageRel(ReviewListParam param) {
@@ -98,11 +83,21 @@ public class ReviewListServiceImpl extends ServiceImpl<ReviewListMapper, ReviewL
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return; return;
} }
// 模块标题以注册表为唯一来源;注册表里查不到的历史 module 回退原串,不让列表报错
Map<String, String> moduleTitleMap = moduleRegistryService.titleMap();
for (ReviewList item : list) { for (ReviewList item : list) {
item.setModuleName(MODULE_NAME_MAP.get(item.getModule())); item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule()));
} }
} }
private String moduleTitleOf(Map<String, String> moduleTitleMap, String module) {
if (module == null) {
return null;
}
String title = moduleTitleMap.get(module);
return title == null || title.isEmpty() ? module : title;
}
@Override @Override
public List<ReviewList> listByModuleNPK(String module, Integer pk){ public List<ReviewList> listByModuleNPK(String module, Integer pk){
return list( return list(
+5
View File
@@ -204,6 +204,11 @@ gxmu:
timeout-ms: 10000 timeout-ms: 10000
# 名册外的账号默认拒绝登录,见 ADR-0001:学生须先同步进名册 # 名册外的账号默认拒绝登录,见 ADR-0001:学生须先同步进名册
allow-unknown-user: false allow-unknown-user: false
# 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、
# 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。
# strict 默认 true;本地无库启动可临时设为 false 跳过校验。
module-registry:
strict: true
openplat-sync: openplat-sync:
enabled: true enabled: true
# 每天 03:30 # 每天 03:30
@@ -0,0 +1,110 @@
package com.gxwebsoft.gxmu.module;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 模块注册表启动期一致性校验的测试(不启 Spring、不连库)。
*
* <p>对应用户故事 21:接入新模块时「表名与 module 名不一致」要启动期直接失败,
* 而不是运行到详情页才发现空白;同时保留 strict=false 供无库本地启动。
*
* @author Codex
* @since 2026-11-10
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ModuleRegistryIntegrityCheckerTest {
@Mock
private ModuleRegistryService moduleRegistryService;
@Mock
private ModuleRegistryMapper moduleRegistryMapper;
@InjectMocks
private ModuleRegistryIntegrityChecker checker;
@BeforeEach
void strictOn() {
ReflectionTestUtils.setField(checker, "strict", true);
}
@Test
void failsWhenTableNameDiffersFromModule() {
when(moduleRegistryService.listAll()).thenReturn(Collections.singletonList(
module("gxmu_new_form", "gxmu_new_form_table", 1)));
IllegalStateException error = assertThrows(IllegalStateException.class, () -> checker.check());
assertTrue(error.getMessage().contains("gxmu_new_form"), error.getMessage());
assertTrue(error.getMessage().contains("gxmu_new_form_table"), error.getMessage());
verify(moduleRegistryMapper, never()).selectExistingTableNames(any());
}
@Test
void failsWhenRegisteredTableDoesNotExist() {
when(moduleRegistryService.listAll()).thenReturn(Arrays.asList(
module("gxmu_sjqn", "gxmu_sjqn", 1),
module("gxmu_ghost_form", "gxmu_ghost_form", 0)));
when(moduleRegistryMapper.selectExistingTableNames(any()))
.thenReturn(Collections.singletonList("gxmu_sjqn"));
IllegalStateException error = assertThrows(IllegalStateException.class, () -> checker.check());
assertTrue(error.getMessage().contains("gxmu_ghost_form"), error.getMessage());
}
@Test
void passesWhenRegistryMatchesSchema() {
when(moduleRegistryService.listAll()).thenReturn(Collections.singletonList(
module("gxmu_sjqn", "gxmu_sjqn", 1)));
when(moduleRegistryMapper.selectExistingTableNames(any()))
.thenReturn(Collections.singletonList("gxmu_sjqn"));
assertDoesNotThrow(() -> checker.check());
}
@Test
void strictFalseSkipsValidationForLocalStartupWithoutDatabase() {
ReflectionTestUtils.setField(checker, "strict", false);
when(moduleRegistryService.listAll()).thenReturn(Collections.singletonList(
module("gxmu_new_form", "gxmu_new_form_table", 1)));
assertDoesNotThrow(() -> checker.check());
verify(moduleRegistryService, never()).listAll();
}
private ModuleRegistry module(String name, String tableName, int enabled) {
ModuleRegistry item = new ModuleRegistry();
item.setModule(name);
item.setTableName(tableName);
item.setTitle(name);
item.setDetailKey(name);
item.setEnabled(enabled);
item.setSortNumber(1);
item.setDeleted(0);
return item;
}
}
@@ -0,0 +1,113 @@
package com.gxwebsoft.gxmu.module;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper;
import com.gxwebsoft.gxmu.module.service.impl.ModuleRegistryServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* 模块注册表服务的单元测试(mock 掉 Mapper,不启 Spring、不连库)。
*
* <p>注册表是模块元数据的唯一来源,这里锁住三件对外可观察的事:
* 启用清单按 sort_number 升序、标题查询在未注册时回退 module 原串、
* 模块集合包含已停用模块(停用模块的历史数据仍要能撤回/作废)。
*
* @author Codex
* @since 2026-11-10
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ModuleRegistryServiceImplTest {
@Mock
private ModuleRegistryMapper moduleRegistryMapper;
@InjectMocks
private ModuleRegistryServiceImpl moduleRegistryService;
@Test
void listEnabledReturnsOnlyEnabledModulesOrderedBySortNumber() {
when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList(
module("gxmu_qmgc_form", "青马工程", 1, 8),
module("gxmu_sjtbzbsj", "十佳团支部书记", 0, 2),
module("gxmu_sjqn", "十佳青年岗位能手", 1, 1)));
List<ModuleRegistry> enabled = moduleRegistryService.listEnabled();
assertEquals(Arrays.asList("gxmu_sjqn", "gxmu_qmgc_form"),
enabled.stream().map(ModuleRegistry::getModule).collect(Collectors.toList()));
}
@Test
void titleOfReturnsRegisteredTitle() {
when(moduleRegistryMapper.selectOne(any())).thenReturn(module("gxmu_tzbcy_form", "挑战杯", 1, 7));
assertEquals("挑战杯", moduleRegistryService.titleOf("gxmu_tzbcy_form"));
}
@Test
void titleOfFallsBackToModuleStringWhenNotRegistered() {
when(moduleRegistryMapper.selectOne(any())).thenReturn(null);
assertEquals("cms_legacy_module", moduleRegistryService.titleOf("cms_legacy_module"));
}
@Test
void titleOfKeepsNullModuleNull() {
assertNull(moduleRegistryService.titleOf(null));
}
@Test
void titleMapCoversDisabledModulesSoHistoricalRowsKeepTitles() {
when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList(
module("gxmu_sjqn", "十佳青年岗位能手", 1, 1),
module("gxmu_yxgqty", "优秀共青团员", 0, 6)));
Map<String, String> titles = moduleRegistryService.titleMap();
assertEquals("十佳青年岗位能手", titles.get("gxmu_sjqn"));
assertEquals("优秀共青团员", titles.get("gxmu_yxgqty"));
assertEquals(2, titles.size());
}
@Test
void moduleSetContainsDisabledModules() {
when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList(
module("gxmu_sjqn", "十佳青年岗位能手", 1, 1),
module("gxmu_yxgqty", "优秀共青团员", 0, 6)));
assertTrue(moduleRegistryService.containsModule("gxmu_yxgqty"));
assertFalse(moduleRegistryService.containsModule("gxmu_unknown"));
assertFalse(moduleRegistryService.containsModule(null));
}
private ModuleRegistry module(String name, String title, int enabled, int sortNumber) {
ModuleRegistry item = new ModuleRegistry();
item.setModule(name);
item.setTableName(name);
item.setTitle(title);
item.setDetailKey(name);
item.setEnabled(enabled);
item.setSortNumber(sortNumber);
item.setDeleted(0);
return item;
}
}
@@ -0,0 +1,94 @@
package com.gxwebsoft.gxmu.service.impl;
import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
import com.gxwebsoft.gxmu.mapper.ReviewFlowConfigMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.param.ReviewFlowConfigParam;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* 审批链配置列表的模块名填充测试(mock 掉 Mapper 与注册表,不启 Spring、不连库)。
*
* <p>模块名改读注册表后仍要保住一条底线:注册表里查不到的老 module
* 不能让列表报错或空白,必须回退 module 原串。
*
* @author Codex
* @since 2026-11-10
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ReviewFlowConfigServiceImplTest {
@Mock
private ReviewFlowConfigMapper reviewFlowConfigMapper;
@Mock
private ReviewFlowService reviewFlowService;
@Mock
private OrganizationService organizationService;
@Mock
private ModuleRegistryService moduleRegistryService;
@InjectMocks
private ReviewFlowConfigServiceImpl reviewFlowConfigService;
@Test
void fillsModuleNameFromRegistry() {
ReviewFlowConfig config = config("gxmu_sjqn");
when(reviewFlowConfigMapper.selectListRel(any())).thenReturn(Collections.singletonList(config));
when(moduleRegistryService.titleMap())
.thenReturn(Collections.singletonMap("gxmu_sjqn", "十佳青年岗位能手"));
List<ReviewFlowConfig> list = reviewFlowConfigService.listRel(new ReviewFlowConfigParam());
assertEquals("十佳青年岗位能手", list.get(0).getModuleName());
}
@Test
void fallsBackToModuleStringWhenRegistryHasNoSuchModule() {
ReviewFlowConfig config = config("cms_legacy_module");
when(reviewFlowConfigMapper.selectListRel(any())).thenReturn(Collections.singletonList(config));
when(moduleRegistryService.titleMap()).thenReturn(Collections.emptyMap());
List<ReviewFlowConfig> list = reviewFlowConfigService.listRel(new ReviewFlowConfigParam());
assertEquals("cms_legacy_module", list.get(0).getModuleName());
}
@Test
void keepsNullModuleNull() {
ReviewFlowConfig config = config(null);
when(reviewFlowConfigMapper.selectListRel(any())).thenReturn(Collections.singletonList(config));
when(moduleRegistryService.titleMap()).thenReturn(Collections.emptyMap());
List<ReviewFlowConfig> list = reviewFlowConfigService.listRel(new ReviewFlowConfigParam());
assertNull(list.get(0).getModuleName());
}
private ReviewFlowConfig config(String module) {
ReviewFlowConfig config = new ReviewFlowConfig();
config.setId(1);
config.setModule(module);
config.setTitle("配置");
return config;
}
}