diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java index 534013a0..046f5606 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java @@ -307,16 +307,38 @@ public class BotController extends BaseCurdController { return Result.ok(data); } + /** + * 提交聊天助手发布审批。 + * + * @param id 助手 ID + * @param applicationReason 审批说明 + * @return 审批实例 ID + */ @PostMapping("/submitPublishApproval") @SaCheckPermission("/api/v1/bot/save") - public Result submitPublishApproval(@JsonBody("id") BigInteger id) { + public Result submitPublishApproval( + @JsonBody("id") BigInteger id, + @JsonBody("applicationReason") String applicationReason + ) { return buildApprovalActionResult( - botPublishAppService.submitPublishApproval(id), + botPublishAppService.submitPublishApproval(id, applicationReason), "已提交发布审批", "已直接发布" ); } + /** + * 预检聊天助手发布是否命中审批流。 + * + * @param id 助手 ID + * @return 是否需要审批 + */ + @GetMapping("/publishApprovalRequirement") + @SaCheckPermission("/api/v1/bot/save") + public Result publishApprovalRequirement(@RequestParam BigInteger id) { + return Result.ok(botPublishAppService.isPublishApprovalRequired(id)); + } + @PostMapping("/submitOfflineApproval") @SaCheckPermission("/api/v1/bot/save") public Result submitOfflineApproval(@JsonBody("id") BigInteger id) { diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentCollectionController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentCollectionController.java index 59dcf159..e4838186 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentCollectionController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentCollectionController.java @@ -203,18 +203,34 @@ public class DocumentCollectionController extends BaseCurdController submitPublishApproval(@JsonBody("id") BigInteger id) { + public Result submitPublishApproval( + @JsonBody("id") BigInteger id, + @JsonBody("applicationReason") String applicationReason + ) { return buildApprovalActionResult( - knowledgePublishAppService.submitPublishApproval(id), + knowledgePublishAppService.submitPublishApproval(id, applicationReason), "已提交发布审批", "已直接发布" ); } + /** + * 预检知识库发布是否命中审批流。 + * + * @param id 知识库 ID + * @return 是否需要审批 + */ + @GetMapping("/publishApprovalRequirement") + @SaCheckPermission("/api/v1/documentCollection/save") + public Result publishApprovalRequirement(@RequestParam BigInteger id) { + return Result.ok(knowledgePublishAppService.isPublishApprovalRequired(id)); + } + /** * 提交下线审批。 * diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java index 62f39697..9e1b942b 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java @@ -8,11 +8,13 @@ import com.mybatisflex.core.paginate.Page; import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.mybatisflex.core.query.QueryWrapper; import org.springframework.util.StringUtils; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; +import tech.easyflow.ai.permission.WorkflowShareResourceAccessGrantProvider; import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; @@ -53,6 +55,7 @@ import java.io.Serializable; import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.Collection; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -94,6 +97,8 @@ public class WorkflowController extends BaseCurdController submitPublishApproval(@JsonBody("id") BigInteger id) { + @RequireResourceAccess( + resource = CategoryResourceType.WORKFLOW, + action = ResourceAction.MANAGE, + lookup = ResourceLookup.WORKFLOW_ID, + idExpr = "#id", + denyMessage = "无权限发布工作流" + ) + public Result submitPublishApproval( + @JsonBody("id") BigInteger id, + @JsonBody("applicationReason") String applicationReason + ) { return buildApprovalActionResult( - workflowPublishAppService.submitPublishApproval(id), + workflowPublishAppService.submitPublishApproval(id, applicationReason), "已提交发布审批", "已直接发布" ); } + /** + * 预检工作流发布是否命中审批流。 + * + * @param id 工作流 ID + * @return 是否需要审批 + */ + @GetMapping("/publishApprovalRequirement") + @SaCheckPermission("/api/v1/workflow/save") + @RequireResourceAccess( + resource = CategoryResourceType.WORKFLOW, + action = ResourceAction.MANAGE, + lookup = ResourceLookup.WORKFLOW_ID, + idExpr = "#id", + denyMessage = "无权限发布工作流" + ) + public Result publishApprovalRequirement(@RequestParam BigInteger id) { + return Result.ok(workflowPublishAppService.isPublishApprovalRequired(id)); + } + /** * 提交下线审批。 * @@ -276,6 +311,13 @@ public class WorkflowController extends BaseCurdController submitOfflineApproval(@JsonBody("id") BigInteger id) { return buildApprovalActionResult( workflowPublishAppService.submitOfflineApproval(id), @@ -311,6 +353,13 @@ public class WorkflowController extends BaseCurdController submitDeleteApproval(@JsonBody("id") BigInteger id) { return buildApprovalActionResult( workflowPublishAppService.submitDeleteApproval(id), @@ -378,14 +427,68 @@ public class WorkflowController extends BaseCurdController update(@JsonBody Workflow entity) { + if (entity == null || entity.getId() == null) { + throw new BusinessException("工作流 ID 不能为空"); + } + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + boolean sharedRequest = workflowShareGrantProvider.isSharedRequestFor( + entity.getId(), + loginAccount + ); + if (sharedRequest && entity.getContent() == null) { + throw new BusinessException(403, 403, "分享链接仅允许编辑工作流内容"); + } + if (entity.getContent() == null) { + return super.update(entity); + } + if (entity.getRevision() == null) { + throw workflowRevisionConflict(); + } + Result beforeResult = onSaveOrUpdateBefore(entity, false); + if (beforeResult != null) { + return beforeResult; + } + boolean updated = service.updateContentByRevision( + entity.getId(), + entity.getContent(), + entity.getRevision(), + new Date(), + loginAccount.getId() + ); + if (!updated) { + throw workflowRevisionConflict(); + } + entity.setRevision(entity.getRevision() + 1); + if (!sharedRequest) { + entity.setContent(null); + service.updateById(entity); + } + onSaveOrUpdateAfter(entity, false); + return Result.ok(Map.of("revision", entity.getRevision())); + } + @Override protected Result onSaveOrUpdateBefore(Workflow entity, boolean isSave) { normalizeVisibilityScope(entity, isSave); + if (isSave && entity.getRevision() == null) { + entity.setRevision(0); + } if (!isSave && entity.getId() != null) { Workflow existed = requireWorkflow(String.valueOf(entity.getId())); resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, existed, ResourceAction.MANAGE, "无权限管理工作流"); @@ -485,6 +588,15 @@ public class WorkflowController extends BaseCurdController createUrlShare( + HttpServletRequest request, + @JsonBody("workflowId") BigInteger workflowId + ) { + Workflow workflow = workflowService.getById(workflowId); + if (workflow == null) { + throw new BusinessException("工作流不存在"); + } + resourceAccessService.assertAccess( + CategoryResourceType.WORKFLOW, + workflow, + ResourceAction.MANAGE, + "无权限分享工作流" + ); + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + WorkflowShareCreateResult result = workflowShareService.createUrlShare( + workflowId, + loginAccount.getTenantId(), + loginAccount.getDeptId(), + loginAccount.getId(), + buildShareBaseUrl(request) + ); + knowledgeShareAuditService.log( + loginAccount.getId(), + "创建工作流协作分享", + "WORKFLOW_SHARE_CREATE", + request.getRequestURI(), + Map.of("workflowId", workflowId, "shareId", result.getId()) + ); + return Result.ok(result); + } + + /** + * 解析当前 URL 分享指向的工作流。 + * + * @param request HTTP 请求 + * @return 工作流标识 + */ + @GetMapping("/resolve") + public Result> resolveUrlShare(HttpServletRequest request) { + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + WorkflowShare share = workflowShareService.resolveUrlShare( + request.getHeader(WorkflowSharePolicy.SHARE_KEY_HEADER), + loginAccount.getTenantId() + ); + return Result.ok(Map.of("workflowId", share.getWorkflowId())); + } + + /** + * 根据管理端来源构建工作流分享基础 URL。 + * + * @param request HTTP 请求 + * @return 工作流设计页 URL + */ + private String buildShareBaseUrl(HttpServletRequest request) { + String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request)); + if (refererBaseUrl != null) { + return refererBaseUrl + "/ai/workflow/design"; + } + + String forwardedOrigin = buildForwardedOrigin(request); + if (forwardedOrigin != null) { + return forwardedOrigin + + normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix"))) + + "/ai/workflow/design"; + } + + String origin = normalizeOrigin(request.getHeader("Origin")); + if (origin != null) { + return origin + normalizeBasePath(request.getContextPath()) + "/ai/workflow/design"; + } + StringBuilder builder = new StringBuilder(); + builder.append(request.getScheme()).append("://").append(request.getServerName()); + if (request.getServerPort() != 80 && request.getServerPort() != 443) { + builder.append(':').append(request.getServerPort()); + } + return builder.append(normalizeBasePath(request.getContextPath())) + .append("/ai/workflow/design") + .toString(); + } + + /** + * 从来源地址提取前端 origin 与部署基路径。 + * + * @param sourceUrl 来源地址 + * @return origin 与部署基路径,无法解析时返回 {@code null} + */ + private String extractFrontendBaseUrl(String sourceUrl) { + if (sourceUrl == null || sourceUrl.isBlank()) { + return null; + } + try { + URI uri = new URI(sourceUrl.trim()); + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + String origin = extractOrigin(sourceUrl); + return origin == null ? null : origin + inferFrontendBasePath(uri.getPath()); + } catch (URISyntaxException e) { + return null; + } + } + + /** + * 从前端页面路径推断部署基路径。 + * + * @param path 页面路径 + * @return 规范化后的部署基路径 + */ + private String inferFrontendBasePath(String path) { + if (path == null || path.isBlank() || "/".equals(path)) { + return ""; + } + for (String marker : new String[]{"/ai/", "/auth/", "/share/"}) { + int markerIndex = path.indexOf(marker); + if (markerIndex > 0) { + return normalizeBasePath(path.substring(0, markerIndex)); + } + if (markerIndex == 0) { + return ""; + } + } + return ""; + } + + /** + * 规范化部署基路径。 + * + * @param basePath 原始基路径 + * @return 无尾斜杠的基路径 + */ + private String normalizeBasePath(String basePath) { + if (basePath == null || basePath.isBlank() || "/".equals(basePath.trim())) { + return ""; + } + String normalized = basePath.trim(); + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + while (normalized.endsWith("/") && normalized.length() > 1) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + /** + * 按反向代理头构建外部访问 origin。 + * + * @param request HTTP 请求 + * @return 外部 origin,缺少代理头时返回 {@code null} + */ + private String buildForwardedOrigin(HttpServletRequest request) { + String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto")); + String host = firstHeaderValue(request.getHeader("X-Forwarded-Host")); + if (proto == null || host == null) { + return null; + } + return normalizeOrigin(proto + "://" + host); + } + + /** + * 从 URL 提取 origin。 + * + * @param url 完整 URL + * @return origin,无法解析时返回 {@code null} + */ + private String extractOrigin(String url) { + if (url == null || url.isBlank()) { + return null; + } + try { + URI uri = new URI(url.trim()); + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + StringBuilder builder = new StringBuilder(); + builder.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() != -1 && uri.getPort() != 80 && uri.getPort() != 443) { + builder.append(':').append(uri.getPort()); + } + return builder.toString(); + } catch (URISyntaxException e) { + return null; + } + } + + /** + * 规范化 origin。 + * + * @param origin 原始 origin + * @return 规范化结果 + */ + private String normalizeOrigin(String origin) { + return extractOrigin(origin); + } + + /** + * 读取代理头的首个有效值。 + * + * @param value 原始请求头 + * @return 首个有效值 + */ + private String firstHeaderValue(String value) { + if (value == null || value.isBlank()) { + return null; + } + int commaIndex = value.indexOf(','); + String normalized = commaIndex >= 0 ? value.substring(0, commaIndex) : value; + normalized = normalized.trim(); + return normalized.isEmpty() ? null : normalized; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/WorkflowShareControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/WorkflowShareControllerTest.java new file mode 100644 index 00000000..c3dc5077 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/ai/WorkflowShareControllerTest.java @@ -0,0 +1,123 @@ +package tech.easyflow.admin.controller.ai; + +import jakarta.servlet.http.HttpServletRequest; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Locale; +import java.util.Map; + +/** + * {@link WorkflowShareController} 分享地址构建测试。 + */ +public class WorkflowShareControllerTest { + + /** + * 验证分享地址保留前端部署基路径。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void shouldPreserveFrontendBasePathFromReferer() throws Exception { + HttpServletRequest request = request(Map.of( + "referer", + "https://example.test/easyflow/ai/workflow?page=1" + )); + + Assert.assertEquals( + buildShareBaseUrl(request), + "https://example.test/easyflow/ai/workflow/design" + ); + } + + /** + * 验证反向代理头用于构建外部 HTTPS 分享地址。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void shouldUseForwardedOriginAndPrefix() throws Exception { + HttpServletRequest request = request(Map.of( + "x-forwarded-proto", "https", + "x-forwarded-host", "example.test", + "x-forwarded-prefix", "/easyflow" + )); + + Assert.assertEquals( + buildShareBaseUrl(request), + "https://example.test/easyflow/ai/workflow/design" + ); + } + + /** + * 调用控制器的分享基础地址构建方法。 + * + * @param request 模拟 HTTP 请求 + * @return 分享基础地址 + * @throws Exception 反射调用失败时抛出 + */ + private String buildShareBaseUrl(HttpServletRequest request) throws Exception { + Method method = WorkflowShareController.class.getDeclaredMethod( + "buildShareBaseUrl", + HttpServletRequest.class + ); + method.setAccessible(true); + return (String) method.invoke(new WorkflowShareController(), request); + } + + /** + * 创建仅提供请求头能力的轻量 Servlet 请求代理。 + * + * @param headers 小写请求头映射 + * @return HTTP 请求代理 + */ + private HttpServletRequest request(Map headers) { + return (HttpServletRequest) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{HttpServletRequest.class}, + (proxy, method, args) -> { + if ("getHeader".equals(method.getName())) { + String name = String.valueOf(args[0]).toLowerCase(Locale.ROOT); + return headers.get(name); + } + return defaultValue(method.getReturnType()); + } + ); + } + + /** + * 返回代理方法所需的基础类型默认值。 + * + * @param returnType 返回值类型 + * @return 对应默认值 + */ + private Object defaultValue(Class returnType) { + if (!returnType.isPrimitive()) { + return null; + } + if (boolean.class == returnType) { + return false; + } + if (char.class == returnType) { + return '\0'; + } + if (byte.class == returnType) { + return (byte) 0; + } + if (short.class == returnType) { + return (short) 0; + } + if (int.class == returnType) { + return 0; + } + if (long.class == returnType) { + return 0L; + } + if (float.class == returnType) { + return 0F; + } + return 0D; + } +} diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java index 9bc1113e..099d10d4 100644 --- a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java +++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java @@ -48,9 +48,12 @@ public class GlobalErrorResolver implements HandlerExceptionResolver { if (ex instanceof MissingServletRequestParameterException) { response.setStatus(HttpStatus.BAD_REQUEST.value()); error = Result.fail(400, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空"); - } else if (ex instanceof NotLoginException) { + } else if (ex instanceof NotLoginException notLoginException) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); - error = Result.fail(401, "请登录"); + String message = NotLoginException.BE_REPLACED.equals(notLoginException.getType()) + ? "该账号已在其他设备登录,请重新登录" + : "请登录"; + error = Result.fail(401, message); } else if (ex instanceof NotPermissionException || ex instanceof NotRoleException) { response.setStatus(HttpStatus.FORBIDDEN.value()); error = Result.fail(403, "无权操作"); diff --git a/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/error/GlobalErrorResolverTest.java b/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/error/GlobalErrorResolverTest.java index 0f95886d..12939282 100644 --- a/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/error/GlobalErrorResolverTest.java +++ b/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/error/GlobalErrorResolverTest.java @@ -1,5 +1,6 @@ package tech.easyflow.common.web.error; +import cn.dev33.satoken.exception.NotLoginException; import org.junit.Test; import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; @@ -22,6 +23,26 @@ public class GlobalErrorResolverTest { private final GlobalErrorResolver resolver = new GlobalErrorResolver(); + /** + * 验证被新 Web 登录替换的旧会话返回明确的 401 提示。 + */ + @Test + public void shouldExplainReplacedLoginSession() { + Resolution resolution = resolve(NotLoginException.newInstance( + "login", + NotLoginException.BE_REPLACED, + NotLoginException.BE_REPLACED_MESSAGE, + null + )); + + assertEquals(401, resolution.response.getStatus()); + assertEquals(401, resolution.modelAndView.getModel().get("errorCode")); + assertEquals( + "该账号已在其他设备登录,请重新登录", + resolution.modelAndView.getModel().get("message") + ); + } + /** * 验证业务冲突不会被包装为 HTTP 200。 */ diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/WorkflowShare.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/WorkflowShare.java new file mode 100644 index 00000000..05860c21 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/WorkflowShare.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/WorkflowBase.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/WorkflowBase.java index 97430f59..936c243d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/WorkflowBase.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/WorkflowBase.java @@ -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; } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/WorkflowMapper.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/WorkflowMapper.java index 027206b9..1e860e32 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/WorkflowMapper.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/WorkflowMapper.java @@ -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 { + /** + * 按预期修订号原子更新工作流内容。 + * + * @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 + ); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/WorkflowShareMapper.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/WorkflowShareMapper.java new file mode 100644 index 00000000..8b4db59b --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/WorkflowShareMapper.java @@ -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 { +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/permission/WorkflowShareResourceAccessGrantProvider.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/permission/WorkflowShareResourceAccessGrantProvider.java new file mode 100644 index 00000000..d35734d8 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/permission/WorkflowShareResourceAccessGrantProvider.java @@ -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(); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleService.java index 3933c377..18c685d9 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleService.java @@ -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 + ); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java index 872e1679..dadc37e3 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AiResourceLifecycleServiceImpl.java @@ -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} */ diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/BotPublishAppService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/BotPublishAppService.java index 6cabde7d..d9b33a39 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/BotPublishAppService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/BotPublishAppService.java @@ -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(), diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/KnowledgePublishAppService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/KnowledgePublishAppService.java index c25847a3..03ce27fa 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/KnowledgePublishAppService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/KnowledgePublishAppService.java @@ -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(), diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowPublishAppService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowPublishAppService.java index 7e13fffa..dc1a5e13 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowPublishAppService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowPublishAppService.java @@ -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(), diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowService.java index d5a2a1cc..0adc6431 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowService.java @@ -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 { * @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 + ); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowShareService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowShareService.java new file mode 100644 index 00000000..ccef161f --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/WorkflowShareService.java @@ -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 { + + /** + * 创建或刷新工作流的唯一协作分享链接。 + * + * @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); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowServiceImpl.java index 751e050e..a4c1037d 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowServiceImpl.java @@ -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 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 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; + } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowShareServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowShareServiceImpl.java new file mode 100644 index 00000000..04ace823 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowShareServiceImpl.java @@ -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 + 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 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, "工作流分享链接无效"); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/share/WorkflowSharePolicy.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/share/WorkflowSharePolicy.java new file mode 100644 index 00000000..33cebbe4 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/share/WorkflowSharePolicy.java @@ -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 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); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/WorkflowShareCreateResult.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/WorkflowShareCreateResult.java new file mode 100644 index 00000000..0bf03fa8 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/WorkflowShareCreateResult.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowShareMigrationContractTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowShareMigrationContractTest.java new file mode 100644 index 00000000..0207865b --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowShareMigrationContractTest.java @@ -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 工作流分享与审批说明迁移"); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowSharePolicyTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowSharePolicyTest.java new file mode 100644 index 00000000..2e111a88 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/share/WorkflowSharePolicyTest.java @@ -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 + )); + } +} diff --git a/easyflow-modules/easyflow-module-approval/pom.xml b/easyflow-modules/easyflow-module-approval/pom.xml index 6a66b3f6..13079569 100644 --- a/easyflow-modules/easyflow-module-approval/pom.xml +++ b/easyflow-modules/easyflow-module-approval/pom.xml @@ -33,5 +33,11 @@ tech.easyflow easyflow-module-system + + junit + junit + ${junit.version} + test + diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java index f6e859c2..3c2c3fc6 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/base/ApprovalInstanceBase.java @@ -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; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstanceDetailVo.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstanceDetailVo.java index de102b5a..3b8c20ee 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstanceDetailVo.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstanceDetailVo.java @@ -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; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstancePageVo.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstancePageVo.java index 67e56b42..7bc7a018 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstancePageVo.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalInstancePageVo.java @@ -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; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalLogVo.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalLogVo.java index 10eab8b1..0d41bee6 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalLogVo.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalLogVo.java @@ -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; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalSubmitRequest.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalSubmitRequest.java index f0531b46..b09ef897 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalSubmitRequest.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalSubmitRequest.java @@ -22,6 +22,11 @@ public class ApprovalSubmitRequest { private String summary; + /** + * 发起人填写的审批说明。 + */ + private String applicationReason; + private Map 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 getSnapshotJson() { return snapshotJson; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalTaskVo.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalTaskVo.java index c0e8433d..807390e3 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalTaskVo.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/entity/vo/ApprovalTaskVo.java @@ -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; } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java index 30b5a8af..373f4461 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalInstanceServiceImpl.java @@ -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 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 map = new LinkedHashMap<>(); map.put("stepNo", item.getStepNo()); diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java index 24c0abfe..8574fa98 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java @@ -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()); diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/support/ApprovalApplicationReasonPolicy.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/support/ApprovalApplicationReasonPolicy.java new file mode 100644 index 00000000..f7cd25b5 --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/support/ApprovalApplicationReasonPolicy.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/support/ApprovalApplicationReasonPolicyTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/support/ApprovalApplicationReasonPolicyTest.java new file mode 100644 index 00000000..11b034f8 --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/support/ApprovalApplicationReasonPolicyTest.java @@ -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 + )); + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthLoginSessionPolicy.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthLoginSessionPolicy.java new file mode 100644 index 00000000..a202d90c --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthLoginSessionPolicy.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java index 0910e865..204511bb 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java @@ -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); diff --git a/easyflow-modules/easyflow-module-auth/src/test/java/tech/easyflow/auth/service/impl/AuthLoginSessionPolicyTest.java b/easyflow-modules/easyflow-module-auth/src/test/java/tech/easyflow/auth/service/impl/AuthLoginSessionPolicyTest.java new file mode 100644 index 00000000..094ae5a1 --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/test/java/tech/easyflow/auth/service/impl/AuthLoginSessionPolicyTest.java @@ -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()); + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/config/AccountSecurityProperties.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/config/AccountSecurityProperties.java new file mode 100644 index 00000000..b8dd347d --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/config/AccountSecurityProperties.java @@ -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(); + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/permission/resource/ResourceAccessGrantProvider.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/permission/resource/ResourceAccessGrantProvider.java new file mode 100644 index 00000000..a849c82d --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/permission/resource/ResourceAccessGrantProvider.java @@ -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 + ); +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java index d46bd97b..48400006 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/ResourceAccessServiceImpl.java @@ -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 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; + } } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java index 2c263905..48eb0433 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java @@ -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 { }; /** 提交 Bot 发布审批 */ -export const submitBotPublishApproval = (id: string) => { +export const submitBotPublishApproval = ( + id: string, + applicationReason?: string, +) => { return api.post>( '/api/v1/bot/submitPublishApproval', - { id }, + { applicationReason, id }, ); }; diff --git a/easyflow-ui-admin/app/src/api/request.ts b/easyflow-ui-admin/app/src/api/request.ts index 859eb02a..cde27cf9 100644 --- a/easyflow-ui-admin/app/src/api/request.ts +++ b/easyflow-ui-admin/app/src/api/request.ts @@ -19,6 +19,11 @@ import { ElMessage } from 'element-plus'; import { events } from 'fetch-event-stream'; import { useAuthStore } from '#/store'; +import { + readWorkflowShareKey, + withWorkflowShareHeader, + WORKFLOW_SHARE_HEADER, +} from '#/utils/workflow-share-context'; import { refreshTokenApi } from './core'; @@ -95,6 +100,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { config.headers['easyflow-token'] = formatToken(accessStore.accessToken); config.headers['Accept-Language'] = preferences.app.locale; + const workflowShareKey = readWorkflowShareKey(); + if (workflowShareKey) { + config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey; + } return config; }, }); @@ -263,13 +272,12 @@ export class SseClient { 'Content-Type': 'application/json', 'easyflow-token': accessStore.accessToken || '', }; - if (!extraHeaders) { - return headers; + if (extraHeaders) { + new Headers(extraHeaders).forEach((value, key) => { + headers[key] = value; + }); } - new Headers(extraHeaders).forEach((value, key) => { - headers[key] = value; - }); - return headers; + return withWorkflowShareHeader(headers); } } diff --git a/easyflow-ui-admin/app/src/components/page/CardList.vue b/easyflow-ui-admin/app/src/components/page/CardList.vue index cc90eaac..6d5b314b 100644 --- a/easyflow-ui-admin/app/src/components/page/CardList.vue +++ b/easyflow-ui-admin/app/src/components/page/CardList.vue @@ -5,7 +5,7 @@ import { computed } from 'vue'; import { useAccess } from '@easyflow/access'; -import { MoreFilled } from '@element-plus/icons-vue'; +import { Loading, MoreFilled } from '@element-plus/icons-vue'; import { ElAvatar, ElButton, @@ -27,6 +27,8 @@ export interface ActionButton { icon?: any; text: ((row: any) => string) | string; className?: string; + disabled?: ((row: any) => boolean) | boolean; + loading?: ((row: any) => boolean) | boolean; permission?: string; placement?: ActionPlacement; tone?: ActionTone; @@ -87,6 +89,13 @@ function isActionVisible(action: ActionButton, row: any) { return action.visible !== false; } +function resolveActionState( + state: ((row: any) => boolean) | boolean | undefined, + row: any, +) { + return typeof state === 'function' ? state(row) : state === true; +} + const resolvedPrimaryAction = computed(() => { if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) { return undefined; @@ -125,6 +134,12 @@ function handlePrimaryAction(item: any) { function handleActionClick(event: Event, action: ActionButton, item: any) { event.stopPropagation(); + if ( + resolveActionState(action.disabled, item) || + resolveActionState(action.loading, item) + ) { + return; + } action.onClick(item); } @@ -265,6 +280,8 @@ function resolveMetaItems(item: any) { size="small" class="card-action-btn" :class="{ 'card-action-btn--danger': action.tone === 'danger' }" + :disabled="resolveActionState(action.disabled, item)" + :loading="resolveActionState(action.loading, item)" link @click.stop="handleActionClick($event, action, item)" > @@ -293,10 +310,20 @@ function resolveMetaItems(item: any) { :class="{ 'card-menu-item--danger': action.tone === 'danger', }" - @click="action.onClick(item)" + :disabled=" + resolveActionState(action.disabled, item) || + resolveActionState(action.loading, item) + " + @click="handleActionClick($event, action, item)" >