merge: 审查修复(college 写入、逐级解析、文件默认收紧、注册表 all/has_college、层级校验下沉)

This commit is contained in:
2026-11-03 23:16:21 +08:00
42 changed files with 1359 additions and 505 deletions
+20
View File
@@ -14,6 +14,26 @@
-- 3) 校验与清单(只读) —— 回填率、空值清单、未匹配文本一览,抄进上线记录 -- 3) 校验与清单(只读) —— 回填率、空值清单、未匹配文本一览,抄进上线记录
-- 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),且不带会话状态 -- 可重复执行:所有 UPDATE 都带「只填空、不覆盖」条件(college_id IS NULL),且不带会话状态
-- (不用临时表、不用视图),任何一句都可以单独重跑。重跑不会改掉人工修对的值。 -- (不用临时表、不用视图),任何一句都可以单独重跑。重跑不会改掉人工修对的值。
-- --
+54 -18
View File
@@ -2,7 +2,8 @@
-- 内容: -- 内容:
-- 1) gxmu_review_flow 增加 reviewer_rule(审批人规则,为空时回退 review_user_id) -- 1) gxmu_review_flow 增加 reviewer_rule(审批人规则,为空时回退 review_user_id)
-- 2) 4 张在跑业务表增加 college_id(学院标识,指向 sys_organization.organization_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), -- 重复执行:加列前用 information_schema 判存(MySQL 5.7 不支持 ADD COLUMN IF NOT EXISTS),
-- 建表用 IF NOT EXISTS,初始数据用 INSERT IGNORE; -- 建表用 IF NOT EXISTS,初始数据用 INSERT IGNORE;
@@ -87,6 +88,8 @@ DEALLOCATE PREPARE stmt;
-- 3) 模块注册表:模块元数据的唯一来源 -- 3) 模块注册表:模块元数据的唯一来源
-- module 同时是业务表名,table_name 与 module 不一致会在启动期直接失败(ADR 0006)。 -- module 同时是业务表名,table_name 与 module 不一致会在启动期直接失败(ADR 0006)。
-- detail_key 对应 moduleDetailConfig.ts 的配置键;export_template 留空表示走默认导出。 -- detail_key 对应 moduleDetailConfig.ts 的配置键;export_template 留空表示走默认导出。
-- has_college=1 表示该模块的业务表有 college_id,审批人解析才去读学院上下文;
-- 后端不再维护「哪几张表有学院标识」的模块白名单,新增表只改注册表一行。
-- ------------------------------------------------------------------ -- ------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS gxmu_module_registry ( CREATE TABLE IF NOT EXISTS gxmu_module_registry (
id int NOT NULL AUTO_INCREMENT, id int NOT NULL AUTO_INCREMENT,
@@ -95,6 +98,7 @@ CREATE TABLE IF NOT EXISTS gxmu_module_registry (
title varchar(100) NOT NULL COMMENT '模块标题', title varchar(100) NOT NULL COMMENT '模块标题',
detail_key varchar(64) NOT NULL COMMENT '详情页配置键(moduleDetailConfig.ts)', detail_key varchar(64) NOT NULL COMMENT '详情页配置键(moduleDetailConfig.ts)',
export_template varchar(100) DEFAULT NULL COMMENT '导出模板标识', 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是', enabled tinyint NOT NULL DEFAULT '1' COMMENT '是否启用:0否 1是',
sort_number int NOT NULL DEFAULT '0' COMMENT '排序号,小的在前', sort_number int NOT NULL DEFAULT '0' COMMENT '排序号,小的在前',
deleted tinyint NOT NULL DEFAULT '0' COMMENT '删除标记:0未删 1已删', 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) UNIQUE KEY uk_module (module)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='模块注册表'; ) 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 的键一致。 -- title 取自 ReviewFlowConfigServiceImpl.MODULE_NAME_MAP,detail_key 与 moduleDetailConfig.ts 的键一致。
-- enabled=1:4 条在跑的 gxmu 审批链 + 稿件(cms_manuscript 有 review_flow_config 配置、当前就在审核下拉里, -- enabled=1(5 行):4 条在跑的 gxmu 审批链 + 稿件(cms_manuscript 有 review_flow_config 配置、当前就在审核下拉里,
-- 停用会让「稿件」从审核筛选里静默消失);5 个「已接线未启用」的 gxmu 模块保持 0(定性见 issue #55)。 -- 停用会让「稿件」从审核筛选里静默消失)。
-- sort_number 按 reviewList/components/search.vue 当前下拉顺序,cms_manuscript 排在最后。 -- 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 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 VALUES
('gxmu_sjqn', 'gxmu_sjqn', '十佳青年岗位能手', 'gxmu_sjqn', NULL, 1, 1, 0, 1, NULL), ('gxmu_sjqn', 'gxmu_sjqn', '十佳青年岗位能手', 'gxmu_sjqn', NULL, 1, 1, 1, 0, 1, NULL),
('gxmu_sjtbzbsj', 'gxmu_sjtbzbsj', '十佳团支部书记', 'gxmu_sjtbzbsj', NULL, 0, 2, 0, 1, NULL), ('gxmu_sjtbzbsj', 'gxmu_sjtbzbsj', '十佳团支部书记', 'gxmu_sjtbzbsj', NULL, 0, 0, 2, 0, 1, NULL),
('gxmu_wshqtw', 'gxmu_wshqtw', '五四红旗团委', 'gxmu_wshqtw', NULL, 0, 3, 0, 1, NULL), ('gxmu_wshqtw', 'gxmu_wshqtw', '五四红旗团委', 'gxmu_wshqtw', NULL, 0, 0, 3, 0, 1, NULL),
('gxmu_wshqtzb', 'gxmu_wshqtzb', '五四红旗团支部', 'gxmu_wshqtzb', NULL, 0, 4, 0, 1, NULL), ('gxmu_wshqtzb', 'gxmu_wshqtzb', '五四红旗团支部', 'gxmu_wshqtzb', NULL, 0, 0, 4, 0, 1, NULL),
('gxmu_yxgqtdgb', 'gxmu_yxgqtdgb', '优秀共青团干部', 'gxmu_yxgqtdgb', NULL, 0, 5, 0, 1, NULL), ('gxmu_yxgqtdgb', 'gxmu_yxgqtdgb', '优秀共青团干部', 'gxmu_yxgqtdgb', NULL, 0, 0, 5, 0, 1, NULL),
('gxmu_yxgqty', 'gxmu_yxgqty', '优秀共青团员', 'gxmu_yxgqty', NULL, 0, 6, 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, 7, 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, 8, 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, 9, 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, 1, 10, 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 TABLE_NAME IN ('gxmu_sjqn', 'gxmu_tzbcy_form', 'gxmu_qmgc_form', 'gxmu_wxxzx_project')
AND COLUMN_NAME = 'college_id' AND IS_NULLABLE = 'YES'; 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 registry_rows FROM gxmu_module_registry WHERE deleted = 0;
SELECT COUNT(*) AS enabled_rows FROM gxmu_module_registry WHERE deleted = 0 AND enabled = 1; 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) 一致性检查:以下查询都应返回空集 -- 3) 一致性检查:以下查询都应返回空集
SELECT module FROM gxmu_module_registry WHERE deleted = 0 AND table_name <> module; 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 = ''); 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 ON t.TABLE_SCHEMA = DATABASE() AND t.TABLE_NAME = r.table_name
WHERE r.deleted = 0 AND t.TABLE_NAME IS NULL; 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 SELECT module, title, sort_number
FROM gxmu_module_registry FROM gxmu_module_registry
WHERE deleted = 0 AND enabled = 1 WHERE deleted = 0 AND enabled = 1
@@ -10,12 +10,7 @@ import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
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 io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -38,11 +33,7 @@ public class ManuscriptController extends BaseController {
@Resource @Resource
private ManuscriptService manuscriptService; private ManuscriptService manuscriptService;
@Resource @Resource
private ReviewFlowConfigService reviewFlowConfigService; private ReviewFlowStarterService reviewFlowStarterService;
@Resource
private ReviewFlowService reviewFlowService;
@Resource
private ReviewListService reviewListService;
@ApiOperation("分页查询稿件") @ApiOperation("分页查询稿件")
@GetMapping("/page") @GetMapping("/page")
@@ -75,23 +66,8 @@ public class ManuscriptController extends BaseController {
manuscript.setUserId(loginUser.getUserId()); manuscript.setUserId(loginUser.getUserId());
} }
if (manuscriptService.save(manuscript)) { if (manuscriptService.save(manuscript)) {
// 查找该模块的审核流 // 首级审批人同样经 ReviewerResolver 解析(与其它模块同一条路径),不复制 review_user_id
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule("cms_manuscript"); reviewFlowStarterService.startReview("cms_manuscript", manuscript.getId());
if (reviewFlowConfig != null) {
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
if (reviewFlow != null) {
List<ReviewFlow> 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);
}
}
}
return success("添加成功"); return success("添加成功");
} }
return fail("添加失败"); return fail("添加失败");
@@ -3,7 +3,6 @@ package com.gxwebsoft.common.core.security;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@@ -17,12 +16,15 @@ import java.util.List;
* <ul> * <ul>
* <li>下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;</li> * <li>下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;</li>
* <li>内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}: * <li>内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}:
* 默认公开({@code file.public-preview=true}),否则现有公开页面的配图会整片变成需要 token;</li> * <b>默认也需要登录</b>({@code file.public-preview} 默认 false)。上传一律落在
* <li>其余文件接口:默认需要登录;命中 {@code file.public-dirs} 的目录额外公开。</li> * {@code /yyyyMMdd/} 日期目录,日期目录正好是两段路径,所以默认公开等于把所有上传文件公开;</li>
* <li>公开页面引用的文件必须把所在目录列进 {@code file.public-dirs} 白名单,
* 否则匿名访问会被拒(表现为 401/403 的空图)。白名单是显式声明,默认空。</li>
* </ul> * </ul>
* *
* <p>规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求, * <p>规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求,
* 因此可以在单元测试里直接验证。 * 因此可以在单元测试里直接验证模式的<b>内容与顺序</b>——顺序恰恰是这条改造的关键:
* 下载必须在公开模式之前注册,公开模式必须在 {@code GET /**} 兜底之前注册。
* *
* @author Codex * @author Codex
* @since 2026-11-03 * @since 2026-11-03
@@ -50,20 +52,18 @@ public class FileAccessRules {
*/ */
private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**"; private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**";
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/** /**
* 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠 * 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠
*/ */
private final List<String> publicDirs; private final List<String> publicDirs;
/** /**
* 内联资源(预览 + 缩略图)是否默认公开 * 内联资源(预览 + 缩略图)是否默认公开;默认 false,只有显式打开才整体放行
*/ */
private final boolean publicPreview; private final boolean publicPreview;
public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs, 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.publicDirs = normalize(publicDirs);
this.publicPreview = publicPreview; this.publicPreview = publicPreview;
} }
@@ -71,7 +71,8 @@ public class FileAccessRules {
/** /**
* 匿名放行的文件路径模式。 * 匿名放行的文件路径模式。
* *
* <p>内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。 * <p>默认({@code public-preview=false})只有 {@code file.public-dirs} 白名单里的目录;
* 显式打开内联公开时才额外放行预览与缩略图,且排在白名单之前。
* *
* @return antMatchers 模式,没有任何公开项时返回空列表 * @return antMatchers 模式,没有任何公开项时返回空列表
*/ */
@@ -104,31 +105,6 @@ public class FileAccessRules {
return Collections.singletonList(DOWNLOAD_PATTERN); 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<String> publicDirPatterns() { private List<String> publicDirPatterns() {
if (publicDirs.isEmpty()) { if (publicDirs.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
@@ -0,0 +1,26 @@
package com.gxwebsoft.gxmu.constant;
/**
* 审核待办(gxmu_review_list.status)的状态取值。
*
* <p>这套取值与申报记录的 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() {
}
}
@@ -15,6 +15,7 @@ import com.gxwebsoft.gxmu.param.QmgcFormParam;
import com.gxwebsoft.gxmu.service.QmgcFormService; import com.gxwebsoft.gxmu.service.QmgcFormService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.model.DeclareCheckResult; import com.gxwebsoft.gxmu.model.DeclareCheckResult;
import com.gxwebsoft.gxmu.service.CollegeResolver;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.gxwebsoft.gxmu.util.GxmuQueryHelper; import com.gxwebsoft.gxmu.util.GxmuQueryHelper;
@@ -42,6 +43,8 @@ public class QmgcFormController extends BaseController {
@Resource @Resource
private QmgcFormService qmgcFormService; private QmgcFormService qmgcFormService;
@Resource @Resource
private CollegeResolver collegeResolver;
@Resource
private ReviewFlowStarterService reviewFlowStarterService; private ReviewFlowStarterService reviewFlowStarterService;
@Resource @Resource
private DeclareGuardService declareGuardService; private DeclareGuardService declareGuardService;
@@ -145,6 +148,9 @@ public class QmgcFormController extends BaseController {
if (loginUser != null) { if (loginUser != null) {
qmgcForm.setUserId(loginUser.getUserId()); qmgcForm.setUserId(loginUser.getUserId());
} }
// 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错):
// 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量
qmgcForm.setCollegeId(collegeResolver.fillIfAbsent(qmgcForm.getCollegeId(), qmgcForm.getUserId()));
DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_qmgc_form", DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_qmgc_form",
qmgcForm.getDeclareId(), qmgcForm.getYear(), qmgcForm.getUserId()); qmgcForm.getDeclareId(), qmgcForm.getYear(), qmgcForm.getUserId());
if (declareCheck.isRejected()) { if (declareCheck.isRejected()) {
@@ -1,33 +1,26 @@
package com.gxwebsoft.gxmu.controller; package com.gxwebsoft.gxmu.controller;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator;
import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam; import com.gxwebsoft.gxmu.param.ReviewFlowParam;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult; 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.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.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.Serializable; import java.util.Collections;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set;
/** /**
* 审核流控制器 * 审核流控制器
* *
* <p>层级连续性(从 1 连续升序)是审批链的业务规则,校验落在
* {@link ReviewFlowService#validateLevelsOnSave} 等方法的实现里;controller 只负责在写操作前调用一次。
*
* @author LX * @author LX
* @since 2026-03-18 10:37:18 * @since 2026-03-18 10:37:18
*/ */
@@ -69,12 +62,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("添加审核流") @ApiOperation("添加审核流")
@PostMapping() @PostMapping()
public ApiResult<?> save(@RequestBody ReviewFlow reviewFlow) { public ApiResult<?> save(@RequestBody ReviewFlow reviewFlow) {
validateLevels(reviewFlow == null ? null : java.util.Collections.singletonList(reviewFlow)); reviewFlowService.validateLevelsOnSave(reviewFlow == null ? null : Collections.singletonList(reviewFlow));
// 记录当前登录用户id
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
// if (checkByName != null) {
// return fail("该名称已存在");
// }
if (reviewFlowService.save(reviewFlow)) { if (reviewFlowService.save(reviewFlow)) {
return success("添加成功"); return success("添加成功");
} }
@@ -84,11 +72,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("修改审核流") @ApiOperation("修改审核流")
@PutMapping() @PutMapping()
public ApiResult<?> update(@RequestBody ReviewFlow reviewFlow) { public ApiResult<?> update(@RequestBody ReviewFlow reviewFlow) {
validateUpdate(reviewFlow); reviewFlowService.validateLevelsOnUpdate(reviewFlow);
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
// if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) {
// return fail("该名称已存在");
// }
if (reviewFlowService.updateById(reviewFlow)) { if (reviewFlowService.updateById(reviewFlow)) {
return success("修改成功"); return success("修改成功");
} }
@@ -98,6 +82,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("删除审核流") @ApiOperation("删除审核流")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ApiResult<?> remove(@PathVariable("id") Integer id) { public ApiResult<?> remove(@PathVariable("id") Integer id) {
reviewFlowService.validateLevelsOnRemove(Collections.singletonList(id));
if (reviewFlowService.removeById(id)) { if (reviewFlowService.removeById(id)) {
return success("删除成功"); return success("删除成功");
} }
@@ -107,7 +92,7 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("批量添加审核流") @ApiOperation("批量添加审核流")
@PostMapping("/batch") @PostMapping("/batch")
public ApiResult<?> saveBatch(@RequestBody List<ReviewFlow> list) { public ApiResult<?> saveBatch(@RequestBody List<ReviewFlow> list) {
validateLevels(list); reviewFlowService.validateLevelsOnSave(list);
if (reviewFlowService.saveBatch(list)) { if (reviewFlowService.saveBatch(list)) {
return success("添加成功"); return success("添加成功");
} }
@@ -117,145 +102,17 @@ public class ReviewFlowController extends BaseController {
@ApiOperation("批量修改审核流") @ApiOperation("批量修改审核流")
@PutMapping("/batch") @PutMapping("/batch")
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewFlow> batchParam) { public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewFlow> batchParam) {
validateBatchUpdate(batchParam); reviewFlowService.validateLevelsOnBatchUpdate(batchParam);
if (batchParam.update(reviewFlowService, "id")) { if (batchParam.update(reviewFlowService, "id")) {
return success("修改成功"); return success("修改成功");
} }
return fail("修改失败"); return fail("修改失败");
} }
/**
* 校验"保存后这条审批链的层级从 1 连续升序"(spec #58 行为约定)。
*
* <p>管理端是"先删旧节点、再逐级新增",因此单条保存时必须把库里同 title 的已有节点一起看,
* 否则逐级保存会被误判为缺级。
*/
private void validateLevels(List<ReviewFlow> incoming) {
if (incoming == null || incoming.isEmpty()) {
return;
}
List<ReviewFlow> proposed = new ArrayList<>();
Set<String> titles = new LinkedHashSet<>();
Set<Integer> 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<ReviewFlow> 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<ReviewFlow> batchParam) {
if (batchParam == null || batchParam.getData() == null
|| batchParam.getIds() == null || batchParam.getIds().isEmpty()) {
return;
}
List<Integer> ids = new ArrayList<>();
for (Serializable id : batchParam.getIds()) {
if (id instanceof Number) {
ids.add(((Number) id).intValue());
}
}
if (ids.isEmpty()) {
return;
}
List<ReviewFlow> existingRows = reviewFlowService.listByIds(ids);
if (existingRows == null || existingRows.isEmpty()) {
return;
}
List<ReviewFlow> mergedRows = new ArrayList<>();
List<String> oldTitles = new ArrayList<>();
for (ReviewFlow existing : existingRows) {
if (existing == null) {
continue;
}
mergedRows.add(merge(existing, batchParam.getData()));
oldTitles.add(existing.getTitle());
}
validateLevels(mergedRows);
Set<String> 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<Integer> excludedIds) {
List<ReviewFlow> remaining = new ArrayList<>();
List<ReviewFlow> 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("批量删除审核流") @ApiOperation("批量删除审核流")
@DeleteMapping("/batch") @DeleteMapping("/batch")
public ApiResult<?> removeBatch(@RequestBody List<Integer> ids) { public ApiResult<?> removeBatch(@RequestBody List<Integer> ids) {
reviewFlowService.validateLevelsOnRemove(ids);
if (reviewFlowService.removeByIds(ids)) { if (reviewFlowService.removeByIds(ids)) {
return success("删除成功"); return success("删除成功");
} }
@@ -3,19 +3,15 @@ package com.gxwebsoft.gxmu.controller;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.core.Constants; import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.constant.ReviewStatus;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.service.ReviewListService;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.param.ReviewListParam; import com.gxwebsoft.gxmu.param.ReviewListParam;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult; 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.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
@@ -23,11 +19,16 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.Serializable;
import java.util.List; import java.util.List;
/** /**
* 审核列表控制器 * 审核列表控制器
* *
* <p>审批动作(通过 / 驳回)的归属校验、状态校验留在这里(HTTP 层的准入判断);
* 通过后「推进到下一级」属于审批链业务规则,交给 {@link ReviewFlowStarterService#advanceReview},
* 审批人一律经 {@link com.gxwebsoft.gxmu.service.ReviewerResolver} 解析,不再复制 review_user_id。
*
* @author LX * @author LX
* @since 2026-03-18 15:50:51 * @since 2026-03-18 15:50:51
*/ */
@@ -38,11 +39,9 @@ public class ReviewListController extends BaseController {
@Resource @Resource
private ReviewListService reviewListService; private ReviewListService reviewListService;
@Resource @Resource
private ReviewFlowConfigService reviewFlowConfigService;
@Resource
private ReviewFlowService reviewFlowService;
@Resource
private DeclareGuardService declareGuardService; private DeclareGuardService declareGuardService;
@Resource
private ReviewFlowStarterService reviewFlowStarterService;
@ApiOperation("分页查询审核列表") @ApiOperation("分页查询审核列表")
@GetMapping("/page") @GetMapping("/page")
@@ -100,8 +99,14 @@ public class ReviewListController extends BaseController {
@ApiOperation("修改审核列表") @ApiOperation("修改审核列表")
@PutMapping() @PutMapping()
public ApiResult<?> update(@RequestBody ReviewList reviewList) { 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) { if (denied != null) {
return denied; return denied;
} }
@@ -110,34 +115,10 @@ public class ReviewListController extends BaseController {
return denied; return denied;
} }
if (reviewListService.updateById(reviewList)) { if (reviewListService.updateById(reviewList)) {
// 通过进入下一个 // 通过则推进到下一级;下一级的审批人由审批链配置解析,不复制本级的 review_user_id
if (Integer.valueOf(1).equals(reviewList.getStatus())) { if (Integer.valueOf(ReviewStatus.APPROVED).equals(reviewList.getStatus())) {
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule()); reviewFlowStarterService.advanceReview(reviewList.getModule(), reviewList.getPk(),
if (reviewFlowConfig != null) { existing == null ? null : existing.getSortNumber());
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
if (reviewFlow != null) {
List<ReviewFlow> 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 ()
// }
// }
}
}
}
} }
declareGuardService.syncReviewStatus(reviewList.getModule(), reviewList.getPk()); declareGuardService.syncReviewStatus(reviewList.getModule(), reviewList.getPk());
return success("修改成功"); return success("修改成功");
@@ -166,11 +147,15 @@ public class ReviewListController extends BaseController {
@ApiOperation("批量修改审核列表") @ApiOperation("批量修改审核列表")
@PutMapping("/batch") @PutMapping("/batch")
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewList> batchParam) { public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewList> batchParam) {
// 批量审批逐行校验归属,只要有一行不是自己的就整体拒绝,避免部分落库 // 批量审批逐行校验归属与状态,只要有一行不满足就整体拒绝,避免部分落库
ApiResult<?> denied = denyIfBatchNotAssignee(batchParam); ApiResult<?> denied = denyIfBatchNotAssignee(batchParam);
if (denied != null) { if (denied != null) {
return denied; return denied;
} }
denied = denyIfBatchAlreadyHandled(batchParam);
if (denied != null) {
return denied;
}
denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData()); denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData());
if (denied != null) { if (denied != null) {
return denied; return denied;
@@ -191,7 +176,10 @@ public class ReviewListController extends BaseController {
* @return null 表示校验通过,否则返回 403 结果 * @return null 表示校验通过,否则返回 403 结果
*/ */
private ApiResult<?> denyIfNotAssignee(Integer id) { 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(); Integer loginUserId = getLoginUserId();
if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) { if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) {
return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG); return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG);
@@ -199,6 +187,25 @@ public class ReviewListController extends BaseController {
return null; return null;
} }
/**
* 当前节点必须仍是「待审核」才允许提交审批动作。
*
* <p>重复通过会给同一申报再造一组待办(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) { if (batchParam == null || batchParam.getIds() == null) {
return 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); ApiResult<?> denied = denyIfNotAssignee(id instanceof Number ? ((Number) id).intValue() : null);
if (denied != null) { if (denied != null) {
return denied; return denied;
@@ -218,6 +225,26 @@ public class ReviewListController extends BaseController {
return null; return null;
} }
/**
* 批量审批的状态校验:任意一行已处理就整体拒绝。
*
* @param batchParam 批量参数
* @return null 表示全部仍是待审核,否则返回可读的失败结果
*/
private ApiResult<?> denyIfBatchAlreadyHandled(BatchParam<ReviewList> 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 表示校验通过,否则返回可读的失败结果 * @return null 表示校验通过,否则返回可读的失败结果
*/ */
private ApiResult<?> denyIfRejectWithoutContent(ReviewList data) { 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 fail("驳回必须填写审核意见");
} }
return null; return null;
@@ -236,7 +264,7 @@ public class ReviewListController extends BaseController {
if (batchParam == null || batchParam.getIds() == null) { if (batchParam == null || batchParam.getIds() == null) {
return; return;
} }
for (java.io.Serializable id : batchParam.getIds()) { for (Serializable id : batchParam.getIds()) {
ReviewList saved = reviewListService.getById(id); ReviewList saved = reviewListService.getById(id);
if (saved != null) { if (saved != null) {
declareGuardService.syncReviewStatus(saved.getModule(), saved.getPk()); declareGuardService.syncReviewStatus(saved.getModule(), saved.getPk());
@@ -16,6 +16,7 @@ import com.gxwebsoft.gxmu.entity.SjqnForm;
import com.gxwebsoft.gxmu.param.SjqnFormParam; import com.gxwebsoft.gxmu.param.SjqnFormParam;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.model.DeclareCheckResult; import com.gxwebsoft.gxmu.model.DeclareCheckResult;
import com.gxwebsoft.gxmu.service.CollegeResolver;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.gxwebsoft.gxmu.service.SjqnFormService; import com.gxwebsoft.gxmu.service.SjqnFormService;
@@ -46,6 +47,8 @@ public class SjqnFormController extends BaseController {
@Resource @Resource
private SjqnFormService sjqnFormService; private SjqnFormService sjqnFormService;
@Resource @Resource
private CollegeResolver collegeResolver;
@Resource
private ReviewFlowStarterService reviewFlowStarterService; private ReviewFlowStarterService reviewFlowStarterService;
@Resource @Resource
private DeclareGuardService declareGuardService; private DeclareGuardService declareGuardService;
@@ -162,6 +165,9 @@ public class SjqnFormController extends BaseController {
if (loginUser != null) { if (loginUser != null) {
sjqnForm.setUserId(loginUser.getUserId()); sjqnForm.setUserId(loginUser.getUserId());
} }
// 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错):
// 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量
sjqnForm.setCollegeId(collegeResolver.fillIfAbsent(sjqnForm.getCollegeId(), sjqnForm.getUserId()));
DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_sjqn", DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_sjqn",
sjqnForm.getDeclareId(), sjqnForm.getYear(), sjqnForm.getUserId()); sjqnForm.getDeclareId(), sjqnForm.getYear(), sjqnForm.getUserId());
if (declareCheck.isRejected()) { if (declareCheck.isRejected()) {
@@ -11,6 +11,7 @@ import com.gxwebsoft.gxmu.entity.TzbcyForm;
import com.gxwebsoft.gxmu.model.TzbcyStatisticsResult; import com.gxwebsoft.gxmu.model.TzbcyStatisticsResult;
import com.gxwebsoft.gxmu.param.TzbcyFormParam; import com.gxwebsoft.gxmu.param.TzbcyFormParam;
import com.gxwebsoft.gxmu.model.DeclareCheckResult; import com.gxwebsoft.gxmu.model.DeclareCheckResult;
import com.gxwebsoft.gxmu.service.CollegeResolver;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -38,6 +39,8 @@ public class TzbcyFormController extends BaseController {
@Resource @Resource
private TzbcyFormService tzbcyFormService; private TzbcyFormService tzbcyFormService;
@Resource @Resource
private CollegeResolver collegeResolver;
@Resource
private ReviewFlowStarterService reviewFlowStarterService; private ReviewFlowStarterService reviewFlowStarterService;
@Resource @Resource
private DeclareGuardService declareGuardService; private DeclareGuardService declareGuardService;
@@ -99,6 +102,9 @@ public class TzbcyFormController extends BaseController {
if (loginUser != null) { if (loginUser != null) {
tzbcyForm.setUserId(loginUser.getUserId()); tzbcyForm.setUserId(loginUser.getUserId());
} }
// 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错):
// 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量
tzbcyForm.setCollegeId(collegeResolver.fillIfAbsent(tzbcyForm.getCollegeId(), tzbcyForm.getUserId()));
DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_tzbcy_form", DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_tzbcy_form",
tzbcyForm.getDeclareId(), tzbcyForm.getYear(), tzbcyForm.getUserId()); tzbcyForm.getDeclareId(), tzbcyForm.getYear(), tzbcyForm.getUserId());
if (declareCheck.isRejected()) { if (declareCheck.isRejected()) {
@@ -27,6 +27,7 @@ import com.gxwebsoft.gxmu.param.WxxzxFormParam;
import com.gxwebsoft.gxmu.service.QmgcFormService; import com.gxwebsoft.gxmu.service.QmgcFormService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.model.DeclareCheckResult; import com.gxwebsoft.gxmu.model.DeclareCheckResult;
import com.gxwebsoft.gxmu.service.CollegeResolver;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.gxwebsoft.gxmu.service.SjqnFormService; import com.gxwebsoft.gxmu.service.SjqnFormService;
@@ -100,6 +101,8 @@ public class WxxzxFormController extends BaseController {
@Resource @Resource
private WxxzxFormService wxxzxFormService; private WxxzxFormService wxxzxFormService;
@Resource @Resource
private CollegeResolver collegeResolver;
@Resource
private SjqnFormService sjqnFormService; private SjqnFormService sjqnFormService;
@Resource @Resource
private SjtbzbsjFormService sjtbzbsjFormService; private SjtbzbsjFormService sjtbzbsjFormService;
@@ -254,6 +257,9 @@ public class WxxzxFormController extends BaseController {
if (loginUser != null) { if (loginUser != null) {
wxxzxForm.setUserId(loginUser.getUserId()); wxxzxForm.setUserId(loginUser.getUserId());
} }
// 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错):
// 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量
wxxzxForm.setCollegeId(collegeResolver.fillIfAbsent(wxxzxForm.getCollegeId(), wxxzxForm.getUserId()));
DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_wxxzx_project", DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_wxxzx_project",
wxxzxForm.getDeclareId(), wxxzxForm.getYear(), wxxzxForm.getUserId()); wxxzxForm.getDeclareId(), wxxzxForm.getYear(), wxxzxForm.getUserId());
if (declareCheck.isRejected()) { if (declareCheck.isRejected()) {
@@ -117,6 +117,9 @@ public class QmgcForm implements Serializable {
@ApiModelProperty("用户ID") @ApiModelProperty("用户ID")
private Integer userId; private Integer userId;
@ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空")
private Integer collegeId;
@ApiModelProperty("是否删除") @ApiModelProperty("是否删除")
@TableLogic @TableLogic
private Integer deleted; private Integer deleted;
@@ -92,6 +92,9 @@ public class SjqnForm implements Serializable {
@ApiModelProperty("用户ID") @ApiModelProperty("用户ID")
private Integer userId; private Integer userId;
@ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空")
private Integer collegeId;
@ApiModelProperty("是否删除") @ApiModelProperty("是否删除")
@TableLogic @TableLogic
private Integer deleted; private Integer deleted;
@@ -114,6 +114,9 @@ public class TzbcyForm implements Serializable {
@ApiModelProperty("用户ID") @ApiModelProperty("用户ID")
private Integer userId; private Integer userId;
@ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空")
private Integer collegeId;
@ApiModelProperty("是否删除") @ApiModelProperty("是否删除")
@TableLogic @TableLogic
private Integer deleted; private Integer deleted;
@@ -143,6 +143,9 @@ public class WxxzxForm implements Serializable {
@ApiModelProperty("用户ID") @ApiModelProperty("用户ID")
private Integer userId; private Integer userId;
@ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空")
private Integer collegeId;
@ApiModelProperty("是否删除") @ApiModelProperty("是否删除")
@TableLogic @TableLogic
private Integer deleted; private Integer deleted;
@@ -26,6 +26,10 @@ import java.util.Set;
* <li>注册表引用的业务表在库里真实存在。</li> * <li>注册表引用的业务表在库里真实存在。</li>
* </ol> * </ol>
* *
* <p>反方向只告警不阻断:{@code gxmu_review_flow_config} 里有、注册表里没有的 module
* 打 warn(这种模块的标题会回退成裸 module 串、也不会出现在模块下拉里,但存量数据仍要能查)。
* 不阻断启动是刻意的——存量脏数据不该挡住上线,但必须留下可检索的线索。
*
* <p>允许 gxmu.module-registry.strict=false 关闭校验,便于无库本地启动; * <p>允许 gxmu.module-registry.strict=false 关闭校验,便于无库本地启动;
* 生产环境必须保持默认的 true。 * 生产环境必须保持默认的 true。
* *
@@ -51,6 +55,34 @@ public class ModuleRegistryIntegrityChecker implements ApplicationRunner {
check(); check();
} }
/**
* 反向校验:审批链配置里有、注册表里没有的 module 打 warn,不阻断启动。
*
* <p>这类模块的标题会回退成裸 module 串、也不会出现在模块下拉里,
* 但存量申报记录仍要能查——所以只提示补注册表行,不抛异常。
*
* @param modules 注册表里的全部模块
*/
private void warnModulesMissingFromRegistry(List<ModuleRegistry> modules) {
Set<String> registered = new HashSet<>();
for (ModuleRegistry item : modules) {
if (item.getModule() != null) {
registered.add(item.getModule());
}
}
List<String> configured = moduleRegistryMapper.selectConfiguredModules();
if (configured == null || configured.isEmpty()) {
return;
}
for (String module : configured) {
if (module != null && !module.isEmpty() && !registered.contains(module)) {
log.warn("gxmu_review_flow_config 里的模块 {} 未在 gxmu_module_registry 登记:"
+ "该模块的标题会回退成 module 原串、也不会出现在模块下拉里;"
+ "请补一行注册表数据(enabled 按是否新开申报入口决定)", module);
}
}
}
/** /**
* 执行校验;不一致时抛 {@link IllegalStateException} 让启动失败。 * 执行校验;不一致时抛 {@link IllegalStateException} 让启动失败。
*/ */
@@ -91,6 +123,7 @@ public class ModuleRegistryIntegrityChecker implements ApplicationRunner {
throw new IllegalStateException("模块注册表引用的业务表不存在:" + String.join(", ", missing) throw new IllegalStateException("模块注册表引用的业务表不存在:" + String.join(", ", missing)
+ "。请先执行建表脚本或修正注册表数据;本地无库启动时可设置 gxmu.module-registry.strict=false"); + "。请先执行建表脚本或修正注册表数据;本地无库启动时可设置 gxmu.module-registry.strict=false");
} }
warnModulesMissingFromRegistry(modules);
log.info("模块注册表一致性校验通过:{} 个模块,{} 张业务表", modules.size(), tableNames.size()); log.info("模块注册表一致性校验通过:{} 个模块,{} 张业务表", modules.size(), tableNames.size());
} }
} }
@@ -3,21 +3,27 @@ package com.gxwebsoft.gxmu.module.controller;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry; import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import com.gxwebsoft.gxmu.module.model.ModuleRegistryDTO;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* 模块注册表控制器 * 模块注册表控制器
* *
* <p>前端启动时一次性拉取启用模块清单并缓存,模块下拉、详情页、导出模板都从这里驱动。 * <p>前端启动时一次性拉取模块清单并缓存:默认只返回启用模块(新链的模块下拉、申报入口);
* {@code all=true} 返回全部模块(含停用,审核列表的模块筛选要能捞出停用模块的存量记录)。
* *
* @author Codex * @author Codex
* @since 2026-11-10 * @since 2026-11-10
@@ -30,10 +36,16 @@ public class ModuleRegistryController extends BaseController {
@Resource @Resource
private ModuleRegistryService moduleRegistryService; private ModuleRegistryService moduleRegistryService;
@ApiOperation("启用模块清单") @ApiOperation("模块清单:默认只返回启用模块,all=true 返回全部(含停用)")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
@GetMapping("/list") @GetMapping("/list")
public ApiResult<List<ModuleRegistry>> list() { public ApiResult<List<ModuleRegistryDTO>> list(
return success(moduleRegistryService.listEnabled()); @ApiParam("是否返回全部模块(含停用):true 返回全部,默认只返回启用")
@RequestParam(value = "all", required = false, defaultValue = "false") boolean all) {
List<ModuleRegistry> modules = all ? moduleRegistryService.listAll() : moduleRegistryService.listEnabled();
if (modules == null || modules.isEmpty()) {
return success(new ArrayList<>());
}
return success(modules.stream().map(ModuleRegistryDTO::of).collect(Collectors.toList()));
} }
} }
@@ -46,6 +46,9 @@ public class ModuleRegistry implements Serializable {
@ApiModelProperty(value = "导出模板标识,空表示走默认导出") @ApiModelProperty(value = "导出模板标识,空表示走默认导出")
private String exportTemplate; private String exportTemplate;
@ApiModelProperty(value = "业务表是否有学院标识列:0否 1是(决定审批人解析是否读 college_id)")
private Integer hasCollege;
@ApiModelProperty(value = "是否启用:0否 1是") @ApiModelProperty(value = "是否启用:0否 1是")
private Integer enabled; private Integer enabled;
@@ -28,4 +28,15 @@ public interface ModuleRegistryMapper extends BaseMapper<ModuleRegistry> {
+ "<foreach collection='names' item='name' open='(' separator=',' close=')'>#{name}</foreach>" + "<foreach collection='names' item='name' open='(' separator=',' close=')'>#{name}</foreach>"
+ "</script>") + "</script>")
List<String> selectExistingTableNames(@Param("names") Collection<String> names); List<String> selectExistingTableNames(@Param("names") Collection<String> names);
/**
* 审批链配置里出现过的模块名,用于反向校验「有审批链但注册表没登记」的模块。
*
* <p>只用于启动期告警,不阻断启动:存量脏数据不该挡住上线。
*
* @return 审批链配置里的模块名(去重)
*/
@Select("SELECT DISTINCT module FROM gxmu_review_flow_config "
+ "WHERE deleted = 0 AND module IS NOT NULL AND module <> ''")
List<String> selectConfiguredModules();
} }
@@ -0,0 +1,65 @@
package com.gxwebsoft.gxmu.module.model;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 模块注册表的对外视图(spec #58 API 契约)。
*
* <p>只暴露前端需要的元数据;tenantId / userId / createTime / updateTime 是库内字段,
* 对前端无意义,不随接口外泄。
*
* @author Codex
* @since 2026-11-11
*/
@Data
@ApiModel(value = "ModuleRegistryDTO对象", description = "模块注册表对外视图")
public class ModuleRegistryDTO implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "模块标识,同时是业务表名")
private String module;
@ApiModelProperty(value = "业务表名")
private String tableName;
@ApiModelProperty(value = "模块标题")
private String title;
@ApiModelProperty(value = "详情页配置键")
private String detailKey;
@ApiModelProperty(value = "导出模板标识,空表示走默认导出")
private String exportTemplate;
@ApiModelProperty(value = "是否启用:0否 1是")
private Integer enabled;
@ApiModelProperty(value = "排序号,小的在前")
private Integer sortNumber;
/**
* 由注册表行构造对外视图。
*
* @param item 注册表行,可为空
* @return 对外视图,入参为空时返回 null
*/
public static ModuleRegistryDTO of(ModuleRegistry item) {
if (item == null) {
return null;
}
ModuleRegistryDTO dto = new ModuleRegistryDTO();
dto.setModule(item.getModule());
dto.setTableName(item.getTableName());
dto.setTitle(item.getTitle());
dto.setDetailKey(item.getDetailKey());
dto.setExportTemplate(item.getExportTemplate());
dto.setEnabled(item.getEnabled());
dto.setSortNumber(item.getSortNumber());
return dto;
}
}
@@ -5,13 +5,12 @@ import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 模块注册表服务:模块元数据的唯一读取入口。 * 模块注册表服务:模块元数据的唯一读取入口。
* *
* <p>调用方不要再维护模块名常量或标题映射,一律从这里取; * <p>调用方不要再维护模块名常量或标题映射,一律从这里取;
* 注册表里查不到的历史 module 由调用方回退 module 原串,不抛错。 * 注册表里查不到的历史 module 由 {@link #titleOf(Map, String)} 回退 module 原串,不抛错。
* *
* @author Codex * @author Codex
* @since 2026-11-10 * @since 2026-11-10
@@ -33,21 +32,11 @@ public interface ModuleRegistryService extends IService<ModuleRegistry> {
*/ */
ModuleRegistry getByModule(String module); ModuleRegistry getByModule(String module);
/**
* 模块标题;未注册或标题为空时回退 module 原串(module 为 null 时返回 null)。
*/
String titleOf(String module);
/** /**
* 全部模块的 module -> title 映射(含停用),供列表页一次性填充标题。 * 全部模块的 module -> title 映射(含停用),供列表页一次性填充标题。
*/ */
Map<String, String> titleMap(); Map<String, String> titleMap();
/**
* 注册表里存在的全部 module(含停用)。
*/
Set<String> allModules();
/** /**
* module 是否在注册表里存在(含停用)。 * module 是否在注册表里存在(含停用)。
* *
@@ -55,4 +44,33 @@ public interface ModuleRegistryService extends IService<ModuleRegistry> {
* 所以停用模块也必须返回 true。 * 所以停用模块也必须返回 true。
*/ */
boolean containsModule(String module); boolean containsModule(String module);
/**
* 该模块的业务表是否有 college_id(注册表 has_college = 1)。
*
* <p>审批人解析要读申报记录的学院标识,但只有部分业务表有这一列;
* 哪张表有由注册表决定,后端不再维护模块白名单(ADR 0006:新增表只改注册表一行)。
*
* @param module 模块编码
* @return true 表示可以按 college_id 读取学院上下文
*/
boolean hasCollege(String module);
/**
* 模块标题的唯一回退口径:注册表里有标题用标题,否则回退 module 原串。
*
* <p>放在接口上是因为注册表是模块元数据的唯一来源,而调用方(审核列表、审批链配置)
* 已经批量取了 {@link #titleMap()},不该为了一个标题再回查一次库。
*
* @param titleMap 模块标题映射,可为空
* @param module 模块编码,可为空
* @return 展示用标题;module 为空时返回 null
*/
static String titleOf(Map<String, String> titleMap, String module) {
if (module == null) {
return null;
}
String title = titleMap == null ? null : titleMap.get(module);
return title == null || title.isEmpty() ? module : title;
}
} }
@@ -8,7 +8,6 @@ import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -27,6 +26,7 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
implements ModuleRegistryService { implements ModuleRegistryService {
private static final int ENABLED = 1; private static final int ENABLED = 1;
private static final int HAS_COLLEGE = 1;
@Override @Override
public List<ModuleRegistry> listEnabled() { public List<ModuleRegistry> listEnabled() {
@@ -35,6 +35,13 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 全部未删除模块。
*
* <p>排序只在 SQL 里做一次:{@code sort_number} 升序(MySQL 把 NULL 排最前,
* 与「没设排序号的模块排在最前面」的口径一致),再按 {@code id} 升序保证稳定。
* 内存里不再二次排序,避免 SQL 与 Java 两套 NULL 口径(SQL 前、Java 后)互相打架。
*/
@Override @Override
public List<ModuleRegistry> listAll() { public List<ModuleRegistry> listAll() {
// 用字符串列名而非 Lambda:注册表服务是纯读路径,且要在无 MyBatis 上下文的单测里可用 // 用字符串列名而非 Lambda:注册表服务是纯读路径,且要在无 MyBatis 上下文的单测里可用
@@ -44,7 +51,7 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
} }
return list.stream().sorted(sortOrder()).collect(Collectors.toList()); return list;
} }
@Override @Override
@@ -57,18 +64,6 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
.last("limit 1")); .last("limit 1"));
} }
@Override
public String titleOf(String module) {
if (module == null) {
return null;
}
ModuleRegistry item = getByModule(module);
if (item == null || item.getTitle() == null || item.getTitle().isEmpty()) {
return module;
}
return item.getTitle();
}
@Override @Override
public Map<String, String> titleMap() { public Map<String, String> titleMap() {
Map<String, String> titleMap = new LinkedHashMap<>(); Map<String, String> titleMap = new LinkedHashMap<>();
@@ -82,24 +77,22 @@ public class ModuleRegistryServiceImpl extends ServiceImpl<ModuleRegistryMapper,
} }
@Override @Override
public Set<String> allModules() { public boolean containsModule(String module) {
if (module == null || module.isEmpty()) {
return false;
}
Set<String> modules = new LinkedHashSet<>(); Set<String> modules = new LinkedHashSet<>();
for (ModuleRegistry item : listAll()) { for (ModuleRegistry item : listAll()) {
if (item.getModule() != null) { if (item.getModule() != null) {
modules.add(item.getModule()); modules.add(item.getModule());
} }
} }
return modules; return modules.contains(module);
} }
@Override @Override
public boolean containsModule(String module) { public boolean hasCollege(String module) {
return module != null && !module.isEmpty() && allModules().contains(module); ModuleRegistry item = getByModule(module);
} return item != null && item.getHasCollege() != null && item.getHasCollege() == HAS_COLLEGE;
private Comparator<ModuleRegistry> sortOrder() {
return Comparator.comparingInt((ModuleRegistry item) ->
item.getSortNumber() == null ? Integer.MAX_VALUE : item.getSortNumber())
.thenComparingInt(item -> item.getId() == null ? Integer.MAX_VALUE : item.getId());
} }
} }
@@ -0,0 +1,34 @@
package com.gxwebsoft.gxmu.service;
/**
* 学院标识解析:把登录用户映射到申报记录的 college_id(spec #58 用户故事 2)。
*
* <p>申报表上的 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 {
/**
* 按登录用户查学院标识。
*
* <p>同一用户有多条学籍时以「在校」那条为准;学院不唯一说明无法判断,返回 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);
}
@@ -8,6 +8,7 @@ import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
/** /**
* 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。 * 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。
@@ -69,13 +70,6 @@ public final class ReviewFlowLevelValidator {
} }
private static String join(List<Integer> levels) { private static String join(List<Integer> levels) {
StringBuilder builder = new StringBuilder(); return levels.stream().map(String::valueOf).collect(Collectors.joining(", "));
for (int index = 0; index < levels.size(); index++) {
if (index > 0) {
builder.append(", ");
}
builder.append(levels.get(index));
}
return builder.toString();
} }
} }
@@ -1,10 +1,12 @@
package com.gxwebsoft.gxmu.service; package com.gxwebsoft.gxmu.service;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam; import com.gxwebsoft.gxmu.param.ReviewFlowParam;
import java.util.Collection;
import java.util.List; import java.util.List;
/** /**
@@ -42,4 +44,40 @@ public interface ReviewFlowService extends IService<ReviewFlow> {
ReviewFlow getByTitle(String title); ReviewFlow getByTitle(String title);
List<ReviewFlow> listByTitle(String title); List<ReviewFlow> listByTitle(String title);
/**
* 保存前校验:保存后这条审批链的层级必须从 1 连续升序(spec #58 行为约定)。
*
* <p>管理端是"先删旧节点、再逐级新增",所以单条保存时必须把库里同 title 的已有节点一起看,
* 否则逐级保存会被误判为缺级。不合法时抛
* {@link com.gxwebsoft.common.core.exception.BusinessException}。
*
* @param incoming 本次提交的节点(可含多条审批链)
*/
void validateLevelsOnSave(List<ReviewFlow> incoming);
/**
* 修改单条节点前校验:未传的字段沿用库里原值,再校验受影响链的连续性;
* 若改了 title,还要校验原链剩下的节点仍连续。
*
* @param incoming 提交的节点
*/
void validateLevelsOnUpdate(ReviewFlow incoming);
/**
* 批量修改前校验:id 集合里每行都按同样字段更新,合并后校验连续性,
* 并校验改名后原链剩余部分仍连续。
*
* @param batchParam 批量参数
*/
void validateLevelsOnBatchUpdate(BatchParam<ReviewFlow> batchParam);
/**
* 删除前校验:删除后受影响审批链剩下的节点必须仍从 1 连续升序。
*
* <p>删除前就已经不连续的链不做校验(否则存量脏数据会变成"永远删不掉")。
*
* @param removedIds 待删除的节点 id
*/
void validateLevelsOnRemove(Collection<Integer> removedIds);
} }
@@ -23,4 +23,16 @@ public interface ReviewFlowStarterService {
* @param pk 业务主键 * @param pk 业务主键
*/ */
void restartReviewWhenLastRejected(String module, Integer pk); void restartReviewWhenLastRejected(String module, Integer pk);
/**
* 当前一级通过后推进到下一级:审批人同样走 {@link ReviewerResolver} 解析,不复制上一级的 review_user_id。
*
* <p>解析不出审批人时抛可读的业务异常且一个节点都不写(宁可卡住,也不生成无人认领的待办);
* 已经是最后一级则什么都不做,链条自然结束。
*
* @param module 模块编码
* @param pk 业务主键
* @param currentSortNumber 当前已通过节点的顺序号(首级为 0,与 review_list.sort_number 同口径)
*/
void advanceReview(String module, Integer pk, Integer currentSortNumber);
} }
@@ -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 是学院标识的唯一权威来源。
*
* <p>口径与一次性回填脚本 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<GxmuStudent> students = gxmuStudentMapper.selectList(new LambdaQueryWrapper<GxmuStudent>()
.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);
}
}
@@ -2,6 +2,7 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.gxmu.constant.ReviewStatus;
import com.gxwebsoft.gxmu.entity.Declare; import com.gxwebsoft.gxmu.entity.Declare;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
@@ -160,7 +161,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return; return;
} }
boolean pending = nodes.stream() boolean pending = nodes.stream()
.anyMatch(node -> node.getStatus() != null && node.getStatus() == 0); .anyMatch(node -> node.getStatus() != null && node.getStatus() == ReviewStatus.PENDING);
if (pending) { if (pending) {
declareRecordMapper.updateStatus(module, recordId, STATUS_PENDING, null); declareRecordMapper.updateStatus(module, recordId, STATUS_PENDING, null);
return; return;
@@ -181,7 +182,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return; return;
} }
declareRecordMapper.updateStatus(module, recordId, declareRecordMapper.updateStatus(module, recordId,
last.getStatus() == 2 ? STATUS_REJECTED : STATUS_PASSED, null); last.getStatus() == ReviewStatus.REJECTED ? STATUS_REJECTED : STATUS_PASSED, null);
} }
@Override @Override
@@ -200,7 +201,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService {
return "只有待审核的申报可以撤回"; return "只有待审核的申报可以撤回";
} }
boolean approved = reviewListService.listByModuleNPK(module, recordId).stream() 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) { if (approved) {
return "已有审核通过的记录不能撤回,请联系管理员处理"; return "已有审核通过的记录不能撤回,请联系管理员处理";
} }
@@ -99,17 +99,9 @@ public class ReviewFlowConfigServiceImpl extends ServiceImpl<ReviewFlowConfigMap
Map<String, String> moduleTitleMap = moduleRegistryService.titleMap(); Map<String, String> moduleTitleMap = moduleRegistryService.titleMap();
for (ReviewFlowConfig item : list) { for (ReviewFlowConfig item : list) {
item.setFlowName(flowNameMap.get(item.getFlowId())); 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())); item.setOrganizationName(organizationNameMap.get(item.getOrganizationId()));
} }
} }
private String moduleTitleOf(Map<String, String> moduleTitleMap, String module) {
if (module == null) {
return null;
}
String title = moduleTitleMap.get(module);
return title == null || title.isEmpty() ? module : title;
}
} }
@@ -2,10 +2,12 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; 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.entity.Organization;
import com.gxwebsoft.common.system.service.OrganizationService; import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.common.system.service.UserService; import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.gxmu.mapper.ReviewFlowMapper; import com.gxwebsoft.gxmu.mapper.ReviewFlowMapper;
import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator;
import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.param.ReviewFlowParam; import com.gxwebsoft.gxmu.param.ReviewFlowParam;
@@ -14,7 +16,13 @@ import com.gxwebsoft.common.core.web.PageResult;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; 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.List;
import java.util.Objects;
import java.util.Set;
/** /**
* 审核流Service实现 * 审核流Service实现
@@ -91,4 +99,180 @@ public class ReviewFlowServiceImpl extends ServiceImpl<ReviewFlowMapper, ReviewF
); );
} }
// ------------------------------------------------------------------
// 层级校验:审批链保存/删除的业务规则落在这里,controller 只负责调用
// ------------------------------------------------------------------
@Override
public void validateLevelsOnSave(List<ReviewFlow> incoming) {
if (incoming == null || incoming.isEmpty()) {
return;
}
List<ReviewFlow> proposed = new ArrayList<>();
Set<String> titles = new LinkedHashSet<>();
Set<Integer> 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<ReviewFlow> 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<ReviewFlow> batchParam) {
if (batchParam == null || batchParam.getData() == null
|| batchParam.getIds() == null || batchParam.getIds().isEmpty()) {
return;
}
List<Integer> ids = new ArrayList<>();
for (Serializable id : batchParam.getIds()) {
if (id instanceof Number) {
ids.add(((Number) id).intValue());
}
}
if (ids.isEmpty()) {
return;
}
List<ReviewFlow> existingRows = listByIds(ids);
if (existingRows == null || existingRows.isEmpty()) {
return;
}
List<ReviewFlow> mergedRows = new ArrayList<>();
List<String> 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<String> 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<Integer> removedIds) {
if (removedIds == null || removedIds.isEmpty()) {
return;
}
List<Integer> ids = new ArrayList<>();
for (Integer id : removedIds) {
if (id != null) {
ids.add(id);
}
}
if (ids.isEmpty()) {
return;
}
List<ReviewFlow> removed = listByIds(ids);
if (removed == null || removed.isEmpty()) {
return;
}
Set<String> titles = new LinkedHashSet<>();
for (ReviewFlow flow : removed) {
if (flow != null && flow.getTitle() != null) {
titles.add(flow.getTitle().trim());
}
}
for (String title : titles) {
List<ReviewFlow> before = listByTitle(title);
if (before == null || before.isEmpty()) {
continue;
}
if (!isContinuous(before)) {
// 删除前就已经不连续:不把存量脏数据变成"永远删不掉"
continue;
}
validateRemaining(title, ids);
}
}
/**
* 校验改名/移走后原审批链剩下的节点仍连续。
*/
private void validateRemaining(String title, Collection<Integer> excludedIds) {
List<ReviewFlow> remaining = new ArrayList<>();
List<ReviewFlow> 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<ReviewFlow> 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());
}
} }
@@ -2,10 +2,14 @@ package com.gxwebsoft.gxmu.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.exception.BusinessException; 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.ReviewFlow;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
@@ -16,28 +20,22 @@ import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 审核流程发起服务实现 * 审核流程发起服务实现
* *
* <p>审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析, * <p>审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析,
* 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。</p> * 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。
* 首级(发起)、驳回重提、逐级推进三条路径都走同一套解析,第 2..N 级不再复制 review_user_id。</p>
* *
* @author LX * @author LX
* @since 2026-03-18 * @since 2026-03-18
*/ */
@Service @Service
public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
/** 有 college_id 的 4 张在跑业务表(#59);其余模块的申报记录只取申报人 */
private static final Set<String> COLLEGE_MODULES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"gxmu_sjqn", "gxmu_tzbcy_form", "gxmu_qmgc_form", "gxmu_wxxzx_project")));
@Resource @Resource
private ReviewFlowConfigService reviewFlowConfigService; private ReviewFlowConfigService reviewFlowConfigService;
@@ -49,6 +47,10 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
private ReviewerResolver reviewerResolver; private ReviewerResolver reviewerResolver;
@Resource @Resource
private DeclareRecordMapper declareRecordMapper; private DeclareRecordMapper declareRecordMapper;
@Resource
private ModuleRegistryService moduleRegistryService;
@Resource
private OrganizationService organizationService;
@Override @Override
public void startReview(String module, Integer pk) { public void startReview(String module, Integer pk) {
@@ -59,15 +61,11 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
if (reviewLists != null && !reviewLists.isEmpty()) { if (reviewLists != null && !reviewLists.isEmpty()) {
return; return;
} }
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); List<ReviewFlow> flowList = loadFlowList(module);
if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { if (flowList.isEmpty()) {
return; return;
} }
ReviewFlow firstOne = getFirstFlow(reviewFlowConfig); saveReviewNode(module, pk, flowList.get(0), 0);
if (firstOne == null) {
return;
}
saveFirstReviewNode(module, pk, firstOne);
} }
/** /**
@@ -90,61 +88,125 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
Comparator.nullsLast(LocalDateTime::compareTo)) Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(ReviewList::getId, Comparator.nullsLast(Integer::compareTo))) .thenComparing(ReviewList::getId, Comparator.nullsLast(Integer::compareTo)))
.orElse(null); .orElse(null);
if (latestReview == null || latestReview.getStatus() == null || latestReview.getStatus() != 2) { if (latestReview == null || latestReview.getStatus() == null
|| latestReview.getStatus() != ReviewStatus.REJECTED) {
return; return;
} }
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); List<ReviewFlow> flowList = loadFlowList(module);
if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { if (flowList.isEmpty()) {
return;
}
ReviewFlow firstOne = getFirstFlow(reviewFlowConfig);
if (firstOne == null) {
return; return;
} }
reviewListService.remove(new LambdaQueryWrapper<ReviewList>() reviewListService.remove(new LambdaQueryWrapper<ReviewList>()
.eq(ReviewList::getModule, module) .eq(ReviewList::getModule, module)
.eq(ReviewList::getPk, pk)); .eq(ReviewList::getPk, pk));
saveFirstReviewNode(module, pk, firstOne); saveReviewNode(module, pk, flowList.get(0), 0);
} }
private ReviewFlow getFirstFlow(ReviewFlowConfig reviewFlowConfig) { /**
* 当前一级通过后推进到下一级。
*
* <p>顺序号与审批链下标一一对应(首级 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<ReviewFlow> 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<ReviewFlow> 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()); ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
if (reviewFlow == null) { if (reviewFlow == null) {
return null; return java.util.Collections.emptyList();
} }
List<ReviewFlow> reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle()); List<ReviewFlow> reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle());
if (reviewFlowList == null || reviewFlowList.isEmpty()) { 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<String, Object> record = selectDeclareRecord(module, pk); Map<String, Object> record = selectDeclareRecord(module, pk);
Integer collegeId = intValue(record, "collegeId"); Integer collegeId = intValue(record, "collegeId");
Integer declareUserId = intValue(record, "userId"); Integer declareUserId = intValue(record, "userId");
List<Integer> reviewers = reviewerResolver.resolve(firstOne, collegeId, declareUserId); List<Integer> reviewers = reviewerResolver.resolve(flow, collegeId, declareUserId);
if (reviewers == null || reviewers.isEmpty()) { if (reviewers == null || reviewers.isEmpty()) {
throw new BusinessException("模块 " + module + " 主键 " + pk + " 第 " // 兜底消息与 ReviewerResolver 同口径:哪个学院、第几级、哪个模块/主键
+ (firstOne.getLevel() == null ? "?" : firstOne.getLevel()) String scope = collegeId == null ? "申报记录缺少学院标识," : "学院「" + collegeName(collegeId) + "」";
+ " 级审批人解析结果为空,未生成待办"); throw new BusinessException(scope + "第 " + levelOf(flow)
+ " 级审批人解析结果为空(模块 " + module + " 主键 " + pk + "),未生成待办");
} }
ReviewList reviewList = new ReviewList(); ReviewList reviewList = new ReviewList();
reviewList.setPk(pk); reviewList.setPk(pk);
reviewList.setModule(module); reviewList.setModule(module);
reviewList.setUserId(reviewers.get(0)); reviewList.setUserId(reviewers.get(0));
reviewList.setSortNumber(0); reviewList.setSortNumber(sortNumber);
reviewListService.save(reviewList); reviewListService.save(reviewList);
} }
/** 读申报记录的申报人与学院标识;只有 4 张在跑表有 college_id */ /**
* 读申报记录的申报人与学院标识。
*
* <p>哪张业务表有 college_id 由注册表 has_college 决定,后端不再维护模块白名单
* (ADR 0006:新增表只改注册表一行)。
*/
private Map<String, Object> selectDeclareRecord(String module, Integer pk) { private Map<String, Object> selectDeclareRecord(String module, Integer pk) {
if (COLLEGE_MODULES.contains(module)) { if (moduleRegistryService.hasCollege(module)) {
return declareRecordMapper.selectReviewContext(module, pk); return declareRecordMapper.selectReviewContext(module, pk);
} }
return declareRecordMapper.selectRecord(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<String, Object> record, String key) { private Integer intValue(Map<String, Object> record, String key) {
if (record == null) { if (record == null) {
return null; return null;
@@ -86,18 +86,10 @@ public class ReviewListServiceImpl extends ServiceImpl<ReviewListMapper, ReviewL
// 模块标题以注册表为唯一来源;注册表里查不到的历史 module 回退原串,不让列表报错 // 模块标题以注册表为唯一来源;注册表里查不到的历史 module 回退原串,不让列表报错
Map<String, String> moduleTitleMap = moduleRegistryService.titleMap(); Map<String, String> moduleTitleMap = moduleRegistryService.titleMap();
for (ReviewList item : list) { for (ReviewList item : list) {
item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule())); item.setModuleName(ModuleRegistryService.titleOf(moduleTitleMap, item.getModule()));
} }
} }
private String moduleTitleOf(Map<String, String> moduleTitleMap, String module) {
if (module == null) {
return null;
}
String title = moduleTitleMap.get(module);
return title == null || title.isEmpty() ? module : title;
}
@Override @Override
public List<ReviewList> listByModuleNPK(String module, Integer pk){ public List<ReviewList> listByModuleNPK(String module, Integer pk){
return list( return list(
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; 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.ReviewList;
import com.gxwebsoft.gxmu.entity.TzbcyForm; import com.gxwebsoft.gxmu.entity.TzbcyForm;
import com.gxwebsoft.gxmu.mapper.TzbcyFormMapper; import com.gxwebsoft.gxmu.mapper.TzbcyFormMapper;
@@ -157,7 +158,7 @@ public class TzbcyFormServiceImpl extends ServiceImpl<TzbcyFormMapper, TzbcyForm
} }
List<ReviewList> reviewList = form.getReviewList(); List<ReviewList> reviewList = form.getReviewList();
ReviewList latest = reviewList.get(reviewList.size() - 1); 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) { private String buildPersonKey(String name, String phone) {
+11 -7
View File
@@ -92,12 +92,16 @@ config:
# 文件访问规则(spec #58) # 文件访问规则(spec #58)
# 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。 # 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。
# #
# 确需公开的内联资源(预览 /api/file/<dir>/<name> 与缩略图 /api/file/thumbnail/**): # 内联预览(/api/file/<dir>/<name>)与缩略图(/api/file/thumbnail/**)默认也需要登录:
# 默认 true,否则公开页面的配图会整片变成需要 token;置 false 则内联也需登录。 # 上传一律落在 /yyyyMMdd/ 日期目录,日期目录正好是两段路径,默认公开等于把所有上传文件
# (含盖章证明材料)变成匿名可读。因此默认值从 true 改成 false。
#
# 公开页面引用的文件必须把所在目录列进下面的 public-dirs 白名单,
# 否则匿名访问会被拒(401/403,表现为公开页面的空图)。
file: file:
public-preview: ${FILE_PUBLIC_PREVIEW:true} public-preview: ${FILE_PUBLIC_PREVIEW:false}
# 额外公开目录(可选,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图即使 # 公开目录白名单(显式声明,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图
# public-preview=false 也允许匿名读取。留空表示没有额外目录。 # 允许匿名读取。留空表示没有任何公开目录——这是安全默认。
# #
# 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store), # 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store),
# 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。 # 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。
@@ -221,9 +225,9 @@ gxmu:
allow-unknown-user: false allow-unknown-user: false
# 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、 # 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、
# 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。 # 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。
# strict 默认 true;本地无库启动可临时设为 false 跳过校验。 # strict 默认 true;本地无库启动可临时设 GXMU_MODULE_REGISTRY_STRICT=false 跳过校验。
module-registry: module-registry:
strict: true strict: ${GXMU_MODULE_REGISTRY_STRICT:true}
openplat-sync: openplat-sync:
enabled: true enabled: true
# 每天 03:30 # 每天 03:30
@@ -2,99 +2,71 @@ package com.gxwebsoft.common.core.security;
import org.junit.jupiter.api.Test; 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; import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* 文件访问规则(spec #58 API 契约、用户故事 14)。 * 文件访问规则(spec #58 API 契约、用户故事 14)。
* *
* <p>改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被 * <p>改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被未登录者按 URL
* 未登录者直接按 URL 取走。spec 的要求只落在"下载接口"上:下载改为需登录; * 直接取走。现在默认「全部需登录」,只有显式声明的东西才匿名可读。
* 内联预览与缩略图必须继续匿名可读,否则公开页面的配图会整片裂掉。 *
* 测试钉住三件事:下载无论怎么配都要登录、内联默认公开、显式关闭时内联也要登录。 * <p>测试钉的是 {@link SecurityConfig} 真正注册的那三个方法返回的<b>模式内容与顺序</b>——
* 顺序本身就是这条改造的关键(下载在公开之前、公开在兜底之前)。这里不再另造一套
* 「这条请求算不算公开」的平行判定,避免测试全绿而线上照旧放行。
* *
* @author Codex * @author Codex
* @since 2026-11-03 * @since 2026-11-03
*/ */
class FileAccessRulesTest { class FileAccessRulesTest {
/** 下载接口永远要登录,白名单为空、内联公开也一样——它是"取走原件"的动作。 */ /** 下载接口单独一条模式,且只放这一个:它是"取走原件"的动作,永远要登录。 */
@Test @Test
void downloadAlwaysRequiresLogin() { void downloadPatternIsRegisteredAlone() {
FileAccessRules rules = new FileAccessRules("", true); FileAccessRules rules = new FileAccessRules("", true);
assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf")); assertEquals(Collections.singletonList("/api/file/download/**"), rules.downloadPatterns());
assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf"));
} }
/** 内联预览与缩略图默认公开:公开页面上的配图不能因为这次收敛而变成需要 token。 */ /** 安全默认:不配白名单、不打开内联公开时,没有任何匿名可读的文件路径。 */
@Test @Test
void previewAndThumbnailArePublicByDefault() { void defaultIsClosedSoNoFilePatternIsAnonymous() {
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() {
assertTrue(new FileAccessRules("", false).permitAllPatterns().isEmpty()); assertTrue(new FileAccessRules("", false).permitAllPatterns().isEmpty());
assertTrue(new FileAccessRules(null, false).permitAllPatterns().isEmpty()); assertTrue(new FileAccessRules(null, false).permitAllPatterns().isEmpty());
assertFalse(new FileAccessRules(null, false).isPublicFileRequest("/api/file/20260708/a.jpg"));
} }
/** 配置里允许空格、斜杠与空项,但产出的模式必须干净。 */ /** 白名单目录产出「原图 → 缩略图」两条模式,按声明顺序展开。 */
@Test @Test
void patternsAreNormalized() { void whitelistedDirsProduceOriginThenThumbnailPatternsInOrder() {
FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ", false); FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ", false);
List<String> patterns = rules.permitAllPatterns(); assertEquals(Arrays.asList(
assertTrue(patterns.contains("/api/file/cms-article/**")); "/api/file/cms-article/**",
assertTrue(patterns.contains("/api/file/public/**")); "/api/file/thumbnail/cms-article/**",
assertTrue(patterns.contains("/api/file/thumbnail/cms-article/**")); "/api/file/public/**",
assertFalse(patterns.contains("/api/file/**"), "不能整段放行 /api/file"); "/api/file/thumbnail/public/**"), rules.permitAllPatterns());
} }
/** 登录态之外的接口(CMS 文章 JSON)与单段文件接口不受公开规则影响。 */ /** 显式打开内联公开:预览与缩略图排在最前,白名单目录随后。 */
@Test @Test
void nonFileRequestsAreNotTreatedAsFiles() { void publicPreviewAddsPreviewAndThumbnailBeforeWhitelistedDirs() {
FileAccessRules rules = new FileAccessRules("cms-article", true); FileAccessRules rules = new FileAccessRules("cms-article", true);
assertFalse(rules.isPublicFileRequest("/api/cms/article")); assertEquals(Arrays.asList(
assertFalse(rules.isPublicFileRequest("/api/file/page")); "/api/file/*/*",
assertFalse(rules.isPublicFileRequest("/api/file/12")); "/api/file/thumbnail/**",
assertFalse(rules.isPublicFileRequest(null)); "/api/file/cms-article/**",
"/api/file/thumbnail/cms-article/**"), rules.permitAllPatterns());
} }
/** 兜底规则:白名单之外的 /api/file/** 仍需登录,下载规则单独可注册。 */ /** 兜底:白名单之外的所有文件接口都要登录,且必须是整段 /api/file/**(不能只覆盖子路径)。 */
@Test @Test
void authenticatedPatternsCoverAllFiles() { void authenticatedFallbackCoversAllFiles() {
FileAccessRules rules = new FileAccessRules("cms-article", true); FileAccessRules rules = new FileAccessRules("cms-article", false);
assertTrue(rules.authenticatedPatterns().contains("/api/file/**")); assertEquals(Collections.singletonList("/api/file/**"), rules.authenticatedPatterns());
assertTrue(rules.downloadPatterns().contains("/api/file/download/**"));
} }
} }
@@ -1,6 +1,7 @@
package com.gxwebsoft.gxmu.controller; package com.gxwebsoft.gxmu.controller;
import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlow;
import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowService;
import org.junit.jupiter.api.Test; 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.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; 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.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
* 审批链保存入口的层级校验接线(spec #58)。 * 审批链写入口的层级校验接线(spec #58)。
* *
* <p>管理端"先删旧节点、再逐级新增",所以单条保存必须结合库里同 title 的已有节点判断, * <p>规则实现已下沉到 {@link ReviewFlowService}(合并库内同 title 节点、校验连续性、
* 否则逐级保存会被误判为缺级;这里钉住这个接线。 * 校验改名后原链剩余;见 ReviewFlowServiceImplLevelTest),controller 只负责
* 「写之前调用一次」:保存、修改、批量、删除四条路径都要接上,且校验抛错时一个字节都不落库。
*
* @author Codex
* @since 2026-11-03
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT) @MockitoSettings(strictness = Strictness.LENIENT)
@@ -46,32 +54,76 @@ class ReviewFlowControllerLevelTest {
return flow; return flow;
} }
/** 逐级保存:controller 把校验交给 service,校验通过才落库。 */
@Test @Test
void savingNextLevelAfterPreviousOneIsAccepted() { void saveDelegatesLevelValidationThenWrites() {
when(reviewFlowService.listByTitle(TITLE))
.thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
when(reviewFlowService.save(any())).thenReturn(true); when(reviewFlowService.save(any())).thenReturn(true);
controller.save(flow(null, TITLE, 2)); controller.save(flow(null, TITLE, 2));
verify(reviewFlowService).validateLevelsOnSave(anyList());
verify(reviewFlowService).save(any()); verify(reviewFlowService).save(any());
} }
/** 校验拒绝(缺级):异常透出,不落库。 */
@Test @Test
void savingLevelThreeWithoutTwoIsRejectedBeforeWrite() { void saveDoesNotWriteWhenServiceRejectsGap() {
when(reviewFlowService.listByTitle(TITLE)) doThrow(new BusinessException("审批链「十佳青年」的层级必须从 1 连续升序"))
.thenReturn(Collections.singletonList(flow(1, TITLE, 1))); .when(reviewFlowService).validateLevelsOnSave(any());
assertThrows(BusinessException.class, () -> controller.save(flow(null, TITLE, 3))); assertThrows(BusinessException.class, () -> controller.save(flow(null, TITLE, 3)));
verify(reviewFlowService, never()).save(any()); verify(reviewFlowService, never()).save(any());
} }
/** 批量保存同样先校验。 */
@Test @Test
void batchSaveRejectsGap() { void batchSaveDelegatesLevelValidation() {
when(reviewFlowService.listByTitle(TITLE)).thenReturn(Collections.emptyList()); when(reviewFlowService.saveBatch(any())).thenReturn(true);
assertThrows(BusinessException.class, () -> controller.saveBatch(Arrays.asList( controller.saveBatch(Arrays.asList(flow(null, TITLE, 1), flow(null, TITLE, 2)));
flow(null, TITLE, 1), flow(null, TITLE, 3))));
verify(reviewFlowService, never()).saveBatch(any()); 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());
} }
} }
@@ -6,8 +6,7 @@ import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.DeclareGuardService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.service.ReviewListService;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -54,10 +53,7 @@ class ReviewListControllerAuthTest {
private ReviewListService reviewListService; private ReviewListService reviewListService;
@Mock @Mock
private ReviewFlowConfigService reviewFlowConfigService; private ReviewFlowStarterService reviewFlowStarterService;
@Mock
private ReviewFlowService reviewFlowService;
@Mock @Mock
private DeclareGuardService declareGuardService; private DeclareGuardService declareGuardService;
@@ -117,6 +113,28 @@ class ReviewListControllerAuthTest {
assertEquals(Constants.RESULT_OK_CODE, result.getCode()); assertEquals(Constants.RESULT_OK_CODE, result.getCode());
verify(reviewListService).updateById(any()); verify(reviewListService).updateById(any());
verify(declareGuardService).syncReviewStatus("jhqn", PK); 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 为空/空白时拒绝,提示要可读。 */ /** 驳回必须写意见:status=2 且 content 为空/空白时拒绝,提示要可读。 */
@@ -144,6 +162,8 @@ class ReviewListControllerAuthTest {
assertEquals(Constants.RESULT_OK_CODE, result.getCode()); assertEquals(Constants.RESULT_OK_CODE, result.getCode());
verify(reviewListService).updateById(any()); verify(reviewListService).updateById(any());
// 驳回不推进下一级
verify(reviewFlowStarterService, never()).advanceReview(any(), any(), any());
} }
/** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */ /** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */
@@ -86,6 +86,22 @@ class ModuleRegistryIntegrityCheckerTest {
assertDoesNotThrow(() -> checker.check()); 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 @Test
void strictFalseSkipsValidationForLocalStartupWithoutDatabase() { void strictFalseSkipsValidationForLocalStartupWithoutDatabase() {
ReflectionTestUtils.setField(checker, "strict", false); ReflectionTestUtils.setField(checker, "strict", false);
@@ -2,6 +2,7 @@ package com.gxwebsoft.gxmu.module;
import com.gxwebsoft.gxmu.module.entity.ModuleRegistry; import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper; import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.module.service.impl.ModuleRegistryServiceImpl; import com.gxwebsoft.gxmu.module.service.impl.ModuleRegistryServiceImpl;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -12,6 +13,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness; import org.mockito.quality.Strictness;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -26,9 +28,10 @@ import static org.mockito.Mockito.when;
/** /**
* 模块注册表服务的单元测试(mock 掉 Mapper,不启 Spring、不连库)。 * 模块注册表服务的单元测试(mock 掉 Mapper,不启 Spring、不连库)。
* *
* <p>注册表是模块元数据的唯一来源,这里锁住三件对外可观察的事: * <p>注册表是模块元数据的唯一来源,这里锁住四件对外可观察的事:
* 启用清单按 sort_number 升序、标题查询在未注册时回退 module 原串、 * 启用清单只留启用项且保持 SQL 的排序(排序只在 SQL 里做一次)、
* 模块集合包含已停用模块(停用模块的历史数据仍要能撤回/作废)。 * 模块集合包含已停用模块(停用模块的历史数据仍要能撤回/作废)、
* has_college 决定审批人解析是否读学院上下文、标题回退只有一份实现。
* *
* @author Codex * @author Codex
* @since 2026-11-10 * @since 2026-11-10
@@ -44,11 +47,12 @@ class ModuleRegistryServiceImplTest {
private ModuleRegistryServiceImpl moduleRegistryService; private ModuleRegistryServiceImpl moduleRegistryService;
@Test @Test
void listEnabledReturnsOnlyEnabledModulesOrderedBySortNumber() { void listEnabledReturnsOnlyEnabledModulesInSqlOrder() {
// SQL 已按 sort_number 升序返回;服务只负责过滤,不再二次排序
when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList( when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList(
module("gxmu_qmgc_form", "青马工程", 1, 8), module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1),
module("gxmu_sjtbzbsj", "十佳团支部书记", 0, 2), module("gxmu_sjtbzbsj", "十佳团支部书记", 0, 2, 0),
module("gxmu_sjqn", "十佳青年岗位能手", 1, 1))); module("gxmu_qmgc_form", "青马工程", 1, 8, 1)));
List<ModuleRegistry> enabled = moduleRegistryService.listEnabled(); List<ModuleRegistry> enabled = moduleRegistryService.listEnabled();
@@ -56,30 +60,11 @@ class ModuleRegistryServiceImplTest {
enabled.stream().map(ModuleRegistry::getModule).collect(Collectors.toList())); 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 @Test
void titleMapCoversDisabledModulesSoHistoricalRowsKeepTitles() { void titleMapCoversDisabledModulesSoHistoricalRowsKeepTitles() {
when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList( when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList(
module("gxmu_sjqn", "十佳青年岗位能手", 1, 1), module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1),
module("gxmu_yxgqty", "优秀共青团员", 0, 6))); module("gxmu_yxgqty", "优秀共青团员", 0, 6, 0)));
Map<String, String> titles = moduleRegistryService.titleMap(); Map<String, String> titles = moduleRegistryService.titleMap();
@@ -91,15 +76,49 @@ class ModuleRegistryServiceImplTest {
@Test @Test
void moduleSetContainsDisabledModules() { void moduleSetContainsDisabledModules() {
when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList( when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList(
module("gxmu_sjqn", "十佳青年岗位能手", 1, 1), module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1),
module("gxmu_yxgqty", "优秀共青团员", 0, 6))); module("gxmu_yxgqty", "优秀共青团员", 0, 6, 0)));
assertTrue(moduleRegistryService.containsModule("gxmu_yxgqty")); assertTrue(moduleRegistryService.containsModule("gxmu_yxgqty"));
assertFalse(moduleRegistryService.containsModule("gxmu_unknown")); assertFalse(moduleRegistryService.containsModule("gxmu_unknown"));
assertFalse(moduleRegistryService.containsModule(null)); 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<String, String> 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(); ModuleRegistry item = new ModuleRegistry();
item.setModule(name); item.setModule(name);
item.setTableName(name); item.setTableName(name);
@@ -107,6 +126,7 @@ class ModuleRegistryServiceImplTest {
item.setDetailKey(name); item.setDetailKey(name);
item.setEnabled(enabled); item.setEnabled(enabled);
item.setSortNumber(sortNumber); item.setSortNumber(sortNumber);
item.setHasCollege(hasCollege);
item.setDeleted(0); item.setDeleted(0);
return item; return item;
} }
@@ -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 个学院共用)。
*
* <p>钉三件事:有学籍时新申报写入学院、没有学籍时留空不报错、更新路径不覆盖已有值。
* 不启 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;
}
}
@@ -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 连续升序」)。
*
* <p>规则实现落在 {@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;
}
}
@@ -1,10 +1,13 @@
package com.gxwebsoft.gxmu.service.impl; package com.gxwebsoft.gxmu.service.impl;
import com.gxwebsoft.common.core.exception.BusinessException; 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.ReviewFlow;
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.ReviewList;
import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper;
import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowConfigService;
import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowService;
import com.gxwebsoft.gxmu.service.ReviewListService; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; 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.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
@@ -36,10 +40,11 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
* 审批流发起与驳回重走(spec #58)。 * 审批流发起、驳回重走与逐级推进(spec #58)。
* *
* <p>接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。 * <p>接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。
* 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、以及解析不出来时「一个节点都不写」。</p> * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、推进时「第 2..N 级同样经解析」,
* 以及解析不出来时「一个节点都不写」。</p>
* *
* @author Codex * @author Codex
* @since 2026-11-10 * @since 2026-11-10
@@ -52,6 +57,7 @@ class ReviewFlowStarterServiceImplTest {
private static final Integer PK = 11; private static final Integer PK = 11;
private static final Integer COLLEGE_ID = 100; private static final Integer COLLEGE_ID = 100;
private static final Integer DECLARE_USER_ID = 12; private static final Integer DECLARE_USER_ID = 12;
private static final String COLLEGE_NAME = "药学院";
@Mock @Mock
private ReviewFlowConfigService reviewFlowConfigService; private ReviewFlowConfigService reviewFlowConfigService;
@@ -68,6 +74,12 @@ class ReviewFlowStarterServiceImplTest {
@Mock @Mock
private DeclareRecordMapper declareRecordMapper; private DeclareRecordMapper declareRecordMapper;
@Mock
private ModuleRegistryService moduleRegistryService;
@Mock
private OrganizationService organizationService;
@InjectMocks @InjectMocks
private ReviewFlowStarterServiceImpl reviewFlowStarterService; private ReviewFlowStarterServiceImpl reviewFlowStarterService;
@@ -87,6 +99,12 @@ class ReviewFlowStarterServiceImplTest {
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList());
when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext()); when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext());
when(declareRecordMapper.selectRecord(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 // 默认:解析出第一级审批人 7
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7)); when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7));
} }
@@ -118,8 +136,12 @@ class ReviewFlowStarterServiceImplTest {
void startReview_noReviewerThrowsAndWritesNoNode() { void startReview_noReviewerThrowsAndWritesNoNode() {
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList()); 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()); verify(reviewListService, never()).save(any());
} }
@@ -136,6 +158,68 @@ class ReviewFlowStarterServiceImplTest {
verify(reviewListService, never()).save(any()); 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<ReviewList> 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());
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 驳回重走:先清旧节点,再解析第一级 // 驳回重走:先清旧节点,再解析第一级
// ------------------------------------------------------------------ // ------------------------------------------------------------------