feat: 完善 Skill 管理与发布治理
- 实现标准资源存储、能力绑定及双格式导入导出 - 接入分类、可见范围、审批发布与资源权限校验 - 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
@@ -39,5 +39,11 @@
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -20,6 +20,9 @@ public class ApprovalInstanceBase implements Serializable {
|
||||
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "主键")
|
||||
private BigInteger id;
|
||||
|
||||
@Column(tenantId = true, comment = "租户ID")
|
||||
private BigInteger tenantId;
|
||||
|
||||
@Column(comment = "流程ID")
|
||||
private BigInteger flowId;
|
||||
|
||||
@@ -82,6 +85,14 @@ public class ApprovalInstanceBase implements Serializable {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public BigInteger getTenantId() {
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public void setTenantId(BigInteger tenantId) {
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
public BigInteger getFlowId() {
|
||||
return flowId;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,16 @@ public class ApprovalInstancePageVo {
|
||||
|
||||
private BigInteger applicantId;
|
||||
|
||||
/**
|
||||
* 申请人展示名称。
|
||||
*/
|
||||
private String applicantName;
|
||||
|
||||
/**
|
||||
* 申请人登录账号。
|
||||
*/
|
||||
private String applicantAccount;
|
||||
|
||||
private Date submittedAt;
|
||||
|
||||
private Date finishedAt;
|
||||
@@ -131,6 +141,42 @@ public class ApprovalInstancePageVo {
|
||||
this.applicantId = applicantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取申请人展示名称。
|
||||
*
|
||||
* @return 申请人展示名称
|
||||
*/
|
||||
public String getApplicantName() {
|
||||
return applicantName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置申请人展示名称。
|
||||
*
|
||||
* @param applicantName 申请人展示名称
|
||||
*/
|
||||
public void setApplicantName(String applicantName) {
|
||||
this.applicantName = applicantName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取申请人登录账号。
|
||||
*
|
||||
* @return 申请人登录账号
|
||||
*/
|
||||
public String getApplicantAccount() {
|
||||
return applicantAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置申请人登录账号。
|
||||
*
|
||||
* @param applicantAccount 申请人登录账号
|
||||
*/
|
||||
public void setApplicantAccount(String applicantAccount) {
|
||||
this.applicantAccount = applicantAccount;
|
||||
}
|
||||
|
||||
public Date getSubmittedAt() {
|
||||
return submittedAt;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ public interface ApprovalActionFacade {
|
||||
*/
|
||||
ApprovalActionResult submit(ApprovalSubmitRequest request);
|
||||
|
||||
/**
|
||||
* 判断当前登录用户是否经资源处理器授权查看审批详情。
|
||||
*
|
||||
* @param resourceType 资源类型
|
||||
* @param identifier 资源标识
|
||||
* @return 允许查看时返回 {@code true}
|
||||
*/
|
||||
boolean canAccessApprovalDetail(String resourceType, Object identifier);
|
||||
|
||||
/**
|
||||
* 处理审批通过后的业务回调。
|
||||
*
|
||||
|
||||
@@ -26,6 +26,17 @@ public interface ApprovalSubjectHandler {
|
||||
*/
|
||||
ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId);
|
||||
|
||||
/**
|
||||
* 判断当前登录用户是否可通过资源权限查看审批详情。
|
||||
*
|
||||
* <p>审批申请人和任务处理人的访问由审批模块统一判断;该方法只负责补充资源自身的
|
||||
* 授权口径,避免审批详情绕过资源权限系统。</p>
|
||||
*
|
||||
* @param identifier 资源标识
|
||||
* @return 允许查看时返回 {@code true}
|
||||
*/
|
||||
boolean canAccessApprovalDetail(Object identifier);
|
||||
|
||||
/**
|
||||
* 校验资源是否已发布。
|
||||
*
|
||||
|
||||
@@ -49,6 +49,15 @@ public class ApprovalActionFacadeImpl implements ApprovalActionFacade {
|
||||
return ApprovalActionResult.required(instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean canAccessApprovalDetail(String resourceType, Object identifier) {
|
||||
ApprovalSubjectHandler handler = getHandler(resourceType);
|
||||
return handler.canAccessApprovalDetail(identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.approval.entity.ApprovalInstance;
|
||||
import tech.easyflow.approval.entity.ApprovalFlowStep;
|
||||
@@ -25,6 +27,8 @@ import tech.easyflow.approval.mapper.ApprovalLogMapper;
|
||||
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
|
||||
import tech.easyflow.approval.service.ApprovalInstanceService;
|
||||
import tech.easyflow.approval.service.ApprovalMatchService;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
@@ -61,6 +65,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
@Resource
|
||||
private ApprovalAssigneeService approvalAssigneeService;
|
||||
|
||||
@Resource
|
||||
private SysAccountService sysAccountService;
|
||||
|
||||
@Lazy
|
||||
@Resource
|
||||
private ApprovalActionFacade approvalActionFacade;
|
||||
@@ -71,13 +78,24 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public BigInteger submitApproval(ApprovalSubmitRequest request) {
|
||||
ApprovalFlowDetailVo flow = approvalMatchService.matchFlow(request);
|
||||
if (CollectionUtil.isEmpty(flow.getSteps())) {
|
||||
throw new BusinessException("审批流程未配置步骤");
|
||||
if (request == null) {
|
||||
throw new BusinessException("审批请求不能为空");
|
||||
}
|
||||
if (request.getApplicantId() == null) {
|
||||
throw new BusinessException("申请人不能为空");
|
||||
}
|
||||
LoginAccount loginAccount = requireCurrentLoginAccount();
|
||||
if (!loginAccount.getId().equals(request.getApplicantId())) {
|
||||
throw new BusinessException(403, 403, "不允许以其他账号身份提交审批");
|
||||
}
|
||||
SysAccount applicant = requireTenantAccount(request.getApplicantId(), "申请人");
|
||||
if (!loginAccount.getTenantId().equals(applicant.getTenantId())) {
|
||||
throw new BusinessException(403, 403, "申请人租户信息与当前登录态不一致");
|
||||
}
|
||||
ApprovalFlowDetailVo flow = approvalMatchService.matchFlow(request);
|
||||
if (CollectionUtil.isEmpty(flow.getSteps())) {
|
||||
throw new BusinessException("审批流程未配置步骤");
|
||||
}
|
||||
|
||||
List<ApprovalFlowStepVo> steps = new ArrayList<>(flow.getSteps());
|
||||
steps.sort(Comparator.comparing(ApprovalFlowStepVo::getStepNo));
|
||||
@@ -85,6 +103,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
Date now = new Date();
|
||||
|
||||
ApprovalInstance instance = new ApprovalInstance();
|
||||
instance.setTenantId(applicant.getTenantId());
|
||||
instance.setFlowId(flow.getId());
|
||||
instance.setFlowVersion(flow.getVersion());
|
||||
instance.setResourceType(flow.getResourceType());
|
||||
@@ -124,7 +143,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void approve(BigInteger instanceId, String comment, BigInteger operatorId) {
|
||||
ApprovalInstance instance = requireActiveInstance(instanceId);
|
||||
ApprovalInstance instance = requireActiveInstance(instanceId, operatorId);
|
||||
ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo());
|
||||
assertTaskOperable(currentTask, operatorId);
|
||||
List<ApprovalFlowStepVo> steps = resolveFrozenSteps(instance);
|
||||
@@ -161,7 +180,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reject(BigInteger instanceId, String comment, BigInteger operatorId) {
|
||||
ApprovalInstance instance = requireActiveInstance(instanceId);
|
||||
ApprovalInstance instance = requireActiveInstance(instanceId, operatorId);
|
||||
ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo());
|
||||
assertTaskOperable(currentTask, operatorId);
|
||||
Date now = new Date();
|
||||
@@ -184,7 +203,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void revoke(BigInteger instanceId, String comment, BigInteger operatorId) {
|
||||
ApprovalInstance instance = requireActiveInstance(instanceId);
|
||||
ApprovalInstance instance = requireActiveInstance(instanceId, operatorId);
|
||||
// 撤回属于发起人的自助操作,不能沿用审批任务处理人的授权口径。
|
||||
if (!Objects.equals(instance.getApplicantId(), operatorId)) {
|
||||
throw new BusinessException(403, 403, "仅审批申请人可以撤回该请求");
|
||||
@@ -209,7 +228,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
*/
|
||||
@Override
|
||||
public boolean existsActiveInstance(String resourceType, BigInteger resourceId) {
|
||||
BigInteger tenantId = requireCurrentTenantId();
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(ApprovalInstance::getTenantId, tenantId)
|
||||
.eq(ApprovalInstance::getResourceType, resourceType)
|
||||
.eq(ApprovalInstance::getResourceId, resourceId)
|
||||
.notIn(ApprovalInstance::getStatus,
|
||||
@@ -224,7 +245,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
*/
|
||||
@Override
|
||||
public ApprovalInstance getById(BigInteger instanceId) {
|
||||
return approvalInstanceMapper.selectOneById(instanceId);
|
||||
return approvalInstanceMapper.selectOneByQuery(QueryWrapper.create()
|
||||
.eq(ApprovalInstance::getId, instanceId)
|
||||
.eq(ApprovalInstance::getTenantId, requireCurrentTenantId()));
|
||||
}
|
||||
|
||||
private Map<String, Object> buildInstanceSnapshot(ApprovalSubmitRequest request, ApprovalFlowDetailVo flow,
|
||||
@@ -344,15 +367,17 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
approvalLogMapper.insert(log);
|
||||
}
|
||||
|
||||
private ApprovalInstance requireActiveInstance(BigInteger instanceId) {
|
||||
private ApprovalInstance requireActiveInstance(BigInteger instanceId, BigInteger operatorId) {
|
||||
if (instanceId == null) {
|
||||
throw new BusinessException("审批实例ID不能为空");
|
||||
}
|
||||
ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(
|
||||
QueryWrapper.create().eq(ApprovalInstance::getId, instanceId).forUpdate()
|
||||
);
|
||||
SysAccount operator = requireTenantAccount(operatorId, "操作人");
|
||||
ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(QueryWrapper.create()
|
||||
.eq(ApprovalInstance::getId, instanceId)
|
||||
.eq(ApprovalInstance::getTenantId, operator.getTenantId())
|
||||
.forUpdate());
|
||||
if (instance == null) {
|
||||
throw new BusinessException("审批实例不存在");
|
||||
throw new BusinessException(404, 404, "审批实例不存在");
|
||||
}
|
||||
if (ApprovalInstanceStatus.from(instance.getStatus()).isFinished()) {
|
||||
throw new BusinessException("审批实例已结束,无法继续处理");
|
||||
@@ -360,6 +385,49 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取账号及其稳定租户归属。
|
||||
*
|
||||
* @param accountId 账号 ID
|
||||
* @param accountLabel 账号角色说明
|
||||
* @return 有效账号
|
||||
* @throws BusinessException 账号不存在或缺少租户归属时抛出
|
||||
*/
|
||||
private SysAccount requireTenantAccount(BigInteger accountId, String accountLabel) {
|
||||
if (accountId == null) {
|
||||
throw new BusinessException(accountLabel + "不能为空");
|
||||
}
|
||||
SysAccount account = sysAccountService.getById(accountId);
|
||||
if (account == null || account.getTenantId() == null) {
|
||||
throw new BusinessException(403, 403, accountLabel + "不存在或租户信息无效");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号的租户 ID。
|
||||
*
|
||||
* @return 当前租户 ID
|
||||
* @throws BusinessException 登录态缺少账号或租户信息时抛出
|
||||
*/
|
||||
private BigInteger requireCurrentTenantId() {
|
||||
return requireCurrentLoginAccount().getTenantId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录态缺少账号或租户信息时抛出
|
||||
*/
|
||||
private LoginAccount requireCurrentLoginAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException(401, 401, "未登录或登录态无效");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private ApprovalTask requireCurrentTask(BigInteger instanceId, Integer stepNo) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(ApprovalTask::getInstanceId, instanceId)
|
||||
|
||||
@@ -26,9 +26,11 @@ 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.approval.service.ApprovalQueryService;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
@@ -64,6 +66,12 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
@Resource
|
||||
private ApprovalAssigneeService approvalAssigneeService;
|
||||
|
||||
@Resource
|
||||
private ApprovalActionFacade approvalActionFacade;
|
||||
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
|
||||
@Resource
|
||||
private SysAccountService sysAccountService;
|
||||
|
||||
@@ -80,6 +88,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
return new Page<>(List.of(), safePageNumber(pageNumber), safePageSize(pageSize), 0L);
|
||||
}
|
||||
QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword);
|
||||
queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId());
|
||||
queryWrapper.in(ApprovalInstance::getId, instanceIds);
|
||||
queryWrapper.in(ApprovalInstance::getStatus, List.of(
|
||||
ApprovalInstanceStatus.PENDING.getCode(),
|
||||
@@ -109,6 +118,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
return new Page<>(List.of(), safePageNumber(pageNumber), safePageSize(pageSize), 0L);
|
||||
}
|
||||
QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword);
|
||||
queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId());
|
||||
queryWrapper.in(ApprovalInstance::getId, instanceIds);
|
||||
queryWrapper.orderBy("finished_at desc, id desc");
|
||||
return mapPage(queryWrapper, safePageNumber(pageNumber), safePageSize(pageSize), false, account, Set.of());
|
||||
@@ -122,6 +132,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
Long pageNumber, Long pageSize) {
|
||||
LoginAccount account = requireLoginAccount();
|
||||
QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword);
|
||||
queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId());
|
||||
queryWrapper.eq(ApprovalInstance::getApplicantId, account.getId());
|
||||
queryWrapper.orderBy("submitted_at desc, id desc");
|
||||
return mapPage(queryWrapper, safePageNumber(pageNumber), safePageSize(pageSize), false, account, Set.of());
|
||||
@@ -132,10 +143,18 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
*/
|
||||
@Override
|
||||
public ApprovalInstanceDetailVo detail(BigInteger instanceId) {
|
||||
ApprovalInstance instance = approvalInstanceMapper.selectOneById(instanceId);
|
||||
if (instance == null) {
|
||||
throw new BusinessException("审批实例不存在");
|
||||
LoginAccount account = requireLoginAccount();
|
||||
ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(QueryWrapper.create()
|
||||
.eq(ApprovalInstance::getId, instanceId)
|
||||
.eq(ApprovalInstance::getTenantId, account.getTenantId()));
|
||||
if (instance == null || !Objects.equals(account.getTenantId(), instance.getTenantId())) {
|
||||
throw new BusinessException(404, 404, "审批实例不存在");
|
||||
}
|
||||
List<ApprovalTask> tasks = approvalTaskMapper.selectListByQuery(
|
||||
QueryWrapper.create().eq(ApprovalTask::getInstanceId, instanceId));
|
||||
Set<BigInteger> roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId());
|
||||
assertDetailAccess(instance, tasks, account, roleIds);
|
||||
|
||||
ApprovalInstanceDetailVo detail = new ApprovalInstanceDetailVo();
|
||||
detail.setId(instance.getId());
|
||||
detail.setFlowId(instance.getFlowId());
|
||||
@@ -152,12 +171,10 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
detail.setFinishedAt(instance.getFinishedAt());
|
||||
detail.setSnapshotJson(instance.getSnapshotJson());
|
||||
|
||||
List<ApprovalTask> tasks = approvalTaskMapper.selectListByQuery(
|
||||
QueryWrapper.create().eq(ApprovalTask::getInstanceId, instanceId));
|
||||
List<ApprovalLog> logs = approvalLogMapper.selectListByQuery(
|
||||
QueryWrapper.create().eq(ApprovalLog::getInstanceId, instanceId));
|
||||
Map<Integer, ApprovalFlowStepVo> frozenStepMap = resolveFrozenStepMap(instance);
|
||||
Map<BigInteger, SysAccount> accountMap = loadAccountMap(instance, tasks, logs);
|
||||
Map<BigInteger, SysAccount> accountMap = loadAccountMap(instance, tasks, logs, account.getTenantId());
|
||||
detail.setApplicantName(resolveAccountName(accountMap.get(instance.getApplicantId())));
|
||||
detail.setApplicantAccount(resolveAccountLoginName(accountMap.get(instance.getApplicantId())));
|
||||
|
||||
@@ -201,8 +218,6 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
})
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
LoginAccount account = requireLoginAccount();
|
||||
Set<BigInteger> roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId());
|
||||
boolean active = !ApprovalInstanceStatus.from(instance.getStatus()).isFinished();
|
||||
boolean canReview = active
|
||||
&& tasks.stream().anyMatch(item -> item.getStepNo().equals(instance.getCurrentStepNo())
|
||||
@@ -220,10 +235,11 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
* @param instance 审批实例
|
||||
* @param tasks 审批任务列表
|
||||
* @param logs 审批日志列表
|
||||
* @param tenantId 当前租户 ID
|
||||
* @return 账号 ID 到账号实体的映射
|
||||
*/
|
||||
private Map<BigInteger, SysAccount> loadAccountMap(ApprovalInstance instance, List<ApprovalTask> tasks,
|
||||
List<ApprovalLog> logs) {
|
||||
List<ApprovalLog> logs, BigInteger tenantId) {
|
||||
Set<BigInteger> accountIds = new HashSet<>();
|
||||
if (instance.getApplicantId() != null) {
|
||||
accountIds.add(instance.getApplicantId());
|
||||
@@ -239,7 +255,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
if (CollectionUtil.isEmpty(accountIds)) {
|
||||
return Map.of();
|
||||
}
|
||||
return sysAccountService.listByIds(accountIds).stream()
|
||||
return sysAccountService.list(QueryWrapper.create()
|
||||
.in(SysAccount::getId, accountIds)
|
||||
.eq(SysAccount::getTenantId, tenantId)).stream()
|
||||
.collect(Collectors.toMap(
|
||||
SysAccount::getId,
|
||||
account -> account,
|
||||
@@ -297,6 +315,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
boolean pendingMode, LoginAccount account, Set<BigInteger> roleIds) {
|
||||
Page<ApprovalInstance> page = approvalInstanceMapper.paginate(pageNumber, pageSize, queryWrapper);
|
||||
List<ApprovalInstance> records = page.getRecords();
|
||||
Map<BigInteger, SysAccount> applicantAccountMap = loadApplicantAccountMap(records, account.getTenantId());
|
||||
Set<BigInteger> pendingTaskInstanceIds = pendingMode
|
||||
? approvalAssigneeService.listPendingInstanceIds(account.getId(), roleIds,
|
||||
records.stream().map(ApprovalInstance::getId).collect(Collectors.toList()))
|
||||
@@ -315,6 +334,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
item.setSummary(record.getSummary());
|
||||
item.setApplicationReason(record.getApplicationReason());
|
||||
item.setApplicantId(record.getApplicantId());
|
||||
SysAccount applicantAccount = applicantAccountMap.get(record.getApplicantId());
|
||||
item.setApplicantName(resolveAccountName(applicantAccount));
|
||||
item.setApplicantAccount(resolveAccountLoginName(applicantAccount));
|
||||
item.setSubmittedAt(record.getSubmittedAt());
|
||||
item.setFinishedAt(record.getFinishedAt());
|
||||
boolean active = !ApprovalInstanceStatus.from(record.getStatus()).isFinished();
|
||||
@@ -332,6 +354,31 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
return voPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载分页记录中的申请人账号,避免列表逐行查询。
|
||||
*
|
||||
* @param records 审批实例分页记录
|
||||
* @param tenantId 当前租户 ID
|
||||
* @return 申请人 ID 到账号实体的映射
|
||||
*/
|
||||
private Map<BigInteger, SysAccount> loadApplicantAccountMap(List<ApprovalInstance> records, BigInteger tenantId) {
|
||||
Set<BigInteger> applicantIds = records.stream()
|
||||
.map(ApprovalInstance::getApplicantId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (CollectionUtil.isEmpty(applicantIds)) {
|
||||
return Map.of();
|
||||
}
|
||||
return sysAccountService.list(QueryWrapper.create()
|
||||
.in(SysAccount::getId, applicantIds)
|
||||
.eq(SysAccount::getTenantId, tenantId)).stream()
|
||||
.collect(Collectors.toMap(
|
||||
SysAccount::getId,
|
||||
account -> account,
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
private long safePageNumber(Long pageNumber) {
|
||||
return pageNumber == null || pageNumber < 1 ? 1L : pageNumber;
|
||||
}
|
||||
@@ -342,12 +389,39 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
|
||||
private LoginAccount requireLoginAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null) {
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前未登录");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验审批详情的主体权限。
|
||||
*
|
||||
* @param instance 审批实例
|
||||
* @param tasks 审批任务
|
||||
* @param account 当前账号
|
||||
* @param roleIds 当前账号的有效角色 ID
|
||||
* @throws BusinessException 当前用户不是申请人、处理人、同租户超管或资源授权者时抛出
|
||||
*/
|
||||
private void assertDetailAccess(ApprovalInstance instance,
|
||||
List<ApprovalTask> tasks,
|
||||
LoginAccount account,
|
||||
Set<BigInteger> roleIds) {
|
||||
if (account.getId().equals(instance.getApplicantId())
|
||||
|| categoryPermissionService.isSuperAdmin(account)) {
|
||||
return;
|
||||
}
|
||||
boolean taskParticipant = tasks.stream().anyMatch(task -> account.getId().equals(task.getActedBy())
|
||||
|| ApprovalTaskStatus.PENDING.getCode().equals(task.getStatus())
|
||||
&& approvalAssigneeService.canHandleTask(task, account.getId(), roleIds));
|
||||
if (taskParticipant
|
||||
|| approvalActionFacade.canAccessApprovalDetail(instance.getResourceType(), instance.getResourceId())) {
|
||||
return;
|
||||
}
|
||||
throw new BusinessException(403, 403, "无权限查看该审批实例");
|
||||
}
|
||||
|
||||
private String resolveCurrentStepName(ApprovalInstance instance) {
|
||||
Map<Integer, ApprovalFlowStepVo> stepMap = resolveFrozenStepMap(instance);
|
||||
return resolveStepName(stepMap, instance.getCurrentStepNo());
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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.ApprovalLog;
|
||||
import tech.easyflow.approval.entity.ApprovalTask;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
|
||||
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.approval.service.ApprovalMatchService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link ApprovalInstanceServiceImpl} 审批提交身份授权测试。
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class ApprovalInstanceServiceImplAccessTest {
|
||||
|
||||
@Mock
|
||||
private ApprovalMatchService approvalMatchService;
|
||||
|
||||
@Mock
|
||||
private SysAccountService sysAccountService;
|
||||
|
||||
@Mock
|
||||
private ApprovalInstanceMapper approvalInstanceMapper;
|
||||
|
||||
@Mock
|
||||
private ApprovalTaskMapper approvalTaskMapper;
|
||||
|
||||
@Mock
|
||||
private ApprovalLogMapper approvalLogMapper;
|
||||
|
||||
@Mock
|
||||
private ApprovalActionFacade approvalActionFacade;
|
||||
|
||||
@InjectMocks
|
||||
private ApprovalInstanceServiceImpl service;
|
||||
|
||||
/**
|
||||
* 验证同租户账号也不能代替当前登录人发起审批。
|
||||
*/
|
||||
@Test
|
||||
public void submitApprovalShouldRejectForgedApplicantBeforeMatchingFlow() {
|
||||
LoginAccount loginAccount = new LoginAccount();
|
||||
loginAccount.setId(BigInteger.ONE);
|
||||
loginAccount.setTenantId(BigInteger.valueOf(42));
|
||||
ApprovalSubmitRequest request = new ApprovalSubmitRequest();
|
||||
request.setApplicantId(BigInteger.TWO);
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.submitApproval(request));
|
||||
assertEquals(403, exception.getHttpStatus());
|
||||
}
|
||||
|
||||
verify(sysAccountService, never()).getById(BigInteger.TWO);
|
||||
verify(approvalMatchService, never()).matchFlow(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证申请人可撤回进行中的审批,并同步结束当前任务与恢复资源状态。
|
||||
*/
|
||||
@Test
|
||||
public void revokeShouldCompleteCurrentTaskForApplicant() {
|
||||
BigInteger applicantId = BigInteger.valueOf(7);
|
||||
BigInteger tenantId = BigInteger.valueOf(42);
|
||||
BigInteger instanceId = BigInteger.valueOf(101);
|
||||
SysAccount applicant = tenantAccount(applicantId, tenantId);
|
||||
ApprovalInstance instance = activeInstance(instanceId, applicantId, tenantId);
|
||||
ApprovalTask task = new ApprovalTask();
|
||||
task.setInstanceId(instanceId);
|
||||
task.setStepNo(1);
|
||||
task.setStatus(ApprovalTaskStatus.PENDING.getCode());
|
||||
|
||||
when(sysAccountService.getById(applicantId)).thenReturn(applicant);
|
||||
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
|
||||
when(approvalTaskMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(task);
|
||||
|
||||
service.revoke(instanceId, "内容需要调整", applicantId);
|
||||
|
||||
assertEquals(ApprovalInstanceStatus.REVOKED.getCode(), instance.getStatus());
|
||||
assertNotNull(instance.getFinishedAt());
|
||||
assertEquals(ApprovalTaskStatus.REVOKED.getCode(), task.getStatus());
|
||||
assertEquals(applicantId, task.getActedBy());
|
||||
assertEquals("内容需要调整", task.getComment());
|
||||
verify(approvalLogMapper).insert(any(ApprovalLog.class));
|
||||
verify(approvalActionFacade).handleRevoked(instance, applicantId, "内容需要调整");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非申请人即使属于同一租户也不能撤回审批。
|
||||
*/
|
||||
@Test
|
||||
public void revokeShouldRejectSameTenantNonApplicant() {
|
||||
BigInteger applicantId = BigInteger.valueOf(7);
|
||||
BigInteger operatorId = BigInteger.valueOf(8);
|
||||
BigInteger tenantId = BigInteger.valueOf(42);
|
||||
BigInteger instanceId = BigInteger.valueOf(101);
|
||||
|
||||
when(sysAccountService.getById(operatorId)).thenReturn(tenantAccount(operatorId, tenantId));
|
||||
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class)))
|
||||
.thenReturn(activeInstance(instanceId, applicantId, tenantId));
|
||||
|
||||
BusinessException exception = assertThrows(
|
||||
BusinessException.class,
|
||||
() -> service.revoke(instanceId, "尝试撤回", 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));
|
||||
}
|
||||
|
||||
private ApprovalInstance activeInstance(BigInteger instanceId, BigInteger applicantId, BigInteger tenantId) {
|
||||
ApprovalInstance instance = new ApprovalInstance();
|
||||
instance.setId(instanceId);
|
||||
instance.setApplicantId(applicantId);
|
||||
instance.setTenantId(tenantId);
|
||||
instance.setCurrentStepNo(1);
|
||||
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
|
||||
return instance;
|
||||
}
|
||||
|
||||
private SysAccount tenantAccount(BigInteger accountId, BigInteger tenantId) {
|
||||
SysAccount account = new SysAccount();
|
||||
account.setId(accountId);
|
||||
account.setTenantId(tenantId);
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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.enums.ApprovalInstanceStatus;
|
||||
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
|
||||
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
|
||||
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.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link ApprovalInstanceServiceImpl} 审批决策并发互斥测试。
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class ApprovalInstanceServiceImplConcurrencyTest {
|
||||
|
||||
@Mock
|
||||
private ApprovalInstanceMapper approvalInstanceMapper;
|
||||
@Mock
|
||||
private ApprovalTaskMapper approvalTaskMapper;
|
||||
@Mock
|
||||
private SysAccountService sysAccountService;
|
||||
@InjectMocks
|
||||
private ApprovalInstanceServiceImpl service;
|
||||
|
||||
/**
|
||||
* 审批实例与当前任务必须在状态判断前加行锁,避免重复执行同一决策。
|
||||
*/
|
||||
@Test
|
||||
public void approvalDecisionLocksInstanceAndCurrentTask() {
|
||||
BigInteger instanceId = BigInteger.valueOf(101);
|
||||
BigInteger tenantId = BigInteger.valueOf(42);
|
||||
ApprovalInstance instance = new ApprovalInstance();
|
||||
instance.setId(instanceId);
|
||||
instance.setTenantId(tenantId);
|
||||
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
|
||||
instance.setCurrentStepNo(1);
|
||||
SysAccount operator = new SysAccount();
|
||||
operator.setId(BigInteger.ONE);
|
||||
operator.setTenantId(tenantId);
|
||||
when(sysAccountService.getById(BigInteger.ONE)).thenReturn(operator);
|
||||
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
|
||||
when(approvalTaskMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
|
||||
|
||||
assertThrows(BusinessException.class,
|
||||
() -> service.approve(instanceId, "通过", BigInteger.ONE));
|
||||
|
||||
ArgumentCaptor<QueryWrapper> instanceQuery = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
ArgumentCaptor<QueryWrapper> taskQuery = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(approvalInstanceMapper).selectOneByQuery(instanceQuery.capture());
|
||||
verify(approvalTaskMapper).selectOneByQuery(taskQuery.capture());
|
||||
assertTrue(instanceQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("for update"));
|
||||
assertTrue(instanceQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"));
|
||||
assertTrue(taskQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("for update"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package tech.easyflow.approval.service.impl;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* V32 审批实例租户迁移契约测试。
|
||||
*/
|
||||
public class ApprovalInstanceTenantMigrationContractTest {
|
||||
|
||||
/**
|
||||
* 验证历史实例从申请人账号回填租户,且空租户会阻断迁移。
|
||||
*
|
||||
* @throws Exception 迁移文件不可读时抛出
|
||||
*/
|
||||
@Test
|
||||
public void migrationShouldBackfillAndGuardApprovalTenant() throws Exception {
|
||||
String sql = migrationSql();
|
||||
|
||||
assertTrue(sql.contains("ADD COLUMN `tenant_id` BIGINT UNSIGNED NULL"));
|
||||
assertTrue(sql.contains("LEFT JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id`"));
|
||||
assertTrue(sql.contains("applicant.`id` IS NULL OR applicant.`tenant_id` IS NULL"));
|
||||
assertTrue(sql.contains("JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id`"));
|
||||
assertTrue(sql.contains("SET approval.`tenant_id` = applicant.`tenant_id`"));
|
||||
assertTrue(sql.contains("tmp_approval_instance_tenant_guard"));
|
||||
assertTrue(sql.indexOf("tmp_approval_instance_tenant_guard") < sql.indexOf("ADD COLUMN `tenant_id`"));
|
||||
assertTrue(sql.contains("MODIFY COLUMN `tenant_id` BIGINT UNSIGNED NOT NULL"));
|
||||
assertTrue(sql.contains("`tenant_id`, `status`, `submitted_at`"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取工作区中的 V32 MySQL 迁移。
|
||||
*
|
||||
* @return 迁移 SQL
|
||||
* @throws Exception 迁移文件不存在或不可读时抛出
|
||||
*/
|
||||
private String migrationSql() throws Exception {
|
||||
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
|
||||
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
|
||||
while (root != null) {
|
||||
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
|
||||
+ "db/migration/mysql/V32__mysql_approval_instance_tenant.sql");
|
||||
if (Files.isRegularFile(migration)) {
|
||||
return Files.readString(migration, StandardCharsets.UTF_8);
|
||||
}
|
||||
root = root.getParent();
|
||||
}
|
||||
throw new IllegalStateException("找不到 V32 审批实例租户迁移");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package tech.easyflow.approval.service.impl;
|
||||
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
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.MockedStatic;
|
||||
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.entity.vo.ApprovalInstanceDetailVo;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalInstancePageVo;
|
||||
import tech.easyflow.approval.enums.ApprovalAssigneeType;
|
||||
import tech.easyflow.approval.enums.ApprovalEventType;
|
||||
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.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
/**
|
||||
* {@link ApprovalQueryServiceImpl} 审批详情租户和主体授权回归测试。
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class ApprovalQueryServiceImplAccessTest {
|
||||
|
||||
private static final BigInteger INSTANCE_ID = BigInteger.valueOf(101);
|
||||
private static final BigInteger RESOURCE_ID = BigInteger.valueOf(501);
|
||||
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;
|
||||
|
||||
/**
|
||||
* 验证详情查询显式带租户条件,并拒绝 Mapper 异常返回的跨租户实例。
|
||||
*/
|
||||
@Test
|
||||
public void detailShouldRejectCrossTenantInstanceBeforeReadingSnapshot() {
|
||||
LoginAccount account = account(7, 42);
|
||||
ApprovalInstance instance = instance(99, 99);
|
||||
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.detail(INSTANCE_ID));
|
||||
assertEquals(404, exception.getHttpStatus());
|
||||
}
|
||||
|
||||
ArgumentCaptor<QueryWrapper> query = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(approvalInstanceMapper).selectOneByQuery(query.capture());
|
||||
assertTrue(query.getValue().toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"));
|
||||
verify(approvalTaskMapper, never()).selectListByQuery(any(QueryWrapper.class));
|
||||
verify(approvalLogMapper, never()).selectListByQuery(any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同租户普通用户不能仅凭审批查询操作权限读取完整资源快照。
|
||||
*/
|
||||
@Test
|
||||
public void detailShouldRejectSameTenantNonParticipant() {
|
||||
LoginAccount account = account(7, 42);
|
||||
ApprovalInstance instance = instance(8, 42);
|
||||
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
|
||||
when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
when(approvalAssigneeService.getAvailableRoleIds(account.getId())).thenReturn(Set.of());
|
||||
when(approvalActionFacade.canAccessApprovalDetail("SKILL", RESOURCE_ID)).thenReturn(false);
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
BusinessException exception = assertThrows(BusinessException.class,
|
||||
() -> service.detail(INSTANCE_ID));
|
||||
assertEquals(403, exception.getHttpStatus());
|
||||
}
|
||||
|
||||
verify(approvalLogMapper, never()).selectListByQuery(any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证申请人仍可读取自己发起的审批快照。
|
||||
*/
|
||||
@Test
|
||||
public void detailShouldAllowApplicant() {
|
||||
LoginAccount account = account(7, 42);
|
||||
ApprovalInstance instance = instance(7, 42);
|
||||
Map<String, Object> snapshot = instance.getSnapshotJson();
|
||||
stubAuthorizedDetail(instance, account, List.of());
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
|
||||
assertSame(snapshot, detail.getSnapshotJson());
|
||||
assertFalse(detail.isCanApprove());
|
||||
assertFalse(detail.isCanReject());
|
||||
assertTrue(detail.isCanRevoke());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前待办处理人可查看审批详情。
|
||||
*/
|
||||
@Test
|
||||
public void detailShouldAllowCurrentTaskHandler() {
|
||||
LoginAccount account = account(7, 42);
|
||||
ApprovalInstance instance = instance(8, 42);
|
||||
ApprovalTask task = task(ApprovalTaskStatus.PENDING.getCode(), null);
|
||||
task.setAssigneeType(ApprovalAssigneeType.USER.getCode());
|
||||
task.setAssigneeTargetId(account.getId());
|
||||
stubAuthorizedDetail(instance, account, 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);
|
||||
assertEquals(INSTANCE_ID, detail.getId());
|
||||
assertTrue(detail.isCanApprove());
|
||||
assertTrue(detail.isCanReject());
|
||||
assertFalse(detail.isCanRevoke());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证实际处理过历史步骤的用户仍可查看审批详情。
|
||||
*/
|
||||
@Test
|
||||
public void detailShouldAllowHistoricalActor() {
|
||||
LoginAccount account = account(7, 42);
|
||||
ApprovalInstance instance = instance(8, 42);
|
||||
ApprovalTask task = task(ApprovalTaskStatus.APPROVED.getCode(), account.getId());
|
||||
stubAuthorizedDetail(instance, account, List.of(task));
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
assertEquals(INSTANCE_ID, service.detail(INSTANCE_ID).getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证审批说明在详情、审批任务和提交日志中完整透传。
|
||||
*/
|
||||
@Test
|
||||
public void detailShouldExposeApplicationReasonAcrossRelatedViews() {
|
||||
LoginAccount account = account(7, 42);
|
||||
ApprovalInstance instance = instance(7, 42);
|
||||
instance.setApplicationReason("发布新的问答流程");
|
||||
ApprovalTask task = task(ApprovalTaskStatus.PENDING.getCode(), null);
|
||||
ApprovalLog log = new ApprovalLog();
|
||||
log.setEventType(ApprovalEventType.SUBMITTED.getCode());
|
||||
stubAuthorizedDetail(instance, account, List.of(task));
|
||||
when(approvalLogMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(log));
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
|
||||
assertEquals("发布新的问答流程", detail.getApplicationReason());
|
||||
assertEquals("发布新的问答流程", detail.getTasks().get(0).getApplicationReason());
|
||||
assertEquals("发布新的问答流程", detail.getLogs().get(0).getApplicationReason());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证审批分页列表返回申请人填写的审批说明和账号信息。
|
||||
*/
|
||||
@Test
|
||||
public void initiatedPageShouldExposeApplicationReasonAndApplicant() {
|
||||
LoginAccount account = account(7, 42);
|
||||
ApprovalInstance instance = instance(7, 42);
|
||||
instance.setApplicationReason("发布新的问答流程");
|
||||
SysAccount applicant = new SysAccount();
|
||||
applicant.setId(account.getId());
|
||||
applicant.setNickname("陈子默");
|
||||
applicant.setLoginName("czm");
|
||||
Page<ApprovalInstance> page = new Page<>(List.of(instance), 1L, 10L, 1L);
|
||||
when(approvalInstanceMapper.paginate(anyLong(), anyLong(), any(QueryWrapper.class))).thenReturn(page);
|
||||
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(applicant));
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
ApprovalInstancePageVo item = service.initiatedPage(null, null, null, 1L, 10L)
|
||||
.getRecords()
|
||||
.get(0);
|
||||
assertEquals("发布新的问答流程", item.getApplicationReason());
|
||||
assertEquals("陈子默", item.getApplicantName());
|
||||
assertEquals("czm", item.getApplicantAccount());
|
||||
assertTrue(item.isCanRevoke());
|
||||
assertFalse(item.isCanApprove());
|
||||
assertFalse(item.isCanReject());
|
||||
}
|
||||
}
|
||||
|
||||
private void stubAuthorizedDetail(ApprovalInstance instance, LoginAccount account, List<ApprovalTask> tasks) {
|
||||
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
|
||||
when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(tasks);
|
||||
when(approvalAssigneeService.getAvailableRoleIds(account.getId())).thenReturn(Set.of());
|
||||
when(approvalLogMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
}
|
||||
|
||||
private ApprovalInstance instance(long applicantId, long tenantId) {
|
||||
ApprovalInstance instance = new ApprovalInstance();
|
||||
instance.setId(INSTANCE_ID);
|
||||
instance.setTenantId(BigInteger.valueOf(tenantId));
|
||||
instance.setFlowId(BigInteger.valueOf(301));
|
||||
instance.setFlowVersion(1);
|
||||
instance.setResourceType("SKILL");
|
||||
instance.setResourceId(RESOURCE_ID);
|
||||
instance.setActionType("PUBLISH");
|
||||
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
|
||||
instance.setCurrentStepNo(1);
|
||||
instance.setApplicantId(BigInteger.valueOf(applicantId));
|
||||
instance.setSnapshotJson(Map.of(
|
||||
"resourceSnapshot", Map.of("skillContent", "private prompt"),
|
||||
"steps", List.of(Map.of(
|
||||
"stepNo", 1,
|
||||
"stepName", "审核",
|
||||
"assigneeType", ApprovalAssigneeType.USER.getCode(),
|
||||
"assigneeTargetId", 7))));
|
||||
return instance;
|
||||
}
|
||||
|
||||
private ApprovalTask task(String status, BigInteger actedBy) {
|
||||
ApprovalTask task = new ApprovalTask();
|
||||
task.setInstanceId(INSTANCE_ID);
|
||||
task.setStepNo(1);
|
||||
task.setStatus(status);
|
||||
task.setActedBy(actedBy);
|
||||
return task;
|
||||
}
|
||||
|
||||
private LoginAccount account(long accountId, long tenantId) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.valueOf(accountId));
|
||||
account.setTenantId(BigInteger.valueOf(tenantId));
|
||||
return account;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user