feat: 完成分享、单会话与发布审批改造
- 增加工作流协作分享与知识库卡片分享入口,统一低版本浏览器复制反馈 - Web 新登录替换旧会话,并保持 API Key 会话隔离 - 发布审批增加必填说明并在审批详情展示 - 账号重置与导入改用可配置默认强密码
This commit is contained in:
@@ -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 内容")
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 执行结果
|
||||
*/
|
||||
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.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}
|
||||
*/
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<artifactId>easyflow-module-system</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -47,6 +47,12 @@ public class ApprovalInstanceBase implements Serializable {
|
||||
@Column(comment = "审批摘要")
|
||||
private String summary;
|
||||
|
||||
/**
|
||||
* 发起人填写的审批说明。
|
||||
*/
|
||||
@Column(comment = "审批说明")
|
||||
private String applicationReason;
|
||||
|
||||
@Column(comment = "申请人ID")
|
||||
private BigInteger applicantId;
|
||||
|
||||
@@ -148,6 +154,14 @@ public class ApprovalInstanceBase implements Serializable {
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
public String getApplicationReason() {
|
||||
return applicationReason;
|
||||
}
|
||||
|
||||
public void setApplicationReason(String applicationReason) {
|
||||
this.applicationReason = applicationReason;
|
||||
}
|
||||
|
||||
public BigInteger getApplicantId() {
|
||||
return applicantId;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ public class ApprovalInstanceDetailVo {
|
||||
|
||||
private String summary;
|
||||
|
||||
/**
|
||||
* 发起人填写的审批说明。
|
||||
*/
|
||||
private String applicationReason;
|
||||
|
||||
private BigInteger applicantId;
|
||||
|
||||
private String applicantName;
|
||||
@@ -123,6 +128,14 @@ public class ApprovalInstanceDetailVo {
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
public String getApplicationReason() {
|
||||
return applicationReason;
|
||||
}
|
||||
|
||||
public void setApplicationReason(String applicationReason) {
|
||||
this.applicationReason = applicationReason;
|
||||
}
|
||||
|
||||
public BigInteger getApplicantId() {
|
||||
return applicantId;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ public class ApprovalInstancePageVo {
|
||||
|
||||
private String summary;
|
||||
|
||||
/**
|
||||
* 发起审批时填写的说明。
|
||||
*/
|
||||
private String applicationReason;
|
||||
|
||||
private BigInteger applicantId;
|
||||
|
||||
private Date submittedAt;
|
||||
@@ -100,6 +105,24 @@ public class ApprovalInstancePageVo {
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取发起审批时填写的说明。
|
||||
*
|
||||
* @return 审批说明
|
||||
*/
|
||||
public String getApplicationReason() {
|
||||
return applicationReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置发起审批时填写的说明。
|
||||
*
|
||||
* @param applicationReason 审批说明
|
||||
*/
|
||||
public void setApplicationReason(String applicationReason) {
|
||||
this.applicationReason = applicationReason;
|
||||
}
|
||||
|
||||
public BigInteger getApplicantId() {
|
||||
return applicantId;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ public class ApprovalLogVo {
|
||||
|
||||
private String eventType;
|
||||
|
||||
/**
|
||||
* 提交审批事件对应的申请说明。
|
||||
*/
|
||||
private String applicationReason;
|
||||
|
||||
private BigInteger operatorId;
|
||||
|
||||
private String operatorAccount;
|
||||
@@ -39,6 +44,24 @@ public class ApprovalLogVo {
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提交审批事件对应的申请说明。
|
||||
*
|
||||
* @return 审批说明,非提交事件时为 {@code null}
|
||||
*/
|
||||
public String getApplicationReason() {
|
||||
return applicationReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置提交审批事件对应的申请说明。
|
||||
*
|
||||
* @param applicationReason 审批说明
|
||||
*/
|
||||
public void setApplicationReason(String applicationReason) {
|
||||
this.applicationReason = applicationReason;
|
||||
}
|
||||
|
||||
public BigInteger getOperatorId() {
|
||||
return operatorId;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ public class ApprovalSubmitRequest {
|
||||
|
||||
private String summary;
|
||||
|
||||
/**
|
||||
* 发起人填写的审批说明。
|
||||
*/
|
||||
private String applicationReason;
|
||||
|
||||
private Map<String, Object> snapshotJson;
|
||||
|
||||
public String getResourceType() {
|
||||
@@ -80,6 +85,24 @@ public class ApprovalSubmitRequest {
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批说明。
|
||||
*
|
||||
* @return 审批说明
|
||||
*/
|
||||
public String getApplicationReason() {
|
||||
return applicationReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置审批说明。
|
||||
*
|
||||
* @param applicationReason 审批说明
|
||||
*/
|
||||
public void setApplicationReason(String applicationReason) {
|
||||
this.applicationReason = applicationReason;
|
||||
}
|
||||
|
||||
public Map<String, Object> getSnapshotJson() {
|
||||
return snapshotJson;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ public class ApprovalTaskVo {
|
||||
|
||||
private String stepName;
|
||||
|
||||
/**
|
||||
* 当前审批实例的申请说明。
|
||||
*/
|
||||
private String applicationReason;
|
||||
|
||||
private String status;
|
||||
|
||||
private String assigneeRoleCode;
|
||||
@@ -58,6 +63,24 @@ public class ApprovalTaskVo {
|
||||
this.stepName = stepName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前审批实例的申请说明。
|
||||
*
|
||||
* @return 审批说明
|
||||
*/
|
||||
public String getApplicationReason() {
|
||||
return applicationReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前审批实例的申请说明。
|
||||
*
|
||||
* @param applicationReason 审批说明
|
||||
*/
|
||||
public void setApplicationReason(String applicationReason) {
|
||||
this.applicationReason = applicationReason;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
instance.setCurrentStepNo(firstStep.getStepNo());
|
||||
instance.setSnapshotJson(buildInstanceSnapshot(request, flow, steps));
|
||||
instance.setSummary(request.getSummary());
|
||||
instance.setApplicationReason(request.getApplicationReason());
|
||||
instance.setApplicantId(request.getApplicantId());
|
||||
instance.setSubmittedAt(now);
|
||||
instance.setCreated(now);
|
||||
@@ -102,11 +103,15 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
approvalInstanceMapper.insert(instance);
|
||||
|
||||
createTask(instance.getId(), firstStep, request.getApplicantId(), now);
|
||||
appendLog(instance.getId(), ApprovalEventType.SUBMITTED.getCode(), request.getApplicantId(), Map.of(
|
||||
"flowId", flow.getId(),
|
||||
"flowVersion", flow.getVersion(),
|
||||
"summary", request.getSummary()
|
||||
), now);
|
||||
Map<String, Object> submittedPayload = new LinkedHashMap<>();
|
||||
submittedPayload.put("flowId", flow.getId());
|
||||
submittedPayload.put("flowVersion", flow.getVersion());
|
||||
submittedPayload.put("summary", request.getSummary());
|
||||
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(),
|
||||
buildStepCreatedPayload(firstStep), now);
|
||||
return instance.getId();
|
||||
@@ -228,6 +233,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
|
||||
snapshot.put("deptId", request.getDeptId());
|
||||
snapshot.put("flowId", flow.getId());
|
||||
snapshot.put("flowVersion", flow.getVersion());
|
||||
if (request.getApplicationReason() != null) {
|
||||
snapshot.put("applicationReason", request.getApplicationReason());
|
||||
}
|
||||
snapshot.put("steps", steps.stream().map(item -> {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
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.ApprovalTaskVo;
|
||||
import tech.easyflow.approval.enums.ApprovalActionType;
|
||||
import tech.easyflow.approval.enums.ApprovalEventType;
|
||||
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
|
||||
import tech.easyflow.approval.enums.ApprovalResourceType;
|
||||
import tech.easyflow.approval.enums.ApprovalTaskStatus;
|
||||
@@ -144,6 +145,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
detail.setStatus(instance.getStatus());
|
||||
detail.setCurrentStepNo(instance.getCurrentStepNo());
|
||||
detail.setSummary(instance.getSummary());
|
||||
detail.setApplicationReason(instance.getApplicationReason());
|
||||
detail.setApplicantId(instance.getApplicantId());
|
||||
detail.setSubmittedAt(instance.getSubmittedAt());
|
||||
detail.setFinishedAt(instance.getFinishedAt());
|
||||
@@ -165,6 +167,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
taskVo.setId(item.getId());
|
||||
taskVo.setStepNo(item.getStepNo());
|
||||
taskVo.setStepName(resolveStepName(frozenStepMap, item.getStepNo()));
|
||||
taskVo.setApplicationReason(instance.getApplicationReason());
|
||||
taskVo.setStatus(item.getStatus());
|
||||
taskVo.setAssigneeRoleCode(item.getAssigneeRoleCode());
|
||||
taskVo.setAssigneeType(item.getAssigneeType());
|
||||
@@ -185,6 +188,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
ApprovalLogVo logVo = new ApprovalLogVo();
|
||||
logVo.setId(item.getId());
|
||||
logVo.setEventType(item.getEventType());
|
||||
if (ApprovalEventType.SUBMITTED.getCode().equals(item.getEventType())) {
|
||||
logVo.setApplicationReason(instance.getApplicationReason());
|
||||
}
|
||||
logVo.setOperatorId(item.getOperatorId());
|
||||
logVo.setOperatorAccount(resolveAccountLoginName(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.setCurrentStepName(resolveCurrentStepName(record));
|
||||
item.setSummary(record.getSummary());
|
||||
item.setApplicationReason(record.getApplicationReason());
|
||||
item.setApplicantId(record.getApplicantId());
|
||||
item.setSubmittedAt(record.getSubmittedAt());
|
||||
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.LoginVO;
|
||||
import tech.easyflow.auth.service.AuthService;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
@@ -26,6 +27,7 @@ import cn.hutool.crypto.digest.BCrypt;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -33,6 +35,10 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
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
|
||||
private SysAccountService sysAccountService;
|
||||
@Resource
|
||||
@@ -41,6 +47,8 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
||||
private SysMenuService sysMenuService;
|
||||
@Resource
|
||||
private SysApiKeyService sysApiKeyService;
|
||||
@Resource
|
||||
private RedisLockExecutor redisLockExecutor;
|
||||
|
||||
@Override
|
||||
public LoginVO login(LoginDTO loginDTO) {
|
||||
@@ -52,7 +60,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
||||
if (!BCrypt.checkpw(pwd, pwdDb)) {
|
||||
throw new BusinessException("用户名/密码错误");
|
||||
}
|
||||
return createLoginVO(record);
|
||||
return createWebLoginVO(record);
|
||||
} finally {
|
||||
TenantManager.restoreTenantCondition();
|
||||
}
|
||||
@@ -63,7 +71,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
||||
try {
|
||||
TenantManager.ignoreTenantCondition();
|
||||
SysAccount record = getAvailableAccount(account, "开发免登账号不存在");
|
||||
return createLoginVO(record);
|
||||
return createWebLoginVO(record);
|
||||
} finally {
|
||||
TenantManager.restoreTenantCondition();
|
||||
}
|
||||
@@ -89,7 +97,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
||||
@Override
|
||||
public LoginVO loginByAccountId(BigInteger accountId, Long timeoutSeconds) {
|
||||
SysAccount record = getAvailableAccount(accountId, "账号不存在或不可用");
|
||||
return createLoginVO(record, timeoutSeconds);
|
||||
return createApiKeyLoginVO(record, timeoutSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -107,18 +115,48 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
||||
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) {
|
||||
SaLoginModel loginModel = new SaLoginModel();
|
||||
loginModel.setTimeout(timeoutSeconds);
|
||||
StpUtil.login(record.getId(), loginModel);
|
||||
} else {
|
||||
StpUtil.login(record.getId());
|
||||
}
|
||||
/**
|
||||
* 创建独立的 API Key 机器会话。
|
||||
*
|
||||
* @param record 登录账号
|
||||
* @param timeoutSeconds 会话有效期秒数
|
||||
* @return 登录结果
|
||||
*/
|
||||
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();
|
||||
BeanUtil.copyProperties(record, 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;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
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.ResourceAction;
|
||||
import tech.easyflow.system.enums.VisibilityScope;
|
||||
import tech.easyflow.system.permission.resource.ResourceAccessGrantProvider;
|
||||
import tech.easyflow.system.permission.resource.VisibilityResource;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
@@ -14,7 +16,12 @@ import tech.easyflow.system.service.SysDeptService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 基于租户、创建者、分类授权和可见范围的统一资源权限实现。
|
||||
*/
|
||||
@Service
|
||||
public class ResourceAccessServiceImpl implements ResourceAccessService {
|
||||
|
||||
@@ -24,11 +31,20 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
|
||||
@Resource
|
||||
private SysDeptService sysDeptService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private List<ResourceAccessGrantProvider> grantProviders = Collections.emptyList();
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
|
||||
return canAccess(SaTokenUtil.getLoginAccount(), resourceType, resource, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
|
||||
if (resource == null) {
|
||||
@@ -38,6 +54,10 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
|
||||
return false;
|
||||
}
|
||||
BigInteger accountId = loginAccount.getId();
|
||||
// 分享访问需要先完成密钥校验与审计,即使当前账号同时也是资源创建者或超管。
|
||||
if (hasExtendedGrant(loginAccount, resourceType, resource, action)) {
|
||||
return true;
|
||||
}
|
||||
if (categoryPermissionService.isSuperAdmin(loginAccount)) {
|
||||
return true;
|
||||
}
|
||||
@@ -60,10 +80,36 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message) {
|
||||
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.util.StringUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.config.AccountSecurityProperties;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.entity.SysAccountPosition;
|
||||
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 Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
||||
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 int MAX_IMPORT_ROWS = 5000;
|
||||
private static final String IMPORT_HEAD_DEPT_NAME = "部门名称*";
|
||||
@@ -110,6 +110,8 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
private RedisLockExecutor redisLockExecutor;
|
||||
@Resource
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Resource
|
||||
private AccountSecurityProperties accountSecurityProperties;
|
||||
|
||||
/**
|
||||
* 批量解析账号展示名称。
|
||||
@@ -238,7 +240,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
validateResetPasswordAllowed(record);
|
||||
SysAccount update = new SysAccount();
|
||||
update.setId(accountId);
|
||||
update.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
||||
update.setPassword(BCrypt.hashpw(accountSecurityProperties.getDefaultResetPassword()));
|
||||
update.setPasswordResetRequired(true);
|
||||
update.setModified(new Date());
|
||||
update.setModifiedBy(operatorId);
|
||||
@@ -421,7 +423,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
entity.setDeptId(dept.getId());
|
||||
entity.setTenantId(loginAccount.getTenantId());
|
||||
entity.setLoginName(loginName);
|
||||
entity.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
||||
entity.setPassword(BCrypt.hashpw(accountSecurityProperties.getDefaultResetPassword()));
|
||||
entity.setPasswordResetRequired(true);
|
||||
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||
entity.setNickname(nickname);
|
||||
@@ -686,7 +688,11 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
||||
rows.add(List.of("可选字段", "手机号、邮箱、状态、角色名称、岗位名称、备注"));
|
||||
rows.add(List.of("状态可选值", "可留空,或填写 1/0/已启用/启用/未启用/停用/禁用"));
|
||||
rows.add(List.of("多值分隔", "角色名称、岗位名称支持使用英文逗号,或中文逗号,分隔多个名称"));
|
||||
rows.add(List.of("导入后初始密码", "导入成功的账号默认密码为 123456,首次登录需要修改密码"));
|
||||
rows.add(List.of(
|
||||
"导入后初始密码",
|
||||
"导入成功的账号默认密码为 " + accountSecurityProperties.getDefaultResetPassword()
|
||||
+ ",首次登录需要修改密码"
|
||||
));
|
||||
rows.add(List.of("示例行", "市场部 | zhangsan | 张三 | 13800138000 | zhangsan@example.com | 已启用 | 普通员工,审批专员 | 产品经理 | 示例导入"));
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user