diff --git a/sql/20261110_spec58_approval_chain.sql b/sql/20261110_spec58_approval_chain.sql index 0562ef7..3c39931 100644 --- a/sql/20261110_spec58_approval_chain.sql +++ b/sql/20261110_spec58_approval_chain.sql @@ -2,7 +2,8 @@ -- 内容: -- 1) gxmu_review_flow 增加 reviewer_rule(审批人规则,为空时回退 review_user_id) -- 2) 4 张在跑业务表增加 college_id(学院标识,指向 sys_organization.organization_id) --- 3) 新建 gxmu_module_registry(模块注册表)并写入 10 个模块的初始数据 +-- 3) 新建 gxmu_module_registry(模块注册表)并写入 12 个模块的初始数据 +-- 4) 注册表增加 has_college(该模块的业务表是否有 college_id),4 张在跑表置 1 -- -- 重复执行:加列前用 information_schema 判存(MySQL 5.7 不支持 ADD COLUMN IF NOT EXISTS), -- 建表用 IF NOT EXISTS,初始数据用 INSERT IGNORE; @@ -87,6 +88,8 @@ DEALLOCATE PREPARE stmt; -- 3) 模块注册表:模块元数据的唯一来源 -- module 同时是业务表名,table_name 与 module 不一致会在启动期直接失败(ADR 0006)。 -- detail_key 对应 moduleDetailConfig.ts 的配置键;export_template 留空表示走默认导出。 +-- has_college=1 表示该模块的业务表有 college_id,审批人解析才去读学院上下文; +-- 后端不再维护「哪几张表有学院标识」的模块白名单,新增表只改注册表一行。 -- ------------------------------------------------------------------ CREATE TABLE IF NOT EXISTS gxmu_module_registry ( id int NOT NULL AUTO_INCREMENT, @@ -95,6 +98,7 @@ CREATE TABLE IF NOT EXISTS gxmu_module_registry ( title varchar(100) NOT NULL COMMENT '模块标题', detail_key varchar(64) NOT NULL COMMENT '详情页配置键(moduleDetailConfig.ts)', export_template varchar(100) DEFAULT NULL COMMENT '导出模板标识', + has_college tinyint NOT NULL DEFAULT '0' COMMENT '业务表是否有 college_id:0否 1是', enabled tinyint NOT NULL DEFAULT '1' COMMENT '是否启用:0否 1是', sort_number int NOT NULL DEFAULT '0' COMMENT '排序号,小的在前', deleted tinyint NOT NULL DEFAULT '0' COMMENT '删除标记:0未删 1已删', @@ -106,24 +110,48 @@ CREATE TABLE IF NOT EXISTS gxmu_module_registry ( UNIQUE KEY uk_module (module) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='模块注册表'; --- 初始数据:9 个 gxmu 模块 + cms_manuscript。 +-- 已有库补列:gxmu_module_registry 可能已由上一版脚本建好,CREATE TABLE IF NOT EXISTS 不会补列 +SET @sql = IF( + EXISTS( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'gxmu_module_registry' AND COLUMN_NAME = 'has_college' + ), + 'SELECT 1', + 'ALTER TABLE gxmu_module_registry ADD COLUMN has_college tinyint NOT NULL DEFAULT ''0'' COMMENT ''业务表是否有 college_id:0否 1是''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 初始数据:11 个 gxmu 模块 + cms_manuscript。 -- title 取自 ReviewFlowConfigServiceImpl.MODULE_NAME_MAP,detail_key 与 moduleDetailConfig.ts 的键一致。 --- enabled=1:4 条在跑的 gxmu 审批链 + 稿件(cms_manuscript 有 review_flow_config 配置、当前就在审核下拉里, --- 停用会让「稿件」从审核筛选里静默消失);5 个「已接线未启用」的 gxmu 模块保持 0(定性见 issue #55)。 --- sort_number 按 reviewList/components/search.vue 当前下拉顺序,cms_manuscript 排在最后。 +-- enabled=1(5 行):4 条在跑的 gxmu 审批链 + 稿件(cms_manuscript 有 review_flow_config 配置、当前就在审核下拉里, +-- 停用会让「稿件」从审核筛选里静默消失)。 +-- enabled=0(7 行):5 个「已接线未启用」的 gxmu 模块(定性见 issue #55) +-- + gxmu_tygl_form / gxmu_tygl(团员管理,有存量数据与 review_flow_config 行,但不开新入口)。 +-- has_college=1:只有 4 张在跑业务表加了 college_id(见本脚本第 2 段)。 +-- sort_number 按 reviewList/components/search.vue 当前下拉顺序,cms_manuscript 排在最后,团员管理两行排在其后。 INSERT IGNORE INTO gxmu_module_registry - (module, table_name, title, detail_key, export_template, enabled, sort_number, deleted, tenant_id, user_id) + (module, table_name, title, detail_key, export_template, has_college, enabled, sort_number, deleted, tenant_id, user_id) VALUES - ('gxmu_sjqn', 'gxmu_sjqn', '十佳青年岗位能手', 'gxmu_sjqn', NULL, 1, 1, 0, 1, NULL), - ('gxmu_sjtbzbsj', 'gxmu_sjtbzbsj', '十佳团支部书记', 'gxmu_sjtbzbsj', NULL, 0, 2, 0, 1, NULL), - ('gxmu_wshqtw', 'gxmu_wshqtw', '五四红旗团委', 'gxmu_wshqtw', NULL, 0, 3, 0, 1, NULL), - ('gxmu_wshqtzb', 'gxmu_wshqtzb', '五四红旗团支部', 'gxmu_wshqtzb', NULL, 0, 4, 0, 1, NULL), - ('gxmu_yxgqtdgb', 'gxmu_yxgqtdgb', '优秀共青团干部', 'gxmu_yxgqtdgb', NULL, 0, 5, 0, 1, NULL), - ('gxmu_yxgqty', 'gxmu_yxgqty', '优秀共青团员', 'gxmu_yxgqty', NULL, 0, 6, 0, 1, NULL), - ('gxmu_tzbcy_form', 'gxmu_tzbcy_form', '挑战杯', 'gxmu_tzbcy_form', NULL, 1, 7, 0, 1, NULL), - ('gxmu_qmgc_form', 'gxmu_qmgc_form', '青马工程', 'gxmu_qmgc_form', NULL, 1, 8, 0, 1, NULL), - ('gxmu_wxxzx_project', 'gxmu_wxxzx_project', '未来学术之星', 'gxmu_wxxzx_project', NULL, 1, 9, 0, 1, NULL), - ('cms_manuscript', 'cms_manuscript', '稿件', 'cms_manuscript', NULL, 1, 10, 0, 1, NULL); + ('gxmu_sjqn', 'gxmu_sjqn', '十佳青年岗位能手', 'gxmu_sjqn', NULL, 1, 1, 1, 0, 1, NULL), + ('gxmu_sjtbzbsj', 'gxmu_sjtbzbsj', '十佳团支部书记', 'gxmu_sjtbzbsj', NULL, 0, 0, 2, 0, 1, NULL), + ('gxmu_wshqtw', 'gxmu_wshqtw', '五四红旗团委', 'gxmu_wshqtw', NULL, 0, 0, 3, 0, 1, NULL), + ('gxmu_wshqtzb', 'gxmu_wshqtzb', '五四红旗团支部', 'gxmu_wshqtzb', NULL, 0, 0, 4, 0, 1, NULL), + ('gxmu_yxgqtdgb', 'gxmu_yxgqtdgb', '优秀共青团干部', 'gxmu_yxgqtdgb', NULL, 0, 0, 5, 0, 1, NULL), + ('gxmu_yxgqty', 'gxmu_yxgqty', '优秀共青团员', 'gxmu_yxgqty', NULL, 0, 0, 6, 0, 1, NULL), + ('gxmu_tzbcy_form', 'gxmu_tzbcy_form', '挑战杯', 'gxmu_tzbcy_form', NULL, 1, 1, 7, 0, 1, NULL), + ('gxmu_qmgc_form', 'gxmu_qmgc_form', '青马工程', 'gxmu_qmgc_form', NULL, 1, 1, 8, 0, 1, NULL), + ('gxmu_wxxzx_project', 'gxmu_wxxzx_project', '未来学术之星', 'gxmu_wxxzx_project', NULL, 1, 1, 9, 0, 1, NULL), + ('cms_manuscript', 'cms_manuscript', '稿件', 'cms_manuscript', NULL, 0, 1, 10, 0, 1, NULL), + ('gxmu_tygl_form', 'gxmu_tygl_form', '团员管理', 'gxmu_tygl_form', NULL, 0, 0, 11, 0, 1, NULL), + ('gxmu_tygl', 'gxmu_tygl', '团员管理', 'gxmu_tygl', NULL, 0, 0, 12, 0, 1, NULL); + +-- 已存在库的行补 has_college:INSERT IGNORE 不会改已存在的行,这里只把 4 张在跑表从 0 提到 1。 +-- 只补 0 → 1,不覆盖后台已改过的值;重复执行是 no-op。 +UPDATE gxmu_module_registry SET has_college = 1 +WHERE deleted = 0 AND has_college = 0 + AND module IN ('gxmu_sjqn', 'gxmu_tzbcy_form', 'gxmu_qmgc_form', 'gxmu_wxxzx_project'); -- ------------------------------------------------------------------ -- 执行后校验(手工执行;每一段注释里是期望结果) @@ -140,10 +168,18 @@ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('gxmu_sjqn', 'gxmu_tzbcy_form', 'gxmu_qmgc_form', 'gxmu_wxxzx_project') AND COLUMN_NAME = 'college_id' AND IS_NULLABLE = 'YES'; --- 2) 注册表行数与启用数:期望 10 行未删除,其中 4 行启用 +-- 2) 注册表行数与启用数:期望 12 行未删除,其中 5 行启用 SELECT COUNT(*) AS registry_rows FROM gxmu_module_registry WHERE deleted = 0; SELECT COUNT(*) AS enabled_rows FROM gxmu_module_registry WHERE deleted = 0 AND enabled = 1; +-- 2b) has_college:期望 4 行(4 张在跑业务表),且与下面的 college_id 列数一致 +SELECT COUNT(*) AS has_college_rows FROM gxmu_module_registry WHERE deleted = 0 AND has_college = 1; +SELECT r.module +FROM gxmu_module_registry r +LEFT JOIN information_schema.COLUMNS c + ON c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = r.table_name AND c.COLUMN_NAME = 'college_id' +WHERE r.deleted = 0 AND r.has_college = 1 AND c.COLUMN_NAME IS NULL; + -- 3) 一致性检查:以下查询都应返回空集 SELECT module FROM gxmu_module_registry WHERE deleted = 0 AND table_name <> module; SELECT module FROM gxmu_module_registry WHERE deleted = 0 AND (detail_key IS NULL OR detail_key = ''); @@ -155,7 +191,7 @@ LEFT JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = DATABASE() AND t.TABLE_NAME = r.table_name WHERE r.deleted = 0 AND t.TABLE_NAME IS NULL; --- 4) 启用名单与顺序:期望依次为 gxmu_sjqn / gxmu_tzbcy_form / gxmu_qmgc_form / gxmu_wxxzx_project +-- 4) 启用名单与顺序:期望依次为 gxmu_sjqn / gxmu_tzbcy_form / gxmu_qmgc_form / gxmu_wxxzx_project / cms_manuscript(5 行) SELECT module, title, sort_number FROM gxmu_module_registry WHERE deleted = 0 AND enabled = 1 diff --git a/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java b/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java index f5c6b8a..5d7aea7 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java @@ -26,6 +26,10 @@ import java.util.Set; *
  • 注册表引用的业务表在库里真实存在。
  • * * + *

    反方向只告警不阻断:{@code gxmu_review_flow_config} 里有、注册表里没有的 module + * 打 warn(这种模块的标题会回退成裸 module 串、也不会出现在模块下拉里,但存量数据仍要能查)。 + * 不阻断启动是刻意的——存量脏数据不该挡住上线,但必须留下可检索的线索。 + * *

    允许 gxmu.module-registry.strict=false 关闭校验,便于无库本地启动; * 生产环境必须保持默认的 true。 * @@ -51,6 +55,34 @@ public class ModuleRegistryIntegrityChecker implements ApplicationRunner { check(); } + /** + * 反向校验:审批链配置里有、注册表里没有的 module 打 warn,不阻断启动。 + * + *

    这类模块的标题会回退成裸 module 串、也不会出现在模块下拉里, + * 但存量申报记录仍要能查——所以只提示补注册表行,不抛异常。 + * + * @param modules 注册表里的全部模块 + */ + private void warnModulesMissingFromRegistry(List modules) { + Set registered = new HashSet<>(); + for (ModuleRegistry item : modules) { + if (item.getModule() != null) { + registered.add(item.getModule()); + } + } + List 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()); } } diff --git a/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java b/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java index 59ada4a..6d68843 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java @@ -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; /** * 模块注册表控制器 * - *

    前端启动时一次性拉取启用模块清单并缓存,模块下拉、详情页、导出模板都从这里驱动。 + *

    前端启动时一次性拉取模块清单并缓存:默认只返回启用模块(新链的模块下拉、申报入口); + * {@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() { - return success(moduleRegistryService.listEnabled()); + public ApiResult> list( + @ApiParam("是否返回全部模块(含停用):true 返回全部,默认只返回启用") + @RequestParam(value = "all", required = false, defaultValue = "false") boolean all) { + List 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())); } } diff --git a/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java b/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java index b2b9836..50b6907 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java @@ -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; diff --git a/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java b/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java index 1615f6a..ff14810 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java @@ -28,4 +28,15 @@ public interface ModuleRegistryMapper extends BaseMapper { + "#{name}" + "") List selectExistingTableNames(@Param("names") Collection names); + + /** + * 审批链配置里出现过的模块名,用于反向校验「有审批链但注册表没登记」的模块。 + * + *

    只用于启动期告警,不阻断启动:存量脏数据不该挡住上线。 + * + * @return 审批链配置里的模块名(去重) + */ + @Select("SELECT DISTINCT module FROM gxmu_review_flow_config " + + "WHERE deleted = 0 AND module IS NOT NULL AND module <> ''") + List selectConfiguredModules(); } diff --git a/src/main/java/com/gxwebsoft/gxmu/module/model/ModuleRegistryDTO.java b/src/main/java/com/gxwebsoft/gxmu/module/model/ModuleRegistryDTO.java new file mode 100644 index 0000000..6bb2b6c --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/model/ModuleRegistryDTO.java @@ -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 契约)。 + * + *

    只暴露前端需要的元数据;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; + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java b/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java index 40b9267..9833e9c 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java @@ -5,13 +5,12 @@ import com.gxwebsoft.gxmu.module.entity.ModuleRegistry; import java.util.List; import java.util.Map; -import java.util.Set; /** * 模块注册表服务:模块元数据的唯一读取入口。 * *

    调用方不要再维护模块名常量或标题映射,一律从这里取; - * 注册表里查不到的历史 module 由调用方回退 module 原串,不抛错。 + * 注册表里查不到的历史 module 由 {@link #titleOf(Map, String)} 回退 module 原串,不抛错。 * * @author Codex * @since 2026-11-10 @@ -33,21 +32,11 @@ public interface ModuleRegistryService extends IService { */ ModuleRegistry getByModule(String module); - /** - * 模块标题;未注册或标题为空时回退 module 原串(module 为 null 时返回 null)。 - */ - String titleOf(String module); - /** * 全部模块的 module -> title 映射(含停用),供列表页一次性填充标题。 */ Map titleMap(); - /** - * 注册表里存在的全部 module(含停用)。 - */ - Set allModules(); - /** * module 是否在注册表里存在(含停用)。 * @@ -55,4 +44,33 @@ public interface ModuleRegistryService extends IService { * 所以停用模块也必须返回 true。 */ boolean containsModule(String module); + + /** + * 该模块的业务表是否有 college_id(注册表 has_college = 1)。 + * + *

    审批人解析要读申报记录的学院标识,但只有部分业务表有这一列; + * 哪张表有由注册表决定,后端不再维护模块白名单(ADR 0006:新增表只改注册表一行)。 + * + * @param module 模块编码 + * @return true 表示可以按 college_id 读取学院上下文 + */ + boolean hasCollege(String module); + + /** + * 模块标题的唯一回退口径:注册表里有标题用标题,否则回退 module 原串。 + * + *

    放在接口上是因为注册表是模块元数据的唯一来源,而调用方(审核列表、审批链配置) + * 已经批量取了 {@link #titleMap()},不该为了一个标题再回查一次库。 + * + * @param titleMap 模块标题映射,可为空 + * @param module 模块编码,可为空 + * @return 展示用标题;module 为空时返回 null + */ + static String titleOf(Map titleMap, String module) { + if (module == null) { + return null; + } + String title = titleMap == null ? null : titleMap.get(module); + return title == null || title.isEmpty() ? module : title; + } } 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 index c498f79..26bae5c 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java @@ -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 listEnabled() { @@ -35,6 +35,13 @@ public class ModuleRegistryServiceImpl extends ServiceImpl排序只在 SQL 里做一次:{@code sort_number} 升序(MySQL 把 NULL 排最前, + * 与「没设排序号的模块排在最前面」的口径一致),再按 {@code id} 升序保证稳定。 + * 内存里不再二次排序,避免 SQL 与 Java 两套 NULL 口径(SQL 前、Java 后)互相打架。 + */ @Override public List listAll() { // 用字符串列名而非 Lambda:注册表服务是纯读路径,且要在无 MyBatis 上下文的单测里可用 @@ -44,7 +51,7 @@ public class ModuleRegistryServiceImpl extends ServiceImpl titleMap() { Map titleMap = new LinkedHashMap<>(); @@ -82,24 +77,22 @@ public class ModuleRegistryServiceImpl extends ServiceImpl allModules() { + public boolean containsModule(String module) { + if (module == null || module.isEmpty()) { + return false; + } Set 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 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; } } diff --git a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java index d57ce2b..7dba452 100644 --- a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java @@ -86,6 +86,22 @@ class ModuleRegistryIntegrityCheckerTest { assertDoesNotThrow(() -> checker.check()); } + /** + * 反方向:审批链配置里有、注册表里没有的模块只打 warn,不阻断启动 + * (存量脏数据不该挡住上线,但必须留线索)。 + */ + @Test + void unregisteredFlowConfigModuleOnlyWarns() { + when(moduleRegistryService.listAll()).thenReturn(Collections.singletonList( + module("gxmu_sjqn", "gxmu_sjqn", 1))); + when(moduleRegistryMapper.selectExistingTableNames(any())) + .thenReturn(Collections.singletonList("gxmu_sjqn")); + when(moduleRegistryMapper.selectConfiguredModules()) + .thenReturn(Collections.singletonList("gxmu_tygl")); + + assertDoesNotThrow(() -> checker.check()); + } + @Test void strictFalseSkipsValidationForLocalStartupWithoutDatabase() { ReflectionTestUtils.setField(checker, "strict", false); diff --git a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java index fa70958..4425b64 100644 --- a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java @@ -2,6 +2,7 @@ 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 com.gxwebsoft.gxmu.module.service.impl.ModuleRegistryServiceImpl; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,6 +13,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -26,9 +28,10 @@ import static org.mockito.Mockito.when; /** * 模块注册表服务的单元测试(mock 掉 Mapper,不启 Spring、不连库)。 * - *

    注册表是模块元数据的唯一来源,这里锁住三件对外可观察的事: - * 启用清单按 sort_number 升序、标题查询在未注册时回退 module 原串、 - * 模块集合包含已停用模块(停用模块的历史数据仍要能撤回/作废)。 + *

    注册表是模块元数据的唯一来源,这里锁住四件对外可观察的事: + * 启用清单只留启用项且保持 SQL 的排序(排序只在 SQL 里做一次)、 + * 模块集合包含已停用模块(停用模块的历史数据仍要能撤回/作废)、 + * has_college 决定审批人解析是否读学院上下文、标题回退只有一份实现。 * * @author Codex * @since 2026-11-10 @@ -44,11 +47,12 @@ class ModuleRegistryServiceImplTest { private ModuleRegistryServiceImpl moduleRegistryService; @Test - void listEnabledReturnsOnlyEnabledModulesOrderedBySortNumber() { + void listEnabledReturnsOnlyEnabledModulesInSqlOrder() { + // SQL 已按 sort_number 升序返回;服务只负责过滤,不再二次排序 when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList( - module("gxmu_qmgc_form", "青马工程", 1, 8), - module("gxmu_sjtbzbsj", "十佳团支部书记", 0, 2), - module("gxmu_sjqn", "十佳青年岗位能手", 1, 1))); + module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1), + module("gxmu_sjtbzbsj", "十佳团支部书记", 0, 2, 0), + module("gxmu_qmgc_form", "青马工程", 1, 8, 1))); List enabled = moduleRegistryService.listEnabled(); @@ -56,30 +60,11 @@ class ModuleRegistryServiceImplTest { 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))); + module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1), + module("gxmu_yxgqty", "优秀共青团员", 0, 6, 0))); Map titles = moduleRegistryService.titleMap(); @@ -91,15 +76,49 @@ class ModuleRegistryServiceImplTest { @Test void moduleSetContainsDisabledModules() { when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList( - module("gxmu_sjqn", "十佳青年岗位能手", 1, 1), - module("gxmu_yxgqty", "优秀共青团员", 0, 6))); + module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1), + module("gxmu_yxgqty", "优秀共青团员", 0, 6, 0))); 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) { + @Test + void hasCollegeFollowsTheRegistryFlag() { + when(moduleRegistryMapper.selectOne(any())).thenReturn(module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1)); + + assertTrue(moduleRegistryService.hasCollege("gxmu_sjqn")); + } + + @Test + void hasCollegeIsFalseForDisabledOrMissingFlag() { + when(moduleRegistryMapper.selectOne(any())).thenReturn(module("gxmu_tygl", "团员管理", 0, 12, 0)); + + assertFalse(moduleRegistryService.hasCollege("gxmu_tygl")); + assertFalse(moduleRegistryService.hasCollege(null)); + } + + @Test + void titleOfUsesRegistryTitleWhenPresent() { + Map titles = new HashMap<>(); + titles.put("gxmu_tzbcy_form", "挑战杯"); + + assertEquals("挑战杯", ModuleRegistryService.titleOf(titles, "gxmu_tzbcy_form")); + } + + @Test + void titleOfFallsBackToModuleStringWhenNotRegistered() { + assertEquals("cms_legacy_module", ModuleRegistryService.titleOf(new HashMap<>(), "cms_legacy_module")); + assertEquals("cms_legacy_module", ModuleRegistryService.titleOf(null, "cms_legacy_module")); + } + + @Test + void titleOfKeepsNullModuleNull() { + assertNull(ModuleRegistryService.titleOf(new HashMap<>(), null)); + } + + private ModuleRegistry module(String name, String title, int enabled, int sortNumber, int hasCollege) { ModuleRegistry item = new ModuleRegistry(); item.setModule(name); item.setTableName(name); @@ -107,6 +126,7 @@ class ModuleRegistryServiceImplTest { item.setDetailKey(name); item.setEnabled(enabled); item.setSortNumber(sortNumber); + item.setHasCollege(hasCollege); item.setDeleted(0); return item; }