feat: 完成分享、单会话与发布审批改造

- 增加工作流协作分享与知识库卡片分享入口,统一低版本浏览器复制反馈

- Web 新登录替换旧会话,并保持 API Key 会话隔离

- 发布审批增加必填说明并在审批详情展示

- 账号重置与导入改用可配置默认强密码
This commit is contained in:
2026-07-23 16:09:31 +08:00
parent caa1f07b66
commit 5a42826d44
71 changed files with 3191 additions and 132 deletions

View File

@@ -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;
}
}

View File

@@ -63,6 +63,12 @@ public class WorkflowBase extends DateEntity implements Serializable {
@Column(comment = "工作流设计的 JSON 内容")
private String content;
/**
* 工作流内容修订号
*/
@Column(comment = "工作流内容修订号")
private Integer revision;
/**
* 创建时间
*/
@@ -205,6 +211,24 @@ public class WorkflowBase extends DateEntity implements Serializable {
this.content = content;
}
/**
* 获取工作流内容修订号。
*
* @return 工作流内容修订号
*/
public Integer getRevision() {
return revision;
}
/**
* 设置工作流内容修订号。
*
* @param revision 工作流内容修订号
*/
public void setRevision(Integer revision) {
this.revision = revision;
}
public Date getCreated() {
return created;
}

View File

@@ -1,7 +1,12 @@
package tech.easyflow.ai.mapper;
import tech.easyflow.ai.entity.Workflow;
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> {
/**
* 按预期修订号原子更新工作流内容。
*
* @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
);
}

View File

@@ -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> {
}

View File

@@ -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();
}
}

View File

@@ -19,4 +19,38 @@ public interface AiResourceLifecycleService {
* @return 执行结果
*/
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
);
}

View File

@@ -11,6 +11,7 @@ import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalMatchService;
import tech.easyflow.approval.service.ApprovalResultHandler;
import tech.easyflow.approval.support.ApprovalApplicationReasonPolicy;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
@@ -44,6 +45,21 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic
@Override
@Transactional(rollbackFor = Exception.class)
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);
ApprovalSubmitRequest request = handler.buildSubmitRequest(resourceId, actionType, operatorId);
ApprovalFlowDetailVo flow = approvalMatchService.matchFlowOrNull(request);
@@ -51,6 +67,11 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic
handler.applyApprovedAction(actionType, resourceId, readResourceSnapshot(request.getSnapshotJson()), operatorId);
return ApprovalActionResult.direct();
}
request.setApplicationReason(ApprovalApplicationReasonPolicy.normalize(
true,
actionType,
applicationReason
));
BigInteger instanceId = approvalInstanceService.submitApproval(request);
handler.updatePendingState(
resourceId,
@@ -60,6 +81,21 @@ public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleServic
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}
*/

View File

@@ -25,11 +25,29 @@ public class BotPublishAppService {
* 提交聊天助手发布审批。
*
* @param id 助手 ID
* @param applicationReason 审批说明
* @return 动作执行结果
*/
public ApprovalActionResult submitPublishApproval(BigInteger id) {
public ApprovalActionResult submitPublishApproval(BigInteger id, String applicationReason) {
assertId(id);
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(),
id,
ApprovalActionType.PUBLISH.getCode(),

View File

@@ -36,11 +36,29 @@ public class KnowledgePublishAppService {
* 提交知识库发布审批。
*
* @param id 知识库 ID
* @param applicationReason 审批说明
* @return 动作执行结果
*/
public ApprovalActionResult submitPublishApproval(BigInteger id) {
public ApprovalActionResult submitPublishApproval(BigInteger id, String applicationReason) {
assertId(id);
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(),
id,
ApprovalActionType.PUBLISH.getCode(),

View File

@@ -36,11 +36,29 @@ public class WorkflowPublishAppService {
* 提交工作流发布审批。
*
* @param id 工作流 ID
* @param applicationReason 审批说明
* @return 动作执行结果
*/
public ApprovalActionResult submitPublishApproval(BigInteger id) {
public ApprovalActionResult submitPublishApproval(BigInteger id, String applicationReason) {
assertId(id, ApprovalResourceType.WORKFLOW.getCode(), ApprovalActionType.PUBLISH.getCode());
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(),
id,
ApprovalActionType.PUBLISH.getCode(),

View File

@@ -4,6 +4,7 @@ import tech.easyflow.ai.entity.Workflow;
import com.mybatisflex.core.service.IService;
import java.math.BigInteger;
import java.util.Date;
/**
* 服务层。
@@ -44,4 +45,22 @@ public interface WorkflowService extends IService<Workflow> {
* @return 已发布视图
*/
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
);
}

View File

@@ -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);
}

View File

@@ -13,6 +13,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.ai.utils.CustomBeanUtils;
import java.math.BigInteger;
import java.util.Date;
import java.util.Map;
/**
@@ -115,7 +116,11 @@ public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> i
throw new BusinessException("工作流不存在");
}
int nextRevision = workFlow.getRevision() == null ? 1 : workFlow.getRevision() + 1;
CustomBeanUtils.copyPropertiesIgnoreNull(entity,workFlow);
if (entity.getContent() != null) {
workFlow.setRevision(nextRevision);
}
if ("".equals(workFlow.getAlias())){
workFlow.setAlias(null);
@@ -125,5 +130,24 @@ public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> i
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;
}
}

View File

@@ -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, "工作流分享链接无效");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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 工作流分享与审批说明迁移");
}
}

View File

@@ -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
));
}
}