fix(review): 审批动作校验归属、驳回必须写意见、文件下载需登录

- PUT /api/gxmu/review-list 与 /batch:操作者必须是待办行 user_id,否则 code 403
- status=2 且意见为空时拒绝并提示「驳回必须填写审核意见」
- GET /api/file/** 由匿名改为需登录,公开目录走 file.public-dirs 白名单
- 新增 FileAccessRules 纯规则组件,SecurityConfig 按其产出注册 antMatchers
- 新增 ReviewListControllerAuthTest(8 例)与 FileAccessRulesTest(7 例)
This commit is contained in:
Codex
2026-11-03 22:23:35 +08:00
parent 0e4986330a
commit 94071092e3
6 changed files with 507 additions and 8 deletions
@@ -0,0 +1,106 @@
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/**} 下哪些路径可以匿名读取。
*
* <p>改造前整段 {@code GET /api/file/**} 都是 {@code permitAll},盖章证明材料
* 这类私人文件可以被未登录者按 URL 直接取走。现在默认全部需要登录,只有
* {@code file.public-dirs} 里显式登记的目录(原图与缩略图)才放行;下载接口
* {@code /api/file/download/**} 不在放行之列——"取走原件"一律要登录。
*
* <p>规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求,
* 因此可以在单元测试里直接验证。
*
* @author Codex
* @since 2026-11-03
*/
@Component
public class FileAccessRules {
/**
* 文件接口统一前缀
*/
public static final String FILE_PATH_PREFIX = "/api/file";
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠
*/
private final List<String> publicDirs;
public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs) {
this.publicDirs = normalize(publicDirs);
}
/**
* 匿名放行的路径模式:白名单目录下的原图与缩略图。
*
* @return antMatchers 模式,白名单为空时返回空列表
*/
public List<String> permitAllPatterns() {
if (publicDirs.isEmpty()) {
return Collections.emptyList();
}
List<String> 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;
}
/**
* 需要登录才能访问的文件路径模式(兜底规则,注册时必须排在 {@code GET /**} 之前)。
*
* @return antMatchers 模式
*/
public List<String> authenticatedPatterns() {
return Collections.singletonList(FILE_PATH_PREFIX + "/**");
}
/**
* 这条文件请求是否按公开资源处理(匿名可读)。
*
* @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;
}
for (String pattern : permitAllPatterns()) {
if (pathMatcher.match(pattern, clean)) {
return true;
}
}
return false;
}
private static List<String> normalize(String raw) {
if (StrUtil.isBlank(raw)) {
return Collections.emptyList();
}
List<String> 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;
}
}
@@ -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,25 @@ 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<HttpSecurity>.ExpressionInterceptUrlRegistry urlRegistry =
http.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**")
.permitAll();
// 文件默认需登录:先放行确需公开的资源目录(file.public-dirs),
// 再兜住其余 /api/file/**。这两条必须排在 GET /** 之前,否则等于没配。
List<String> 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 +98,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
)
.permitAll()
.anyRequest()
.authenticated()
.and()
.sessionManagement()
.authenticated();
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf()
@@ -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<ReviewList> 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(当前一级的审批人)。
*
* <p>待办不存在时也按无权限处理,不区分"不存在"与"不是你的"。
*
* @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<ReviewList> 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<ReviewList> batchParam) {
if (batchParam == null || batchParam.getIds() == null) {
+11
View File
@@ -89,6 +89,17 @@ config:
upload-path: /Users/liangxin/Project/JAVA/tower-java/
local-upload-path: /Users/gxwebsoft/Documents/uploads/
# 文件访问白名单(spec #58):确需公开的资源目录,逗号分隔,相对 /api/file。
# file.public-dirs: cms-article,public
# 列进这里的目录,其原图 /api/file/<dir>/** 与缩略图 /api/file/thumbnail/<dir>/** 允许匿名读取;
# 下载接口 /api/file/download/** 永远需要登录。留空 = 所有文件都需要登录。
#
# 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store),
# 目录本身不区分公开/私有,所以生产要放开公开站点图片,只能按日期目录逐条登记,
# 或后续给上传链路加语义前缀。不要图省事写成 /api/file/**。
file:
public-dirs: ${FILE_PUBLIC_DIRS:}
wx:
miniapp:
configs:
@@ -0,0 +1,84 @@
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)。
*
* <p>改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被
* 未登录者直接按 URL 取走。规则收敛成"默认需登录 + 配置白名单",白名单由
* {@code file.public-dirs} 显式声明。测试只钉外部可观察的三件事:
* 下载要登录、白名单目录匿名可读、其余目录仍要登录。
*
* @author Codex
* @since 2026-11-03
*/
class FileAccessRulesTest {
/** 下载接口永远要登录,公开目录也一样——它是"取走原件"的动作。 */
@Test
void downloadAlwaysRequiresLogin() {
FileAccessRules rules = new FileAccessRules("cms-article");
assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf"));
assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf"));
}
/** 白名单目录:原图与缩略图都匿名可读,公开页面才能正常渲染。 */
@Test
void whitelistedDirectoryIsPublic() {
FileAccessRules rules = new FileAccessRules("cms-article");
assertTrue(rules.isPublicFileRequest("/api/file/cms-article/20260708/封面.png"));
assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/cms-article/20260708/封面.png"));
}
/** 没进白名单的目录(当前所有上传都落在日期目录)默认要登录。 */
@Test
void otherDirectoriesStillRequireLogin() {
FileAccessRules rules = new FileAccessRules("cms-article");
assertFalse(rules.isPublicFileRequest("/api/file/20260708/盖章证明.jpg"));
assertFalse(rules.isPublicFileRequest("/api/file/thumbnail/20260708/盖章证明.jpg"));
}
/** 白名单为空时不产生任何放行模式,安全默认 = 全部需登录。 */
@Test
void blankConfigurationYieldsNoPublicPatterns() {
assertTrue(new FileAccessRules("").permitAllPatterns().isEmpty());
assertTrue(new FileAccessRules(null).permitAllPatterns().isEmpty());
assertFalse(new FileAccessRules("").isPublicFileRequest("/api/file/20260708/a.jpg"));
}
/** 配置里允许空格、斜杠与空项,但产出的模式必须干净。 */
@Test
void patternsAreNormalized() {
FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ");
List<String> patterns = rules.permitAllPatterns();
assertTrue(patterns.contains("/api/file/cms-article/**"));
assertTrue(patterns.contains("/api/file/public/**"));
assertTrue(patterns.contains("/api/file/thumbnail/cms-article/**"));
assertFalse(patterns.contains("/api/file/**"), "不能整段放行 /api/file");
}
/** 登录态之外的接口(CMS 文章 JSON)不受文件规则影响。 */
@Test
void nonFileRequestsAreNotTreatedAsFiles() {
FileAccessRules rules = new FileAccessRules("cms-article");
assertFalse(rules.isPublicFileRequest("/api/cms/article"));
assertFalse(rules.isPublicFileRequest(null));
}
/** 授权链最后一条:除白名单外的所有文件请求都要登录。 */
@Test
void authenticatedPatternsCoverAllFiles() {
assertTrue(new FileAccessRules("cms-article").authenticatedPatterns().contains("/api/file/**"));
}
}
@@ -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 契约)。
*
* <p>待办行的 {@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<ReviewList> batch(Integer status, Integer... ids) {
BatchParam<ReviewList> 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));
}
}