From 37cc8ee0d56c49b01a60511682866d87505e5b72 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Tue, 3 Nov 2026 23:13:29 +0800
Subject: [PATCH 1/5] =?UTF-8?q?fix(gxmu):=20=E6=B3=A8=E5=86=8C=E8=A1=A8?=
=?UTF-8?q?=E8=A1=A5=20has=5Fcollege=20=E4=B8=8E=E5=9B=A2=E5=91=98?=
=?UTF-8?q?=E7=AE=A1=E7=90=86=E7=A7=8D=E5=AD=90=EF=BC=8C=E6=8E=A5=E5=8F=A3?=
=?UTF-8?q?=E8=BF=94=E5=9B=9E=20DTO=20=E5=B9=B6=E6=94=AF=E6=8C=81=20all=3D?=
=?UTF-8?q?true?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
sql/20261110_spec58_approval_chain.sql | 72 ++++++++++++----
.../ModuleRegistryIntegrityChecker.java | 33 ++++++++
.../controller/ModuleRegistryController.java | 20 ++++-
.../gxmu/module/entity/ModuleRegistry.java | 3 +
.../module/mapper/ModuleRegistryMapper.java | 11 +++
.../gxmu/module/model/ModuleRegistryDTO.java | 65 +++++++++++++++
.../module/service/ModuleRegistryService.java | 42 +++++++---
.../impl/ModuleRegistryServiceImpl.java | 41 ++++------
.../ModuleRegistryIntegrityCheckerTest.java | 16 ++++
.../module/ModuleRegistryServiceImplTest.java | 82 ++++++++++++-------
10 files changed, 296 insertions(+), 89 deletions(-)
create mode 100644 src/main/java/com/gxwebsoft/gxmu/module/model/ModuleRegistryDTO.java
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;
}
From be011dd4f119ca3fd391542eb62748bd53365d43 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Tue, 3 Nov 2026 23:13:41 +0800
Subject: [PATCH 2/5] =?UTF-8?q?fix(gxmu):=20=E6=96=B0=E7=94=B3=E6=8A=A5?=
=?UTF-8?q?=E6=8C=89=E5=AD=A6=E7=B1=8D=E8=A1=A5=E9=BD=90=20college=5Fid?=
=?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8F=AA=E6=9C=89=E5=AD=98=E9=87=8F?=
=?UTF-8?q?=E5=9B=9E=E5=A1=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
4 张在跑表(gxmu_sjqn / gxmu_tzbcy_form / gxmu_qmgc_form / gxmu_wxxzx_project)
的实体补 collegeId,save 路径在为空时按「登录用户 → gxmu_student.college_id」
补齐;解析不到(无学籍、名册未同步、学院不唯一)一律留空不报错。
更新路径不调用解析器,collegeId 为 null 时 MyBatis-Plus 忽略该字段,库里已有值不变。
解析逻辑收在一个共享组件 CollegeResolver 里,4 个 controller 只各调一行;
口径与一次性回填脚本一致(优先在校那条学籍,学院不唯一就留空)。
单测:有学籍时写入、无学籍时留空、学院多义时留空、更新不覆盖已有值。
refs #58
---
.../gxmu/controller/QmgcFormController.java | 6 +
.../gxmu/controller/SjqnFormController.java | 6 +
.../gxmu/controller/TzbcyFormController.java | 6 +
.../gxmu/controller/WxxzxFormController.java | 6 +
.../com/gxwebsoft/gxmu/entity/QmgcForm.java | 3 +
.../com/gxwebsoft/gxmu/entity/SjqnForm.java | 3 +
.../com/gxwebsoft/gxmu/entity/TzbcyForm.java | 3 +
.../com/gxwebsoft/gxmu/entity/WxxzxForm.java | 3 +
.../gxmu/service/CollegeResolver.java | 34 ++++++
.../service/impl/CollegeResolverImpl.java | 62 ++++++++++
.../service/impl/CollegeResolverImplTest.java | 106 ++++++++++++++++++
11 files changed, 238 insertions(+)
create mode 100644 src/main/java/com/gxwebsoft/gxmu/service/CollegeResolver.java
create mode 100644 src/main/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImpl.java
create mode 100644 src/test/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImplTest.java
diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/QmgcFormController.java b/src/main/java/com/gxwebsoft/gxmu/controller/QmgcFormController.java
index 82c06ef..20dcf33 100644
--- a/src/main/java/com/gxwebsoft/gxmu/controller/QmgcFormController.java
+++ b/src/main/java/com/gxwebsoft/gxmu/controller/QmgcFormController.java
@@ -15,6 +15,7 @@ import com.gxwebsoft.gxmu.param.QmgcFormParam;
import com.gxwebsoft.gxmu.service.QmgcFormService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.model.DeclareCheckResult;
+import com.gxwebsoft.gxmu.service.CollegeResolver;
import com.gxwebsoft.gxmu.service.DeclareGuardService;
import org.springframework.transaction.annotation.Transactional;
import com.gxwebsoft.gxmu.util.GxmuQueryHelper;
@@ -42,6 +43,8 @@ public class QmgcFormController extends BaseController {
@Resource
private QmgcFormService qmgcFormService;
@Resource
+ private CollegeResolver collegeResolver;
+ @Resource
private ReviewFlowStarterService reviewFlowStarterService;
@Resource
private DeclareGuardService declareGuardService;
@@ -145,6 +148,9 @@ public class QmgcFormController extends BaseController {
if (loginUser != null) {
qmgcForm.setUserId(loginUser.getUserId());
}
+ // 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错):
+ // 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量
+ qmgcForm.setCollegeId(collegeResolver.fillIfAbsent(qmgcForm.getCollegeId(), qmgcForm.getUserId()));
DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_qmgc_form",
qmgcForm.getDeclareId(), qmgcForm.getYear(), qmgcForm.getUserId());
if (declareCheck.isRejected()) {
diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/SjqnFormController.java b/src/main/java/com/gxwebsoft/gxmu/controller/SjqnFormController.java
index 38d9bf9..cf84fd1 100644
--- a/src/main/java/com/gxwebsoft/gxmu/controller/SjqnFormController.java
+++ b/src/main/java/com/gxwebsoft/gxmu/controller/SjqnFormController.java
@@ -16,6 +16,7 @@ import com.gxwebsoft.gxmu.entity.SjqnForm;
import com.gxwebsoft.gxmu.param.SjqnFormParam;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.model.DeclareCheckResult;
+import com.gxwebsoft.gxmu.service.CollegeResolver;
import com.gxwebsoft.gxmu.service.DeclareGuardService;
import org.springframework.transaction.annotation.Transactional;
import com.gxwebsoft.gxmu.service.SjqnFormService;
@@ -46,6 +47,8 @@ public class SjqnFormController extends BaseController {
@Resource
private SjqnFormService sjqnFormService;
@Resource
+ private CollegeResolver collegeResolver;
+ @Resource
private ReviewFlowStarterService reviewFlowStarterService;
@Resource
private DeclareGuardService declareGuardService;
@@ -162,6 +165,9 @@ public class SjqnFormController extends BaseController {
if (loginUser != null) {
sjqnForm.setUserId(loginUser.getUserId());
}
+ // 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错):
+ // 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量
+ sjqnForm.setCollegeId(collegeResolver.fillIfAbsent(sjqnForm.getCollegeId(), sjqnForm.getUserId()));
DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_sjqn",
sjqnForm.getDeclareId(), sjqnForm.getYear(), sjqnForm.getUserId());
if (declareCheck.isRejected()) {
diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/TzbcyFormController.java b/src/main/java/com/gxwebsoft/gxmu/controller/TzbcyFormController.java
index b935226..00603f7 100644
--- a/src/main/java/com/gxwebsoft/gxmu/controller/TzbcyFormController.java
+++ b/src/main/java/com/gxwebsoft/gxmu/controller/TzbcyFormController.java
@@ -11,6 +11,7 @@ import com.gxwebsoft.gxmu.entity.TzbcyForm;
import com.gxwebsoft.gxmu.model.TzbcyStatisticsResult;
import com.gxwebsoft.gxmu.param.TzbcyFormParam;
import com.gxwebsoft.gxmu.model.DeclareCheckResult;
+import com.gxwebsoft.gxmu.service.CollegeResolver;
import com.gxwebsoft.gxmu.service.DeclareGuardService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import org.springframework.transaction.annotation.Transactional;
@@ -38,6 +39,8 @@ public class TzbcyFormController extends BaseController {
@Resource
private TzbcyFormService tzbcyFormService;
@Resource
+ private CollegeResolver collegeResolver;
+ @Resource
private ReviewFlowStarterService reviewFlowStarterService;
@Resource
private DeclareGuardService declareGuardService;
@@ -99,6 +102,9 @@ public class TzbcyFormController extends BaseController {
if (loginUser != null) {
tzbcyForm.setUserId(loginUser.getUserId());
}
+ // 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错):
+ // 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量
+ tzbcyForm.setCollegeId(collegeResolver.fillIfAbsent(tzbcyForm.getCollegeId(), tzbcyForm.getUserId()));
DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_tzbcy_form",
tzbcyForm.getDeclareId(), tzbcyForm.getYear(), tzbcyForm.getUserId());
if (declareCheck.isRejected()) {
diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/WxxzxFormController.java b/src/main/java/com/gxwebsoft/gxmu/controller/WxxzxFormController.java
index 46c7812..f6a7614 100644
--- a/src/main/java/com/gxwebsoft/gxmu/controller/WxxzxFormController.java
+++ b/src/main/java/com/gxwebsoft/gxmu/controller/WxxzxFormController.java
@@ -27,6 +27,7 @@ import com.gxwebsoft.gxmu.param.WxxzxFormParam;
import com.gxwebsoft.gxmu.service.QmgcFormService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.model.DeclareCheckResult;
+import com.gxwebsoft.gxmu.service.CollegeResolver;
import com.gxwebsoft.gxmu.service.DeclareGuardService;
import org.springframework.transaction.annotation.Transactional;
import com.gxwebsoft.gxmu.service.SjqnFormService;
@@ -100,6 +101,8 @@ public class WxxzxFormController extends BaseController {
@Resource
private WxxzxFormService wxxzxFormService;
@Resource
+ private CollegeResolver collegeResolver;
+ @Resource
private SjqnFormService sjqnFormService;
@Resource
private SjtbzbsjFormService sjtbzbsjFormService;
@@ -254,6 +257,9 @@ public class WxxzxFormController extends BaseController {
if (loginUser != null) {
wxxzxForm.setUserId(loginUser.getUserId());
}
+ // 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错):
+ // 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量
+ wxxzxForm.setCollegeId(collegeResolver.fillIfAbsent(wxxzxForm.getCollegeId(), wxxzxForm.getUserId()));
DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_wxxzx_project",
wxxzxForm.getDeclareId(), wxxzxForm.getYear(), wxxzxForm.getUserId());
if (declareCheck.isRejected()) {
diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/QmgcForm.java b/src/main/java/com/gxwebsoft/gxmu/entity/QmgcForm.java
index ff60c4a..eb25330 100644
--- a/src/main/java/com/gxwebsoft/gxmu/entity/QmgcForm.java
+++ b/src/main/java/com/gxwebsoft/gxmu/entity/QmgcForm.java
@@ -117,6 +117,9 @@ public class QmgcForm implements Serializable {
@ApiModelProperty("用户ID")
private Integer userId;
+ @ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空")
+ private Integer collegeId;
+
@ApiModelProperty("是否删除")
@TableLogic
private Integer deleted;
diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/SjqnForm.java b/src/main/java/com/gxwebsoft/gxmu/entity/SjqnForm.java
index a9d3a0d..c91b9ff 100644
--- a/src/main/java/com/gxwebsoft/gxmu/entity/SjqnForm.java
+++ b/src/main/java/com/gxwebsoft/gxmu/entity/SjqnForm.java
@@ -92,6 +92,9 @@ public class SjqnForm implements Serializable {
@ApiModelProperty("用户ID")
private Integer userId;
+ @ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空")
+ private Integer collegeId;
+
@ApiModelProperty("是否删除")
@TableLogic
private Integer deleted;
diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/TzbcyForm.java b/src/main/java/com/gxwebsoft/gxmu/entity/TzbcyForm.java
index 8ac524b..22803cc 100644
--- a/src/main/java/com/gxwebsoft/gxmu/entity/TzbcyForm.java
+++ b/src/main/java/com/gxwebsoft/gxmu/entity/TzbcyForm.java
@@ -114,6 +114,9 @@ public class TzbcyForm implements Serializable {
@ApiModelProperty("用户ID")
private Integer userId;
+ @ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空")
+ private Integer collegeId;
+
@ApiModelProperty("是否删除")
@TableLogic
private Integer deleted;
diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/WxxzxForm.java b/src/main/java/com/gxwebsoft/gxmu/entity/WxxzxForm.java
index 3031b0c..a86d2d7 100644
--- a/src/main/java/com/gxwebsoft/gxmu/entity/WxxzxForm.java
+++ b/src/main/java/com/gxwebsoft/gxmu/entity/WxxzxForm.java
@@ -143,6 +143,9 @@ public class WxxzxForm implements Serializable {
@ApiModelProperty("用户ID")
private Integer userId;
+ @ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空")
+ private Integer collegeId;
+
@ApiModelProperty("是否删除")
@TableLogic
private Integer deleted;
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/CollegeResolver.java b/src/main/java/com/gxwebsoft/gxmu/service/CollegeResolver.java
new file mode 100644
index 0000000..4a319d5
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/gxmu/service/CollegeResolver.java
@@ -0,0 +1,34 @@
+package com.gxwebsoft.gxmu.service;
+
+/**
+ * 学院标识解析:把登录用户映射到申报记录的 college_id(spec #58 用户故事 2)。
+ *
+ * 申报表上的 college_id 指向 sys_organization.organization_id,与学生名册
+ * gxmu_student.college_id 是同一套 ID(见 ADR 0006「学院标识」)。新建申报时按
+ * 「登录用户 → gxmu_student.college_id」补齐,审批链里的 COLLEGE_ROLE 规则才有字段可用;
+ * 解析不到(没有学籍、名册未同步、学院不唯一)一律返回 null,不报错、不猜。
+ *
+ * @author Codex
+ * @since 2026-11-11
+ */
+public interface CollegeResolver {
+
+ /**
+ * 按登录用户查学院标识。
+ *
+ *
同一用户有多条学籍时以「在校」那条为准;学院不唯一说明无法判断,返回 null。
+ *
+ * @param userId 登录用户 id(sys_user.user_id),可为空
+ * @return 学院标识,解析不到返回 null
+ */
+ Integer resolve(Integer userId);
+
+ /**
+ * 只在申报记录还没有学院标识时补齐;已有值一律保留(更新路径不覆盖已有值)。
+ *
+ * @param currentCollegeId 申报记录当前的学院标识
+ * @param userId 登录用户 id
+ * @return 最终写入的学院标识
+ */
+ Integer fillIfAbsent(Integer currentCollegeId, Integer userId);
+}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImpl.java
new file mode 100644
index 0000000..143ee79
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImpl.java
@@ -0,0 +1,62 @@
+package com.gxwebsoft.gxmu.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent;
+import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper;
+import com.gxwebsoft.gxmu.service.CollegeResolver;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 学院标识解析实现:gxmu_student.college_id 是学院标识的唯一权威来源。
+ *
+ *
口径与一次性回填脚本 sql/20261110_backfill_college_id.sql 保持一致:
+ * 优先取「在校(status = 1)」那条学籍的学院;学院不唯一就留空交给人工,
+ * 不做层级推导、不猜。
+ *
+ * @author Codex
+ * @since 2026-11-11
+ */
+@Service
+public class CollegeResolverImpl implements CollegeResolver {
+
+ @Resource
+ private GxmuStudentMapper gxmuStudentMapper;
+
+ @Override
+ public Integer resolve(Integer userId) {
+ if (userId == null) {
+ return null;
+ }
+ List students = gxmuStudentMapper.selectList(new LambdaQueryWrapper()
+ .select(GxmuStudent::getCollegeId, GxmuStudent::getStatus)
+ .eq(GxmuStudent::getUserId, userId)
+ .isNotNull(GxmuStudent::getCollegeId)
+ .orderByDesc(GxmuStudent::getStatus)
+ .orderByDesc(GxmuStudent::getId));
+ if (students == null || students.isEmpty()) {
+ return null;
+ }
+ Integer collegeId = null;
+ for (GxmuStudent student : students) {
+ Integer candidate = student.getCollegeId();
+ if (candidate == null) {
+ continue;
+ }
+ if (collegeId == null) {
+ collegeId = candidate;
+ } else if (!collegeId.equals(candidate)) {
+ // 同一用户的本科学院与研究生学院不同且没有可判定的在校行:留空,不猜
+ return null;
+ }
+ }
+ return collegeId;
+ }
+
+ @Override
+ public Integer fillIfAbsent(Integer currentCollegeId, Integer userId) {
+ return currentCollegeId != null ? currentCollegeId : resolve(userId);
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImplTest.java
new file mode 100644
index 0000000..2f60cb4
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImplTest.java
@@ -0,0 +1,106 @@
+package com.gxwebsoft.gxmu.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent;
+import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+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.Collections;
+
+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;
+
+/**
+ * 学院标识解析(spec #58 用户故事 2:一条链 27 个学院共用)。
+ *
+ * 钉三件事:有学籍时新申报写入学院、没有学籍时留空不报错、更新路径不覆盖已有值。
+ * 不启 Spring、不连库。
+ *
+ * @author Codex
+ * @since 2026-11-11
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class CollegeResolverImplTest {
+
+ private static final Integer USER_ID = 12;
+ private static final Integer COLLEGE_ID = 100;
+
+ @Mock
+ private GxmuStudentMapper gxmuStudentMapper;
+
+ @InjectMocks
+ private CollegeResolverImpl collegeResolver;
+
+ /** MyBatis-Plus 的 lambda 列名解析需要 TableInfo,纯单元测试里手工初始化一次 */
+ @BeforeAll
+ static void initLambdaCache() {
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, GxmuStudent.class);
+ }
+
+ /** 有学籍:按 gxmu_student.college_id 解析出学院。 */
+ @Test
+ void resolveReturnsCollegeFromStudentRecord() {
+ when(gxmuStudentMapper.selectList(any()))
+ .thenReturn(Collections.singletonList(student(COLLEGE_ID, 1)));
+
+ assertEquals(COLLEGE_ID, collegeResolver.resolve(USER_ID));
+ }
+
+ /** 没有学籍(或学籍没学院):留空,不报错——申报不能因为名册没同步就提交不了。 */
+ @Test
+ void resolveReturnsNullWithoutStudentRecord() {
+ when(gxmuStudentMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+ assertNull(collegeResolver.resolve(USER_ID));
+ assertNull(collegeResolver.resolve(null));
+ }
+
+ /** 同一用户本科学院与研究生学院不同且没有在校行:留空,不猜。 */
+ @Test
+ void resolveReturnsNullWhenCollegesAreAmbiguous() {
+ when(gxmuStudentMapper.selectList(any())).thenReturn(Arrays.asList(
+ student(COLLEGE_ID, 0),
+ student(200, 0)));
+
+ assertNull(collegeResolver.resolve(USER_ID));
+ }
+
+ /** 新建申报:没有学院标识时写入解析结果。 */
+ @Test
+ void fillIfAbsentWritesResolvedCollegeForNewRecord() {
+ when(gxmuStudentMapper.selectList(any()))
+ .thenReturn(Collections.singletonList(student(COLLEGE_ID, 1)));
+
+ assertEquals(COLLEGE_ID, collegeResolver.fillIfAbsent(null, USER_ID));
+ }
+
+ /** 更新路径:已有学院标识一律保留,不被登录用户的学籍覆盖。 */
+ @Test
+ void fillIfAbsentKeepsExistingCollegeOnUpdate() {
+ assertEquals(Integer.valueOf(999), collegeResolver.fillIfAbsent(999, USER_ID));
+ // 已有值时不查库
+ assertEquals(Integer.valueOf(999), collegeResolver.fillIfAbsent(999, null));
+ }
+
+ private GxmuStudent student(Integer collegeId, Integer status) {
+ GxmuStudent student = new GxmuStudent();
+ student.setUserId(USER_ID);
+ student.setCollegeId(collegeId);
+ student.setStatus(status);
+ return student;
+ }
+}
From 43f0119b2510f92ad4d046934e46f3309e97b28a Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Tue, 3 Nov 2026 23:14:00 +0800
Subject: [PATCH 3/5] =?UTF-8?q?fix(gxmu):=20=E5=AE=A1=E6=89=B9=E4=BA=BA?=
=?UTF-8?q?=E8=A7=A3=E6=9E=90=E8=A6=86=E7=9B=96=E7=AC=AC=202..N=20?=
=?UTF-8?q?=E7=BA=A7=EF=BC=8C=E5=B7=B2=E5=A4=84=E7=90=86=E8=8A=82=E7=82=B9?=
=?UTF-8?q?=E5=86=8D=E6=8F=90=E4=BA=A4=E7=9B=B4=E6=8E=A5=E6=8B=92=E7=BB=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ReviewFlowStarterService 新增 advanceReview(module, pk, currentSortNumber):
通过后推进下一级也走 ReviewerResolver,不再 setUserId(nextOne.getReviewUserId());
解析为空抛可读 BusinessException 且不写节点,最后一级则不生成节点
- ReviewListController 的推进改为调用 advanceReview;ManuscriptController 的首级
改为 startReview("cms_manuscript", id),cms 稿件不再复制 review_user_id
- 推进前校验当前节点 status == 0(ReviewStatus.PENDING),已处理节点再 PUT 直接拒绝,
避免重复通过造出第二组活跃节点;批量审批同样逐行校验
- 新增 ReviewStatus 常量(gxmu_review_list.status:0待审 1通过 2驳回),
把 controller / starter / DeclareGuard / TzbcyFormServiceImpl 里的裸 1、2 换成常量
- 解析失败的兜底消息补上学院与层级,与 ReviewerResolver 口径一致
- 审批动作注释里的「撤销」改成实际语义(通过 / 驳回)
refs #58
---
.../cms/controller/ManuscriptController.java | 32 +----
.../gxwebsoft/gxmu/constant/ReviewStatus.java | 26 ++++
.../gxmu/controller/ReviewListController.java | 120 ++++++++++------
.../service/ReviewFlowStarterService.java | 12 ++
.../service/impl/DeclareGuardServiceImpl.java | 7 +-
.../impl/ReviewFlowStarterServiceImpl.java | 132 +++++++++++++-----
.../service/impl/TzbcyFormServiceImpl.java | 3 +-
.../ReviewListControllerAuthTest.java | 32 ++++-
.../ReviewFlowStarterServiceImplTest.java | 90 +++++++++++-
9 files changed, 332 insertions(+), 122 deletions(-)
create mode 100644 src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java
diff --git a/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java b/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java
index 8a0a71f..fccb7b0 100644
--- a/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java
+++ b/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java
@@ -10,12 +10,7 @@ import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.system.entity.User;
-import com.gxwebsoft.gxmu.entity.ReviewFlow;
-import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
-import com.gxwebsoft.gxmu.entity.ReviewList;
-import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
-import com.gxwebsoft.gxmu.service.ReviewFlowService;
-import com.gxwebsoft.gxmu.service.ReviewListService;
+import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -38,11 +33,7 @@ public class ManuscriptController extends BaseController {
@Resource
private ManuscriptService manuscriptService;
@Resource
- private ReviewFlowConfigService reviewFlowConfigService;
- @Resource
- private ReviewFlowService reviewFlowService;
- @Resource
- private ReviewListService reviewListService;
+ private ReviewFlowStarterService reviewFlowStarterService;
@ApiOperation("分页查询稿件")
@GetMapping("/page")
@@ -75,23 +66,8 @@ public class ManuscriptController extends BaseController {
manuscript.setUserId(loginUser.getUserId());
}
if (manuscriptService.save(manuscript)) {
- // 查找该模块的审核流
- ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule("cms_manuscript");
- if (reviewFlowConfig != null) {
- ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
- if (reviewFlow != null) {
- List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle());
- if (reviewFlowList != null && !reviewFlowList.isEmpty()) {
- ReviewFlow firstOne = reviewFlowList.get(0);
- ReviewList reviewList = new ReviewList();
- reviewList.setPk(manuscript.getId());
- reviewList.setModule("cms_manuscript");
- reviewList.setUserId(firstOne.getReviewUserId());
- reviewList.setSortNumber(0);
- reviewListService.save(reviewList);
- }
- }
- }
+ // 首级审批人同样经 ReviewerResolver 解析(与其它模块同一条路径),不复制 review_user_id
+ reviewFlowStarterService.startReview("cms_manuscript", manuscript.getId());
return success("添加成功");
}
return fail("添加失败");
diff --git a/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java b/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java
new file mode 100644
index 0000000..3612904
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java
@@ -0,0 +1,26 @@
+package com.gxwebsoft.gxmu.constant;
+
+/**
+ * 审核待办(gxmu_review_list.status)的状态取值。
+ *
+ * 这套取值与申报记录的 declare_status(0草稿 1待审核 2通过 3驳回 4作废,见
+ * {@link com.gxwebsoft.gxmu.service.impl.DeclareGuardServiceImpl} 的 STATUS_* 常量)
+ * 是两套语义不同的编码:同为 "2",这里是驳回、那里是通过,因此不要混用字面量。
+ *
+ * @author Codex
+ * @since 2026-11-11
+ */
+public final class ReviewStatus {
+
+ /** 待审核:当前一级的责任人可以审批 */
+ public static final int PENDING = 0;
+
+ /** 通过:节点已处理,链条推进到下一级 */
+ public static final int APPROVED = 1;
+
+ /** 驳回:节点已处理,申报退回申报人 */
+ public static final int REJECTED = 2;
+
+ private ReviewStatus() {
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java
index de68ad3..548c99f 100644
--- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java
+++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java
@@ -3,19 +3,15 @@ package com.gxwebsoft.gxmu.controller;
import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.web.BaseController;
-import com.gxwebsoft.gxmu.entity.ReviewFlow;
-import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
-import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
-import com.gxwebsoft.gxmu.service.ReviewFlowService;
+import com.gxwebsoft.gxmu.constant.ReviewStatus;
+import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.service.DeclareGuardService;
import com.gxwebsoft.gxmu.service.ReviewListService;
import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.param.ReviewListParam;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
-import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.BatchParam;
-import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.system.entity.User;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -23,11 +19,16 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
+import java.io.Serializable;
import java.util.List;
/**
* 审核列表控制器
*
+ *
审批动作(通过 / 驳回)的归属校验、状态校验留在这里(HTTP 层的准入判断);
+ * 通过后「推进到下一级」属于审批链业务规则,交给 {@link ReviewFlowStarterService#advanceReview},
+ * 审批人一律经 {@link com.gxwebsoft.gxmu.service.ReviewerResolver} 解析,不再复制 review_user_id。
+ *
* @author LX
* @since 2026-03-18 15:50:51
*/
@@ -38,11 +39,9 @@ public class ReviewListController extends BaseController {
@Resource
private ReviewListService reviewListService;
@Resource
- private ReviewFlowConfigService reviewFlowConfigService;
- @Resource
- private ReviewFlowService reviewFlowService;
- @Resource
private DeclareGuardService declareGuardService;
+ @Resource
+ private ReviewFlowStarterService reviewFlowStarterService;
@ApiOperation("分页查询审核列表")
@GetMapping("/page")
@@ -100,8 +99,14 @@ public class ReviewListController extends BaseController {
@ApiOperation("修改审核列表")
@PutMapping()
public ApiResult> update(@RequestBody ReviewList reviewList) {
- // 审批动作(通过/驳回/撤销)只能由待办的责任人本人执行
- ApiResult> denied = denyIfNotAssignee(reviewList.getId());
+ // 审批动作(通过 / 驳回)只能由待办的责任人本人执行
+ ReviewList existing = reviewList.getId() == null ? null : reviewListService.getById(reviewList.getId());
+ ApiResult> denied = denyIfNotAssignee(existing);
+ if (denied != null) {
+ return denied;
+ }
+ // 已处理过的节点再提交直接拒绝:否则会再插一条下一级待办,同一申报出现第二组活跃节点
+ denied = denyIfAlreadyHandled(existing);
if (denied != null) {
return denied;
}
@@ -110,34 +115,10 @@ public class ReviewListController extends BaseController {
return denied;
}
if (reviewListService.updateById(reviewList)) {
- // 通过进入下一个
- if (Integer.valueOf(1).equals(reviewList.getStatus())) {
- ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule());
- if (reviewFlowConfig != null) {
- ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
- if (reviewFlow != null) {
- List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle());
- if (reviewFlowList != null && !reviewFlowList.isEmpty()) {
- if (reviewList.getSortNumber() + 1 < reviewFlowList.size()) {
- ReviewFlow nextOne = reviewFlowList.get(reviewList.getSortNumber() + 1);
- if (nextOne != null) {
- ReviewList nextReviewList = new ReviewList();
- nextReviewList.setPk(reviewList.getPk());
- nextReviewList.setModule(reviewList.getModule());
- nextReviewList.setUserId(nextOne.getReviewUserId());
- nextReviewList.setSortNumber(reviewList.getSortNumber() + 1);
- reviewListService.save(nextReviewList);
- }
- }
-// switch (reviewList.getModule()) {
-// case "cms_manuscript": {
-// // 社团指导老师/二级学院团委书记-团委社团管理部/团委办公室-学校团委组织宣传部,拟发布后,再提交给团委副书记-团委书记
-// if ()
-// }
-// }
- }
- }
- }
+ // 通过则推进到下一级;下一级的审批人由审批链配置解析,不复制本级的 review_user_id
+ if (Integer.valueOf(ReviewStatus.APPROVED).equals(reviewList.getStatus())) {
+ reviewFlowStarterService.advanceReview(reviewList.getModule(), reviewList.getPk(),
+ existing == null ? null : existing.getSortNumber());
}
declareGuardService.syncReviewStatus(reviewList.getModule(), reviewList.getPk());
return success("修改成功");
@@ -166,11 +147,15 @@ public class ReviewListController extends BaseController {
@ApiOperation("批量修改审核列表")
@PutMapping("/batch")
public ApiResult> updateBatch(@RequestBody BatchParam batchParam) {
- // 批量审批逐行校验归属,只要有一行不是自己的就整体拒绝,避免部分落库
+ // 批量审批逐行校验归属与状态,只要有一行不满足就整体拒绝,避免部分落库
ApiResult> denied = denyIfBatchNotAssignee(batchParam);
if (denied != null) {
return denied;
}
+ denied = denyIfBatchAlreadyHandled(batchParam);
+ if (denied != null) {
+ return denied;
+ }
denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData());
if (denied != null) {
return denied;
@@ -191,7 +176,10 @@ public class ReviewListController extends BaseController {
* @return null 表示校验通过,否则返回 403 结果
*/
private ApiResult> denyIfNotAssignee(Integer id) {
- ReviewList existing = id == null ? null : reviewListService.getById(id);
+ return denyIfNotAssignee(id == null ? null : reviewListService.getById(id));
+ }
+
+ private ApiResult> denyIfNotAssignee(ReviewList existing) {
Integer loginUserId = getLoginUserId();
if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) {
return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG);
@@ -199,6 +187,25 @@ public class ReviewListController extends BaseController {
return null;
}
+ /**
+ * 当前节点必须仍是「待审核」才允许提交审批动作。
+ *
+ * 重复通过会给同一申报再造一组待办(gxmu_review_list 没有 (module, pk, level) 唯一键兜底),
+ * 双击或多标签页即可复现,所以在落库前就拒绝。
+ *
+ * @param existing 库里现有的待办行
+ * @return null 表示仍是待审核,否则返回可读的失败结果
+ */
+ private ApiResult> denyIfAlreadyHandled(ReviewList existing) {
+ if (existing == null || existing.getStatus() == null) {
+ return null;
+ }
+ if (existing.getStatus() != ReviewStatus.PENDING) {
+ return fail("该审批已处理(通过 / 驳回),请勿重复提交");
+ }
+ return null;
+ }
+
/**
* 批量审批的归属校验:逐行确认都属于当前用户。
*
@@ -209,7 +216,7 @@ public class ReviewListController extends BaseController {
if (batchParam == null || batchParam.getIds() == null) {
return null;
}
- for (java.io.Serializable id : batchParam.getIds()) {
+ for (Serializable id : batchParam.getIds()) {
ApiResult> denied = denyIfNotAssignee(id instanceof Number ? ((Number) id).intValue() : null);
if (denied != null) {
return denied;
@@ -218,6 +225,26 @@ public class ReviewListController extends BaseController {
return null;
}
+ /**
+ * 批量审批的状态校验:任意一行已处理就整体拒绝。
+ *
+ * @param batchParam 批量参数
+ * @return null 表示全部仍是待审核,否则返回可读的失败结果
+ */
+ private ApiResult> denyIfBatchAlreadyHandled(BatchParam batchParam) {
+ if (batchParam == null || batchParam.getIds() == null) {
+ return null;
+ }
+ for (Serializable id : batchParam.getIds()) {
+ ReviewList existing = id instanceof Number ? reviewListService.getById(((Number) id).intValue()) : null;
+ ApiResult> denied = denyIfAlreadyHandled(existing);
+ if (denied != null) {
+ return denied;
+ }
+ }
+ return null;
+ }
+
/**
* 驳回必须携带意见,否则申报人不知道要改什么。
*
@@ -225,7 +252,8 @@ public class ReviewListController extends BaseController {
* @return null 表示校验通过,否则返回可读的失败结果
*/
private ApiResult> denyIfRejectWithoutContent(ReviewList data) {
- if (data != null && Integer.valueOf(2).equals(data.getStatus()) && StrUtil.isBlank(data.getContent())) {
+ if (data != null && Integer.valueOf(ReviewStatus.REJECTED).equals(data.getStatus())
+ && StrUtil.isBlank(data.getContent())) {
return fail("驳回必须填写审核意见");
}
return null;
@@ -236,7 +264,7 @@ public class ReviewListController extends BaseController {
if (batchParam == null || batchParam.getIds() == null) {
return;
}
- for (java.io.Serializable id : batchParam.getIds()) {
+ for (Serializable id : batchParam.getIds()) {
ReviewList saved = reviewListService.getById(id);
if (saved != null) {
declareGuardService.syncReviewStatus(saved.getModule(), saved.getPk());
@@ -283,4 +311,4 @@ public class ReviewListController extends BaseController {
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java
index 9a6b233..e855037 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java
@@ -23,4 +23,16 @@ public interface ReviewFlowStarterService {
* @param pk 业务主键
*/
void restartReviewWhenLastRejected(String module, Integer pk);
+
+ /**
+ * 当前一级通过后推进到下一级:审批人同样走 {@link ReviewerResolver} 解析,不复制上一级的 review_user_id。
+ *
+ * 解析不出审批人时抛可读的业务异常且一个节点都不写(宁可卡住,也不生成无人认领的待办);
+ * 已经是最后一级则什么都不做,链条自然结束。
+ *
+ * @param module 模块编码
+ * @param pk 业务主键
+ * @param currentSortNumber 当前已通过节点的顺序号(首级为 0,与 review_list.sort_number 同口径)
+ */
+ void advanceReview(String module, Integer pk, Integer currentSortNumber);
}
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 046e3c3..e66b888 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java
@@ -2,6 +2,7 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.gxmu.constant.ReviewStatus;
import com.gxwebsoft.gxmu.entity.Declare;
import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
@@ -160,7 +161,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return;
}
boolean pending = nodes.stream()
- .anyMatch(node -> node.getStatus() != null && node.getStatus() == 0);
+ .anyMatch(node -> node.getStatus() != null && node.getStatus() == ReviewStatus.PENDING);
if (pending) {
declareRecordMapper.updateStatus(module, recordId, STATUS_PENDING, null);
return;
@@ -181,7 +182,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return;
}
declareRecordMapper.updateStatus(module, recordId,
- last.getStatus() == 2 ? STATUS_REJECTED : STATUS_PASSED, null);
+ last.getStatus() == ReviewStatus.REJECTED ? STATUS_REJECTED : STATUS_PASSED, null);
}
@Override
@@ -200,7 +201,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return "只有待审核的申报可以撤回";
}
boolean approved = reviewListService.listByModuleNPK(module, recordId).stream()
- .anyMatch(node -> node.getStatus() != null && node.getStatus() == 1);
+ .anyMatch(node -> node.getStatus() != null && node.getStatus() == ReviewStatus.APPROVED);
if (approved) {
return "已有审核通过的记录不能撤回,请联系管理员处理";
}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java
index dd44440..071d892 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java
@@ -2,10 +2,14 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.system.entity.Organization;
+import com.gxwebsoft.common.system.service.OrganizationService;
+import com.gxwebsoft.gxmu.constant.ReviewStatus;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
+import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
@@ -16,28 +20,22 @@ import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
-import java.util.Arrays;
-import java.util.Collections;
import java.util.Comparator;
-import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.Set;
/**
* 审核流程发起服务实现
*
*
审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析,
- * 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。
+ * 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。
+ * 首级(发起)、驳回重提、逐级推进三条路径都走同一套解析,第 2..N 级不再复制 review_user_id。
*
* @author LX
* @since 2026-03-18
*/
@Service
public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
- /** 有 college_id 的 4 张在跑业务表(#59);其余模块的申报记录只取申报人 */
- private static final Set COLLEGE_MODULES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
- "gxmu_sjqn", "gxmu_tzbcy_form", "gxmu_qmgc_form", "gxmu_wxxzx_project")));
@Resource
private ReviewFlowConfigService reviewFlowConfigService;
@@ -49,6 +47,10 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
private ReviewerResolver reviewerResolver;
@Resource
private DeclareRecordMapper declareRecordMapper;
+ @Resource
+ private ModuleRegistryService moduleRegistryService;
+ @Resource
+ private OrganizationService organizationService;
@Override
public void startReview(String module, Integer pk) {
@@ -59,15 +61,11 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
if (reviewLists != null && !reviewLists.isEmpty()) {
return;
}
- ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module);
- if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) {
+ List flowList = loadFlowList(module);
+ if (flowList.isEmpty()) {
return;
}
- ReviewFlow firstOne = getFirstFlow(reviewFlowConfig);
- if (firstOne == null) {
- return;
- }
- saveFirstReviewNode(module, pk, firstOne);
+ saveReviewNode(module, pk, flowList.get(0), 0);
}
/**
@@ -90,61 +88,125 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(ReviewList::getId, Comparator.nullsLast(Integer::compareTo)))
.orElse(null);
- if (latestReview == null || latestReview.getStatus() == null || latestReview.getStatus() != 2) {
+ if (latestReview == null || latestReview.getStatus() == null
+ || latestReview.getStatus() != ReviewStatus.REJECTED) {
return;
}
- ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module);
- if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) {
- return;
- }
- ReviewFlow firstOne = getFirstFlow(reviewFlowConfig);
- if (firstOne == null) {
+ List flowList = loadFlowList(module);
+ if (flowList.isEmpty()) {
return;
}
reviewListService.remove(new LambdaQueryWrapper()
.eq(ReviewList::getModule, module)
.eq(ReviewList::getPk, pk));
- saveFirstReviewNode(module, pk, firstOne);
+ saveReviewNode(module, pk, flowList.get(0), 0);
}
- private ReviewFlow getFirstFlow(ReviewFlowConfig reviewFlowConfig) {
+ /**
+ * 当前一级通过后推进到下一级。
+ *
+ * 顺序号与审批链下标一一对应(首级 sort_number = 0),所以下一级就是 flowList 的
+ * {@code currentSortNumber + 1};越界说明这一级已是最后一级,链条结束。
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void advanceReview(String module, Integer pk, Integer currentSortNumber) {
+ if (module == null || pk == null || currentSortNumber == null) {
+ return;
+ }
+ List flowList = loadFlowList(module);
+ if (flowList.isEmpty()) {
+ return;
+ }
+ int nextIndex = currentSortNumber + 1;
+ if (nextIndex >= flowList.size()) {
+ return;
+ }
+ ReviewFlow nextOne = flowList.get(nextIndex);
+ if (nextOne == null) {
+ return;
+ }
+ saveReviewNode(module, pk, nextOne, nextIndex);
+ }
+
+ /**
+ * 读模块的审批链节点(按 level 升序,下标即 sort_number)。
+ *
+ * @param module 模块编码
+ * @return 审批链节点;没有配置时返回空列表
+ */
+ private List loadFlowList(String module) {
+ ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module);
+ if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) {
+ return java.util.Collections.emptyList();
+ }
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
if (reviewFlow == null) {
- return null;
+ return java.util.Collections.emptyList();
}
List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle());
if (reviewFlowList == null || reviewFlowList.isEmpty()) {
- return null;
+ return java.util.Collections.emptyList();
}
- return reviewFlowList.get(0);
+ return reviewFlowList;
}
- private void saveFirstReviewNode(String module, Integer pk, ReviewFlow firstOne) {
+ /**
+ * 解析该级审批人并写一个待办节点。
+ *
+ * @param module 模块编码
+ * @param pk 业务主键
+ * @param flow 该级审批链配置
+ * @param sortNumber 待办顺序号(0 起)
+ */
+ private void saveReviewNode(String module, Integer pk, ReviewFlow flow, int sortNumber) {
Map record = selectDeclareRecord(module, pk);
Integer collegeId = intValue(record, "collegeId");
Integer declareUserId = intValue(record, "userId");
- List reviewers = reviewerResolver.resolve(firstOne, collegeId, declareUserId);
+ List reviewers = reviewerResolver.resolve(flow, collegeId, declareUserId);
if (reviewers == null || reviewers.isEmpty()) {
- throw new BusinessException("模块 " + module + " 主键 " + pk + " 第 "
- + (firstOne.getLevel() == null ? "?" : firstOne.getLevel())
- + " 级审批人解析结果为空,未生成待办");
+ // 兜底消息与 ReviewerResolver 同口径:哪个学院、第几级、哪个模块/主键
+ String scope = collegeId == null ? "申报记录缺少学院标识," : "学院「" + collegeName(collegeId) + "」";
+ throw new BusinessException(scope + "第 " + levelOf(flow)
+ + " 级审批人解析结果为空(模块 " + module + " 主键 " + pk + "),未生成待办");
}
ReviewList reviewList = new ReviewList();
reviewList.setPk(pk);
reviewList.setModule(module);
reviewList.setUserId(reviewers.get(0));
- reviewList.setSortNumber(0);
+ reviewList.setSortNumber(sortNumber);
reviewListService.save(reviewList);
}
- /** 读申报记录的申报人与学院标识;只有 4 张在跑表有 college_id */
+ /**
+ * 读申报记录的申报人与学院标识。
+ *
+ * 哪张业务表有 college_id 由注册表 has_college 决定,后端不再维护模块白名单
+ * (ADR 0006:新增表只改注册表一行)。
+ */
private Map selectDeclareRecord(String module, Integer pk) {
- if (COLLEGE_MODULES.contains(module)) {
+ if (moduleRegistryService.hasCollege(module)) {
return declareRecordMapper.selectReviewContext(module, pk);
}
return declareRecordMapper.selectRecord(module, pk);
}
+ private String collegeName(Integer collegeId) {
+ if (collegeId == null) {
+ return "学院标识为空";
+ }
+ Organization organization = organizationService.getById(collegeId);
+ if (organization == null || organization.getOrganizationName() == null
+ || organization.getOrganizationName().isEmpty()) {
+ return "未知学院(" + collegeId + ")";
+ }
+ return organization.getOrganizationName();
+ }
+
+ private String levelOf(ReviewFlow flow) {
+ return flow == null || flow.getLevel() == null ? "?" : String.valueOf(flow.getLevel());
+ }
+
private Integer intValue(Map record, String key) {
if (record == null) {
return null;
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java
index cc1ff6d..0100dc0 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.gxwebsoft.gxmu.constant.ReviewStatus;
import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.entity.TzbcyForm;
import com.gxwebsoft.gxmu.mapper.TzbcyFormMapper;
@@ -157,7 +158,7 @@ public class TzbcyFormServiceImpl extends ServiceImpl reviewList = form.getReviewList();
ReviewList latest = reviewList.get(reviewList.size() - 1);
- return latest != null && latest.getStatus() != null && latest.getStatus() == 1;
+ return latest != null && latest.getStatus() != null && latest.getStatus() == ReviewStatus.APPROVED;
}
private String buildPersonKey(String name, String phone) {
diff --git a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java
index 7cdd178..8e2fd0f 100644
--- a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java
+++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java
@@ -6,8 +6,7 @@ import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.service.DeclareGuardService;
-import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
-import com.gxwebsoft.gxmu.service.ReviewFlowService;
+import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.service.ReviewListService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -54,10 +53,7 @@ class ReviewListControllerAuthTest {
private ReviewListService reviewListService;
@Mock
- private ReviewFlowConfigService reviewFlowConfigService;
-
- @Mock
- private ReviewFlowService reviewFlowService;
+ private ReviewFlowStarterService reviewFlowStarterService;
@Mock
private DeclareGuardService declareGuardService;
@@ -117,6 +113,28 @@ class ReviewListControllerAuthTest {
assertEquals(Constants.RESULT_OK_CODE, result.getCode());
verify(reviewListService).updateById(any());
verify(declareGuardService).syncReviewStatus("jhqn", PK);
+ // 通过后推进下一级:交给审批链服务按规则解析审批人
+ verify(reviewFlowStarterService).advanceReview("jhqn", PK, 1);
+ }
+
+ /**
+ * 已处理过的节点再 PUT 一次直接拒绝:否则会再插一条下一级待办,
+ * 同一申报出现第二组活跃节点(双击 / 多标签页即可复现)。
+ */
+ @Test
+ void approveIsDeniedWhenNodeIsAlreadyHandled() {
+ ReviewList handled = row(ROW_ID, OWNER_ID);
+ handled.setStatus(1);
+ when(reviewListService.getById(ROW_ID)).thenReturn(handled);
+ loginAs(OWNER_ID);
+
+ ApiResult> result = controller.update(request(ROW_ID, 1, "同意"));
+
+ assertNotEquals(Constants.RESULT_OK_CODE, result.getCode());
+ assertTrue(result.getMessage() != null && result.getMessage().contains("已处理"),
+ "重复提交要给出可读提示,实际:" + result.getMessage());
+ verify(reviewListService, never()).updateById(any());
+ verify(reviewFlowStarterService, never()).advanceReview(any(), any(), any());
}
/** 驳回必须写意见:status=2 且 content 为空/空白时拒绝,提示要可读。 */
@@ -144,6 +162,8 @@ class ReviewListControllerAuthTest {
assertEquals(Constants.RESULT_OK_CODE, result.getCode());
verify(reviewListService).updateById(any());
+ // 驳回不推进下一级
+ verify(reviewFlowStarterService, never()).advanceReview(any(), any(), any());
}
/** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */
diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java
index 35e0fbe..17bf56c 100644
--- a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java
+++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java
@@ -1,10 +1,13 @@
package com.gxwebsoft.gxmu.service.impl;
import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.system.entity.Organization;
+import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
+import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.ReviewListService;
@@ -28,6 +31,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
@@ -36,10 +40,11 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
- * 审批流发起与驳回重走(spec #58)。
+ * 审批流发起、驳回重走与逐级推进(spec #58)。
*
* 接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。
- * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、以及解析不出来时「一个节点都不写」。
+ * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、推进时「第 2..N 级同样经解析」,
+ * 以及解析不出来时「一个节点都不写」。
*
* @author Codex
* @since 2026-11-10
@@ -52,6 +57,7 @@ class ReviewFlowStarterServiceImplTest {
private static final Integer PK = 11;
private static final Integer COLLEGE_ID = 100;
private static final Integer DECLARE_USER_ID = 12;
+ private static final String COLLEGE_NAME = "药学院";
@Mock
private ReviewFlowConfigService reviewFlowConfigService;
@@ -68,6 +74,12 @@ class ReviewFlowStarterServiceImplTest {
@Mock
private DeclareRecordMapper declareRecordMapper;
+ @Mock
+ private ModuleRegistryService moduleRegistryService;
+
+ @Mock
+ private OrganizationService organizationService;
+
@InjectMocks
private ReviewFlowStarterServiceImpl reviewFlowStarterService;
@@ -87,6 +99,12 @@ class ReviewFlowStarterServiceImplTest {
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList());
when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext());
when(declareRecordMapper.selectRecord(MODULE, PK)).thenReturn(recordContext());
+ // 学院标识由注册表 has_college 决定是否读
+ when(moduleRegistryService.hasCollege(MODULE)).thenReturn(true);
+ Organization college = new Organization();
+ college.setOrganizationId(COLLEGE_ID);
+ college.setOrganizationName(COLLEGE_NAME);
+ when(organizationService.getById(COLLEGE_ID)).thenReturn(college);
// 默认:解析出第一级审批人 7
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7));
}
@@ -118,8 +136,12 @@ class ReviewFlowStarterServiceImplTest {
void startReview_noReviewerThrowsAndWritesNoNode() {
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList());
- assertThrows(BusinessException.class, () -> reviewFlowStarterService.startReview(MODULE, PK));
+ BusinessException error = assertThrows(BusinessException.class,
+ () -> reviewFlowStarterService.startReview(MODULE, PK));
+ // 兜底消息与 ReviewerResolver 同口径:哪个学院、第几级
+ assertTrue(error.getMessage().contains(COLLEGE_NAME), error.getMessage());
+ assertTrue(error.getMessage().contains("第 1 级"), error.getMessage());
verify(reviewListService, never()).save(any());
}
@@ -136,6 +158,68 @@ class ReviewFlowStarterServiceImplTest {
verify(reviewListService, never()).save(any());
}
+ // ------------------------------------------------------------------
+ // 逐级推进:第 2..N 级同样经 ReviewerResolver,不复制 review_user_id
+ // ------------------------------------------------------------------
+
+ /** 通过后推进:下一级节点的人来自解析,而不是上一级配置里写死的 review_user_id。 */
+ @Test
+ void advanceReview_writesResolvedReviewerAsNextNode() {
+ when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList(
+ flow("十佳青年", 1, 7), flow("十佳青年", 2, 8)));
+ when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(43));
+
+ reviewFlowStarterService.advanceReview(MODULE, PK, 0);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class);
+ verify(reviewListService).save(captor.capture());
+ ReviewList saved = captor.getValue();
+ assertEquals(MODULE, saved.getModule());
+ assertEquals(PK, saved.getPk());
+ assertEquals(Integer.valueOf(1), saved.getSortNumber());
+ assertEquals(Integer.valueOf(43), saved.getUserId(), "下一级应落在解析出的人上,而不是 review_user_id=8");
+ // 解析的是第 2 级那条配置,并且带上了学院上下文
+ verify(reviewerResolver).resolve(
+ org.mockito.ArgumentMatchers.argThat(item -> item != null && Integer.valueOf(2).equals(item.getLevel())),
+ eq(COLLEGE_ID), eq(DECLARE_USER_ID));
+ }
+
+ /** 已是最后一级:链条结束,不再生成节点。 */
+ @Test
+ void advanceReview_lastLevelWritesNothing() {
+ when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList(
+ flow("十佳青年", 1, 7), flow("十佳青年", 2, 8)));
+
+ reviewFlowStarterService.advanceReview(MODULE, PK, 1);
+
+ verify(reviewListService, never()).save(any());
+ }
+
+ /** 推进时解析为空:抛可读异常(带学院与层级)且不写节点,避免第二组无人认领的待办。 */
+ @Test
+ void advanceReview_emptyReviewerThrowsAndWritesNoNode() {
+ when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList(
+ flow("十佳青年", 1, 7), flow("十佳青年", 2, 8)));
+ when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList());
+
+ BusinessException error = assertThrows(BusinessException.class,
+ () -> reviewFlowStarterService.advanceReview(MODULE, PK, 0));
+
+ assertTrue(error.getMessage().contains(COLLEGE_NAME), error.getMessage());
+ assertTrue(error.getMessage().contains("第 2 级"), error.getMessage());
+ verify(reviewListService, never()).save(any());
+ }
+
+ /** 没有审批链配置时推进是 no-op,不写节点也不报错。 */
+ @Test
+ void advanceReview_withoutFlowConfigDoesNothing() {
+ when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(null);
+
+ reviewFlowStarterService.advanceReview(MODULE, PK, 0);
+
+ verify(reviewListService, never()).save(any());
+ }
+
// ------------------------------------------------------------------
// 驳回重走:先清旧节点,再解析第一级
// ------------------------------------------------------------------
From 6bfdae5787b30d5301f35cf1b0936f3d8c5f261b Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Tue, 3 Nov 2026 23:14:13 +0800
Subject: [PATCH 4/5] =?UTF-8?q?fix(security):=20=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=E5=86=85=E8=81=94=E9=A2=84=E8=A7=88=E9=BB=98=E8=AE=A4=E9=9C=80?=
=?UTF-8?q?=E7=99=BB=E5=BD=95=EF=BC=8C=E5=85=AC=E5=BC=80=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=E7=99=BD=E5=90=8D=E5=8D=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- file.public-preview 默认值由 true 改为 false(application.yml 与 @Value 同步),
上传一律落在 /yyyyMMdd/ 日期目录,默认公开等于把所有上传文件(含盖章证明材料)
变成匿名可读;公开页面引用的文件必须把所在目录列进 file.public-dirs
- 注释写清「公开页面引用的文件必须列进白名单,否则匿名访问会被拒」,同步更新
FileAccessRules 类注释
- 删掉只被测试调用的 FileAccessRules.isPublicFileRequest(测试钉住的是没上线的
平行判定);测试改为断言 downloadPatterns / permitAllPatterns /
authenticatedPatterns 的内容与顺序
- 顺带把 gxmu.module-registry.strict 改成 ${GXMU_MODULE_REGISTRY_STRICT:true},
与相邻配置一致
refs #58
---
.../common/core/security/FileAccessRules.java | 44 ++-------
src/main/resources/application.yml | 18 ++--
.../core/security/FileAccessRulesTest.java | 92 +++++++------------
3 files changed, 53 insertions(+), 101 deletions(-)
diff --git a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java
index bdb0cfb..b8fc6bf 100644
--- a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java
+++ b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java
@@ -3,7 +3,6 @@ package com.gxwebsoft.common.core.security;
import cn.hutool.core.util.StrUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
-import org.springframework.util.AntPathMatcher;
import java.util.ArrayList;
import java.util.Collections;
@@ -17,12 +16,15 @@ import java.util.List;
*
* - 下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;
* - 内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}:
- * 默认公开({@code file.public-preview=true}),否则现有公开页面的配图会整片变成需要 token;
- * - 其余文件接口:默认需要登录;命中 {@code file.public-dirs} 的目录额外公开。
+ * 默认也需要登录({@code file.public-preview} 默认 false)。上传一律落在
+ * {@code /yyyyMMdd/} 日期目录,日期目录正好是两段路径,所以默认公开等于把所有上传文件公开;
+ * - 公开页面引用的文件必须把所在目录列进 {@code file.public-dirs} 白名单,
+ * 否则匿名访问会被拒(表现为 401/403 的空图)。白名单是显式声明,默认空。
*
*
* 规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求,
- * 因此可以在单元测试里直接验证。
+ * 因此可以在单元测试里直接验证模式的内容与顺序——顺序恰恰是这条改造的关键:
+ * 下载必须在公开模式之前注册,公开模式必须在 {@code GET /**} 兜底之前注册。
*
* @author Codex
* @since 2026-11-03
@@ -50,20 +52,18 @@ public class FileAccessRules {
*/
private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**";
- private final AntPathMatcher pathMatcher = new AntPathMatcher();
-
/**
* 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠
*/
private final List publicDirs;
/**
- * 内联资源(预览 + 缩略图)是否默认公开
+ * 内联资源(预览 + 缩略图)是否默认公开;默认 false,只有显式打开才整体放行
*/
private final boolean publicPreview;
public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs,
- @Value("${file.public-preview:true}") boolean publicPreview) {
+ @Value("${file.public-preview:false}") boolean publicPreview) {
this.publicDirs = normalize(publicDirs);
this.publicPreview = publicPreview;
}
@@ -71,7 +71,8 @@ public class FileAccessRules {
/**
* 匿名放行的文件路径模式。
*
- * 内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。
+ *
默认({@code public-preview=false})只有 {@code file.public-dirs} 白名单里的目录;
+ * 显式打开内联公开时才额外放行预览与缩略图,且排在白名单之前。
*
* @return antMatchers 模式,没有任何公开项时返回空列表
*/
@@ -104,31 +105,6 @@ public class FileAccessRules {
return Collections.singletonList(DOWNLOAD_PATTERN);
}
- /**
- * 这条文件请求是否按公开资源处理(匿名可读)。
- *
- * @param path 请求路径,可带查询串
- * @return true 表示命中公开规则;下载、未命中目录、非文件路径一律 false
- */
- public boolean isPublicFileRequest(String path) {
- if (StrUtil.isBlank(path)) {
- return false;
- }
- String clean = StrUtil.subBefore(path, "?", false);
- if (!clean.startsWith(FILE_PATH_PREFIX + "/")) {
- return false;
- }
- if (pathMatcher.match(DOWNLOAD_PATTERN, clean)) {
- return false;
- }
- for (String pattern : permitAllPatterns()) {
- if (pathMatcher.match(pattern, clean)) {
- return true;
- }
- }
- return false;
- }
-
private List publicDirPatterns() {
if (publicDirs.isEmpty()) {
return Collections.emptyList();
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index c986a26..28c7b4c 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -92,12 +92,16 @@ config:
# 文件访问规则(spec #58)
# 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。
#
-# 确需公开的内联资源(预览 /api/file// 与缩略图 /api/file/thumbnail/**):
-# 默认 true,否则公开页面的配图会整片变成需要 token;置 false 则内联也需登录。
+# 内联预览(/api/file//)与缩略图(/api/file/thumbnail/**)默认也需要登录:
+# 上传一律落在 /yyyyMMdd/ 日期目录,日期目录正好是两段路径,默认公开等于把所有上传文件
+# (含盖章证明材料)变成匿名可读。因此默认值从 true 改成 false。
+#
+# 公开页面引用的文件必须把所在目录列进下面的 public-dirs 白名单,
+# 否则匿名访问会被拒(401/403,表现为公开页面的空图)。
file:
- public-preview: ${FILE_PUBLIC_PREVIEW:true}
- # 额外公开目录(可选,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图即使
- # public-preview=false 也允许匿名读取。留空表示没有额外目录。
+ public-preview: ${FILE_PUBLIC_PREVIEW:false}
+ # 公开目录白名单(显式声明,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图
+ # 允许匿名读取。留空表示没有任何公开目录——这是安全默认。
#
# 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store),
# 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。
@@ -221,9 +225,9 @@ gxmu:
allow-unknown-user: false
# 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、
# 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。
- # strict 默认 true;本地无库启动可临时设为 false 跳过校验。
+ # strict 默认 true;本地无库启动可临时设 GXMU_MODULE_REGISTRY_STRICT=false 跳过校验。
module-registry:
- strict: true
+ strict: ${GXMU_MODULE_REGISTRY_STRICT:true}
openplat-sync:
enabled: true
# 每天 03:30
diff --git a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java
index 7a091d6..2e04c9b 100644
--- a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java
+++ b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java
@@ -2,99 +2,71 @@ package com.gxwebsoft.common.core.security;
import org.junit.jupiter.api.Test;
-import java.util.List;
+import java.util.Arrays;
+import java.util.Collections;
-import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 文件访问规则(spec #58 API 契约、用户故事 14)。
*
- * 改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被
- * 未登录者直接按 URL 取走。spec 的要求只落在"下载接口"上:下载改为需登录;
- * 内联预览与缩略图必须继续匿名可读,否则公开页面的配图会整片裂掉。
- * 测试钉住三件事:下载无论怎么配都要登录、内联默认公开、显式关闭时内联也要登录。
+ *
改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被未登录者按 URL
+ * 直接取走。现在默认「全部需登录」,只有显式声明的东西才匿名可读。
+ *
+ *
测试钉的是 {@link SecurityConfig} 真正注册的那三个方法返回的模式内容与顺序——
+ * 顺序本身就是这条改造的关键(下载在公开之前、公开在兜底之前)。这里不再另造一套
+ * 「这条请求算不算公开」的平行判定,避免测试全绿而线上照旧放行。
*
* @author Codex
* @since 2026-11-03
*/
class FileAccessRulesTest {
- /** 下载接口永远要登录,白名单为空、内联公开也一样——它是"取走原件"的动作。 */
+ /** 下载接口单独一条模式,且只放这一个:它是"取走原件"的动作,永远要登录。 */
@Test
- void downloadAlwaysRequiresLogin() {
+ void downloadPatternIsRegisteredAlone() {
FileAccessRules rules = new FileAccessRules("", true);
- assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf"));
- assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf"));
+ assertEquals(Collections.singletonList("/api/file/download/**"), rules.downloadPatterns());
}
- /** 内联预览与缩略图默认公开:公开页面上的配图不能因为这次收敛而变成需要 token。 */
+ /** 安全默认:不配白名单、不打开内联公开时,没有任何匿名可读的文件路径。 */
@Test
- void previewAndThumbnailArePublicByDefault() {
- FileAccessRules rules = new FileAccessRules("", true);
-
- assertTrue(rules.isPublicFileRequest("/api/file/20260708/封面.png"));
- assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/20260708/封面.png"));
- }
-
- /** 显式关闭内联公开后,预览与缩略图都要登录——白名单开关确实生效。 */
- @Test
- void previewRequiresLoginWhenPublicPreviewDisabled() {
- FileAccessRules rules = new FileAccessRules("", false);
-
- assertFalse(rules.isPublicFileRequest("/api/file/20260708/封面.png"));
- assertFalse(rules.isPublicFileRequest("/api/file/thumbnail/20260708/封面.png"));
- assertFalse(rules.permitAllPatterns().contains("/api/file/*/*"));
- }
-
- /** 额外公开目录即使在内联关闭时也生效(原图与缩略图)。 */
- @Test
- void whitelistedDirectoryIsPublic() {
- FileAccessRules rules = new FileAccessRules("cms-article", false);
-
- assertTrue(rules.isPublicFileRequest("/api/file/cms-article/20260708/封面.png"));
- assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/cms-article/20260708/封面.png"));
- assertFalse(rules.isPublicFileRequest("/api/file/20260708/盖章证明.jpg"));
- }
-
- /** 没有公开项时不产生任何放行模式,安全默认 = 全部需登录。 */
- @Test
- void noPublicItemYieldsNoPublicPatterns() {
+ void defaultIsClosedSoNoFilePatternIsAnonymous() {
assertTrue(new FileAccessRules("", false).permitAllPatterns().isEmpty());
assertTrue(new FileAccessRules(null, false).permitAllPatterns().isEmpty());
- assertFalse(new FileAccessRules(null, false).isPublicFileRequest("/api/file/20260708/a.jpg"));
}
- /** 配置里允许空格、斜杠与空项,但产出的模式必须干净。 */
+ /** 白名单目录产出「原图 → 缩略图」两条模式,按声明顺序展开。 */
@Test
- void patternsAreNormalized() {
+ void whitelistedDirsProduceOriginThenThumbnailPatternsInOrder() {
FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ", false);
- List patterns = rules.permitAllPatterns();
- assertTrue(patterns.contains("/api/file/cms-article/**"));
- assertTrue(patterns.contains("/api/file/public/**"));
- assertTrue(patterns.contains("/api/file/thumbnail/cms-article/**"));
- assertFalse(patterns.contains("/api/file/**"), "不能整段放行 /api/file");
+ assertEquals(Arrays.asList(
+ "/api/file/cms-article/**",
+ "/api/file/thumbnail/cms-article/**",
+ "/api/file/public/**",
+ "/api/file/thumbnail/public/**"), rules.permitAllPatterns());
}
- /** 登录态之外的接口(CMS 文章 JSON)与单段文件接口不受公开规则影响。 */
+ /** 显式打开内联公开:预览与缩略图排在最前,白名单目录随后。 */
@Test
- void nonFileRequestsAreNotTreatedAsFiles() {
+ void publicPreviewAddsPreviewAndThumbnailBeforeWhitelistedDirs() {
FileAccessRules rules = new FileAccessRules("cms-article", true);
- assertFalse(rules.isPublicFileRequest("/api/cms/article"));
- assertFalse(rules.isPublicFileRequest("/api/file/page"));
- assertFalse(rules.isPublicFileRequest("/api/file/12"));
- assertFalse(rules.isPublicFileRequest(null));
+ assertEquals(Arrays.asList(
+ "/api/file/*/*",
+ "/api/file/thumbnail/**",
+ "/api/file/cms-article/**",
+ "/api/file/thumbnail/cms-article/**"), rules.permitAllPatterns());
}
- /** 兜底规则:白名单之外的 /api/file/** 仍需登录,下载规则单独可注册。 */
+ /** 兜底:白名单之外的所有文件接口都要登录,且必须是整段 /api/file/**(不能只覆盖子路径)。 */
@Test
- void authenticatedPatternsCoverAllFiles() {
- FileAccessRules rules = new FileAccessRules("cms-article", true);
+ void authenticatedFallbackCoversAllFiles() {
+ FileAccessRules rules = new FileAccessRules("cms-article", false);
- assertTrue(rules.authenticatedPatterns().contains("/api/file/**"));
- assertTrue(rules.downloadPatterns().contains("/api/file/download/**"));
+ assertEquals(Collections.singletonList("/api/file/**"), rules.authenticatedPatterns());
}
}
From 70213ab7838c2fda39d8b661ba37145659a84316 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Tue, 3 Nov 2026 23:14:24 +0800
Subject: [PATCH 5/5] =?UTF-8?q?refactor(gxmu):=20=E5=AE=A1=E6=89=B9?=
=?UTF-8?q?=E9=93=BE=E5=B1=82=E7=BA=A7=E6=A0=A1=E9=AA=8C=E4=B8=8B=E6=B2=89?=
=?UTF-8?q?=E5=88=B0=20Service=EF=BC=8C=E5=88=A0=E9=99=A4=E8=B7=AF?=
=?UTF-8?q?=E5=BE=84=E4=B9=9F=E6=A0=A1=E9=AA=8C=E5=89=A9=E4=BD=99=E9=93=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ReviewFlowController 里约 115 行的 validateLevels / validateUpdate /
validateBatchUpdate / validateRemaining / merge 下沉为
ReviewFlowService.validateLevelsOnSave / OnUpdate / OnBatchUpdate / OnRemove,
controller 只在写操作前调用一次
- remove / removeBatch 新增删除后剩余链连续性校验;删除前就已不连续的链跳过校验,
避免存量脏数据变成「永远删不掉」
- 顺手修掉一个真缺陷:merge 会就地改 existing,导致「改名后原链剩余校验」永远不触发
(批量路径同样先取旧标题再合并)
- 模块标题回退三份收敛成 ModuleRegistryService.titleOf(Map, module) 一处,
ReviewFlowConfigServiceImpl / ReviewListServiceImpl 直接调用
- ReviewFlowLevelValidator 的手写 join 换成 Collectors.joining(", ")
- 回填脚本头部补「表名 → 学院文本列」对照表,注明新增表按此表同步 4 处
refs #58
---
sql/20261110_backfill_college_id.sql | 20 ++
.../gxmu/controller/ReviewFlowController.java | 163 +---------------
.../service/ReviewFlowLevelValidator.java | 10 +-
.../gxmu/service/ReviewFlowService.java | 38 ++++
.../impl/ReviewFlowConfigServiceImpl.java | 10 +-
.../service/impl/ReviewFlowServiceImpl.java | 184 ++++++++++++++++++
.../service/impl/ReviewListServiceImpl.java | 10 +-
.../ReviewFlowControllerLevelTest.java | 80 ++++++--
.../impl/ReviewFlowServiceImplLevelTest.java | 118 +++++++++++
9 files changed, 440 insertions(+), 193 deletions(-)
create mode 100644 src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java
diff --git a/sql/20261110_backfill_college_id.sql b/sql/20261110_backfill_college_id.sql
index 8ba5c48..5f50677 100644
--- a/sql/20261110_backfill_college_id.sql
+++ b/sql/20261110_backfill_college_id.sql
@@ -14,6 +14,26 @@
-- 3) 校验与清单(只读) —— 回填率、空值清单、未匹配文本一览,抄进上线记录
-- 3) ⚠ 只执行一次,不接定时任务。
--
+-- ============================================================================
+-- 表名 → 学院文本列 对照表(本脚本唯一的一处机械清单,B 段兜底按它逐段复制)
+--
+-- ┌──────────────────────┬──────────────────────────────────┬──────────┐
+-- │ 表名 │ 学院文本列(B 段兜底用) │ 列注释 │
+-- ├──────────────────────┼──────────────────────────────────┼──────────┤
+-- │ gxmu_sjqn │ unit_name │ 所在单位 │
+-- │ gxmu_tzbcy_form │ school_name │ 学校名称 │
+-- │ gxmu_qmgc_form │ school_info │ 学校、院系、年级、专业 │
+-- │ gxmu_wxxzx_project │ college_grade_class │ 学院、年级、班别 │
+-- └──────────────────────┴──────────────────────────────────┴──────────┘
+--
+-- 新增第 5 张带 college_id 的表时,按上表同步 4 处:
+-- ① sql/20261110_spec58_approval_chain.sql 的加列段 + 注册表种子(has_college = 1)
+-- ② 本脚本 A 段(学生档案主路径:user_id → gxmu_student.college_id)
+-- ③ 本脚本 B 段(表单文本兜底:按上表的文本列 LIKE 组织名)
+-- ④ 本脚本 3a / 3b / 3c / 3d(回填率、空值清单、未匹配文本、逐表计数)
+-- 漏改的后果不是报错,而是新表回填率恒为 0、审批人按学院解析静默失败。
+-- ============================================================================
+--
-- 可重复执行:所有 UPDATE 都带「只填空、不覆盖」条件(college_id IS NULL),且不带会话状态
-- (不用临时表、不用视图),任何一句都可以单独重跑。重跑不会改掉人工修对的值。
--
diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java
index 5ef157b..5b7cf22 100644
--- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java
+++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java
@@ -1,33 +1,26 @@
package com.gxwebsoft.gxmu.controller;
import com.gxwebsoft.common.core.web.BaseController;
-import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
-import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.BatchParam;
-import com.gxwebsoft.common.core.annotation.OperationLog;
-import com.gxwebsoft.common.system.entity.User;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.LinkedHashSet;
+import java.util.Collections;
import java.util.List;
-import java.util.Objects;
-import java.util.Set;
/**
* 审核流控制器
*
+ * 层级连续性(从 1 连续升序)是审批链的业务规则,校验落在
+ * {@link ReviewFlowService#validateLevelsOnSave} 等方法的实现里;controller 只负责在写操作前调用一次。
+ *
* @author LX
* @since 2026-03-18 10:37:18
*/
@@ -69,12 +62,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("添加审核流")
@PostMapping()
public ApiResult> save(@RequestBody ReviewFlow reviewFlow) {
- validateLevels(reviewFlow == null ? null : java.util.Collections.singletonList(reviewFlow));
- // 记录当前登录用户id
-// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
-// if (checkByName != null) {
-// return fail("该名称已存在");
-// }
+ reviewFlowService.validateLevelsOnSave(reviewFlow == null ? null : Collections.singletonList(reviewFlow));
if (reviewFlowService.save(reviewFlow)) {
return success("添加成功");
}
@@ -84,11 +72,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("修改审核流")
@PutMapping()
public ApiResult> update(@RequestBody ReviewFlow reviewFlow) {
- validateUpdate(reviewFlow);
-// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
-// if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) {
-// return fail("该名称已存在");
-// }
+ reviewFlowService.validateLevelsOnUpdate(reviewFlow);
if (reviewFlowService.updateById(reviewFlow)) {
return success("修改成功");
}
@@ -98,6 +82,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("删除审核流")
@DeleteMapping("/{id}")
public ApiResult> remove(@PathVariable("id") Integer id) {
+ reviewFlowService.validateLevelsOnRemove(Collections.singletonList(id));
if (reviewFlowService.removeById(id)) {
return success("删除成功");
}
@@ -107,7 +92,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("批量添加审核流")
@PostMapping("/batch")
public ApiResult> saveBatch(@RequestBody List list) {
- validateLevels(list);
+ reviewFlowService.validateLevelsOnSave(list);
if (reviewFlowService.saveBatch(list)) {
return success("添加成功");
}
@@ -117,145 +102,17 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("批量修改审核流")
@PutMapping("/batch")
public ApiResult> updateBatch(@RequestBody BatchParam batchParam) {
- validateBatchUpdate(batchParam);
+ reviewFlowService.validateLevelsOnBatchUpdate(batchParam);
if (batchParam.update(reviewFlowService, "id")) {
return success("修改成功");
}
return fail("修改失败");
}
- /**
- * 校验"保存后这条审批链的层级从 1 连续升序"(spec #58 行为约定)。
- *
- * 管理端是"先删旧节点、再逐级新增",因此单条保存时必须把库里同 title 的已有节点一起看,
- * 否则逐级保存会被误判为缺级。
- */
- private void validateLevels(List incoming) {
- if (incoming == null || incoming.isEmpty()) {
- return;
- }
- List proposed = new ArrayList<>();
- Set titles = new LinkedHashSet<>();
- Set replacedIds = new LinkedHashSet<>();
- for (ReviewFlow flow : incoming) {
- if (flow == null) {
- continue;
- }
- proposed.add(flow);
- if (flow.getId() != null) {
- replacedIds.add(flow.getId());
- }
- if (flow.getTitle() != null) {
- titles.add(flow.getTitle().trim());
- }
- }
- for (String title : titles) {
- List existing = reviewFlowService.listByTitle(title);
- if (existing == null) {
- continue;
- }
- for (ReviewFlow flow : existing) {
- if (flow != null && flow.getId() != null && !replacedIds.contains(flow.getId())) {
- proposed.add(flow);
- }
- }
- }
- ReviewFlowLevelValidator.validate(proposed);
- }
-
- /**
- * 修改单条节点:未传的字段沿用库里原值(MyBatis-Plus 同样忽略 null),再校验受影响链的连续性。
- */
- private void validateUpdate(ReviewFlow reviewFlow) {
- if (reviewFlow == null) {
- return;
- }
- ReviewFlow existing = reviewFlow.getId() == null ? null : reviewFlowService.getById(reviewFlow.getId());
- ReviewFlow merged = merge(existing, reviewFlow);
- validateLevels(java.util.Collections.singletonList(merged));
- if (existing != null && !sameTitle(existing.getTitle(), merged.getTitle())) {
- validateRemaining(existing.getTitle(), java.util.Collections.singletonList(reviewFlow.getId()));
- }
- }
-
- /**
- * 批量修改:BatchParam 只带一个 data,id 集合里每行都按同样字段更新。
- */
- private void validateBatchUpdate(BatchParam batchParam) {
- if (batchParam == null || batchParam.getData() == null
- || batchParam.getIds() == null || batchParam.getIds().isEmpty()) {
- return;
- }
- List ids = new ArrayList<>();
- for (Serializable id : batchParam.getIds()) {
- if (id instanceof Number) {
- ids.add(((Number) id).intValue());
- }
- }
- if (ids.isEmpty()) {
- return;
- }
- List existingRows = reviewFlowService.listByIds(ids);
- if (existingRows == null || existingRows.isEmpty()) {
- return;
- }
- List mergedRows = new ArrayList<>();
- List oldTitles = new ArrayList<>();
- for (ReviewFlow existing : existingRows) {
- if (existing == null) {
- continue;
- }
- mergedRows.add(merge(existing, batchParam.getData()));
- oldTitles.add(existing.getTitle());
- }
- validateLevels(mergedRows);
- Set newTitles = new LinkedHashSet<>();
- for (ReviewFlow row : mergedRows) {
- newTitles.add(row.getTitle() == null ? "" : row.getTitle().trim());
- }
- for (String oldTitle : oldTitles) {
- if (!newTitles.contains(oldTitle == null ? "" : oldTitle.trim())) {
- validateRemaining(oldTitle, ids);
- }
- }
- }
-
- /**
- * 校验改名/移走后原审批链剩下的节点仍连续。
- */
- private void validateRemaining(String title, Collection excludedIds) {
- List remaining = new ArrayList<>();
- List existing = title == null ? null : reviewFlowService.listByTitle(title);
- if (existing != null) {
- for (ReviewFlow flow : existing) {
- if (flow != null && !excludedIds.contains(flow.getId())) {
- remaining.add(flow);
- }
- }
- }
- ReviewFlowLevelValidator.validate(remaining);
- }
-
- private ReviewFlow merge(ReviewFlow existing, ReviewFlow incoming) {
- if (existing == null) {
- return incoming;
- }
- if (incoming.getTitle() != null) {
- existing.setTitle(incoming.getTitle());
- }
- if (incoming.getLevel() != null) {
- existing.setLevel(incoming.getLevel());
- }
- return existing;
- }
-
- private boolean sameTitle(String left, String right) {
- return Objects.equals(left == null ? null : left.trim(), right == null ? null : right.trim());
- }
-
@ApiOperation("批量删除审核流")
@DeleteMapping("/batch")
public ApiResult> removeBatch(@RequestBody List ids) {
+ reviewFlowService.validateLevelsOnRemove(ids);
if (reviewFlowService.removeByIds(ids)) {
return success("删除成功");
}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java
index ad4b30c..8cc5d94 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java
@@ -8,6 +8,7 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
/**
* 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。
@@ -69,13 +70,6 @@ public final class ReviewFlowLevelValidator {
}
private static String join(List levels) {
- StringBuilder builder = new StringBuilder();
- for (int index = 0; index < levels.size(); index++) {
- if (index > 0) {
- builder.append(", ");
- }
- builder.append(levels.get(index));
- }
- return builder.toString();
+ return levels.stream().map(String::valueOf).collect(Collectors.joining(", "));
}
}
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java
index 1d7172b..7f3e2bc 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java
@@ -1,10 +1,12 @@
package com.gxwebsoft.gxmu.service;
import com.baomidou.mybatisplus.extension.service.IService;
+import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
+import java.util.Collection;
import java.util.List;
/**
@@ -42,4 +44,40 @@ public interface ReviewFlowService extends IService {
ReviewFlow getByTitle(String title);
List listByTitle(String title);
+
+ /**
+ * 保存前校验:保存后这条审批链的层级必须从 1 连续升序(spec #58 行为约定)。
+ *
+ * 管理端是"先删旧节点、再逐级新增",所以单条保存时必须把库里同 title 的已有节点一起看,
+ * 否则逐级保存会被误判为缺级。不合法时抛
+ * {@link com.gxwebsoft.common.core.exception.BusinessException}。
+ *
+ * @param incoming 本次提交的节点(可含多条审批链)
+ */
+ void validateLevelsOnSave(List incoming);
+
+ /**
+ * 修改单条节点前校验:未传的字段沿用库里原值,再校验受影响链的连续性;
+ * 若改了 title,还要校验原链剩下的节点仍连续。
+ *
+ * @param incoming 提交的节点
+ */
+ void validateLevelsOnUpdate(ReviewFlow incoming);
+
+ /**
+ * 批量修改前校验:id 集合里每行都按同样字段更新,合并后校验连续性,
+ * 并校验改名后原链剩余部分仍连续。
+ *
+ * @param batchParam 批量参数
+ */
+ void validateLevelsOnBatchUpdate(BatchParam batchParam);
+
+ /**
+ * 删除前校验:删除后受影响审批链剩下的节点必须仍从 1 连续升序。
+ *
+ * 删除前就已经不连续的链不做校验(否则存量脏数据会变成"永远删不掉")。
+ *
+ * @param removedIds 待删除的节点 id
+ */
+ void validateLevelsOnRemove(Collection removedIds);
}
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 4d6c733..9cee673 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java
@@ -99,17 +99,9 @@ public class ReviewFlowConfigServiceImpl extends ServiceImpl moduleTitleMap = moduleRegistryService.titleMap();
for (ReviewFlowConfig item : list) {
item.setFlowName(flowNameMap.get(item.getFlowId()));
- item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule()));
+ item.setModuleName(ModuleRegistryService.titleOf(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/ReviewFlowServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java
index 8c1fe90..939c4c9 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java
@@ -2,10 +2,12 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.system.entity.Organization;
import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.gxmu.mapper.ReviewFlowMapper;
+import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
@@ -14,7 +16,13 @@ import com.gxwebsoft.common.core.web.PageResult;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Objects;
+import java.util.Set;
/**
* 审核流Service实现
@@ -91,4 +99,180 @@ public class ReviewFlowServiceImpl extends ServiceImpl incoming) {
+ if (incoming == null || incoming.isEmpty()) {
+ return;
+ }
+ List proposed = new ArrayList<>();
+ Set titles = new LinkedHashSet<>();
+ Set replacedIds = new LinkedHashSet<>();
+ for (ReviewFlow flow : incoming) {
+ if (flow == null) {
+ continue;
+ }
+ proposed.add(flow);
+ if (flow.getId() != null) {
+ replacedIds.add(flow.getId());
+ }
+ if (flow.getTitle() != null) {
+ titles.add(flow.getTitle().trim());
+ }
+ }
+ for (String title : titles) {
+ List existing = listByTitle(title);
+ if (existing == null) {
+ continue;
+ }
+ for (ReviewFlow flow : existing) {
+ if (flow != null && flow.getId() != null && !replacedIds.contains(flow.getId())) {
+ proposed.add(flow);
+ }
+ }
+ }
+ ReviewFlowLevelValidator.validate(proposed);
+ }
+
+ @Override
+ public void validateLevelsOnUpdate(ReviewFlow incoming) {
+ if (incoming == null) {
+ return;
+ }
+ ReviewFlow existing = incoming.getId() == null ? null : getById(incoming.getId());
+ // merge 会就地改 existing,旧标题必须先存下来,否则「改名后原链剩余校验」永远不触发
+ String oldTitle = existing == null ? null : existing.getTitle();
+ ReviewFlow merged = merge(existing, incoming);
+ validateLevelsOnSave(java.util.Collections.singletonList(merged));
+ if (existing != null && !sameTitle(oldTitle, merged.getTitle())) {
+ validateRemaining(oldTitle, java.util.Collections.singletonList(incoming.getId()));
+ }
+ }
+
+ @Override
+ public void validateLevelsOnBatchUpdate(BatchParam batchParam) {
+ if (batchParam == null || batchParam.getData() == null
+ || batchParam.getIds() == null || batchParam.getIds().isEmpty()) {
+ return;
+ }
+ List ids = new ArrayList<>();
+ for (Serializable id : batchParam.getIds()) {
+ if (id instanceof Number) {
+ ids.add(((Number) id).intValue());
+ }
+ }
+ if (ids.isEmpty()) {
+ return;
+ }
+ List existingRows = listByIds(ids);
+ if (existingRows == null || existingRows.isEmpty()) {
+ return;
+ }
+ List mergedRows = new ArrayList<>();
+ List oldTitles = new ArrayList<>();
+ for (ReviewFlow existing : existingRows) {
+ if (existing == null) {
+ continue;
+ }
+ // 同上:merge 就地改 existing,旧标题要先取
+ oldTitles.add(existing.getTitle());
+ mergedRows.add(merge(existing, batchParam.getData()));
+ }
+ validateLevelsOnSave(mergedRows);
+ Set newTitles = new LinkedHashSet<>();
+ for (ReviewFlow row : mergedRows) {
+ newTitles.add(row.getTitle() == null ? "" : row.getTitle().trim());
+ }
+ for (String oldTitle : oldTitles) {
+ if (!newTitles.contains(oldTitle == null ? "" : oldTitle.trim())) {
+ validateRemaining(oldTitle, ids);
+ }
+ }
+ }
+
+ @Override
+ public void validateLevelsOnRemove(Collection removedIds) {
+ if (removedIds == null || removedIds.isEmpty()) {
+ return;
+ }
+ List ids = new ArrayList<>();
+ for (Integer id : removedIds) {
+ if (id != null) {
+ ids.add(id);
+ }
+ }
+ if (ids.isEmpty()) {
+ return;
+ }
+ List removed = listByIds(ids);
+ if (removed == null || removed.isEmpty()) {
+ return;
+ }
+ Set titles = new LinkedHashSet<>();
+ for (ReviewFlow flow : removed) {
+ if (flow != null && flow.getTitle() != null) {
+ titles.add(flow.getTitle().trim());
+ }
+ }
+ for (String title : titles) {
+ List before = listByTitle(title);
+ if (before == null || before.isEmpty()) {
+ continue;
+ }
+ if (!isContinuous(before)) {
+ // 删除前就已经不连续:不把存量脏数据变成"永远删不掉"
+ continue;
+ }
+ validateRemaining(title, ids);
+ }
+ }
+
+ /**
+ * 校验改名/移走后原审批链剩下的节点仍连续。
+ */
+ private void validateRemaining(String title, Collection excludedIds) {
+ List remaining = new ArrayList<>();
+ List existing = title == null ? null : listByTitle(title);
+ if (existing != null) {
+ for (ReviewFlow flow : existing) {
+ if (flow != null && !excludedIds.contains(flow.getId())) {
+ remaining.add(flow);
+ }
+ }
+ }
+ ReviewFlowLevelValidator.validate(remaining);
+ }
+
+ /**
+ * 这组节点本身是否已经连续(用于决定要不要在删除前做校验)。
+ */
+ private boolean isContinuous(List flows) {
+ try {
+ ReviewFlowLevelValidator.validate(flows);
+ return true;
+ } catch (com.gxwebsoft.common.core.exception.BusinessException e) {
+ return false;
+ }
+ }
+
+ private ReviewFlow merge(ReviewFlow existing, ReviewFlow incoming) {
+ if (existing == null) {
+ return incoming;
+ }
+ if (incoming.getTitle() != null) {
+ existing.setTitle(incoming.getTitle());
+ }
+ if (incoming.getLevel() != null) {
+ existing.setLevel(incoming.getLevel());
+ }
+ return existing;
+ }
+
+ private boolean sameTitle(String left, String right) {
+ return Objects.equals(left == null ? null : left.trim(), right == null ? null : right.trim());
+ }
+
}
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 9d034c7..839744f 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java
@@ -86,18 +86,10 @@ public class ReviewListServiceImpl extends ServiceImpl moduleTitleMap = moduleRegistryService.titleMap();
for (ReviewList item : list) {
- item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule()));
+ item.setModuleName(ModuleRegistryService.titleOf(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/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java
index 49888ce..15215bb 100644
--- a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java
+++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java
@@ -1,6 +1,7 @@
package com.gxwebsoft.gxmu.controller;
import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import org.junit.jupiter.api.Test;
@@ -16,15 +17,22 @@ import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
- * 审批链保存入口的层级校验接线(spec #58)。
+ * 审批链写入口的层级校验接线(spec #58)。
*
- * 管理端"先删旧节点、再逐级新增",所以单条保存必须结合库里同 title 的已有节点判断,
- * 否则逐级保存会被误判为缺级;这里钉住这个接线。
+ *
规则实现已下沉到 {@link ReviewFlowService}(合并库内同 title 节点、校验连续性、
+ * 校验改名后原链剩余;见 ReviewFlowServiceImplLevelTest),controller 只负责
+ * 「写之前调用一次」:保存、修改、批量、删除四条路径都要接上,且校验抛错时一个字节都不落库。
+ *
+ * @author Codex
+ * @since 2026-11-03
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -46,32 +54,76 @@ class ReviewFlowControllerLevelTest {
return flow;
}
+ /** 逐级保存:controller 把校验交给 service,校验通过才落库。 */
@Test
- void savingNextLevelAfterPreviousOneIsAccepted() {
- when(reviewFlowService.listByTitle(TITLE))
- .thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
+ void saveDelegatesLevelValidationThenWrites() {
when(reviewFlowService.save(any())).thenReturn(true);
controller.save(flow(null, TITLE, 2));
+ verify(reviewFlowService).validateLevelsOnSave(anyList());
verify(reviewFlowService).save(any());
}
+ /** 校验拒绝(缺级):异常透出,不落库。 */
@Test
- void savingLevelThreeWithoutTwoIsRejectedBeforeWrite() {
- when(reviewFlowService.listByTitle(TITLE))
- .thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
+ void saveDoesNotWriteWhenServiceRejectsGap() {
+ doThrow(new BusinessException("审批链「十佳青年」的层级必须从 1 连续升序"))
+ .when(reviewFlowService).validateLevelsOnSave(any());
assertThrows(BusinessException.class, () -> controller.save(flow(null, TITLE, 3)));
verify(reviewFlowService, never()).save(any());
}
+ /** 批量保存同样先校验。 */
@Test
- void batchSaveRejectsGap() {
- when(reviewFlowService.listByTitle(TITLE)).thenReturn(Collections.emptyList());
+ void batchSaveDelegatesLevelValidation() {
+ when(reviewFlowService.saveBatch(any())).thenReturn(true);
- assertThrows(BusinessException.class, () -> controller.saveBatch(Arrays.asList(
- flow(null, TITLE, 1), flow(null, TITLE, 3))));
- verify(reviewFlowService, never()).saveBatch(any());
+ controller.saveBatch(Arrays.asList(flow(null, TITLE, 1), flow(null, TITLE, 2)));
+
+ verify(reviewFlowService).validateLevelsOnSave(anyList());
+ verify(reviewFlowService).saveBatch(any());
+ }
+
+ /** 单条修改:合并库内原值后校验,再落库。 */
+ @Test
+ void updateDelegatesLevelValidation() {
+ when(reviewFlowService.updateById(any())).thenReturn(true);
+
+ controller.update(flow(1, TITLE, 2));
+
+ verify(reviewFlowService).validateLevelsOnUpdate(any());
+ verify(reviewFlowService).updateById(any());
+ }
+
+ /** 批量修改同样先校验。 */
+ @Test
+ void batchUpdateDelegatesLevelValidation() {
+ controller.updateBatch(new BatchParam<>());
+
+ verify(reviewFlowService).validateLevelsOnBatchUpdate(any());
+ }
+
+ /** 删除:删之前校验剩余链仍连续。 */
+ @Test
+ void removeDelegatesRemainingChainValidation() {
+ when(reviewFlowService.removeById(5)).thenReturn(true);
+
+ controller.remove(5);
+
+ verify(reviewFlowService).validateLevelsOnRemove(eq(Collections.singletonList(5)));
+ verify(reviewFlowService).removeById(5);
+ }
+
+ /** 批量删除同样先校验。 */
+ @Test
+ void removeBatchDelegatesRemainingChainValidation() {
+ when(reviewFlowService.removeByIds(anyList())).thenReturn(true);
+
+ controller.removeBatch(Arrays.asList(5, 6));
+
+ verify(reviewFlowService).validateLevelsOnRemove(eq(Arrays.asList(5, 6)));
+ verify(reviewFlowService).removeByIds(anyList());
}
}
diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java
new file mode 100644
index 0000000..833eb25
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java
@@ -0,0 +1,118 @@
+package com.gxwebsoft.gxmu.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.gxmu.entity.ReviewFlow;
+import com.gxwebsoft.gxmu.mapper.ReviewFlowMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+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.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * 审批链层级校验下沉到 Service 之后的接线(spec #58「层级从 1 连续升序」)。
+ *
+ *
规则实现落在 {@link ReviewFlowServiceImpl},controller 只调用;这里用 mock 掉 Mapper
+ * 的方式验证「合并库内同 title 节点 → 校验连续性 → 校验改名后原链剩余」这条链路,
+ * 以及删除后剩余链的连续性。不启 Spring、不连库。
+ *
+ * @author Codex
+ * @since 2026-11-11
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class ReviewFlowServiceImplLevelTest {
+
+ private static final String TITLE = "十佳青年";
+
+ @Mock
+ private ReviewFlowMapper reviewFlowMapper;
+
+ @InjectMocks
+ private ReviewFlowServiceImpl reviewFlowService;
+
+ /** MyBatis-Plus 的 lambda 列名解析需要 TableInfo,纯单元测试里手工初始化一次 */
+ @BeforeAll
+ static void initLambdaCache() {
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, ReviewFlow.class);
+ }
+
+ /** 逐级保存:库里已有第 1 级时,新增第 2 级应被接受。 */
+ @Test
+ void savingNextLevelAfterExistingOneIsAccepted() {
+ when(reviewFlowMapper.selectList(any()))
+ .thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
+
+ assertDoesNotThrow(() -> reviewFlowService.validateLevelsOnSave(
+ Collections.singletonList(flow(null, TITLE, 2))));
+ }
+
+ /** 缺级:库里只有第 1 级时直接保存第 3 级要被拒绝。 */
+ @Test
+ void savingLevelThreeWithoutTwoIsRejected() {
+ when(reviewFlowMapper.selectList(any()))
+ .thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
+
+ assertThrows(BusinessException.class, () -> reviewFlowService.validateLevelsOnSave(
+ Collections.singletonList(flow(null, TITLE, 3))));
+ }
+
+ /** 改名把第 2 级挪走后,原链剩下 1、3 不连续,必须拒绝。 */
+ @Test
+ void renamingMiddleNodeLeavesOldChainGapAndIsRejected() {
+ when(reviewFlowMapper.selectById(2)).thenReturn(flow(2, TITLE, 2));
+ when(reviewFlowMapper.selectList(any()))
+ // 第一次:新 title 下暂无节点;第二次:原 title 下 1、2、3
+ .thenReturn(Collections.emptyList())
+ .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3)));
+
+ assertThrows(BusinessException.class,
+ () -> reviewFlowService.validateLevelsOnUpdate(flow(2, "新链", 1)));
+ }
+
+ /** 删除中间一级会留下 1、3 的缺口,拒绝删除。 */
+ @Test
+ void removingMiddleLevelIsRejected() {
+ when(reviewFlowMapper.selectBatchIds(any()))
+ .thenReturn(Collections.singletonList(flow(2, TITLE, 2)));
+ when(reviewFlowMapper.selectList(any()))
+ .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3)));
+
+ assertThrows(BusinessException.class,
+ () -> reviewFlowService.validateLevelsOnRemove(Collections.singletonList(2)));
+ }
+
+ /** 删除最后一级不产生缺口,允许。 */
+ @Test
+ void removingLastLevelIsAccepted() {
+ when(reviewFlowMapper.selectBatchIds(any()))
+ .thenReturn(Collections.singletonList(flow(3, TITLE, 3)));
+ when(reviewFlowMapper.selectList(any()))
+ .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3)));
+
+ assertDoesNotThrow(() -> reviewFlowService.validateLevelsOnRemove(Collections.singletonList(3)));
+ }
+
+ private ReviewFlow flow(Integer id, String title, Integer level) {
+ ReviewFlow flow = new ReviewFlow();
+ flow.setId(id);
+ flow.setTitle(title);
+ flow.setLevel(level);
+ return flow;
+ }
+}