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.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<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 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)。
*
* <p>口径:停用只影响入口与列表,不影响存量记录的撤回/作废能力;
* 若只认启用模块,停用模块的历史数据会失去撤回、作废与状态同步。
*/
@Override
public boolean supports(String module) {
return module != null && MODULES.contains(module);
return module != null && moduleRegistryService.containsModule(module);
}
@Override
@@ -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<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
private ReviewFlowService reviewFlowService;
@Resource
private OrganizationService organizationService;
@Resource
private ModuleRegistryService moduleRegistryService;
@Override
public PageResult<ReviewFlowConfig> pageRel(ReviewFlowConfigParam param) {
@@ -110,11 +95,21 @@ public class ReviewFlowConfigServiceImpl extends ServiceImpl<ReviewFlowConfigMap
flowNameMap = reviewFlowService.listByIds(flowIds).stream()
.collect(Collectors.toMap(ReviewFlow::getId, ReviewFlow::getTitle, (left, right) -> left));
}
// 模块标题以注册表为唯一来源;注册表里查不到的历史 module 回退原串,不让列表报错
Map<String, String> 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<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.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<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
private UserService userService;
@Resource
private ModuleRegistryService moduleRegistryService;
@Override
public PageResult<ReviewList> pageRel(ReviewListParam param) {
@@ -98,11 +83,21 @@ public class ReviewListServiceImpl extends ServiceImpl<ReviewListMapper, ReviewL
if (list == null || list.isEmpty()) {
return;
}
// 模块标题以注册表为唯一来源;注册表里查不到的历史 module 回退原串,不让列表报错
Map<String, String> 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<String, String> moduleTitleMap, String module) {
if (module == null) {
return null;
}
String title = moduleTitleMap.get(module);
return title == null || title.isEmpty() ? module : title;
}
@Override
public List<ReviewList> listByModuleNPK(String module, Integer pk){
return list(
+5
View File
@@ -204,6 +204,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