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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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