diff --git a/sql/20261110_backfill_college_id.sql b/sql/20261110_backfill_college_id.sql index 8ba5c48..5f50677 100644 --- a/sql/20261110_backfill_college_id.sql +++ b/sql/20261110_backfill_college_id.sql @@ -14,6 +14,26 @@ -- 3) 校验与清单(只读) —— 回填率、空值清单、未匹配文本一览,抄进上线记录 -- 3) ⚠ 只执行一次,不接定时任务。 -- +-- ============================================================================ +-- 表名 → 学院文本列 对照表(本脚本唯一的一处机械清单,B 段兜底按它逐段复制) +-- +-- ┌──────────────────────┬──────────────────────────────────┬──────────┐ +-- │ 表名 │ 学院文本列(B 段兜底用) │ 列注释 │ +-- ├──────────────────────┼──────────────────────────────────┼──────────┤ +-- │ gxmu_sjqn │ unit_name │ 所在单位 │ +-- │ gxmu_tzbcy_form │ school_name │ 学校名称 │ +-- │ gxmu_qmgc_form │ school_info │ 学校、院系、年级、专业 │ +-- │ gxmu_wxxzx_project │ college_grade_class │ 学院、年级、班别 │ +-- └──────────────────────┴──────────────────────────────────┴──────────┘ +-- +-- 新增第 5 张带 college_id 的表时,按上表同步 4 处: +-- ① sql/20261110_spec58_approval_chain.sql 的加列段 + 注册表种子(has_college = 1) +-- ② 本脚本 A 段(学生档案主路径:user_id → gxmu_student.college_id) +-- ③ 本脚本 B 段(表单文本兜底:按上表的文本列 LIKE 组织名) +-- ④ 本脚本 3a / 3b / 3c / 3d(回填率、空值清单、未匹配文本、逐表计数) +-- 漏改的后果不是报错,而是新表回填率恒为 0、审批人按学院解析静默失败。 +-- ============================================================================ +-- -- 可重复执行:所有 UPDATE 都带「只填空、不覆盖」条件(college_id IS NULL),且不带会话状态 -- (不用临时表、不用视图),任何一句都可以单独重跑。重跑不会改掉人工修对的值。 -- diff --git a/sql/20261110_spec58_approval_chain.sql b/sql/20261110_spec58_approval_chain.sql index 0562ef7..3c39931 100644 --- a/sql/20261110_spec58_approval_chain.sql +++ b/sql/20261110_spec58_approval_chain.sql @@ -2,7 +2,8 @@ -- 内容: -- 1) gxmu_review_flow 增加 reviewer_rule(审批人规则,为空时回退 review_user_id) -- 2) 4 张在跑业务表增加 college_id(学院标识,指向 sys_organization.organization_id) --- 3) 新建 gxmu_module_registry(模块注册表)并写入 10 个模块的初始数据 +-- 3) 新建 gxmu_module_registry(模块注册表)并写入 12 个模块的初始数据 +-- 4) 注册表增加 has_college(该模块的业务表是否有 college_id),4 张在跑表置 1 -- -- 重复执行:加列前用 information_schema 判存(MySQL 5.7 不支持 ADD COLUMN IF NOT EXISTS), -- 建表用 IF NOT EXISTS,初始数据用 INSERT IGNORE; @@ -87,6 +88,8 @@ DEALLOCATE PREPARE stmt; -- 3) 模块注册表:模块元数据的唯一来源 -- module 同时是业务表名,table_name 与 module 不一致会在启动期直接失败(ADR 0006)。 -- detail_key 对应 moduleDetailConfig.ts 的配置键;export_template 留空表示走默认导出。 +-- has_college=1 表示该模块的业务表有 college_id,审批人解析才去读学院上下文; +-- 后端不再维护「哪几张表有学院标识」的模块白名单,新增表只改注册表一行。 -- ------------------------------------------------------------------ CREATE TABLE IF NOT EXISTS gxmu_module_registry ( id int NOT NULL AUTO_INCREMENT, @@ -95,6 +98,7 @@ CREATE TABLE IF NOT EXISTS gxmu_module_registry ( title varchar(100) NOT NULL COMMENT '模块标题', detail_key varchar(64) NOT NULL COMMENT '详情页配置键(moduleDetailConfig.ts)', export_template varchar(100) DEFAULT NULL COMMENT '导出模板标识', + has_college tinyint NOT NULL DEFAULT '0' COMMENT '业务表是否有 college_id:0否 1是', enabled tinyint NOT NULL DEFAULT '1' COMMENT '是否启用:0否 1是', sort_number int NOT NULL DEFAULT '0' COMMENT '排序号,小的在前', deleted tinyint NOT NULL DEFAULT '0' COMMENT '删除标记:0未删 1已删', @@ -106,24 +110,48 @@ CREATE TABLE IF NOT EXISTS gxmu_module_registry ( UNIQUE KEY uk_module (module) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='模块注册表'; --- 初始数据:9 个 gxmu 模块 + cms_manuscript。 +-- 已有库补列:gxmu_module_registry 可能已由上一版脚本建好,CREATE TABLE IF NOT EXISTS 不会补列 +SET @sql = IF( + EXISTS( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'gxmu_module_registry' AND COLUMN_NAME = 'has_college' + ), + 'SELECT 1', + 'ALTER TABLE gxmu_module_registry ADD COLUMN has_college tinyint NOT NULL DEFAULT ''0'' COMMENT ''业务表是否有 college_id:0否 1是''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 初始数据:11 个 gxmu 模块 + cms_manuscript。 -- title 取自 ReviewFlowConfigServiceImpl.MODULE_NAME_MAP,detail_key 与 moduleDetailConfig.ts 的键一致。 --- enabled=1:4 条在跑的 gxmu 审批链 + 稿件(cms_manuscript 有 review_flow_config 配置、当前就在审核下拉里, --- 停用会让「稿件」从审核筛选里静默消失);5 个「已接线未启用」的 gxmu 模块保持 0(定性见 issue #55)。 --- sort_number 按 reviewList/components/search.vue 当前下拉顺序,cms_manuscript 排在最后。 +-- enabled=1(5 行):4 条在跑的 gxmu 审批链 + 稿件(cms_manuscript 有 review_flow_config 配置、当前就在审核下拉里, +-- 停用会让「稿件」从审核筛选里静默消失)。 +-- enabled=0(7 行):5 个「已接线未启用」的 gxmu 模块(定性见 issue #55) +-- + gxmu_tygl_form / gxmu_tygl(团员管理,有存量数据与 review_flow_config 行,但不开新入口)。 +-- has_college=1:只有 4 张在跑业务表加了 college_id(见本脚本第 2 段)。 +-- sort_number 按 reviewList/components/search.vue 当前下拉顺序,cms_manuscript 排在最后,团员管理两行排在其后。 INSERT IGNORE INTO gxmu_module_registry - (module, table_name, title, detail_key, export_template, enabled, sort_number, deleted, tenant_id, user_id) + (module, table_name, title, detail_key, export_template, has_college, enabled, sort_number, deleted, tenant_id, user_id) VALUES - ('gxmu_sjqn', 'gxmu_sjqn', '十佳青年岗位能手', 'gxmu_sjqn', NULL, 1, 1, 0, 1, NULL), - ('gxmu_sjtbzbsj', 'gxmu_sjtbzbsj', '十佳团支部书记', 'gxmu_sjtbzbsj', NULL, 0, 2, 0, 1, NULL), - ('gxmu_wshqtw', 'gxmu_wshqtw', '五四红旗团委', 'gxmu_wshqtw', NULL, 0, 3, 0, 1, NULL), - ('gxmu_wshqtzb', 'gxmu_wshqtzb', '五四红旗团支部', 'gxmu_wshqtzb', NULL, 0, 4, 0, 1, NULL), - ('gxmu_yxgqtdgb', 'gxmu_yxgqtdgb', '优秀共青团干部', 'gxmu_yxgqtdgb', NULL, 0, 5, 0, 1, NULL), - ('gxmu_yxgqty', 'gxmu_yxgqty', '优秀共青团员', 'gxmu_yxgqty', NULL, 0, 6, 0, 1, NULL), - ('gxmu_tzbcy_form', 'gxmu_tzbcy_form', '挑战杯', 'gxmu_tzbcy_form', NULL, 1, 7, 0, 1, NULL), - ('gxmu_qmgc_form', 'gxmu_qmgc_form', '青马工程', 'gxmu_qmgc_form', NULL, 1, 8, 0, 1, NULL), - ('gxmu_wxxzx_project', 'gxmu_wxxzx_project', '未来学术之星', 'gxmu_wxxzx_project', NULL, 1, 9, 0, 1, NULL), - ('cms_manuscript', 'cms_manuscript', '稿件', 'cms_manuscript', NULL, 1, 10, 0, 1, NULL); + ('gxmu_sjqn', 'gxmu_sjqn', '十佳青年岗位能手', 'gxmu_sjqn', NULL, 1, 1, 1, 0, 1, NULL), + ('gxmu_sjtbzbsj', 'gxmu_sjtbzbsj', '十佳团支部书记', 'gxmu_sjtbzbsj', NULL, 0, 0, 2, 0, 1, NULL), + ('gxmu_wshqtw', 'gxmu_wshqtw', '五四红旗团委', 'gxmu_wshqtw', NULL, 0, 0, 3, 0, 1, NULL), + ('gxmu_wshqtzb', 'gxmu_wshqtzb', '五四红旗团支部', 'gxmu_wshqtzb', NULL, 0, 0, 4, 0, 1, NULL), + ('gxmu_yxgqtdgb', 'gxmu_yxgqtdgb', '优秀共青团干部', 'gxmu_yxgqtdgb', NULL, 0, 0, 5, 0, 1, NULL), + ('gxmu_yxgqty', 'gxmu_yxgqty', '优秀共青团员', 'gxmu_yxgqty', NULL, 0, 0, 6, 0, 1, NULL), + ('gxmu_tzbcy_form', 'gxmu_tzbcy_form', '挑战杯', 'gxmu_tzbcy_form', NULL, 1, 1, 7, 0, 1, NULL), + ('gxmu_qmgc_form', 'gxmu_qmgc_form', '青马工程', 'gxmu_qmgc_form', NULL, 1, 1, 8, 0, 1, NULL), + ('gxmu_wxxzx_project', 'gxmu_wxxzx_project', '未来学术之星', 'gxmu_wxxzx_project', NULL, 1, 1, 9, 0, 1, NULL), + ('cms_manuscript', 'cms_manuscript', '稿件', 'cms_manuscript', NULL, 0, 1, 10, 0, 1, NULL), + ('gxmu_tygl_form', 'gxmu_tygl_form', '团员管理', 'gxmu_tygl_form', NULL, 0, 0, 11, 0, 1, NULL), + ('gxmu_tygl', 'gxmu_tygl', '团员管理', 'gxmu_tygl', NULL, 0, 0, 12, 0, 1, NULL); + +-- 已存在库的行补 has_college:INSERT IGNORE 不会改已存在的行,这里只把 4 张在跑表从 0 提到 1。 +-- 只补 0 → 1,不覆盖后台已改过的值;重复执行是 no-op。 +UPDATE gxmu_module_registry SET has_college = 1 +WHERE deleted = 0 AND has_college = 0 + AND module IN ('gxmu_sjqn', 'gxmu_tzbcy_form', 'gxmu_qmgc_form', 'gxmu_wxxzx_project'); -- ------------------------------------------------------------------ -- 执行后校验(手工执行;每一段注释里是期望结果) @@ -140,10 +168,18 @@ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('gxmu_sjqn', 'gxmu_tzbcy_form', 'gxmu_qmgc_form', 'gxmu_wxxzx_project') AND COLUMN_NAME = 'college_id' AND IS_NULLABLE = 'YES'; --- 2) 注册表行数与启用数:期望 10 行未删除,其中 4 行启用 +-- 2) 注册表行数与启用数:期望 12 行未删除,其中 5 行启用 SELECT COUNT(*) AS registry_rows FROM gxmu_module_registry WHERE deleted = 0; SELECT COUNT(*) AS enabled_rows FROM gxmu_module_registry WHERE deleted = 0 AND enabled = 1; +-- 2b) has_college:期望 4 行(4 张在跑业务表),且与下面的 college_id 列数一致 +SELECT COUNT(*) AS has_college_rows FROM gxmu_module_registry WHERE deleted = 0 AND has_college = 1; +SELECT r.module +FROM gxmu_module_registry r +LEFT JOIN information_schema.COLUMNS c + ON c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = r.table_name AND c.COLUMN_NAME = 'college_id' +WHERE r.deleted = 0 AND r.has_college = 1 AND c.COLUMN_NAME IS NULL; + -- 3) 一致性检查:以下查询都应返回空集 SELECT module FROM gxmu_module_registry WHERE deleted = 0 AND table_name <> module; SELECT module FROM gxmu_module_registry WHERE deleted = 0 AND (detail_key IS NULL OR detail_key = ''); @@ -155,7 +191,7 @@ LEFT JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = DATABASE() AND t.TABLE_NAME = r.table_name WHERE r.deleted = 0 AND t.TABLE_NAME IS NULL; --- 4) 启用名单与顺序:期望依次为 gxmu_sjqn / gxmu_tzbcy_form / gxmu_qmgc_form / gxmu_wxxzx_project +-- 4) 启用名单与顺序:期望依次为 gxmu_sjqn / gxmu_tzbcy_form / gxmu_qmgc_form / gxmu_wxxzx_project / cms_manuscript(5 行) SELECT module, title, sort_number FROM gxmu_module_registry WHERE deleted = 0 AND enabled = 1 diff --git a/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java b/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java index 8a0a71f..fccb7b0 100644 --- a/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java +++ b/src/main/java/com/gxwebsoft/cms/controller/ManuscriptController.java @@ -10,12 +10,7 @@ import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.system.entity.User; -import com.gxwebsoft.gxmu.entity.ReviewFlow; -import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; -import com.gxwebsoft.gxmu.entity.ReviewList; -import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; -import com.gxwebsoft.gxmu.service.ReviewFlowService; -import com.gxwebsoft.gxmu.service.ReviewListService; +import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.security.access.prepost.PreAuthorize; @@ -38,11 +33,7 @@ public class ManuscriptController extends BaseController { @Resource private ManuscriptService manuscriptService; @Resource - private ReviewFlowConfigService reviewFlowConfigService; - @Resource - private ReviewFlowService reviewFlowService; - @Resource - private ReviewListService reviewListService; + private ReviewFlowStarterService reviewFlowStarterService; @ApiOperation("分页查询稿件") @GetMapping("/page") @@ -75,23 +66,8 @@ public class ManuscriptController extends BaseController { manuscript.setUserId(loginUser.getUserId()); } if (manuscriptService.save(manuscript)) { - // 查找该模块的审核流 - ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule("cms_manuscript"); - if (reviewFlowConfig != null) { - ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId()); - if (reviewFlow != null) { - List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle()); - if (reviewFlowList != null && !reviewFlowList.isEmpty()) { - ReviewFlow firstOne = reviewFlowList.get(0); - ReviewList reviewList = new ReviewList(); - reviewList.setPk(manuscript.getId()); - reviewList.setModule("cms_manuscript"); - reviewList.setUserId(firstOne.getReviewUserId()); - reviewList.setSortNumber(0); - reviewListService.save(reviewList); - } - } - } + // 首级审批人同样经 ReviewerResolver 解析(与其它模块同一条路径),不复制 review_user_id + reviewFlowStarterService.startReview("cms_manuscript", manuscript.getId()); return success("添加成功"); } return fail("添加失败"); diff --git a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java index bdb0cfb..b8fc6bf 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java +++ b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java @@ -3,7 +3,6 @@ package com.gxwebsoft.common.core.security; import cn.hutool.core.util.StrUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.springframework.util.AntPathMatcher; import java.util.ArrayList; import java.util.Collections; @@ -17,12 +16,15 @@ import java.util.List; * * *

规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求, - * 因此可以在单元测试里直接验证。 + * 因此可以在单元测试里直接验证模式的内容与顺序——顺序恰恰是这条改造的关键: + * 下载必须在公开模式之前注册,公开模式必须在 {@code GET /**} 兜底之前注册。 * * @author Codex * @since 2026-11-03 @@ -50,20 +52,18 @@ public class FileAccessRules { */ private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**"; - private final AntPathMatcher pathMatcher = new AntPathMatcher(); - /** * 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠 */ private final List publicDirs; /** - * 内联资源(预览 + 缩略图)是否默认公开 + * 内联资源(预览 + 缩略图)是否默认公开;默认 false,只有显式打开才整体放行 */ private final boolean publicPreview; public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs, - @Value("${file.public-preview:true}") boolean publicPreview) { + @Value("${file.public-preview:false}") boolean publicPreview) { this.publicDirs = normalize(publicDirs); this.publicPreview = publicPreview; } @@ -71,7 +71,8 @@ public class FileAccessRules { /** * 匿名放行的文件路径模式。 * - *

内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。 + *

默认({@code public-preview=false})只有 {@code file.public-dirs} 白名单里的目录; + * 显式打开内联公开时才额外放行预览与缩略图,且排在白名单之前。 * * @return antMatchers 模式,没有任何公开项时返回空列表 */ @@ -104,31 +105,6 @@ public class FileAccessRules { return Collections.singletonList(DOWNLOAD_PATTERN); } - /** - * 这条文件请求是否按公开资源处理(匿名可读)。 - * - * @param path 请求路径,可带查询串 - * @return true 表示命中公开规则;下载、未命中目录、非文件路径一律 false - */ - public boolean isPublicFileRequest(String path) { - if (StrUtil.isBlank(path)) { - return false; - } - String clean = StrUtil.subBefore(path, "?", false); - if (!clean.startsWith(FILE_PATH_PREFIX + "/")) { - return false; - } - if (pathMatcher.match(DOWNLOAD_PATTERN, clean)) { - return false; - } - for (String pattern : permitAllPatterns()) { - if (pathMatcher.match(pattern, clean)) { - return true; - } - } - return false; - } - private List publicDirPatterns() { if (publicDirs.isEmpty()) { return Collections.emptyList(); diff --git a/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java b/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java new file mode 100644 index 0000000..3612904 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/constant/ReviewStatus.java @@ -0,0 +1,26 @@ +package com.gxwebsoft.gxmu.constant; + +/** + * 审核待办(gxmu_review_list.status)的状态取值。 + * + *

这套取值与申报记录的 declare_status(0草稿 1待审核 2通过 3驳回 4作废,见 + * {@link com.gxwebsoft.gxmu.service.impl.DeclareGuardServiceImpl} 的 STATUS_* 常量) + * 是两套语义不同的编码:同为 "2",这里是驳回、那里是通过,因此不要混用字面量。 + * + * @author Codex + * @since 2026-11-11 + */ +public final class ReviewStatus { + + /** 待审核:当前一级的责任人可以审批 */ + public static final int PENDING = 0; + + /** 通过:节点已处理,链条推进到下一级 */ + public static final int APPROVED = 1; + + /** 驳回:节点已处理,申报退回申报人 */ + public static final int REJECTED = 2; + + private ReviewStatus() { + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/QmgcFormController.java b/src/main/java/com/gxwebsoft/gxmu/controller/QmgcFormController.java index 82c06ef..20dcf33 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/QmgcFormController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/QmgcFormController.java @@ -15,6 +15,7 @@ import com.gxwebsoft.gxmu.param.QmgcFormParam; import com.gxwebsoft.gxmu.service.QmgcFormService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.model.DeclareCheckResult; +import com.gxwebsoft.gxmu.service.CollegeResolver; import com.gxwebsoft.gxmu.service.DeclareGuardService; import org.springframework.transaction.annotation.Transactional; import com.gxwebsoft.gxmu.util.GxmuQueryHelper; @@ -42,6 +43,8 @@ public class QmgcFormController extends BaseController { @Resource private QmgcFormService qmgcFormService; @Resource + private CollegeResolver collegeResolver; + @Resource private ReviewFlowStarterService reviewFlowStarterService; @Resource private DeclareGuardService declareGuardService; @@ -145,6 +148,9 @@ public class QmgcFormController extends BaseController { if (loginUser != null) { qmgcForm.setUserId(loginUser.getUserId()); } + // 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错): + // 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量 + qmgcForm.setCollegeId(collegeResolver.fillIfAbsent(qmgcForm.getCollegeId(), qmgcForm.getUserId())); DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_qmgc_form", qmgcForm.getDeclareId(), qmgcForm.getYear(), qmgcForm.getUserId()); if (declareCheck.isRejected()) { diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java index 5ef157b..5b7cf22 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java @@ -1,33 +1,26 @@ package com.gxwebsoft.gxmu.controller; import com.gxwebsoft.common.core.web.BaseController; -import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator; import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.param.ReviewFlowParam; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.PageResult; -import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.BatchParam; -import com.gxwebsoft.common.core.annotation.OperationLog; -import com.gxwebsoft.common.system.entity.User; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; -import java.io.Serializable; -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashSet; +import java.util.Collections; import java.util.List; -import java.util.Objects; -import java.util.Set; /** * 审核流控制器 * + *

层级连续性(从 1 连续升序)是审批链的业务规则,校验落在 + * {@link ReviewFlowService#validateLevelsOnSave} 等方法的实现里;controller 只负责在写操作前调用一次。 + * * @author LX * @since 2026-03-18 10:37:18 */ @@ -69,12 +62,7 @@ public class ReviewFlowController extends BaseController { @ApiOperation("添加审核流") @PostMapping() public ApiResult save(@RequestBody ReviewFlow reviewFlow) { - validateLevels(reviewFlow == null ? null : java.util.Collections.singletonList(reviewFlow)); - // 记录当前登录用户id -// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle()); -// if (checkByName != null) { -// return fail("该名称已存在"); -// } + reviewFlowService.validateLevelsOnSave(reviewFlow == null ? null : Collections.singletonList(reviewFlow)); if (reviewFlowService.save(reviewFlow)) { return success("添加成功"); } @@ -84,11 +72,7 @@ public class ReviewFlowController extends BaseController { @ApiOperation("修改审核流") @PutMapping() public ApiResult update(@RequestBody ReviewFlow reviewFlow) { - validateUpdate(reviewFlow); -// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle()); -// if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) { -// return fail("该名称已存在"); -// } + reviewFlowService.validateLevelsOnUpdate(reviewFlow); if (reviewFlowService.updateById(reviewFlow)) { return success("修改成功"); } @@ -98,6 +82,7 @@ public class ReviewFlowController extends BaseController { @ApiOperation("删除审核流") @DeleteMapping("/{id}") public ApiResult remove(@PathVariable("id") Integer id) { + reviewFlowService.validateLevelsOnRemove(Collections.singletonList(id)); if (reviewFlowService.removeById(id)) { return success("删除成功"); } @@ -107,7 +92,7 @@ public class ReviewFlowController extends BaseController { @ApiOperation("批量添加审核流") @PostMapping("/batch") public ApiResult saveBatch(@RequestBody List list) { - validateLevels(list); + reviewFlowService.validateLevelsOnSave(list); if (reviewFlowService.saveBatch(list)) { return success("添加成功"); } @@ -117,145 +102,17 @@ public class ReviewFlowController extends BaseController { @ApiOperation("批量修改审核流") @PutMapping("/batch") public ApiResult updateBatch(@RequestBody BatchParam batchParam) { - validateBatchUpdate(batchParam); + reviewFlowService.validateLevelsOnBatchUpdate(batchParam); if (batchParam.update(reviewFlowService, "id")) { return success("修改成功"); } return fail("修改失败"); } - /** - * 校验"保存后这条审批链的层级从 1 连续升序"(spec #58 行为约定)。 - * - *

管理端是"先删旧节点、再逐级新增",因此单条保存时必须把库里同 title 的已有节点一起看, - * 否则逐级保存会被误判为缺级。 - */ - private void validateLevels(List incoming) { - if (incoming == null || incoming.isEmpty()) { - return; - } - List proposed = new ArrayList<>(); - Set titles = new LinkedHashSet<>(); - Set replacedIds = new LinkedHashSet<>(); - for (ReviewFlow flow : incoming) { - if (flow == null) { - continue; - } - proposed.add(flow); - if (flow.getId() != null) { - replacedIds.add(flow.getId()); - } - if (flow.getTitle() != null) { - titles.add(flow.getTitle().trim()); - } - } - for (String title : titles) { - List existing = reviewFlowService.listByTitle(title); - if (existing == null) { - continue; - } - for (ReviewFlow flow : existing) { - if (flow != null && flow.getId() != null && !replacedIds.contains(flow.getId())) { - proposed.add(flow); - } - } - } - ReviewFlowLevelValidator.validate(proposed); - } - - /** - * 修改单条节点:未传的字段沿用库里原值(MyBatis-Plus 同样忽略 null),再校验受影响链的连续性。 - */ - private void validateUpdate(ReviewFlow reviewFlow) { - if (reviewFlow == null) { - return; - } - ReviewFlow existing = reviewFlow.getId() == null ? null : reviewFlowService.getById(reviewFlow.getId()); - ReviewFlow merged = merge(existing, reviewFlow); - validateLevels(java.util.Collections.singletonList(merged)); - if (existing != null && !sameTitle(existing.getTitle(), merged.getTitle())) { - validateRemaining(existing.getTitle(), java.util.Collections.singletonList(reviewFlow.getId())); - } - } - - /** - * 批量修改:BatchParam 只带一个 data,id 集合里每行都按同样字段更新。 - */ - private void validateBatchUpdate(BatchParam batchParam) { - if (batchParam == null || batchParam.getData() == null - || batchParam.getIds() == null || batchParam.getIds().isEmpty()) { - return; - } - List ids = new ArrayList<>(); - for (Serializable id : batchParam.getIds()) { - if (id instanceof Number) { - ids.add(((Number) id).intValue()); - } - } - if (ids.isEmpty()) { - return; - } - List existingRows = reviewFlowService.listByIds(ids); - if (existingRows == null || existingRows.isEmpty()) { - return; - } - List mergedRows = new ArrayList<>(); - List oldTitles = new ArrayList<>(); - for (ReviewFlow existing : existingRows) { - if (existing == null) { - continue; - } - mergedRows.add(merge(existing, batchParam.getData())); - oldTitles.add(existing.getTitle()); - } - validateLevels(mergedRows); - Set newTitles = new LinkedHashSet<>(); - for (ReviewFlow row : mergedRows) { - newTitles.add(row.getTitle() == null ? "" : row.getTitle().trim()); - } - for (String oldTitle : oldTitles) { - if (!newTitles.contains(oldTitle == null ? "" : oldTitle.trim())) { - validateRemaining(oldTitle, ids); - } - } - } - - /** - * 校验改名/移走后原审批链剩下的节点仍连续。 - */ - private void validateRemaining(String title, Collection excludedIds) { - List remaining = new ArrayList<>(); - List existing = title == null ? null : reviewFlowService.listByTitle(title); - if (existing != null) { - for (ReviewFlow flow : existing) { - if (flow != null && !excludedIds.contains(flow.getId())) { - remaining.add(flow); - } - } - } - ReviewFlowLevelValidator.validate(remaining); - } - - private ReviewFlow merge(ReviewFlow existing, ReviewFlow incoming) { - if (existing == null) { - return incoming; - } - if (incoming.getTitle() != null) { - existing.setTitle(incoming.getTitle()); - } - if (incoming.getLevel() != null) { - existing.setLevel(incoming.getLevel()); - } - return existing; - } - - private boolean sameTitle(String left, String right) { - return Objects.equals(left == null ? null : left.trim(), right == null ? null : right.trim()); - } - @ApiOperation("批量删除审核流") @DeleteMapping("/batch") public ApiResult removeBatch(@RequestBody List ids) { + reviewFlowService.validateLevelsOnRemove(ids); if (reviewFlowService.removeByIds(ids)) { return success("删除成功"); } diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java index de68ad3..548c99f 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java @@ -3,19 +3,15 @@ package com.gxwebsoft.gxmu.controller; import cn.hutool.core.util.StrUtil; import com.gxwebsoft.common.core.Constants; import com.gxwebsoft.common.core.web.BaseController; -import com.gxwebsoft.gxmu.entity.ReviewFlow; -import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; -import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; -import com.gxwebsoft.gxmu.service.ReviewFlowService; +import com.gxwebsoft.gxmu.constant.ReviewStatus; +import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.param.ReviewListParam; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.PageResult; -import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.BatchParam; -import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.system.entity.User; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -23,11 +19,16 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; +import java.io.Serializable; import java.util.List; /** * 审核列表控制器 * + *

审批动作(通过 / 驳回)的归属校验、状态校验留在这里(HTTP 层的准入判断); + * 通过后「推进到下一级」属于审批链业务规则,交给 {@link ReviewFlowStarterService#advanceReview}, + * 审批人一律经 {@link com.gxwebsoft.gxmu.service.ReviewerResolver} 解析,不再复制 review_user_id。 + * * @author LX * @since 2026-03-18 15:50:51 */ @@ -38,11 +39,9 @@ public class ReviewListController extends BaseController { @Resource private ReviewListService reviewListService; @Resource - private ReviewFlowConfigService reviewFlowConfigService; - @Resource - private ReviewFlowService reviewFlowService; - @Resource private DeclareGuardService declareGuardService; + @Resource + private ReviewFlowStarterService reviewFlowStarterService; @ApiOperation("分页查询审核列表") @GetMapping("/page") @@ -100,8 +99,14 @@ public class ReviewListController extends BaseController { @ApiOperation("修改审核列表") @PutMapping() public ApiResult update(@RequestBody ReviewList reviewList) { - // 审批动作(通过/驳回/撤销)只能由待办的责任人本人执行 - ApiResult denied = denyIfNotAssignee(reviewList.getId()); + // 审批动作(通过 / 驳回)只能由待办的责任人本人执行 + ReviewList existing = reviewList.getId() == null ? null : reviewListService.getById(reviewList.getId()); + ApiResult denied = denyIfNotAssignee(existing); + if (denied != null) { + return denied; + } + // 已处理过的节点再提交直接拒绝:否则会再插一条下一级待办,同一申报出现第二组活跃节点 + denied = denyIfAlreadyHandled(existing); if (denied != null) { return denied; } @@ -110,34 +115,10 @@ public class ReviewListController extends BaseController { return denied; } if (reviewListService.updateById(reviewList)) { - // 通过进入下一个 - if (Integer.valueOf(1).equals(reviewList.getStatus())) { - ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule()); - if (reviewFlowConfig != null) { - ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId()); - if (reviewFlow != null) { - List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle()); - if (reviewFlowList != null && !reviewFlowList.isEmpty()) { - if (reviewList.getSortNumber() + 1 < reviewFlowList.size()) { - ReviewFlow nextOne = reviewFlowList.get(reviewList.getSortNumber() + 1); - if (nextOne != null) { - ReviewList nextReviewList = new ReviewList(); - nextReviewList.setPk(reviewList.getPk()); - nextReviewList.setModule(reviewList.getModule()); - nextReviewList.setUserId(nextOne.getReviewUserId()); - nextReviewList.setSortNumber(reviewList.getSortNumber() + 1); - reviewListService.save(nextReviewList); - } - } -// switch (reviewList.getModule()) { -// case "cms_manuscript": { -// // 社团指导老师/二级学院团委书记-团委社团管理部/团委办公室-学校团委组织宣传部,拟发布后,再提交给团委副书记-团委书记 -// if () -// } -// } - } - } - } + // 通过则推进到下一级;下一级的审批人由审批链配置解析,不复制本级的 review_user_id + if (Integer.valueOf(ReviewStatus.APPROVED).equals(reviewList.getStatus())) { + reviewFlowStarterService.advanceReview(reviewList.getModule(), reviewList.getPk(), + existing == null ? null : existing.getSortNumber()); } declareGuardService.syncReviewStatus(reviewList.getModule(), reviewList.getPk()); return success("修改成功"); @@ -166,11 +147,15 @@ public class ReviewListController extends BaseController { @ApiOperation("批量修改审核列表") @PutMapping("/batch") public ApiResult updateBatch(@RequestBody BatchParam batchParam) { - // 批量审批逐行校验归属,只要有一行不是自己的就整体拒绝,避免部分落库 + // 批量审批逐行校验归属与状态,只要有一行不满足就整体拒绝,避免部分落库 ApiResult denied = denyIfBatchNotAssignee(batchParam); if (denied != null) { return denied; } + denied = denyIfBatchAlreadyHandled(batchParam); + if (denied != null) { + return denied; + } denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData()); if (denied != null) { return denied; @@ -191,7 +176,10 @@ public class ReviewListController extends BaseController { * @return null 表示校验通过,否则返回 403 结果 */ private ApiResult denyIfNotAssignee(Integer id) { - ReviewList existing = id == null ? null : reviewListService.getById(id); + return denyIfNotAssignee(id == null ? null : reviewListService.getById(id)); + } + + private ApiResult denyIfNotAssignee(ReviewList existing) { Integer loginUserId = getLoginUserId(); if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) { return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG); @@ -199,6 +187,25 @@ public class ReviewListController extends BaseController { return null; } + /** + * 当前节点必须仍是「待审核」才允许提交审批动作。 + * + *

重复通过会给同一申报再造一组待办(gxmu_review_list 没有 (module, pk, level) 唯一键兜底), + * 双击或多标签页即可复现,所以在落库前就拒绝。 + * + * @param existing 库里现有的待办行 + * @return null 表示仍是待审核,否则返回可读的失败结果 + */ + private ApiResult denyIfAlreadyHandled(ReviewList existing) { + if (existing == null || existing.getStatus() == null) { + return null; + } + if (existing.getStatus() != ReviewStatus.PENDING) { + return fail("该审批已处理(通过 / 驳回),请勿重复提交"); + } + return null; + } + /** * 批量审批的归属校验:逐行确认都属于当前用户。 * @@ -209,7 +216,7 @@ public class ReviewListController extends BaseController { if (batchParam == null || batchParam.getIds() == null) { return null; } - for (java.io.Serializable id : batchParam.getIds()) { + for (Serializable id : batchParam.getIds()) { ApiResult denied = denyIfNotAssignee(id instanceof Number ? ((Number) id).intValue() : null); if (denied != null) { return denied; @@ -218,6 +225,26 @@ public class ReviewListController extends BaseController { return null; } + /** + * 批量审批的状态校验:任意一行已处理就整体拒绝。 + * + * @param batchParam 批量参数 + * @return null 表示全部仍是待审核,否则返回可读的失败结果 + */ + private ApiResult denyIfBatchAlreadyHandled(BatchParam batchParam) { + if (batchParam == null || batchParam.getIds() == null) { + return null; + } + for (Serializable id : batchParam.getIds()) { + ReviewList existing = id instanceof Number ? reviewListService.getById(((Number) id).intValue()) : null; + ApiResult denied = denyIfAlreadyHandled(existing); + if (denied != null) { + return denied; + } + } + return null; + } + /** * 驳回必须携带意见,否则申报人不知道要改什么。 * @@ -225,7 +252,8 @@ public class ReviewListController extends BaseController { * @return null 表示校验通过,否则返回可读的失败结果 */ private ApiResult denyIfRejectWithoutContent(ReviewList data) { - if (data != null && Integer.valueOf(2).equals(data.getStatus()) && StrUtil.isBlank(data.getContent())) { + if (data != null && Integer.valueOf(ReviewStatus.REJECTED).equals(data.getStatus()) + && StrUtil.isBlank(data.getContent())) { return fail("驳回必须填写审核意见"); } return null; @@ -236,7 +264,7 @@ public class ReviewListController extends BaseController { if (batchParam == null || batchParam.getIds() == null) { return; } - for (java.io.Serializable id : batchParam.getIds()) { + for (Serializable id : batchParam.getIds()) { ReviewList saved = reviewListService.getById(id); if (saved != null) { declareGuardService.syncReviewStatus(saved.getModule(), saved.getPk()); @@ -283,4 +311,4 @@ public class ReviewListController extends BaseController { } } -} \ No newline at end of file +} diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/SjqnFormController.java b/src/main/java/com/gxwebsoft/gxmu/controller/SjqnFormController.java index 38d9bf9..cf84fd1 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/SjqnFormController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/SjqnFormController.java @@ -16,6 +16,7 @@ import com.gxwebsoft.gxmu.entity.SjqnForm; import com.gxwebsoft.gxmu.param.SjqnFormParam; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.model.DeclareCheckResult; +import com.gxwebsoft.gxmu.service.CollegeResolver; import com.gxwebsoft.gxmu.service.DeclareGuardService; import org.springframework.transaction.annotation.Transactional; import com.gxwebsoft.gxmu.service.SjqnFormService; @@ -46,6 +47,8 @@ public class SjqnFormController extends BaseController { @Resource private SjqnFormService sjqnFormService; @Resource + private CollegeResolver collegeResolver; + @Resource private ReviewFlowStarterService reviewFlowStarterService; @Resource private DeclareGuardService declareGuardService; @@ -162,6 +165,9 @@ public class SjqnFormController extends BaseController { if (loginUser != null) { sjqnForm.setUserId(loginUser.getUserId()); } + // 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错): + // 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量 + sjqnForm.setCollegeId(collegeResolver.fillIfAbsent(sjqnForm.getCollegeId(), sjqnForm.getUserId())); DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_sjqn", sjqnForm.getDeclareId(), sjqnForm.getYear(), sjqnForm.getUserId()); if (declareCheck.isRejected()) { diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/TzbcyFormController.java b/src/main/java/com/gxwebsoft/gxmu/controller/TzbcyFormController.java index b935226..00603f7 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/TzbcyFormController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/TzbcyFormController.java @@ -11,6 +11,7 @@ import com.gxwebsoft.gxmu.entity.TzbcyForm; import com.gxwebsoft.gxmu.model.TzbcyStatisticsResult; import com.gxwebsoft.gxmu.param.TzbcyFormParam; import com.gxwebsoft.gxmu.model.DeclareCheckResult; +import com.gxwebsoft.gxmu.service.CollegeResolver; import com.gxwebsoft.gxmu.service.DeclareGuardService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import org.springframework.transaction.annotation.Transactional; @@ -38,6 +39,8 @@ public class TzbcyFormController extends BaseController { @Resource private TzbcyFormService tzbcyFormService; @Resource + private CollegeResolver collegeResolver; + @Resource private ReviewFlowStarterService reviewFlowStarterService; @Resource private DeclareGuardService declareGuardService; @@ -99,6 +102,9 @@ public class TzbcyFormController extends BaseController { if (loginUser != null) { tzbcyForm.setUserId(loginUser.getUserId()); } + // 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错): + // 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量 + tzbcyForm.setCollegeId(collegeResolver.fillIfAbsent(tzbcyForm.getCollegeId(), tzbcyForm.getUserId())); DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_tzbcy_form", tzbcyForm.getDeclareId(), tzbcyForm.getYear(), tzbcyForm.getUserId()); if (declareCheck.isRejected()) { diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/WxxzxFormController.java b/src/main/java/com/gxwebsoft/gxmu/controller/WxxzxFormController.java index 46c7812..f6a7614 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/WxxzxFormController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/WxxzxFormController.java @@ -27,6 +27,7 @@ import com.gxwebsoft.gxmu.param.WxxzxFormParam; import com.gxwebsoft.gxmu.service.QmgcFormService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.model.DeclareCheckResult; +import com.gxwebsoft.gxmu.service.CollegeResolver; import com.gxwebsoft.gxmu.service.DeclareGuardService; import org.springframework.transaction.annotation.Transactional; import com.gxwebsoft.gxmu.service.SjqnFormService; @@ -100,6 +101,8 @@ public class WxxzxFormController extends BaseController { @Resource private WxxzxFormService wxxzxFormService; @Resource + private CollegeResolver collegeResolver; + @Resource private SjqnFormService sjqnFormService; @Resource private SjtbzbsjFormService sjtbzbsjFormService; @@ -254,6 +257,9 @@ public class WxxzxFormController extends BaseController { if (loginUser != null) { wxxzxForm.setUserId(loginUser.getUserId()); } + // 新申报没有学院标识时按学籍补齐(没有学籍 / 学院不唯一就留空,不报错): + // 审批链的 COLLEGE_ROLE 规则要靠它定位学院,一次性回填只解决存量 + wxxzxForm.setCollegeId(collegeResolver.fillIfAbsent(wxxzxForm.getCollegeId(), wxxzxForm.getUserId())); DeclareCheckResult declareCheck = declareGuardService.checkBeforeCreate("gxmu_wxxzx_project", wxxzxForm.getDeclareId(), wxxzxForm.getYear(), wxxzxForm.getUserId()); if (declareCheck.isRejected()) { diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/QmgcForm.java b/src/main/java/com/gxwebsoft/gxmu/entity/QmgcForm.java index ff60c4a..eb25330 100644 --- a/src/main/java/com/gxwebsoft/gxmu/entity/QmgcForm.java +++ b/src/main/java/com/gxwebsoft/gxmu/entity/QmgcForm.java @@ -117,6 +117,9 @@ public class QmgcForm implements Serializable { @ApiModelProperty("用户ID") private Integer userId; + @ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空") + private Integer collegeId; + @ApiModelProperty("是否删除") @TableLogic private Integer deleted; diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/SjqnForm.java b/src/main/java/com/gxwebsoft/gxmu/entity/SjqnForm.java index a9d3a0d..c91b9ff 100644 --- a/src/main/java/com/gxwebsoft/gxmu/entity/SjqnForm.java +++ b/src/main/java/com/gxwebsoft/gxmu/entity/SjqnForm.java @@ -92,6 +92,9 @@ public class SjqnForm implements Serializable { @ApiModelProperty("用户ID") private Integer userId; + @ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空") + private Integer collegeId; + @ApiModelProperty("是否删除") @TableLogic private Integer deleted; diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/TzbcyForm.java b/src/main/java/com/gxwebsoft/gxmu/entity/TzbcyForm.java index 8ac524b..22803cc 100644 --- a/src/main/java/com/gxwebsoft/gxmu/entity/TzbcyForm.java +++ b/src/main/java/com/gxwebsoft/gxmu/entity/TzbcyForm.java @@ -114,6 +114,9 @@ public class TzbcyForm implements Serializable { @ApiModelProperty("用户ID") private Integer userId; + @ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空") + private Integer collegeId; + @ApiModelProperty("是否删除") @TableLogic private Integer deleted; diff --git a/src/main/java/com/gxwebsoft/gxmu/entity/WxxzxForm.java b/src/main/java/com/gxwebsoft/gxmu/entity/WxxzxForm.java index 3031b0c..a86d2d7 100644 --- a/src/main/java/com/gxwebsoft/gxmu/entity/WxxzxForm.java +++ b/src/main/java/com/gxwebsoft/gxmu/entity/WxxzxForm.java @@ -143,6 +143,9 @@ public class WxxzxForm implements Serializable { @ApiModelProperty("用户ID") private Integer userId; + @ApiModelProperty("学院标识(sys_organization.organization_id),新建申报时按学籍补齐,允许为空") + private Integer collegeId; + @ApiModelProperty("是否删除") @TableLogic private Integer deleted; diff --git a/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java b/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java index f5c6b8a..5d7aea7 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java @@ -26,6 +26,10 @@ import java.util.Set; *

  • 注册表引用的业务表在库里真实存在。
  • * * + *

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

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

    这类模块的标题会回退成裸 module 串、也不会出现在模块下拉里, + * 但存量申报记录仍要能查——所以只提示补注册表行,不抛异常。 + * + * @param modules 注册表里的全部模块 + */ + private void warnModulesMissingFromRegistry(List modules) { + Set registered = new HashSet<>(); + for (ModuleRegistry item : modules) { + if (item.getModule() != null) { + registered.add(item.getModule()); + } + } + List configured = moduleRegistryMapper.selectConfiguredModules(); + if (configured == null || configured.isEmpty()) { + return; + } + for (String module : configured) { + if (module != null && !module.isEmpty() && !registered.contains(module)) { + log.warn("gxmu_review_flow_config 里的模块 {} 未在 gxmu_module_registry 登记:" + + "该模块的标题会回退成 module 原串、也不会出现在模块下拉里;" + + "请补一行注册表数据(enabled 按是否新开申报入口决定)", module); + } + } + } + /** * 执行校验;不一致时抛 {@link IllegalStateException} 让启动失败。 */ @@ -91,6 +123,7 @@ public class ModuleRegistryIntegrityChecker implements ApplicationRunner { throw new IllegalStateException("模块注册表引用的业务表不存在:" + String.join(", ", missing) + "。请先执行建表脚本或修正注册表数据;本地无库启动时可设置 gxmu.module-registry.strict=false"); } + warnModulesMissingFromRegistry(modules); log.info("模块注册表一致性校验通过:{} 个模块,{} 张业务表", modules.size(), tableNames.size()); } } diff --git a/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java b/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java index 59ada4a..6d68843 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java @@ -3,21 +3,27 @@ package com.gxwebsoft.gxmu.module.controller; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.gxmu.module.entity.ModuleRegistry; +import com.gxwebsoft.gxmu.module.model.ModuleRegistryDTO; import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; +import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; /** * 模块注册表控制器 * - *

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

    前端启动时一次性拉取模块清单并缓存:默认只返回启用模块(新链的模块下拉、申报入口); + * {@code all=true} 返回全部模块(含停用,审核列表的模块筛选要能捞出停用模块的存量记录)。 * * @author Codex * @since 2026-11-10 @@ -30,10 +36,16 @@ public class ModuleRegistryController extends BaseController { @Resource private ModuleRegistryService moduleRegistryService; - @ApiOperation("启用模块清单") + @ApiOperation("模块清单:默认只返回启用模块,all=true 返回全部(含停用)") @PreAuthorize("isAuthenticated()") @GetMapping("/list") - public ApiResult> list() { - return success(moduleRegistryService.listEnabled()); + public ApiResult> list( + @ApiParam("是否返回全部模块(含停用):true 返回全部,默认只返回启用") + @RequestParam(value = "all", required = false, defaultValue = "false") boolean all) { + List modules = all ? moduleRegistryService.listAll() : moduleRegistryService.listEnabled(); + if (modules == null || modules.isEmpty()) { + return success(new ArrayList<>()); + } + return success(modules.stream().map(ModuleRegistryDTO::of).collect(Collectors.toList())); } } diff --git a/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java b/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java index b2b9836..50b6907 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java @@ -46,6 +46,9 @@ public class ModuleRegistry implements Serializable { @ApiModelProperty(value = "导出模板标识,空表示走默认导出") private String exportTemplate; + @ApiModelProperty(value = "业务表是否有学院标识列:0否 1是(决定审批人解析是否读 college_id)") + private Integer hasCollege; + @ApiModelProperty(value = "是否启用:0否 1是") private Integer enabled; diff --git a/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java b/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java index 1615f6a..ff14810 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java @@ -28,4 +28,15 @@ public interface ModuleRegistryMapper extends BaseMapper { + "#{name}" + "") List selectExistingTableNames(@Param("names") Collection names); + + /** + * 审批链配置里出现过的模块名,用于反向校验「有审批链但注册表没登记」的模块。 + * + *

    只用于启动期告警,不阻断启动:存量脏数据不该挡住上线。 + * + * @return 审批链配置里的模块名(去重) + */ + @Select("SELECT DISTINCT module FROM gxmu_review_flow_config " + + "WHERE deleted = 0 AND module IS NOT NULL AND module <> ''") + List selectConfiguredModules(); } diff --git a/src/main/java/com/gxwebsoft/gxmu/module/model/ModuleRegistryDTO.java b/src/main/java/com/gxwebsoft/gxmu/module/model/ModuleRegistryDTO.java new file mode 100644 index 0000000..6bb2b6c --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/model/ModuleRegistryDTO.java @@ -0,0 +1,65 @@ +package com.gxwebsoft.gxmu.module.model; + +import com.gxwebsoft.gxmu.module.entity.ModuleRegistry; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 模块注册表的对外视图(spec #58 API 契约)。 + * + *

    只暴露前端需要的元数据;tenantId / userId / createTime / updateTime 是库内字段, + * 对前端无意义,不随接口外泄。 + * + * @author Codex + * @since 2026-11-11 + */ +@Data +@ApiModel(value = "ModuleRegistryDTO对象", description = "模块注册表对外视图") +public class ModuleRegistryDTO implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty(value = "模块标识,同时是业务表名") + private String module; + + @ApiModelProperty(value = "业务表名") + private String tableName; + + @ApiModelProperty(value = "模块标题") + private String title; + + @ApiModelProperty(value = "详情页配置键") + private String detailKey; + + @ApiModelProperty(value = "导出模板标识,空表示走默认导出") + private String exportTemplate; + + @ApiModelProperty(value = "是否启用:0否 1是") + private Integer enabled; + + @ApiModelProperty(value = "排序号,小的在前") + private Integer sortNumber; + + /** + * 由注册表行构造对外视图。 + * + * @param item 注册表行,可为空 + * @return 对外视图,入参为空时返回 null + */ + public static ModuleRegistryDTO of(ModuleRegistry item) { + if (item == null) { + return null; + } + ModuleRegistryDTO dto = new ModuleRegistryDTO(); + dto.setModule(item.getModule()); + dto.setTableName(item.getTableName()); + dto.setTitle(item.getTitle()); + dto.setDetailKey(item.getDetailKey()); + dto.setExportTemplate(item.getExportTemplate()); + dto.setEnabled(item.getEnabled()); + dto.setSortNumber(item.getSortNumber()); + return dto; + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java b/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java index 40b9267..9833e9c 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java @@ -5,13 +5,12 @@ import com.gxwebsoft.gxmu.module.entity.ModuleRegistry; import java.util.List; import java.util.Map; -import java.util.Set; /** * 模块注册表服务:模块元数据的唯一读取入口。 * *

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

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

    放在接口上是因为注册表是模块元数据的唯一来源,而调用方(审核列表、审批链配置) + * 已经批量取了 {@link #titleMap()},不该为了一个标题再回查一次库。 + * + * @param titleMap 模块标题映射,可为空 + * @param module 模块编码,可为空 + * @return 展示用标题;module 为空时返回 null + */ + static String titleOf(Map titleMap, String module) { + if (module == null) { + return null; + } + String title = titleMap == null ? null : titleMap.get(module); + return title == null || title.isEmpty() ? module : title; + } } diff --git a/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java index c498f79..26bae5c 100644 --- a/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java @@ -8,7 +8,6 @@ import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import org.springframework.stereotype.Service; import java.util.Collections; -import java.util.Comparator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -27,6 +26,7 @@ public class ModuleRegistryServiceImpl extends ServiceImpl listEnabled() { @@ -35,6 +35,13 @@ public class ModuleRegistryServiceImpl extends ServiceImpl排序只在 SQL 里做一次:{@code sort_number} 升序(MySQL 把 NULL 排最前, + * 与「没设排序号的模块排在最前面」的口径一致),再按 {@code id} 升序保证稳定。 + * 内存里不再二次排序,避免 SQL 与 Java 两套 NULL 口径(SQL 前、Java 后)互相打架。 + */ @Override public List listAll() { // 用字符串列名而非 Lambda:注册表服务是纯读路径,且要在无 MyBatis 上下文的单测里可用 @@ -44,7 +51,7 @@ public class ModuleRegistryServiceImpl extends ServiceImpl titleMap() { Map titleMap = new LinkedHashMap<>(); @@ -82,24 +77,22 @@ public class ModuleRegistryServiceImpl extends ServiceImpl allModules() { + public boolean containsModule(String module) { + if (module == null || module.isEmpty()) { + return false; + } Set modules = new LinkedHashSet<>(); for (ModuleRegistry item : listAll()) { if (item.getModule() != null) { modules.add(item.getModule()); } } - return modules; + return modules.contains(module); } @Override - public boolean containsModule(String module) { - return module != null && !module.isEmpty() && allModules().contains(module); - } - - private Comparator sortOrder() { - return Comparator.comparingInt((ModuleRegistry item) -> - item.getSortNumber() == null ? Integer.MAX_VALUE : item.getSortNumber()) - .thenComparingInt(item -> item.getId() == null ? Integer.MAX_VALUE : item.getId()); + public boolean hasCollege(String module) { + ModuleRegistry item = getByModule(module); + return item != null && item.getHasCollege() != null && item.getHasCollege() == HAS_COLLEGE; } } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/CollegeResolver.java b/src/main/java/com/gxwebsoft/gxmu/service/CollegeResolver.java new file mode 100644 index 0000000..4a319d5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/service/CollegeResolver.java @@ -0,0 +1,34 @@ +package com.gxwebsoft.gxmu.service; + +/** + * 学院标识解析:把登录用户映射到申报记录的 college_id(spec #58 用户故事 2)。 + * + *

    申报表上的 college_id 指向 sys_organization.organization_id,与学生名册 + * gxmu_student.college_id 是同一套 ID(见 ADR 0006「学院标识」)。新建申报时按 + * 「登录用户 → gxmu_student.college_id」补齐,审批链里的 COLLEGE_ROLE 规则才有字段可用; + * 解析不到(没有学籍、名册未同步、学院不唯一)一律返回 null,不报错、不猜。 + * + * @author Codex + * @since 2026-11-11 + */ +public interface CollegeResolver { + + /** + * 按登录用户查学院标识。 + * + *

    同一用户有多条学籍时以「在校」那条为准;学院不唯一说明无法判断,返回 null。 + * + * @param userId 登录用户 id(sys_user.user_id),可为空 + * @return 学院标识,解析不到返回 null + */ + Integer resolve(Integer userId); + + /** + * 只在申报记录还没有学院标识时补齐;已有值一律保留(更新路径不覆盖已有值)。 + * + * @param currentCollegeId 申报记录当前的学院标识 + * @param userId 登录用户 id + * @return 最终写入的学院标识 + */ + Integer fillIfAbsent(Integer currentCollegeId, Integer userId); +} diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java index ad4b30c..8cc5d94 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java @@ -8,6 +8,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。 @@ -69,13 +70,6 @@ public final class ReviewFlowLevelValidator { } private static String join(List levels) { - StringBuilder builder = new StringBuilder(); - for (int index = 0; index < levels.size(); index++) { - if (index > 0) { - builder.append(", "); - } - builder.append(levels.get(index)); - } - return builder.toString(); + return levels.stream().map(String::valueOf).collect(Collectors.joining(", ")); } } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java index 1d7172b..7f3e2bc 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowService.java @@ -1,10 +1,12 @@ package com.gxwebsoft.gxmu.service; import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.param.ReviewFlowParam; +import java.util.Collection; import java.util.List; /** @@ -42,4 +44,40 @@ public interface ReviewFlowService extends IService { ReviewFlow getByTitle(String title); List listByTitle(String title); + + /** + * 保存前校验:保存后这条审批链的层级必须从 1 连续升序(spec #58 行为约定)。 + * + *

    管理端是"先删旧节点、再逐级新增",所以单条保存时必须把库里同 title 的已有节点一起看, + * 否则逐级保存会被误判为缺级。不合法时抛 + * {@link com.gxwebsoft.common.core.exception.BusinessException}。 + * + * @param incoming 本次提交的节点(可含多条审批链) + */ + void validateLevelsOnSave(List incoming); + + /** + * 修改单条节点前校验:未传的字段沿用库里原值,再校验受影响链的连续性; + * 若改了 title,还要校验原链剩下的节点仍连续。 + * + * @param incoming 提交的节点 + */ + void validateLevelsOnUpdate(ReviewFlow incoming); + + /** + * 批量修改前校验:id 集合里每行都按同样字段更新,合并后校验连续性, + * 并校验改名后原链剩余部分仍连续。 + * + * @param batchParam 批量参数 + */ + void validateLevelsOnBatchUpdate(BatchParam batchParam); + + /** + * 删除前校验:删除后受影响审批链剩下的节点必须仍从 1 连续升序。 + * + *

    删除前就已经不连续的链不做校验(否则存量脏数据会变成"永远删不掉")。 + * + * @param removedIds 待删除的节点 id + */ + void validateLevelsOnRemove(Collection removedIds); } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java index 9a6b233..e855037 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowStarterService.java @@ -23,4 +23,16 @@ public interface ReviewFlowStarterService { * @param pk 业务主键 */ void restartReviewWhenLastRejected(String module, Integer pk); + + /** + * 当前一级通过后推进到下一级:审批人同样走 {@link ReviewerResolver} 解析,不复制上一级的 review_user_id。 + * + *

    解析不出审批人时抛可读的业务异常且一个节点都不写(宁可卡住,也不生成无人认领的待办); + * 已经是最后一级则什么都不做,链条自然结束。 + * + * @param module 模块编码 + * @param pk 业务主键 + * @param currentSortNumber 当前已通过节点的顺序号(首级为 0,与 review_list.sort_number 同口径) + */ + void advanceReview(String module, Integer pk, Integer currentSortNumber); } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImpl.java new file mode 100644 index 0000000..143ee79 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImpl.java @@ -0,0 +1,62 @@ +package com.gxwebsoft.gxmu.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper; +import com.gxwebsoft.gxmu.service.CollegeResolver; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 学院标识解析实现:gxmu_student.college_id 是学院标识的唯一权威来源。 + * + *

    口径与一次性回填脚本 sql/20261110_backfill_college_id.sql 保持一致: + * 优先取「在校(status = 1)」那条学籍的学院;学院不唯一就留空交给人工, + * 不做层级推导、不猜。 + * + * @author Codex + * @since 2026-11-11 + */ +@Service +public class CollegeResolverImpl implements CollegeResolver { + + @Resource + private GxmuStudentMapper gxmuStudentMapper; + + @Override + public Integer resolve(Integer userId) { + if (userId == null) { + return null; + } + List students = gxmuStudentMapper.selectList(new LambdaQueryWrapper() + .select(GxmuStudent::getCollegeId, GxmuStudent::getStatus) + .eq(GxmuStudent::getUserId, userId) + .isNotNull(GxmuStudent::getCollegeId) + .orderByDesc(GxmuStudent::getStatus) + .orderByDesc(GxmuStudent::getId)); + if (students == null || students.isEmpty()) { + return null; + } + Integer collegeId = null; + for (GxmuStudent student : students) { + Integer candidate = student.getCollegeId(); + if (candidate == null) { + continue; + } + if (collegeId == null) { + collegeId = candidate; + } else if (!collegeId.equals(candidate)) { + // 同一用户的本科学院与研究生学院不同且没有可判定的在校行:留空,不猜 + return null; + } + } + return collegeId; + } + + @Override + public Integer fillIfAbsent(Integer currentCollegeId, Integer userId) { + return currentCollegeId != null ? currentCollegeId : resolve(userId); + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java index 046e3c3..e66b888 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/DeclareGuardServiceImpl.java @@ -2,6 +2,7 @@ package com.gxwebsoft.gxmu.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.gxmu.constant.ReviewStatus; import com.gxwebsoft.gxmu.entity.Declare; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; @@ -160,7 +161,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService { return; } boolean pending = nodes.stream() - .anyMatch(node -> node.getStatus() != null && node.getStatus() == 0); + .anyMatch(node -> node.getStatus() != null && node.getStatus() == ReviewStatus.PENDING); if (pending) { declareRecordMapper.updateStatus(module, recordId, STATUS_PENDING, null); return; @@ -181,7 +182,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService { return; } declareRecordMapper.updateStatus(module, recordId, - last.getStatus() == 2 ? STATUS_REJECTED : STATUS_PASSED, null); + last.getStatus() == ReviewStatus.REJECTED ? STATUS_REJECTED : STATUS_PASSED, null); } @Override @@ -200,7 +201,7 @@ public class DeclareGuardServiceImpl implements DeclareGuardService { return "只有待审核的申报可以撤回"; } boolean approved = reviewListService.listByModuleNPK(module, recordId).stream() - .anyMatch(node -> node.getStatus() != null && node.getStatus() == 1); + .anyMatch(node -> node.getStatus() != null && node.getStatus() == ReviewStatus.APPROVED); if (approved) { return "已有审核通过的记录不能撤回,请联系管理员处理"; } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java index 4d6c733..9cee673 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java @@ -99,17 +99,9 @@ public class ReviewFlowConfigServiceImpl extends ServiceImpl moduleTitleMap = moduleRegistryService.titleMap(); for (ReviewFlowConfig item : list) { item.setFlowName(flowNameMap.get(item.getFlowId())); - item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule())); + item.setModuleName(ModuleRegistryService.titleOf(moduleTitleMap, item.getModule())); item.setOrganizationName(organizationNameMap.get(item.getOrganizationId())); } } - private String moduleTitleOf(Map moduleTitleMap, String module) { - if (module == null) { - return null; - } - String title = moduleTitleMap.get(module); - return title == null || title.isEmpty() ? module : title; - } - } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java index 8c1fe90..939c4c9 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImpl.java @@ -2,10 +2,12 @@ package com.gxwebsoft.gxmu.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.system.entity.Organization; import com.gxwebsoft.common.system.service.OrganizationService; import com.gxwebsoft.common.system.service.UserService; import com.gxwebsoft.gxmu.mapper.ReviewFlowMapper; +import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator; import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.param.ReviewFlowParam; @@ -14,7 +16,13 @@ import com.gxwebsoft.common.core.web.PageResult; import org.springframework.stereotype.Service; import javax.annotation.Resource; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; /** * 审核流Service实现 @@ -91,4 +99,180 @@ public class ReviewFlowServiceImpl extends ServiceImpl incoming) { + if (incoming == null || incoming.isEmpty()) { + return; + } + List proposed = new ArrayList<>(); + Set titles = new LinkedHashSet<>(); + Set replacedIds = new LinkedHashSet<>(); + for (ReviewFlow flow : incoming) { + if (flow == null) { + continue; + } + proposed.add(flow); + if (flow.getId() != null) { + replacedIds.add(flow.getId()); + } + if (flow.getTitle() != null) { + titles.add(flow.getTitle().trim()); + } + } + for (String title : titles) { + List existing = listByTitle(title); + if (existing == null) { + continue; + } + for (ReviewFlow flow : existing) { + if (flow != null && flow.getId() != null && !replacedIds.contains(flow.getId())) { + proposed.add(flow); + } + } + } + ReviewFlowLevelValidator.validate(proposed); + } + + @Override + public void validateLevelsOnUpdate(ReviewFlow incoming) { + if (incoming == null) { + return; + } + ReviewFlow existing = incoming.getId() == null ? null : getById(incoming.getId()); + // merge 会就地改 existing,旧标题必须先存下来,否则「改名后原链剩余校验」永远不触发 + String oldTitle = existing == null ? null : existing.getTitle(); + ReviewFlow merged = merge(existing, incoming); + validateLevelsOnSave(java.util.Collections.singletonList(merged)); + if (existing != null && !sameTitle(oldTitle, merged.getTitle())) { + validateRemaining(oldTitle, java.util.Collections.singletonList(incoming.getId())); + } + } + + @Override + public void validateLevelsOnBatchUpdate(BatchParam batchParam) { + if (batchParam == null || batchParam.getData() == null + || batchParam.getIds() == null || batchParam.getIds().isEmpty()) { + return; + } + List ids = new ArrayList<>(); + for (Serializable id : batchParam.getIds()) { + if (id instanceof Number) { + ids.add(((Number) id).intValue()); + } + } + if (ids.isEmpty()) { + return; + } + List existingRows = listByIds(ids); + if (existingRows == null || existingRows.isEmpty()) { + return; + } + List mergedRows = new ArrayList<>(); + List oldTitles = new ArrayList<>(); + for (ReviewFlow existing : existingRows) { + if (existing == null) { + continue; + } + // 同上:merge 就地改 existing,旧标题要先取 + oldTitles.add(existing.getTitle()); + mergedRows.add(merge(existing, batchParam.getData())); + } + validateLevelsOnSave(mergedRows); + Set newTitles = new LinkedHashSet<>(); + for (ReviewFlow row : mergedRows) { + newTitles.add(row.getTitle() == null ? "" : row.getTitle().trim()); + } + for (String oldTitle : oldTitles) { + if (!newTitles.contains(oldTitle == null ? "" : oldTitle.trim())) { + validateRemaining(oldTitle, ids); + } + } + } + + @Override + public void validateLevelsOnRemove(Collection removedIds) { + if (removedIds == null || removedIds.isEmpty()) { + return; + } + List ids = new ArrayList<>(); + for (Integer id : removedIds) { + if (id != null) { + ids.add(id); + } + } + if (ids.isEmpty()) { + return; + } + List removed = listByIds(ids); + if (removed == null || removed.isEmpty()) { + return; + } + Set titles = new LinkedHashSet<>(); + for (ReviewFlow flow : removed) { + if (flow != null && flow.getTitle() != null) { + titles.add(flow.getTitle().trim()); + } + } + for (String title : titles) { + List before = listByTitle(title); + if (before == null || before.isEmpty()) { + continue; + } + if (!isContinuous(before)) { + // 删除前就已经不连续:不把存量脏数据变成"永远删不掉" + continue; + } + validateRemaining(title, ids); + } + } + + /** + * 校验改名/移走后原审批链剩下的节点仍连续。 + */ + private void validateRemaining(String title, Collection excludedIds) { + List remaining = new ArrayList<>(); + List existing = title == null ? null : listByTitle(title); + if (existing != null) { + for (ReviewFlow flow : existing) { + if (flow != null && !excludedIds.contains(flow.getId())) { + remaining.add(flow); + } + } + } + ReviewFlowLevelValidator.validate(remaining); + } + + /** + * 这组节点本身是否已经连续(用于决定要不要在删除前做校验)。 + */ + private boolean isContinuous(List flows) { + try { + ReviewFlowLevelValidator.validate(flows); + return true; + } catch (com.gxwebsoft.common.core.exception.BusinessException e) { + return false; + } + } + + private ReviewFlow merge(ReviewFlow existing, ReviewFlow incoming) { + if (existing == null) { + return incoming; + } + if (incoming.getTitle() != null) { + existing.setTitle(incoming.getTitle()); + } + if (incoming.getLevel() != null) { + existing.setLevel(incoming.getLevel()); + } + return existing; + } + + private boolean sameTitle(String left, String right) { + return Objects.equals(left == null ? null : left.trim(), right == null ? null : right.trim()); + } + } diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java index dd44440..071d892 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java @@ -2,10 +2,14 @@ package com.gxwebsoft.gxmu.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.Organization; +import com.gxwebsoft.common.system.service.OrganizationService; +import com.gxwebsoft.gxmu.constant.ReviewStatus; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; +import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; @@ -16,28 +20,22 @@ import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collections; import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; /** * 审核流程发起服务实现 * *

    审批人不再写死:由 {@link ReviewerResolver} 按审批链配置的 reviewer_rule 解析, - * 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。

    + * 一期串行单审取 userId 最小的那个;解析不出来直接抛业务异常,不生成待办。 + * 首级(发起)、驳回重提、逐级推进三条路径都走同一套解析,第 2..N 级不再复制 review_user_id。

    * * @author LX * @since 2026-03-18 */ @Service public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { - /** 有 college_id 的 4 张在跑业务表(#59);其余模块的申报记录只取申报人 */ - private static final Set COLLEGE_MODULES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( - "gxmu_sjqn", "gxmu_tzbcy_form", "gxmu_qmgc_form", "gxmu_wxxzx_project"))); @Resource private ReviewFlowConfigService reviewFlowConfigService; @@ -49,6 +47,10 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { private ReviewerResolver reviewerResolver; @Resource private DeclareRecordMapper declareRecordMapper; + @Resource + private ModuleRegistryService moduleRegistryService; + @Resource + private OrganizationService organizationService; @Override public void startReview(String module, Integer pk) { @@ -59,15 +61,11 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { if (reviewLists != null && !reviewLists.isEmpty()) { return; } - ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); - if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { + List flowList = loadFlowList(module); + if (flowList.isEmpty()) { return; } - ReviewFlow firstOne = getFirstFlow(reviewFlowConfig); - if (firstOne == null) { - return; - } - saveFirstReviewNode(module, pk, firstOne); + saveReviewNode(module, pk, flowList.get(0), 0); } /** @@ -90,61 +88,125 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { Comparator.nullsLast(LocalDateTime::compareTo)) .thenComparing(ReviewList::getId, Comparator.nullsLast(Integer::compareTo))) .orElse(null); - if (latestReview == null || latestReview.getStatus() == null || latestReview.getStatus() != 2) { + if (latestReview == null || latestReview.getStatus() == null + || latestReview.getStatus() != ReviewStatus.REJECTED) { return; } - ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); - if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { - return; - } - ReviewFlow firstOne = getFirstFlow(reviewFlowConfig); - if (firstOne == null) { + List flowList = loadFlowList(module); + if (flowList.isEmpty()) { return; } reviewListService.remove(new LambdaQueryWrapper() .eq(ReviewList::getModule, module) .eq(ReviewList::getPk, pk)); - saveFirstReviewNode(module, pk, firstOne); + saveReviewNode(module, pk, flowList.get(0), 0); } - private ReviewFlow getFirstFlow(ReviewFlowConfig reviewFlowConfig) { + /** + * 当前一级通过后推进到下一级。 + * + *

    顺序号与审批链下标一一对应(首级 sort_number = 0),所以下一级就是 flowList 的 + * {@code currentSortNumber + 1};越界说明这一级已是最后一级,链条结束。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void advanceReview(String module, Integer pk, Integer currentSortNumber) { + if (module == null || pk == null || currentSortNumber == null) { + return; + } + List flowList = loadFlowList(module); + if (flowList.isEmpty()) { + return; + } + int nextIndex = currentSortNumber + 1; + if (nextIndex >= flowList.size()) { + return; + } + ReviewFlow nextOne = flowList.get(nextIndex); + if (nextOne == null) { + return; + } + saveReviewNode(module, pk, nextOne, nextIndex); + } + + /** + * 读模块的审批链节点(按 level 升序,下标即 sort_number)。 + * + * @param module 模块编码 + * @return 审批链节点;没有配置时返回空列表 + */ + private List loadFlowList(String module) { + ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(module); + if (reviewFlowConfig == null || reviewFlowConfig.getFlowId() == null) { + return java.util.Collections.emptyList(); + } ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId()); if (reviewFlow == null) { - return null; + return java.util.Collections.emptyList(); } List reviewFlowList = reviewFlowService.listByTitle(reviewFlow.getTitle()); if (reviewFlowList == null || reviewFlowList.isEmpty()) { - return null; + return java.util.Collections.emptyList(); } - return reviewFlowList.get(0); + return reviewFlowList; } - private void saveFirstReviewNode(String module, Integer pk, ReviewFlow firstOne) { + /** + * 解析该级审批人并写一个待办节点。 + * + * @param module 模块编码 + * @param pk 业务主键 + * @param flow 该级审批链配置 + * @param sortNumber 待办顺序号(0 起) + */ + private void saveReviewNode(String module, Integer pk, ReviewFlow flow, int sortNumber) { Map record = selectDeclareRecord(module, pk); Integer collegeId = intValue(record, "collegeId"); Integer declareUserId = intValue(record, "userId"); - List reviewers = reviewerResolver.resolve(firstOne, collegeId, declareUserId); + List reviewers = reviewerResolver.resolve(flow, collegeId, declareUserId); if (reviewers == null || reviewers.isEmpty()) { - throw new BusinessException("模块 " + module + " 主键 " + pk + " 第 " - + (firstOne.getLevel() == null ? "?" : firstOne.getLevel()) - + " 级审批人解析结果为空,未生成待办"); + // 兜底消息与 ReviewerResolver 同口径:哪个学院、第几级、哪个模块/主键 + String scope = collegeId == null ? "申报记录缺少学院标识," : "学院「" + collegeName(collegeId) + "」"; + throw new BusinessException(scope + "第 " + levelOf(flow) + + " 级审批人解析结果为空(模块 " + module + " 主键 " + pk + "),未生成待办"); } ReviewList reviewList = new ReviewList(); reviewList.setPk(pk); reviewList.setModule(module); reviewList.setUserId(reviewers.get(0)); - reviewList.setSortNumber(0); + reviewList.setSortNumber(sortNumber); reviewListService.save(reviewList); } - /** 读申报记录的申报人与学院标识;只有 4 张在跑表有 college_id */ + /** + * 读申报记录的申报人与学院标识。 + * + *

    哪张业务表有 college_id 由注册表 has_college 决定,后端不再维护模块白名单 + * (ADR 0006:新增表只改注册表一行)。 + */ private Map selectDeclareRecord(String module, Integer pk) { - if (COLLEGE_MODULES.contains(module)) { + if (moduleRegistryService.hasCollege(module)) { return declareRecordMapper.selectReviewContext(module, pk); } return declareRecordMapper.selectRecord(module, pk); } + private String collegeName(Integer collegeId) { + if (collegeId == null) { + return "学院标识为空"; + } + Organization organization = organizationService.getById(collegeId); + if (organization == null || organization.getOrganizationName() == null + || organization.getOrganizationName().isEmpty()) { + return "未知学院(" + collegeId + ")"; + } + return organization.getOrganizationName(); + } + + private String levelOf(ReviewFlow flow) { + return flow == null || flow.getLevel() == null ? "?" : String.valueOf(flow.getLevel()); + } + private Integer intValue(Map record, String key) { if (record == null) { return null; diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java index 9d034c7..839744f 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewListServiceImpl.java @@ -86,18 +86,10 @@ public class ReviewListServiceImpl extends ServiceImpl moduleTitleMap = moduleRegistryService.titleMap(); for (ReviewList item : list) { - item.setModuleName(moduleTitleOf(moduleTitleMap, item.getModule())); + item.setModuleName(ModuleRegistryService.titleOf(moduleTitleMap, item.getModule())); } } - private String moduleTitleOf(Map moduleTitleMap, String module) { - if (module == null) { - return null; - } - String title = moduleTitleMap.get(module); - return title == null || title.isEmpty() ? module : title; - } - @Override public List listByModuleNPK(String module, Integer pk){ return list( diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java index cc1ff6d..0100dc0 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/TzbcyFormServiceImpl.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.gxmu.constant.ReviewStatus; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.entity.TzbcyForm; import com.gxwebsoft.gxmu.mapper.TzbcyFormMapper; @@ -157,7 +158,7 @@ public class TzbcyFormServiceImpl extends ServiceImpl reviewList = form.getReviewList(); ReviewList latest = reviewList.get(reviewList.size() - 1); - return latest != null && latest.getStatus() != null && latest.getStatus() == 1; + return latest != null && latest.getStatus() != null && latest.getStatus() == ReviewStatus.APPROVED; } private String buildPersonKey(String name, String phone) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c986a26..28c7b4c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -92,12 +92,16 @@ config: # 文件访问规则(spec #58) # 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。 # -# 确需公开的内联资源(预览 /api/file/

    / 与缩略图 /api/file/thumbnail/**): -# 默认 true,否则公开页面的配图会整片变成需要 token;置 false 则内联也需登录。 +# 内联预览(/api/file//)与缩略图(/api/file/thumbnail/**)默认也需要登录: +# 上传一律落在 /yyyyMMdd/ 日期目录,日期目录正好是两段路径,默认公开等于把所有上传文件 +# (含盖章证明材料)变成匿名可读。因此默认值从 true 改成 false。 +# +# 公开页面引用的文件必须把所在目录列进下面的 public-dirs 白名单, +# 否则匿名访问会被拒(401/403,表现为公开页面的空图)。 file: - public-preview: ${FILE_PUBLIC_PREVIEW:true} - # 额外公开目录(可选,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图即使 - # public-preview=false 也允许匿名读取。留空表示没有额外目录。 + public-preview: ${FILE_PUBLIC_PREVIEW:false} + # 公开目录白名单(显式声明,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图 + # 允许匿名读取。留空表示没有任何公开目录——这是安全默认。 # # 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store), # 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。 @@ -221,9 +225,9 @@ gxmu: allow-unknown-user: false # 模块注册表(spec #58 / ADR 0006):启动期校验注册表 table_name 与 module 一致、 # 且注册表引用的业务表在库里存在,不一致直接启动失败(用户故事 21)。 - # strict 默认 true;本地无库启动可临时设为 false 跳过校验。 + # strict 默认 true;本地无库启动可临时设 GXMU_MODULE_REGISTRY_STRICT=false 跳过校验。 module-registry: - strict: true + strict: ${GXMU_MODULE_REGISTRY_STRICT:true} openplat-sync: enabled: true # 每天 03:30 diff --git a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java index 7a091d6..2e04c9b 100644 --- a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java +++ b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java @@ -2,99 +2,71 @@ package com.gxwebsoft.common.core.security; import org.junit.jupiter.api.Test; -import java.util.List; +import java.util.Arrays; +import java.util.Collections; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 文件访问规则(spec #58 API 契约、用户故事 14)。 * - *

    改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被 - * 未登录者直接按 URL 取走。spec 的要求只落在"下载接口"上:下载改为需登录; - * 内联预览与缩略图必须继续匿名可读,否则公开页面的配图会整片裂掉。 - * 测试钉住三件事:下载无论怎么配都要登录、内联默认公开、显式关闭时内联也要登录。 + *

    改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被未登录者按 URL + * 直接取走。现在默认「全部需登录」,只有显式声明的东西才匿名可读。 + * + *

    测试钉的是 {@link SecurityConfig} 真正注册的那三个方法返回的模式内容与顺序—— + * 顺序本身就是这条改造的关键(下载在公开之前、公开在兜底之前)。这里不再另造一套 + * 「这条请求算不算公开」的平行判定,避免测试全绿而线上照旧放行。 * * @author Codex * @since 2026-11-03 */ class FileAccessRulesTest { - /** 下载接口永远要登录,白名单为空、内联公开也一样——它是"取走原件"的动作。 */ + /** 下载接口单独一条模式,且只放这一个:它是"取走原件"的动作,永远要登录。 */ @Test - void downloadAlwaysRequiresLogin() { + void downloadPatternIsRegisteredAlone() { FileAccessRules rules = new FileAccessRules("", true); - assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf")); - assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf")); + assertEquals(Collections.singletonList("/api/file/download/**"), rules.downloadPatterns()); } - /** 内联预览与缩略图默认公开:公开页面上的配图不能因为这次收敛而变成需要 token。 */ + /** 安全默认:不配白名单、不打开内联公开时,没有任何匿名可读的文件路径。 */ @Test - void previewAndThumbnailArePublicByDefault() { - FileAccessRules rules = new FileAccessRules("", true); - - assertTrue(rules.isPublicFileRequest("/api/file/20260708/封面.png")); - assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/20260708/封面.png")); - } - - /** 显式关闭内联公开后,预览与缩略图都要登录——白名单开关确实生效。 */ - @Test - void previewRequiresLoginWhenPublicPreviewDisabled() { - FileAccessRules rules = new FileAccessRules("", false); - - assertFalse(rules.isPublicFileRequest("/api/file/20260708/封面.png")); - assertFalse(rules.isPublicFileRequest("/api/file/thumbnail/20260708/封面.png")); - assertFalse(rules.permitAllPatterns().contains("/api/file/*/*")); - } - - /** 额外公开目录即使在内联关闭时也生效(原图与缩略图)。 */ - @Test - void whitelistedDirectoryIsPublic() { - FileAccessRules rules = new FileAccessRules("cms-article", false); - - assertTrue(rules.isPublicFileRequest("/api/file/cms-article/20260708/封面.png")); - assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/cms-article/20260708/封面.png")); - assertFalse(rules.isPublicFileRequest("/api/file/20260708/盖章证明.jpg")); - } - - /** 没有公开项时不产生任何放行模式,安全默认 = 全部需登录。 */ - @Test - void noPublicItemYieldsNoPublicPatterns() { + void defaultIsClosedSoNoFilePatternIsAnonymous() { assertTrue(new FileAccessRules("", false).permitAllPatterns().isEmpty()); assertTrue(new FileAccessRules(null, false).permitAllPatterns().isEmpty()); - assertFalse(new FileAccessRules(null, false).isPublicFileRequest("/api/file/20260708/a.jpg")); } - /** 配置里允许空格、斜杠与空项,但产出的模式必须干净。 */ + /** 白名单目录产出「原图 → 缩略图」两条模式,按声明顺序展开。 */ @Test - void patternsAreNormalized() { + void whitelistedDirsProduceOriginThenThumbnailPatternsInOrder() { FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ", false); - List patterns = rules.permitAllPatterns(); - assertTrue(patterns.contains("/api/file/cms-article/**")); - assertTrue(patterns.contains("/api/file/public/**")); - assertTrue(patterns.contains("/api/file/thumbnail/cms-article/**")); - assertFalse(patterns.contains("/api/file/**"), "不能整段放行 /api/file"); + assertEquals(Arrays.asList( + "/api/file/cms-article/**", + "/api/file/thumbnail/cms-article/**", + "/api/file/public/**", + "/api/file/thumbnail/public/**"), rules.permitAllPatterns()); } - /** 登录态之外的接口(CMS 文章 JSON)与单段文件接口不受公开规则影响。 */ + /** 显式打开内联公开:预览与缩略图排在最前,白名单目录随后。 */ @Test - void nonFileRequestsAreNotTreatedAsFiles() { + void publicPreviewAddsPreviewAndThumbnailBeforeWhitelistedDirs() { FileAccessRules rules = new FileAccessRules("cms-article", true); - assertFalse(rules.isPublicFileRequest("/api/cms/article")); - assertFalse(rules.isPublicFileRequest("/api/file/page")); - assertFalse(rules.isPublicFileRequest("/api/file/12")); - assertFalse(rules.isPublicFileRequest(null)); + assertEquals(Arrays.asList( + "/api/file/*/*", + "/api/file/thumbnail/**", + "/api/file/cms-article/**", + "/api/file/thumbnail/cms-article/**"), rules.permitAllPatterns()); } - /** 兜底规则:白名单之外的 /api/file/** 仍需登录,下载规则单独可注册。 */ + /** 兜底:白名单之外的所有文件接口都要登录,且必须是整段 /api/file/**(不能只覆盖子路径)。 */ @Test - void authenticatedPatternsCoverAllFiles() { - FileAccessRules rules = new FileAccessRules("cms-article", true); + void authenticatedFallbackCoversAllFiles() { + FileAccessRules rules = new FileAccessRules("cms-article", false); - assertTrue(rules.authenticatedPatterns().contains("/api/file/**")); - assertTrue(rules.downloadPatterns().contains("/api/file/download/**")); + assertEquals(Collections.singletonList("/api/file/**"), rules.authenticatedPatterns()); } } diff --git a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java index 49888ce..15215bb 100644 --- a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java @@ -1,6 +1,7 @@ package com.gxwebsoft.gxmu.controller; import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.service.ReviewFlowService; import org.junit.jupiter.api.Test; @@ -16,15 +17,22 @@ import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 审批链保存入口的层级校验接线(spec #58)。 + * 审批链写入口的层级校验接线(spec #58)。 * - *

    管理端"先删旧节点、再逐级新增",所以单条保存必须结合库里同 title 的已有节点判断, - * 否则逐级保存会被误判为缺级;这里钉住这个接线。 + *

    规则实现已下沉到 {@link ReviewFlowService}(合并库内同 title 节点、校验连续性、 + * 校验改名后原链剩余;见 ReviewFlowServiceImplLevelTest),controller 只负责 + * 「写之前调用一次」:保存、修改、批量、删除四条路径都要接上,且校验抛错时一个字节都不落库。 + * + * @author Codex + * @since 2026-11-03 */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -46,32 +54,76 @@ class ReviewFlowControllerLevelTest { return flow; } + /** 逐级保存:controller 把校验交给 service,校验通过才落库。 */ @Test - void savingNextLevelAfterPreviousOneIsAccepted() { - when(reviewFlowService.listByTitle(TITLE)) - .thenReturn(Collections.singletonList(flow(1, TITLE, 1))); + void saveDelegatesLevelValidationThenWrites() { when(reviewFlowService.save(any())).thenReturn(true); controller.save(flow(null, TITLE, 2)); + verify(reviewFlowService).validateLevelsOnSave(anyList()); verify(reviewFlowService).save(any()); } + /** 校验拒绝(缺级):异常透出,不落库。 */ @Test - void savingLevelThreeWithoutTwoIsRejectedBeforeWrite() { - when(reviewFlowService.listByTitle(TITLE)) - .thenReturn(Collections.singletonList(flow(1, TITLE, 1))); + void saveDoesNotWriteWhenServiceRejectsGap() { + doThrow(new BusinessException("审批链「十佳青年」的层级必须从 1 连续升序")) + .when(reviewFlowService).validateLevelsOnSave(any()); assertThrows(BusinessException.class, () -> controller.save(flow(null, TITLE, 3))); verify(reviewFlowService, never()).save(any()); } + /** 批量保存同样先校验。 */ @Test - void batchSaveRejectsGap() { - when(reviewFlowService.listByTitle(TITLE)).thenReturn(Collections.emptyList()); + void batchSaveDelegatesLevelValidation() { + when(reviewFlowService.saveBatch(any())).thenReturn(true); - assertThrows(BusinessException.class, () -> controller.saveBatch(Arrays.asList( - flow(null, TITLE, 1), flow(null, TITLE, 3)))); - verify(reviewFlowService, never()).saveBatch(any()); + controller.saveBatch(Arrays.asList(flow(null, TITLE, 1), flow(null, TITLE, 2))); + + verify(reviewFlowService).validateLevelsOnSave(anyList()); + verify(reviewFlowService).saveBatch(any()); + } + + /** 单条修改:合并库内原值后校验,再落库。 */ + @Test + void updateDelegatesLevelValidation() { + when(reviewFlowService.updateById(any())).thenReturn(true); + + controller.update(flow(1, TITLE, 2)); + + verify(reviewFlowService).validateLevelsOnUpdate(any()); + verify(reviewFlowService).updateById(any()); + } + + /** 批量修改同样先校验。 */ + @Test + void batchUpdateDelegatesLevelValidation() { + controller.updateBatch(new BatchParam<>()); + + verify(reviewFlowService).validateLevelsOnBatchUpdate(any()); + } + + /** 删除:删之前校验剩余链仍连续。 */ + @Test + void removeDelegatesRemainingChainValidation() { + when(reviewFlowService.removeById(5)).thenReturn(true); + + controller.remove(5); + + verify(reviewFlowService).validateLevelsOnRemove(eq(Collections.singletonList(5))); + verify(reviewFlowService).removeById(5); + } + + /** 批量删除同样先校验。 */ + @Test + void removeBatchDelegatesRemainingChainValidation() { + when(reviewFlowService.removeByIds(anyList())).thenReturn(true); + + controller.removeBatch(Arrays.asList(5, 6)); + + verify(reviewFlowService).validateLevelsOnRemove(eq(Arrays.asList(5, 6))); + verify(reviewFlowService).removeByIds(anyList()); } } diff --git a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java index 7cdd178..8e2fd0f 100644 --- a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java @@ -6,8 +6,7 @@ import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.service.DeclareGuardService; -import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; -import com.gxwebsoft.gxmu.service.ReviewFlowService; +import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewListService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -54,10 +53,7 @@ class ReviewListControllerAuthTest { private ReviewListService reviewListService; @Mock - private ReviewFlowConfigService reviewFlowConfigService; - - @Mock - private ReviewFlowService reviewFlowService; + private ReviewFlowStarterService reviewFlowStarterService; @Mock private DeclareGuardService declareGuardService; @@ -117,6 +113,28 @@ class ReviewListControllerAuthTest { assertEquals(Constants.RESULT_OK_CODE, result.getCode()); verify(reviewListService).updateById(any()); verify(declareGuardService).syncReviewStatus("jhqn", PK); + // 通过后推进下一级:交给审批链服务按规则解析审批人 + verify(reviewFlowStarterService).advanceReview("jhqn", PK, 1); + } + + /** + * 已处理过的节点再 PUT 一次直接拒绝:否则会再插一条下一级待办, + * 同一申报出现第二组活跃节点(双击 / 多标签页即可复现)。 + */ + @Test + void approveIsDeniedWhenNodeIsAlreadyHandled() { + ReviewList handled = row(ROW_ID, OWNER_ID); + handled.setStatus(1); + when(reviewListService.getById(ROW_ID)).thenReturn(handled); + loginAs(OWNER_ID); + + ApiResult result = controller.update(request(ROW_ID, 1, "同意")); + + assertNotEquals(Constants.RESULT_OK_CODE, result.getCode()); + assertTrue(result.getMessage() != null && result.getMessage().contains("已处理"), + "重复提交要给出可读提示,实际:" + result.getMessage()); + verify(reviewListService, never()).updateById(any()); + verify(reviewFlowStarterService, never()).advanceReview(any(), any(), any()); } /** 驳回必须写意见:status=2 且 content 为空/空白时拒绝,提示要可读。 */ @@ -144,6 +162,8 @@ class ReviewListControllerAuthTest { assertEquals(Constants.RESULT_OK_CODE, result.getCode()); verify(reviewListService).updateById(any()); + // 驳回不推进下一级 + verify(reviewFlowStarterService, never()).advanceReview(any(), any(), any()); } /** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */ diff --git a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java index d57ce2b..7dba452 100644 --- a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java @@ -86,6 +86,22 @@ class ModuleRegistryIntegrityCheckerTest { assertDoesNotThrow(() -> checker.check()); } + /** + * 反方向:审批链配置里有、注册表里没有的模块只打 warn,不阻断启动 + * (存量脏数据不该挡住上线,但必须留线索)。 + */ + @Test + void unregisteredFlowConfigModuleOnlyWarns() { + when(moduleRegistryService.listAll()).thenReturn(Collections.singletonList( + module("gxmu_sjqn", "gxmu_sjqn", 1))); + when(moduleRegistryMapper.selectExistingTableNames(any())) + .thenReturn(Collections.singletonList("gxmu_sjqn")); + when(moduleRegistryMapper.selectConfiguredModules()) + .thenReturn(Collections.singletonList("gxmu_tygl")); + + assertDoesNotThrow(() -> checker.check()); + } + @Test void strictFalseSkipsValidationForLocalStartupWithoutDatabase() { ReflectionTestUtils.setField(checker, "strict", false); diff --git a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java index fa70958..4425b64 100644 --- a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java @@ -2,6 +2,7 @@ package com.gxwebsoft.gxmu.module; import com.gxwebsoft.gxmu.module.entity.ModuleRegistry; import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper; +import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import com.gxwebsoft.gxmu.module.service.impl.ModuleRegistryServiceImpl; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,6 +13,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -26,9 +28,10 @@ import static org.mockito.Mockito.when; /** * 模块注册表服务的单元测试(mock 掉 Mapper,不启 Spring、不连库)。 * - *

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

    注册表是模块元数据的唯一来源,这里锁住四件对外可观察的事: + * 启用清单只留启用项且保持 SQL 的排序(排序只在 SQL 里做一次)、 + * 模块集合包含已停用模块(停用模块的历史数据仍要能撤回/作废)、 + * has_college 决定审批人解析是否读学院上下文、标题回退只有一份实现。 * * @author Codex * @since 2026-11-10 @@ -44,11 +47,12 @@ class ModuleRegistryServiceImplTest { private ModuleRegistryServiceImpl moduleRegistryService; @Test - void listEnabledReturnsOnlyEnabledModulesOrderedBySortNumber() { + void listEnabledReturnsOnlyEnabledModulesInSqlOrder() { + // SQL 已按 sort_number 升序返回;服务只负责过滤,不再二次排序 when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList( - module("gxmu_qmgc_form", "青马工程", 1, 8), - module("gxmu_sjtbzbsj", "十佳团支部书记", 0, 2), - module("gxmu_sjqn", "十佳青年岗位能手", 1, 1))); + module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1), + module("gxmu_sjtbzbsj", "十佳团支部书记", 0, 2, 0), + module("gxmu_qmgc_form", "青马工程", 1, 8, 1))); List enabled = moduleRegistryService.listEnabled(); @@ -56,30 +60,11 @@ class ModuleRegistryServiceImplTest { enabled.stream().map(ModuleRegistry::getModule).collect(Collectors.toList())); } - @Test - void titleOfReturnsRegisteredTitle() { - when(moduleRegistryMapper.selectOne(any())).thenReturn(module("gxmu_tzbcy_form", "挑战杯", 1, 7)); - - assertEquals("挑战杯", moduleRegistryService.titleOf("gxmu_tzbcy_form")); - } - - @Test - void titleOfFallsBackToModuleStringWhenNotRegistered() { - when(moduleRegistryMapper.selectOne(any())).thenReturn(null); - - assertEquals("cms_legacy_module", moduleRegistryService.titleOf("cms_legacy_module")); - } - - @Test - void titleOfKeepsNullModuleNull() { - assertNull(moduleRegistryService.titleOf(null)); - } - @Test void titleMapCoversDisabledModulesSoHistoricalRowsKeepTitles() { when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList( - module("gxmu_sjqn", "十佳青年岗位能手", 1, 1), - module("gxmu_yxgqty", "优秀共青团员", 0, 6))); + module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1), + module("gxmu_yxgqty", "优秀共青团员", 0, 6, 0))); Map titles = moduleRegistryService.titleMap(); @@ -91,15 +76,49 @@ class ModuleRegistryServiceImplTest { @Test void moduleSetContainsDisabledModules() { when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList( - module("gxmu_sjqn", "十佳青年岗位能手", 1, 1), - module("gxmu_yxgqty", "优秀共青团员", 0, 6))); + module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1), + module("gxmu_yxgqty", "优秀共青团员", 0, 6, 0))); assertTrue(moduleRegistryService.containsModule("gxmu_yxgqty")); assertFalse(moduleRegistryService.containsModule("gxmu_unknown")); assertFalse(moduleRegistryService.containsModule(null)); } - private ModuleRegistry module(String name, String title, int enabled, int sortNumber) { + @Test + void hasCollegeFollowsTheRegistryFlag() { + when(moduleRegistryMapper.selectOne(any())).thenReturn(module("gxmu_sjqn", "十佳青年岗位能手", 1, 1, 1)); + + assertTrue(moduleRegistryService.hasCollege("gxmu_sjqn")); + } + + @Test + void hasCollegeIsFalseForDisabledOrMissingFlag() { + when(moduleRegistryMapper.selectOne(any())).thenReturn(module("gxmu_tygl", "团员管理", 0, 12, 0)); + + assertFalse(moduleRegistryService.hasCollege("gxmu_tygl")); + assertFalse(moduleRegistryService.hasCollege(null)); + } + + @Test + void titleOfUsesRegistryTitleWhenPresent() { + Map titles = new HashMap<>(); + titles.put("gxmu_tzbcy_form", "挑战杯"); + + assertEquals("挑战杯", ModuleRegistryService.titleOf(titles, "gxmu_tzbcy_form")); + } + + @Test + void titleOfFallsBackToModuleStringWhenNotRegistered() { + assertEquals("cms_legacy_module", ModuleRegistryService.titleOf(new HashMap<>(), "cms_legacy_module")); + assertEquals("cms_legacy_module", ModuleRegistryService.titleOf(null, "cms_legacy_module")); + } + + @Test + void titleOfKeepsNullModuleNull() { + assertNull(ModuleRegistryService.titleOf(new HashMap<>(), null)); + } + + private ModuleRegistry module(String name, String title, int enabled, int sortNumber, int hasCollege) { ModuleRegistry item = new ModuleRegistry(); item.setModule(name); item.setTableName(name); @@ -107,6 +126,7 @@ class ModuleRegistryServiceImplTest { item.setDetailKey(name); item.setEnabled(enabled); item.setSortNumber(sortNumber); + item.setHasCollege(hasCollege); item.setDeleted(0); return item; } diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImplTest.java new file mode 100644 index 0000000..2f60cb4 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/CollegeResolverImplTest.java @@ -0,0 +1,106 @@ +package com.gxwebsoft.gxmu.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * 学院标识解析(spec #58 用户故事 2:一条链 27 个学院共用)。 + * + *

    钉三件事:有学籍时新申报写入学院、没有学籍时留空不报错、更新路径不覆盖已有值。 + * 不启 Spring、不连库。 + * + * @author Codex + * @since 2026-11-11 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CollegeResolverImplTest { + + private static final Integer USER_ID = 12; + private static final Integer COLLEGE_ID = 100; + + @Mock + private GxmuStudentMapper gxmuStudentMapper; + + @InjectMocks + private CollegeResolverImpl collegeResolver; + + /** MyBatis-Plus 的 lambda 列名解析需要 TableInfo,纯单元测试里手工初始化一次 */ + @BeforeAll + static void initLambdaCache() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, GxmuStudent.class); + } + + /** 有学籍:按 gxmu_student.college_id 解析出学院。 */ + @Test + void resolveReturnsCollegeFromStudentRecord() { + when(gxmuStudentMapper.selectList(any())) + .thenReturn(Collections.singletonList(student(COLLEGE_ID, 1))); + + assertEquals(COLLEGE_ID, collegeResolver.resolve(USER_ID)); + } + + /** 没有学籍(或学籍没学院):留空,不报错——申报不能因为名册没同步就提交不了。 */ + @Test + void resolveReturnsNullWithoutStudentRecord() { + when(gxmuStudentMapper.selectList(any())).thenReturn(Collections.emptyList()); + + assertNull(collegeResolver.resolve(USER_ID)); + assertNull(collegeResolver.resolve(null)); + } + + /** 同一用户本科学院与研究生学院不同且没有在校行:留空,不猜。 */ + @Test + void resolveReturnsNullWhenCollegesAreAmbiguous() { + when(gxmuStudentMapper.selectList(any())).thenReturn(Arrays.asList( + student(COLLEGE_ID, 0), + student(200, 0))); + + assertNull(collegeResolver.resolve(USER_ID)); + } + + /** 新建申报:没有学院标识时写入解析结果。 */ + @Test + void fillIfAbsentWritesResolvedCollegeForNewRecord() { + when(gxmuStudentMapper.selectList(any())) + .thenReturn(Collections.singletonList(student(COLLEGE_ID, 1))); + + assertEquals(COLLEGE_ID, collegeResolver.fillIfAbsent(null, USER_ID)); + } + + /** 更新路径:已有学院标识一律保留,不被登录用户的学籍覆盖。 */ + @Test + void fillIfAbsentKeepsExistingCollegeOnUpdate() { + assertEquals(Integer.valueOf(999), collegeResolver.fillIfAbsent(999, USER_ID)); + // 已有值时不查库 + assertEquals(Integer.valueOf(999), collegeResolver.fillIfAbsent(999, null)); + } + + private GxmuStudent student(Integer collegeId, Integer status) { + GxmuStudent student = new GxmuStudent(); + student.setUserId(USER_ID); + student.setCollegeId(collegeId); + student.setStatus(status); + return student; + } +} diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java new file mode 100644 index 0000000..833eb25 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowServiceImplLevelTest.java @@ -0,0 +1,118 @@ +package com.gxwebsoft.gxmu.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.gxmu.entity.ReviewFlow; +import com.gxwebsoft.gxmu.mapper.ReviewFlowMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * 审批链层级校验下沉到 Service 之后的接线(spec #58「层级从 1 连续升序」)。 + * + *

    规则实现落在 {@link ReviewFlowServiceImpl},controller 只调用;这里用 mock 掉 Mapper + * 的方式验证「合并库内同 title 节点 → 校验连续性 → 校验改名后原链剩余」这条链路, + * 以及删除后剩余链的连续性。不启 Spring、不连库。 + * + * @author Codex + * @since 2026-11-11 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ReviewFlowServiceImplLevelTest { + + private static final String TITLE = "十佳青年"; + + @Mock + private ReviewFlowMapper reviewFlowMapper; + + @InjectMocks + private ReviewFlowServiceImpl reviewFlowService; + + /** MyBatis-Plus 的 lambda 列名解析需要 TableInfo,纯单元测试里手工初始化一次 */ + @BeforeAll + static void initLambdaCache() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, ReviewFlow.class); + } + + /** 逐级保存:库里已有第 1 级时,新增第 2 级应被接受。 */ + @Test + void savingNextLevelAfterExistingOneIsAccepted() { + when(reviewFlowMapper.selectList(any())) + .thenReturn(Collections.singletonList(flow(1, TITLE, 1))); + + assertDoesNotThrow(() -> reviewFlowService.validateLevelsOnSave( + Collections.singletonList(flow(null, TITLE, 2)))); + } + + /** 缺级:库里只有第 1 级时直接保存第 3 级要被拒绝。 */ + @Test + void savingLevelThreeWithoutTwoIsRejected() { + when(reviewFlowMapper.selectList(any())) + .thenReturn(Collections.singletonList(flow(1, TITLE, 1))); + + assertThrows(BusinessException.class, () -> reviewFlowService.validateLevelsOnSave( + Collections.singletonList(flow(null, TITLE, 3)))); + } + + /** 改名把第 2 级挪走后,原链剩下 1、3 不连续,必须拒绝。 */ + @Test + void renamingMiddleNodeLeavesOldChainGapAndIsRejected() { + when(reviewFlowMapper.selectById(2)).thenReturn(flow(2, TITLE, 2)); + when(reviewFlowMapper.selectList(any())) + // 第一次:新 title 下暂无节点;第二次:原 title 下 1、2、3 + .thenReturn(Collections.emptyList()) + .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3))); + + assertThrows(BusinessException.class, + () -> reviewFlowService.validateLevelsOnUpdate(flow(2, "新链", 1))); + } + + /** 删除中间一级会留下 1、3 的缺口,拒绝删除。 */ + @Test + void removingMiddleLevelIsRejected() { + when(reviewFlowMapper.selectBatchIds(any())) + .thenReturn(Collections.singletonList(flow(2, TITLE, 2))); + when(reviewFlowMapper.selectList(any())) + .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3))); + + assertThrows(BusinessException.class, + () -> reviewFlowService.validateLevelsOnRemove(Collections.singletonList(2))); + } + + /** 删除最后一级不产生缺口,允许。 */ + @Test + void removingLastLevelIsAccepted() { + when(reviewFlowMapper.selectBatchIds(any())) + .thenReturn(Collections.singletonList(flow(3, TITLE, 3))); + when(reviewFlowMapper.selectList(any())) + .thenReturn(Arrays.asList(flow(1, TITLE, 1), flow(2, TITLE, 2), flow(3, TITLE, 3))); + + assertDoesNotThrow(() -> reviewFlowService.validateLevelsOnRemove(Collections.singletonList(3))); + } + + private ReviewFlow flow(Integer id, String title, Integer level) { + ReviewFlow flow = new ReviewFlow(); + flow.setId(id); + flow.setTitle(title); + flow.setLevel(level); + return flow; + } +} diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java index 35e0fbe..17bf56c 100644 --- a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java @@ -1,10 +1,13 @@ package com.gxwebsoft.gxmu.service.impl; import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.Organization; +import com.gxwebsoft.common.system.service.OrganizationService; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; import com.gxwebsoft.gxmu.entity.ReviewList; import com.gxwebsoft.gxmu.mapper.DeclareRecordMapper; +import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; import com.gxwebsoft.gxmu.service.ReviewFlowConfigService; import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.service.ReviewListService; @@ -28,6 +31,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; @@ -36,10 +40,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 审批流发起与驳回重走(spec #58)。 + * 审批流发起、驳回重走与逐级推进(spec #58)。 * *

    接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。 - * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、以及解析不出来时「一个节点都不写」。

    + * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、推进时「第 2..N 级同样经解析」, + * 以及解析不出来时「一个节点都不写」。

    * * @author Codex * @since 2026-11-10 @@ -52,6 +57,7 @@ class ReviewFlowStarterServiceImplTest { private static final Integer PK = 11; private static final Integer COLLEGE_ID = 100; private static final Integer DECLARE_USER_ID = 12; + private static final String COLLEGE_NAME = "药学院"; @Mock private ReviewFlowConfigService reviewFlowConfigService; @@ -68,6 +74,12 @@ class ReviewFlowStarterServiceImplTest { @Mock private DeclareRecordMapper declareRecordMapper; + @Mock + private ModuleRegistryService moduleRegistryService; + + @Mock + private OrganizationService organizationService; + @InjectMocks private ReviewFlowStarterServiceImpl reviewFlowStarterService; @@ -87,6 +99,12 @@ class ReviewFlowStarterServiceImplTest { when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext()); when(declareRecordMapper.selectRecord(MODULE, PK)).thenReturn(recordContext()); + // 学院标识由注册表 has_college 决定是否读 + when(moduleRegistryService.hasCollege(MODULE)).thenReturn(true); + Organization college = new Organization(); + college.setOrganizationId(COLLEGE_ID); + college.setOrganizationName(COLLEGE_NAME); + when(organizationService.getById(COLLEGE_ID)).thenReturn(college); // 默认:解析出第一级审批人 7 when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7)); } @@ -118,8 +136,12 @@ class ReviewFlowStarterServiceImplTest { void startReview_noReviewerThrowsAndWritesNoNode() { when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList()); - assertThrows(BusinessException.class, () -> reviewFlowStarterService.startReview(MODULE, PK)); + BusinessException error = assertThrows(BusinessException.class, + () -> reviewFlowStarterService.startReview(MODULE, PK)); + // 兜底消息与 ReviewerResolver 同口径:哪个学院、第几级 + assertTrue(error.getMessage().contains(COLLEGE_NAME), error.getMessage()); + assertTrue(error.getMessage().contains("第 1 级"), error.getMessage()); verify(reviewListService, never()).save(any()); } @@ -136,6 +158,68 @@ class ReviewFlowStarterServiceImplTest { verify(reviewListService, never()).save(any()); } + // ------------------------------------------------------------------ + // 逐级推进:第 2..N 级同样经 ReviewerResolver,不复制 review_user_id + // ------------------------------------------------------------------ + + /** 通过后推进:下一级节点的人来自解析,而不是上一级配置里写死的 review_user_id。 */ + @Test + void advanceReview_writesResolvedReviewerAsNextNode() { + when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList( + flow("十佳青年", 1, 7), flow("十佳青年", 2, 8))); + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(43)); + + reviewFlowStarterService.advanceReview(MODULE, PK, 0); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class); + verify(reviewListService).save(captor.capture()); + ReviewList saved = captor.getValue(); + assertEquals(MODULE, saved.getModule()); + assertEquals(PK, saved.getPk()); + assertEquals(Integer.valueOf(1), saved.getSortNumber()); + assertEquals(Integer.valueOf(43), saved.getUserId(), "下一级应落在解析出的人上,而不是 review_user_id=8"); + // 解析的是第 2 级那条配置,并且带上了学院上下文 + verify(reviewerResolver).resolve( + org.mockito.ArgumentMatchers.argThat(item -> item != null && Integer.valueOf(2).equals(item.getLevel())), + eq(COLLEGE_ID), eq(DECLARE_USER_ID)); + } + + /** 已是最后一级:链条结束,不再生成节点。 */ + @Test + void advanceReview_lastLevelWritesNothing() { + when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList( + flow("十佳青年", 1, 7), flow("十佳青年", 2, 8))); + + reviewFlowStarterService.advanceReview(MODULE, PK, 1); + + verify(reviewListService, never()).save(any()); + } + + /** 推进时解析为空:抛可读异常(带学院与层级)且不写节点,避免第二组无人认领的待办。 */ + @Test + void advanceReview_emptyReviewerThrowsAndWritesNoNode() { + when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Arrays.asList( + flow("十佳青年", 1, 7), flow("十佳青年", 2, 8))); + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.emptyList()); + + BusinessException error = assertThrows(BusinessException.class, + () -> reviewFlowStarterService.advanceReview(MODULE, PK, 0)); + + assertTrue(error.getMessage().contains(COLLEGE_NAME), error.getMessage()); + assertTrue(error.getMessage().contains("第 2 级"), error.getMessage()); + verify(reviewListService, never()).save(any()); + } + + /** 没有审批链配置时推进是 no-op,不写节点也不报错。 */ + @Test + void advanceReview_withoutFlowConfigDoesNothing() { + when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(null); + + reviewFlowStarterService.advanceReview(MODULE, PK, 0); + + verify(reviewListService, never()).save(any()); + } + // ------------------------------------------------------------------ // 驳回重走:先清旧节点,再解析第一级 // ------------------------------------------------------------------