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