feat: 支持发起人撤回审批

- 在我发起和审批详情中提供申请人撤回操作并恢复资源状态

- 补齐审批说明列表展示、动作权限校验和防重复提交

- 为审批决策增加行锁并补充撤回权限回归测试
This commit is contained in:
2026-07-23 16:28:45 +08:00
parent ebfd91eeab
commit 3e8ee66ab6
6 changed files with 465 additions and 42 deletions

View File

@@ -34,6 +34,7 @@ import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
@@ -184,8 +185,11 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
@Transactional(rollbackFor = Exception.class)
public void revoke(BigInteger instanceId, String comment, BigInteger operatorId) {
ApprovalInstance instance = requireActiveInstance(instanceId);
// 撤回属于发起人的自助操作,不能沿用审批任务处理人的授权口径。
if (!Objects.equals(instance.getApplicantId(), operatorId)) {
throw new BusinessException(403, 403, "仅审批申请人可以撤回该请求");
}
ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo());
assertTaskOperable(currentTask, operatorId);
Date now = new Date();
finishTask(currentTask, ApprovalTaskStatus.REVOKED.getCode(), comment, operatorId, now);
instance.setStatus(ApprovalInstanceStatus.REVOKED.getCode());
@@ -344,7 +348,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
if (instanceId == null) {
throw new BusinessException("审批实例ID不能为空");
}
ApprovalInstance instance = approvalInstanceMapper.selectOneById(instanceId);
ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(
QueryWrapper.create().eq(ApprovalInstance::getId, instanceId).forUpdate()
);
if (instance == null) {
throw new BusinessException("审批实例不存在");
}
@@ -358,7 +364,8 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(ApprovalTask::getInstanceId, instanceId)
.eq(ApprovalTask::getStepNo, stepNo)
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode());
.eq(ApprovalTask::getStatus, ApprovalTaskStatus.PENDING.getCode())
.forUpdate();
ApprovalTask task = approvalTaskMapper.selectOneByQuery(queryWrapper);
if (task == null) {
throw new BusinessException("当前审批任务不存在");

View File

@@ -39,6 +39,7 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -202,13 +203,14 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
LoginAccount account = requireLoginAccount();
Set<BigInteger> roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId());
boolean canOperate = !ApprovalInstanceStatus.from(instance.getStatus()).isFinished()
boolean active = !ApprovalInstanceStatus.from(instance.getStatus()).isFinished();
boolean canReview = active
&& tasks.stream().anyMatch(item -> item.getStepNo().equals(instance.getCurrentStepNo())
&& ApprovalTaskStatus.PENDING.getCode().equals(item.getStatus())
&& approvalAssigneeService.canHandleTask(item, account.getId(), roleIds));
detail.setCanApprove(canOperate);
detail.setCanReject(canOperate);
detail.setCanRevoke(canOperate);
detail.setCanApprove(canReview);
detail.setCanReject(canReview);
detail.setCanRevoke(active && Objects.equals(account.getId(), instance.getApplicantId()));
return detail;
}
@@ -315,12 +317,13 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
item.setApplicantId(record.getApplicantId());
item.setSubmittedAt(record.getSubmittedAt());
item.setFinishedAt(record.getFinishedAt());
boolean canOperate = pendingMode
boolean active = !ApprovalInstanceStatus.from(record.getStatus()).isFinished();
boolean canReview = pendingMode
&& pendingTaskInstanceIds.contains(record.getId())
&& !ApprovalInstanceStatus.from(record.getStatus()).isFinished();
item.setCanApprove(canOperate);
item.setCanReject(canOperate);
item.setCanRevoke(canOperate);
&& active;
item.setCanApprove(canReview);
item.setCanReject(canReview);
item.setCanRevoke(active && Objects.equals(account.getId(), record.getApplicantId()));
result.add(item);
}

View File

