feat: 完成分享、单会话与发布审批改造
- 增加工作流协作分享与知识库卡片分享入口,统一低版本浏览器复制反馈 - Web 新登录替换旧会话,并保持 API Key 会话隔离 - 发布审批增加必填说明并在审批详情展示 - 账号重置与导入改用可配置默认强密码
This commit is contained in:
@@ -307,16 +307,38 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
|||||||
return Result.ok(data);
|
return Result.ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交聊天助手发布审批。
|
||||||
|
*
|
||||||
|
* @param id 助手 ID
|
||||||
|
* @param applicationReason 审批说明
|
||||||
|
* @return 审批实例 ID
|
||||||
|
*/
|
||||||
@PostMapping("/submitPublishApproval")
|
@PostMapping("/submitPublishApproval")
|
||||||
@SaCheckPermission("/api/v1/bot/save")
|
@SaCheckPermission("/api/v1/bot/save")
|
||||||
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
|
public Result<BigInteger> submitPublishApproval(
|
||||||
|
@JsonBody("id") BigInteger id,
|
||||||
|
@JsonBody("applicationReason") String applicationReason
|
||||||
|
) {
|
||||||
return buildApprovalActionResult(
|
return buildApprovalActionResult(
|
||||||
botPublishAppService.submitPublishApproval(id),
|
botPublishAppService.submitPublishApproval(id, applicationReason),
|
||||||
"已提交发布审批",
|
"已提交发布审批",
|
||||||
"已直接发布"
|
"已直接发布"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检聊天助手发布是否命中审批流。
|
||||||
|
*
|
||||||
|
* @param id 助手 ID
|
||||||
|
* @return 是否需要审批
|
||||||
|
*/
|
||||||
|
@GetMapping("/publishApprovalRequirement")
|
||||||
|
@SaCheckPermission("/api/v1/bot/save")
|
||||||
|
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
|
||||||
|
return Result.ok(botPublishAppService.isPublishApprovalRequired(id));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/submitOfflineApproval")
|
@PostMapping("/submitOfflineApproval")
|
||||||
@SaCheckPermission("/api/v1/bot/save")
|
@SaCheckPermission("/api/v1/bot/save")
|
||||||
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
||||||
|
|||||||
@@ -203,18 +203,34 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
|
|||||||
* 提交发布审批。
|
* 提交发布审批。
|
||||||
*
|
*
|
||||||
* @param id 知识库 ID
|
* @param id 知识库 ID
|
||||||
|
* @param applicationReason 审批说明
|
||||||
* @return 审批实例 ID
|
* @return 审批实例 ID
|
||||||
*/
|
*/
|
||||||
@PostMapping("/submitPublishApproval")
|
@PostMapping("/submitPublishApproval")
|
||||||
@SaCheckPermission("/api/v1/documentCollection/save")
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
|
public Result<BigInteger> submitPublishApproval(
|
||||||
|
@JsonBody("id") BigInteger id,
|
||||||
|
@JsonBody("applicationReason") String applicationReason
|
||||||
|
) {
|
||||||
return buildApprovalActionResult(
|
return buildApprovalActionResult(
|
||||||
knowledgePublishAppService.submitPublishApproval(id),
|
knowledgePublishAppService.submitPublishApproval(id, applicationReason),
|
||||||
"已提交发布审批",
|
"已提交发布审批",
|
||||||
"已直接发布"
|
"已直接发布"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检知识库发布是否命中审批流。
|
||||||
|
*
|
||||||
|
* @param id 知识库 ID
|
||||||
|
* @return 是否需要审批
|
||||||
|
*/
|
||||||
|
@GetMapping("/publishApprovalRequirement")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
|
||||||
|
return Result.ok(knowledgePublishAppService.isPublishApprovalRequired(id));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交下线审批。
|
* 提交下线审批。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import com.mybatisflex.core.paginate.Page;
|
|||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||||
|
import tech.easyflow.ai.permission.WorkflowShareResourceAccessGrantProvider;
|
||||||
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
|
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||||
@@ -53,6 +55,7 @@ import java.io.Serializable;
|
|||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -94,6 +97,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
private AiResourceApprovalStateService aiResourceApprovalStateService;
|
private AiResourceApprovalStateService aiResourceApprovalStateService;
|
||||||
@Resource
|
@Resource
|
||||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||||
|
@Resource
|
||||||
|
private WorkflowShareResourceAccessGrantProvider workflowShareGrantProvider;
|
||||||
|
|
||||||
public WorkflowController(WorkflowService service, ModelService modelService) {
|
public WorkflowController(WorkflowService service, ModelService modelService) {
|
||||||
super(service);
|
super(service);
|
||||||
@@ -256,18 +261,48 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
* 提交发布审批。
|
* 提交发布审批。
|
||||||
*
|
*
|
||||||
* @param id 工作流 ID
|
* @param id 工作流 ID
|
||||||
|
* @param applicationReason 审批说明
|
||||||
* @return 审批实例 ID
|
* @return 审批实例 ID
|
||||||
*/
|
*/
|
||||||
@PostMapping("/submitPublishApproval")
|
@PostMapping("/submitPublishApproval")
|
||||||
@SaCheckPermission("/api/v1/workflow/save")
|
@SaCheckPermission("/api/v1/workflow/save")
|
||||||
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
|
@RequireResourceAccess(
|
||||||
|
resource = CategoryResourceType.WORKFLOW,
|
||||||
|
action = ResourceAction.MANAGE,
|
||||||
|
lookup = ResourceLookup.WORKFLOW_ID,
|
||||||
|
idExpr = "#id",
|
||||||
|
denyMessage = "无权限发布工作流"
|
||||||
|
)
|
||||||
|
public Result<BigInteger> submitPublishApproval(
|
||||||
|
@JsonBody("id") BigInteger id,
|
||||||
|
@JsonBody("applicationReason") String applicationReason
|
||||||
|
) {
|
||||||
return buildApprovalActionResult(
|
return buildApprovalActionResult(
|
||||||
workflowPublishAppService.submitPublishApproval(id),
|
workflowPublishAppService.submitPublishApproval(id, applicationReason),
|
||||||
"已提交发布审批",
|
"已提交发布审批",
|
||||||
"已直接发布"
|
"已直接发布"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检工作流发布是否命中审批流。
|
||||||
|
*
|
||||||
|
* @param id 工作流 ID
|
||||||
|
* @return 是否需要审批
|
||||||
|
*/
|
||||||
|
@GetMapping("/publishApprovalRequirement")
|
||||||
|
@SaCheckPermission("/api/v1/workflow/save")
|
||||||
|
@RequireResourceAccess(
|
||||||
|
resource = CategoryResourceType.WORKFLOW,
|
||||||
|
action = ResourceAction.MANAGE,
|
||||||
|
lookup = ResourceLookup.WORKFLOW_ID,
|
||||||
|
idExpr = "#id",
|
||||||
|
denyMessage = "无权限发布工作流"
|
||||||
|
)
|
||||||
|
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
|
||||||
|
return Result.ok(workflowPublishAppService.isPublishApprovalRequired(id));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交下线审批。
|
* 提交下线审批。
|
||||||
*
|
*
|
||||||
@@ -276,6 +311,13 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/submitOfflineApproval")
|
@PostMapping("/submitOfflineApproval")
|
||||||
@SaCheckPermission("/api/v1/workflow/save")
|
@SaCheckPermission("/api/v1/workflow/save")
|
||||||
|
@RequireResourceAccess(
|
||||||
|
resource = CategoryResourceType.WORKFLOW,
|
||||||
|
action = ResourceAction.MANAGE,
|
||||||
|
lookup = ResourceLookup.WORKFLOW_ID,
|
||||||
|
idExpr = "#id",
|
||||||
|
denyMessage = "无权限下线工作流"
|
||||||
|
)
|
||||||
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
||||||
return buildApprovalActionResult(
|
return buildApprovalActionResult(
|
||||||
workflowPublishAppService.submitOfflineApproval(id),
|
workflowPublishAppService.submitOfflineApproval(id),
|
||||||
@@ -311,6 +353,13 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/submitDeleteApproval")
|
@PostMapping("/submitDeleteApproval")
|
||||||
@SaCheckPermission("/api/v1/workflow/remove")
|
@SaCheckPermission("/api/v1/workflow/remove")
|
||||||
|
@RequireResourceAccess(
|
||||||
|
resource = CategoryResourceType.WORKFLOW,
|
||||||
|
action = ResourceAction.MANAGE,
|
||||||
|
lookup = ResourceLookup.WORKFLOW_ID,
|
||||||
|
idExpr = "#id",
|
||||||
|
denyMessage = "无权限删除工作流"
|
||||||
|
)
|
||||||
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
|
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
|
||||||
return buildApprovalActionResult(
|
return buildApprovalActionResult(
|
||||||
workflowPublishAppService.submitDeleteApproval(id),
|
workflowPublishAppService.submitDeleteApproval(id),
|
||||||
@@ -378,14 +427,68 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
}
|
}
|
||||||
workflow.setId(null);
|
workflow.setId(null);
|
||||||
workflow.setAlias(IdUtil.fastSimpleUUID());
|
workflow.setAlias(IdUtil.fastSimpleUUID());
|
||||||
|
workflow.setRevision(0);
|
||||||
commonFiled(workflow, account.getId(), account.getTenantId(), account.getDeptId());
|
commonFiled(workflow, account.getId(), account.getTenantId(), account.getDeptId());
|
||||||
service.save(workflow);
|
service.save(workflow);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新工作流;分享访问只允许按修订号更新设计内容。
|
||||||
|
*
|
||||||
|
* @param entity 工作流更新内容
|
||||||
|
* @return 更新结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@PostMapping("update")
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Result<?> update(@JsonBody Workflow entity) {
|
||||||
|
if (entity == null || entity.getId() == null) {
|
||||||
|
throw new BusinessException("工作流 ID 不能为空");
|
||||||
|
}
|
||||||
|
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||||
|
boolean sharedRequest = workflowShareGrantProvider.isSharedRequestFor(
|
||||||
|
entity.getId(),
|
||||||
|
loginAccount
|
||||||
|
);
|
||||||
|
if (sharedRequest && entity.getContent() == null) {
|
||||||
|
throw new BusinessException(403, 403, "分享链接仅允许编辑工作流内容");
|
||||||
|
}
|
||||||
|
if (entity.getContent() == null) {
|
||||||
|
return super.update(entity);
|
||||||
|
}
|
||||||
|
if (entity.getRevision() == null) {
|
||||||
|
throw workflowRevisionConflict();
|
||||||
|
}
|
||||||
|
Result<?> beforeResult = onSaveOrUpdateBefore(entity, false);
|
||||||
|
if (beforeResult != null) {
|
||||||
|
return beforeResult;
|
||||||
|
}
|
||||||
|
boolean updated = service.updateContentByRevision(
|
||||||
|
entity.getId(),
|
||||||
|
entity.getContent(),
|
||||||
|
entity.getRevision(),
|
||||||
|
new Date(),
|
||||||
|
loginAccount.getId()
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
throw workflowRevisionConflict();
|
||||||
|
}
|
||||||
|
entity.setRevision(entity.getRevision() + 1);
|
||||||
|
if (!sharedRequest) {
|
||||||
|
entity.setContent(null);
|
||||||
|
service.updateById(entity);
|
||||||
|
}
|
||||||
|
onSaveOrUpdateAfter(entity, false);
|
||||||
|
return Result.ok(Map.of("revision", entity.getRevision()));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Result onSaveOrUpdateBefore(Workflow entity, boolean isSave) {
|
protected Result onSaveOrUpdateBefore(Workflow entity, boolean isSave) {
|
||||||
normalizeVisibilityScope(entity, isSave);
|
normalizeVisibilityScope(entity, isSave);
|
||||||
|
if (isSave && entity.getRevision() == null) {
|
||||||
|
entity.setRevision(0);
|
||||||
|
}
|
||||||
if (!isSave && entity.getId() != null) {
|
if (!isSave && entity.getId() != null) {
|
||||||
Workflow existed = requireWorkflow(String.valueOf(entity.getId()));
|
Workflow existed = requireWorkflow(String.valueOf(entity.getId()));
|
||||||
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, existed, ResourceAction.MANAGE, "无权限管理工作流");
|
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, existed, ResourceAction.MANAGE, "无权限管理工作流");
|
||||||
@@ -485,6 +588,15 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
return workflow;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建工作流内容修订冲突异常。
|
||||||
|
*
|
||||||
|
* @return HTTP 409 业务异常
|
||||||
|
*/
|
||||||
|
private BusinessException workflowRevisionConflict() {
|
||||||
|
return new BusinessException(409, 409, "工作流已被其他人更新,请刷新后重新编辑");
|
||||||
|
}
|
||||||
|
|
||||||
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
|
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
|
||||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
if (attributes == null) {
|
if (attributes == null) {
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowShareService;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.ai.vo.WorkflowShareCreateResult;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.util.RequestUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流协作分享管理接口。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/workflowShare")
|
||||||
|
public class WorkflowShareController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private WorkflowShareService workflowShareService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private WorkflowService workflowService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ResourceAccessService resourceAccessService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KnowledgeShareAuditService knowledgeShareAuditService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或刷新工作流协作分享链接。
|
||||||
|
*
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @return 分享创建结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/url/create")
|
||||||
|
@SaCheckPermission("/api/v1/workflow/save")
|
||||||
|
public Result<WorkflowShareCreateResult> createUrlShare(
|
||||||
|
HttpServletRequest request,
|
||||||
|
@JsonBody("workflowId") BigInteger workflowId
|
||||||
|
) {
|
||||||
|
Workflow workflow = workflowService.getById(workflowId);
|
||||||
|
if (workflow == null) {
|
||||||
|
throw new BusinessException("工作流不存在");
|
||||||
|
}
|
||||||
|
resourceAccessService.assertAccess(
|
||||||
|
CategoryResourceType.WORKFLOW,
|
||||||
|
workflow,
|
||||||
|
ResourceAction.MANAGE,
|
||||||
|
"无权限分享工作流"
|
||||||
|
);
|
||||||
|
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||||
|
WorkflowShareCreateResult result = workflowShareService.createUrlShare(
|
||||||
|
workflowId,
|
||||||
|
loginAccount.getTenantId(),
|
||||||
|
loginAccount.getDeptId(),
|
||||||
|
loginAccount.getId(),
|
||||||
|
buildShareBaseUrl(request)
|
||||||
|
);
|
||||||
|
knowledgeShareAuditService.log(
|
||||||
|
loginAccount.getId(),
|
||||||
|
"创建工作流协作分享",
|
||||||
|
"WORKFLOW_SHARE_CREATE",
|
||||||
|
request.getRequestURI(),
|
||||||
|
Map.of("workflowId", workflowId, "shareId", result.getId())
|
||||||
|
);
|
||||||
|
return Result.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析当前 URL 分享指向的工作流。
|
||||||
|
*
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @return 工作流标识
|
||||||
|
*/
|
||||||
|
@GetMapping("/resolve")
|
||||||
|
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
|
||||||
|
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||||
|
WorkflowShare share = workflowShareService.resolveUrlShare(
|
||||||
|
request.getHeader(WorkflowSharePolicy.SHARE_KEY_HEADER),
|
||||||
|
loginAccount.getTenantId()
|
||||||
|
);
|
||||||
|
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据管理端来源构建工作流分享基础 URL。
|
||||||
|
*
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @return 工作流设计页 URL
|
||||||
|
*/
|
||||||
|
private String buildShareBaseUrl(HttpServletRequest request) {
|
||||||
|
String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request));
|
||||||
|
if (refererBaseUrl != null) {
|
||||||
|
return refererBaseUrl + "/ai/workflow/design";
|
||||||
|
}
|
||||||
|
|
||||||
|
String forwardedOrigin = buildForwardedOrigin(request);
|
||||||
|
if (forwardedOrigin != null) {
|
||||||
|
return forwardedOrigin
|
||||||
|
+ normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix")))
|
||||||
|
+ "/ai/workflow/design";
|
||||||
|
}
|
||||||
|
|
||||||
|
String origin = normalizeOrigin(request.getHeader("Origin"));
|
||||||
|
if (origin != null) {
|
||||||
|
return origin + normalizeBasePath(request.getContextPath()) + "/ai/workflow/design";
|
||||||
|
}
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
builder.append(request.getScheme()).append("://").append(request.getServerName());
|
||||||
|
if (request.getServerPort() != 80 && request.getServerPort() != 443) {
|
||||||
|
builder.append(':').append(request.getServerPort());
|
||||||
|
}
|
||||||
|
return builder.append(normalizeBasePath(request.getContextPath()))
|
||||||
|
.append("/ai/workflow/design")
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从来源地址提取前端 origin 与部署基路径。
|
||||||
|
*
|
||||||
|
* @param sourceUrl 来源地址
|
||||||
|
* @return origin 与部署基路径,无法解析时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private String extractFrontendBaseUrl(String sourceUrl) {
|
||||||
|
if (sourceUrl == null || sourceUrl.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = new URI(sourceUrl.trim());
|
||||||
|
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String origin = extractOrigin(sourceUrl);
|
||||||
|
return origin == null ? null : origin + inferFrontendBasePath(uri.getPath());
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从前端页面路径推断部署基路径。
|
||||||
|
*
|
||||||
|
* @param path 页面路径
|
||||||
|
* @return 规范化后的部署基路径
|
||||||
|
*/
|
||||||
|
private String inferFrontendBasePath(String path) {
|
||||||
|
if (path == null || path.isBlank() || "/".equals(path)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
for (String marker : new String[]{"/ai/", "/auth/", "/share/"}) {
|
||||||
|
int markerIndex = path.indexOf(marker);
|
||||||
|
if (markerIndex > 0) {
|
||||||
|
return normalizeBasePath(path.substring(0, markerIndex));
|
||||||
|
}
|
||||||
|
if (markerIndex == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化部署基路径。
|
||||||
|
*
|
||||||
|
* @param basePath 原始基路径
|
||||||
|
* @return 无尾斜杠的基路径
|
||||||
|
*/
|
||||||
|
private String normalizeBasePath(String basePath) {
|
||||||
|
if (basePath == null || basePath.isBlank() || "/".equals(basePath.trim())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = basePath.trim();
|
||||||
|
if (!normalized.startsWith("/")) {
|
||||||
|
normalized = "/" + normalized;
|
||||||
|
}
|
||||||
|
while (normalized.endsWith("/") && normalized.length() > 1) {
|
||||||
|
normalized = normalized.substring(0, normalized.length() - 1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按反向代理头构建外部访问 origin。
|
||||||
|
*
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @return 外部 origin,缺少代理头时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private String buildForwardedOrigin(HttpServletRequest request) {
|
||||||
|
String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto"));
|
||||||
|
String host = firstHeaderValue(request.getHeader("X-Forwarded-Host"));
|
||||||
|
if (proto == null || host == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizeOrigin(proto + "://" + host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 提取 origin。
|
||||||
|
*
|
||||||
|
* @param url 完整 URL
|
||||||
|
* @return origin,无法解析时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private String extractOrigin(String url) {
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = new URI(url.trim());
|
||||||
|
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
builder.append(uri.getScheme()).append("://").append(uri.getHost());
|
||||||
|
if (uri.getPort() != -1 && uri.getPort() != 80 && uri.getPort() != 443) {
|
||||||
|
builder.append(':').append(uri.getPort());
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化 origin。
|
||||||
|
*
|
||||||
|
* @param origin 原始 origin
|
||||||
|
* @return 规范化结果
|
||||||
|
*/
|
||||||
|
private String normalizeOrigin(String origin) {
|
||||||
|
return extractOrigin(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取代理头的首个有效值。
|
||||||
|
*
|
||||||
|
* @param value 原始请求头
|
||||||
|
* @return 首个有效值
|
||||||
|
*/
|
||||||
|
private String firstHeaderValue(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int commaIndex = value.indexOf(',');
|
||||||
|
String normalized = commaIndex >= 0 ? value.substring(0, commaIndex) : value;
|
||||||
|
normalized = normalized.trim();
|
||||||
|
return normalized.isEmpty() ? null : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowShareController} 分享地址构建测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowShareControllerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证分享地址保留前端部署基路径。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPreserveFrontendBasePathFromReferer() throws Exception {
|
||||||
|
HttpServletRequest request = request(Map.of(
|
||||||
|
"referer",
|
||||||
|
"https://example.test/easyflow/ai/workflow?page=1"
|
||||||
|
));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
buildShareBaseUrl(request),
|
||||||
|
"https://example.test/easyflow/ai/workflow/design"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证反向代理头用于构建外部 HTTPS 分享地址。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldUseForwardedOriginAndPrefix() throws Exception {
|
||||||
|
HttpServletRequest request = request(Map.of(
|
||||||
|
"x-forwarded-proto", "https",
|
||||||
|
"x-forwarded-host", "example.test",
|
||||||
|
"x-forwarded-prefix", "/easyflow"
|
||||||
|
));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
buildShareBaseUrl(request),
|
||||||
|
"https://example.test/easyflow/ai/workflow/design"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用控制器的分享基础地址构建方法。
|
||||||
|
*
|
||||||
|
* @param request 模拟 HTTP 请求
|
||||||
|
* @return 分享基础地址
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
private String buildShareBaseUrl(HttpServletRequest request) throws Exception {
|
||||||
|
Method method = WorkflowShareController.class.getDeclaredMethod(
|
||||||
|
"buildShareBaseUrl",
|
||||||
|
HttpServletRequest.class
|
||||||
|
);
|
||||||
|
method.setAccessible(true);
|
||||||
|
return (String) method.invoke(new WorkflowShareController(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建仅提供请求头能力的轻量 Servlet 请求代理。
|
||||||
|
*
|
||||||
|
* @param headers 小写请求头映射
|
||||||
|
* @return HTTP 请求代理
|
||||||
|
*/
|
||||||
|
private HttpServletRequest request(Map<String, String> headers) {
|
||||||
|
return (HttpServletRequest) Proxy.newProxyInstance(
|
||||||
|
getClass().getClassLoader(),
|
||||||
|
new Class<?>[]{HttpServletRequest.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("getHeader".equals(method.getName())) {
|
||||||
|
String name = String.valueOf(args[0]).toLowerCase(Locale.ROOT);
|
||||||
|
return headers.get(name);
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回代理方法所需的基础类型默认值。
|
||||||
|
*
|
||||||
|
* @param returnType 返回值类型
|
||||||
|
* @return 对应默认值
|
||||||
|
*/
|
||||||
|
private Object defaultValue(Class<?> returnType) {
|
||||||
|
if (!returnType.isPrimitive()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (boolean.class == returnType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (char.class == returnType) {
|
||||||
|
return '\0';
|
||||||
|
}
|
||||||
|
if (byte.class == returnType) {
|
||||||
|
return (byte) 0;
|
||||||
|
}
|
||||||
|
if (short.class == returnType) {
|
||||||
|
return (short) 0;
|
||||||
|
}
|
||||||
|
if (int.class == returnType) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (long.class == returnType) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
if (float.class == returnType) {
|
||||||
|
return 0F;
|
||||||
|
}
|
||||||
|
return 0D;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,9 +48,12 @@ public class GlobalErrorResolver implements HandlerExceptionResolver {
|
|||||||
if (ex instanceof MissingServletRequestParameterException) {
|
if (ex instanceof MissingServletRequestParameterException) {
|
||||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||||
error = Result.fail(400, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空");
|
error = Result.fail(400, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空");
|
||||||
} else if (ex instanceof NotLoginException) {
|
} else if (ex instanceof NotLoginException notLoginException) {
|
||||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||||
error = Result.fail(401, "请登录");
|
String message = NotLoginException.BE_REPLACED.equals(notLoginException.getType())
|
||||||
|
? "该账号已在其他设备登录,请重新登录"
|
||||||
|
: "请登录";
|
||||||
|
error = Result.fail(401, message);
|
||||||
} else if (ex instanceof NotPermissionException || ex instanceof NotRoleException) {
|
} else if (ex instanceof NotPermissionException || ex instanceof NotRoleException) {
|
||||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||||
error = Result.fail(403, "无权操作");
|
error = Result.fail(403, "无权操作");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.common.web.error;
|
package tech.easyflow.common.web.error;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.exception.NotLoginException;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.springframework.core.MethodParameter;
|
import org.springframework.core.MethodParameter;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -22,6 +23,26 @@ public class GlobalErrorResolverTest {
|
|||||||
|
|
||||||
private final GlobalErrorResolver resolver = new GlobalErrorResolver();
|
private final GlobalErrorResolver resolver = new GlobalErrorResolver();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证被新 Web 登录替换的旧会话返回明确的 401 提示。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldExplainReplacedLoginSession() {
|
||||||
|
Resolution resolution = resolve(NotLoginException.newInstance(
|
||||||
|
"login",
|
||||||
|
NotLoginException.BE_REPLACED,
|
||||||
|
NotLoginException.BE_REPLACED_MESSAGE,
|
||||||
|
null
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals(401, resolution.response.getStatus());
|
||||||
|
assertEquals(401, resolution.modelAndView.getModel().get("errorCode"));
|
||||||
|
assertEquals(
|
||||||
|
"该账号已在其他设备登录,请重新登录",
|
||||||
|
resolution.modelAndView.getModel().get("message")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证业务冲突不会被包装为 HTTP 200。
|
* 验证业务冲突不会被包装为 HTTP 200。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package tech.easyflow.ai.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Id;
|
||||||
|
import com.mybatisflex.annotation.KeyType;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流协作分享记录。
|
||||||
|
*/
|
||||||
|
@Table("tb_workflow_share")
|
||||||
|
public class WorkflowShare implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "ID")
|
||||||
|
private BigInteger id;
|
||||||
|
|
||||||
|
@Column(comment = "工作流ID")
|
||||||
|
private BigInteger workflowId;
|
||||||
|
|
||||||
|
@Column(comment = "分享密钥哈希")
|
||||||
|
private String shareKeyHash;
|
||||||
|
|
||||||
|
@Column(comment = "分享状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(comment = "过期时间")
|
||||||
|
private Date expiresAt;
|
||||||
|
|
||||||
|
@Column(tenantId = true, comment = "租户ID")
|
||||||
|
private BigInteger tenantId;
|
||||||
|
|
||||||
|
@Column(comment = "部门ID")
|
||||||
|
private BigInteger deptId;
|
||||||
|
|
||||||
|
@Column(comment = "创建时间")
|
||||||
|
private Date created;
|
||||||
|
|
||||||
|
@Column(comment = "创建人")
|
||||||
|
private BigInteger createdBy;
|
||||||
|
|
||||||
|
@Column(comment = "修改时间")
|
||||||
|
private Date modified;
|
||||||
|
|
||||||
|
@Column(comment = "修改人")
|
||||||
|
private BigInteger modifiedBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取记录 ID。
|
||||||
|
*
|
||||||
|
* @return 记录 ID
|
||||||
|
*/
|
||||||
|
public BigInteger getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置记录 ID。
|
||||||
|
*
|
||||||
|
* @param id 记录 ID
|
||||||
|
*/
|
||||||
|
public void setId(BigInteger id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作流 ID。
|
||||||
|
*
|
||||||
|
* @return 工作流 ID
|
||||||
|
*/
|
||||||
|
public BigInteger getWorkflowId() {
|
||||||
|
return workflowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工作流 ID。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
*/
|
||||||
|
public void setWorkflowId(BigInteger workflowId) {
|
||||||
|
this.workflowId = workflowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享密钥哈希。
|
||||||
|
*
|
||||||
|
* @return 分享密钥哈希
|
||||||
|
*/
|
||||||
|
public String getShareKeyHash() {
|
||||||
|
return shareKeyHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置分享密钥哈希。
|
||||||
|
*
|
||||||
|
* @param shareKeyHash 分享密钥哈希
|
||||||
|
*/
|
||||||
|
public void setShareKeyHash(String shareKeyHash) {
|
||||||
|
this.shareKeyHash = shareKeyHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享状态。
|
||||||
|
*
|
||||||
|
* @return 分享状态
|
||||||
|
*/
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置分享状态。
|
||||||
|
*
|
||||||
|
* @param status 分享状态
|
||||||
|
*/
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取过期时间。
|
||||||
|
*
|
||||||
|
* @return 过期时间
|
||||||
|
*/
|
||||||
|
public Date getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置过期时间。
|
||||||
|
*
|
||||||
|
* @param expiresAt 过期时间
|
||||||
|
*/
|
||||||
|
public void setExpiresAt(Date expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取租户 ID。
|
||||||
|
*
|
||||||
|
* @return 租户 ID
|
||||||
|
*/
|
||||||
|
public BigInteger getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置租户 ID。
|
||||||
|
*
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
*/
|
||||||
|
public void setTenantId(BigInteger tenantId) {
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门 ID。
|
||||||
|
*
|
||||||
|
* @return 部门 ID
|
||||||
|
*/
|
||||||
|
public BigInteger getDeptId() {
|
||||||
|
return deptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置部门 ID。
|
||||||
|
*
|
||||||
|
* @param deptId 部门 ID
|
||||||
|
*/
|
||||||
|
public void setDeptId(BigInteger deptId) {
|
||||||
|
this.deptId = deptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取创建时间。
|
||||||
|
*
|
||||||
|
* @return 创建时间
|
||||||
|
*/
|
||||||
|
public Date getCreated() {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置创建时间。
|
||||||
|
*
|
||||||
|
* @param created 创建时间
|
||||||
|
*/
|
||||||
|
public void setCreated(Date created) {
|
||||||
|
this.created = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取创建人。
|
||||||
|
*
|
||||||
|
* @return 创建人账号 ID
|
||||||
|
*/
|
||||||
|
public BigInteger getCreatedBy() {
|
||||||
|
return createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置创建人。
|
||||||
|
*
|
||||||
|
* @param createdBy 创建人账号 ID
|
||||||
|
*/
|
||||||
|
public void setCreatedBy(BigInteger createdBy) {
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取修改时间。
|
||||||
|
*
|
||||||
|
* @return 修改时间
|
||||||
|
*/
|
||||||
|
public Date getModified() {
|
||||||
|
return modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置修改时间。
|
||||||
|
*
|
||||||
|
* @param modified 修改时间
|
||||||
|
*/
|
||||||
|
public void setModified(Date modified) {
|
||||||
|
this.modified = modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取修改人。
|
||||||
|
*
|
||||||
|
* @return 修改人账号 ID
|
||||||
|
*/
|
||||||
|
public BigInteger getModifiedBy() {
|
||||||
|
return modifiedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置修改人。
|
||||||
|
*
|
||||||
|
* @param modifiedBy 修改人账号 ID
|
||||||
|
*/
|
||||||
|
public void setModifiedBy(BigInteger modifiedBy) {
|
||||||
|
this.modifiedBy = modifiedBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,12 @@ public class WorkflowBase extends DateEntity implements Serializable {
|
|||||||
@Column(comment = "工作流设计的 JSON 内容")
|
@Column(comment = "工作流设计的 JSON 内容")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流内容修订号
|
||||||
|
*/
|
||||||
|
@Column(comment = "工作流内容修订号")
|
||||||
|
private Integer revision;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建时间
|
* 创建时间
|
||||||
*/
|
*/
|
||||||
@@ -205,6 +211,24 @@ public class WorkflowBase extends DateEntity implements Serializable {
|
|||||||
this.content = content;
|
this.content = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作流内容修订号。
|
||||||
|
*
|
||||||
|
* @return 工作流内容修订号
|
||||||
|
*/
|
||||||
|
public Integer getRevision() {
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工作流内容修订号。
|
||||||
|
*
|
||||||
|
* @param revision 工作流内容修订号
|
||||||
|
*/
|
||||||
|
public void setRevision(Integer revision) {
|
||||||
|
this.revision = revision;
|
||||||
|
}
|
||||||
|
|
||||||
public Date getCreated() {
|
public Date getCreated() {
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package tech.easyflow.ai.mapper;
|
package tech.easyflow.ai.mapper;
|
||||||
|
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
|
||||||
import com.mybatisflex.core.BaseMapper;
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 映射层。
|
* 映射层。
|
||||||
@@ -11,4 +16,24 @@ import com.mybatisflex.core.BaseMapper;
|
|||||||
*/
|
*/
|
||||||
public interface WorkflowMapper extends BaseMapper<Workflow> {
|
public interface WorkflowMapper extends BaseMapper<Workflow> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按预期修订号原子更新工作流内容。
|
||||||
|
*
|
||||||
|
* @param id 工作流 ID
|
||||||
|
* @param content 工作流内容
|
||||||
|
* @param expectedRevision 预期修订号
|
||||||
|
* @param modified 修改时间
|
||||||
|
* @param modifiedBy 修改人账号 ID
|
||||||
|
* @return 更新行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE tb_workflow "
|
||||||
|
+ "SET content=#{content}, revision=revision+1, modified=#{modified}, modified_by=#{modifiedBy} "
|
||||||
|
+ "WHERE id=#{id} AND revision=#{expectedRevision}")
|
||||||
|
int updateContentByRevision(
|
||||||
|
@Param("id") BigInteger id,
|
||||||
|
@Param("content") String content,
|
||||||
|
@Param("expectedRevision") Integer expectedRevision,
|
||||||
|
@Param("modified") Date modified,
|
||||||
|
@Param("modifiedBy") BigInteger modifiedBy
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package tech.easyflow.ai.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流协作分享记录映射层。
|
||||||
|
*/
|
||||||
|
public interface WorkflowShareMapper extends BaseMapper<WorkflowShare> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package tech.easyflow.ai.permission;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowShareService;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
import tech.easyflow.system.permission.resource.ResourceAccessGrantProvider;
|
||||||
|
import tech.easyflow.system.permission.resource.VisibilityResource;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于工作流分享密钥补充协作编辑权限。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowShareResourceAccessGrantProvider implements ResourceAccessGrantProvider {
|
||||||
|
|
||||||
|
private static final String AUDIT_RECORDED_ATTRIBUTE =
|
||||||
|
WorkflowShareResourceAccessGrantProvider.class.getName() + ".AUDIT_RECORDED";
|
||||||
|
|
||||||
|
private final WorkflowShareService workflowShareService;
|
||||||
|
private final KnowledgeShareAuditService knowledgeShareAuditService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流分享授权提供器。
|
||||||
|
*
|
||||||
|
* @param workflowShareService 工作流分享服务
|
||||||
|
* @param knowledgeShareAuditService 分享审计服务
|
||||||
|
*/
|
||||||
|
public WorkflowShareResourceAccessGrantProvider(
|
||||||
|
WorkflowShareService workflowShareService,
|
||||||
|
KnowledgeShareAuditService knowledgeShareAuditService
|
||||||
|
) {
|
||||||
|
this.workflowShareService = workflowShareService;
|
||||||
|
this.knowledgeShareAuditService = knowledgeShareAuditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean grants(
|
||||||
|
LoginAccount loginAccount,
|
||||||
|
CategoryResourceType resourceType,
|
||||||
|
VisibilityResource resource,
|
||||||
|
ResourceAction action
|
||||||
|
) {
|
||||||
|
if (resourceType != CategoryResourceType.WORKFLOW
|
||||||
|
|| !(resource instanceof Workflow workflow)
|
||||||
|
|| loginAccount == null
|
||||||
|
|| !WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
currentRequestMethod(),
|
||||||
|
currentRequestUri(),
|
||||||
|
action
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isSharedRequestFor(workflow.getId(), loginAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前请求是否携带指定工作流的有效分享密钥。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @return 未携带分享密钥时返回 {@code false},携带有效密钥时返回 {@code true}
|
||||||
|
*/
|
||||||
|
public boolean isSharedRequestFor(BigInteger workflowId, LoginAccount loginAccount) {
|
||||||
|
HttpServletRequest request = currentRequest();
|
||||||
|
if (request == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String shareKey = request.getHeader(WorkflowSharePolicy.SHARE_KEY_HEADER);
|
||||||
|
if (shareKey == null || shareKey.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
WorkflowShare share = workflowShareService.assertUrlShareAccess(
|
||||||
|
shareKey,
|
||||||
|
workflowId,
|
||||||
|
loginAccount == null ? null : loginAccount.getTenantId()
|
||||||
|
);
|
||||||
|
recordSharedOperation(request, share, loginAccount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个请求只记录一次工作流分享操作审计。
|
||||||
|
*
|
||||||
|
* @param request 当前请求
|
||||||
|
* @param share 分享记录
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
*/
|
||||||
|
private void recordSharedOperation(
|
||||||
|
HttpServletRequest request,
|
||||||
|
WorkflowShare share,
|
||||||
|
LoginAccount loginAccount
|
||||||
|
) {
|
||||||
|
if (Boolean.TRUE.equals(request.getAttribute(AUDIT_RECORDED_ATTRIBUTE))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.setAttribute(AUDIT_RECORDED_ATTRIBUTE, Boolean.TRUE);
|
||||||
|
knowledgeShareAuditService.log(
|
||||||
|
loginAccount.getId(),
|
||||||
|
"使用工作流协作分享",
|
||||||
|
"WORKFLOW_SHARE_ACCESS",
|
||||||
|
request.getRequestURI(),
|
||||||
|
Map.of(
|
||||||
|
"workflowId", share.getWorkflowId(),
|
||||||
|
"shareId", share.getId(),
|
||||||
|
"method", request.getMethod()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前 HTTP 请求。
|
||||||
|
*
|
||||||
|
* @return 当前请求,不在 Web 请求中时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private HttpServletRequest currentRequest() {
|
||||||
|
if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return attributes.getRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前请求方法。
|
||||||
|
*
|
||||||
|
* @return 请求方法
|
||||||
|
*/
|
||||||
|
private String currentRequestMethod() {
|
||||||
|
HttpServletRequest request = currentRequest();
|
||||||
|
return request == null ? null : request.getMethod();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前请求路径。
|
||||||
|
*
|
||||||
|
* @return 请求路径
|
||||||
|
*/
|
||||||
|
private String currentRequestUri() {
|
||||||
|
HttpServletRequest request = currentRequest();
|
||||||
|
return request == null ? null : request.getRequestURI();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,4 +19,38 @@ public interface AiResourceLifecycleService {
|
|||||||
* @return 执行结果
|
* @return 执行结果
|
||||||
*/
|
*/
|
||||||
ApprovalActionResult submitAction(String resourceType, BigInteger resourceId, String actionType, BigInteger operatorId);
|
ApprovalActionResult submitAction(String resourceType, BigInteger resourceId, String actionType, BigInteger operatorId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交带发起说明的资源动作。
|
||||||
|
*
|
||||||
|
* @param resourceType 资源类型
|
||||||
|
* @param resourceId 资源 ID
|
||||||
|
* @param actionType 动作类型
|
||||||
|
* @param operatorId 操作人 ID
|
||||||
|
* @param applicationReason 审批说明
|
||||||
|
* @return 执行结果
|
||||||
|
*/
|
||||||
|
ApprovalActionResult submitAction(
|
||||||
|
String resourceType,
|
||||||
|
BigInteger resourceId,
|
||||||
|
String actionType,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String applicationReason
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检当前资源动作是否命中审批流。
|
||||||
|
*
|
||||||
|
* @param resourceType 资源类型
|
||||||
|
* @param resourceId 资源 ID
|
||||||
|
* @param actionType 动作类型
|
||||||
|
* @param operatorId 操作人 ID
|
||||||
|
* @return 是否需要审批
|
||||||
|
*/
|
||||||
|
boolean isApprovalRequired(
|
||||||
|
String resourceType,
|
||||||
|
BigInteger resourceId,
|
||||||
|
String actionType,
|
||||||
|
BigInteger operatorId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import tech.easyflow.approval.enums.ApprovalActionType;
|
|||||||
import tech.easyflow.approval.service.ApprovalInstanceService;
|
import tech.easyflow.approval.service.ApprovalInstanceService;
|
||||||
import tech.easyflow.approval.service.ApprovalMatchService;
|
import tech.easyflow.approval.service.ApprovalMatchService;
|
||||||
import tech.easyflow.approval.service.ApprovalResultHandler;
|
import tech.easyflow.approval.service.ApprovalResultHandler;
|
||||||
|
import tech.easyflow.approval.support.ApprovalApplicationReasonPolicy;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
@@ -44,6 +45,21 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic
|
|||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public ApprovalActionResult submitAction(String resourceType, BigInteger resourceId, String actionType, BigInteger operatorId) {
|
public ApprovalActionResult submitAction(String resourceType, BigInteger resourceId, String actionType, BigInteger operatorId) {
|
||||||
|
return submitAction(resourceType, resourceId, actionType, operatorId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public ApprovalActionResult submitAction(
|
||||||
|
String resourceType,
|
||||||
|
BigInteger resourceId,
|
||||||
|
String actionType,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String applicationReason
|
||||||
|
) {
|
||||||
AiResourceLifecycleHandler handler = getHandler(resourceType);
|
AiResourceLifecycleHandler handler = getHandler(resourceType);
|
||||||
ApprovalSubmitRequest request = handler.buildSubmitRequest(resourceId, actionType, operatorId);
|
ApprovalSubmitRequest request = handler.buildSubmitRequest(resourceId, actionType, operatorId);
|
||||||
ApprovalFlowDetailVo flow = approvalMatchService.matchFlowOrNull(request);
|
ApprovalFlowDetailVo flow = approvalMatchService.matchFlowOrNull(request);
|
||||||
@@ -51,6 +67,11 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic
|
|||||||
handler.applyApprovedAction(actionType, resourceId, readResourceSnapshot(request.getSnapshotJson()), operatorId);
|
handler.applyApprovedAction(actionType, resourceId, readResourceSnapshot(request.getSnapshotJson()), operatorId);
|
||||||
return ApprovalActionResult.direct();
|
return ApprovalActionResult.direct();
|
||||||
}
|
}
|
||||||
|
request.setApplicationReason(ApprovalApplicationReasonPolicy.normalize(
|
||||||
|
true,
|
||||||
|
actionType,
|
||||||
|
applicationReason
|
||||||
|
));
|
||||||
BigInteger instanceId = approvalInstanceService.submitApproval(request);
|
BigInteger instanceId = approvalInstanceService.submitApproval(request);
|
||||||
handler.updatePendingState(
|
handler.updatePendingState(
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -60,6 +81,21 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic
|
|||||||
return ApprovalActionResult.required(instanceId);
|
return ApprovalActionResult.required(instanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isApprovalRequired(
|
||||||
|
String resourceType,
|
||||||
|
BigInteger resourceId,
|
||||||
|
String actionType,
|
||||||
|
BigInteger operatorId
|
||||||
|
) {
|
||||||
|
AiResourceLifecycleHandler handler = getHandler(resourceType);
|
||||||
|
ApprovalSubmitRequest request = handler.buildSubmitRequest(resourceId, actionType, operatorId);
|
||||||
|
return approvalMatchService.matchFlowOrNull(request) != null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,11 +25,29 @@ public class BotPublishAppService {
|
|||||||
* 提交聊天助手发布审批。
|
* 提交聊天助手发布审批。
|
||||||
*
|
*
|
||||||
* @param id 助手 ID
|
* @param id 助手 ID
|
||||||
|
* @param applicationReason 审批说明
|
||||||
* @return 动作执行结果
|
* @return 动作执行结果
|
||||||
*/
|
*/
|
||||||
public ApprovalActionResult submitPublishApproval(BigInteger id) {
|
public ApprovalActionResult submitPublishApproval(BigInteger id, String applicationReason) {
|
||||||
assertId(id);
|
assertId(id);
|
||||||
return aiResourceLifecycleService.submitAction(
|
return aiResourceLifecycleService.submitAction(
|
||||||
|
ApprovalResourceType.BOT.getCode(),
|
||||||
|
id,
|
||||||
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
SaTokenUtil.getLoginAccount().getId(),
|
||||||
|
applicationReason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检聊天助手发布是否需要审批。
|
||||||
|
*
|
||||||
|
* @param id 助手 ID
|
||||||
|
* @return 是否需要审批
|
||||||
|
*/
|
||||||
|
public boolean isPublishApprovalRequired(BigInteger id) {
|
||||||
|
assertId(id);
|
||||||
|
return aiResourceLifecycleService.isApprovalRequired(
|
||||||
ApprovalResourceType.BOT.getCode(),
|
ApprovalResourceType.BOT.getCode(),
|
||||||
id,
|
id,
|
||||||
ApprovalActionType.PUBLISH.getCode(),
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
|||||||
@@ -36,11 +36,29 @@ public class KnowledgePublishAppService {
|
|||||||
* 提交知识库发布审批。
|
* 提交知识库发布审批。
|
||||||
*
|
*
|
||||||
* @param id 知识库 ID
|
* @param id 知识库 ID
|
||||||
|
* @param applicationReason 审批说明
|
||||||
* @return 动作执行结果
|
* @return 动作执行结果
|
||||||
*/
|
*/
|
||||||
public ApprovalActionResult submitPublishApproval(BigInteger id) {
|
public ApprovalActionResult submitPublishApproval(BigInteger id, String applicationReason) {
|
||||||
assertId(id);
|
assertId(id);
|
||||||
return aiResourceLifecycleService.submitAction(
|
return aiResourceLifecycleService.submitAction(
|
||||||
|
ApprovalResourceType.KNOWLEDGE.getCode(),
|
||||||
|
id,
|
||||||
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
SaTokenUtil.getLoginAccount().getId(),
|
||||||
|
applicationReason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检知识库发布是否需要审批。
|
||||||
|
*
|
||||||
|
* @param id 知识库 ID
|
||||||
|
* @return 是否需要审批
|
||||||
|
*/
|
||||||
|
public boolean isPublishApprovalRequired(BigInteger id) {
|
||||||
|
assertId(id);
|
||||||
|
return aiResourceLifecycleService.isApprovalRequired(
|
||||||
ApprovalResourceType.KNOWLEDGE.getCode(),
|
ApprovalResourceType.KNOWLEDGE.getCode(),
|
||||||
id,
|
id,
|
||||||
ApprovalActionType.PUBLISH.getCode(),
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
|||||||
@@ -36,11 +36,29 @@ public class WorkflowPublishAppService {
|
|||||||
* 提交工作流发布审批。
|
* 提交工作流发布审批。
|
||||||
*
|
*
|
||||||
* @param id 工作流 ID
|
* @param id 工作流 ID
|
||||||
|
* @param applicationReason 审批说明
|
||||||
* @return 动作执行结果
|
* @return 动作执行结果
|
||||||
*/
|
*/
|
||||||
public ApprovalActionResult submitPublishApproval(BigInteger id) {
|
public ApprovalActionResult submitPublishApproval(BigInteger id, String applicationReason) {
|
||||||
assertId(id, ApprovalResourceType.WORKFLOW.getCode(), ApprovalActionType.PUBLISH.getCode());
|
assertId(id, ApprovalResourceType.WORKFLOW.getCode(), ApprovalActionType.PUBLISH.getCode());
|
||||||
return aiResourceLifecycleService.submitAction(
|
return aiResourceLifecycleService.submitAction(
|
||||||
|
ApprovalResourceType.WORKFLOW.getCode(),
|
||||||
|
id,
|
||||||
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
SaTokenUtil.getLoginAccount().getId(),
|
||||||
|
applicationReason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检工作流发布是否需要审批。
|
||||||
|
*
|
||||||
|
* @param id 工作流 ID
|
||||||
|
* @return 是否需要审批
|
||||||
|
*/
|
||||||
|
public boolean isPublishApprovalRequired(BigInteger id) {
|
||||||
|
assertId(id, ApprovalResourceType.WORKFLOW.getCode(), ApprovalActionType.PUBLISH.getCode());
|
||||||
|
return aiResourceLifecycleService.isApprovalRequired(
|
||||||
ApprovalResourceType.WORKFLOW.getCode(),
|
ApprovalResourceType.WORKFLOW.getCode(),
|
||||||
id,
|
id,
|
||||||
ApprovalActionType.PUBLISH.getCode(),
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import tech.easyflow.ai.entity.Workflow;
|
|||||||
import com.mybatisflex.core.service.IService;
|
import com.mybatisflex.core.service.IService;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务层。
|
* 服务层。
|
||||||
@@ -44,4 +45,22 @@ public interface WorkflowService extends IService<Workflow> {
|
|||||||
* @return 已发布视图
|
* @return 已发布视图
|
||||||
*/
|
*/
|
||||||
Workflow toPublishedView(Workflow workflow);
|
Workflow toPublishedView(Workflow workflow);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按预期修订号原子更新工作流内容。
|
||||||
|
*
|
||||||
|
* @param id 工作流 ID
|
||||||
|
* @param content 工作流内容
|
||||||
|
* @param expectedRevision 预期修订号
|
||||||
|
* @param modified 修改时间
|
||||||
|
* @param modifiedBy 修改人账号 ID
|
||||||
|
* @return 更新成功时返回 {@code true}
|
||||||
|
*/
|
||||||
|
boolean updateContentByRevision(
|
||||||
|
BigInteger id,
|
||||||
|
String content,
|
||||||
|
Integer expectedRevision,
|
||||||
|
Date modified,
|
||||||
|
BigInteger modifiedBy
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.service.IService;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
import tech.easyflow.ai.vo.WorkflowShareCreateResult;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流协作分享服务。
|
||||||
|
*/
|
||||||
|
public interface WorkflowShareService extends IService<WorkflowShare> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或刷新工作流的唯一协作分享链接。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param deptId 部门 ID
|
||||||
|
* @param operatorId 操作人账号 ID
|
||||||
|
* @param baseUrl 工作流分享基础 URL
|
||||||
|
* @return 创建结果
|
||||||
|
*/
|
||||||
|
WorkflowShareCreateResult createUrlShare(
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger deptId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String baseUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验分享密钥是否可访问指定工作流。
|
||||||
|
*
|
||||||
|
* @param shareKey 原始分享密钥
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param tenantId 当前登录租户 ID
|
||||||
|
* @return 有效分享记录
|
||||||
|
*/
|
||||||
|
WorkflowShare assertUrlShareAccess(
|
||||||
|
String shareKey,
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验分享密钥并解析目标工作流。
|
||||||
|
*
|
||||||
|
* @param shareKey 原始分享密钥
|
||||||
|
* @param tenantId 当前登录租户 ID
|
||||||
|
* @return 有效分享记录
|
||||||
|
*/
|
||||||
|
WorkflowShare resolveUrlShare(String shareKey, BigInteger tenantId);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
import tech.easyflow.ai.utils.CustomBeanUtils;
|
import tech.easyflow.ai.utils.CustomBeanUtils;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,7 +116,11 @@ public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> i
|
|||||||
throw new BusinessException("工作流不存在");
|
throw new BusinessException("工作流不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int nextRevision = workFlow.getRevision() == null ? 1 : workFlow.getRevision() + 1;
|
||||||
CustomBeanUtils.copyPropertiesIgnoreNull(entity,workFlow);
|
CustomBeanUtils.copyPropertiesIgnoreNull(entity,workFlow);
|
||||||
|
if (entity.getContent() != null) {
|
||||||
|
workFlow.setRevision(nextRevision);
|
||||||
|
}
|
||||||
|
|
||||||
if ("".equals(workFlow.getAlias())){
|
if ("".equals(workFlow.getAlias())){
|
||||||
workFlow.setAlias(null);
|
workFlow.setAlias(null);
|
||||||
@@ -125,5 +130,24 @@ public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> i
|
|||||||
return super.updateById(workFlow,false);
|
return super.updateById(workFlow,false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean updateContentByRevision(
|
||||||
|
BigInteger id,
|
||||||
|
String content,
|
||||||
|
Integer expectedRevision,
|
||||||
|
Date modified,
|
||||||
|
BigInteger modifiedBy
|
||||||
|
) {
|
||||||
|
return getMapper().updateContentByRevision(
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
expectedRevision,
|
||||||
|
modified,
|
||||||
|
modifiedBy
|
||||||
|
) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
import tech.easyflow.ai.enums.KnowledgeShareStatus;
|
||||||
|
import tech.easyflow.ai.mapper.WorkflowShareMapper;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowShareService;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.ai.vo.WorkflowShareCreateResult;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流协作分享服务实现。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, WorkflowShare>
|
||||||
|
implements WorkflowShareService {
|
||||||
|
|
||||||
|
private static final String LOCK_KEY_PREFIX = "easyflow:lock:workflow-share:";
|
||||||
|
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
||||||
|
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private WorkflowService workflowService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private RedisLockExecutor redisLockExecutor;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public WorkflowShareCreateResult createUrlShare(
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger deptId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String baseUrl
|
||||||
|
) {
|
||||||
|
return redisLockExecutor.executeWithLock(
|
||||||
|
LOCK_KEY_PREFIX + workflowId,
|
||||||
|
LOCK_WAIT_TIMEOUT,
|
||||||
|
LOCK_LEASE_TIMEOUT,
|
||||||
|
() -> {
|
||||||
|
// 在释放分布式锁前完成事务提交,避免并发请求观察到未提交的旧分享状态。
|
||||||
|
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
|
||||||
|
return transactionTemplate.execute(status -> createOrReplaceShare(
|
||||||
|
workflowId,
|
||||||
|
tenantId,
|
||||||
|
deptId,
|
||||||
|
operatorId,
|
||||||
|
baseUrl
|
||||||
|
));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public WorkflowShare assertUrlShareAccess(
|
||||||
|
String shareKey,
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId
|
||||||
|
) {
|
||||||
|
if (workflowId == null) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
WorkflowShare share = resolveUrlShare(shareKey, tenantId);
|
||||||
|
if (!workflowId.equals(share.getWorkflowId())) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public WorkflowShare resolveUrlShare(String shareKey, BigInteger tenantId) {
|
||||||
|
if (shareKey == null || shareKey.isBlank() || tenantId == null) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
WorkflowShare share = getOne(QueryWrapper.create()
|
||||||
|
.eq(WorkflowShare::getShareKeyHash, WorkflowSharePolicy.hashShareKey(shareKey))
|
||||||
|
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
|
||||||
|
if (share == null || !tenantId.equals(share.getTenantId())) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
if (share.getExpiresAt() == null || !share.getExpiresAt().after(new Date())) {
|
||||||
|
throw new BusinessException(403, 403, "工作流分享链接已过期");
|
||||||
|
}
|
||||||
|
Workflow workflow = workflowService.getById(share.getWorkflowId());
|
||||||
|
if (workflow == null || !tenantId.equals(workflow.getTenantId())) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在锁保护下创建或替换工作流的唯一分享记录。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param deptId 部门 ID
|
||||||
|
* @param operatorId 操作人账号 ID
|
||||||
|
* @param baseUrl 工作流分享基础 URL
|
||||||
|
* @return 创建结果
|
||||||
|
*/
|
||||||
|
private WorkflowShareCreateResult createOrReplaceShare(
|
||||||
|
BigInteger workflowId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger deptId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String baseUrl
|
||||||
|
) {
|
||||||
|
Workflow workflow = workflowService.getById(workflowId);
|
||||||
|
if (workflow == null || tenantId == null || !tenantId.equals(workflow.getTenantId())) {
|
||||||
|
throw new BusinessException("工作流不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
String shareKey = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiresAt = WorkflowSharePolicy.defaultExpiresAt(now);
|
||||||
|
invalidateExistingShares(workflowId, operatorId, now);
|
||||||
|
WorkflowShare share = new WorkflowShare();
|
||||||
|
share.setWorkflowId(workflowId);
|
||||||
|
share.setTenantId(tenantId);
|
||||||
|
share.setDeptId(deptId);
|
||||||
|
share.setShareKeyHash(WorkflowSharePolicy.hashShareKey(shareKey));
|
||||||
|
share.setStatus(KnowledgeShareStatus.ENABLED.name());
|
||||||
|
share.setExpiresAt(expiresAt);
|
||||||
|
share.setCreated(now);
|
||||||
|
share.setCreatedBy(operatorId);
|
||||||
|
share.setModified(now);
|
||||||
|
share.setModifiedBy(operatorId);
|
||||||
|
save(share);
|
||||||
|
|
||||||
|
WorkflowShareCreateResult result = new WorkflowShareCreateResult();
|
||||||
|
result.setId(share.getId());
|
||||||
|
result.setShareKey(shareKey);
|
||||||
|
result.setShareUrl(buildShareUrl(baseUrl, shareKey));
|
||||||
|
result.setExpiresAt(expiresAt);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使工作流已有的有效分享记录失效。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
* @param operatorId 操作人账号 ID
|
||||||
|
* @param now 当前时间
|
||||||
|
*/
|
||||||
|
private void invalidateExistingShares(BigInteger workflowId, BigInteger operatorId, Date now) {
|
||||||
|
List<WorkflowShare> activeShares = list(QueryWrapper.create()
|
||||||
|
.eq(WorkflowShare::getWorkflowId, workflowId)
|
||||||
|
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
|
||||||
|
for (WorkflowShare activeShare : activeShares) {
|
||||||
|
WorkflowShare update = new WorkflowShare();
|
||||||
|
update.setId(activeShare.getId());
|
||||||
|
update.setStatus(KnowledgeShareStatus.DISABLED.name());
|
||||||
|
update.setModified(now);
|
||||||
|
update.setModifiedBy(operatorId);
|
||||||
|
updateById(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建仅包含分享密钥的协作 URL。
|
||||||
|
*
|
||||||
|
* @param baseUrl 分享基础 URL
|
||||||
|
* @param shareKey 原始分享密钥
|
||||||
|
* @return 分享 URL
|
||||||
|
*/
|
||||||
|
private String buildShareUrl(String baseUrl, String shareKey) {
|
||||||
|
if (baseUrl == null || baseUrl.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return baseUrl + (baseUrl.contains("?") ? "&" : "?") + "shareKey=" + shareKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建无效分享异常。
|
||||||
|
*
|
||||||
|
* @return 无效分享异常
|
||||||
|
*/
|
||||||
|
private BusinessException invalidShare() {
|
||||||
|
return new BusinessException(403, 403, "工作流分享链接无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package tech.easyflow.ai.share;
|
||||||
|
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流协作分享的密钥、时效与接口授权策略。
|
||||||
|
*/
|
||||||
|
public final class WorkflowSharePolicy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流协作分享请求头。
|
||||||
|
*/
|
||||||
|
public static final String SHARE_KEY_HEADER = "X-Workflow-Share-Key";
|
||||||
|
|
||||||
|
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
|
||||||
|
private static final Set<String> ALLOWED_REQUESTS = Set.of(
|
||||||
|
permissionKey("GET", "/api/v1/workflow/detail", ResourceAction.READ),
|
||||||
|
permissionKey("GET", "/api/v1/workflow/getRunningParameters", ResourceAction.READ),
|
||||||
|
permissionKey("POST", "/api/v1/workflow/update", ResourceAction.MANAGE),
|
||||||
|
permissionKey("POST", "/api/v1/workflow/check", ResourceAction.MANAGE),
|
||||||
|
permissionKey("POST", "/api/v1/workflow/singleRun", ResourceAction.USE),
|
||||||
|
permissionKey("POST", "/api/v1/workflow/runAsync", ResourceAction.USE),
|
||||||
|
permissionKey("POST", "/api/v1/workflow/getChainStatus", ResourceAction.USE),
|
||||||
|
permissionKey("POST", "/api/v1/workflow/resume", ResourceAction.USE),
|
||||||
|
permissionKey("GET", "/api/v1/workflow/publishApprovalRequirement", ResourceAction.MANAGE),
|
||||||
|
permissionKey("POST", "/api/v1/workflow/submitPublishApproval", ResourceAction.MANAGE)
|
||||||
|
);
|
||||||
|
|
||||||
|
private WorkflowSharePolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算分享密钥的 SHA-256 摘要。
|
||||||
|
*
|
||||||
|
* @param shareKey 原始分享密钥
|
||||||
|
* @return 十六进制摘要
|
||||||
|
*/
|
||||||
|
public static String hashShareKey(String shareKey) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] value = digest.digest(shareKey.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(value);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算默认过期时间。
|
||||||
|
*
|
||||||
|
* @param createdAt 创建时间
|
||||||
|
* @return 创建后 30 分钟的时间
|
||||||
|
*/
|
||||||
|
public static Date defaultExpiresAt(Date createdAt) {
|
||||||
|
if (createdAt == null) {
|
||||||
|
throw new IllegalArgumentException("创建时间不能为空");
|
||||||
|
}
|
||||||
|
return new Date(createdAt.getTime() + DEFAULT_EXPIRE_DURATION.toMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 HTTP 请求是否位于工作流协作授权白名单。
|
||||||
|
*
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @param requestUri 请求路径
|
||||||
|
* @param action 资源动作
|
||||||
|
* @return 位于白名单时返回 {@code true}
|
||||||
|
*/
|
||||||
|
public static boolean isAllowedRequest(String method, String requestUri, ResourceAction action) {
|
||||||
|
if (method == null || requestUri == null || action == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String methodPrefix = method.trim().toUpperCase() + ":";
|
||||||
|
String actionSuffix = ":" + action.name();
|
||||||
|
for (String allowedRequest : ALLOWED_REQUESTS) {
|
||||||
|
if (allowedRequest.startsWith(methodPrefix)
|
||||||
|
&& allowedRequest.endsWith(actionSuffix)
|
||||||
|
&& requestUri.endsWith(requestUriSuffix(allowedRequest))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成请求授权键。
|
||||||
|
*
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @param path 请求路径
|
||||||
|
* @param action 资源动作
|
||||||
|
* @return 授权键
|
||||||
|
*/
|
||||||
|
private static String permissionKey(String method, String path, ResourceAction action) {
|
||||||
|
return method + ":" + path + ":" + action.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从授权键中读取请求路径。
|
||||||
|
*
|
||||||
|
* @param permissionKey 授权键
|
||||||
|
* @return 请求路径
|
||||||
|
*/
|
||||||
|
private static String requestUriSuffix(String permissionKey) {
|
||||||
|
int firstSeparator = permissionKey.indexOf(':');
|
||||||
|
int lastSeparator = permissionKey.lastIndexOf(':');
|
||||||
|
return permissionKey.substring(firstSeparator + 1, lastSeparator);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package tech.easyflow.ai.vo;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流协作分享创建结果。
|
||||||
|
*/
|
||||||
|
public class WorkflowShareCreateResult implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private BigInteger id;
|
||||||
|
private String shareKey;
|
||||||
|
private String shareUrl;
|
||||||
|
private Date expiresAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享记录 ID。
|
||||||
|
*
|
||||||
|
* @return 分享记录 ID
|
||||||
|
*/
|
||||||
|
public BigInteger getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置分享记录 ID。
|
||||||
|
*
|
||||||
|
* @param id 分享记录 ID
|
||||||
|
*/
|
||||||
|
public void setId(BigInteger id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原始分享密钥。
|
||||||
|
*
|
||||||
|
* @return 原始分享密钥
|
||||||
|
*/
|
||||||
|
public String getShareKey() {
|
||||||
|
return shareKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置原始分享密钥。
|
||||||
|
*
|
||||||
|
* @param shareKey 原始分享密钥
|
||||||
|
*/
|
||||||
|
public void setShareKey(String shareKey) {
|
||||||
|
this.shareKey = shareKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享 URL。
|
||||||
|
*
|
||||||
|
* @return 分享 URL
|
||||||
|
*/
|
||||||
|
public String getShareUrl() {
|
||||||
|
return shareUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置分享 URL。
|
||||||
|
*
|
||||||
|
* @param shareUrl 分享 URL
|
||||||
|
*/
|
||||||
|
public void setShareUrl(String shareUrl) {
|
||||||
|
this.shareUrl = shareUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取过期时间。
|
||||||
|
*
|
||||||
|
* @return 过期时间
|
||||||
|
*/
|
||||||
|
public Date getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置过期时间。
|
||||||
|
*
|
||||||
|
* @param expiresAt 过期时间
|
||||||
|
*/
|
||||||
|
public void setExpiresAt(Date expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package tech.easyflow.ai.share;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V34 工作流分享与审批说明迁移契约测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowShareMigrationContractTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证迁移同时创建审批说明、工作流修订号和分享记录所需字段及索引。
|
||||||
|
*
|
||||||
|
* @throws Exception 迁移文件不可读时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void migrationShouldCreateWorkflowShareContracts() throws Exception {
|
||||||
|
String sql = migrationSql();
|
||||||
|
|
||||||
|
assertTrue(sql.contains("ADD COLUMN `application_reason` VARCHAR(500)"));
|
||||||
|
assertTrue(sql.contains("ADD COLUMN `revision` INT NOT NULL DEFAULT 0"));
|
||||||
|
assertTrue(sql.contains("CREATE TABLE `tb_workflow_share`"));
|
||||||
|
assertTrue(sql.contains("`share_key_hash` VARCHAR(64) NOT NULL"));
|
||||||
|
assertTrue(sql.contains("`status` VARCHAR(32) NOT NULL"));
|
||||||
|
assertTrue(sql.contains("`dept_id` BIGINT UNSIGNED NULL"));
|
||||||
|
assertTrue(sql.contains("`modified` DATETIME NULL"));
|
||||||
|
assertTrue(sql.contains("UNIQUE INDEX `uni_workflow_share_key_hash`"));
|
||||||
|
assertTrue(sql.contains("INDEX `idx_workflow_share_status` (`workflow_id`, `status`)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取工作区中的 V34 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/V34__mysql_workflow_share_and_approval_reason.sql");
|
||||||
|
if (Files.isRegularFile(migration)) {
|
||||||
|
return Files.readString(migration, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
root = root.getParent();
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("找不到 V34 工作流分享与审批说明迁移");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package tech.easyflow.ai.share;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowSharePolicy} 测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowSharePolicyTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证相同密钥生成稳定的 SHA-256 摘要。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldHashShareKeyDeterministically() {
|
||||||
|
String first = WorkflowSharePolicy.hashShareKey("share-key");
|
||||||
|
String second = WorkflowSharePolicy.hashShareKey("share-key");
|
||||||
|
|
||||||
|
Assert.assertEquals(first, second);
|
||||||
|
Assert.assertEquals(64, first.length());
|
||||||
|
Assert.assertNotEquals("share-key", first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证默认过期时间为创建时间后 30 分钟。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldExpireThirtyMinutesAfterCreation() {
|
||||||
|
Date createdAt = new Date(1_000L);
|
||||||
|
|
||||||
|
Date expiresAt = WorkflowSharePolicy.defaultExpiresAt(createdAt);
|
||||||
|
|
||||||
|
Assert.assertEquals(30 * 60 * 1_000L, expiresAt.getTime() - createdAt.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证分享授权仅覆盖编辑、运行和发布所需接口。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAllowOnlyCollaborativeWorkflowOperations() {
|
||||||
|
Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
"GET",
|
||||||
|
"/api/v1/workflow/detail",
|
||||||
|
ResourceAction.READ
|
||||||
|
));
|
||||||
|
Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/workflow/update",
|
||||||
|
ResourceAction.MANAGE
|
||||||
|
));
|
||||||
|
Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/workflow/runAsync",
|
||||||
|
ResourceAction.USE
|
||||||
|
));
|
||||||
|
Assert.assertTrue(WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/workflow/submitPublishApproval",
|
||||||
|
ResourceAction.MANAGE
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证删除、下线和再次分享不在协作授权范围内。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectSensitiveWorkflowOperations() {
|
||||||
|
Assert.assertFalse(WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/workflow/submitDeleteApproval",
|
||||||
|
ResourceAction.MANAGE
|
||||||
|
));
|
||||||
|
Assert.assertFalse(WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/workflow/submitOfflineApproval",
|
||||||
|
ResourceAction.MANAGE
|
||||||
|
));
|
||||||
|
Assert.assertFalse(WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/workflowShare/url/create",
|
||||||
|
ResourceAction.MANAGE
|
||||||
|
));
|
||||||
|
Assert.assertFalse(WorkflowSharePolicy.isAllowedRequest(
|
||||||
|
"GET",
|
||||||
|
"/api/v1/workflow/update",
|
||||||
|
ResourceAction.MANAGE
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,5 +33,11 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-module-system</artifactId>
|
<artifactId>easyflow-module-system</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ public class ApprovalInstanceBase implements Serializable {
|
|||||||
@Column(comment = "审批摘要")
|
@Column(comment = "审批摘要")
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起人填写的审批说明。
|
||||||
|
*/
|
||||||
|
@Column(comment = "审批说明")
|
||||||
|
private String applicationReason;
|
||||||
|
|
||||||
@Column(comment = "申请人ID")
|
@Column(comment = "申请人ID")
|
||||||
private BigInteger applicantId;
|
private BigInteger applicantId;
|
||||||
|
|
||||||
@@ -148,6 +154,14 @@ public class ApprovalInstanceBase implements Serializable {
|
|||||||
this.summary = summary;
|
this.summary = summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getApplicationReason() {
|
||||||
|
return applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApplicationReason(String applicationReason) {
|
||||||
|
this.applicationReason = applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
public BigInteger getApplicantId() {
|
public BigInteger getApplicantId() {
|
||||||
return applicantId;
|
return applicantId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ public class ApprovalInstanceDetailVo {
|
|||||||
|
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起人填写的审批说明。
|
||||||
|
*/
|
||||||
|
private String applicationReason;
|
||||||
|
|
||||||
private BigInteger applicantId;
|
private BigInteger applicantId;
|
||||||
|
|
||||||
private String applicantName;
|
private String applicantName;
|
||||||
@@ -123,6 +128,14 @@ public class ApprovalInstanceDetailVo {
|
|||||||
this.summary = summary;
|
this.summary = summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getApplicationReason() {
|
||||||
|
return applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApplicationReason(String applicationReason) {
|
||||||
|
this.applicationReason = applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
public BigInteger getApplicantId() {
|
public BigInteger getApplicantId() {
|
||||||
return applicantId;
|
return applicantId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ public class ApprovalInstancePageVo {
|
|||||||
|
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起审批时填写的说明。
|
||||||
|
*/
|
||||||
|
private String applicationReason;
|
||||||
|
|
||||||
private BigInteger applicantId;
|
private BigInteger applicantId;
|
||||||
|
|
||||||
private Date submittedAt;
|
private Date submittedAt;
|
||||||
@@ -100,6 +105,24 @@ public class ApprovalInstancePageVo {
|
|||||||
this.summary = summary;
|
this.summary = summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取发起审批时填写的说明。
|
||||||
|
*
|
||||||
|
* @return 审批说明
|
||||||
|
*/
|
||||||
|
public String getApplicationReason() {
|
||||||
|
return applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置发起审批时填写的说明。
|
||||||
|
*
|
||||||
|
* @param applicationReason 审批说明
|
||||||
|
*/
|
||||||
|
public void setApplicationReason(String applicationReason) {
|
||||||
|
this.applicationReason = applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
public BigInteger getApplicantId() {
|
public BigInteger getApplicantId() {
|
||||||
return applicantId;
|
return applicantId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ public class ApprovalLogVo {
|
|||||||
|
|
||||||
private String eventType;
|
private String eventType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交审批事件对应的申请说明。
|
||||||
|
*/
|
||||||
|
private String applicationReason;
|
||||||
|
|
||||||
private BigInteger operatorId;
|
private BigInteger operatorId;
|
||||||
|
|
||||||
private String operatorAccount;
|
private String operatorAccount;
|
||||||
@@ -39,6 +44,24 @@ public class ApprovalLogVo {
|
|||||||
this.eventType = eventType;
|
this.eventType = eventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取提交审批事件对应的申请说明。
|
||||||
|
*
|
||||||
|
* @return 审批说明,非提交事件时为 {@code null}
|
||||||
|
*/
|
||||||
|
public String getApplicationReason() {
|
||||||
|
return applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置提交审批事件对应的申请说明。
|
||||||
|
*
|
||||||
|
* @param applicationReason 审批说明
|
||||||
|
*/
|
||||||
|
public void setApplicationReason(String applicationReason) {
|
||||||
|
this.applicationReason = applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
public BigInteger getOperatorId() {
|
public BigInteger getOperatorId() {
|
||||||
return operatorId;
|
return operatorId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ public class ApprovalSubmitRequest {
|
|||||||
|
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起人填写的审批说明。
|
||||||
|
*/
|
||||||
|
private String applicationReason;
|
||||||
|
|
||||||
private Map<String, Object> snapshotJson;
|
private Map<String, Object> snapshotJson;
|
||||||
|
|
||||||
public String getResourceType() {
|
public String getResourceType() {
|
||||||
@@ -80,6 +85,24 @@ public class ApprovalSubmitRequest {
|
|||||||
this.summary = summary;
|
this.summary = summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审批说明。
|
||||||
|
*
|
||||||
|
* @return 审批说明
|
||||||
|
*/
|
||||||
|
public String getApplicationReason() {
|
||||||
|
return applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置审批说明。
|
||||||
|
*
|
||||||
|
* @param applicationReason 审批说明
|
||||||
|
*/
|
||||||
|
public void setApplicationReason(String applicationReason) {
|
||||||
|
this.applicationReason = applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, Object> getSnapshotJson() {
|
public Map<String, Object> getSnapshotJson() {
|
||||||
return snapshotJson;
|
return snapshotJson;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ public class ApprovalTaskVo {
|
|||||||
|
|
||||||
private String stepName;
|
private String stepName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前审批实例的申请说明。
|
||||||
|
*/
|
||||||
|
private String applicationReason;
|
||||||
|
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
private String assigneeRoleCode;
|
private String assigneeRoleCode;
|
||||||
@@ -58,6 +63,24 @@ public class ApprovalTaskVo {
|
|||||||
this.stepName = stepName;
|
this.stepName = stepName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前审批实例的申请说明。
|
||||||
|
*
|
||||||
|
* @return 审批说明
|
||||||
|
*/
|
||||||
|
public String getApplicationReason() {
|
||||||
|
return applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前审批实例的申请说明。
|
||||||
|
*
|
||||||
|
* @param applicationReason 审批说明
|
||||||
|
*/
|
||||||
|
public void setApplicationReason(String applicationReason) {
|
||||||
|
this.applicationReason = applicationReason;
|
||||||
|
}
|
||||||
|
|
||||||
public String getStatus() {
|
public String getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
|||||||
instance.setCurrentStepNo(firstStep.getStepNo());
|
instance.setCurrentStepNo(firstStep.getStepNo());
|
||||||
instance.setSnapshotJson(buildInstanceSnapshot(request, flow, steps));
|
instance.setSnapshotJson(buildInstanceSnapshot(request, flow, steps));
|
||||||
instance.setSummary(request.getSummary());
|
instance.setSummary(request.getSummary());
|
||||||
|
instance.setApplicationReason(request.getApplicationReason());
|
||||||
instance.setApplicantId(request.getApplicantId());
|
instance.setApplicantId(request.getApplicantId());
|
||||||
instance.setSubmittedAt(now);
|
instance.setSubmittedAt(now);
|
||||||
instance.setCreated(now);
|
instance.setCreated(now);
|
||||||
@@ -102,11 +103,15 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
|||||||
approvalInstanceMapper.insert(instance);
|
approvalInstanceMapper.insert(instance);
|
||||||
|
|
||||||
createTask(instance.getId(), firstStep, request.getApplicantId(), now);
|
createTask(instance.getId(), firstStep, request.getApplicantId(), now);
|
||||||
appendLog(instance.getId(), ApprovalEventType.SUBMITTED.getCode(), request.getApplicantId(), Map.of(
|
Map<String, Object> submittedPayload = new LinkedHashMap<>();
|
||||||
"flowId", flow.getId(),
|
submittedPayload.put("flowId", flow.getId());
|
||||||
"flowVersion", flow.getVersion(),
|
submittedPayload.put("flowVersion", flow.getVersion());
|
||||||
"summary", request.getSummary()
|
submittedPayload.put("summary", request.getSummary());
|
||||||
), now);
|
if (request.getApplicationReason() != null) {
|
||||||
|
submittedPayload.put("applicationReason", request.getApplicationReason());
|
||||||
|
}
|
||||||
|
appendLog(instance.getId(), ApprovalEventType.SUBMITTED.getCode(), request.getApplicantId(),
|
||||||
|
submittedPayload, now);
|
||||||
appendLog(instance.getId(), ApprovalEventType.STEP_CREATED.getCode(), request.getApplicantId(),
|
appendLog(instance.getId(), ApprovalEventType.STEP_CREATED.getCode(), request.getApplicantId(),
|
||||||
buildStepCreatedPayload(firstStep), now);
|
buildStepCreatedPayload(firstStep), now);
|
||||||
return instance.getId();
|
return instance.getId();
|
||||||
@@ -228,6 +233,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
|||||||
snapshot.put("deptId", request.getDeptId());
|
snapshot.put("deptId", request.getDeptId());
|
||||||
snapshot.put("flowId", flow.getId());
|
snapshot.put("flowId", flow.getId());
|
||||||
snapshot.put("flowVersion", flow.getVersion());
|
snapshot.put("flowVersion", flow.getVersion());
|
||||||
|
if (request.getApplicationReason() != null) {
|
||||||
|
snapshot.put("applicationReason", request.getApplicationReason());
|
||||||
|
}
|
||||||
snapshot.put("steps", steps.stream().map(item -> {
|
snapshot.put("steps", steps.stream().map(item -> {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("stepNo", item.getStepNo());
|
map.put("stepNo", item.getStepNo());
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import tech.easyflow.approval.entity.vo.ApprovalFlowStepVo;
|
|||||||
import tech.easyflow.approval.entity.vo.ApprovalLogVo;
|
import tech.easyflow.approval.entity.vo.ApprovalLogVo;
|
||||||
import tech.easyflow.approval.entity.vo.ApprovalTaskVo;
|
import tech.easyflow.approval.entity.vo.ApprovalTaskVo;
|
||||||
import tech.easyflow.approval.enums.ApprovalActionType;
|
import tech.easyflow.approval.enums.ApprovalActionType;
|
||||||
|
import tech.easyflow.approval.enums.ApprovalEventType;
|
||||||
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
|
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
|
||||||
import tech.easyflow.approval.enums.ApprovalResourceType;
|
import tech.easyflow.approval.enums.ApprovalResourceType;
|
||||||
import tech.easyflow.approval.enums.ApprovalTaskStatus;
|
import tech.easyflow.approval.enums.ApprovalTaskStatus;
|
||||||
@@ -144,6 +145,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
|||||||
detail.setStatus(instance.getStatus());
|
detail.setStatus(instance.getStatus());
|
||||||
detail.setCurrentStepNo(instance.getCurrentStepNo());
|
detail.setCurrentStepNo(instance.getCurrentStepNo());
|
||||||
detail.setSummary(instance.getSummary());
|
detail.setSummary(instance.getSummary());
|
||||||
|
detail.setApplicationReason(instance.getApplicationReason());
|
||||||
detail.setApplicantId(instance.getApplicantId());
|
detail.setApplicantId(instance.getApplicantId());
|
||||||
detail.setSubmittedAt(instance.getSubmittedAt());
|
detail.setSubmittedAt(instance.getSubmittedAt());
|
||||||
detail.setFinishedAt(instance.getFinishedAt());
|
detail.setFinishedAt(instance.getFinishedAt());
|
||||||
@@ -165,6 +167,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
|||||||
taskVo.setId(item.getId());
|
taskVo.setId(item.getId());
|
||||||
taskVo.setStepNo(item.getStepNo());
|
taskVo.setStepNo(item.getStepNo());
|
||||||
taskVo.setStepName(resolveStepName(frozenStepMap, item.getStepNo()));
|
taskVo.setStepName(resolveStepName(frozenStepMap, item.getStepNo()));
|
||||||
|
taskVo.setApplicationReason(instance.getApplicationReason());
|
||||||
taskVo.setStatus(item.getStatus());
|
taskVo.setStatus(item.getStatus());
|
||||||
taskVo.setAssigneeRoleCode(item.getAssigneeRoleCode());
|
taskVo.setAssigneeRoleCode(item.getAssigneeRoleCode());
|
||||||
taskVo.setAssigneeType(item.getAssigneeType());
|
taskVo.setAssigneeType(item.getAssigneeType());
|
||||||
@@ -185,6 +188,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
|||||||
ApprovalLogVo logVo = new ApprovalLogVo();
|
ApprovalLogVo logVo = new ApprovalLogVo();
|
||||||
logVo.setId(item.getId());
|
logVo.setId(item.getId());
|
||||||
logVo.setEventType(item.getEventType());
|
logVo.setEventType(item.getEventType());
|
||||||
|
if (ApprovalEventType.SUBMITTED.getCode().equals(item.getEventType())) {
|
||||||
|
logVo.setApplicationReason(instance.getApplicationReason());
|
||||||
|
}
|
||||||
logVo.setOperatorId(item.getOperatorId());
|
logVo.setOperatorId(item.getOperatorId());
|
||||||
logVo.setOperatorAccount(resolveAccountLoginName(accountMap.get(item.getOperatorId())));
|
logVo.setOperatorAccount(resolveAccountLoginName(accountMap.get(item.getOperatorId())));
|
||||||
logVo.setOperatorName(resolveAccountName(accountMap.get(item.getOperatorId())));
|
logVo.setOperatorName(resolveAccountName(accountMap.get(item.getOperatorId())));
|
||||||
@@ -305,6 +311,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
|||||||
item.setCurrentStepNo(record.getCurrentStepNo());
|
item.setCurrentStepNo(record.getCurrentStepNo());
|
||||||
item.setCurrentStepName(resolveCurrentStepName(record));
|
item.setCurrentStepName(resolveCurrentStepName(record));
|
||||||
item.setSummary(record.getSummary());
|
item.setSummary(record.getSummary());
|
||||||
|
item.setApplicationReason(record.getApplicationReason());
|
||||||
item.setApplicantId(record.getApplicantId());
|
item.setApplicantId(record.getApplicantId());
|
||||||
item.setSubmittedAt(record.getSubmittedAt());
|
item.setSubmittedAt(record.getSubmittedAt());
|
||||||
item.setFinishedAt(record.getFinishedAt());
|
item.setFinishedAt(record.getFinishedAt());
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package tech.easyflow.approval.support;
|
||||||
|
|
||||||
|
import tech.easyflow.approval.enums.ApprovalActionType;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布审批说明校验策略。
|
||||||
|
*/
|
||||||
|
public final class ApprovalApplicationReasonPolicy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批说明最大长度。
|
||||||
|
*/
|
||||||
|
public static final int MAX_LENGTH = 500;
|
||||||
|
|
||||||
|
private ApprovalApplicationReasonPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按审批匹配结果和动作类型规范化审批说明。
|
||||||
|
*
|
||||||
|
* @param approvalRequired 是否命中审批流
|
||||||
|
* @param actionType 动作类型
|
||||||
|
* @param applicationReason 原始审批说明
|
||||||
|
* @return 需要说明时返回去除首尾空白的文本,否则返回 {@code null}
|
||||||
|
* @throws BusinessException 必填说明为空或超过长度限制时抛出
|
||||||
|
*/
|
||||||
|
public static String normalize(
|
||||||
|
boolean approvalRequired,
|
||||||
|
String actionType,
|
||||||
|
String applicationReason
|
||||||
|
) {
|
||||||
|
if (!approvalRequired || !ApprovalActionType.PUBLISH.getCode().equals(actionType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = applicationReason == null ? "" : applicationReason.trim();
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
throw new BusinessException(400, 400, "请填写审批说明");
|
||||||
|
}
|
||||||
|
if (normalized.length() > MAX_LENGTH) {
|
||||||
|
throw new BusinessException(400, 400, "审批说明不能超过500字");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package tech.easyflow.approval.support;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.approval.enums.ApprovalActionType;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ApprovalApplicationReasonPolicy} 测试。
|
||||||
|
*/
|
||||||
|
public class ApprovalApplicationReasonPolicyTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证命中发布审批时说明必填并去除首尾空白。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldNormalizeRequiredPublishReason() {
|
||||||
|
String result = ApprovalApplicationReasonPolicy.normalize(
|
||||||
|
true,
|
||||||
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
" 修复校验逻辑并补充测试 "
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals("修复校验逻辑并补充测试", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证命中发布审批时拒绝空说明。
|
||||||
|
*/
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void shouldRejectBlankRequiredPublishReason() {
|
||||||
|
ApprovalApplicationReasonPolicy.normalize(
|
||||||
|
true,
|
||||||
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
" "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证命中发布审批时拒绝超过 500 字的说明。
|
||||||
|
*/
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void shouldRejectOversizedRequiredPublishReason() {
|
||||||
|
ApprovalApplicationReasonPolicy.normalize(
|
||||||
|
true,
|
||||||
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
"a".repeat(501)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证未命中审批或非发布动作无需说明。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldIgnoreReasonWhenItIsNotRequired() {
|
||||||
|
Assert.assertNull(ApprovalApplicationReasonPolicy.normalize(
|
||||||
|
false,
|
||||||
|
ApprovalActionType.PUBLISH.getCode(),
|
||||||
|
null
|
||||||
|
));
|
||||||
|
Assert.assertNull(ApprovalApplicationReasonPolicy.normalize(
|
||||||
|
true,
|
||||||
|
ApprovalActionType.OFFLINE.getCode(),
|
||||||
|
null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tech.easyflow.auth.service.impl;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.SaLoginModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录会话来源隔离策略。
|
||||||
|
*/
|
||||||
|
public final class AuthLoginSessionPolicy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人类用户 Web 会话设备类型。
|
||||||
|
*/
|
||||||
|
public static final String WEB_DEVICE = "WEB";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 升级前未显式指定设备类型的历史 Web 会话设备类型。
|
||||||
|
*/
|
||||||
|
public static final String LEGACY_WEB_DEVICE = "default-device";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key 机器会话设备类型。
|
||||||
|
*/
|
||||||
|
public static final String API_KEY_DEVICE = "API_KEY";
|
||||||
|
|
||||||
|
private AuthLoginSessionPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 Web 登录模型。
|
||||||
|
*
|
||||||
|
* @return Web 登录模型
|
||||||
|
*/
|
||||||
|
public static SaLoginModel webLoginModel() {
|
||||||
|
return new SaLoginModel().setDevice(WEB_DEVICE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 API Key 登录模型。
|
||||||
|
*
|
||||||
|
* @param timeoutSeconds 会话有效期秒数,可为空
|
||||||
|
* @return API Key 登录模型
|
||||||
|
*/
|
||||||
|
public static SaLoginModel apiKeyLoginModel(Long timeoutSeconds) {
|
||||||
|
SaLoginModel loginModel = new SaLoginModel().setDevice(API_KEY_DEVICE);
|
||||||
|
if (timeoutSeconds != null) {
|
||||||
|
loginModel.setTimeout(timeoutSeconds);
|
||||||
|
}
|
||||||
|
return loginModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 Web 登录是否需要替换既有 Web 会话。
|
||||||
|
*
|
||||||
|
* @return 始终为 true
|
||||||
|
*/
|
||||||
|
public static boolean shouldReplaceExistingWebSession() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import tech.easyflow.auth.entity.LoginDTO;
|
import tech.easyflow.auth.entity.LoginDTO;
|
||||||
import tech.easyflow.auth.entity.LoginVO;
|
import tech.easyflow.auth.entity.LoginVO;
|
||||||
import tech.easyflow.auth.service.AuthService;
|
import tech.easyflow.auth.service.AuthService;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
import tech.easyflow.common.constant.Constants;
|
import tech.easyflow.common.constant.Constants;
|
||||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
@@ -26,6 +27,7 @@ import cn.hutool.crypto.digest.BCrypt;
|
|||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -33,6 +35,10 @@ import java.util.stream.Collectors;
|
|||||||
@Service
|
@Service
|
||||||
public class AuthServiceImpl implements AuthService, StpInterface {
|
public class AuthServiceImpl implements AuthService, StpInterface {
|
||||||
|
|
||||||
|
private static final String WEB_LOGIN_LOCK_KEY_PREFIX = "easyflow:lock:auth:web-login:";
|
||||||
|
private static final Duration WEB_LOGIN_LOCK_WAIT = Duration.ofSeconds(3);
|
||||||
|
private static final Duration WEB_LOGIN_LOCK_LEASE = Duration.ofSeconds(10);
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysAccountService sysAccountService;
|
private SysAccountService sysAccountService;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -41,6 +47,8 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
|||||||
private SysMenuService sysMenuService;
|
private SysMenuService sysMenuService;
|
||||||
@Resource
|
@Resource
|
||||||
private SysApiKeyService sysApiKeyService;
|
private SysApiKeyService sysApiKeyService;
|
||||||
|
@Resource
|
||||||
|
private RedisLockExecutor redisLockExecutor;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public LoginVO login(LoginDTO loginDTO) {
|
public LoginVO login(LoginDTO loginDTO) {
|
||||||
@@ -52,7 +60,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
|||||||
if (!BCrypt.checkpw(pwd, pwdDb)) {
|
if (!BCrypt.checkpw(pwd, pwdDb)) {
|
||||||
throw new BusinessException("用户名/密码错误");
|
throw new BusinessException("用户名/密码错误");
|
||||||
}
|
}
|
||||||
return createLoginVO(record);
|
return createWebLoginVO(record);
|
||||||
} finally {
|
} finally {
|
||||||
TenantManager.restoreTenantCondition();
|
TenantManager.restoreTenantCondition();
|
||||||
}
|
}
|
||||||
@@ -63,7 +71,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
|||||||
try {
|
try {
|
||||||
TenantManager.ignoreTenantCondition();
|
TenantManager.ignoreTenantCondition();
|
||||||
SysAccount record = getAvailableAccount(account, "开发免登账号不存在");
|
SysAccount record = getAvailableAccount(account, "开发免登账号不存在");
|
||||||
return createLoginVO(record);
|
return createWebLoginVO(record);
|
||||||
} finally {
|
} finally {
|
||||||
TenantManager.restoreTenantCondition();
|
TenantManager.restoreTenantCondition();
|
||||||
}
|
}
|
||||||
@@ -89,7 +97,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
|||||||
@Override
|
@Override
|
||||||
public LoginVO loginByAccountId(BigInteger accountId, Long timeoutSeconds) {
|
public LoginVO loginByAccountId(BigInteger accountId, Long timeoutSeconds) {
|
||||||
SysAccount record = getAvailableAccount(accountId, "账号不存在或不可用");
|
SysAccount record = getAvailableAccount(accountId, "账号不存在或不可用");
|
||||||
return createLoginVO(record, timeoutSeconds);
|
return createApiKeyLoginVO(record, timeoutSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -107,18 +115,48 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
|||||||
return roles.stream().map(SysRole::getRoleKey).collect(Collectors.toList());
|
return roles.stream().map(SysRole::getRoleKey).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoginVO createLoginVO(SysAccount record) {
|
/**
|
||||||
return createLoginVO(record, null);
|
* 创建互斥的 Web 登录会话。
|
||||||
|
*
|
||||||
|
* @param record 登录账号
|
||||||
|
* @return 登录结果
|
||||||
|
*/
|
||||||
|
private LoginVO createWebLoginVO(SysAccount record) {
|
||||||
|
return redisLockExecutor.executeWithLock(
|
||||||
|
WEB_LOGIN_LOCK_KEY_PREFIX + record.getId(),
|
||||||
|
WEB_LOGIN_LOCK_WAIT,
|
||||||
|
WEB_LOGIN_LOCK_LEASE,
|
||||||
|
() -> {
|
||||||
|
if (AuthLoginSessionPolicy.shouldReplaceExistingWebSession()) {
|
||||||
|
StpUtil.replaced(record.getId(), AuthLoginSessionPolicy.WEB_DEVICE);
|
||||||
|
// 首次升级后的新登录同时淘汰旧版本未标记设备类型的浏览器会话。
|
||||||
|
StpUtil.replaced(record.getId(), AuthLoginSessionPolicy.LEGACY_WEB_DEVICE);
|
||||||
|
}
|
||||||
|
return createLoginVO(record, AuthLoginSessionPolicy.webLoginModel());
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoginVO createLoginVO(SysAccount record, Long timeoutSeconds) {
|
/**
|
||||||
if (timeoutSeconds != null) {
|
* 创建独立的 API Key 机器会话。
|
||||||
SaLoginModel loginModel = new SaLoginModel();
|
*
|
||||||
loginModel.setTimeout(timeoutSeconds);
|
* @param record 登录账号
|
||||||
StpUtil.login(record.getId(), loginModel);
|
* @param timeoutSeconds 会话有效期秒数
|
||||||
} else {
|
* @return 登录结果
|
||||||
StpUtil.login(record.getId());
|
*/
|
||||||
}
|
private LoginVO createApiKeyLoginVO(SysAccount record, Long timeoutSeconds) {
|
||||||
|
return createLoginVO(record, AuthLoginSessionPolicy.apiKeyLoginModel(timeoutSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建指定来源的登录会话。
|
||||||
|
*
|
||||||
|
* @param record 登录账号
|
||||||
|
* @param loginModel 登录参数
|
||||||
|
* @return 登录结果
|
||||||
|
*/
|
||||||
|
private LoginVO createLoginVO(SysAccount record, SaLoginModel loginModel) {
|
||||||
|
StpUtil.login(record.getId(), loginModel);
|
||||||
LoginAccount loginAccount = new LoginAccount();
|
LoginAccount loginAccount = new LoginAccount();
|
||||||
BeanUtil.copyProperties(record, loginAccount);
|
BeanUtil.copyProperties(record, loginAccount);
|
||||||
StpUtil.getSession().set(Constants.LOGIN_USER_KEY, loginAccount);
|
StpUtil.getSession().set(Constants.LOGIN_USER_KEY, loginAccount);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package tech.easyflow.auth.service.impl;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.SaLoginModel;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AuthLoginSessionPolicy} 测试。
|
||||||
|
*/
|
||||||
|
public class AuthLoginSessionPolicyTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Web 登录使用独立设备类型并要求替换旧会话。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldBuildExclusiveWebLoginModel() {
|
||||||
|
SaLoginModel model = AuthLoginSessionPolicy.webLoginModel();
|
||||||
|
|
||||||
|
Assert.assertEquals("WEB", model.getDevice());
|
||||||
|
Assert.assertEquals("default-device", AuthLoginSessionPolicy.LEGACY_WEB_DEVICE);
|
||||||
|
Assert.assertTrue(AuthLoginSessionPolicy.shouldReplaceExistingWebSession());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 API Key 会话使用独立设备类型和指定有效期。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldBuildIndependentApiKeyLoginModel() {
|
||||||
|
SaLoginModel model = AuthLoginSessionPolicy.apiKeyLoginModel(120L);
|
||||||
|
|
||||||
|
Assert.assertEquals("API_KEY", model.getDevice());
|
||||||
|
Assert.assertEquals(Long.valueOf(120L), model.getTimeout());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package tech.easyflow.system.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.InitializingBean;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.system.util.SysPasswordPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号安全配置。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.security.account")
|
||||||
|
public class AccountSecurityProperties implements InitializingBean {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号重置和导入时使用的默认强密码。
|
||||||
|
*/
|
||||||
|
private String defaultResetPassword = "!QAZ2wsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认重置密码。
|
||||||
|
*
|
||||||
|
* @return 默认重置密码
|
||||||
|
*/
|
||||||
|
public String getDefaultResetPassword() {
|
||||||
|
return defaultResetPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置默认重置密码。
|
||||||
|
*
|
||||||
|
* @param defaultResetPassword 默认重置密码
|
||||||
|
*/
|
||||||
|
public void setDefaultResetPassword(String defaultResetPassword) {
|
||||||
|
this.defaultResetPassword = defaultResetPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用启动时校验默认密码符合系统强密码策略。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterPropertiesSet() {
|
||||||
|
SysPasswordPolicy.validateStrongPassword(
|
||||||
|
defaultResetPassword == null ? null : defaultResetPassword.trim()
|
||||||
|
);
|
||||||
|
defaultResetPassword = defaultResetPassword.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package tech.easyflow.system.permission.resource;
|
||||||
|
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为特定业务场景补充资源访问授权的扩展点。
|
||||||
|
*/
|
||||||
|
public interface ResourceAccessGrantProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前业务上下文是否补充授予资源动作权限。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @param resourceType 资源类型
|
||||||
|
* @param resource 资源对象
|
||||||
|
* @param action 资源动作
|
||||||
|
* @return 授予权限时返回 {@code true}
|
||||||
|
*/
|
||||||
|
boolean grants(
|
||||||
|
LoginAccount loginAccount,
|
||||||
|
CategoryResourceType resourceType,
|
||||||
|
VisibilityResource resource,
|
||||||
|
ResourceAction action
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.system.service.impl;
|
package tech.easyflow.system.service.impl;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
@@ -7,6 +8,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
import tech.easyflow.system.enums.CategoryResourceType;
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
import tech.easyflow.system.enums.ResourceAction;
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
import tech.easyflow.system.enums.VisibilityScope;
|
import tech.easyflow.system.enums.VisibilityScope;
|
||||||
|
import tech.easyflow.system.permission.resource.ResourceAccessGrantProvider;
|
||||||
import tech.easyflow.system.permission.resource.VisibilityResource;
|
import tech.easyflow.system.permission.resource.VisibilityResource;
|
||||||
import tech.easyflow.system.service.CategoryPermissionService;
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
import tech.easyflow.system.service.ResourceAccessService;
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
@@ -14,7 +16,12 @@ import tech.easyflow.system.service.SysDeptService;
|
|||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于租户、创建者、分类授权和可见范围的统一资源权限实现。
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class ResourceAccessServiceImpl implements ResourceAccessService {
|
public class ResourceAccessServiceImpl implements ResourceAccessService {
|
||||||
|
|
||||||
@@ -24,11 +31,20 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
|
|||||||
@Resource
|
@Resource
|
||||||
private SysDeptService sysDeptService;
|
private SysDeptService sysDeptService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private List<ResourceAccessGrantProvider> grantProviders = Collections.emptyList();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
|
public boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
|
||||||
return canAccess(SaTokenUtil.getLoginAccount(), resourceType, resource, action);
|
return canAccess(SaTokenUtil.getLoginAccount(), resourceType, resource, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
|
public boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
|
||||||
if (resource == null) {
|
if (resource == null) {
|
||||||
@@ -38,6 +54,10 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
BigInteger accountId = loginAccount.getId();
|
BigInteger accountId = loginAccount.getId();
|
||||||
|
// 分享访问需要先完成密钥校验与审计,即使当前账号同时也是资源创建者或超管。
|
||||||
|
if (hasExtendedGrant(loginAccount, resourceType, resource, action)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (categoryPermissionService.isSuperAdmin(loginAccount)) {
|
if (categoryPermissionService.isSuperAdmin(loginAccount)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -60,10 +80,36 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message) {
|
public void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message) {
|
||||||
if (!canAccess(resourceType, resource, action)) {
|
if (!canAccess(resourceType, resource, action)) {
|
||||||
throw new BusinessException(message == null ? "无权限访问该资源" : message);
|
throw new BusinessException(403, 403, message == null ? "无权限访问该资源" : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断业务扩展授权是否允许当前动作。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @param resourceType 资源类型
|
||||||
|
* @param resource 资源对象
|
||||||
|
* @param action 资源动作
|
||||||
|
* @return 任一扩展授权允许时返回 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean hasExtendedGrant(
|
||||||
|
LoginAccount loginAccount,
|
||||||
|
CategoryResourceType resourceType,
|
||||||
|
VisibilityResource resource,
|
||||||
|
ResourceAction action
|
||||||
|
) {
|
||||||
|
for (ResourceAccessGrantProvider provider : grantProviders) {
|
||||||
|
if (provider.grants(loginAccount, resourceType, resource, action)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import tech.easyflow.common.constant.enums.EnumDataStatus;
|
|||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.util.StringUtil;
|
import tech.easyflow.common.util.StringUtil;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.config.AccountSecurityProperties;
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
import tech.easyflow.system.entity.SysAccountPosition;
|
import tech.easyflow.system.entity.SysAccountPosition;
|
||||||
import tech.easyflow.system.entity.SysAccountRole;
|
import tech.easyflow.system.entity.SysAccountRole;
|
||||||
@@ -71,7 +72,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
private static final String ACCOUNT_RELATION_LOCK_KEY_PREFIX = "easyflow:lock:sys:account:relation:";
|
private static final String ACCOUNT_RELATION_LOCK_KEY_PREFIX = "easyflow:lock:sys:account:relation:";
|
||||||
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
||||||
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
||||||
private static final String DEFAULT_RESET_PASSWORD = "123456";
|
|
||||||
private static final long MAX_IMPORT_FILE_SIZE_BYTES = 10L * 1024 * 1024;
|
private static final long MAX_IMPORT_FILE_SIZE_BYTES = 10L * 1024 * 1024;
|
||||||
private static final int MAX_IMPORT_ROWS = 5000;
|
private static final int MAX_IMPORT_ROWS = 5000;
|
||||||
private static final String IMPORT_HEAD_DEPT_NAME = "部门名称*";
|
private static final String IMPORT_HEAD_DEPT_NAME = "部门名称*";
|
||||||
@@ -110,6 +110,8 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
private RedisLockExecutor redisLockExecutor;
|
private RedisLockExecutor redisLockExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
private PlatformTransactionManager transactionManager;
|
private PlatformTransactionManager transactionManager;
|
||||||
|
@Resource
|
||||||
|
private AccountSecurityProperties accountSecurityProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量解析账号展示名称。
|
* 批量解析账号展示名称。
|
||||||
@@ -238,7 +240,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
validateResetPasswordAllowed(record);
|
validateResetPasswordAllowed(record);
|
||||||
SysAccount update = new SysAccount();
|
SysAccount update = new SysAccount();
|
||||||
update.setId(accountId);
|
update.setId(accountId);
|
||||||
update.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
update.setPassword(BCrypt.hashpw(accountSecurityProperties.getDefaultResetPassword()));
|
||||||
update.setPasswordResetRequired(true);
|
update.setPasswordResetRequired(true);
|
||||||
update.setModified(new Date());
|
update.setModified(new Date());
|
||||||
update.setModifiedBy(operatorId);
|
update.setModifiedBy(operatorId);
|
||||||
@@ -421,7 +423,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
entity.setDeptId(dept.getId());
|
entity.setDeptId(dept.getId());
|
||||||
entity.setTenantId(loginAccount.getTenantId());
|
entity.setTenantId(loginAccount.getTenantId());
|
||||||
entity.setLoginName(loginName);
|
entity.setLoginName(loginName);
|
||||||
entity.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
entity.setPassword(BCrypt.hashpw(accountSecurityProperties.getDefaultResetPassword()));
|
||||||
entity.setPasswordResetRequired(true);
|
entity.setPasswordResetRequired(true);
|
||||||
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||||
entity.setNickname(nickname);
|
entity.setNickname(nickname);
|
||||||
@@ -686,7 +688,11 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
rows.add(List.of("可选字段", "手机号、邮箱、状态、角色名称、岗位名称、备注"));
|
rows.add(List.of("可选字段", "手机号、邮箱、状态、角色名称、岗位名称、备注"));
|
||||||
rows.add(List.of("状态可选值", "可留空,或填写 1/0/已启用/启用/未启用/停用/禁用"));
|
rows.add(List.of("状态可选值", "可留空,或填写 1/0/已启用/启用/未启用/停用/禁用"));
|
||||||
rows.add(List.of("多值分隔", "角色名称、岗位名称支持使用英文逗号,或中文逗号,分隔多个名称"));
|
rows.add(List.of("多值分隔", "角色名称、岗位名称支持使用英文逗号,或中文逗号,分隔多个名称"));
|
||||||
rows.add(List.of("导入后初始密码", "导入成功的账号默认密码为 123456,首次登录需要修改密码"));
|
rows.add(List.of(
|
||||||
|
"导入后初始密码",
|
||||||
|
"导入成功的账号默认密码为 " + accountSecurityProperties.getDefaultResetPassword()
|
||||||
|
+ ",首次登录需要修改密码"
|
||||||
|
));
|
||||||
rows.add(List.of("示例行", "市场部 | zhangsan | 张三 | 13800138000 | zhangsan@example.com | 已启用 | 普通员工,审批专员 | 产品经理 | 示例导入"));
|
rows.add(List.of("示例行", "市场部 | zhangsan | 张三 | 13800138000 | zhangsan@example.com | 已启用 | 普通员工,审批专员 | 产品经理 | 示例导入"));
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package tech.easyflow.system.config;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AccountSecurityProperties} 测试。
|
||||||
|
*/
|
||||||
|
public class AccountSecurityPropertiesTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证默认重置密码符合强密码策略。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAcceptDefaultStrongResetPassword() {
|
||||||
|
AccountSecurityProperties properties = new AccountSecurityProperties();
|
||||||
|
|
||||||
|
properties.afterPropertiesSet();
|
||||||
|
|
||||||
|
Assert.assertEquals("!QAZ2wsx", properties.getDefaultResetPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证弱默认密码会阻止应用启动。
|
||||||
|
*/
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void shouldRejectWeakResetPassword() {
|
||||||
|
AccountSecurityProperties properties = new AccountSecurityProperties();
|
||||||
|
properties.setDefaultResetPassword("123456");
|
||||||
|
|
||||||
|
properties.afterPropertiesSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证空默认密码会阻止应用启动。
|
||||||
|
*/
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void shouldRejectBlankResetPassword() {
|
||||||
|
AccountSecurityProperties properties = new AccountSecurityProperties();
|
||||||
|
properties.setDefaultResetPassword(" ");
|
||||||
|
|
||||||
|
properties.afterPropertiesSet();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,9 @@ spring:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
easyflow:
|
easyflow:
|
||||||
|
security:
|
||||||
|
account:
|
||||||
|
default-reset-password: '${EASYFLOW_DEFAULT_RESET_PASSWORD:!QAZ2wsx}'
|
||||||
license:
|
license:
|
||||||
location: classpath:easyflow.lic
|
location: classpath:easyflow.lic
|
||||||
chat:
|
chat:
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE `tb_approval_instance`
|
||||||
|
ADD COLUMN `application_reason` VARCHAR(500) NULL COMMENT '审批说明' AFTER `summary`;
|
||||||
|
|
||||||
|
ALTER TABLE `tb_workflow`
|
||||||
|
ADD COLUMN `revision` INT NOT NULL DEFAULT 0 COMMENT '工作流内容修订号' AFTER `content`;
|
||||||
|
|
||||||
|
CREATE TABLE `tb_workflow_share`
|
||||||
|
(
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL COMMENT 'ID',
|
||||||
|
`workflow_id` BIGINT UNSIGNED NOT NULL COMMENT '工作流ID',
|
||||||
|
`share_key_hash` VARCHAR(64) NOT NULL COMMENT '分享密钥哈希',
|
||||||
|
`status` VARCHAR(32) NOT NULL COMMENT '分享状态',
|
||||||
|
`expires_at` DATETIME NOT NULL COMMENT '过期时间',
|
||||||
|
`tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`dept_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '部门ID',
|
||||||
|
`created` DATETIME NULL DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`created_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '创建人',
|
||||||
|
`modified` DATETIME NULL DEFAULT NULL COMMENT '修改时间',
|
||||||
|
`modified_by` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '修改人',
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
UNIQUE INDEX `uni_workflow_share_key_hash` (`share_key_hash`) USING BTREE,
|
||||||
|
INDEX `idx_workflow_share_status` (`workflow_id`, `status`) USING BTREE,
|
||||||
|
INDEX `idx_workflow_share_expires_at` (`expires_at`) USING BTREE
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
CHARACTER SET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_0900_ai_ci
|
||||||
|
COMMENT = '工作流协作分享记录'
|
||||||
|
ROW_FORMAT = Dynamic;
|
||||||
@@ -54,10 +54,13 @@ export const removeBotFromId = (id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** 提交 Bot 发布审批 */
|
/** 提交 Bot 发布审批 */
|
||||||
export const submitBotPublishApproval = (id: string) => {
|
export const submitBotPublishApproval = (
|
||||||
|
id: string,
|
||||||
|
applicationReason?: string,
|
||||||
|
) => {
|
||||||
return api.post<RequestResult<number | string>>(
|
return api.post<RequestResult<number | string>>(
|
||||||
'/api/v1/bot/submitPublishApproval',
|
'/api/v1/bot/submitPublishApproval',
|
||||||
{ id },
|
{ applicationReason, id },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import { ElMessage } from 'element-plus';
|
|||||||
import { events } from 'fetch-event-stream';
|
import { events } from 'fetch-event-stream';
|
||||||
|
|
||||||
import { useAuthStore } from '#/store';
|
import { useAuthStore } from '#/store';
|
||||||
|
import {
|
||||||
|
readWorkflowShareKey,
|
||||||
|
withWorkflowShareHeader,
|
||||||
|
WORKFLOW_SHARE_HEADER,
|
||||||
|
} from '#/utils/workflow-share-context';
|
||||||
|
|
||||||
import { refreshTokenApi } from './core';
|
import { refreshTokenApi } from './core';
|
||||||
|
|
||||||
@@ -95,6 +100,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
|
|
||||||
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
|
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
|
||||||
config.headers['Accept-Language'] = preferences.app.locale;
|
config.headers['Accept-Language'] = preferences.app.locale;
|
||||||
|
const workflowShareKey = readWorkflowShareKey();
|
||||||
|
if (workflowShareKey) {
|
||||||
|
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey;
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -263,13 +272,12 @@ export class SseClient {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'easyflow-token': accessStore.accessToken || '',
|
'easyflow-token': accessStore.accessToken || '',
|
||||||
};
|
};
|
||||||
if (!extraHeaders) {
|
if (extraHeaders) {
|
||||||
return headers;
|
new Headers(extraHeaders).forEach((value, key) => {
|
||||||
|
headers[key] = value;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
new Headers(extraHeaders).forEach((value, key) => {
|
return withWorkflowShareHeader(headers);
|
||||||
headers[key] = value;
|
|
||||||
});
|
|
||||||
return headers;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { computed } from 'vue';
|
|||||||
|
|
||||||
import { useAccess } from '@easyflow/access';
|
import { useAccess } from '@easyflow/access';
|
||||||
|
|
||||||
import { MoreFilled } from '@element-plus/icons-vue';
|
import { Loading, MoreFilled } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElAvatar,
|
ElAvatar,
|
||||||
ElButton,
|
ElButton,
|
||||||
@@ -27,6 +27,8 @@ export interface ActionButton {
|
|||||||
icon?: any;
|
icon?: any;
|
||||||
text: ((row: any) => string) | string;
|
text: ((row: any) => string) | string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: ((row: any) => boolean) | boolean;
|
||||||
|
loading?: ((row: any) => boolean) | boolean;
|
||||||
permission?: string;
|
permission?: string;
|
||||||
placement?: ActionPlacement;
|
placement?: ActionPlacement;
|
||||||
tone?: ActionTone;
|
tone?: ActionTone;
|
||||||
@@ -87,6 +89,13 @@ function isActionVisible(action: ActionButton, row: any) {
|
|||||||
return action.visible !== false;
|
return action.visible !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveActionState(
|
||||||
|
state: ((row: any) => boolean) | boolean | undefined,
|
||||||
|
row: any,
|
||||||
|
) {
|
||||||
|
return typeof state === 'function' ? state(row) : state === true;
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedPrimaryAction = computed(() => {
|
const resolvedPrimaryAction = computed(() => {
|
||||||
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
|
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -125,6 +134,12 @@ function handlePrimaryAction(item: any) {
|
|||||||
|
|
||||||
function handleActionClick(event: Event, action: ActionButton, item: any) {
|
function handleActionClick(event: Event, action: ActionButton, item: any) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (
|
||||||
|
resolveActionState(action.disabled, item) ||
|
||||||
|
resolveActionState(action.loading, item)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
action.onClick(item);
|
action.onClick(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +280,8 @@ function resolveMetaItems(item: any) {
|
|||||||
size="small"
|
size="small"
|
||||||
class="card-action-btn"
|
class="card-action-btn"
|
||||||
:class="{ 'card-action-btn--danger': action.tone === 'danger' }"
|
:class="{ 'card-action-btn--danger': action.tone === 'danger' }"
|
||||||
|
:disabled="resolveActionState(action.disabled, item)"
|
||||||
|
:loading="resolveActionState(action.loading, item)"
|
||||||
link
|
link
|
||||||
@click.stop="handleActionClick($event, action, item)"
|
@click.stop="handleActionClick($event, action, item)"
|
||||||
>
|
>
|
||||||
@@ -293,10 +310,20 @@ function resolveMetaItems(item: any) {
|
|||||||
:class="{
|
:class="{
|
||||||
'card-menu-item--danger': action.tone === 'danger',
|
'card-menu-item--danger': action.tone === 'danger',
|
||||||
}"
|
}"
|
||||||
@click="action.onClick(item)"
|
:disabled="
|
||||||
|
resolveActionState(action.disabled, item) ||
|
||||||
|
resolveActionState(action.loading, item)
|
||||||
|
"
|
||||||
|
@click="handleActionClick($event, action, item)"
|
||||||
>
|
>
|
||||||
<div class="menu-action-content">
|
<div class="menu-action-content">
|
||||||
<ElIcon v-if="action.icon">
|
<ElIcon
|
||||||
|
v-if="resolveActionState(action.loading, item)"
|
||||||
|
class="is-loading"
|
||||||
|
>
|
||||||
|
<Loading />
|
||||||
|
</ElIcon>
|
||||||
|
<ElIcon v-else-if="action.icon">
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
v-if="typeof action.icon === 'string'"
|
v-if="typeof action.icon === 'string'"
|
||||||
:icon="action.icon"
|
:icon="action.icon"
|
||||||
|
|||||||
@@ -89,6 +89,28 @@ describe('cardList', () => {
|
|||||||
expect(primaryAction).not.toHaveBeenCalled();
|
expect(primaryAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('异步次级操作执行中会禁用重复点击并展示加载态', async () => {
|
||||||
|
const inlineAction = vi.fn();
|
||||||
|
const wrapper = mountCardList({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
disabled: () => true,
|
||||||
|
loading: () => true,
|
||||||
|
text: '分享',
|
||||||
|
placement: 'inline',
|
||||||
|
onClick: inlineAction,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionButton = wrapper.get('.card-action-btn');
|
||||||
|
await actionButton.trigger('click');
|
||||||
|
|
||||||
|
expect(actionButton.classes()).toContain('is-loading');
|
||||||
|
expect(actionButton.classes()).toContain('is-disabled');
|
||||||
|
expect(inlineAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('键盘 Enter 可以触发主动作', async () => {
|
it('键盘 Enter 可以触发主动作', async () => {
|
||||||
const primaryAction = vi.fn();
|
const primaryAction = vi.fn();
|
||||||
const wrapper = mountCardList({
|
const wrapper = mountCardList({
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
"apiVariablesEmpty": "This workflow has no start parameters",
|
"apiVariablesEmpty": "This workflow has no start parameters",
|
||||||
"apiStatusExample": "Status Query Example",
|
"apiStatusExample": "Status Query Example",
|
||||||
"apiResumeExample": "Resume Example",
|
"apiResumeExample": "Resume Example",
|
||||||
|
"shareExpired": "This workflow share link has expired. Request a new link",
|
||||||
"submitPublishApprovalConfirm": "Publish the current workflow now?",
|
"submitPublishApprovalConfirm": "Publish the current workflow now?",
|
||||||
"submitRepublishApprovalConfirm": "Republish the current workflow now?",
|
"submitRepublishApprovalConfirm": "Republish the current workflow now?",
|
||||||
"submitOfflineApprovalConfirm": "Take the current workflow offline?",
|
"submitOfflineApprovalConfirm": "Take the current workflow offline?",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"revoke": "Revoke",
|
"revoke": "Revoke",
|
||||||
|
"submit": "Submit Approval",
|
||||||
"addScope": "Add Scope",
|
"addScope": "Add Scope",
|
||||||
"addStep": "Add Step"
|
"addStep": "Add Step"
|
||||||
},
|
},
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"stepNoLabel": "Step No.",
|
"stepNoLabel": "Step No.",
|
||||||
"currentStep": "Current Step",
|
"currentStep": "Current Step",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
|
"applicationReason": "Application Reason",
|
||||||
"resourceId": "Resource ID",
|
"resourceId": "Resource ID",
|
||||||
"taskId": "Approval Task ID",
|
"taskId": "Approval Task ID",
|
||||||
"applicant": "Applicant",
|
"applicant": "Applicant",
|
||||||
@@ -104,7 +106,8 @@
|
|||||||
"assigneeType": "Select assignee type",
|
"assigneeType": "Select assignee type",
|
||||||
"assigneeTarget": "Select assignee",
|
"assigneeTarget": "Select assignee",
|
||||||
"stepName": "Enter step name",
|
"stepName": "Enter step name",
|
||||||
"actionComment": "Enter a comment"
|
"actionComment": "Enter a comment",
|
||||||
|
"applicationReason": "Describe this publication and its purpose"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"needStep": "At least one step is required",
|
"needStep": "At least one step is required",
|
||||||
@@ -123,7 +126,10 @@
|
|||||||
"eventRevokedStep": "Step {value} revoked",
|
"eventRevokedStep": "Step {value} revoked",
|
||||||
"workflowSnapshotUntitled": "Untitled workflow snapshot",
|
"workflowSnapshotUntitled": "Untitled workflow snapshot",
|
||||||
"workflowSnapshotMissing": "Workflow snapshot not found",
|
"workflowSnapshotMissing": "Workflow snapshot not found",
|
||||||
"workflowSnapshotParseFailed": "Failed to parse workflow snapshot"
|
"workflowSnapshotParseFailed": "Failed to parse workflow snapshot",
|
||||||
|
"applicationReasonPrompt": "Enter an approval reason (1-500 characters)",
|
||||||
|
"applicationReasonRequired": "Approval reason is required",
|
||||||
|
"applicationReasonTooLong": "Approval reason cannot exceed 500 characters"
|
||||||
},
|
},
|
||||||
"snapshot": {
|
"snapshot": {
|
||||||
"knowledgeBasic": "Basic Info",
|
"knowledgeBasic": "Basic Info",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"run": "Run",
|
"run": "Run",
|
||||||
"runTest": "RunTest",
|
"runTest": "RunTest",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
|
"share": "Share",
|
||||||
"selectAll": "Select All",
|
"selectAll": "Select All",
|
||||||
"choose": "Select",
|
"choose": "Select",
|
||||||
"setting": "Setting",
|
"setting": "Setting",
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
||||||
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.",
|
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.",
|
||||||
"resetPassword": "Reset Password",
|
"resetPassword": "Reset Password",
|
||||||
"resetPasswordConfirm": "Reset this account password to 123456? The user will be required to change it on next login.",
|
"resetPasswordConfirm": "Reset this account password to the system default strong password? The user will be required to change it on next login.",
|
||||||
"resetPasswordSuccess": "Password has been reset to 123456 and must be changed on next login",
|
"resetPasswordSuccess": "Password has been reset to the system default strong password and must be changed on next login",
|
||||||
"batchSelectedCount": "{count} selected",
|
"batchSelectedCount": "{count} selected",
|
||||||
"batchToolbarHint": "Batch actions are available for selected accounts",
|
"batchToolbarHint": "Batch actions are available for selected accounts",
|
||||||
"batchActionSelectRequired": "Please select at least one account",
|
"batchActionSelectRequired": "Please select at least one account",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"batchDeletePartialSuccess": "Batch delete completed. {successCount} succeeded and {errorCount} failed.",
|
"batchDeletePartialSuccess": "Batch delete completed. {successCount} succeeded and {errorCount} failed.",
|
||||||
"batchDeleteAllFailed": "Batch delete failed",
|
"batchDeleteAllFailed": "Batch delete failed",
|
||||||
"batchResetPassword": "Batch Reset Password",
|
"batchResetPassword": "Batch Reset Password",
|
||||||
"batchResetPasswordConfirm": "Reset the selected {count} accounts to 123456? Users must change it on next login, and protected administrator accounts will be skipped.",
|
"batchResetPasswordConfirm": "Reset the selected {count} accounts to the system default strong password? Users must change it on next login, and protected administrator accounts will be skipped.",
|
||||||
"batchResetPasswordSuccess": "{count} account passwords have been reset",
|
"batchResetPasswordSuccess": "{count} account passwords have been reset",
|
||||||
"batchResetPasswordPartialSuccess": "Batch password reset completed. {successCount} succeeded and {errorCount} failed.",
|
"batchResetPasswordPartialSuccess": "Batch password reset completed. {successCount} succeeded and {errorCount} failed.",
|
||||||
"batchResetPasswordAllFailed": "Batch password reset failed",
|
"batchResetPasswordAllFailed": "Batch password reset failed",
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
"apiVariablesEmpty": "当前工作流没有开始参数",
|
"apiVariablesEmpty": "当前工作流没有开始参数",
|
||||||
"apiStatusExample": "状态查询示例",
|
"apiStatusExample": "状态查询示例",
|
||||||
"apiResumeExample": "恢复执行示例",
|
"apiResumeExample": "恢复执行示例",
|
||||||
|
"shareExpired": "工作流分享链接已失效,请重新获取",
|
||||||
"submitPublishApprovalConfirm": "确认发布当前工作流吗?",
|
"submitPublishApprovalConfirm": "确认发布当前工作流吗?",
|
||||||
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
|
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
|
||||||
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
|
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"approve": "通过",
|
"approve": "通过",
|
||||||
"reject": "驳回",
|
"reject": "驳回",
|
||||||
"revoke": "撤回",
|
"revoke": "撤回",
|
||||||
|
"submit": "提交审批",
|
||||||
"addScope": "新增范围",
|
"addScope": "新增范围",
|
||||||
"addStep": "新增步骤"
|
"addStep": "新增步骤"
|
||||||
},
|
},
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"stepNoLabel": "步骤序号",
|
"stepNoLabel": "步骤序号",
|
||||||
"currentStep": "当前步骤",
|
"currentStep": "当前步骤",
|
||||||
"summary": "审批摘要",
|
"summary": "审批摘要",
|
||||||
|
"applicationReason": "审批说明",
|
||||||
"resourceId": "资源ID",
|
"resourceId": "资源ID",
|
||||||
"taskId": "审批任务ID",
|
"taskId": "审批任务ID",
|
||||||
"applicant": "申请人",
|
"applicant": "申请人",
|
||||||
@@ -104,7 +106,8 @@
|
|||||||
"assigneeType": "请选择审批方式",
|
"assigneeType": "请选择审批方式",
|
||||||
"assigneeTarget": "请选择审批对象",
|
"assigneeTarget": "请选择审批对象",
|
||||||
"stepName": "请输入步骤名称",
|
"stepName": "请输入步骤名称",
|
||||||
"actionComment": "请输入处理说明"
|
"actionComment": "请输入处理说明",
|
||||||
|
"applicationReason": "请输入本次发布内容和原因"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"needStep": "至少需要一个审批步骤",
|
"needStep": "至少需要一个审批步骤",
|
||||||
@@ -123,7 +126,10 @@
|
|||||||
"eventRevokedStep": "第 {value} 步已撤回",
|
"eventRevokedStep": "第 {value} 步已撤回",
|
||||||
"workflowSnapshotUntitled": "未命名工作流快照",
|
"workflowSnapshotUntitled": "未命名工作流快照",
|
||||||
"workflowSnapshotMissing": "未找到工作流快照",
|
"workflowSnapshotMissing": "未找到工作流快照",
|
||||||
"workflowSnapshotParseFailed": "工作流快照解析失败"
|
"workflowSnapshotParseFailed": "工作流快照解析失败",
|
||||||
|
"applicationReasonPrompt": "请填写审批说明(1-500字)",
|
||||||
|
"applicationReasonRequired": "请填写审批说明",
|
||||||
|
"applicationReasonTooLong": "审批说明不能超过500字"
|
||||||
},
|
},
|
||||||
"snapshot": {
|
"snapshot": {
|
||||||
"knowledgeBasic": "基础信息",
|
"knowledgeBasic": "基础信息",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"run": "运行",
|
"run": "运行",
|
||||||
"runTest": "试运行",
|
"runTest": "试运行",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
|
"share": "分享",
|
||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"choose": "选择",
|
"choose": "选择",
|
||||||
"setting": "设置",
|
"setting": "设置",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||||
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面",
|
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面",
|
||||||
"resetPassword": "重置密码",
|
"resetPassword": "重置密码",
|
||||||
"resetPasswordConfirm": "确认将该用户密码重置为 123456 吗?重置后用户下次登录必须先修改密码。",
|
"resetPasswordConfirm": "确认将该用户密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码。",
|
||||||
"resetPasswordSuccess": "密码已重置为 123456,用户下次登录需修改密码",
|
"resetPasswordSuccess": "密码已重置为系统默认强密码,用户下次登录需修改密码",
|
||||||
"batchSelectedCount": "已选择 {count} 项",
|
"batchSelectedCount": "已选择 {count} 项",
|
||||||
"batchToolbarHint": "可对选中账号执行批量操作",
|
"batchToolbarHint": "可对选中账号执行批量操作",
|
||||||
"batchActionSelectRequired": "请先选择要操作的账号",
|
"batchActionSelectRequired": "请先选择要操作的账号",
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"batchDeletePartialSuccess": "批量删除完成,成功 {successCount} 个,失败 {errorCount} 个",
|
"batchDeletePartialSuccess": "批量删除完成,成功 {successCount} 个,失败 {errorCount} 个",
|
||||||
"batchDeleteAllFailed": "批量删除失败",
|
"batchDeleteAllFailed": "批量删除失败",
|
||||||
"batchResetPassword": "批量重置密码",
|
"batchResetPassword": "批量重置密码",
|
||||||
"batchResetPasswordConfirm": "确认将已选中的 {count} 个账号密码重置为 123456 吗?重置后用户下次登录必须先修改密码,管理员账号将跳过并返回失败结果。",
|
"batchResetPasswordConfirm": "确认将已选中的 {count} 个账号密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码,管理员账号将跳过并返回失败结果。",
|
||||||
"batchResetPasswordSuccess": "已完成 {count} 个账号密码重置",
|
"batchResetPasswordSuccess": "已完成 {count} 个账号密码重置",
|
||||||
"batchResetPasswordPartialSuccess": "批量重置密码完成,成功 {successCount} 个,失败 {errorCount} 个",
|
"batchResetPasswordPartialSuccess": "批量重置密码完成,成功 {successCount} 个,失败 {errorCount} 个",
|
||||||
"batchResetPasswordAllFailed": "批量重置密码失败",
|
"batchResetPasswordAllFailed": "批量重置密码失败",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { copyTextWithFeedback } from '../clipboard-feedback';
|
||||||
|
|
||||||
|
const { copyTextToClipboard, error, success } = vi.hoisted(() => ({
|
||||||
|
copyTextToClipboard: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@easyflow/utils', () => ({
|
||||||
|
copyTextToClipboard,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('element-plus', () => ({
|
||||||
|
ElMessage: {
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('copyTextWithFeedback', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies through the shared Chrome 90 compatible helper', async () => {
|
||||||
|
copyTextToClipboard.mockResolvedValue('exec-command');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
copyTextWithFeedback('https://example.test/share', '复制成功'),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(copyTextToClipboard).toHaveBeenCalledWith(
|
||||||
|
'https://example.test/share',
|
||||||
|
);
|
||||||
|
expect(success).toHaveBeenCalledWith('复制成功');
|
||||||
|
expect(error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a recoverable error when copying fails', async () => {
|
||||||
|
copyTextToClipboard.mockRejectedValue(new Error('copy denied'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
copyTextWithFeedback('content', '复制成功', '复制失败,请手动复制'),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(error).toHaveBeenCalledWith('复制失败,请手动复制');
|
||||||
|
expect(success).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
26
easyflow-ui-admin/app/src/utils/clipboard-feedback.ts
Normal file
26
easyflow-ui-admin/app/src/utils/clipboard-feedback.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { copyTextToClipboard } from '@easyflow/utils';
|
||||||
|
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文本并统一展示成功或失败反馈。
|
||||||
|
*
|
||||||
|
* @param text 待复制文本
|
||||||
|
* @param successMessage 成功提示
|
||||||
|
* @param failureMessage 失败提示
|
||||||
|
* @returns 是否复制成功
|
||||||
|
*/
|
||||||
|
export async function copyTextWithFeedback(
|
||||||
|
text: string,
|
||||||
|
successMessage: string,
|
||||||
|
failureMessage = '复制失败,请手动复制',
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await copyTextToClipboard(text);
|
||||||
|
ElMessage.success(successMessage);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(failureMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
easyflow-ui-admin/app/src/utils/workflow-share-context.ts
Normal file
88
easyflow-ui-admin/app/src/utils/workflow-share-context.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 工作流协作分享请求头。
|
||||||
|
*/
|
||||||
|
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Share-Key';
|
||||||
|
|
||||||
|
interface WorkflowShareResolutionOptions<T> {
|
||||||
|
currentWorkflowId?: null | T;
|
||||||
|
onFailure: (error: unknown) => Promise<void> | void;
|
||||||
|
resolve: () => Promise<null | T | undefined>;
|
||||||
|
shareKey?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
|
||||||
|
*/
|
||||||
|
export function readWorkflowShareKey(url?: string): null | string {
|
||||||
|
const currentUrl =
|
||||||
|
url || (typeof window === 'undefined' ? '' : window.location.href);
|
||||||
|
if (!currentUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(
|
||||||
|
currentUrl,
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? 'http://localhost'
|
||||||
|
: window.location.origin,
|
||||||
|
);
|
||||||
|
const historyKey = parsed.searchParams.get('shareKey')?.trim();
|
||||||
|
if (historyKey) {
|
||||||
|
return historyKey;
|
||||||
|
}
|
||||||
|
const queryIndex = parsed.hash.indexOf('?');
|
||||||
|
if (queryIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
new URLSearchParams(parsed.hash.slice(queryIndex + 1))
|
||||||
|
.get('shareKey')
|
||||||
|
?.trim() || null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在保留现有请求头的基础上附加工作流分享密钥。
|
||||||
|
*/
|
||||||
|
export function withWorkflowShareHeader(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
url?: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const shareKey = readWorkflowShareKey(url);
|
||||||
|
if (!shareKey) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...headers,
|
||||||
|
[WORKFLOW_SHARE_HEADER]: shareKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析分享地址对应的工作流,并在链接失效时统一收口异常。
|
||||||
|
*/
|
||||||
|
export async function resolveWorkflowShareWorkflowId<T>({
|
||||||
|
currentWorkflowId,
|
||||||
|
onFailure,
|
||||||
|
resolve,
|
||||||
|
shareKey,
|
||||||
|
}: WorkflowShareResolutionOptions<T>): Promise<null | T> {
|
||||||
|
if (currentWorkflowId !== null && currentWorkflowId !== undefined) {
|
||||||
|
return currentWorkflowId;
|
||||||
|
}
|
||||||
|
const normalizedShareKey = Array.isArray(shareKey)
|
||||||
|
? shareKey.find((value) => String(value || '').trim())
|
||||||
|
: shareKey;
|
||||||
|
if (!String(normalizedShareKey || '').trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await resolve()) ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
await onFailure(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
|||||||
import CardList from '#/components/page/CardList.vue';
|
import CardList from '#/components/page/CardList.vue';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import PageSide from '#/components/page/PageSide.vue';
|
import PageSide from '#/components/page/PageSide.vue';
|
||||||
|
import {
|
||||||
|
confirmPublishSubmission,
|
||||||
|
} from '#/views/ai/shared/approval-application-reason';
|
||||||
import {
|
import {
|
||||||
canAiResourceDelete,
|
canAiResourceDelete,
|
||||||
canAiResourceOffline,
|
canAiResourceOffline,
|
||||||
@@ -158,22 +161,22 @@ const handlePublishAction = async (bot: BotInfo) => {
|
|||||||
ElMessage.warning($t('bot.publishPendingHint'));
|
ElMessage.warning($t('bot.publishPendingHint'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const confirmation = await confirmPublishSubmission({
|
||||||
await ElMessageBox.confirm(
|
api,
|
||||||
isRepublishAction(bot)
|
confirmMessage: isRepublishAction(bot)
|
||||||
? $t('bot.submitRepublishApprovalConfirm')
|
? $t('bot.submitRepublishApprovalConfirm')
|
||||||
: $t('bot.submitPublishApprovalConfirm'),
|
: $t('bot.submitPublishApprovalConfirm'),
|
||||||
$t('message.noticeTitle'),
|
id: String(bot.id),
|
||||||
{
|
resourcePath: '/api/v1/bot',
|
||||||
confirmButtonText: $t('button.confirm'),
|
title: $t('message.noticeTitle'),
|
||||||
cancelButtonText: $t('button.cancel'),
|
});
|
||||||
type: 'info',
|
if (!confirmation) {
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await submitBotPublishApproval(String(bot.id));
|
const res = await submitBotPublishApproval(
|
||||||
|
String(bot.id),
|
||||||
|
confirmation.applicationReason,
|
||||||
|
);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(res.message || $t('message.saveOkMessage'));
|
ElMessage.success(res.message || $t('message.saveOkMessage'));
|
||||||
pageDataRef.value?.reload?.();
|
pageDataRef.value?.reload?.();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Promotion,
|
Promotion,
|
||||||
Search,
|
Search,
|
||||||
|
Share,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElForm,
|
ElForm,
|
||||||
@@ -43,8 +44,10 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
|||||||
import CardPage from '#/components/page/CardList.vue';
|
import CardPage from '#/components/page/CardList.vue';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import PageSide from '#/components/page/PageSide.vue';
|
import PageSide from '#/components/page/PageSide.vue';
|
||||||
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
||||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||||
|
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||||
import {
|
import {
|
||||||
buildOfflineImpactMessage,
|
buildOfflineImpactMessage,
|
||||||
type OfflineImpactCheck,
|
type OfflineImpactCheck,
|
||||||
@@ -70,6 +73,7 @@ type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
|
|||||||
const canManageKnowledgePermission = computed(() =>
|
const canManageKnowledgePermission = computed(() =>
|
||||||
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
hasAccessByCodes(['/api/v1/documentCollection/save']),
|
||||||
);
|
);
|
||||||
|
const sharingKnowledgeId = ref<null | number | string>(null);
|
||||||
const updatingScopeId = ref<null | number | string>(null);
|
const updatingScopeId = ref<null | number | string>(null);
|
||||||
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
|
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
|
||||||
const visibilityScopeMeta = computed(() => ({
|
const visibilityScopeMeta = computed(() => ({
|
||||||
@@ -140,6 +144,30 @@ function openKnowledgeDetail(row: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function shareKnowledge(row: Record<string, any>) {
|
||||||
|
if (!row?.id || sharingKnowledgeId.value === row.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sharingKnowledgeId.value = row.id;
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/knowledgeShare/url/create', {
|
||||||
|
knowledgeId: row.id,
|
||||||
|
});
|
||||||
|
const shareUrl = String(res.data?.shareUrl || '').trim();
|
||||||
|
if (res.errorCode !== 0 || !shareUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await copyTextWithFeedback(
|
||||||
|
shareUrl,
|
||||||
|
$t('message.copySuccess'),
|
||||||
|
$t('message.copyFail'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
sharingKnowledgeId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface FieldDefinition {
|
interface FieldDefinition {
|
||||||
// 字段名称
|
// 字段名称
|
||||||
prop: string;
|
prop: string;
|
||||||
@@ -208,6 +236,20 @@ const actions: ActionButton[] = [
|
|||||||
submitPublishAction(row);
|
submitPublishAction(row);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Share,
|
||||||
|
text: $t('button.share'),
|
||||||
|
permission: '/api/v1/documentCollection/save',
|
||||||
|
placement: 'menu',
|
||||||
|
disabled: (row) => sharingKnowledgeId.value === row.id,
|
||||||
|
loading: (row) => sharingKnowledgeId.value === row.id,
|
||||||
|
onClick(row) {
|
||||||
|
if (!ensureManageKnowledgeItem(row)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shareKnowledge(row);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Promotion,
|
icon: Promotion,
|
||||||
text: $t('button.offline'),
|
text: $t('button.offline'),
|
||||||
@@ -251,24 +293,22 @@ const submitPublishAction = async (item: any) => {
|
|||||||
ElMessage.warning($t('documentCollection.publishPendingHint'));
|
ElMessage.warning($t('documentCollection.publishPendingHint'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const confirmation = await confirmPublishSubmission({
|
||||||
await ElMessageBox.confirm(
|
api,
|
||||||
isRepublishAction(item)
|
confirmMessage: isRepublishAction(item)
|
||||||
? $t('documentCollection.submitRepublishApprovalConfirm')
|
? $t('documentCollection.submitRepublishApprovalConfirm')
|
||||||
: $t('documentCollection.submitPublishApprovalConfirm'),
|
: $t('documentCollection.submitPublishApprovalConfirm'),
|
||||||
$t('message.noticeTitle'),
|
id: item.id,
|
||||||
{
|
resourcePath: '/api/v1/documentCollection',
|
||||||
confirmButtonText: $t('button.confirm'),
|
title: $t('message.noticeTitle'),
|
||||||
cancelButtonText: $t('button.cancel'),
|
});
|
||||||
type: 'info',
|
if (!confirmation) {
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await api.post(
|
const res = await api.post(
|
||||||
'/api/v1/documentCollection/submitPublishApproval',
|
'/api/v1/documentCollection/submitPublishApproval',
|
||||||
{
|
{
|
||||||
|
applicationReason: confirmation.applicationReason,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { CopyDocument } from '@element-plus/icons-vue';
|
|||||||
import { ElButton, ElCard, ElIcon, ElInput, ElMessage } from 'element-plus';
|
import { ElButton, ElCard, ElIcon, ElInput, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
|
|
||||||
type EndpointParam = {
|
type EndpointParam = {
|
||||||
location: 'body' | 'query';
|
location: 'body' | 'query';
|
||||||
@@ -202,18 +203,15 @@ const copyGeneratedUrl = async () => {
|
|||||||
ElMessage.warning('请先生成分享链接');
|
ElMessage.warning('请先生成分享链接');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await navigator.clipboard.writeText(generatedUrl.value);
|
await copyTextWithFeedback(generatedUrl.value, '已复制分享链接');
|
||||||
ElMessage.success('已复制分享链接');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyApiExample = async (content: string) => {
|
const copyApiExample = async (content: string) => {
|
||||||
await navigator.clipboard.writeText(content);
|
await copyTextWithFeedback(content, '已复制调用示例');
|
||||||
ElMessage.success('已复制调用示例');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyEndpointUrl = async (url: string) => {
|
const copyEndpointUrl = async (url: string) => {
|
||||||
await navigator.clipboard.writeText(url);
|
await copyTextWithFeedback(url, '已复制接口地址');
|
||||||
ElMessage.success('已复制接口地址');
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { confirmPublishSubmission } from './approval-application-reason';
|
||||||
|
|
||||||
|
const { confirm, prompt } = vi.hoisted(() => ({
|
||||||
|
confirm: vi.fn(),
|
||||||
|
prompt: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('element-plus', () => ({
|
||||||
|
ElMessageBox: {
|
||||||
|
confirm,
|
||||||
|
prompt,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('#/locales', () => ({
|
||||||
|
$t: (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('confirmPublishSubmission', () => {
|
||||||
|
const api = {
|
||||||
|
get: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the normal confirmation when no approval flow matches', async () => {
|
||||||
|
api.get.mockResolvedValue({ data: false, errorCode: 0 });
|
||||||
|
confirm.mockResolvedValue('confirm');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
confirmPublishSubmission({
|
||||||
|
api,
|
||||||
|
confirmMessage: '确认发布?',
|
||||||
|
id: '1',
|
||||||
|
resourcePath: '/api/v1/workflow',
|
||||||
|
title: '提示',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({});
|
||||||
|
|
||||||
|
expect(confirm).toHaveBeenCalled();
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires and trims a reason when an approval flow matches', async () => {
|
||||||
|
api.get.mockResolvedValue({ data: true, errorCode: 0 });
|
||||||
|
prompt.mockResolvedValue({ value: ' 本次修复复制兼容问题 ' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
confirmPublishSubmission({
|
||||||
|
api,
|
||||||
|
confirmMessage: '确认提交发布审批?',
|
||||||
|
id: '1',
|
||||||
|
resourcePath: '/api/v1/workflow',
|
||||||
|
title: '提示',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
applicationReason: '本次修复复制兼容问题',
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptOptions = prompt.mock.calls[0]?.[2];
|
||||||
|
expect(promptOptions.inputValidator(' ')).toBe(
|
||||||
|
'approval.message.applicationReasonRequired',
|
||||||
|
);
|
||||||
|
expect(promptOptions.inputValidator('a'.repeat(501))).toBe(
|
||||||
|
'approval.message.applicationReasonTooLong',
|
||||||
|
);
|
||||||
|
expect(promptOptions.inputValidator('有效说明')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the user cancels', async () => {
|
||||||
|
api.get.mockResolvedValue({ data: true, errorCode: 0 });
|
||||||
|
prompt.mockRejectedValue(new Error('cancel'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
confirmPublishSubmission({
|
||||||
|
api,
|
||||||
|
confirmMessage: '确认提交发布审批?',
|
||||||
|
id: '1',
|
||||||
|
resourcePath: '/api/v1/workflow',
|
||||||
|
title: '提示',
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ElMessageBox } from 'element-plus';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
type ApprovalRequirementApi = {
|
||||||
|
get: (
|
||||||
|
url: string,
|
||||||
|
config: { params: { id: number | string } },
|
||||||
|
) => Promise<{ data?: boolean; errorCode?: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfirmPublishOptions = {
|
||||||
|
api: ApprovalRequirementApi;
|
||||||
|
confirmMessage: string;
|
||||||
|
id: number | string;
|
||||||
|
resourcePath: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublishConfirmation = {
|
||||||
|
applicationReason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预检发布审批并完成相应的确认交互。
|
||||||
|
*
|
||||||
|
* @param options 发布确认参数
|
||||||
|
* @returns 用户取消时返回 null,否则返回提交参数
|
||||||
|
*/
|
||||||
|
export async function confirmPublishSubmission(
|
||||||
|
options: ConfirmPublishOptions,
|
||||||
|
): Promise<null | PublishConfirmation> {
|
||||||
|
const response = await options.api.get(
|
||||||
|
`${options.resourcePath}/publishApprovalRequirement`,
|
||||||
|
{
|
||||||
|
params: { id: options.id },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (!response.data) {
|
||||||
|
await ElMessageBox.confirm(options.confirmMessage, options.title, {
|
||||||
|
cancelButtonText: $t('button.cancel'),
|
||||||
|
confirmButtonText: $t('button.confirm'),
|
||||||
|
type: 'info',
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
$t('approval.message.applicationReasonPrompt'),
|
||||||
|
options.title,
|
||||||
|
{
|
||||||
|
cancelButtonText: $t('button.cancel'),
|
||||||
|
confirmButtonText: $t('approval.action.submit'),
|
||||||
|
inputPlaceholder: $t('approval.placeholder.applicationReason'),
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputValidator: (input: string) => {
|
||||||
|
const normalized = String(input || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return $t('approval.message.applicationReasonRequired');
|
||||||
|
}
|
||||||
|
if (normalized.length > 500) {
|
||||||
|
return $t('approval.message.applicationReasonTooLong');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
type: 'info',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
applicationReason: String(value || '').trim(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,18 +14,20 @@ import {getOptions, sortNodes} from '@easyflow/utils';
|
|||||||
import {Tinyflow} from '@tinyflow-ai/vue';
|
import {Tinyflow} from '@tinyflow-ai/vue';
|
||||||
|
|
||||||
import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-vue';
|
import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-vue';
|
||||||
import {ElButton, ElDrawer, ElMessage, ElMessageBox, ElSkeleton,} from 'element-plus';
|
import {ElButton, ElDrawer, ElMessage, ElSkeleton,} from 'element-plus';
|
||||||
|
|
||||||
import {api} from '#/api/request';
|
import {api} from '#/api/request';
|
||||||
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
||||||
import {$t} from '#/locales';
|
import {$t} from '#/locales';
|
||||||
import {router} from '#/router';
|
import {router} from '#/router';
|
||||||
|
import { resolveWorkflowShareWorkflowId } from '#/utils/workflow-share-context';
|
||||||
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
|
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||||
import {
|
import {
|
||||||
canAiResourceRepublish,
|
canAiResourceRepublish,
|
||||||
isAiResourceApprovalPending,
|
isAiResourceApprovalPending,
|
||||||
resolveAiResourceDisplayStatus,
|
resolveAiResourceDisplayStatus,
|
||||||
} from '#/views/ai/shared/publish-status';
|
} from '#/views/ai/shared/publish-status';
|
||||||
|
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||||
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
|
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
|
||||||
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
|
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
|
||||||
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
||||||
@@ -52,6 +54,10 @@ const { isDark } = usePreferences();
|
|||||||
// vue
|
// vue
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('keydown', handleKeydown);
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
await resolveSharedWorkflowId();
|
||||||
|
if (!workflowId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadCustomNode(),
|
loadCustomNode(),
|
||||||
getLlmList(),
|
getLlmList(),
|
||||||
@@ -88,6 +94,21 @@ const codeEngineList = ref<any[]>([
|
|||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
async function resolveSharedWorkflowId() {
|
||||||
|
workflowId.value = await resolveWorkflowShareWorkflowId({
|
||||||
|
currentWorkflowId: workflowId.value,
|
||||||
|
shareKey: route.query.shareKey,
|
||||||
|
resolve: async () => {
|
||||||
|
const res = await api.get('/api/v1/workflowShare/resolve');
|
||||||
|
return res.data?.workflowId;
|
||||||
|
},
|
||||||
|
onFailure: async () => {
|
||||||
|
ElMessage.error($t('aiWorkflow.shareExpired'));
|
||||||
|
await router.replace({ path: '/ai/workflow' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
|
const WORKFLOW_DRAFT_WRITE_DELAY = 320;
|
||||||
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
|
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let pendingDraftContent: any = null;
|
let pendingDraftContent: any = null;
|
||||||
@@ -372,8 +393,11 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
|||||||
const res = await api.post('/api/v1/workflow/update', {
|
const res = await api.post('/api/v1/workflow/update', {
|
||||||
id: workflowId.value,
|
id: workflowId.value,
|
||||||
content,
|
content,
|
||||||
|
revision: workflowInfo.value?.revision ?? 0,
|
||||||
});
|
});
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
|
workflowInfo.value.revision =
|
||||||
|
res.data?.revision ?? (workflowInfo.value?.revision ?? 0) + 1;
|
||||||
reconcileWorkflowDraftAfterSave(savedContentSignature);
|
reconcileWorkflowDraftAfterSave(savedContentSignature);
|
||||||
if (showMsg) {
|
if (showMsg) {
|
||||||
ElMessage.success(res.message);
|
ElMessage.success(res.message);
|
||||||
@@ -640,22 +664,19 @@ async function handlePublishAction() {
|
|||||||
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
|
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const confirmation = await confirmPublishSubmission({
|
||||||
await ElMessageBox.confirm(
|
api,
|
||||||
canAiResourceRepublish(
|
confirmMessage: canAiResourceRepublish(
|
||||||
workflowInfo.value?.displayPublishStatus,
|
workflowInfo.value?.displayPublishStatus,
|
||||||
workflowInfo.value?.publishStatus,
|
workflowInfo.value?.publishStatus,
|
||||||
)
|
)
|
||||||
? $t('aiWorkflow.submitRepublishApprovalConfirm')
|
? $t('aiWorkflow.submitRepublishApprovalConfirm')
|
||||||
: $t('aiWorkflow.submitPublishApprovalConfirm'),
|
: $t('aiWorkflow.submitPublishApprovalConfirm'),
|
||||||
$t('message.noticeTitle'),
|
id: String(workflowId.value),
|
||||||
{
|
resourcePath: '/api/v1/workflow',
|
||||||
confirmButtonText: $t('button.confirm'),
|
title: $t('message.noticeTitle'),
|
||||||
cancelButtonText: $t('button.cancel'),
|
});
|
||||||
type: 'info',
|
if (!confirmation) {
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const saved = await handleSave();
|
const saved = await handleSave();
|
||||||
@@ -665,6 +686,7 @@ async function handlePublishAction() {
|
|||||||
publishLoading.value = true;
|
publishLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
|
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
|
||||||
|
applicationReason: confirmation.applicationReason,
|
||||||
id: workflowId.value,
|
id: workflowId.value,
|
||||||
});
|
});
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
OfficeBuilding,
|
OfficeBuilding,
|
||||||
Plus,
|
Plus,
|
||||||
Promotion,
|
Promotion,
|
||||||
|
Share,
|
||||||
Tickets,
|
Tickets,
|
||||||
Upload,
|
Upload,
|
||||||
VideoPlay,
|
VideoPlay,
|
||||||
@@ -60,7 +61,9 @@ import PageSide from '#/components/page/PageSide.vue';
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { router } from '#/router';
|
import { router } from '#/router';
|
||||||
import { useDictStore } from '#/store';
|
import { useDictStore } from '#/store';
|
||||||
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||||
|
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||||
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
|
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
|
||||||
import {
|
import {
|
||||||
canAiResourceDelete,
|
canAiResourceDelete,
|
||||||
@@ -117,6 +120,7 @@ const canManageWorkflow = computed(() =>
|
|||||||
hasAccessByCodes(['/api/v1/workflow/save']),
|
hasAccessByCodes(['/api/v1/workflow/save']),
|
||||||
);
|
);
|
||||||
const updatingScopeId = ref<null | number | string>(null);
|
const updatingScopeId = ref<null | number | string>(null);
|
||||||
|
const sharingWorkflowId = ref<null | number | string>(null);
|
||||||
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
|
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
|
||||||
const apiInstructionVisible = ref(false);
|
const apiInstructionVisible = ref(false);
|
||||||
const apiInstructionRow = ref<any>(null);
|
const apiInstructionRow = ref<any>(null);
|
||||||
@@ -192,6 +196,17 @@ const actions: ActionButton[] = [
|
|||||||
showApiInstruction(row);
|
showApiInstruction(row);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Share,
|
||||||
|
text: $t('button.share'),
|
||||||
|
permission: '/api/v1/workflow/save',
|
||||||
|
placement: 'menu',
|
||||||
|
disabled: (row: any) => sharingWorkflowId.value === row.id,
|
||||||
|
loading: (row: any) => sharingWorkflowId.value === row.id,
|
||||||
|
onClick: (row: any) => {
|
||||||
|
shareWorkflow(row);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Download,
|
icon: Download,
|
||||||
text: $t('button.export'),
|
text: $t('button.export'),
|
||||||
@@ -479,12 +494,11 @@ function buildResumeRequestExample() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
async function copyApiContent(content: string) {
|
async function copyApiContent(content: string) {
|
||||||
try {
|
await copyTextWithFeedback(
|
||||||
await navigator.clipboard.writeText(content);
|
content,
|
||||||
ElMessage.success($t('message.copySuccess'));
|
$t('message.copySuccess'),
|
||||||
} catch {
|
$t('message.copyFail'),
|
||||||
ElMessage.error($t('message.copyFail'));
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function handleApiDocClick(e: MouseEvent) {
|
function handleApiDocClick(e: MouseEvent) {
|
||||||
const target = (e.target as HTMLElement).closest('.api-url-copy-btn');
|
const target = (e.target as HTMLElement).closest('.api-url-copy-btn');
|
||||||
@@ -665,22 +679,20 @@ async function submitPublishAction(row: any) {
|
|||||||
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
|
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const confirmation = await confirmPublishSubmission({
|
||||||
await ElMessageBox.confirm(
|
api,
|
||||||
isRepublishAction(row)
|
confirmMessage: isRepublishAction(row)
|
||||||
? $t('aiWorkflow.submitRepublishApprovalConfirm')
|
? $t('aiWorkflow.submitRepublishApprovalConfirm')
|
||||||
: $t('aiWorkflow.submitPublishApprovalConfirm'),
|
: $t('aiWorkflow.submitPublishApprovalConfirm'),
|
||||||
$t('message.noticeTitle'),
|
id: row.id,
|
||||||
{
|
resourcePath: '/api/v1/workflow',
|
||||||
confirmButtonText: $t('button.confirm'),
|
title: $t('message.noticeTitle'),
|
||||||
cancelButtonText: $t('button.cancel'),
|
});
|
||||||
type: 'info',
|
if (!confirmation) {
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
|
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
|
||||||
|
applicationReason: confirmation.applicationReason,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
});
|
});
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
@@ -838,6 +850,36 @@ function toDesignPage(row: any) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async function shareWorkflow(row: any) {
|
||||||
|
if (!row?.id || sharingWorkflowId.value === row.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sharingWorkflowId.value = row.id;
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/workflowShare/url/create', {
|
||||||
|
workflowId: row.id,
|
||||||
|
});
|
||||||
|
if (res.errorCode !== 0 || !res.data?.shareKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const routeLocation = router.resolve({
|
||||||
|
name: 'WorkflowDesign',
|
||||||
|
query: {
|
||||||
|
shareKey: res.data.shareKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shareUrl =
|
||||||
|
res.data.shareUrl ||
|
||||||
|
new URL(routeLocation.href, window.location.origin).toString();
|
||||||
|
await copyTextWithFeedback(
|
||||||
|
shareUrl,
|
||||||
|
$t('message.copySuccess'),
|
||||||
|
$t('message.copyFail'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
sharingWorkflowId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
function exportJson(row: any) {
|
function exportJson(row: any) {
|
||||||
api
|
api
|
||||||
.get('/api/v1/workflow/exportWorkFlow', {
|
.get('/api/v1/workflow/exportWorkFlow', {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
readWorkflowShareKey,
|
||||||
|
resolveWorkflowShareWorkflowId,
|
||||||
|
withWorkflowShareHeader,
|
||||||
|
WORKFLOW_SHARE_HEADER,
|
||||||
|
} from '#/utils/workflow-share-context';
|
||||||
|
|
||||||
|
describe('workflow share context', () => {
|
||||||
|
it('reads the share key from a history-mode URL', () => {
|
||||||
|
expect(
|
||||||
|
readWorkflowShareKey(
|
||||||
|
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
|
||||||
|
),
|
||||||
|
).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the share key from a hash-mode URL', () => {
|
||||||
|
expect(
|
||||||
|
readWorkflowShareKey(
|
||||||
|
'https://example.test/#/ai/workflow/design?id=1&shareKey=hash-key',
|
||||||
|
),
|
||||||
|
).toBe('hash-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the workflow share header without dropping existing headers', () => {
|
||||||
|
expect(
|
||||||
|
withWorkflowShareHeader(
|
||||||
|
{ 'Accept-Language': 'zh-CN' },
|
||||||
|
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
'Accept-Language': 'zh-CN',
|
||||||
|
[WORKFLOW_SHARE_HEADER]: 'abc123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves headers unchanged outside a shared URL', () => {
|
||||||
|
const headers = { 'Accept-Language': 'zh-CN' };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
withWorkflowShareHeader(
|
||||||
|
headers,
|
||||||
|
'https://example.test/ai/workflow/design?id=1',
|
||||||
|
),
|
||||||
|
).toEqual(headers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the workflow id for a shared URL', async () => {
|
||||||
|
const resolve = vi.fn().mockResolvedValue('workflow-1');
|
||||||
|
const onFailure = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveWorkflowShareWorkflowId({
|
||||||
|
currentWorkflowId: undefined,
|
||||||
|
onFailure,
|
||||||
|
resolve,
|
||||||
|
shareKey: 'share-key',
|
||||||
|
}),
|
||||||
|
).resolves.toBe('workflow-1');
|
||||||
|
expect(resolve).toHaveBeenCalledOnce();
|
||||||
|
expect(onFailure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an expired share without leaking the resolve error', async () => {
|
||||||
|
const resolveError = new Error('expired');
|
||||||
|
const resolve = vi.fn().mockRejectedValue(resolveError);
|
||||||
|
const onFailure = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveWorkflowShareWorkflowId({
|
||||||
|
currentWorkflowId: undefined,
|
||||||
|
onFailure,
|
||||||
|
resolve,
|
||||||
|
shareKey: 'expired-key',
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(onFailure).toHaveBeenCalledWith(resolveError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const detail = ref<any>(null);
|
const detail = ref<any>(null);
|
||||||
|
const approvalActionLoading = ref<'approve' | 'reject' | 'revoke' | null>(null);
|
||||||
|
|
||||||
const resourceLabelMap: Record<string, string> = {
|
const resourceLabelMap: Record<string, string> = {
|
||||||
BOT: $t('approval.resource.bot'),
|
BOT: $t('approval.resource.bot'),
|
||||||
@@ -96,28 +97,45 @@ async function loadDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitApprovalAction(action: 'approve' | 'reject' | 'revoke') {
|
async function submitApprovalAction(action: 'approve' | 'reject' | 'revoke') {
|
||||||
|
if (approvalActionLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
approvalActionLoading.value = action;
|
||||||
const titleMap = {
|
const titleMap = {
|
||||||
approve: $t('approval.action.approve'),
|
approve: $t('approval.action.approve'),
|
||||||
reject: $t('approval.action.reject'),
|
reject: $t('approval.action.reject'),
|
||||||
revoke: $t('approval.action.revoke'),
|
revoke: $t('approval.action.revoke'),
|
||||||
};
|
};
|
||||||
const { value } = await ElMessageBox.prompt(
|
try {
|
||||||
$t('approval.placeholder.actionComment'),
|
let value = '';
|
||||||
titleMap[action],
|
try {
|
||||||
{
|
const promptResult = await ElMessageBox.prompt(
|
||||||
inputValue: '',
|
$t('approval.placeholder.actionComment'),
|
||||||
inputType: 'textarea',
|
titleMap[action],
|
||||||
},
|
{
|
||||||
);
|
inputValue: '',
|
||||||
const res = await api.post(`/api/v1/approvalInstance/${action}`, {
|
inputType: 'textarea',
|
||||||
comment: value || '',
|
},
|
||||||
instanceId: detail.value?.id,
|
);
|
||||||
});
|
value = promptResult.value || '';
|
||||||
if (res.errorCode !== 0) {
|
} catch (error) {
|
||||||
return;
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const res = await api.post(`/api/v1/approvalInstance/${action}`, {
|
||||||
|
comment: value,
|
||||||
|
instanceId: detail.value?.id,
|
||||||
|
});
|
||||||
|
if (res.errorCode !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.success($t('approval.message.actionSuccess'));
|
||||||
|
await loadDetail();
|
||||||
|
} finally {
|
||||||
|
approvalActionLoading.value = null;
|
||||||
}
|
}
|
||||||
ElMessage.success($t('approval.message.actionSuccess'));
|
|
||||||
await loadDetail();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusType(status: string) {
|
function getStatusType(status: string) {
|
||||||
@@ -167,6 +185,10 @@ function formatOperatorName(name?: null | string) {
|
|||||||
return name || '-';
|
return name || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatApplicationReason(value?: null | string) {
|
||||||
|
return String(value || '').trim() || '-';
|
||||||
|
}
|
||||||
|
|
||||||
function formatAssigneeDisplay(row: Record<string, any>) {
|
function formatAssigneeDisplay(row: Record<string, any>) {
|
||||||
if (!row?.assigneeType || !row?.assigneeTargetName) {
|
if (!row?.assigneeType || !row?.assigneeTargetName) {
|
||||||
return '-';
|
return '-';
|
||||||
@@ -238,6 +260,8 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
<ElButton
|
<ElButton
|
||||||
v-if="detail?.canApprove"
|
v-if="detail?.canApprove"
|
||||||
type="success"
|
type="success"
|
||||||
|
:disabled="Boolean(approvalActionLoading)"
|
||||||
|
:loading="approvalActionLoading === 'approve'"
|
||||||
@click="submitApprovalAction('approve')"
|
@click="submitApprovalAction('approve')"
|
||||||
>
|
>
|
||||||
{{ $t('approval.action.approve') }}
|
{{ $t('approval.action.approve') }}
|
||||||
@@ -245,12 +269,17 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
<ElButton
|
<ElButton
|
||||||
v-if="detail?.canReject"
|
v-if="detail?.canReject"
|
||||||
type="danger"
|
type="danger"
|
||||||
|
:disabled="Boolean(approvalActionLoading)"
|
||||||
|
:loading="approvalActionLoading === 'reject'"
|
||||||
@click="submitApprovalAction('reject')"
|
@click="submitApprovalAction('reject')"
|
||||||
>
|
>
|
||||||
{{ $t('approval.action.reject') }}
|
{{ $t('approval.action.reject') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="detail?.canRevoke"
|
v-if="detail?.canRevoke"
|
||||||
|
type="warning"
|
||||||
|
:disabled="Boolean(approvalActionLoading)"
|
||||||
|
:loading="approvalActionLoading === 'revoke'"
|
||||||
@click="submitApprovalAction('revoke')"
|
@click="submitApprovalAction('revoke')"
|
||||||
>
|
>
|
||||||
{{ $t('approval.action.revoke') }}
|
{{ $t('approval.action.revoke') }}
|
||||||
@@ -270,6 +299,9 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
<ElDescriptionsItem :label="$t('approval.fields.summary')">
|
<ElDescriptionsItem :label="$t('approval.fields.summary')">
|
||||||
{{ detail.summary || '-' }}
|
{{ detail.summary || '-' }}
|
||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('approval.fields.applicationReason')">
|
||||||
|
{{ detail.applicationReason || '-' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
<ElDescriptionsItem :label="$t('approval.fields.currentStep')">
|
<ElDescriptionsItem :label="$t('approval.fields.currentStep')">
|
||||||
{{ detail.currentStepNo || '-' }}
|
{{ detail.currentStepNo || '-' }}
|
||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
@@ -319,6 +351,14 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
:label="$t('approval.fields.stepName')"
|
:label="$t('approval.fields.stepName')"
|
||||||
min-width="180"
|
min-width="180"
|
||||||
/>
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('approval.fields.applicationReason')"
|
||||||
|
min-width="220"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatApplicationReason(row.applicationReason) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn :label="$t('approval.fields.status')" width="120">
|
<ElTableColumn :label="$t('approval.fields.status')" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<ElTag :type="getStatusType(row.status)">
|
<ElTag :type="getStatusType(row.status)">
|
||||||
@@ -390,6 +430,14 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('approval.fields.applicationReason')"
|
||||||
|
min-width="220"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatApplicationReason(row.applicationReason) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
</ElTable>
|
</ElTable>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,24 @@ import { useAuthStore } from '#/store';
|
|||||||
import { refreshTokenApi } from './core';
|
import { refreshTokenApi } from './core';
|
||||||
|
|
||||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
|
const ERROR_MESSAGE_DEDUP_WINDOW = 800;
|
||||||
|
let lastErrorMessage = '';
|
||||||
|
let lastErrorTimestamp = 0;
|
||||||
|
|
||||||
|
function showErrorOnce(message?: string) {
|
||||||
|
const nextMessage = String(message || '').trim();
|
||||||
|
if (!nextMessage) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
nextMessage === lastErrorMessage &&
|
||||||
|
now - lastErrorTimestamp < ERROR_MESSAGE_DEDUP_WINDOW
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastErrorMessage = nextMessage;
|
||||||
|
lastErrorTimestamp = now;
|
||||||
|
ElMessage.error(nextMessage);
|
||||||
|
}
|
||||||
|
|
||||||
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||||
const client = new RequestClient({
|
const client = new RequestClient({
|
||||||
@@ -80,7 +98,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
codeField: 'errorCode',
|
codeField: 'errorCode',
|
||||||
dataField: 'data',
|
dataField: 'data',
|
||||||
showErrorMessage: (message) => {
|
showErrorMessage: (message) => {
|
||||||
ElMessage.error(message);
|
showErrorOnce(message);
|
||||||
},
|
},
|
||||||
successCode: 0,
|
successCode: 0,
|
||||||
}),
|
}),
|
||||||
@@ -105,7 +123,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
const responseData = error?.response?.data ?? {};
|
const responseData = error?.response?.data ?? {};
|
||||||
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
||||||
// 如果没有错误信息,则会根据状态码进行提示
|
// 如果没有错误信息,则会根据状态码进行提示
|
||||||
ElMessage.error(errorMessage || msg);
|
showErrorOnce(errorMessage || msg);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user