diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java index 373f4461..d19ed9cc 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java @@ -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("当前审批任务不存在"); diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java index 8574fa98..5e9b0d60 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java @@ -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 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); } diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalRevokeQueryTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalRevokeQueryTest.java new file mode 100644 index 00000000..6b43ab48 --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalRevokeQueryTest.java @@ -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 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 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 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; + } +} diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalRevokeServiceTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalRevokeServiceTest.java new file mode 100644 index 00000000..9fecd9bc --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalRevokeServiceTest.java @@ -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 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; + } +} diff --git a/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue b/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue index 0875b942..43d347d9 100644 --- a/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue +++ b/easyflow-ui-admin/app/src/views/system/approval/ApprovalDetail.vue @@ -17,6 +17,7 @@ import { ElTag, } from 'element-plus'; +import { hasPermission } from '#/api/common/hasPermission'; import { api } from '#/api/request'; import { $t } from '#/locales'; import { router } from '#/router'; @@ -41,11 +42,24 @@ const actionLabelMap: Record = { PUBLISH: $t('approval.action.publish'), }; +const canApproveAction = computed( + () => + !!detail.value?.canApprove && + hasPermission(['/api/v1/approvalInstance/approve']), +); +const canRejectAction = computed( + () => + !!detail.value?.canReject && + hasPermission(['/api/v1/approvalInstance/reject']), +); +const canRevokeAction = computed( + () => + !!detail.value?.canRevoke && + hasPermission(['/api/v1/approvalInstance/revoke']), +); const canOperate = computed(() => { return ( - !!detail.value?.canApprove || - !!detail.value?.canReject || - !!detail.value?.canRevoke + canApproveAction.value || canRejectAction.value || canRevokeAction.value ); }); @@ -258,7 +272,7 @@ function formatEventInfo(row: Record) {
) { {{ $t('approval.action.approve') }} ) { {{ $t('approval.action.reject') }} item.value === value)?.label || value || '-' ); } + +function formatApplicationReason(value?: null | string) { + return String(value || '').trim() || '-'; +}