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)。
+ *
+ *
两件事不满足就在启动期直接失败,而不是等运行到详情页才发现空白:
+ *
+ * - 注册表 table_name 与 module 一致(module 同时是业务表名);
+ * - 注册表引用的业务表在库里真实存在。
+ *
+ *
+ * 允许 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;
+ }
+}