Merge branch 'spec58/integration' into spec58/t6
This commit is contained in:
@@ -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/**} 下哪些路径可以匿名读取。
|
||||||
|
*
|
||||||
|
* <p>改造前整段 {@code GET /api/file/**} 都是 {@code permitAll},盖章证明材料
|
||||||
|
* 这类私人文件可以被未登录者按 URL 直接取走。现在按三类动作分开:
|
||||||
|
* <ul>
|
||||||
|
* <li>下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;</li>
|
||||||
|
* <li>内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}:
|
||||||
|
* 默认公开({@code file.public-preview=true}),否则现有公开页面的配图会整片变成需要 token;</li>
|
||||||
|
* <li>其余文件接口:默认需要登录;命中 {@code file.public-dirs} 的目录额外公开。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>规则以字符串模式产出,交给 {@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<String> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匿名放行的文件路径模式。
|
||||||
|
*
|
||||||
|
* <p>内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。
|
||||||
|
*
|
||||||
|
* @return antMatchers 模式,没有任何公开项时返回空列表
|
||||||
|
*/
|
||||||
|
public List<String> permitAllPatterns() {
|
||||||
|
if (publicPreview) {
|
||||||
|
List<String> 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<String> authenticatedPatterns() {
|
||||||
|
return Collections.singletonList(FILE_PATH_PREFIX + "/**");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载接口的路径模式:必须在公开模式之前注册,保证下载永远要登录。
|
||||||
|
*
|
||||||
|
* @return antMatchers 模式
|
||||||
|
*/
|
||||||
|
public List<String> 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<String> publicDirPatterns() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
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.configuration.WebSecurityConfigurerAdapter;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Security配置
|
* Spring Security配置
|
||||||
@@ -29,13 +31,32 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||||
@Resource
|
@Resource
|
||||||
private JwtAuthenticationFilter jwtAuthenticationFilter;
|
private JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
@Resource
|
||||||
|
private FileAccessRules fileAccessRules;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void configure(HttpSecurity http) throws Exception {
|
protected void configure(HttpSecurity http) throws Exception {
|
||||||
http.authorizeRequests()
|
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry urlRegistry =
|
||||||
.antMatchers(HttpMethod.OPTIONS, "/**")
|
http.authorizeRequests()
|
||||||
.permitAll()
|
.antMatchers(HttpMethod.OPTIONS, "/**")
|
||||||
.antMatchers(HttpMethod.GET, "/api/file/**","/**", "/api/captcha", "/")
|
.permitAll();
|
||||||
|
// 文件访问分三段,顺序不能换(antMatchers 先匹配先命中):
|
||||||
|
// 1. 下载恒需登录——必须排在公开模式之前,因为 /api/file/*/* 也会命中两段的 download 路径;
|
||||||
|
// 2. 内联预览/缩略图与 file.public-dirs 目录按规则公开;
|
||||||
|
// 3. 其余 /api/file/** 需登录。整段仍要排在 GET /** 之前,否则等于没配。
|
||||||
|
List<String> downloadPatterns = fileAccessRules.downloadPatterns();
|
||||||
|
if (!downloadPatterns.isEmpty()) {
|
||||||
|
urlRegistry.antMatchers(HttpMethod.GET, downloadPatterns.toArray(new String[0]))
|
||||||
|
.authenticated();
|
||||||
|
}
|
||||||
|
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()
|
.permitAll()
|
||||||
.antMatchers(
|
.antMatchers(
|
||||||
"/api/login",
|
"/api/login",
|
||||||
@@ -84,9 +105,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
)
|
)
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
.authenticated()
|
.authenticated();
|
||||||
.and()
|
http.sessionManagement()
|
||||||
.sessionManagement()
|
|
||||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
.and()
|
.and()
|
||||||
.csrf()
|
.csrf()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft.gxmu.controller;
|
package com.gxwebsoft.gxmu.controller;
|
||||||
|
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator;
|
||||||
import com.gxwebsoft.gxmu.service.ReviewFlowService;
|
import com.gxwebsoft.gxmu.service.ReviewFlowService;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||||
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
|
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
|
||||||
@@ -16,8 +17,13 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
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.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审核流控制器
|
* 审核流控制器
|
||||||
@@ -63,6 +69,7 @@ public class ReviewFlowController extends BaseController {
|
|||||||
@ApiOperation("添加审核流")
|
@ApiOperation("添加审核流")
|
||||||
@PostMapping()
|
@PostMapping()
|
||||||
public ApiResult<?> save(@RequestBody ReviewFlow reviewFlow) {
|
public ApiResult<?> save(@RequestBody ReviewFlow reviewFlow) {
|
||||||
|
validateLevels(reviewFlow == null ? null : java.util.Collections.singletonList(reviewFlow));
|
||||||
// 记录当前登录用户id
|
// 记录当前登录用户id
|
||||||
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
|
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
|
||||||
// if (checkByName != null) {
|
// if (checkByName != null) {
|
||||||
@@ -77,6 +84,7 @@ public class ReviewFlowController extends BaseController {
|
|||||||
@ApiOperation("修改审核流")
|
@ApiOperation("修改审核流")
|
||||||
@PutMapping()
|
@PutMapping()
|
||||||
public ApiResult<?> update(@RequestBody ReviewFlow reviewFlow) {
|
public ApiResult<?> update(@RequestBody ReviewFlow reviewFlow) {
|
||||||
|
validateUpdate(reviewFlow);
|
||||||
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
|
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
|
||||||
// if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) {
|
// if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) {
|
||||||
// return fail("该名称已存在");
|
// return fail("该名称已存在");
|
||||||
@@ -99,6 +107,7 @@ public class ReviewFlowController extends BaseController {
|
|||||||
@ApiOperation("批量添加审核流")
|
@ApiOperation("批量添加审核流")
|
||||||
@PostMapping("/batch")
|
@PostMapping("/batch")
|
||||||
public ApiResult<?> saveBatch(@RequestBody List<ReviewFlow> list) {
|
public ApiResult<?> saveBatch(@RequestBody List<ReviewFlow> list) {
|
||||||
|
validateLevels(list);
|
||||||
if (reviewFlowService.saveBatch(list)) {
|
if (reviewFlowService.saveBatch(list)) {
|
||||||
return success("添加成功");
|
return success("添加成功");
|
||||||
}
|
}
|
||||||
@@ -108,12 +117,142 @@ public class ReviewFlowController extends BaseController {
|
|||||||
@ApiOperation("批量修改审核流")
|
@ApiOperation("批量修改审核流")
|
||||||
@PutMapping("/batch")
|
@PutMapping("/batch")
|
||||||
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewFlow> batchParam) {
|
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewFlow> batchParam) {
|
||||||
|
validateBatchUpdate(batchParam);
|
||||||
if (batchParam.update(reviewFlowService, "id")) {
|
if (batchParam.update(reviewFlowService, "id")) {
|
||||||
return success("修改成功");
|
return success("修改成功");
|
||||||
}
|
}
|
||||||
return fail("修改失败");
|
return fail("修改失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验"保存后这条审批链的层级从 1 连续升序"(spec #58 行为约定)。
|
||||||
|
*
|
||||||
|
* <p>管理端是"先删旧节点、再逐级新增",因此单条保存时必须把库里同 title 的已有节点一起看,
|
||||||
|
* 否则逐级保存会被误判为缺级。
|
||||||
|
*/
|
||||||
|
private void validateLevels(List<ReviewFlow> incoming) {
|
||||||
|
if (incoming == null || incoming.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ReviewFlow> proposed = new ArrayList<>();
|
||||||
|
Set<String> titles = new LinkedHashSet<>();
|
||||||
|
Set<Integer> replacedIds = new LinkedHashSet<>();
|
||||||
|
for (ReviewFlow flow : incoming) {
|
||||||
|
if (flow == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
proposed.add(flow);
|
||||||
|
if (flow.getId() != null) {
|
||||||
|
replacedIds.add(flow.getId());
|
||||||
|
}
|
||||||
|
if (flow.getTitle() != null) {
|
||||||
|
titles.add(flow.getTitle().trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String title : titles) {
|
||||||
|
List<ReviewFlow> existing = reviewFlowService.listByTitle(title);
|
||||||
|
if (existing == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (ReviewFlow flow : existing) {
|
||||||
|
if (flow != null && flow.getId() != null && !replacedIds.contains(flow.getId())) {
|
||||||
|
proposed.add(flow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReviewFlowLevelValidator.validate(proposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改单条节点:未传的字段沿用库里原值(MyBatis-Plus 同样忽略 null),再校验受影响链的连续性。
|
||||||
|
*/
|
||||||
|
private void validateUpdate(ReviewFlow reviewFlow) {
|
||||||
|
if (reviewFlow == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ReviewFlow existing = reviewFlow.getId() == null ? null : reviewFlowService.getById(reviewFlow.getId());
|
||||||
|
ReviewFlow merged = merge(existing, reviewFlow);
|
||||||
|
validateLevels(java.util.Collections.singletonList(merged));
|
||||||
|
if (existing != null && !sameTitle(existing.getTitle(), merged.getTitle())) {
|
||||||
|
validateRemaining(existing.getTitle(), java.util.Collections.singletonList(reviewFlow.getId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量修改:BatchParam 只带一个 data,id 集合里每行都按同样字段更新。
|
||||||
|
*/
|
||||||
|
private void validateBatchUpdate(BatchParam<ReviewFlow> batchParam) {
|
||||||
|
if (batchParam == null || batchParam.getData() == null
|
||||||
|
|| batchParam.getIds() == null || batchParam.getIds().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Integer> ids = new ArrayList<>();
|
||||||
|
for (Serializable id : batchParam.getIds()) {
|
||||||
|
if (id instanceof Number) {
|
||||||
|
ids.add(((Number) id).intValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ReviewFlow> existingRows = reviewFlowService.listByIds(ids);
|
||||||
|
if (existingRows == null || existingRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ReviewFlow> mergedRows = new ArrayList<>();
|
||||||
|
List<String> oldTitles = new ArrayList<>();
|
||||||
|
for (ReviewFlow existing : existingRows) {
|
||||||
|
if (existing == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mergedRows.add(merge(existing, batchParam.getData()));
|
||||||
|
oldTitles.add(existing.getTitle());
|
||||||
|
}
|
||||||
|
validateLevels(mergedRows);
|
||||||
|
Set<String> newTitles = new LinkedHashSet<>();
|
||||||
|
for (ReviewFlow row : mergedRows) {
|
||||||
|
newTitles.add(row.getTitle() == null ? "" : row.getTitle().trim());
|
||||||
|
}
|
||||||
|
for (String oldTitle : oldTitles) {
|
||||||
|
if (!newTitles.contains(oldTitle == null ? "" : oldTitle.trim())) {
|
||||||
|
validateRemaining(oldTitle, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验改名/移走后原审批链剩下的节点仍连续。
|
||||||
|
*/
|
||||||
|
private void validateRemaining(String title, Collection<Integer> excludedIds) {
|
||||||
|
List<ReviewFlow> remaining = new ArrayList<>();
|
||||||
|
List<ReviewFlow> existing = title == null ? null : reviewFlowService.listByTitle(title);
|
||||||
|
if (existing != null) {
|
||||||
|
for (ReviewFlow flow : existing) {
|
||||||
|
if (flow != null && !excludedIds.contains(flow.getId())) {
|
||||||
|
remaining.add(flow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReviewFlowLevelValidator.validate(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReviewFlow merge(ReviewFlow existing, ReviewFlow incoming) {
|
||||||
|
if (existing == null) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
if (incoming.getTitle() != null) {
|
||||||
|
existing.setTitle(incoming.getTitle());
|
||||||
|
}
|
||||||
|
if (incoming.getLevel() != null) {
|
||||||
|
existing.setLevel(incoming.getLevel());
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sameTitle(String left, String right) {
|
||||||
|
return Objects.equals(left == null ? null : left.trim(), right == null ? null : right.trim());
|
||||||
|
}
|
||||||
|
|
||||||
@ApiOperation("批量删除审核流")
|
@ApiOperation("批量删除审核流")
|
||||||
@DeleteMapping("/batch")
|
@DeleteMapping("/batch")
|
||||||
public ApiResult<?> removeBatch(@RequestBody List<Integer> ids) {
|
public ApiResult<?> removeBatch(@RequestBody List<Integer> ids) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.gxwebsoft.gxmu.controller;
|
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.common.core.web.BaseController;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
|
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
|
||||||
@@ -98,9 +100,18 @@ public class ReviewListController extends BaseController {
|
|||||||
@ApiOperation("修改审核列表")
|
@ApiOperation("修改审核列表")
|
||||||
@PutMapping()
|
@PutMapping()
|
||||||
public ApiResult<?> update(@RequestBody ReviewList reviewList) {
|
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 (reviewListService.updateById(reviewList)) {
|
||||||
// 通过进入下一个
|
// 通过进入下一个
|
||||||
if (reviewList.getStatus().equals(1)) {
|
if (Integer.valueOf(1).equals(reviewList.getStatus())) {
|
||||||
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule());
|
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule());
|
||||||
if (reviewFlowConfig != null) {
|
if (reviewFlowConfig != null) {
|
||||||
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
|
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
|
||||||
@@ -155,6 +166,15 @@ public class ReviewListController extends BaseController {
|
|||||||
@ApiOperation("批量修改审核列表")
|
@ApiOperation("批量修改审核列表")
|
||||||
@PutMapping("/batch")
|
@PutMapping("/batch")
|
||||||
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewList> batchParam) {
|
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")) {
|
if (batchParam.update(reviewListService, "id")) {
|
||||||
syncBatchReviewStatus(batchParam);
|
syncBatchReviewStatus(batchParam);
|
||||||
return success("修改成功");
|
return success("修改成功");
|
||||||
@@ -162,6 +182,55 @@ public class ReviewListController extends BaseController {
|
|||||||
return fail("修改失败");
|
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) {
|
private void syncBatchReviewStatus(BatchParam<ReviewList> batchParam) {
|
||||||
if (batchParam == null || batchParam.getIds() == null) {
|
if (batchParam == null || batchParam.getIds() == null) {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.gxwebsoft.gxmu.service;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。
|
||||||
|
*
|
||||||
|
* <p>一条审批链的层级必须恰好是 1..N,缺级会让申报卡在半路,重复级会让两个人拿到同一条待办。
|
||||||
|
* 按 title 分组校验,纯函数,不依赖数据库。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-11-03
|
||||||
|
*/
|
||||||
|
public final class ReviewFlowLevelValidator {
|
||||||
|
|
||||||
|
private ReviewFlowLevelValidator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验一组审批流节点的层级;不合法时抛出 {@link BusinessException}。
|
||||||
|
*
|
||||||
|
* @param flows 同一轮保存涉及的节点,可包含多条审批链(按 title 分组)
|
||||||
|
*/
|
||||||
|
public static void validate(List<ReviewFlow> flows) {
|
||||||
|
if (flows == null || flows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, List<Integer>> levelsByTitle = new LinkedHashMap<>();
|
||||||
|
for (ReviewFlow flow : flows) {
|
||||||
|
if (flow == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
levelsByTitle.computeIfAbsent(titleOf(flow), key -> new ArrayList<>()).add(flow.getLevel());
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, List<Integer>> entry : levelsByTitle.entrySet()) {
|
||||||
|
validateOne(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateOne(String title, List<Integer> levels) {
|
||||||
|
for (Integer level : levels) {
|
||||||
|
if (level == null) {
|
||||||
|
throw new BusinessException("审批链「" + displayTitle(title) + "」存在未填写层级的节点,层级必须从 1 连续升序");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Integer> sorted = new ArrayList<>(levels);
|
||||||
|
Collections.sort(sorted);
|
||||||
|
for (int index = 0; index < sorted.size(); index++) {
|
||||||
|
if (sorted.get(index) != index + 1) {
|
||||||
|
throw new BusinessException("审批链「" + displayTitle(title) + "」的层级必须从 1 连续升序,当前层级为 "
|
||||||
|
+ join(sorted) + ",请调整后再保存");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String titleOf(ReviewFlow flow) {
|
||||||
|
return flow.getTitle() == null ? "" : flow.getTitle().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String displayTitle(String title) {
|
||||||
|
return title.isEmpty() ? "未命名" : title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String join(List<Integer> levels) {
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
for (int index = 0; index < levels.size(); index++) {
|
||||||
|
if (index > 0) {
|
||||||
|
builder.append(", ");
|
||||||
|
}
|
||||||
|
builder.append(levels.get(index));
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.gxwebsoft.gxmu.service.impl;
|
package com.gxwebsoft.gxmu.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
|
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewList;
|
import com.gxwebsoft.gxmu.entity.ReviewList;
|
||||||
@@ -8,6 +9,7 @@ import com.gxwebsoft.gxmu.service.ReviewFlowService;
|
|||||||
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
|
import com.gxwebsoft.gxmu.service.ReviewFlowStarterService;
|
||||||
import com.gxwebsoft.gxmu.service.ReviewListService;
|
import com.gxwebsoft.gxmu.service.ReviewListService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -49,7 +51,12 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
|
|||||||
saveFirstReviewNode(module, pk, firstOne);
|
saveFirstReviewNode(module, pk, firstOne);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回后重走审批链:先清掉同一申报记录下的旧节点,再生成从第一级开始的新节点,
|
||||||
|
* 保证任一时刻只有一组活跃节点(spec #58 行为约定)。判定"上一条是驳回"必须在删除前完成。
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void restartReviewWhenLastRejected(String module, Integer pk) {
|
public void restartReviewWhenLastRejected(String module, Integer pk) {
|
||||||
if (module == null || pk == null) {
|
if (module == null || pk == null) {
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +82,9 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
|
|||||||
if (firstOne == null) {
|
if (firstOne == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
reviewListService.remove(new LambdaQueryWrapper<ReviewList>()
|
||||||
|
.eq(ReviewList::getModule, module)
|
||||||
|
.eq(ReviewList::getPk, pk));
|
||||||
saveFirstReviewNode(module, pk, firstOne);
|
saveFirstReviewNode(module, pk, firstOne);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,21 @@ config:
|
|||||||
upload-path: /Users/liangxin/Project/JAVA/tower-java/
|
upload-path: /Users/liangxin/Project/JAVA/tower-java/
|
||||||
local-upload-path: /Users/gxwebsoft/Documents/uploads/
|
local-upload-path: /Users/gxwebsoft/Documents/uploads/
|
||||||
|
|
||||||
|
# 文件访问规则(spec #58)
|
||||||
|
# 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。
|
||||||
|
#
|
||||||
|
# 确需公开的内联资源(预览 /api/file/<dir>/<name> 与缩略图 /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:
|
wx:
|
||||||
miniapp:
|
miniapp:
|
||||||
configs:
|
configs:
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>改造前 {@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<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", 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/**"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.gxwebsoft.gxmu.controller;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||||
|
import com.gxwebsoft.gxmu.service.ReviewFlowService;
|
||||||
|
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.assertThrows;
|
||||||
|
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)。
|
||||||
|
*
|
||||||
|
* <p>管理端"先删旧节点、再逐级新增",所以单条保存必须结合库里同 title 的已有节点判断,
|
||||||
|
* 否则逐级保存会被误判为缺级;这里钉住这个接线。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
|
class ReviewFlowControllerLevelTest {
|
||||||
|
|
||||||
|
private static final String TITLE = "十佳青年";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ReviewFlowService reviewFlowService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ReviewFlowController controller;
|
||||||
|
|
||||||
|
private static ReviewFlow flow(Integer id, String title, Integer level) {
|
||||||
|
ReviewFlow flow = new ReviewFlow();
|
||||||
|
flow.setId(id);
|
||||||
|
flow.setTitle(title);
|
||||||
|
flow.setLevel(level);
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savingNextLevelAfterPreviousOneIsAccepted() {
|
||||||
|
when(reviewFlowService.listByTitle(TITLE))
|
||||||
|
.thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
|
||||||
|
when(reviewFlowService.save(any())).thenReturn(true);
|
||||||
|
|
||||||
|
controller.save(flow(null, TITLE, 2));
|
||||||
|
|
||||||
|
verify(reviewFlowService).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savingLevelThreeWithoutTwoIsRejectedBeforeWrite() {
|
||||||
|
when(reviewFlowService.listByTitle(TITLE))
|
||||||
|
.thenReturn(Collections.singletonList(flow(1, TITLE, 1)));
|
||||||
|
|
||||||
|
assertThrows(BusinessException.class, () -> controller.save(flow(null, TITLE, 3)));
|
||||||
|
verify(reviewFlowService, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchSaveRejectsGap() {
|
||||||
|
when(reviewFlowService.listByTitle(TITLE)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
assertThrows(BusinessException.class, () -> controller.saveBatch(Arrays.asList(
|
||||||
|
flow(null, TITLE, 1), flow(null, TITLE, 3))));
|
||||||
|
verify(reviewFlowService, never()).saveBatch(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.gxwebsoft.gxmu.service;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。
|
||||||
|
*
|
||||||
|
* <p>层级是审批顺序的唯一依据:缺一级会让申报卡在半路,重复一级会让两个人同时看到同一条待办。
|
||||||
|
* 这里钉住"保存前就能拒绝"的行为,不依赖数据库。
|
||||||
|
*/
|
||||||
|
class ReviewFlowLevelValidatorTest {
|
||||||
|
|
||||||
|
private static ReviewFlow flow(String title, Integer level) {
|
||||||
|
ReviewFlow flow = new ReviewFlow();
|
||||||
|
flow.setTitle(title);
|
||||||
|
flow.setLevel(level);
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contiguousLevelsAreAccepted() {
|
||||||
|
ReviewFlowLevelValidator.validate(Arrays.asList(
|
||||||
|
flow("十佳青年", 3), flow("十佳青年", 1), flow("十佳青年", 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gapIsRejected() {
|
||||||
|
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||||
|
ReviewFlowLevelValidator.validate(Arrays.asList(
|
||||||
|
flow("十佳青年", 1), flow("十佳青年", 3), flow("十佳青年", 4))));
|
||||||
|
assertTrue(error.getMessage().contains("十佳青年"), "报错要指出是哪条审批链:" + error.getMessage());
|
||||||
|
assertTrue(error.getMessage().contains("1, 3, 4"), "报错要列出实际层级:" + error.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void duplicateLevelIsRejected() {
|
||||||
|
assertThrows(BusinessException.class, () ->
|
||||||
|
ReviewFlowLevelValidator.validate(Arrays.asList(
|
||||||
|
flow("挑战杯培育", 1), flow("挑战杯培育", 2), flow("挑战杯培育", 2))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chainMustStartAtOne() {
|
||||||
|
assertThrows(BusinessException.class, () ->
|
||||||
|
ReviewFlowLevelValidator.validate(Arrays.asList(
|
||||||
|
flow("青马工程", 2), flow("青马工程", 3))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingLevelIsRejected() {
|
||||||
|
assertThrows(BusinessException.class, () ->
|
||||||
|
ReviewFlowLevelValidator.validate(Arrays.asList(
|
||||||
|
flow("未来学术之星", 1), flow("未来学术之星", null))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titlesAreValidatedIndependently() {
|
||||||
|
BusinessException error = assertThrows(BusinessException.class, () ->
|
||||||
|
ReviewFlowLevelValidator.validate(Arrays.asList(
|
||||||
|
flow("甲链", 1), flow("甲链", 2),
|
||||||
|
flow("乙链", 1), flow("乙链", 3))));
|
||||||
|
assertTrue(error.getMessage().contains("乙链"), error.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyChainNeedsNoValidation() {
|
||||||
|
ReviewFlowLevelValidator.validate(null);
|
||||||
|
ReviewFlowLevelValidator.validate(Collections.emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singleLevelOneIsAccepted() {
|
||||||
|
ReviewFlowLevelValidator.validate(Collections.singletonList(flow("单级链", 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package com.gxwebsoft.gxmu.service.impl;
|
||||||
|
|
||||||
|
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 org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
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.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回后重发审批链(spec #58:同一申报记录任一时刻只有一组活跃节点)。
|
||||||
|
*
|
||||||
|
* <p>修复前 {@code restartReviewWhenLastRejected} 只追加一条新节点,旧节点留在待办里,
|
||||||
|
* 同一份申报会出现多条待办、还会叠加多轮历史。这里钉住"先清旧节点、再建第一级"。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
|
class ReviewFlowStarterServiceImplTest {
|
||||||
|
|
||||||
|
private static final String MODULE = "gxmu_sjqn";
|
||||||
|
private static final Integer PK = 11;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ReviewFlowConfigService reviewFlowConfigService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ReviewFlowService reviewFlowService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ReviewListService reviewListService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ReviewFlowStarterServiceImpl starterService;
|
||||||
|
|
||||||
|
private ReviewList reviewNode(Integer id, Integer status, LocalDateTime createTime) {
|
||||||
|
ReviewList node = new ReviewList();
|
||||||
|
node.setId(id);
|
||||||
|
node.setModule(MODULE);
|
||||||
|
node.setPk(PK);
|
||||||
|
node.setUserId(5);
|
||||||
|
node.setStatus(status);
|
||||||
|
node.setSortNumber(1);
|
||||||
|
node.setCreateTime(createTime);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReviewFlow flow(String title, Integer level, Integer reviewUserId) {
|
||||||
|
ReviewFlow flow = new ReviewFlow();
|
||||||
|
flow.setTitle(title);
|
||||||
|
flow.setLevel(level);
|
||||||
|
flow.setReviewUserId(reviewUserId);
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubFirstFlow() {
|
||||||
|
ReviewFlowConfig config = new ReviewFlowConfig();
|
||||||
|
config.setFlowId(3);
|
||||||
|
when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(config);
|
||||||
|
when(reviewFlowService.getById(3)).thenReturn(flow("十佳青年", 1, 99));
|
||||||
|
when(reviewFlowService.listByTitle("十佳青年"))
|
||||||
|
.thenReturn(Collections.singletonList(flow("十佳青年", 1, 7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectedRecordClearsOldNodesBeforeRestarting() {
|
||||||
|
LocalDateTime rejectedAt = LocalDateTime.of(2026, 11, 1, 10, 0);
|
||||||
|
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Arrays.asList(
|
||||||
|
reviewNode(90, 1, rejectedAt.minusDays(2)),
|
||||||
|
reviewNode(91, 2, rejectedAt)));
|
||||||
|
stubFirstFlow();
|
||||||
|
|
||||||
|
starterService.restartReviewWhenLastRejected(MODULE, PK);
|
||||||
|
|
||||||
|
InOrder order = inOrder(reviewListService);
|
||||||
|
order.verify(reviewListService).remove(any());
|
||||||
|
order.verify(reviewListService).save(any());
|
||||||
|
|
||||||
|
ArgumentCaptor<ReviewList> captor = ArgumentCaptor.forClass(ReviewList.class);
|
||||||
|
verify(reviewListService).save(captor.capture());
|
||||||
|
ReviewList saved = captor.getValue();
|
||||||
|
assertEquals(MODULE, saved.getModule());
|
||||||
|
assertEquals(PK, saved.getPk());
|
||||||
|
assertEquals(Integer.valueOf(7), saved.getUserId(), "新节点应落在重新解析出的第一级审批人上");
|
||||||
|
assertEquals(Integer.valueOf(0), saved.getSortNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approvedRecordDoesNotRestart() {
|
||||||
|
when(reviewListService.listByModuleNPK(MODULE, PK))
|
||||||
|
.thenReturn(Collections.singletonList(reviewNode(91, 1, LocalDateTime.now())));
|
||||||
|
|
||||||
|
starterService.restartReviewWhenLastRejected(MODULE, PK);
|
||||||
|
|
||||||
|
verify(reviewListService, never()).remove(any());
|
||||||
|
verify(reviewListService, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordWithoutNodesIsLeftAlone() {
|
||||||
|
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
starterService.restartReviewWhenLastRejected(MODULE, PK);
|
||||||
|
|
||||||
|
verify(reviewListService, never()).remove(any());
|
||||||
|
verify(reviewListService, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingFlowConfigDoesNotTouchNodes() {
|
||||||
|
when(reviewListService.listByModuleNPK(MODULE, PK))
|
||||||
|
.thenReturn(Collections.singletonList(reviewNode(91, 2, LocalDateTime.now())));
|
||||||
|
when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(null);
|
||||||
|
|
||||||
|
starterService.restartReviewWhenLastRejected(MODULE, PK);
|
||||||
|
|
||||||
|
verify(reviewListService, never()).remove(any());
|
||||||
|
verify(reviewListService, never()).save(any());
|
||||||
|
assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user