@@ -0,0 +1,170 @@
package tech.easyflow.approval.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.MockitoJUnitRunner;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.vo.ApprovalInstanceDetailVo;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalTaskStatus;
import tech.easyflow.approval.mapper.ApprovalFlowStepMapper;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import tech.easyflow.approval.mapper.ApprovalLogMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalActionFacade;
import tech.easyflow.approval.service.ApprovalAssigneeService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* 审批撤回入口查询测试。
*/
@RunWith(MockitoJUnitRunner.class)
public class ApprovalRevokeQueryTest {
private static final BigInteger INSTANCE_ID = BigInteger.valueOf(101);
private static final BigInteger TENANT_ID = BigInteger.valueOf(42);
@Mock
private ApprovalInstanceMapper approvalInstanceMapper;
@Mock
private ApprovalTaskMapper approvalTaskMapper;
@Mock
private ApprovalLogMapper approvalLogMapper;
@Mock
private ApprovalFlowStepMapper approvalFlowStepMapper;
@Mock
private ApprovalAssigneeService approvalAssigneeService;
@Mock
private ApprovalActionFacade approvalActionFacade;
@Mock
private CategoryPermissionService categoryPermissionService;
@Mock
private SysAccountService sysAccountService;
@InjectMocks
private ApprovalQueryServiceImpl service;
/**
* 验证活动审批的发起人只能看到撤回入口。
*/
@Test
public void detailShouldExposeRevokeOnlyToApplicant() {
LoginAccount account = account(7);
ApprovalInstance instance = instance(7);
stubDetail(instance, List.of());
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
assertTrue(detail.isCanRevoke());
assertFalse(detail.isCanApprove());
assertFalse(detail.isCanReject());
}
}
/**
* 验证当前审批人只能看到通过和驳回入口,不能撤回他人申请。
*/
@Test
public void detailShouldNotExposeRevokeToCurrentReviewer() {
LoginAccount account = account(7);
ApprovalInstance instance = instance(8);
ApprovalTask task = new ApprovalTask();
task.setInstanceId(INSTANCE_ID);
task.setStepNo(1);
task.setStatus(ApprovalTaskStatus.PENDING.getCode());
stubDetail(instance, List.of(task));
when(approvalAssigneeService.canHandleTask(task, account.getId(), Set.of())).thenReturn(true);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
assertTrue(detail.isCanApprove());
assertTrue(detail.isCanReject());
assertFalse(detail.isCanRevoke());
}
}
/**
* 配置详情查询所需的兼容桩。
*
* @param instance 审批实例
* @param tasks 审批任务
*/
private void stubDetail(ApprovalInstance instance, List<ApprovalTask> tasks) {
lenient().when(approvalInstanceMapper.selectOneById(INSTANCE_ID)).thenReturn(instance);
lenient().when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(tasks);
when(approvalLogMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
when(approvalAssigneeService.getAvailableRoleIds(any(BigInteger.class))).thenReturn(Set.of());
lenient().when(sysAccountService.listByIds(any())).thenReturn(List.of());
lenient().when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of());
}
/**
* 构造活动审批实例。
*
* @param applicantId 申请人 ID
* @return 活动审批实例
*/
private ApprovalInstance instance(long applicantId) {
ApprovalInstance instance = new ApprovalInstance();
instance.setId(INSTANCE_ID);
instance.setTenantId(TENANT_ID);
instance.setFlowId(BigInteger.valueOf(301));
instance.setFlowVersion(1);
instance.setResourceType("KNOWLEDGE");
instance.setResourceId(BigInteger.valueOf(501));
instance.setActionType("PUBLISH");
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
instance.setCurrentStepNo(1);
instance.setApplicantId(BigInteger.valueOf(applicantId));
instance.setSnapshotJson(Map.of(
"steps", List.of(Map.of(
"stepNo", 1,
"stepName", "审核"
))
));
return instance;
}
/**
* 构造当前登录账号。
*
* @param accountId 账号 ID
* @return 登录账号
*/
private LoginAccount account(long accountId) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(accountId));
account.setTenantId(TENANT_ID);
return account;
}
}

View File

