diff --git a/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java b/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java new file mode 100644 index 0000000..f5c6b8a --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java @@ -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)。 + * + *

两件事不满足就在启动期直接失败,而不是等运行到详情页才发现空白: + *

    + *
  1. 注册表 table_name 与 module 一致(module 同时是业务表名);
  2. + *
  3. 注册表引用的业务表在库里真实存在。
  4. + *
+ * + *

允许 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 modules = moduleRegistryService.listAll(); + if (modules == null || modules.isEmpty()) { + log.warn("模块注册表为空,跳过一致性校验;请确认 gxmu_module_registry 已初始化"); + return; + } + List mismatches = new ArrayList<>(); + Set 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 existing = new HashSet<>(moduleRegistryMapper.selectExistingTableNames(tableNames)); + List 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()); + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java b/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java new file mode 100644 index 0000000..59ada4a --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java @@ -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; + +/** + * 模块注册表控制器 + * + *

前端启动时一次性拉取启用模块清单并缓存,模块下拉、详情页、导出模板都从这里驱动。 + * + * @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() { + return success(moduleRegistryService.listEnabled()); + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java b/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java new file mode 100644 index 0000000..b2b9836 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java @@ -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。 + * + *

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; +} diff --git a/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java b/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java new file mode 100644 index 0000000..1615f6a --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java @@ -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 { + + /** + * 查询当前库中真实存在的表名,用于启动期校验注册表引用的业务表。 + * + * @param names 待校验的表名集合 + * @return 存在的表名 + */ + @Select("") + List selectExistingTableNames(@Param("names") Collection names); +} diff --git a/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java b/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java new file mode 100644 index 0000000..40b9267 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java @@ -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; + +/** + * 模块注册表服务:模块元数据的唯一读取入口。 + * + *

调用方不要再维护模块名常量或标题映射,一律从这里取; + * 注册表里查不到的历史 module 由调用方回退 module 原串,不抛错。 + * + * @author Codex + * @since 2026-11-10 + */ +public interface ModuleRegistryService extends IService { + + /** + * 启用模块清单,按 sort_number 升序。 + */ + List listEnabled(); + + /** + * 全部未删除模块(含停用),按 sort_number 升序。 + */ + List listAll(); + + /** + * 按 module 查注册行,未注册返回 null。 + */ + ModuleRegistry getByModule(String module); + + /** + * 模块标题;未注册或标题为空时回退 module 原串(module 为 null 时返回 null)。 + */ + String titleOf(String module); + + /** + * 全部模块的 module -> title 映射(含停用),供列表页一次性填充标题。 + */ + Map titleMap(); + + /** + * 注册表里存在的全部 module(含停用)。 + */ + Set allModules(); + + /** + * module 是否在注册表里存在(含停用)。 + * + *

口径:停用只影响入口与列表,不影响存量记录的撤回/作废能力, + * 所以停用模块也必须返回 true。 + */ + boolean containsModule(String module); +} diff --git a/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java new file mode 100644 index 0000000..c498f79 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java @@ -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 + implements ModuleRegistryService { + + private static final int ENABLED = 1; + + @Override + public List listEnabled() { + return listAll().stream() + .filter(item -> item.getEnabled() != null && item.getEnabled() == ENABLED) + .collect(Collectors.toList()); + } + + @Override + public List listAll() { + // 用字符串列名而非 Lambda:注册表服务是纯读路径,且要在无 MyBatis 上下文的单测里可用 + List list = baseMapper.selectList(new QueryWrapper() + .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() + .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 titleMap() { + Map 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 allModules() { + Set 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 sortOrder() { + return Comparator.comparingInt((ModuleRegistry item) -> + item.getSortNumber() == null ? Integer.MAX_VALUE : item.getSortNumber()) + .thenComparingInt(item -> item.getId() == null ? Integer.MAX_VALUE : item.getId()); + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java index 25985d5..046e3c3 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java @@ -6,6 +6,7 @@ import com.gxwebsoft.gxmu.entity.Declare; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; import com.gxwebsoft.gxmu.model.DeclareCheckResult; +import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareService; import com.gxwebsoft.gxmu.service.ReviewListService; @@ -17,7 +18,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.HashSet; 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_VOID = 4; - /** 走申报项目判重的 9 个模块,模块名同时是表名 */ - private static final Set 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 DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @@ -57,10 +51,18 @@ public class DeclareGuardServiceImpl implements DeclareGuardService { private DeclareService declareService; @Resource private ReviewListService reviewListService; + @Resource + private ModuleRegistryService moduleRegistryService; + /** + * 注册表驱动的模块集合:注册表里存在的 module 一律支持(含 enabled=0)。 + * + *

口径:停用只影响入口与列表,不影响存量记录的撤回/作废能力; + * 若只认启用模块,停用模块的历史数据会失去撤回、作废与状态同步。 + */ @Override public boolean supports(String module) { - return module != null && MODULES.contains(module); + return module != null && moduleRegistryService.containsModule(module); } @Override diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java index 6b1b92c..4d6c733 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java @@ -12,12 +12,11 @@ import com.gxwebsoft.common.system.entity.Organization; import com.gxwebsoft.common.system.service.OrganizationService; import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import org.springframework.stereotype.Service; import javax.annotation.Resource; -import java.util.Collections; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -31,27 +30,13 @@ import java.util.stream.Collectors; */ @Service public class ReviewFlowConfigServiceImpl extends ServiceImpl implements ReviewFlowConfigService { - private static final Map MODULE_NAME_MAP; - - static { - Map 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 private ReviewFlowService reviewFlowService; @Resource private OrganizationService organizationService; + @Resource + private ModuleRegistryService moduleRegistryService; @Override public PageResult pageRel(ReviewFlowConfigParam param) { @@ -110,11 +95,21 @@ public class ReviewFlowConfigServiceImpl extends ServiceImpl left)); } + // 模块标题以注册表为唯一来源;注册表里查不到的历史 module 回退原串,不让列表报错 + Map moduleTitleMap = moduleRegistryService.titleMap(); for (ReviewFlowConfig item : list) { 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())); } } + private String moduleTitleOf(Map moduleTitleMap, String module) { + if (module == null) { + return null; + } + String title = moduleTitleMap.get(module); + return title == null || title.isEmpty() ? module : title; + } + } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java index cc4d1fb..9d034c7 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java @@ -7,13 +7,12 @@ import com.gxwebsoft.gxmu.mapper.ReviewListMapper; import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.entity.ReviewList; 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.PageResult; import org.springframework.stereotype.Service; import javax.annotation.Resource; -import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -25,25 +24,11 @@ import java.util.Map; */ @Service public class ReviewListServiceImpl extends ServiceImpl implements ReviewListService { - private static final Map MODULE_NAME_MAP; - - static { - Map 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 private UserService userService; + @Resource + private ModuleRegistryService moduleRegistryService; @Override public PageResult pageRel(ReviewListParam param) { @@ -98,11 +83,21 @@ public class ReviewListServiceImpl extends ServiceImpl moduleTitleMap = moduleRegistryService.titleMap(); for (ReviewList item : list) { - item.setModuleName(MODULE_NAME_MAP.get(item.getModule())); + item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule())); } } + private String moduleTitleOf(Map moduleTitleMap, String module) { + if (module == null) { + return null; + } + String title = moduleTitleMap.get(module); + return title == null || title.isEmpty() ? module : title; + } + @Override public List listByModuleNPK(String module, Integer pk){ return list( diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 19d6f6b..c986a26 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -219,6 +219,11 @@ gxmu: timeout-ms: 10000 # 名册外的账号默认拒绝登录,见 ADR-0001:学生须先同步进名册 allow-unknown-user: false + # 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、 + # 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。 + # strict 默认 true;本地无库启动可临时设为 false 跳过校验。 + module-registry: + strict: true openplat-sync: enabled: true # 每天 03:30 diff --git a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java new file mode 100644 index 0000000..d57ce2b --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java @@ -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、不连库)。 + * + *

对应用户故事 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; + } +} diff --git a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java new file mode 100644 index 0000000..fa70958 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java @@ -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、不连库)。 + * + *

注册表是模块元数据的唯一来源,这里锁住三件对外可观察的事: + * 启用清单按 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 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 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; + } +} diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImplTest.java new file mode 100644 index 0000000..a4aac4c --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImplTest.java @@ -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、不连库)。 + * + *

模块名改读注册表后仍要保住一条底线:注册表里查不到的老 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 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 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 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; + } +}