diff --git a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java
new file mode 100644
index 0000000..bdb0cfb
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java
@@ -0,0 +1,157 @@
+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;
+import java.util.List;
+
+/**
+ * 文件访问规则:{@code /api/file/**} 下哪些路径可以匿名读取。
+ *
+ *
改造前整段 {@code GET /api/file/**} 都是 {@code permitAll},盖章证明材料
+ * 这类私人文件可以被未登录者按 URL 直接取走。现在按三类动作分开:
+ *
+ * - 下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;
+ * - 内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}:
+ * 默认公开({@code file.public-preview=true}),否则现有公开页面的配图会整片变成需要 token;
+ * - 其余文件接口:默认需要登录;命中 {@code file.public-dirs} 的目录额外公开。
+ *
+ *
+ * 规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求,
+ * 因此可以在单元测试里直接验证。
+ *
+ * @author Codex
+ * @since 2026-11-03
+ */
+@Component
+public class FileAccessRules {
+
+ /**
+ * 文件接口统一前缀
+ */
+ public static final String FILE_PATH_PREFIX = "/api/file";
+
+ /**
+ * 下载接口模式:永远需要登录
+ */
+ private static final String DOWNLOAD_PATTERN = FILE_PATH_PREFIX + "/download/**";
+
+ /**
+ * 内联预览模式:{@code /api/file/{dir}/{name}},正好两段,不会匹配 download/thumbnail(三段)
+ */
+ private static final String PREVIEW_PATTERN = FILE_PATH_PREFIX + "/*/*";
+
+ /**
+ * 缩略图模式
+ */
+ private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**";
+
+ private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+ /**
+ * 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠
+ */
+ private final List publicDirs;
+
+ /**
+ * 内联资源(预览 + 缩略图)是否默认公开
+ */
+ private final boolean publicPreview;
+
+ public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs,
+ @Value("${file.public-preview:true}") boolean publicPreview) {
+ this.publicDirs = normalize(publicDirs);
+ this.publicPreview = publicPreview;
+ }
+
+ /**
+ * 匿名放行的文件路径模式。
+ *
+ * 内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。
+ *
+ * @return antMatchers 模式,没有任何公开项时返回空列表
+ */
+ public List permitAllPatterns() {
+ if (publicPreview) {
+ List patterns = new ArrayList<>(2 + publicDirs.size() * 2);
+ patterns.add(PREVIEW_PATTERN);
+ patterns.add(THUMBNAIL_PATTERN);
+ patterns.addAll(publicDirPatterns());
+ return patterns;
+ }
+ return publicDirPatterns();
+ }
+
+ /**
+ * 需要登录才能访问的文件路径模式(兜底规则,注册时必须排在 {@code GET /**} 之前)。
+ *
+ * @return antMatchers 模式
+ */
+ public List authenticatedPatterns() {
+ return Collections.singletonList(FILE_PATH_PREFIX + "/**");
+ }
+
+ /**
+ * 下载接口的路径模式:必须在公开模式之前注册,保证下载永远要登录。
+ *
+ * @return antMatchers 模式
+ */
+ public List downloadPatterns() {
+ 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();
+ }
+ List patterns = new ArrayList<>(publicDirs.size() * 2);
+ for (String dir : publicDirs) {
+ patterns.add(FILE_PATH_PREFIX + "/" + dir + "/**");
+ patterns.add(FILE_PATH_PREFIX + "/thumbnail/" + dir + "/**");
+ }
+ return patterns;
+ }
+
+ private static List normalize(String raw) {
+ if (StrUtil.isBlank(raw)) {
+ return Collections.emptyList();
+ }
+ List dirs = new ArrayList<>();
+ for (String item : StrUtil.split(raw, ',')) {
+ String dir = StrUtil.strip(StrUtil.trim(item), "/");
+ if (StrUtil.isNotBlank(dir) && !dirs.contains(dir)) {
+ dirs.add(dir);
+ }
+ }
+ return dirs;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java
index aca9b21..2fbaa0d 100644
--- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java
+++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java
@@ -7,11 +7,13 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.annotation.Resource;
+import java.util.List;
/**
* Spring Security配置
@@ -29,13 +31,32 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Resource
private JwtAuthenticationFilter jwtAuthenticationFilter;
+ @Resource
+ private FileAccessRules fileAccessRules;
@Override
protected void configure(HttpSecurity http) throws Exception {
- http.authorizeRequests()
- .antMatchers(HttpMethod.OPTIONS, "/**")
- .permitAll()
- .antMatchers(HttpMethod.GET, "/api/file/**","/**", "/api/captcha", "/")
+ ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry urlRegistry =
+ http.authorizeRequests()
+ .antMatchers(HttpMethod.OPTIONS, "/**")
+ .permitAll();
+ // 文件访问分三段,顺序不能换(antMatchers 先匹配先命中):
+ // 1. 下载恒需登录——必须排在公开模式之前,因为 /api/file/*/* 也会命中两段的 download 路径;
+ // 2. 内联预览/缩略图与 file.public-dirs 目录按规则公开;
+ // 3. 其余 /api/file/** 需登录。整段仍要排在 GET /** 之前,否则等于没配。
+ List downloadPatterns = fileAccessRules.downloadPatterns();
+ if (!downloadPatterns.isEmpty()) {
+ urlRegistry.antMatchers(HttpMethod.GET, downloadPatterns.toArray(new String[0]))
+ .authenticated();
+ }
+ List publicFilePatterns = fileAccessRules.permitAllPatterns();
+ if (!publicFilePatterns.isEmpty()) {
+ urlRegistry.antMatchers(HttpMethod.GET, publicFilePatterns.toArray(new String[0]))
+ .permitAll();
+ }
+ urlRegistry.antMatchers(HttpMethod.GET, fileAccessRules.authenticatedPatterns().toArray(new String[0]))
+ .authenticated()
+ .antMatchers(HttpMethod.GET, "/**", "/api/captcha", "/")
.permitAll()
.antMatchers(
"/api/login",
@@ -84,9 +105,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
)
.permitAll()
.anyRequest()
- .authenticated()
- .and()
- .sessionManagement()
+ .authenticated();
+ http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf()
diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java
index 4d12d7f..de68ad3 100644
--- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java
+++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewListController.java
@@ -1,5 +1,7 @@
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;
@@ -98,9 +100,18 @@ public class ReviewListController extends BaseController {
@ApiOperation("修改审核列表")
@PutMapping()
public ApiResult> update(@RequestBody ReviewList reviewList) {
+ // 审批动作(通过/驳回/撤销)只能由待办的责任人本人执行
+ ApiResult> denied = denyIfNotAssignee(reviewList.getId());
+ if (denied != null) {
+ return denied;
+ }
+ denied = denyIfRejectWithoutContent(reviewList);
+ if (denied != null) {
+ return denied;
+ }
if (reviewListService.updateById(reviewList)) {
// 通过进入下一个
- if (reviewList.getStatus().equals(1)) {
+ if (Integer.valueOf(1).equals(reviewList.getStatus())) {
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule());
if (reviewFlowConfig != null) {
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
@@ -155,6 +166,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 = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData());
+ if (denied != null) {
+ return denied;
+ }
if (batchParam.update(reviewListService, "id")) {
syncBatchReviewStatus(batchParam);
return success("修改成功");
@@ -162,6 +182,55 @@ public class ReviewListController extends BaseController {
return fail("修改失败");
}
+ /**
+ * 审批动作的归属校验:操作者必须是该待办行的 user_id(当前一级的审批人)。
+ *
+ * 待办不存在时也按无权限处理,不区分"不存在"与"不是你的"。
+ *
+ * @param id 待办行 id
+ * @return null 表示校验通过,否则返回 403 结果
+ */
+ private ApiResult> denyIfNotAssignee(Integer id) {
+ ReviewList existing = id == null ? null : reviewListService.getById(id);
+ Integer loginUserId = getLoginUserId();
+ if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) {
+ return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG);
+ }
+ return null;
+ }
+
+ /**
+ * 批量审批的归属校验:逐行确认都属于当前用户。
+ *
+ * @param batchParam 批量参数
+ * @return null 表示全部通过,否则返回 403 结果
+ */
+ private ApiResult> denyIfBatchNotAssignee(BatchParam batchParam) {
+ if (batchParam == null || batchParam.getIds() == null) {
+ return null;
+ }
+ for (java.io.Serializable id : batchParam.getIds()) {
+ ApiResult> denied = denyIfNotAssignee(id instanceof Number ? ((Number) id).intValue() : null);
+ if (denied != null) {
+ return denied;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 驳回必须携带意见,否则申报人不知道要改什么。
+ *
+ * @param data 提交的审批数据
+ * @return null 表示校验通过,否则返回可读的失败结果
+ */
+ private ApiResult> denyIfRejectWithoutContent(ReviewList data) {
+ if (data != null && Integer.valueOf(2).equals(data.getStatus()) && StrUtil.isBlank(data.getContent())) {
+ return fail("驳回必须填写审核意见");
+ }
+ return null;
+ }
+
/** 批量审核后同样要回写申报记录状态,否则重复申报限制会读到旧状态 */
private void syncBatchReviewStatus(BatchParam batchParam) {
if (batchParam == null || batchParam.getIds() == null) {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 0687ff6..19d6f6b 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -89,6 +89,21 @@ config:
upload-path: /Users/liangxin/Project/JAVA/tower-java/
local-upload-path: /Users/gxwebsoft/Documents/uploads/
+# 文件访问规则(spec #58)
+# 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。
+#
+# 确需公开的内联资源(预览 /api/file// 与缩略图 /api/file/thumbnail/**):
+# 默认 true,否则公开页面的配图会整片变成需要 token;置 false 则内联也需登录。
+file:
+ public-preview: ${FILE_PUBLIC_PREVIEW:true}
+ # 额外公开目录(可选,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图即使
+ # public-preview=false 也允许匿名读取。留空表示没有额外目录。
+ #
+ # 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store),
+ # 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。
+ # 也不要图省事写成 /api/file/**。
+ public-dirs: ${FILE_PUBLIC_DIRS:}
+
wx:
miniapp:
configs:
diff --git a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java
new file mode 100644
index 0000000..7a091d6
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java
@@ -0,0 +1,100 @@
+package com.gxwebsoft.common.core.security;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 文件访问规则(spec #58 API 契约、用户故事 14)。
+ *
+ * 改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被
+ * 未登录者直接按 URL 取走。spec 的要求只落在"下载接口"上:下载改为需登录;
+ * 内联预览与缩略图必须继续匿名可读,否则公开页面的配图会整片裂掉。
+ * 测试钉住三件事:下载无论怎么配都要登录、内联默认公开、显式关闭时内联也要登录。
+ *
+ * @author Codex
+ * @since 2026-11-03
+ */
+class FileAccessRulesTest {
+
+ /** 下载接口永远要登录,白名单为空、内联公开也一样——它是"取走原件"的动作。 */
+ @Test
+ void downloadAlwaysRequiresLogin() {
+ FileAccessRules rules = new FileAccessRules("", true);
+
+ assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf"));
+ assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf"));
+ }
+
+ /** 内联预览与缩略图默认公开:公开页面上的配图不能因为这次收敛而变成需要 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() {
+ 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() {
+ 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");
+ }
+
+ /** 登录态之外的接口(CMS 文章 JSON)与单段文件接口不受公开规则影响。 */
+ @Test
+ void nonFileRequestsAreNotTreatedAsFiles() {
+ 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));
+ }
+
+ /** 兜底规则:白名单之外的 /api/file/** 仍需登录,下载规则单独可注册。 */
+ @Test
+ void authenticatedPatternsCoverAllFiles() {
+ FileAccessRules rules = new FileAccessRules("cms-article", true);
+
+ assertTrue(rules.authenticatedPatterns().contains("/api/file/**"));
+ assertTrue(rules.downloadPatterns().contains("/api/file/download/**"));
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java
new file mode 100644
index 0000000..7cdd178
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewListControllerAuthTest.java
@@ -0,0 +1,216 @@
+package com.gxwebsoft.gxmu.controller;
+
+import com.gxwebsoft.common.core.Constants;
+import com.gxwebsoft.common.core.web.ApiResult;
+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.ReviewListService;
+import org.junit.jupiter.api.AfterEach;
+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 org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.io.Serializable;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 审批动作的归属校验(spec #58 用户故事 10、API 契约)。
+ *
+ * 待办行的 {@code user_id} 就是这一级审批人。控制器必须先确认"操作者 =
+ * 该待办的责任人",再落库——此前任何人都能替别人通过/驳回,前端把按钮藏起来
+ * 只是假象。这里钉住拒绝路径:越权、匿名、驳回不写意见都不能落库。
+ *
+ * @author Codex
+ * @since 2026-11-03
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class ReviewListControllerAuthTest {
+
+ private static final Integer OWNER_ID = 9;
+ private static final Integer OTHER_ID = 7;
+ private static final Integer ROW_ID = 11;
+ private static final Integer PK = 100;
+
+ @Mock
+ private ReviewListService reviewListService;
+
+ @Mock
+ private ReviewFlowConfigService reviewFlowConfigService;
+
+ @Mock
+ private ReviewFlowService reviewFlowService;
+
+ @Mock
+ private DeclareGuardService declareGuardService;
+
+ @InjectMocks
+ private ReviewListController controller;
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ /** 越权审批:待办属于别人,即使带了合法 id 也不能落库。 */
+ @Test
+ void approveIsDeniedWhenOperatorIsNotTheAssignee() {
+ when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OTHER_ID));
+ loginAs(OWNER_ID);
+
+ ApiResult> result = controller.update(request(ROW_ID, 1, "同意"));
+
+ assertEquals(Constants.UNAUTHORIZED_CODE, result.getCode());
+ verify(reviewListService, never()).updateById(any());
+ }
+
+ /** 未登录(无 SecurityContext)一律拒绝,不能靠"没人登"这条路径改状态。 */
+ @Test
+ void approveIsDeniedWhenAnonymous() {
+ when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OTHER_ID));
+
+ ApiResult> result = controller.update(request(ROW_ID, 1, "同意"));
+
+ assertEquals(Constants.UNAUTHORIZED_CODE, result.getCode());
+ verify(reviewListService, never()).updateById(any());
+ }
+
+ /** 待办不存在时同样按无权限处理,不泄露"这条记录存在但归别人"。 */
+ @Test
+ void approveIsDeniedWhenRowIsMissing() {
+ when(reviewListService.getById(ROW_ID)).thenReturn(null);
+ loginAs(OWNER_ID);
+
+ ApiResult> result = controller.update(request(ROW_ID, 1, "同意"));
+
+ assertEquals(Constants.UNAUTHORIZED_CODE, result.getCode());
+ verify(reviewListService, never()).updateById(any());
+ }
+
+ /** 本人审批:这是正常路径,必须落库并回写申报状态。 */
+ @Test
+ void approveIsAllowedForTheAssignee() {
+ when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID));
+ when(reviewListService.updateById(any())).thenReturn(true);
+ loginAs(OWNER_ID);
+
+ ApiResult> result = controller.update(request(ROW_ID, 1, "同意"));
+
+ assertEquals(Constants.RESULT_OK_CODE, result.getCode());
+ verify(reviewListService).updateById(any());
+ verify(declareGuardService).syncReviewStatus("jhqn", PK);
+ }
+
+ /** 驳回必须写意见:status=2 且 content 为空/空白时拒绝,提示要可读。 */
+ @Test
+ void rejectIsDeniedWithoutContent() {
+ when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID));
+ loginAs(OWNER_ID);
+
+ ApiResult> result = controller.update(request(ROW_ID, 2, " "));
+
+ assertNotEquals(Constants.RESULT_OK_CODE, result.getCode());
+ assertTrue(result.getMessage() != null && result.getMessage().contains("意见"),
+ "驳回没写意见要给出可读提示,实际:" + result.getMessage());
+ verify(reviewListService, never()).updateById(any());
+ }
+
+ /** 驳回写了意见则正常落库。 */
+ @Test
+ void rejectIsAllowedWithContent() {
+ when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID));
+ when(reviewListService.updateById(any())).thenReturn(true);
+ loginAs(OWNER_ID);
+
+ ApiResult> result = controller.update(request(ROW_ID, 2, "材料不全,退回补充"));
+
+ assertEquals(Constants.RESULT_OK_CODE, result.getCode());
+ verify(reviewListService).updateById(any());
+ }
+
+ /** 批量审批只要有一行不属于自己就整体拒绝,不能部分落库。 */
+ @Test
+ void batchIsDeniedWhenAnyRowIsNotOwned() {
+ when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID));
+ when(reviewListService.getById(ROW_ID + 1)).thenReturn(row(ROW_ID + 1, OTHER_ID));
+ loginAs(OWNER_ID);
+
+ ApiResult> result = controller.updateBatch(batch(1, ROW_ID, ROW_ID + 1));
+
+ assertEquals(Constants.UNAUTHORIZED_CODE, result.getCode());
+ verify(reviewListService, never()).update(any(), any());
+ }
+
+ /** 批量驳回同样要校验意见,否则等于绕过单条驳回的限制。 */
+ @Test
+ void batchRejectIsDeniedWithoutContent() {
+ when(reviewListService.getById(ROW_ID)).thenReturn(row(ROW_ID, OWNER_ID));
+ loginAs(OWNER_ID);
+
+ ApiResult> result = controller.updateBatch(batch(2, ROW_ID));
+
+ assertNotEquals(Constants.RESULT_OK_CODE, result.getCode());
+ assertTrue(result.getMessage() != null && result.getMessage().contains("意见"),
+ "批量驳回落库前也要拦,实际:" + result.getMessage());
+ verify(reviewListService, never()).update(any(), any());
+ }
+
+ private static ReviewList row(Integer id, Integer userId) {
+ ReviewList row = new ReviewList();
+ row.setId(id);
+ row.setModule("jhqn");
+ row.setPk(PK);
+ row.setUserId(userId);
+ row.setStatus(0);
+ row.setSortNumber(1);
+ return row;
+ }
+
+ private static ReviewList request(Integer id, Integer status, String content) {
+ ReviewList request = new ReviewList();
+ request.setId(id);
+ request.setStatus(status);
+ request.setContent(content);
+ request.setModule("jhqn");
+ request.setPk(PK);
+ request.setSortNumber(1);
+ return request;
+ }
+
+ private static BatchParam batch(Integer status, Integer... ids) {
+ BatchParam batch = new BatchParam<>();
+ Serializable[] serializableIds = new Serializable[ids.length];
+ System.arraycopy(ids, 0, serializableIds, 0, ids.length);
+ batch.setIds(Arrays.asList(serializableIds));
+ ReviewList data = new ReviewList();
+ data.setStatus(status);
+ data.setContent(null);
+ batch.setData(data);
+ return batch;
+ }
+
+ private static void loginAs(Integer userId) {
+ User user = new User();
+ user.setUserId(userId);
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(user, null));
+ }
+}