@@ -0,0 +1,152 @@
package tech.easyflow.approval.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.ApprovalLog;
import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalTaskStatus;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import tech.easyflow.approval.mapper.ApprovalLogMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalActionFacade;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 审批发起人撤回行为测试。
*/
@RunWith(MockitoJUnitRunner.class)
public class ApprovalRevokeServiceTest {
private static final BigInteger APPLICANT_ID = BigInteger.valueOf(7);
private static final BigInteger INSTANCE_ID = BigInteger.valueOf(101);
private static final BigInteger TENANT_ID = BigInteger.valueOf(42);
@Mock
private ApprovalInstanceMapper approvalInstanceMapper;
@Mock
private ApprovalTaskMapper approvalTaskMapper;
@Mock
private ApprovalLogMapper approvalLogMapper;
@Mock
private ApprovalActionFacade approvalActionFacade;
@Mock
private SysAccountService sysAccountService;
@InjectMocks
private ApprovalInstanceServiceImpl service;
/**
* 验证发起人撤回后结束当前任务、记录日志并恢复资源状态。
*/
@Test
public void revokeShouldCompleteCurrentTaskForApplicant() {
ApprovalInstance instance = activeInstance(APPLICANT_ID);
ApprovalTask task = pendingTask();
lenient().when(sysAccountService.getById(APPLICANT_ID)).thenReturn(tenantAccount(APPLICANT_ID));
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
when(approvalTaskMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(task);
service.revoke(INSTANCE_ID, "内容需要调整", APPLICANT_ID);
assertEquals(ApprovalInstanceStatus.REVOKED.getCode(), instance.getStatus());
assertNotNull(instance.getFinishedAt());
assertEquals(ApprovalTaskStatus.REVOKED.getCode(), task.getStatus());
assertEquals(APPLICANT_ID, task.getActedBy());
assertEquals("内容需要调整", task.getComment());
verify(approvalLogMapper).insert(any(ApprovalLog.class));
verify(approvalActionFacade).handleRevoked(instance, APPLICANT_ID, "内容需要调整");
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(approvalInstanceMapper).selectOneByQuery(queryCaptor.capture());
assertTrue(queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT).contains("for update"));
}
/**
* 验证当前审批人或其他同租户账号不能代替发起人撤回。
*/
@Test
public void revokeShouldRejectNonApplicant() {
BigInteger operatorId = BigInteger.valueOf(8);
lenient().when(sysAccountService.getById(operatorId)).thenReturn(tenantAccount(operatorId));
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class)))
.thenReturn(activeInstance(APPLICANT_ID));
BusinessException exception = assertThrows(
BusinessException.class,
() -> service.revoke(INSTANCE_ID, "尝试撤回", operatorId)
);
assertEquals(403, exception.getHttpStatus());
verify(approvalTaskMapper, never()).selectOneByQuery(any(QueryWrapper.class));
verify(approvalActionFacade, never())
.handleRevoked(any(ApprovalInstance.class), any(BigInteger.class), any(String.class));
}
/**
* 构造活动审批实例。
*
* @param applicantId 申请人 ID
* @return 活动审批实例
*/
private ApprovalInstance activeInstance(BigInteger applicantId) {
ApprovalInstance instance = new ApprovalInstance();
instance.setId(INSTANCE_ID);
instance.setTenantId(TENANT_ID);
instance.setApplicantId(applicantId);
instance.setCurrentStepNo(1);
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
return instance;
}
/**
* 构造待处理任务。
*
* @return 待处理任务
*/
private ApprovalTask pendingTask() {
ApprovalTask task = new ApprovalTask();
task.setInstanceId(INSTANCE_ID);
task.setStepNo(1);
task.setStatus(ApprovalTaskStatus.PENDING.getCode());
return task;
}
/**
* 构造具有租户归属的账号。
*
* @param accountId 账号 ID
* @return 账号实体
*/
private SysAccount tenantAccount(BigInteger accountId) {
SysAccount account = new SysAccount();
account.setId(accountId);
account.setTenantId(TENANT_ID);
return account;
}
}