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 直接取走。现在按三类动作分开: + *

+ * + *

规则以字符串模式产出,交给 {@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)); + } +}