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

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

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

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

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

View File

@@ -307,16 +307,38 @@ public class BotController extends BaseCurdController<BotService, Bot> {
return Result.ok(data);
}
/**
* 提交聊天助手发布审批。
*
* @param id 助手 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
return buildApprovalActionResult(
botPublishAppService.submitPublishApproval(id),
botPublishAppService.submitPublishApproval(id, applicationReason),
"已提交发布审批",
"已直接发布"
);
}
/**
* 预检聊天助手发布是否命中审批流。
*
* @param id 助手 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/bot/save")
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(botPublishAppService.isPublishApprovalRequired(id));
}
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {

View File

@@ -203,18 +203,34 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
* 提交发布审批。
*
* @param id 知识库 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
return buildApprovalActionResult(
knowledgePublishAppService.submitPublishApproval(id),
knowledgePublishAppService.submitPublishApproval(id, applicationReason),
"已提交发布审批",
"已直接发布"
);
}
/**
* 预检知识库发布是否命中审批流。
*
* @param id 知识库 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(knowledgePublishAppService.isPublishApprovalRequired(id));
}
/**
* 提交下线审批。
*

View File

@@ -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<WorkflowService, Work
private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Resource
private WorkflowShareResourceAccessGrantProvider workflowShareGrantProvider;
public WorkflowController(WorkflowService service, ModelService modelService) {
super(service);
@@ -256,18 +261,48 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
* 提交发布审批。
*
* @param id 工作流 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/workflow/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限发布工作流"
)
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
return buildApprovalActionResult(
workflowPublishAppService.submitPublishApproval(id),
workflowPublishAppService.submitPublishApproval(id, applicationReason),
"已提交发布审批",
"已直接发布"
);
}
/**
* 预检工作流发布是否命中审批流。
*
* @param id 工作流 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限发布工作流"
)
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(workflowPublishAppService.isPublishApprovalRequired(id));
}
/**
* 提交下线审批。
*
@@ -276,6 +311,13 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限下线工作流"
)
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
workflowPublishAppService.submitOfflineApproval(id),
@@ -311,6 +353,13 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
*/
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/workflow/remove")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限删除工作流"
)
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
workflowPublishAppService.submitDeleteApproval(id),
@@ -378,14 +427,68 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
}
workflow.setId(null);
workflow.setAlias(IdUtil.fastSimpleUUID());
workflow.setRevision(0);
commonFiled(workflow, account.getId(), account.getTenantId(), account.getDeptId());
service.save(workflow);
return Result.ok();
}
/**
* 更新工作流;分享访问只允许按修订号更新设计内容。
*
* @param entity 工作流更新内容
* @return 更新结果
*/
@Override
@PostMapping("update")
@Transactional(rollbackFor = Exception.class)
public Result<?> update(@JsonBody Workflow entity) {
if (entity == null || entity.getId() == null) {
throw new BusinessException("工作流 ID 不能为空");
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
boolean sharedRequest = workflowShareGrantProvider.isSharedRequestFor(
entity.getId(),
loginAccount
);
if (sharedRequest && entity.getContent() == null) {
throw new BusinessException(403, 403, "分享链接仅允许编辑工作流内容");
}
if (entity.getContent() == null) {
return super.update(entity);
}
if (entity.getRevision() == null) {
throw workflowRevisionConflict();
}
Result<?> beforeResult = onSaveOrUpdateBefore(entity, false);
if (beforeResult != null) {
return beforeResult;
}
boolean updated = service.updateContentByRevision(
entity.getId(),
entity.getContent(),
entity.getRevision(),
new Date(),
loginAccount.getId()
);
if (!updated) {
throw workflowRevisionConflict();
}
entity.setRevision(entity.getRevision() + 1);
if (!sharedRequest) {
entity.setContent(null);
service.updateById(entity);
}
onSaveOrUpdateAfter(entity, false);
return Result.ok(Map.of("revision", entity.getRevision()));
}
@Override
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<WorkflowService, Work
return workflow;
}
/**
* 构建工作流内容修订冲突异常。
*
* @return HTTP 409 业务异常
*/
private BusinessException workflowRevisionConflict() {
return new BusinessException(409, 409, "工作流已被其他人更新,请刷新后重新编辑");
}
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {

View File

@@ -0,0 +1,271 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.ai.vo.WorkflowShareCreateResult;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.RequestUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Map;
/**
* 工作流协作分享管理接口。
*/
@RestController
@RequestMapping("/api/v1/workflowShare")
public class WorkflowShareController {
@Resource
private WorkflowShareService workflowShareService;
@Resource
private WorkflowService workflowService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private KnowledgeShareAuditService knowledgeShareAuditService;
/**
* 创建或刷新工作流协作分享链接。
*
* @param request HTTP 请求
* @param workflowId 工作流 ID
* @return 分享创建结果
*/
@PostMapping("/url/create")
@SaCheckPermission("/api/v1/workflow/save")
public Result<WorkflowShareCreateResult> createUrlShare(
HttpServletRequest request,
@JsonBody("workflowId") BigInteger workflowId
) {
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
throw new BusinessException("工作流不存在");
}
resourceAccessService.assertAccess(
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.MANAGE,
"无权限分享工作流"
);
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
WorkflowShareCreateResult result = workflowShareService.createUrlShare(
workflowId,
loginAccount.getTenantId(),
loginAccount.getDeptId(),
loginAccount.getId(),
buildShareBaseUrl(request)
);
knowledgeShareAuditService.log(
loginAccount.getId(),
"创建工作流协作分享",
"WORKFLOW_SHARE_CREATE",
request.getRequestURI(),
Map.of("workflowId", workflowId, "shareId", result.getId())
);
return Result.ok(result);
}
/**
* 解析当前 URL 分享指向的工作流。
*
* @param request HTTP 请求
* @return 工作流标识
*/
@GetMapping("/resolve")
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
WorkflowShare share = workflowShareService.resolveUrlShare(
request.getHeader(WorkflowSharePolicy.SHARE_KEY_HEADER),
loginAccount.getTenantId()
);
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
}
/**
* 根据管理端来源构建工作流分享基础 URL。
*
* @param request HTTP 请求
* @return 工作流设计页 URL
*/
private String buildShareBaseUrl(HttpServletRequest request) {
String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request));
if (refererBaseUrl != null) {
return refererBaseUrl + "/ai/workflow/design";
}
String forwardedOrigin = buildForwardedOrigin(request);
if (forwardedOrigin != null) {
return forwardedOrigin
+ normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix")))
+ "/ai/workflow/design";
}
String origin = normalizeOrigin(request.getHeader("Origin"));
if (origin != null) {
return origin + normalizeBasePath(request.getContextPath()) + "/ai/workflow/design";
}
StringBuilder builder = new StringBuilder();
builder.append(request.getScheme()).append("://").append(request.getServerName());
if (request.getServerPort() != 80 && request.getServerPort() != 443) {
builder.append(':').append(request.getServerPort());
}
return builder.append(normalizeBasePath(request.getContextPath()))
.append("/ai/workflow/design")
.toString();
}
/**
* 从来源地址提取前端 origin 与部署基路径。
*
* @param sourceUrl 来源地址
* @return origin 与部署基路径,无法解析时返回 {@code null}
*/
private String extractFrontendBaseUrl(String sourceUrl) {
if (sourceUrl == null || sourceUrl.isBlank()) {
return null;
}
try {
URI uri = new URI(sourceUrl.trim());
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
String origin = extractOrigin(sourceUrl);
return origin == null ? null : origin + inferFrontendBasePath(uri.getPath());
} catch (URISyntaxException e) {
return null;
}
}
/**
* 从前端页面路径推断部署基路径。
*
* @param path 页面路径
* @return 规范化后的部署基路径
*/
private String inferFrontendBasePath(String path) {
if (path == null || path.isBlank() || "/".equals(path)) {
return "";
}
for (String marker : new String[]{"/ai/", "/auth/", "/share/"}) {
int markerIndex = path.indexOf(marker);
if (markerIndex > 0) {
return normalizeBasePath(path.substring(0, markerIndex));
}
if (markerIndex == 0) {
return "";
}
}
return "";
}
/**
* 规范化部署基路径。
*
* @param basePath 原始基路径
* @return 无尾斜杠的基路径
*/
private String normalizeBasePath(String basePath) {
if (basePath == null || basePath.isBlank() || "/".equals(basePath.trim())) {
return "";
}
String normalized = basePath.trim();
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
while (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
/**
* 按反向代理头构建外部访问 origin。
*
* @param request HTTP 请求
* @return 外部 origin缺少代理头时返回 {@code null}
*/
private String buildForwardedOrigin(HttpServletRequest request) {
String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto"));
String host = firstHeaderValue(request.getHeader("X-Forwarded-Host"));
if (proto == null || host == null) {
return null;
}
return normalizeOrigin(proto + "://" + host);
}
/**
* 从 URL 提取 origin。
*
* @param url 完整 URL
* @return origin无法解析时返回 {@code null}
*/
private String extractOrigin(String url) {
if (url == null || url.isBlank()) {
return null;
}
try {
URI uri = new URI(url.trim());
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
StringBuilder builder = new StringBuilder();
builder.append(uri.getScheme()).append("://").append(uri.getHost());
if (uri.getPort() != -1 && uri.getPort() != 80 && uri.getPort() != 443) {
builder.append(':').append(uri.getPort());
}
return builder.toString();
} catch (URISyntaxException e) {
return null;
}
}
/**
* 规范化 origin。
*
* @param origin 原始 origin
* @return 规范化结果
*/
private String normalizeOrigin(String origin) {
return extractOrigin(origin);
}
/**
* 读取代理头的首个有效值。
*
* @param value 原始请求头
* @return 首个有效值
*/
private String firstHeaderValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
int commaIndex = value.indexOf(',');
String normalized = commaIndex >= 0 ? value.substring(0, commaIndex) : value;
normalized = normalized.trim();
return normalized.isEmpty() ? null : normalized;
}
}