fix(gxmu): 注册表补 has_college 与团员管理种子,接口返回 DTO 并支持 all=true

- gxmu_module_registry 增加 has_college(4 张在跑表置 1,含已有库补列与补数据语句)
- 种子补 gxmu_tygl_form / gxmu_tygl(enabled=0,团员管理,sort 11/12),
  同步修正末尾「执行后校验」:12 行未删除、5 行启用、has_college 4 行
- GET /api/gxmu/module-registry/list 支持 all=true(默认只返回启用),
  改为返回 ModuleRegistryDTO,不再外泄 tenantId / userId / 时间戳
- ModuleRegistryService:删掉无生产调用的 titleOf(String)/allModules,
  标题回退收敛成唯一的静态 titleOf(Map, module);getByModule 改由 hasCollege 使用
- listAll 只保留 SQL 排序(sort_number 升序,NULL 排最前,与 MySQL ASC 口径一致),
  删掉内存二次排序
- ModuleRegistryIntegrityChecker 增加反向告警:review_flow_config 里有、注册表里没有的
  module 打 warn,不阻断启动

refs #58
This commit is contained in:
2026-11-03 23:13:29 +08:00
parent 9c4476a395
commit 37cc8ee0d5
10 changed files with 296 additions and 89 deletions
@@ -26,6 +26,10 @@ import java.util.Set;
* <li>注册表引用的业务表在库里真实存在。</li>
* </ol>
*
* <p>反方向只告警不阻断:{@code gxmu_review_flow_config} 里有、注册表里没有的 module
* 打 warn(这种模块的标题会回退成裸 module 串、也不会出现在模块下拉里,但存量数据仍要能查)。
* 不阻断启动是刻意的——存量脏数据不该挡住上线,但必须留下可检索的线索。
*
* <p>允许 gxmu.module-registry.strict=false 关闭校验,便于无库本地启动;
* 生产环境必须保持默认的 true。
*
@@ -51,6 +55,34 @@ public class ModuleRegistryIntegrityChecker implements ApplicationRunner {
check();
}
/**
* 反向校验:审批链配置里有、注册表里没有的 module 打 warn,不阻断启动。
*
* <p>这类模块的标题会回退成裸 module 串、也不会出现在模块下拉里,
* 但存量申报记录仍要能查——所以只提示补注册表行,不抛异常。
*
* @param modules 注册表里的全部模块
*/
private void warnModulesMissingFromRegistry(List<ModuleRegistry> modules) {
Set<String> registered = new HashSet<>();
for (ModuleRegistry item : modules) {
if (item.getModule() != null) {
registered.add(item.getModule());
}
}
List<String> configured = moduleRegistryMapper.selectConfiguredModules();
if (configured == null || configured.isEmpty()) {
return;
}
for (String module : configured) {
if (module != null && !module.isEmpty() && !registered.contains(module)) {
log.warn("gxmu_review_flow_config 里的模块 {} 未在 gxmu_module_registry 登记:"
+ "该模块的标题会回退成 module 原串、也不会出现在模块下拉里;"
+ "请补一行注册表数据(enabled 按是否新开申报入口决定)", module);
}
}
}
/**
* 执行校验;不一致时抛 {@link IllegalStateException} 让启动失败。
*/
@@ -91,6 +123,7 @@ public class ModuleRegistryIntegrityChecker implements ApplicationRunner {
throw new IllegalStateException("模块注册表引用的业务表不存在:" + String.join(", ", missing)
+ "。请先执行建表脚本或修正注册表数据;本地无库启动时可设置 gxmu.module-registry.strict=false");
}
warnModulesMissingFromRegistry(modules);
log.info("模块注册表一致性校验通过:{} 个模块,{} 张业务表", modules.size(), tableNames.size());
}
}
@@ -3,21 +3,27 @@ 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.model.ModuleRegistryDTO;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 模块注册表控制器
*
* <p>前端启动时一次性拉取启用模块清单并缓存,模块下拉、详情页、导出模板都从这里驱动。
* <p>前端启动时一次性拉取模块清单并缓存:默认只返回启用模块(新链的模块下拉、申报入口);
* {@code all=true} 返回全部模块(含停用,审核列表的模块筛选要能捞出停用模块的存量记录)。
*
* @author Codex
* @since 2026-11-10
@@ -30,10 +36,16 @@ public class ModuleRegistryController extends BaseController {
@Resource
private ModuleRegistryService moduleRegistryService;
@ApiOperation("启用模块清单")
@ApiOperation("模块清单:默认只返回启用模块,all=true 返回全部(含停用)")
@PreAuthorize("isAuthenticated()")
@GetMapping("/list")
public ApiResult<List<ModuleRegistry>> list() {
return success(moduleRegistryService.listEnabled());
public ApiResult<List<ModuleRegistryDTO>> list(
@ApiParam("是否返回全部模块(含停用):true 返回全部,默认只返回启用")
@RequestParam(value = "all", required = false, defaultValue = "false") boolean all) {
List<ModuleRegistry> modules = all ? moduleRegistryService.listAll() : moduleRegistryService.listEnabled();
if (modules == null || modules.isEmpty()) {
return success(new ArrayList<>());
}
return success(modules.stream().map(ModuleRegistryDTO::of).collect(Collectors.toList()));
}
}
@@ -46,6 +46,9 @@ public class ModuleRegistry implements Serializable {
@ApiModelProperty(value = "导出模板标识,空表示走默认导出")
private String exportTemplate;
@ApiModelProperty(value = "业务表是否有学院标识列:0否 1是(决定审批人解析是否读 college_id)")
private Integer hasCollege;
@ApiModelProperty(value = "是否启用:0否 1是")
private Integer enabled;
@@ -28,4 +28,15 @@ public interface ModuleRegistryMapper extends BaseMapper<ModuleRegistry> {
+ "<foreach collection='names' item='name' open='(' separator=',' close=')'>#{name}</foreach>"
+ "</script>")
List<String> selectExistingTableNames(@Param("names") Collection<String> names);
/**
* 审批链配置里出现过的模块名,用于反向校验「有审批链但注册表没登记」的模块。
*
* <p>只用于启动期告警,不阻断启动:存量脏数据不该挡住上线。
*
* @return 审批链配置里的模块名(去重)
*/
@Select("SELECT DISTINCT module FROM gxmu_review_flow_config "
+ "WHERE deleted = 0 AND module IS NOT NULL AND module <> ''")
List<String> selectConfiguredModules();
}
@@ -0,0 +1,65 @@
package com.gxwebsoft.gxmu.module.model;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 模块注册表的对外视图(spec #58 API 契约)。
*
* <p>只暴露前端需要的元数据;tenantId / userId / createTime / updateTime 是库内字段,
* 对前端无意义,不随接口外泄。
*
* @author Codex
* @since 2026-11-11
*/
@Data
@ApiModel(value = "ModuleRegistryDTO对象", description = "模块注册表对外视图")
public class ModuleRegistryDTO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "模块标识,同时是业务表名")
private String module;
@ApiModelProperty(value = "业务表名")
private String tableName;
@ApiModelProperty(value = "模块标题")
private String title;
@ApiModelProperty(value = "详情页配置键")
private String detailKey;
@ApiModelProperty(value = "导出模板标识,空表示走默认导出")
private String exportTemplate;
@ApiModelProperty(value = "是否启用:0否 1是")
private Integer enabled;
@ApiModelProperty(value = "排序号,小的在前")
private Integer sortNumber;
/**
* 由注册表行构造对外视图。
*
* @param item 注册表行,可为空
* @return 对外视图,入参为空时返回 null
*/
public static ModuleRegistryDTO of(ModuleRegistry item) {
if (item == null) {
return null;
}
ModuleRegistryDTO dto = new ModuleRegistryDTO();
dto.setModule(item.getModule());
dto.setTableName(item.getTableName());
dto.setTitle(item.getTitle());
dto.setDetailKey(item.getDetailKey());
dto.setExportTemplate(item.getExportTemplate());
dto.setEnabled(item.getEnabled());
dto.setSortNumber(item.getSortNumber());
return dto;
}
}
@@ -5,13 +5,12 @@ import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 模块注册表服务:模块元数据的唯一读取入口。
*
* <p>调用方不要再维护模块名常量或标题映射,一律从这里取;
* 注册表里查不到的历史 module 由调用方回退 module 原串,不抛错。
* 注册表里查不到的历史 module 由 {@link #titleOf(Map, String)} 回退 module 原串,不抛错。
*
* @author Codex
* @since 2026-11-10
@@ -33,21 +32,11 @@ public interface ModuleRegistryService extends IService<ModuleRegistry> {
*/
ModuleRegistry getByModule(String module);
/**
* 模块标题;未注册或标题为空时回退 module 原串(module 为 null 时返回 null)。
*/
String titleOf(String module);
/**
* 全部模块的 module -> title 映射(含停用),供列表页一次性填充标题。
*/
Map<String, String> titleMap();
/**
* 注册表里存在的全部 module(含停用)。
*/
Set<String> allModules();
/**
* module 是否在注册表里存在(含停用)。
*
@@ -55,4 +44,33 @@ public interface ModuleRegistryService extends IService<ModuleRegistry> {
* 所以停用模块也必须返回 true。
*/
boolean containsModule(String module);
/**
* 该模块的业务表是否有 college_id(注册表 has_college = 1)。
*
* <p>审批人解析要读申报记录的学院标识,但只有部分业务表有这一列;
* 哪张表有由注册表决定,后端不再维护模块白名单(ADR 0006:新增表只改注册表一行)。
*
* @param module 模块编码
* @return true 表示可以按 college_id 读取学院上下文
*/
boolean hasCollege(String module);
/**
* 模块标题的唯一回退口径:注册表里有标题用标题,否则回退 module 原串。
*
* <p>放在接口上是因为注册表是模块元数据的唯一来源,而调用方(审核列表、审批链配置)
* 已经批量取了 {@link #titleMap()},不该为了一个标题再回查一次库。
*
* @param titleMap 模块标题映射,可为空
* @param module 模块编码,可为空
* @return 展示用标题;module 为空时返回 null
*/
static String titleOf(Map<String, String> titleMap, String module) {
if (module == null) {
return null;
}
String title = titleMap == null ? null : titleMap.get(module);
return title == null || title.isEmpty() ? module : title;
}
}
@@ -8,7 +8,6 @@ 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;
@@ -27,6 +26,7 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
implements ModuleRegistryService {
private static final int ENABLED = 1;
private static final int HAS_COLLEGE = 1;
@Override
public List<ModuleRegistry> listEnabled() {
@@ -35,6 +35,13 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
.collect(Collectors.toList());
}
/**
* 全部未删除模块。
*
* <p>排序只在 SQL 里做一次:{@code sort_number} 升序(MySQL 把 NULL 排最前,
* 与「没设排序号的模块排在最前面」的口径一致),再按 {@code id} 升序保证稳定。
* 内存里不再二次排序,避免 SQL 与 Java 两套 NULL 口径(SQL 前、Java 后)互相打架。
*/
@Override
public List<ModuleRegistry> listAll() {
// 用字符串列名而非 Lambda:注册表服务是纯读路径,且要在无 MyBatis 上下文的单测里可用
@@ -44,7 +51,7 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
return list.stream().sorted(sortOrder()).collect(Collectors.toList());
return list;
}
@Override
@@ -57,18 +64,6 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
.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<>();
@@ -82,24 +77,22 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
}
@Override
public Set<String> allModules() {
public boolean containsModule(String module) {
if (module == null || module.isEmpty()) {
return false;
}
Set<String> modules = new LinkedHashSet<>();
for (ModuleRegistry item : listAll()) {
if (item.getModule() != null) {
modules.add(item.getModule());
}
}
return modules;
return modules.contains(module);
}
@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());
public boolean hasCollege(String module) {
ModuleRegistry item = getByModule(module);
return item != null && item.getHasCollege() != null && item.getHasCollege() == HAS_COLLEGE;
}
}