feat: 收敛AI资源发布审批生命周期

- 统一工作流、知识库、聊天助手的发布、重新发布、下线与删除链路

- 收敛审批编排、生命周期状态机与展示态,补齐审批管理和快照预览

- 调整审批管理权限模型为单入口页面加内部按钮权限
This commit is contained in:
2026-04-09 17:13:54 +08:00
parent 81125ce55c
commit 4e565aef99
68 changed files with 3859 additions and 817 deletions

View File

@@ -19,6 +19,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.publish.BotPublishAppService;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.service.*;
import tech.easyflow.ai.service.impl.BotServiceImpl;
import tech.easyflow.common.audio.core.AudioServiceManager;
@@ -70,6 +71,8 @@ public class BotController extends BaseCurdController<BotService, Bot> {
private CategoryPermissionService categoryPermissionService;
@Resource
private BotPublishAppService botPublishAppService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService;
public BotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
BotDocumentCollectionService botDocumentCollectionService, BotMessageService botMessageService) {
@@ -197,6 +200,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (!StpUtil.isLogin() && !tech.easyflow.ai.enums.PublishStatus.from(bot.getPublishStatus()).isExternallyVisible()) {
throw new BusinessException("聊天助手尚未发布");
}
if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(bot);
}
return Result.ok(bot);
}
@@ -221,6 +227,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
}
if (data.getModelId() == null) {
if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data);
}
return Result.ok(data);
}
@@ -229,6 +238,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (llm == null) {
data.setModelId(null);
if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data);
}
return Result.ok(data);
}
@@ -242,19 +254,40 @@ public class BotController extends BaseCurdController<BotService, Bot> {
}
if (StpUtil.isLogin()) {
aiResourceApprovalStateService.fillBotApprovalState(data);
}
return Result.ok(data);
}
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return Result.ok(botPublishAppService.submitPublishApproval(id));
return buildApprovalActionResult(
botPublishAppService.submitPublishApproval(id),
"已提交发布审批",
"已直接发布"
);
}
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
botPublishAppService.submitOfflineApproval(id),
"已提交下线审批",
"已直接下线"
);
}
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/bot/remove")
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return Result.ok(botPublishAppService.submitDeleteApproval(id));
return buildApprovalActionResult(
botPublishAppService.submitDeleteApproval(id),
"已提交删除审批",
"已直接删除"
);
}
@Override
@@ -262,13 +295,26 @@ public class BotController extends BaseCurdController<BotService, Bot> {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
applyCategoryPermission(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
List<Bot> bots = service.list(queryWrapper);
aiResourceApprovalStateService.fillBotApprovalState(bots);
return Result.ok(bots);
}
@Override
protected Page<Bot> queryPage(Page<Bot> page, QueryWrapper queryWrapper) {
applyCategoryPermission(queryWrapper);
return super.queryPage(page, queryWrapper);
Page<Bot> result = super.queryPage(page, queryWrapper);
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords());
return result;
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(
actionResult.isApprovalRequired() ? approvalMessage : directMessage,
actionResult.getInstanceId()
);
}
private void applyCategoryPermission(QueryWrapper queryWrapper) {
@@ -388,7 +434,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@Override
@PostMapping("remove")
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
return Result.fail(1, "提交删除审批");
return Result.fail(1, "使用发布状态操作删除聊天助手");
}
/**

View File

@@ -21,6 +21,9 @@ import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.KnowledgePublishAppService;
import tech.easyflow.ai.service.AiResourceApprovalStateService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.service.BotDocumentCollectionService;
@@ -71,6 +74,8 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
private KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper;
@Resource
private KnowledgePublishAppService knowledgePublishAppService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService;
public DocumentCollectionController(DocumentCollectionService service, DocumentChunkService chunkService, ModelService llmService) {
super(service);
@@ -177,6 +182,7 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
)
public Result<DocumentCollection> detail(String id) {
DocumentCollection detail = service.getDetail(id);
aiResourceApprovalStateService.fillKnowledgeApprovalState(detail);
return Result.ok(detail);
}
@@ -195,7 +201,46 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return Result.ok(knowledgePublishAppService.submitPublishApproval(id));
return buildApprovalActionResult(
knowledgePublishAppService.submitPublishApproval(id),
"已提交发布审批",
"已直接发布"
);
}
/**
* 提交下线审批。
*
* @param id 知识库 ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
knowledgePublishAppService.submitOfflineApproval(id),
"已提交下线审批",
"已直接下线"
);
}
/**
* 检查知识库下线影响。
*
* @param id 知识库 ID
* @return 下线影响结果
*/
@GetMapping("/offlineImpactCheck")
@SaCheckPermission("/api/v1/documentCollection/save")
@RequireResourceAccess(
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.KNOWLEDGE_ID,
idExpr = "#id",
denyMessage = "无权限管理知识库"
)
public Result<OfflineImpactCheckVo> offlineImpactCheck(@RequestParam BigInteger id) {
return Result.ok(knowledgePublishAppService.checkOfflineImpact(id));
}
/**
@@ -207,7 +252,11 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/documentCollection/remove")
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return Result.ok(knowledgePublishAppService.submitDeleteApproval(id));
return buildApprovalActionResult(
knowledgePublishAppService.submitDeleteApproval(id),
"已提交删除审批",
"已直接删除"
);
}
@PostMapping("splitterProfile/save")
@@ -250,20 +299,33 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
knowledgeVisibilityQueryHelper.applyReadableAccess(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
List<DocumentCollection> collections = service.list(queryWrapper);
aiResourceApprovalStateService.fillKnowledgeApprovalState(collections);
return Result.ok(collections);
}
@Override
protected Page<DocumentCollection> queryPage(Page<DocumentCollection> page, QueryWrapper queryWrapper) {
knowledgeVisibilityQueryHelper.applyReadableAccess(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
return super.queryPage(page, queryWrapper);
Page<DocumentCollection> result = super.queryPage(page, queryWrapper);
aiResourceApprovalStateService.fillKnowledgeApprovalState(result.getRecords());
return result;
}
@Override
@PostMapping("remove")
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
return Result.fail(1, "提交删除审批");
return Result.fail(1, "使用发布状态操作删除知识库");
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(
actionResult.isApprovalRequired() ? approvalMessage : directMessage,
actionResult.getInstanceId()
);
}
private void normalizeVisibilityScope(DocumentCollection entity, boolean isSave) {

View File

@@ -4,14 +4,19 @@ import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.http.HttpUtil;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.admin.model.ai.ResourcePreviewVo;
import tech.easyflow.ai.entity.Resource;
import tech.easyflow.ai.service.ResourceService;
import tech.easyflow.ai.utils.DocUtil;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService;
@@ -31,6 +36,8 @@ import static tech.easyflow.ai.entity.table.ResourceTableDef.RESOURCE;
@RestController
@RequestMapping("/api/v1/resource")
public class ResourceController extends BaseCurdController<ResourceService, Resource> {
private static final int RESOURCE_PREVIEW_CONTENT_LIMIT = 20_000;
@javax.annotation.Resource
private CategoryPermissionService categoryPermissionService;
@@ -79,6 +86,26 @@ public class ResourceController extends BaseCurdController<ResourceService, Reso
return Result.ok(resource);
}
/**
* 获取素材预览内容。
*
* @param id 素材ID
* @return 预览文本
*/
@GetMapping("/previewContent")
public Result<ResourcePreviewVo> previewContent(String id) {
Resource resource = service.getById(id);
if (resource == null) {
throw new BusinessException("素材不存在");
}
categoryPermissionService.assertCategoryResourceVisible("RESOURCE", resource.getCreatedBy(), resource.getCategoryId(), "无权限访问素材");
byte[] bytes = DocUtil.downloadFile(resource.getResourceUrl());
String suffix = resolvePreviewSuffix(resource, bytes);
String content = DocUtil.readPreviewContent(suffix, new ByteArrayInputStream(bytes));
return Result.ok(buildPreviewVo(content));
}
private void applyCategoryPermission(QueryWrapper queryWrapper) {
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess("RESOURCE");
if (!access.isRestricted()) {
@@ -90,4 +117,37 @@ public class ResourceController extends BaseCurdController<ResourceService, Reso
}
queryWrapper.and(RESOURCE.CREATED_BY.eq(access.getAccountId()).or(RESOURCE.CATEGORY_ID.in(access.getCategoryIds())));
}
/**
* 解析用于预览的文件后缀。
*
* @param resource 素材实体
* @param bytes 文件字节
* @return 标准化后的文件后缀
*/
private String resolvePreviewSuffix(Resource resource, byte[] bytes) {
if (StringUtils.hasText(resource.getSuffix())) {
return DocUtil.normalizeSuffix(resource.getSuffix());
}
String detectedSuffix = FileTypeUtil.getType(new ByteArrayInputStream(bytes), resource.getResourceUrl());
if (!StringUtils.hasText(detectedSuffix)) {
throw new BusinessException("无法识别当前素材文件类型");
}
return DocUtil.normalizeSuffix(detectedSuffix);
}
/**
* 构建预览返回对象,并在服务端截断超长内容。
*
* @param content 原始内容
* @return 预览内容对象
*/
private ResourcePreviewVo buildPreviewVo(String content) {
String safeContent = content == null ? "" : content;
boolean truncated = safeContent.length() > RESOURCE_PREVIEW_CONTENT_LIMIT;
ResourcePreviewVo vo = new ResourcePreviewVo();
vo.setContent(truncated ? safeContent.substring(0, RESOURCE_PREVIEW_CONTENT_LIMIT) : safeContent);
vo.setTruncated(truncated);
return vo;
}
}

View File

@@ -27,6 +27,9 @@ import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.WorkflowPublishAppService;
import tech.easyflow.ai.service.AiResourceApprovalStateService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.WorkflowService;
@@ -88,6 +91,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@Resource
private WorkflowPublishAppService workflowPublishAppService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService;
public WorkflowController(WorkflowService service, ModelService modelService) {
super(service);
@@ -254,7 +259,46 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/workflow/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return Result.ok(workflowPublishAppService.submitPublishApproval(id));
return buildApprovalActionResult(
workflowPublishAppService.submitPublishApproval(id),
"已提交发布审批",
"已直接发布"
);
}
/**
* 提交下线审批。
*
* @param id 工作流 ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/workflow/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
workflowPublishAppService.submitOfflineApproval(id),
"已提交下线审批",
"已直接下线"
);
}
/**
* 检查工作流下线影响。
*
* @param id 工作流 ID
* @return 下线影响结果
*/
@GetMapping("/offlineImpactCheck")
@SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限管理工作流"
)
public Result<OfflineImpactCheckVo> offlineImpactCheck(@RequestParam BigInteger id) {
return Result.ok(workflowPublishAppService.checkOfflineImpact(id));
}
/**
@@ -266,7 +310,11 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/workflow/remove")
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return Result.ok(workflowPublishAppService.submitDeleteApproval(id));
return buildApprovalActionResult(
workflowPublishAppService.submitDeleteApproval(id),
"已提交删除审批",
"已直接删除"
);
}
@GetMapping("/supportedCodeEngines")
@@ -307,6 +355,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
)
public Result<Workflow> detail(String id) {
Workflow workflow = service.getDetail(id);
aiResourceApprovalStateService.fillWorkflowApprovalState(workflow);
return Result.ok(workflow);
}
@@ -368,20 +417,33 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
List<Workflow> workflows = service.list(queryWrapper);
aiResourceApprovalStateService.fillWorkflowApprovalState(workflows);
return Result.ok(workflows);
}
@Override
protected Page<Workflow> queryPage(Page<Workflow> page, QueryWrapper queryWrapper) {
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
applyPublishedOnlyFilter(queryWrapper);
return super.queryPage(page, queryWrapper);
Page<Workflow> result = super.queryPage(page, queryWrapper);
aiResourceApprovalStateService.fillWorkflowApprovalState(result.getRecords());
return result;
}
@Override
@PostMapping("remove")
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
return Result.fail(1, "提交删除审批");
return Result.fail(1, "使用发布状态操作删除工作流");
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(
actionResult.isApprovalRequired() ? approvalMessage : directMessage,
actionResult.getInstanceId()
);
}
@Override

View File

@@ -0,0 +1,27 @@
package tech.easyflow.admin.model.ai;
/**
* 素材预览内容返回对象。
*/
public class ResourcePreviewVo {
private String content;
private boolean truncated;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public boolean isTruncated() {
return truncated;
}
public void setTruncated(boolean truncated) {
this.truncated = truncated;
}
}

View File

@@ -1,5 +1,6 @@
package tech.easyflow.ai.entity;
import com.mybatisflex.annotation.Column;
import tech.easyflow.ai.entity.base.BotBase;
import com.mybatisflex.annotation.Table;
@@ -19,6 +20,15 @@ public class Bot extends BotBase {
public static final String KEY_MAX_MESSAGE_COUNT = "maxMessageCount";
public static final String KEY_ENABLE_DEEP_THINKING = "enableDeepThinking";
@Column(ignore = true)
private Boolean approvalPending;
@Column(ignore = true)
private String currentApprovalActionType;
@Column(ignore = true)
private String displayPublishStatus;
public boolean isAnonymousEnabled() {
Map<String, Object> options = getOptions();
if (options == null) {
@@ -28,4 +38,28 @@ public class Bot extends BotBase {
return o != null && (boolean) o;
}
public Boolean getApprovalPending() {
return approvalPending;
}
public void setApprovalPending(Boolean approvalPending) {
this.approvalPending = approvalPending;
}
public String getCurrentApprovalActionType() {
return currentApprovalActionType;
}
public void setCurrentApprovalActionType(String currentApprovalActionType) {
this.currentApprovalActionType = currentApprovalActionType;
}
public String getDisplayPublishStatus() {
return displayPublishStatus;
}
public void setDisplayPublishStatus(String displayPublishStatus) {
this.displayPublishStatus = displayPublishStatus;
}
}

View File

@@ -3,6 +3,7 @@ package tech.easyflow.ai.entity;
import com.easyagents.core.model.chat.tool.Tool;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.rag.retrieval.RetrievalMode;
import com.mybatisflex.annotation.Column;
import com.easyagents.store.milvus.MilvusVectorStore;
import com.easyagents.store.milvus.MilvusVectorStoreConfig;
import com.mybatisflex.annotation.Table;
@@ -27,6 +28,15 @@ import java.util.Map;
@Table("tb_document_collection")
public class DocumentCollection extends DocumentCollectionBase implements VisibilityResource {
@Column(ignore = true)
private Boolean approvalPending;
@Column(ignore = true)
private String currentApprovalActionType;
@Column(ignore = true)
private String displayPublishStatus;
public static final String TYPE_DOCUMENT = "DOCUMENT";
public static final String TYPE_FAQ = "FAQ";
@@ -135,4 +145,28 @@ public class DocumentCollection extends DocumentCollectionBase implements Visibi
}
return options.get(key);
}
public Boolean getApprovalPending() {
return approvalPending;
}
public void setApprovalPending(Boolean approvalPending) {
this.approvalPending = approvalPending;
}
public String getCurrentApprovalActionType() {
return currentApprovalActionType;
}
public void setCurrentApprovalActionType(String currentApprovalActionType) {
this.currentApprovalActionType = currentApprovalActionType;
}
public String getDisplayPublishStatus() {
return displayPublishStatus;
}
public void setDisplayPublishStatus(String displayPublishStatus) {
this.displayPublishStatus = displayPublishStatus;
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.ai.entity;
import com.easyagents.core.model.chat.tool.Tool;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Table;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.entity.base.WorkflowBase;
@@ -16,6 +17,15 @@ import tech.easyflow.system.permission.resource.VisibilityResource;
@Table("tb_workflow")
public class Workflow extends WorkflowBase implements VisibilityResource {
@Column(ignore = true)
private Boolean approvalPending;
@Column(ignore = true)
private String currentApprovalActionType;
@Column(ignore = true)
private String displayPublishStatus;
public Tool toFunction(boolean needEnglishName) {
return new WorkflowTool(this, needEnglishName);
}
@@ -23,4 +33,28 @@ public class Workflow extends WorkflowBase implements VisibilityResource {
public Tool toFunction(boolean needEnglishName, String definitionId) {
return new WorkflowTool(this, needEnglishName, definitionId);
}
public Boolean getApprovalPending() {
return approvalPending;
}
public void setApprovalPending(Boolean approvalPending) {
this.approvalPending = approvalPending;
}
public String getCurrentApprovalActionType() {
return currentApprovalActionType;
}
public void setCurrentApprovalActionType(String currentApprovalActionType) {
this.currentApprovalActionType = currentApprovalActionType;
}
public String getDisplayPublishStatus() {
return displayPublishStatus;
}
public void setDisplayPublishStatus(String displayPublishStatus) {
this.displayPublishStatus = displayPublishStatus;
}
}

View File

@@ -11,6 +11,8 @@ public enum PublishStatus {
DRAFT("DRAFT"),
PUBLISH_PENDING("PUBLISH_PENDING"),
PUBLISHED("PUBLISHED"),
OFFLINE_PENDING("OFFLINE_PENDING"),
OFFLINE("OFFLINE"),
DELETE_PENDING("DELETE_PENDING");
private final String code;
@@ -34,7 +36,16 @@ public enum PublishStatus {
* @return 允许外部访问或线上运行时返回 true
*/
public boolean isExternallyVisible() {
return this == PUBLISHED || this == DELETE_PENDING;
return this == PUBLISHED || this == DELETE_PENDING || this == OFFLINE_PENDING;
}
/**
* 是否允许作为 Bot 新绑定候选。
*
* @return 仅已发布状态返回 true
*/
public boolean isSelectableForBot() {
return this == PUBLISHED;
}
/**

View File

@@ -0,0 +1,366 @@
package tech.easyflow.ai.publish;
import com.fasterxml.jackson.databind.ObjectMapper;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalSubjectHandler;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* AI 资源生命周期处理器抽象基类。
*
* @param <T> 资源类型
*/
public abstract class AbstractAiResourceLifecycleHandler<T> implements ApprovalSubjectHandler, AiResourceLifecycleHandler {
protected static final String SNAPSHOT_KEY = "resourceSnapshot";
protected static final String PREVIOUS_STATUS_KEY = "previousPublishStatus";
private final ApprovalInstanceService approvalInstanceService;
protected final ObjectMapper objectMapper;
protected AbstractAiResourceLifecycleHandler(ApprovalInstanceService approvalInstanceService,
ObjectMapper objectMapper) {
this.approvalInstanceService = approvalInstanceService;
this.objectMapper = objectMapper;
}
/**
* 加载资源,不存在时抛异常。
*
* @param resourceId 资源 ID
* @return 资源
*/
protected abstract T requireResource(BigInteger resourceId);
/**
* 校验资源管理权限。
*
* @param resource 资源
*/
protected abstract void assertManagePermission(T resource);
/**
* 获取资源分类 ID。
*
* @param resource 资源
* @return 分类 ID
*/
protected abstract BigInteger getCategoryId(T resource);
/**
* 获取资源部门 ID。
*
* @param resource 资源
* @return 部门 ID
*/
protected abstract BigInteger getDeptId(T resource);
/**
* 获取资源标题。
*
* @param resource 资源
* @return 资源标题
*/
protected abstract String getTitle(T resource);
/**
* 获取当前发布状态。
*
* @param resource 资源
* @return 发布状态
*/
protected abstract PublishStatus getCurrentStatus(T resource);
/**
* 获取当前已发布快照。
*
* @param resource 资源
* @return 已发布快照
*/
protected abstract Map<String, Object> getPublishedSnapshot(T resource);
/**
* 构建当前草稿快照。
*
* @param resource 资源
* @return 草稿快照
*/
protected abstract Map<String, Object> buildResourceSnapshot(T resource);
/**
* 更新资源待审批状态。
*
* @param resourceId 资源 ID
* @param publishStatus 发布状态
* @param currentApprovalInstanceId 审批实例 ID
*/
protected abstract void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId);
/**
* 发布成功后写入已发布状态。
*
* @param resourceId 资源 ID
* @param resourceSnapshot 已发布快照
* @param operatorId 操作人 ID
*/
protected abstract void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId);
/**
* 下线成功后写入下线状态。
*
* @param resourceId 资源 ID
*/
protected abstract void markResourceOffline(BigInteger resourceId);
/**
* 删除资源。
*
* @param resourceId 资源 ID
*/
protected abstract void removeResource(BigInteger resourceId);
/**
* 返回资源中文名称。
*
* @return 资源名称
*/
protected abstract String resourceLabel();
/**
* 返回当前动作摘要前缀。
*
* @param actionType 动作类型
* @return 摘要前缀
*/
protected String resolveActionLabel(ApprovalActionType actionType) {
return switch (actionType) {
case PUBLISH -> "发布";
case OFFLINE -> "下线";
case DELETE -> "删除";
};
}
/**
* 扩展下线快照内容。
*
* @param resource 资源
* @param snapshot 当前快照副本
*/
protected void enrichOfflineSnapshot(T resource, Map<String, Object> snapshot) {
}
/**
* 删除前额外校验。
*
* @param resource 资源
* @param currentStatus 当前状态
*/
protected void validateDelete(T resource, PublishStatus currentStatus) {
}
/**
* 下线成功后的额外副作用。
*
* @param resourceId 资源 ID
*/
protected void afterOffline(BigInteger resourceId) {
}
/**
* 删除成功前的额外副作用。
*
* @param resourceId 资源 ID
*/
protected void beforeRemove(BigInteger resourceId) {
}
/**
* {@inheritDoc}
*/
@Override
public ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId) {
T resource = requireResource(resourceId);
assertManagePermission(resource);
if (approvalInstanceService.existsActiveInstance(resourceType(), resourceId)) {
throw new BusinessException("当前" + resourceLabel() + "存在未结束审批,请先处理完成");
}
ApprovalActionType approvalActionType = ApprovalActionType.from(actionType);
PublishStatus currentStatus = getCurrentStatus(resource);
Map<String, Object> resourceSnapshot = resolveSubmitSnapshot(resource, approvalActionType, currentStatus);
ApprovalSubmitRequest request = new ApprovalSubmitRequest();
request.setResourceType(resourceType());
request.setResourceId(resourceId);
request.setActionType(approvalActionType.getCode());
request.setApplicantId(operatorId);
request.setCategoryId(getCategoryId(resource));
request.setDeptId(getDeptId(resource));
request.setSummary(resolveActionLabel(approvalActionType) + resourceLabel() + "" + getTitle(resource));
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put(SNAPSHOT_KEY, resourceSnapshot);
snapshot.put(PREVIOUS_STATUS_KEY, currentStatus.getCode());
request.setSnapshotJson(snapshot);
return request;
}
/**
* 解析动作对应的提交快照。
*
* @param resource 资源
* @param actionType 动作类型
* @param currentStatus 当前状态
* @return 提交快照
*/
protected Map<String, Object> resolveSubmitSnapshot(T resource,
ApprovalActionType actionType,
PublishStatus currentStatus) {
return switch (actionType) {
case PUBLISH -> buildPublishSnapshot(resource, currentStatus);
case OFFLINE -> buildOfflineSnapshot(resource, currentStatus);
case DELETE -> buildDeleteSnapshot(resource, currentStatus);
};
}
/**
* 构建发布快照。
*
* @param resource 资源
* @param currentStatus 当前状态
* @return 发布快照
*/
protected Map<String, Object> buildPublishSnapshot(T resource, PublishStatus currentStatus) {
if (currentStatus != PublishStatus.DRAFT
&& currentStatus != PublishStatus.OFFLINE
&& currentStatus != PublishStatus.PUBLISHED) {
throw new BusinessException("当前" + resourceLabel() + "状态不允许发布");
}
Map<String, Object> snapshot = buildResourceSnapshot(resource);
if (currentStatus == PublishStatus.PUBLISHED && isSameSnapshot(snapshot, getPublishedSnapshot(resource))) {
throw new BusinessException("当前内容与已发布版本一致,无需重新发布");
}
return snapshot;
}
/**
* 构建下线快照。
*
* @param resource 资源
* @param currentStatus 当前状态
* @return 下线快照
*/
protected Map<String, Object> buildOfflineSnapshot(T resource, PublishStatus currentStatus) {
if (currentStatus != PublishStatus.PUBLISHED) {
throw new BusinessException("当前" + resourceLabel() + "尚未发布,无法下线");
}
Map<String, Object> publishedSnapshot = getPublishedSnapshot(resource);
if (publishedSnapshot == null || publishedSnapshot.isEmpty()) {
throw new BusinessException("当前" + resourceLabel() + "缺少已发布快照,无法下线");
}
Map<String, Object> snapshot = new LinkedHashMap<>(publishedSnapshot);
enrichOfflineSnapshot(resource, snapshot);
return snapshot;
}
/**
* 构建删除快照。
*
* @param resource 资源
* @param currentStatus 当前状态
* @return 删除快照
*/
protected Map<String, Object> buildDeleteSnapshot(T resource, PublishStatus currentStatus) {
if (currentStatus == PublishStatus.PUBLISHED) {
throw new BusinessException("当前" + resourceLabel() + "已发布,请先下线后再删除");
}
if (currentStatus == PublishStatus.PUBLISH_PENDING
|| currentStatus == PublishStatus.OFFLINE_PENDING
|| currentStatus == PublishStatus.DELETE_PENDING) {
throw new BusinessException("当前" + resourceLabel() + "存在进行中的审批,请先处理完成");
}
validateDelete(resource, currentStatus);
return buildResourceSnapshot(resource);
}
/**
* 执行审批通过后的真实动作。
*
* @param actionType 动作类型
* @param resourceId 资源 ID
* @param resourceSnapshot 快照
* @param operatorId 操作人 ID
*/
@Override
public void applyApprovedAction(String actionType,
BigInteger resourceId,
Map<String, Object> resourceSnapshot,
BigInteger operatorId) {
ApprovalActionType normalizedAction = ApprovalActionType.from(actionType);
if (normalizedAction == ApprovalActionType.PUBLISH) {
publishResource(resourceId, resourceSnapshot, operatorId);
return;
}
if (normalizedAction == ApprovalActionType.OFFLINE) {
markResourceOffline(resourceId);
afterOffline(resourceId);
return;
}
beforeRemove(resourceId);
removeResource(resourceId);
}
/**
* {@inheritDoc}
*/
@Override
public void updatePendingState(BigInteger resourceId, PublishStatus publishStatus, BigInteger instanceId) {
persistResourceState(resourceId, publishStatus, instanceId);
}
/**
* {@inheritDoc}
*/
@Override
public void restoreState(BigInteger resourceId, PublishStatus previousStatus) {
persistResourceState(resourceId, previousStatus, null);
}
/**
* 从请求快照读取资源内容。
*
* @param snapshotJson 请求快照
* @return 资源快照
*/
@SuppressWarnings("unchecked")
protected Map<String, Object> readResourceSnapshot(Map<String, Object> snapshotJson) {
Object snapshot = snapshotJson == null ? null : snapshotJson.get(SNAPSHOT_KEY);
if (!(snapshot instanceof Map<?, ?> map)) {
throw new BusinessException("审批快照缺少" + resourceLabel() + "内容");
}
return new LinkedHashMap<>((Map<String, Object>) map);
}
/**
* 判断当前草稿快照与已发布快照是否一致。
*
* @param currentSnapshot 当前快照
* @param publishedSnapshot 已发布快照
* @return 一致返回 true
*/
protected boolean isSameSnapshot(Map<String, Object> currentSnapshot, Map<String, Object> publishedSnapshot) {
if (publishedSnapshot == null || publishedSnapshot.isEmpty()) {
return false;
}
return Objects.equals(
objectMapper.valueToTree(currentSnapshot),
objectMapper.valueToTree(publishedSnapshot)
);
}
}

View File

@@ -0,0 +1,57 @@
package tech.easyflow.ai.publish;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import java.math.BigInteger;
import java.util.Map;
/**
* AI 资源生命周期处理器。
*/
public interface AiResourceLifecycleHandler {
/**
* 当前处理器支持的资源类型。
*
* @return 资源类型编码
*/
String resourceType();
/**
* 构建动作提交请求。
*
* @param resourceId 资源 ID
* @param actionType 动作类型
* @param operatorId 操作人 ID
* @return 提交请求
*/
ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId);
/**
* 写入待审批状态。
*
* @param resourceId 资源 ID
* @param publishStatus 待写入的发布状态
* @param instanceId 审批实例 ID
*/
void updatePendingState(BigInteger resourceId, PublishStatus publishStatus, BigInteger instanceId);
/**
* 执行动作真正生效后的资源更新与副作用。
*
* @param actionType 动作类型
* @param resourceId 资源 ID
* @param resourceSnapshot 审批冻结快照
* @param operatorId 操作人 ID
*/
void applyApprovedAction(String actionType, BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId);
/**
* 按提交前真实状态恢复资源状态。
*
* @param resourceId 资源 ID
* @param previousStatus 提交前状态
*/
void restoreState(BigInteger resourceId, PublishStatus previousStatus);
}

View File

@@ -0,0 +1,22 @@
package tech.easyflow.ai.publish;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import java.math.BigInteger;
/**
* AI 资源生命周期统一状态机服务。
*/
public interface AiResourceLifecycleService {
/**
* 提交资源动作。
*
* @param resourceType 资源类型
* @param resourceId 资源 ID
* @param actionType 动作类型
* @param operatorId 操作人 ID
* @return 执行结果
*/
ApprovalActionResult submitAction(String resourceType, BigInteger resourceId, String actionType, BigInteger operatorId);
}

View File

@@ -0,0 +1,153 @@
package tech.easyflow.ai.publish;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
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.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* AI 资源生命周期统一状态机实现。
*/
@Service
public class AiResourceLifecycleServiceImpl implements AiResourceLifecycleService, ApprovalResultHandler {
private static final String SNAPSHOT_KEY = "resourceSnapshot";
private static final String PREVIOUS_STATUS_KEY = "previousPublishStatus";
private final List<AiResourceLifecycleHandler> handlers;
private final ApprovalMatchService approvalMatchService;
private final ApprovalInstanceService approvalInstanceService;
public AiResourceLifecycleServiceImpl(List<AiResourceLifecycleHandler> handlers,
ApprovalMatchService approvalMatchService,
ApprovalInstanceService approvalInstanceService) {
this.handlers = handlers;
this.approvalMatchService = approvalMatchService;
this.approvalInstanceService = approvalInstanceService;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ApprovalActionResult submitAction(String resourceType, BigInteger resourceId, String actionType, BigInteger operatorId) {
AiResourceLifecycleHandler handler = getHandler(resourceType);
ApprovalSubmitRequest request = handler.buildSubmitRequest(resourceId, actionType, operatorId);
ApprovalFlowDetailVo flow = approvalMatchService.matchFlowOrNull(request);
if (flow == null) {
handler.applyApprovedAction(actionType, resourceId, readResourceSnapshot(request.getSnapshotJson()), operatorId);
return ApprovalActionResult.direct();
}
BigInteger instanceId = approvalInstanceService.submitApproval(request);
handler.updatePendingState(
resourceId,
resolveSubmittedStatus(actionType, resolvePreviousStatus(request.getSnapshotJson())),
instanceId
);
return ApprovalActionResult.required(instanceId);
}
/**
* {@inheritDoc}
*/
@Override
public void handleApproved(ApprovalInstance instance, BigInteger operatorId, String comment) {
getHandler(instance.getResourceType()).applyApprovedAction(
instance.getActionType(),
instance.getResourceId(),
readResourceSnapshot(instance.getSnapshotJson()),
operatorId
);
}
/**
* {@inheritDoc}
*/
@Override
public void handleRejected(ApprovalInstance instance, BigInteger operatorId, String comment) {
getHandler(instance.getResourceType()).restoreState(
instance.getResourceId(),
resolvePreviousStatus(instance.getSnapshotJson())
);
}
/**
* {@inheritDoc}
*/
@Override
public void handleRevoked(ApprovalInstance instance, BigInteger operatorId, String comment) {
getHandler(instance.getResourceType()).restoreState(
instance.getResourceId(),
resolvePreviousStatus(instance.getSnapshotJson())
);
}
private AiResourceLifecycleHandler getHandler(String resourceType) {
return handlers.stream()
.filter(item -> item.resourceType().equals(resourceType))
.findFirst()
.orElseThrow(() -> new BusinessException("未找到资源生命周期处理器: " + resourceType));
}
/**
* 解析提交动作后的真实持久化状态。
*
* @param actionType 动作类型
* @param previousStatus 提交前真实状态
* @return 提交后应写入的状态
*/
private PublishStatus resolveSubmittedStatus(String actionType, PublishStatus previousStatus) {
if (ApprovalActionType.PUBLISH.getCode().equals(actionType) && previousStatus == PublishStatus.PUBLISHED) {
return PublishStatus.PUBLISHED;
}
if (ApprovalActionType.DELETE.getCode().equals(actionType)) {
return PublishStatus.DELETE_PENDING;
}
if (ApprovalActionType.OFFLINE.getCode().equals(actionType)) {
return PublishStatus.OFFLINE_PENDING;
}
return PublishStatus.PUBLISH_PENDING;
}
/**
* 从审批快照解析提交前真实状态。
*
* @param snapshotJson 审批冻结快照
* @return 提交前真实状态
*/
private PublishStatus resolvePreviousStatus(Map<String, Object> snapshotJson) {
Object status = snapshotJson == null ? null : snapshotJson.get(PREVIOUS_STATUS_KEY);
if (status instanceof String value && !value.isBlank()) {
return PublishStatus.from(value);
}
return PublishStatus.DRAFT;
}
/**
* 从审批快照中提取冻结资源内容。
*
* @param snapshotJson 审批冻结快照
* @return 冻结资源内容
*/
@SuppressWarnings("unchecked")
private Map<String, Object> readResourceSnapshot(Map<String, Object> snapshotJson) {
Object snapshot = snapshotJson == null ? null : snapshotJson.get(SNAPSHOT_KEY);
if (!(snapshot instanceof Map<?, ?> map)) {
throw new BusinessException("审批快照缺少资源内容");
}
return (Map<String, Object>) map;
}
}

View File

@@ -1,11 +1,11 @@
package tech.easyflow.ai.publish;
import com.alibaba.fastjson2.JSON;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.BotCategory;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.BotMcp;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.BotWorkflow;
@@ -26,35 +26,27 @@ import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalSubjectHandler;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysDeptService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Date;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 聊天助手审批处理器。
* 聊天助手生命周期处理器。
*/
@Component
public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
private static final String SNAPSHOT_KEY = "resourceSnapshot";
public class BotApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler<Bot> {
private final BotService botService;
private final BotWorkflowService botWorkflowService;
@@ -63,7 +55,6 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
private final BotMcpService botMcpService;
private final WorkflowService workflowService;
private final DocumentCollectionService documentCollectionService;
private final ApprovalInstanceService approvalInstanceService;
private final CategoryPermissionService categoryPermissionService;
private final ModelService modelService;
private final BotCategoryService botCategoryService;
@@ -84,7 +75,9 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
BotCategoryService botCategoryService,
SysDeptService sysDeptService,
PluginItemService pluginItemService,
McpService mcpService) {
McpService mcpService,
ObjectMapper objectMapper) {
super(approvalInstanceService, objectMapper);
this.botService = botService;
this.botWorkflowService = botWorkflowService;
this.botDocumentCollectionService = botDocumentCollectionService;
@@ -92,7 +85,6 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
this.botMcpService = botMcpService;
this.workflowService = workflowService;
this.documentCollectionService = documentCollectionService;
this.approvalInstanceService = approvalInstanceService;
this.categoryPermissionService = categoryPermissionService;
this.modelService = modelService;
this.botCategoryService = botCategoryService;
@@ -106,69 +98,6 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
return ApprovalResourceType.BOT.getCode();
}
@Override
public ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId) {
Bot bot = requireBot(resourceId);
assertManagePermission(bot);
if (approvalInstanceService.existsActiveInstance(resourceType(), resourceId)) {
throw new BusinessException("当前聊天助手存在未结束审批,请先处理完成");
}
ApprovalActionType approvalActionType = ApprovalActionType.from(actionType);
Map<String, Object> resourceSnapshot = buildResourceSnapshot(bot);
if (approvalActionType == ApprovalActionType.PUBLISH
&& JSON.toJSONString(resourceSnapshot).equals(JSON.toJSONString(bot.getPublishedSnapshotJson()))) {
throw new BusinessException("当前聊天助手没有变更,无需重复发布");
}
ApprovalSubmitRequest request = new ApprovalSubmitRequest();
request.setResourceType(resourceType());
request.setResourceId(resourceId);
request.setActionType(approvalActionType.getCode());
request.setApplicantId(operatorId);
request.setCategoryId(bot.getCategoryId());
request.setDeptId(bot.getDeptId());
request.setSummary((approvalActionType == ApprovalActionType.PUBLISH ? "发布" : "删除") + "聊天助手:" + bot.getTitle());
request.setSnapshotJson(Map.of(SNAPSHOT_KEY, resourceSnapshot));
return request;
}
@Override
public void onSubmitted(ApprovalSubmitCallbackContext context) {
Bot update = new Bot();
update.setId(context.getResourceId());
update.setCurrentApprovalInstanceId(context.getInstanceId());
update.setPublishStatus(resolvePendingStatus(context.getActionType()).getCode());
botService.updateById(update);
}
@Override
public void onApproved(ApprovalCallbackContext context) {
ApprovalInstance instance = context.getInstance();
if (ApprovalActionType.PUBLISH.getCode().equals(instance.getActionType())) {
Bot update = new Bot();
update.setId(instance.getResourceId());
update.setPublishStatus(PublishStatus.PUBLISHED.getCode());
update.setCurrentApprovalInstanceId(null);
update.setPublishedSnapshotJson(readResourceSnapshot(instance));
update.setPublishedAt(new Date());
update.setPublishedBy(context.getOperatorId());
botService.updateById(update);
return;
}
removeBotRelations(instance.getResourceId());
botService.removeById(instance.getResourceId());
}
@Override
public void onRejected(ApprovalCallbackContext context) {
clearPendingStatus(context.getInstance().getResourceId());
}
@Override
public void onRevoked(ApprovalCallbackContext context) {
clearPendingStatus(context.getInstance().getResourceId());
}
@Override
public void assertPublishedAccess(Object identifier, String denyMessage) {
Bot bot = botService.getDetail(String.valueOf(identifier));
@@ -178,24 +107,52 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
}
}
private Bot requireBot(BigInteger id) {
Bot bot = botService.getById(id);
@Override
protected Bot requireResource(BigInteger resourceId) {
Bot bot = botService.getById(resourceId);
if (bot == null) {
throw new BusinessException("聊天助手不存在");
}
return bot;
}
private void assertManagePermission(Bot bot) {
@Override
protected void assertManagePermission(Bot resource) {
LoginAccount account = SaTokenUtil.getLoginAccount();
boolean superAdmin = categoryPermissionService.isCurrentSuperAdmin();
boolean creator = account != null && account.getId() != null && account.getId().equals(bot.getCreatedBy());
boolean creator = account != null && account.getId() != null && account.getId().equals(resource.getCreatedBy());
if (!superAdmin && !creator) {
throw new BusinessException("仅创建者或超级管理员可管理聊天助手");
}
}
private Map<String, Object> buildResourceSnapshot(Bot bot) {
@Override
protected BigInteger getCategoryId(Bot resource) {
return resource.getCategoryId();
}
@Override
protected BigInteger getDeptId(Bot resource) {
return resource.getDeptId();
}
@Override
protected String getTitle(Bot resource) {
return resource.getTitle();
}
@Override
protected PublishStatus getCurrentStatus(Bot resource) {
return PublishStatus.from(resource.getPublishStatus());
}
@Override
protected Map<String, Object> getPublishedSnapshot(Bot resource) {
return resource.getPublishedSnapshotJson();
}
@Override
protected Map<String, Object> buildResourceSnapshot(Bot bot) {
Model model = resolveModel(bot.getModelId());
BotCategory category = resolveCategory(bot.getCategoryId());
SysDept dept = resolveDept(bot.getDeptId());
@@ -228,13 +185,59 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
return snapshot;
}
@Override
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
Bot update = new Bot();
update.setId(resourceId);
update.setPublishStatus(publishStatus.getCode());
update.setCurrentApprovalInstanceId(currentApprovalInstanceId);
botService.updateById(update);
}
@Override
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
Bot update = new Bot();
update.setId(resourceId);
update.setPublishStatus(PublishStatus.PUBLISHED.getCode());
update.setCurrentApprovalInstanceId(null);
update.setPublishedSnapshotJson(resourceSnapshot);
update.setPublishedAt(new java.util.Date());
update.setPublishedBy(operatorId);
botService.updateById(update);
}
@Override
protected void markResourceOffline(BigInteger resourceId) {
Bot update = new Bot();
update.setId(resourceId);
update.setPublishStatus(PublishStatus.OFFLINE.getCode());
update.setCurrentApprovalInstanceId(null);
botService.updateById(update);
}
@Override
protected void removeResource(BigInteger resourceId) {
botService.removeById(resourceId);
}
@Override
protected String resourceLabel() {
return "聊天助手";
}
@Override
protected void beforeRemove(BigInteger resourceId) {
removeBotRelations(resourceId);
}
private List<Map<String, Object>> buildWorkflowBindings(BigInteger botId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotWorkflow::getBotId, botId);
List<BotWorkflow> relations = botWorkflowService.getMapper().selectListWithRelationsByQuery(queryWrapper);
relations.sort(Comparator.comparing(BotWorkflow::getWorkflowId, Comparator.nullsLast(BigInteger::compareTo)));
List<Map<String, Object>> result = new ArrayList<>();
for (BotWorkflow relation : relations) {
Workflow workflow = relation.getWorkflow();
if (workflow == null || !PublishStatus.from(workflow.getPublishStatus()).isExternallyVisible()) {
if (workflow == null || !PublishStatus.from(workflow.getPublishStatus()).isSelectableForBot()) {
throw new BusinessException("聊天助手绑定的工作流未发布,无法发布聊天助手");
}
Map<String, Object> item = new LinkedHashMap<>();
@@ -248,10 +251,11 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
private List<Map<String, Object>> buildKnowledgeBindings(BigInteger botId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotDocumentCollection::getBotId, botId);
List<BotDocumentCollection> relations = botDocumentCollectionService.getMapper().selectListWithRelationsByQuery(queryWrapper);
relations.sort(Comparator.comparing(BotDocumentCollection::getDocumentCollectionId, Comparator.nullsLast(BigInteger::compareTo)));
List<Map<String, Object>> result = new ArrayList<>();
for (BotDocumentCollection relation : relations) {
DocumentCollection knowledge = relation.getKnowledge();
if (knowledge == null || !PublishStatus.from(knowledge.getPublishStatus()).isExternallyVisible()) {
if (knowledge == null || !PublishStatus.from(knowledge.getPublishStatus()).isSelectableForBot()) {
throw new BusinessException("聊天助手绑定的知识库未发布,无法发布聊天助手");
}
Map<String, Object> item = new LinkedHashMap<>();
@@ -266,6 +270,7 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
private List<Map<String, Object>> buildPluginBindings(BigInteger botId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotPlugin::getBotId, botId);
List<BotPlugin> relations = botPluginService.list(queryWrapper);
relations.sort(Comparator.comparing(BotPlugin::getPluginItemId, Comparator.nullsLast(BigInteger::compareTo)));
List<Map<String, Object>> result = new ArrayList<>();
for (BotPlugin relation : relations) {
PluginItem pluginItem = pluginItemService.getById(relation.getPluginItemId());
@@ -274,7 +279,7 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("pluginItemId", relation.getPluginItemId());
item.put("pluginItemName", resolvePluginName(pluginItem));
item.put("pluginItemName", pluginItem.getName());
result.add(item);
}
return result;
@@ -283,156 +288,85 @@ public class BotApprovalSubjectHandler implements ApprovalSubjectHandler {
private List<Map<String, Object>> buildMcpBindings(BigInteger botId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotMcp::getBotId, botId);
List<BotMcp> relations = botMcpService.list(queryWrapper);
relations.sort(Comparator.comparing(BotMcp::getMcpId, Comparator.nullsLast(BigInteger::compareTo)));
List<Map<String, Object>> result = new ArrayList<>();
for (BotMcp relation : relations) {
Mcp mcp = mcpService.getById(relation.getMcpId());
if (mcp == null) {
throw new BusinessException("聊天助手绑定的MCP不存在无法发布聊天助手");
throw new BusinessException("聊天助手绑定的 MCP 不存在,无法发布聊天助手");
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("mcpId", relation.getMcpId());
item.put("mcpName", mcp.getTitle());
item.put("mcpToolName", relation.getMcpToolName());
item.put("mcpToolDescription", relation.getMcpToolDescription());
result.add(item);
}
return result;
}
/**
* 解析聊天助手分类。
*
* @param categoryId 分类 ID
* @return 分类实体,不存在时返回 {@code null}
*/
private BotCategory resolveCategory(BigInteger categoryId) {
if (categoryId == null) {
return null;
}
return botCategoryService.getById(categoryId);
}
/**
* 解析部门信息。
*
* @param deptId 部门 ID
* @return 部门实体,不存在时返回 {@code null}
*/
private SysDept resolveDept(BigInteger deptId) {
if (deptId == null) {
return null;
}
return sysDeptService.getById(deptId);
}
/**
* 解析聊天模型。
*
* @param modelId 模型 ID
* @return 模型实体
*/
private Model resolveModel(BigInteger modelId) {
if (modelId == null) {
throw new BusinessException("聊天助手未配置模型,无法提交审批");
}
Model model = modelService.getById(modelId);
if (model == null) {
throw new BusinessException("聊天助手绑定的模型不存在,无法提交审批");
}
return model;
}
/**
* 生成模型展示名称。
*
* @param model 模型实体
* @return 模型名称
*/
private String resolveModelName(Model model) {
if (model.getTitle() != null && !model.getTitle().isBlank()) {
return model.getTitle();
}
return model.getModelName();
}
/**
* 提取系统提示词。
*
* @param bot 聊天助手
* @return 系统提示词
*/
private String resolveSystemPrompt(Bot bot) {
if (bot.getModelOptions() == null) {
return null;
}
Object prompt = bot.getModelOptions().get(Bot.KEY_SYSTEM_PROMPT);
return prompt == null ? null : String.valueOf(prompt);
}
/**
* 读取数值配置项。
*
* @param options 配置 map
* @param key 配置键
* @return 数值配置,不存在时返回 {@code null}
*/
private Number readNumberOption(Map<String, Object> options, String key) {
if (options == null) {
return null;
}
Object value = options.get(key);
if (value instanceof Number number) {
return number;
}
return null;
}
/**
* 生成插件工具展示名称。
*
* @param pluginItem 插件工具实体
* @return 展示名称
*/
private String resolvePluginName(PluginItem pluginItem) {
if (pluginItem.getName() != null && !pluginItem.getName().isBlank()) {
return pluginItem.getName();
}
return pluginItem.getEnglishName();
}
@SuppressWarnings("unchecked")
private Map<String, Object> readResourceSnapshot(ApprovalInstance instance) {
Object snapshot = instance.getSnapshotJson() == null ? null : instance.getSnapshotJson().get(SNAPSHOT_KEY);
if (!(snapshot instanceof Map<?, ?> map)) {
throw new BusinessException("审批快照缺少聊天助手发布内容");
}
return (Map<String, Object>) map;
}
private PublishStatus resolvePendingStatus(String actionType) {
return ApprovalActionType.DELETE.getCode().equals(actionType)
? PublishStatus.DELETE_PENDING
: PublishStatus.PUBLISH_PENDING;
}
private void clearPendingStatus(BigInteger botId) {
Bot bot = botService.getById(botId);
if (bot == null) {
return;
}
Bot update = new Bot();
update.setId(botId);
update.setCurrentApprovalInstanceId(null);
update.setPublishStatus(bot.getPublishedSnapshotJson() == null || bot.getPublishedSnapshotJson().isEmpty()
? PublishStatus.DRAFT.getCode()
: PublishStatus.PUBLISHED.getCode());
botService.updateById(update);
}
private void removeBotRelations(BigInteger botId) {
botDocumentCollectionService.remove(QueryWrapper.create().eq(BotDocumentCollection::getBotId, botId));
botWorkflowService.remove(QueryWrapper.create().eq(BotWorkflow::getBotId, botId));
botPluginService.remove(QueryWrapper.create().eq(BotPlugin::getBotId, botId));
botMcpService.remove(QueryWrapper.create().eq(BotMcp::getBotId, botId));
}
private BotCategory resolveCategory(BigInteger categoryId) {
if (categoryId == null) {
return null;
}
return botCategoryService.getById(categoryId);
}
private SysDept resolveDept(BigInteger deptId) {
if (deptId == null) {
return null;
}
return sysDeptService.getById(deptId);
}
private Model resolveModel(BigInteger modelId) {
if (modelId == null) {
throw new BusinessException("聊天助手未配置模型,无法提交审批");
}
Model model = modelService.getById(modelId);
if (model == null) {
throw new BusinessException("聊天助手关联模型不存在,无法提交审批");
}
return model;
}
private String resolveModelName(Model model) {
String providerName = model.getModelProvider() == null ? null : model.getModelProvider().getProviderName();
if (providerName == null || providerName.isBlank()) {
return model.getModelName();
}
return providerName + " / " + model.getModelName();
}
private String resolveSystemPrompt(Bot bot) {
if (bot.getModelOptions() == null) {
return null;
}
Object value = bot.getModelOptions().get("systemPrompt");
return value == null ? null : String.valueOf(value);
}
private Number readNumberOption(Map<String, Object> options, String key) {
if (options == null || !options.containsKey(key)) {
return null;
}
Object value = options.get(key);
if (value instanceof Number number) {
return number;
}
if (value instanceof String string && !string.isBlank()) {
try {
return Double.valueOf(string);
} catch (NumberFormatException ignore) {
return null;
}
}
return null;
}
}

View File

@@ -1,8 +1,11 @@
package tech.easyflow.ai.publish;
import org.springframework.stereotype.Service;
import tech.easyflow.approval.annotation.ApprovalAction;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import java.math.BigInteger;
@@ -12,36 +15,58 @@ import java.math.BigInteger;
@Service
public class BotPublishAppService {
private final AiResourceLifecycleService aiResourceLifecycleService;
public BotPublishAppService(AiResourceLifecycleService aiResourceLifecycleService) {
this.aiResourceLifecycleService = aiResourceLifecycleService;
}
/**
* 提交聊天助手发布审批。
*
* @param id 助手 ID
* @return 助手 ID
* @return 动作执行结果
*/
@ApprovalAction(
resourceType = "BOT",
actionType = "PUBLISH",
idExpr = "#id"
)
public BigInteger submitPublishApproval(BigInteger id) {
public ApprovalActionResult submitPublishApproval(BigInteger id) {
assertId(id);
return id;
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.BOT.getCode(),
id,
ApprovalActionType.PUBLISH.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
/**
* 提交聊天助手下线审批。
*
* @param id 助手 ID
* @return 动作执行结果
*/
public ApprovalActionResult submitOfflineApproval(BigInteger id) {
assertId(id);
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.BOT.getCode(),
id,
ApprovalActionType.OFFLINE.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
/**
* 提交聊天助手删除审批。
*
* @param id 助手 ID
* @return 助手 ID
* @return 动作执行结果
*/
@ApprovalAction(
resourceType = "BOT",
actionType = "DELETE",
idExpr = "#id"
)
public BigInteger submitDeleteApproval(BigInteger id) {
public ApprovalActionResult submitDeleteApproval(BigInteger id) {
assertId(id);
return id;
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.BOT.getCode(),
id,
ApprovalActionType.DELETE.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
private void assertId(BigInteger id) {

View File

@@ -1,6 +1,6 @@
package tech.easyflow.ai.publish;
import com.alibaba.fastjson2.JSON;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.BotDocumentCollection;
@@ -12,43 +12,36 @@ import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.DocumentCollectionCategoryService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalSubjectHandler;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.VisibilityScope;
import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.system.service.ResourceAccessService;
import tech.easyflow.system.service.SysDeptService;
import java.math.BigInteger;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 知识库审批处理器。
* 知识库生命周期处理器。
*/
@Component
public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
private static final String SNAPSHOT_KEY = "resourceSnapshot";
public class KnowledgeApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler<DocumentCollection> {
private final DocumentCollectionService documentCollectionService;
private final ResourceAccessService resourceAccessService;
private final ApprovalInstanceService approvalInstanceService;
private final BotDocumentCollectionService botDocumentCollectionService;
private final ModelService modelService;
private final DocumentCollectionCategoryService documentCollectionCategoryService;
private final SysDeptService sysDeptService;
private final ResourceOfflineImpactService resourceOfflineImpactService;
public KnowledgeApprovalSubjectHandler(DocumentCollectionService documentCollectionService,
ResourceAccessService resourceAccessService,
@@ -56,14 +49,17 @@ public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
BotDocumentCollectionService botDocumentCollectionService,
ModelService modelService,
DocumentCollectionCategoryService documentCollectionCategoryService,
SysDeptService sysDeptService) {
SysDeptService sysDeptService,
ResourceOfflineImpactService resourceOfflineImpactService,
ObjectMapper objectMapper) {
super(approvalInstanceService, objectMapper);
this.documentCollectionService = documentCollectionService;
this.resourceAccessService = resourceAccessService;
this.approvalInstanceService = approvalInstanceService;
this.botDocumentCollectionService = botDocumentCollectionService;
this.modelService = modelService;
this.documentCollectionCategoryService = documentCollectionCategoryService;
this.sysDeptService = sysDeptService;
this.resourceOfflineImpactService = resourceOfflineImpactService;
}
@Override
@@ -71,72 +67,6 @@ public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
return ApprovalResourceType.KNOWLEDGE.getCode();
}
@Override
public ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId) {
DocumentCollection knowledge = requireKnowledge(resourceId);
resourceAccessService.assertAccess(CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.MANAGE, "无权限管理知识库");
if (approvalInstanceService.existsActiveInstance(resourceType(), resourceId)) {
throw new BusinessException("当前知识库存在未结束审批,请先处理完成");
}
ApprovalActionType approvalActionType = ApprovalActionType.from(actionType);
Map<String, Object> resourceSnapshot = buildResourceSnapshot(knowledge);
if (approvalActionType == ApprovalActionType.PUBLISH
&& JSON.toJSONString(resourceSnapshot).equals(JSON.toJSONString(knowledge.getPublishedSnapshotJson()))) {
throw new BusinessException("当前知识库没有变更,无需重复发布");
}
if (approvalActionType == ApprovalActionType.DELETE && hasBotBinding(resourceId)) {
throw new BusinessException("此知识库还关联着bot请先取消关联");
}
ApprovalSubmitRequest request = new ApprovalSubmitRequest();
request.setResourceType(resourceType());
request.setResourceId(resourceId);
request.setActionType(approvalActionType.getCode());
request.setApplicantId(operatorId);
request.setCategoryId(knowledge.getCategoryId());
request.setDeptId(knowledge.getDeptId());
request.setSummary((approvalActionType == ApprovalActionType.PUBLISH ? "发布" : "删除") + "知识库:" + knowledge.getTitle());
request.setSnapshotJson(Map.of(SNAPSHOT_KEY, resourceSnapshot));
return request;
}
@Override
public void onSubmitted(ApprovalSubmitCallbackContext context) {
DocumentCollection update = new DocumentCollection();
update.setId(context.getResourceId());
update.setCurrentApprovalInstanceId(context.getInstanceId());
update.setPublishStatus(resolvePendingStatus(context.getActionType()).getCode());
documentCollectionService.updateById(update);
}
@Override
public void onApproved(ApprovalCallbackContext context) {
ApprovalInstance instance = context.getInstance();
if (ApprovalActionType.PUBLISH.getCode().equals(instance.getActionType())) {
DocumentCollection update = new DocumentCollection();
update.setId(instance.getResourceId());
update.setPublishStatus(PublishStatus.PUBLISHED.getCode());
update.setCurrentApprovalInstanceId(null);
update.setPublishedSnapshotJson(readResourceSnapshot(instance));
update.setPublishedAt(new Date());
update.setPublishedBy(context.getOperatorId());
documentCollectionService.updateById(update);
return;
}
documentCollectionService.removeById(instance.getResourceId());
}
@Override
public void onRejected(ApprovalCallbackContext context) {
clearPendingStatus(context.getInstance().getResourceId());
}
@Override
public void onRevoked(ApprovalCallbackContext context) {
clearPendingStatus(context.getInstance().getResourceId());
}
@Override
public void assertPublishedAccess(Object identifier, String denyMessage) {
DocumentCollection collection = documentCollectionService.getDetail(String.valueOf(identifier));
@@ -146,20 +76,47 @@ public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
}
}
private DocumentCollection requireKnowledge(BigInteger id) {
DocumentCollection knowledge = documentCollectionService.getById(id);
@Override
protected DocumentCollection requireResource(BigInteger resourceId) {
DocumentCollection knowledge = documentCollectionService.getById(resourceId);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
return knowledge;
}
private boolean hasBotBinding(BigInteger knowledgeId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId);
return botDocumentCollectionService.exists(queryWrapper);
@Override
protected void assertManagePermission(DocumentCollection resource) {
resourceAccessService.assertAccess(CategoryResourceType.KNOWLEDGE, resource, ResourceAction.MANAGE, "无权限管理知识库");
}
private Map<String, Object> buildResourceSnapshot(DocumentCollection collection) {
@Override
protected BigInteger getCategoryId(DocumentCollection resource) {
return resource.getCategoryId();
}
@Override
protected BigInteger getDeptId(DocumentCollection resource) {
return resource.getDeptId();
}
@Override
protected String getTitle(DocumentCollection resource) {
return resource.getTitle();
}
@Override
protected PublishStatus getCurrentStatus(DocumentCollection resource) {
return PublishStatus.from(resource.getPublishStatus());
}
@Override
protected Map<String, Object> getPublishedSnapshot(DocumentCollection resource) {
return resource.getPublishedSnapshotJson();
}
@Override
protected Map<String, Object> buildResourceSnapshot(DocumentCollection collection) {
Model vectorModel = resolveModel(collection.getVectorEmbedModelId(), "知识库向量模型不存在,无法提交审批");
Model rerankModel = resolveOptionalModel(collection.getRerankModelId(), "知识库重排模型不存在,无法提交审批");
DocumentCollectionCategory category = resolveCategory(collection.getCategoryId());
@@ -197,12 +154,82 @@ public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
return snapshot;
}
/**
* 解析知识库分类信息。
*
* @param categoryId 分类 ID
* @return 分类实体,不存在时返回 {@code null}
*/
@Override
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
DocumentCollection update = new DocumentCollection();
update.setId(resourceId);
update.setPublishStatus(publishStatus.getCode());
update.setCurrentApprovalInstanceId(currentApprovalInstanceId);
documentCollectionService.updateById(update);
}
@Override
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
DocumentCollection update = new DocumentCollection();
update.setId(resourceId);
update.setPublishStatus(PublishStatus.PUBLISHED.getCode());
update.setCurrentApprovalInstanceId(null);
update.setPublishedSnapshotJson(resourceSnapshot);
update.setPublishedAt(new java.util.Date());
update.setPublishedBy(operatorId);
documentCollectionService.updateById(update);
}
@Override
protected void markResourceOffline(BigInteger resourceId) {
DocumentCollection update = new DocumentCollection();
update.setId(resourceId);
update.setPublishStatus(PublishStatus.OFFLINE.getCode());
update.setCurrentApprovalInstanceId(null);
documentCollectionService.updateById(update);
}
@Override
protected void removeResource(BigInteger resourceId) {
documentCollectionService.removeById(resourceId);
}
@Override
protected String resourceLabel() {
return "知识库";
}
@Override
protected void enrichOfflineSnapshot(DocumentCollection resource, Map<String, Object> snapshot) {
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkKnowledgeImpact(resource.getId());
if (!impact.isCanProceed()) {
throw new BusinessException(buildWorkflowUsageBlockMessage(impact));
}
if (impact.isHasBotBindings()) {
snapshot.put("botBindings", impact.getBotBindings());
}
}
@Override
protected void validateDelete(DocumentCollection resource, PublishStatus currentStatus) {
if (hasBotBinding(resource.getId())) {
throw new BusinessException("此知识库还关联着bot请先取消关联");
}
}
@Override
protected void afterOffline(BigInteger resourceId) {
resourceOfflineImpactService.unbindKnowledgeFromBots(resourceId);
}
private boolean hasBotBinding(BigInteger knowledgeId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId);
return botDocumentCollectionService.exists(queryWrapper);
}
private String buildWorkflowUsageBlockMessage(OfflineImpactCheckVo impact) {
String names = impact.getWorkflowUsages().stream()
.map(item -> item.getTitle() == null ? String.valueOf(item.getId()) : item.getTitle())
.reduce((left, right) -> left + "" + right)
.orElse("未知工作流");
return "当前知识库被以下工作流使用:" + names + ",请先在工作流中调整解绑后再下线";
}
private DocumentCollectionCategory resolveCategory(BigInteger categoryId) {
if (categoryId == null) {
return null;
@@ -210,12 +237,6 @@ public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
return documentCollectionCategoryService.getById(categoryId);
}
/**
* 解析部门信息。
*
* @param deptId 部门 ID
* @return 部门实体,不存在时返回 {@code null}
*/
private SysDept resolveDept(BigInteger deptId) {
if (deptId == null) {
return null;
@@ -223,13 +244,6 @@ public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
return sysDeptService.getById(deptId);
}
/**
* 解析必填模型。
*
* @param modelId 模型 ID
* @param errorMessage 模型不存在时抛出的提示
* @return 模型实体
*/
private Model resolveModel(BigInteger modelId, String errorMessage) {
if (modelId == null) {
throw new BusinessException(errorMessage);
@@ -241,13 +255,6 @@ public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
return model;
}
/**
* 解析可选模型。
*
* @param modelId 模型 ID
* @param errorMessage 模型不存在时抛出的提示
* @return 模型实体,不存在时返回 {@code null}
*/
private Model resolveOptionalModel(BigInteger modelId, String errorMessage) {
if (modelId == null) {
return null;
@@ -259,75 +266,30 @@ public class KnowledgeApprovalSubjectHandler implements ApprovalSubjectHandler {
return model;
}
/**
* 生成模型展示名称。
*
* @param model 模型实体
* @return 模型名称
*/
private String resolveModelName(Model model) {
if (model == null) {
return null;
}
if (model.getTitle() != null && !model.getTitle().isBlank()) {
return model.getTitle();
String providerName = model.getModelProvider() == null ? null : model.getModelProvider().getProviderName();
if (providerName == null || providerName.isBlank()) {
return model.getModelName();
}
return model.getModelName();
return providerName + " / " + model.getModelName();
}
/**
* 解析知识库可见范围文案。
*
* @param visibilityScope 可见范围编码
* @return 展示文案
*/
private String resolveVisibilityScopeLabel(String visibilityScope) {
return switch (VisibilityScope.fromOrDefault(visibilityScope, VisibilityScope.PRIVATE)) {
case DEPT -> "部门";
case PUBLIC -> "公开";
default -> "个人";
};
}
/**
* 解析知识库类型文案。
*
* @param collectionType 知识库类型
* @return 展示文案
*/
private String resolveCollectionTypeLabel(String collectionType) {
if (DocumentCollection.TYPE_FAQ.equalsIgnoreCase(collectionType)) {
if ("FAQ".equalsIgnoreCase(collectionType)) {
return "FAQ";
}
return "文档";
}
@SuppressWarnings("unchecked")
private Map<String, Object> readResourceSnapshot(ApprovalInstance instance) {
Object snapshot = instance.getSnapshotJson() == null ? null : instance.getSnapshotJson().get(SNAPSHOT_KEY);
if (!(snapshot instanceof Map<?, ?> map)) {
throw new BusinessException("审批快照缺少知识库发布内容");
}
return (Map<String, Object>) map;
}
private PublishStatus resolvePendingStatus(String actionType) {
return ApprovalActionType.DELETE.getCode().equals(actionType)
? PublishStatus.DELETE_PENDING
: PublishStatus.PUBLISH_PENDING;
}
private void clearPendingStatus(BigInteger knowledgeId) {
DocumentCollection collection = documentCollectionService.getById(knowledgeId);
if (collection == null) {
return;
}
DocumentCollection update = new DocumentCollection();
update.setId(knowledgeId);
update.setCurrentApprovalInstanceId(null);
update.setPublishStatus(collection.getPublishedSnapshotJson() == null || collection.getPublishedSnapshotJson().isEmpty()
? PublishStatus.DRAFT.getCode()
: PublishStatus.PUBLISHED.getCode());
documentCollectionService.updateById(update);
private String resolveVisibilityScopeLabel(String visibilityScope) {
VisibilityScope scope = VisibilityScope.from(visibilityScope);
return switch (scope) {
case PRIVATE -> "仅自己";
case DEPT -> "本部门";
case PUBLIC -> "公开";
};
}
}

View File

@@ -1,8 +1,16 @@
package tech.easyflow.ai.publish;
import org.springframework.stereotype.Service;
import tech.easyflow.approval.annotation.ApprovalAction;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import java.math.BigInteger;
@@ -12,36 +20,82 @@ import java.math.BigInteger;
@Service
public class KnowledgePublishAppService {
private final DocumentCollectionService documentCollectionService;
private final ResourceOfflineImpactService resourceOfflineImpactService;
private final AiResourceLifecycleService aiResourceLifecycleService;
public KnowledgePublishAppService(DocumentCollectionService documentCollectionService,
ResourceOfflineImpactService resourceOfflineImpactService,
AiResourceLifecycleService aiResourceLifecycleService) {
this.documentCollectionService = documentCollectionService;
this.resourceOfflineImpactService = resourceOfflineImpactService;
this.aiResourceLifecycleService = aiResourceLifecycleService;
}
/**
* 提交知识库发布审批。
*
* @param id 知识库 ID
* @return 知识库 ID
* @return 动作执行结果
*/
@ApprovalAction(
resourceType = "KNOWLEDGE",
actionType = "PUBLISH",
idExpr = "#id"
)
public BigInteger submitPublishApproval(BigInteger id) {
public ApprovalActionResult submitPublishApproval(BigInteger id) {
assertId(id);
return id;
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.KNOWLEDGE.getCode(),
id,
ApprovalActionType.PUBLISH.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
/**
* 提交知识库下线审批。
*
* @param id 知识库 ID
* @return 动作执行结果
*/
public ApprovalActionResult submitOfflineApproval(BigInteger id) {
assertId(id);
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.KNOWLEDGE.getCode(),
id,
ApprovalActionType.OFFLINE.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
/**
* 检查知识库下线影响。
*
* @param id 知识库 ID
* @return 下线影响结果
*/
public OfflineImpactCheckVo checkOfflineImpact(BigInteger id) {
assertId(id);
DocumentCollection knowledge = documentCollectionService.getById(id);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
if (PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("当前知识库尚未发布,无法下线");
}
return resourceOfflineImpactService.checkKnowledgeImpact(id);
}
/**
* 提交知识库删除审批。
*
* @param id 知识库 ID
* @return 知识库 ID
* @return 动作执行结果
*/
@ApprovalAction(
resourceType = "KNOWLEDGE",
actionType = "DELETE",
idExpr = "#id"
)
public BigInteger submitDeleteApproval(BigInteger id) {
public ApprovalActionResult submitDeleteApproval(BigInteger id) {
assertId(id);
return id;
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.KNOWLEDGE.getCode(),
id,
ApprovalActionType.DELETE.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
private void assertId(BigInteger id) {

View File

@@ -1,52 +1,48 @@
package tech.easyflow.ai.publish;
import com.alibaba.fastjson2.JSON;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.BotWorkflow;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalSubjectHandler;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 工作流审批处理器。
* 工作流生命周期处理器。
*/
@Component
public class WorkflowApprovalSubjectHandler implements ApprovalSubjectHandler {
private static final String SNAPSHOT_KEY = "resourceSnapshot";
public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler<Workflow> {
private final WorkflowService workflowService;
private final ResourceAccessService resourceAccessService;
private final ApprovalInstanceService approvalInstanceService;
private final BotWorkflowService botWorkflowService;
private final ResourceOfflineImpactService resourceOfflineImpactService;
public WorkflowApprovalSubjectHandler(WorkflowService workflowService,
ResourceAccessService resourceAccessService,
ApprovalInstanceService approvalInstanceService,
BotWorkflowService botWorkflowService) {
BotWorkflowService botWorkflowService,
ResourceOfflineImpactService resourceOfflineImpactService,
ObjectMapper objectMapper) {
super(approvalInstanceService, objectMapper);
this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService;
this.approvalInstanceService = approvalInstanceService;
this.botWorkflowService = botWorkflowService;
this.resourceOfflineImpactService = resourceOfflineImpactService;
}
@Override
@@ -54,72 +50,6 @@ public class WorkflowApprovalSubjectHandler implements ApprovalSubjectHandler {
return ApprovalResourceType.WORKFLOW.getCode();
}
@Override
public ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId) {
Workflow workflow = requireWorkflow(resourceId);
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.MANAGE, "无权限管理工作流");
if (approvalInstanceService.existsActiveInstance(resourceType(), resourceId)) {
throw new BusinessException("当前工作流存在未结束审批,请先处理完成");
}
ApprovalActionType approvalActionType = ApprovalActionType.from(actionType);
Map<String, Object> resourceSnapshot = buildResourceSnapshot(workflow);
if (approvalActionType == ApprovalActionType.PUBLISH
&& JSON.toJSONString(resourceSnapshot).equals(JSON.toJSONString(workflow.getPublishedSnapshotJson()))) {
throw new BusinessException("当前工作流没有变更,无需重复发布");
}
if (approvalActionType == ApprovalActionType.DELETE && hasBotBinding(resourceId)) {
throw new BusinessException("此工作流还关联有bot请先取消关联后再删除");
}
ApprovalSubmitRequest request = new ApprovalSubmitRequest();
request.setResourceType(resourceType());
request.setResourceId(resourceId);
request.setActionType(approvalActionType.getCode());
request.setApplicantId(operatorId);
request.setCategoryId(workflow.getCategoryId());
request.setDeptId(workflow.getDeptId());
request.setSummary((approvalActionType == ApprovalActionType.PUBLISH ? "发布" : "删除") + "工作流:" + workflow.getTitle());
request.setSnapshotJson(Map.of(SNAPSHOT_KEY, resourceSnapshot));
return request;
}
@Override
public void onSubmitted(ApprovalSubmitCallbackContext context) {
Workflow update = new Workflow();
update.setId(context.getResourceId());
update.setCurrentApprovalInstanceId(context.getInstanceId());
update.setPublishStatus(resolvePendingStatus(context.getActionType()).getCode());
workflowService.updateById(update);
}
@Override
public void onApproved(ApprovalCallbackContext context) {
ApprovalInstance instance = context.getInstance();
if (ApprovalActionType.PUBLISH.getCode().equals(instance.getActionType())) {
Workflow update = new Workflow();
update.setId(instance.getResourceId());
update.setPublishStatus(PublishStatus.PUBLISHED.getCode());
update.setCurrentApprovalInstanceId(null);
update.setPublishedSnapshotJson(readResourceSnapshot(instance));
update.setPublishedAt(new Date());
update.setPublishedBy(context.getOperatorId());
workflowService.updateById(update);
return;
}
workflowService.removeById(instance.getResourceId());
}
@Override
public void onRejected(ApprovalCallbackContext context) {
clearPendingStatus(context.getInstance().getResourceId());
}
@Override
public void onRevoked(ApprovalCallbackContext context) {
clearPendingStatus(context.getInstance().getResourceId());
}
@Override
public void assertPublishedAccess(Object identifier, String denyMessage) {
Workflow workflow = workflowService.getDetail(String.valueOf(identifier));
@@ -129,62 +59,125 @@ public class WorkflowApprovalSubjectHandler implements ApprovalSubjectHandler {
}
}
private Workflow requireWorkflow(BigInteger id) {
Workflow workflow = workflowService.getById(id);
@Override
protected Workflow requireResource(BigInteger resourceId) {
Workflow workflow = workflowService.getById(resourceId);
if (workflow == null) {
throw new BusinessException("工作流不存在");
}
return workflow;
}
@Override
protected void assertManagePermission(Workflow resource) {
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, resource, ResourceAction.MANAGE, "无权限管理工作流");
}
@Override
protected BigInteger getCategoryId(Workflow resource) {
return resource.getCategoryId();
}
@Override
protected BigInteger getDeptId(Workflow resource) {
return resource.getDeptId();
}
@Override
protected String getTitle(Workflow resource) {
return resource.getTitle();
}
@Override
protected PublishStatus getCurrentStatus(Workflow resource) {
return PublishStatus.from(resource.getPublishStatus());
}
@Override
protected Map<String, Object> getPublishedSnapshot(Workflow resource) {
return resource.getPublishedSnapshotJson();
}
@Override
protected Map<String, Object> buildResourceSnapshot(Workflow resource) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", resource.getId());
snapshot.put("alias", resource.getAlias());
snapshot.put("deptId", resource.getDeptId());
snapshot.put("tenantId", resource.getTenantId());
snapshot.put("title", resource.getTitle());
snapshot.put("description", resource.getDescription());
snapshot.put("icon", resource.getIcon());
snapshot.put("content", resource.getContent());
snapshot.put("englishName", resource.getEnglishName());
snapshot.put("status", resource.getStatus());
snapshot.put("categoryId", resource.getCategoryId());
snapshot.put("visibilityScope", resource.getVisibilityScope());
return snapshot;
}
@Override
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
Workflow update = new Workflow();
update.setId(resourceId);
update.setPublishStatus(publishStatus.getCode());
update.setCurrentApprovalInstanceId(currentApprovalInstanceId);
workflowService.updateById(update);
}
@Override
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
Workflow update = new Workflow();
update.setId(resourceId);
update.setPublishStatus(PublishStatus.PUBLISHED.getCode());
update.setCurrentApprovalInstanceId(null);
update.setPublishedSnapshotJson(resourceSnapshot);
update.setPublishedAt(new java.util.Date());
update.setPublishedBy(operatorId);
workflowService.updateById(update);
}
@Override
protected void markResourceOffline(BigInteger resourceId) {
Workflow update = new Workflow();
update.setId(resourceId);
update.setPublishStatus(PublishStatus.OFFLINE.getCode());
update.setCurrentApprovalInstanceId(null);
workflowService.updateById(update);
}
@Override
protected void removeResource(BigInteger resourceId) {
workflowService.removeById(resourceId);
}
@Override
protected String resourceLabel() {
return "工作流";
}
@Override
protected void enrichOfflineSnapshot(Workflow resource, Map<String, Object> snapshot) {
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
if (impact.isHasBotBindings()) {
snapshot.put("botBindings", impact.getBotBindings());
}
}
@Override
protected void validateDelete(Workflow resource, PublishStatus currentStatus) {
if (hasBotBinding(resource.getId())) {
throw new BusinessException("此工作流还关联有bot请先取消关联后再删除");
}
}
@Override
protected void afterOffline(BigInteger resourceId) {
resourceOfflineImpactService.unbindWorkflowFromBots(resourceId);
}
private boolean hasBotBinding(BigInteger workflowId) {
QueryWrapper queryWrapper = QueryWrapper.create().eq(BotWorkflow::getWorkflowId, workflowId);
return botWorkflowService.exists(queryWrapper);
}
private Map<String, Object> buildResourceSnapshot(Workflow workflow) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", workflow.getId());
snapshot.put("alias", workflow.getAlias());
snapshot.put("deptId", workflow.getDeptId());
snapshot.put("tenantId", workflow.getTenantId());
snapshot.put("title", workflow.getTitle());
snapshot.put("description", workflow.getDescription());
snapshot.put("icon", workflow.getIcon());
snapshot.put("content", workflow.getContent());
snapshot.put("englishName", workflow.getEnglishName());
snapshot.put("status", workflow.getStatus());
snapshot.put("categoryId", workflow.getCategoryId());
snapshot.put("visibilityScope", workflow.getVisibilityScope());
return snapshot;
}
@SuppressWarnings("unchecked")
private Map<String, Object> readResourceSnapshot(ApprovalInstance instance) {
Object snapshot = instance.getSnapshotJson() == null ? null : instance.getSnapshotJson().get(SNAPSHOT_KEY);
if (!(snapshot instanceof Map<?, ?> map)) {
throw new BusinessException("审批快照缺少工作流发布内容");
}
return (Map<String, Object>) map;
}
private PublishStatus resolvePendingStatus(String actionType) {
return ApprovalActionType.DELETE.getCode().equals(actionType)
? PublishStatus.DELETE_PENDING
: PublishStatus.PUBLISH_PENDING;
}
private void clearPendingStatus(BigInteger workflowId) {
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
return;
}
Workflow update = new Workflow();
update.setId(workflowId);
update.setCurrentApprovalInstanceId(null);
update.setPublishStatus(workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()
? PublishStatus.DRAFT.getCode()
: PublishStatus.PUBLISHED.getCode());
workflowService.updateById(update);
}
}

View File

@@ -1,10 +1,16 @@
package tech.easyflow.ai.publish;
import org.springframework.stereotype.Service;
import tech.easyflow.approval.annotation.ApprovalAction;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import java.math.BigInteger;
@@ -14,36 +20,82 @@ import java.math.BigInteger;
@Service
public class WorkflowPublishAppService {
private final WorkflowService workflowService;
private final ResourceOfflineImpactService resourceOfflineImpactService;
private final AiResourceLifecycleService aiResourceLifecycleService;
public WorkflowPublishAppService(WorkflowService workflowService,
ResourceOfflineImpactService resourceOfflineImpactService,
AiResourceLifecycleService aiResourceLifecycleService) {
this.workflowService = workflowService;
this.resourceOfflineImpactService = resourceOfflineImpactService;
this.aiResourceLifecycleService = aiResourceLifecycleService;
}
/**
* 提交工作流发布审批。
*
* @param id 工作流 ID
* @return 工作流 ID
* @return 动作执行结果
*/
@ApprovalAction(
resourceType = "WORKFLOW",
actionType = "PUBLISH",
idExpr = "#id"
)
public BigInteger submitPublishApproval(BigInteger id) {
public ApprovalActionResult submitPublishApproval(BigInteger id) {
assertId(id, ApprovalResourceType.WORKFLOW.getCode(), ApprovalActionType.PUBLISH.getCode());
return id;
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.WORKFLOW.getCode(),
id,
ApprovalActionType.PUBLISH.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
/**
* 提交工作流下线审批。
*
* @param id 工作流 ID
* @return 动作执行结果
*/
public ApprovalActionResult submitOfflineApproval(BigInteger id) {
assertId(id, ApprovalResourceType.WORKFLOW.getCode(), ApprovalActionType.OFFLINE.getCode());
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.WORKFLOW.getCode(),
id,
ApprovalActionType.OFFLINE.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
/**
* 检查工作流下线影响。
*
* @param id 工作流 ID
* @return 下线影响结果
*/
public OfflineImpactCheckVo checkOfflineImpact(BigInteger id) {
assertId(id, ApprovalResourceType.WORKFLOW.getCode(), ApprovalActionType.OFFLINE.getCode());
Workflow workflow = workflowService.getById(id);
if (workflow == null) {
throw new BusinessException("工作流不存在");
}
if (PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) {
throw new BusinessException("当前工作流尚未发布,无法下线");
}
return resourceOfflineImpactService.checkWorkflowImpact(id);
}
/**
* 提交工作流删除审批。
*
* @param id 工作流 ID
* @return 工作流 ID
* @return 动作执行结果
*/
@ApprovalAction(
resourceType = "WORKFLOW",
actionType = "DELETE",
idExpr = "#id"
)
public BigInteger submitDeleteApproval(BigInteger id) {
public ApprovalActionResult submitDeleteApproval(BigInteger id) {
assertId(id, ApprovalResourceType.WORKFLOW.getCode(), ApprovalActionType.DELETE.getCode());
return id;
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.WORKFLOW.getCode(),
id,
ApprovalActionType.DELETE.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
private void assertId(BigInteger id, String resourceType, String actionType) {

View File

@@ -0,0 +1,58 @@
package tech.easyflow.ai.service;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Workflow;
import java.util.Collection;
/**
* AI 资源审批状态派生服务。
* <p>
* 该服务仅负责根据当前审批实例与资源真实状态派生只读展示字段,
* 不参与资源状态回写,也不在读链路中执行任何数据库修复操作。
*/
public interface AiResourceApprovalStateService {
/**
* 填充工作流审批展示状态。
*
* @param workflow 工作流
*/
void fillWorkflowApprovalState(Workflow workflow);
/**
* 批量填充工作流审批展示状态。
*
* @param workflows 工作流集合
*/
void fillWorkflowApprovalState(Collection<Workflow> workflows);
/**
* 填充知识库审批展示状态。
*
* @param collection 知识库
*/
void fillKnowledgeApprovalState(DocumentCollection collection);
/**
* 批量填充知识库审批展示状态。
*
* @param collections 知识库集合
*/
void fillKnowledgeApprovalState(Collection<DocumentCollection> collections);
/**
* 填充聊天助手审批展示状态。
*
* @param bot 聊天助手
*/
void fillBotApprovalState(Bot bot);
/**
* 批量填充聊天助手审批展示状态。
*
* @param bots 聊天助手集合
*/
void fillBotApprovalState(Collection<Bot> bots);
}

View File

@@ -0,0 +1,41 @@
package tech.easyflow.ai.service;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import java.math.BigInteger;
/**
* 资源下线影响检查与解绑服务。
*/
public interface ResourceOfflineImpactService {
/**
* 检查工作流下线影响。
*
* @param workflowId 工作流 ID
* @return 下线影响结果
*/
OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId);
/**
* 检查知识库下线影响。
*
* @param knowledgeId 知识库 ID
* @return 下线影响结果
*/
OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId);
/**
* 工作流下线后,静默解绑所有关联 Bot。
*
* @param workflowId 工作流 ID
*/
void unbindWorkflowFromBots(BigInteger workflowId);
/**
* 知识库下线后,静默解绑所有关联 Bot。
*
* @param knowledgeId 知识库 ID
*/
void unbindKnowledgeFromBots(BigInteger knowledgeId);
}

View File

@@ -0,0 +1,242 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.AiResourceApprovalStateService;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import java.math.BigInteger;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* AI 资源审批状态派生服务实现。
*/
@Service
public class AiResourceApprovalStateServiceImpl implements AiResourceApprovalStateService {
private final ApprovalInstanceMapper approvalInstanceMapper;
public AiResourceApprovalStateServiceImpl(ApprovalInstanceMapper approvalInstanceMapper) {
this.approvalInstanceMapper = approvalInstanceMapper;
}
/**
* {@inheritDoc}
*/
@Override
public void fillWorkflowApprovalState(Workflow workflow) {
fillWorkflowApprovalState(workflow == null ? List.of() : List.of(workflow));
}
/**
* {@inheritDoc}
*/
@Override
public void fillWorkflowApprovalState(Collection<Workflow> workflows) {
fillApprovalState(
workflows,
ApprovalResourceType.WORKFLOW.getCode(),
Workflow::getCurrentApprovalInstanceId,
workflow -> PublishStatus.from(workflow.getPublishStatus()),
Workflow::getPublishedSnapshotJson,
Workflow::setApprovalPending,
Workflow::setCurrentApprovalActionType,
Workflow::setDisplayPublishStatus
);
}
/**
* {@inheritDoc}
*/
@Override
public void fillKnowledgeApprovalState(DocumentCollection collection) {
fillKnowledgeApprovalState(collection == null ? List.of() : List.of(collection));
}
/**
* {@inheritDoc}
*/
@Override
public void fillKnowledgeApprovalState(Collection<DocumentCollection> collections) {
fillApprovalState(
collections,
ApprovalResourceType.KNOWLEDGE.getCode(),
DocumentCollection::getCurrentApprovalInstanceId,
collection -> PublishStatus.from(collection.getPublishStatus()),
DocumentCollection::getPublishedSnapshotJson,
DocumentCollection::setApprovalPending,
DocumentCollection::setCurrentApprovalActionType,
DocumentCollection::setDisplayPublishStatus
);
}
/**
* {@inheritDoc}
*/
@Override
public void fillBotApprovalState(Bot bot) {
fillBotApprovalState(bot == null ? List.of() : List.of(bot));
}
/**
* {@inheritDoc}
*/
@Override
public void fillBotApprovalState(Collection<Bot> bots) {
fillApprovalState(
bots,
ApprovalResourceType.BOT.getCode(),
Bot::getCurrentApprovalInstanceId,
bot -> PublishStatus.from(bot.getPublishStatus()),
Bot::getPublishedSnapshotJson,
Bot::setApprovalPending,
Bot::setCurrentApprovalActionType,
Bot::setDisplayPublishStatus
);
}
/**
* 统一派生审批展示状态。
*
* @param resources 资源集合
* @param resourceType 资源类型
* @param instanceIdGetter 当前审批实例 ID 获取器
* @param statusGetter 发布状态获取器
* @param snapshotGetter 已发布快照获取器
* @param pendingSetter 审批中标记写入器
* @param actionSetter 当前审批动作写入器
* @param displaySetter 展示状态写入器
* @param <T> 资源类型
*/
private <T> void fillApprovalState(Collection<T> resources,
String resourceType,
Function<T, BigInteger> instanceIdGetter,
Function<T, PublishStatus> statusGetter,
Function<T, Map<String, Object>> snapshotGetter,
BiConsumer<T, Boolean> pendingSetter,
BiConsumer<T, String> actionSetter,
BiConsumer<T, String> displaySetter) {
if (CollectionUtils.isEmpty(resources)) {
return;
}
List<T> validResources = resources.stream().filter(Objects::nonNull).toList();
if (validResources.isEmpty()) {
return;
}
Map<BigInteger, ApprovalInstance> instanceMap = loadInstanceMap(validResources, instanceIdGetter);
for (T resource : validResources) {
PublishStatus currentStatus = statusGetter.apply(resource);
BigInteger instanceId = instanceIdGetter.apply(resource);
ApprovalInstance instance = instanceId == null ? null : instanceMap.get(instanceId);
if (!isValidCurrentInstance(resourceType, instance)) {
pendingSetter.accept(resource, false);
actionSetter.accept(resource, null);
displaySetter.accept(resource, resolveDisplayStatusWithoutActiveInstance(currentStatus, snapshotGetter.apply(resource)).getCode());
continue;
}
ApprovalInstanceStatus instanceStatus = ApprovalInstanceStatus.from(instance.getStatus());
if (instanceStatus.isFinished()) {
pendingSetter.accept(resource, false);
actionSetter.accept(resource, null);
displaySetter.accept(resource, resolveDisplayStatusWithoutActiveInstance(currentStatus, snapshotGetter.apply(resource)).getCode());
continue;
}
ApprovalActionType actionType = ApprovalActionType.from(instance.getActionType());
pendingSetter.accept(resource, true);
actionSetter.accept(resource, actionType.getCode());
displaySetter.accept(resource, resolveDisplayStatusWithActiveInstance(currentStatus, actionType).getCode());
}
}
/**
* 批量加载当前审批实例。
*
* @param resources 资源集合
* @param instanceIdGetter 当前审批实例 ID 获取器
* @param <T> 资源类型
* @return 审批实例映射
*/
private <T> Map<BigInteger, ApprovalInstance> loadInstanceMap(Collection<T> resources,
Function<T, BigInteger> instanceIdGetter) {
Set<BigInteger> instanceIds = resources.stream()
.map(instanceIdGetter)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (instanceIds.isEmpty()) {
return Collections.emptyMap();
}
List<ApprovalInstance> instances = approvalInstanceMapper.selectListByQuery(
QueryWrapper.create().in(ApprovalInstance::getId, instanceIds)
);
return instances.stream().collect(Collectors.toMap(ApprovalInstance::getId, Function.identity()));
}
/**
* 判断审批实例是否仍是当前资源的有效实例。
*
* @param resourceType 资源类型
* @param instance 审批实例
* @return 有效返回 true
*/
private boolean isValidCurrentInstance(String resourceType, ApprovalInstance instance) {
return instance != null && resourceType.equals(instance.getResourceType());
}
/**
* 在没有活动审批实例时派生展示状态。
*
* @param currentStatus 当前真实状态
* @param publishedSnapshot 已发布快照
* @return 展示状态
*/
private PublishStatus resolveDisplayStatusWithoutActiveInstance(PublishStatus currentStatus,
Map<String, Object> publishedSnapshot) {
// 读接口不再猜测或掩盖真实状态。若资源表已经落成 pending 但当前找不到有效审批实例,
// 页面应直接看到真实状态,后续再由独立修复流程处理脏数据,而不是在展示层伪装成已发布。
return currentStatus;
}
/**
* 在存在活动审批实例时派生展示状态。
*
* @param currentStatus 当前真实状态
* @param actionType 当前审批动作
* @return 展示状态
*/
private PublishStatus resolveDisplayStatusWithActiveInstance(PublishStatus currentStatus,
ApprovalActionType actionType) {
if (currentStatus == PublishStatus.PUBLISHED && actionType == ApprovalActionType.PUBLISH) {
return PublishStatus.PUBLISH_PENDING;
}
if (currentStatus == PublishStatus.PUBLISH_PENDING
|| currentStatus == PublishStatus.OFFLINE_PENDING
|| currentStatus == PublishStatus.DELETE_PENDING) {
return currentStatus;
}
return switch (actionType) {
case PUBLISH -> PublishStatus.PUBLISH_PENDING;
case OFFLINE -> PublishStatus.OFFLINE_PENDING;
case DELETE -> PublishStatus.DELETE_PENDING;
};
}
}

View File

@@ -0,0 +1,299 @@
package tech.easyflow.ai.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.BotWorkflow;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
import tech.easyflow.common.cache.RedisLockExecutor;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 资源下线影响检查与 Bot 静默解绑实现。
*/
@Service
public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactService {
private static final String KNOWLEDGE_NODE_TYPE = "knowledgeNode";
private static final String BOT_BINDING_LOCK_KEY_PREFIX = "easyflow:lock:bot:binding:";
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
private final BotWorkflowService botWorkflowService;
private final BotDocumentCollectionService botDocumentCollectionService;
private final BotService botService;
private final WorkflowService workflowService;
private final RedisLockExecutor redisLockExecutor;
public ResourceOfflineImpactServiceImpl(BotWorkflowService botWorkflowService,
BotDocumentCollectionService botDocumentCollectionService,
BotService botService,
WorkflowService workflowService,
RedisLockExecutor redisLockExecutor) {
this.botWorkflowService = botWorkflowService;
this.botDocumentCollectionService = botDocumentCollectionService;
this.botService = botService;
this.workflowService = workflowService;
this.redisLockExecutor = redisLockExecutor;
}
/**
* {@inheritDoc}
*/
@Override
public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) {
List<OfflineImpactBindingVo> botBindings = listBotsByWorkflowId(workflowId);
OfflineImpactCheckVo result = new OfflineImpactCheckVo();
result.setCanProceed(true);
result.setBotBindings(botBindings);
result.setHasBotBindings(!botBindings.isEmpty());
result.setWorkflowUsages(Collections.emptyList());
result.setHasWorkflowUsages(false);
result.setMessage(botBindings.isEmpty()
? "当前工作流下线后不会影响已有绑定"
: "当前工作流下线成功后,将自动从相关聊天助手中解绑");
return result;
}
/**
* {@inheritDoc}
*/
@Override
public OfflineImpactCheckVo checkKnowledgeImpact(BigInteger knowledgeId) {
List<OfflineImpactBindingVo> botBindings = listBotsByKnowledgeId(knowledgeId);
List<OfflineImpactBindingVo> workflowUsages = listWorkflowsUsingKnowledge(knowledgeId);
OfflineImpactCheckVo result = new OfflineImpactCheckVo();
result.setBotBindings(botBindings);
result.setHasBotBindings(!botBindings.isEmpty());
result.setWorkflowUsages(workflowUsages);
result.setHasWorkflowUsages(!workflowUsages.isEmpty());
result.setCanProceed(workflowUsages.isEmpty());
result.setMessage(workflowUsages.isEmpty()
? (botBindings.isEmpty()
? "当前知识库下线后不会影响已有绑定"
: "当前知识库下线成功后,将自动从相关聊天助手中解绑")
: "当前知识库仍被工作流使用,请先调整工作流后再下线");
return result;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindWorkflowFromBots(BigInteger workflowId) {
List<BotWorkflow> relations = botWorkflowService.list(QueryWrapper.create()
.eq(BotWorkflow::getWorkflowId, workflowId));
Set<BigInteger> botIds = collectBotIds(relations, BotWorkflow::getBotId);
for (BigInteger botId : botIds) {
redisLockExecutor.executeWithLock(
BOT_BINDING_LOCK_KEY_PREFIX + botId,
LOCK_WAIT_TIMEOUT,
LOCK_LEASE_TIMEOUT,
() -> {
botWorkflowService.remove(QueryWrapper.create()
.eq(BotWorkflow::getBotId, botId)
.eq(BotWorkflow::getWorkflowId, workflowId));
trimPublishedSnapshotBindings(botId, "workflowBindings", "workflowId", workflowId);
}
);
}
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindKnowledgeFromBots(BigInteger knowledgeId) {
List<BotDocumentCollection> relations = botDocumentCollectionService.list(QueryWrapper.create()
.eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId));
Set<BigInteger> botIds = collectBotIds(relations, BotDocumentCollection::getBotId);
for (BigInteger botId : botIds) {
redisLockExecutor.executeWithLock(
BOT_BINDING_LOCK_KEY_PREFIX + botId,
LOCK_WAIT_TIMEOUT,
LOCK_LEASE_TIMEOUT,
() -> {
botDocumentCollectionService.remove(QueryWrapper.create()
.eq(BotDocumentCollection::getBotId, botId)
.eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId));
trimPublishedSnapshotBindings(botId, "knowledgeBindings", "knowledgeId", knowledgeId);
}
);
}
}
private List<OfflineImpactBindingVo> listBotsByWorkflowId(BigInteger workflowId) {
List<BotWorkflow> relations = botWorkflowService.list(QueryWrapper.create()
.eq(BotWorkflow::getWorkflowId, workflowId));
return listBotsByIds(collectBotIds(relations, BotWorkflow::getBotId));
}
private List<OfflineImpactBindingVo> listBotsByKnowledgeId(BigInteger knowledgeId) {
List<BotDocumentCollection> relations = botDocumentCollectionService.list(QueryWrapper.create()
.eq(BotDocumentCollection::getDocumentCollectionId, knowledgeId));
return listBotsByIds(collectBotIds(relations, BotDocumentCollection::getBotId));
}
private List<OfflineImpactBindingVo> listBotsByIds(Set<BigInteger> botIds) {
if (botIds.isEmpty()) {
return Collections.emptyList();
}
List<Bot> bots = botService.listByIds(botIds);
Map<BigInteger, Bot> botMap = new HashMap<>();
for (Bot bot : bots) {
botMap.put(bot.getId(), bot);
}
List<OfflineImpactBindingVo> result = new ArrayList<>(botIds.size());
for (BigInteger botId : botIds) {
Bot bot = botMap.get(botId);
if (bot == null) {
continue;
}
result.add(toBindingVo(bot.getId(), bot.getTitle()));
}
return result;
}
private List<OfflineImpactBindingVo> listWorkflowsUsingKnowledge(BigInteger knowledgeId) {
List<Workflow> workflows = workflowService.list();
if (workflows == null || workflows.isEmpty()) {
return Collections.emptyList();
}
List<OfflineImpactBindingVo> result = new ArrayList<>();
for (Workflow workflow : workflows) {
if (workflow == null || workflow.getId() == null) {
continue;
}
if (containsKnowledgeReference(workflow.getContent(), knowledgeId)) {
result.add(toBindingVo(workflow.getId(), workflow.getTitle()));
}
}
return result;
}
private boolean containsKnowledgeReference(String content, BigInteger knowledgeId) {
if (!StringUtils.hasText(content) || knowledgeId == null) {
return false;
}
try {
Object parsed = JSON.parse(content);
if (!(parsed instanceof JSONObject root)) {
return false;
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null || nodes.isEmpty()) {
return false;
}
String expected = knowledgeId.toString();
for (int i = 0; i < nodes.size(); i++) {
JSONObject node = nodes.getJSONObject(i);
if (node == null || !KNOWLEDGE_NODE_TYPE.equals(node.getString("type"))) {
continue;
}
JSONObject data = node.getJSONObject("data");
if (data == null) {
continue;
}
Object rawKnowledgeId = data.get("knowledgeId");
if (rawKnowledgeId != null && expected.equals(String.valueOf(rawKnowledgeId))) {
return true;
}
}
return false;
} catch (Exception ignored) {
return false;
}
}
private void trimPublishedSnapshotBindings(BigInteger botId,
String bindingsKey,
String idKey,
BigInteger resourceId) {
Bot bot = botService.getById(botId);
if (bot == null || bot.getPublishedSnapshotJson() == null || bot.getPublishedSnapshotJson().isEmpty()) {
return;
}
Map<String, Object> snapshot = new LinkedHashMap<>(bot.getPublishedSnapshotJson());
Object rawBindings = snapshot.get(bindingsKey);
if (!(rawBindings instanceof List<?> bindings)) {
return;
}
List<Map<String, Object>> filtered = new ArrayList<>();
boolean changed = false;
String expectedId = resourceId == null ? null : resourceId.toString();
for (Object item : bindings) {
if (!(item instanceof Map<?, ?> bindingMap)) {
continue;
}
Object currentId = bindingMap.get(idKey);
if (expectedId != null && currentId != null && expectedId.equals(String.valueOf(currentId))) {
changed = true;
continue;
}
filtered.add(new LinkedHashMap<>((Map<String, Object>) bindingMap));
}
if (!changed) {
return;
}
snapshot.put(bindingsKey, filtered);
Bot update = new Bot();
update.setId(botId);
update.setPublishedSnapshotJson(snapshot);
botService.updateById(update);
}
private <T> Set<BigInteger> collectBotIds(Collection<T> relations, BotIdGetter<T> getter) {
if (relations == null || relations.isEmpty()) {
return Collections.emptySet();
}
Set<BigInteger> result = new LinkedHashSet<>();
for (T relation : relations) {
BigInteger botId = getter.getBotId(relation);
if (botId != null) {
result.add(botId);
}
}
return result;
}
private OfflineImpactBindingVo toBindingVo(BigInteger id, String title) {
OfflineImpactBindingVo vo = new OfflineImpactBindingVo();
vo.setId(id);
vo.setTitle(title);
return vo;
}
@FunctionalInterface
private interface BotIdGetter<T> {
BigInteger getBotId(T relation);
}
}

View File

@@ -5,6 +5,8 @@ import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.poi.extractor.ExtractorFactory;
import org.apache.poi.extractor.POITextExtractor;
import org.apache.pdfbox.multipdf.Splitter;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPageTree;
@@ -22,8 +24,10 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class DocUtil {
@@ -85,6 +89,27 @@ public class DocUtil {
}
}
/**
* 读取素材预览文本。
*
* @param suffix 文件后缀
* @param is 文件输入流
* @return 预览文本
*/
public static String readPreviewContent(String suffix, InputStream is) {
String normalizedSuffix = normalizeSuffix(suffix);
if (isPlainTextSuffix(normalizedSuffix)) {
return readPlainTextFile(is);
}
if ("pdf".equals(normalizedSuffix)) {
return readPdfFile(is);
}
if (isOfficeSuffix(normalizedSuffix)) {
return readOfficeFile(is);
}
throw new IllegalArgumentException("不支持的文件类型: " + suffix);
}
public static Map<Integer, byte[]> splitPdf(byte[] bytes, int splitSize) {
Map<Integer, byte[]> map = new HashMap<>();
@@ -174,6 +199,16 @@ public class DocUtil {
return name.substring(name.lastIndexOf(".") + 1);
}
/**
* 规范化文件后缀,统一使用小写。
*
* @param suffix 原始文件后缀
* @return 规范化后的后缀
*/
public static String normalizeSuffix(String suffix) {
return suffix == null ? "" : suffix.trim().toLowerCase(Locale.ROOT);
}
public static byte[] readBytes(InputStream inputStream) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try {
@@ -194,4 +229,59 @@ public class DocUtil {
public static String getFileNameByUrl(String url) {
return url.substring(url.lastIndexOf("/") + 1);
}
/**
* 读取纯文本类型文件。
*
* @param is 文件输入流
* @return 文本内容
*/
private static String readPlainTextFile(InputStream is) {
try {
return new String(readBytes(is), StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("读取文本文件失败:", e);
throw new RuntimeException(e);
}
}
/**
* 使用 Apache POI 提取 Office 文档中的纯文本。
*
* @param is 文件输入流
* @return 文本内容
*/
private static String readOfficeFile(InputStream is) {
try (POITextExtractor extractor = ExtractorFactory.createExtractor(is)) {
return extractor.getText();
} catch (Exception e) {
log.error("读取 Office 文件失败:", e);
throw new RuntimeException(e);
}
}
/**
* 判断是否为纯文本类文件。
*
* @param suffix 文件后缀
* @return 是否纯文本类文件
*/
private static boolean isPlainTextSuffix(String suffix) {
return "txt".equals(suffix) || "md".equals(suffix) || "csv".equals(suffix);
}
/**
* 判断是否为 Office 文件。
*
* @param suffix 文件后缀
* @return 是否 Office 文件
*/
private static boolean isOfficeSuffix(String suffix) {
return "doc".equals(suffix)
|| "docx".equals(suffix)
|| "xls".equals(suffix)
|| "xlsx".equals(suffix)
|| "ppt".equals(suffix)
|| "pptx".equals(suffix);
}
}

View File

@@ -0,0 +1,49 @@
package tech.easyflow.ai.vo;
import java.math.BigInteger;
/**
* 下线影响项视图对象。
*/
public class OfflineImpactBindingVo {
private BigInteger id;
private String title;
/**
* 获取资源 ID。
*
* @return 资源 ID
*/
public BigInteger getId() {
return id;
}
/**
* 设置资源 ID。
*
* @param id 资源 ID
*/
public void setId(BigInteger id) {
this.id = id;
}
/**
* 获取资源名称。
*
* @return 资源名称
*/
public String getTitle() {
return title;
}
/**
* 设置资源名称。
*
* @param title 资源名称
*/
public void setTitle(String title) {
this.title = title;
}
}

View File

@@ -0,0 +1,130 @@
package tech.easyflow.ai.vo;
import java.util.ArrayList;
import java.util.List;
/**
* 下线影响检查结果。
*/
public class OfflineImpactCheckVo {
private boolean canProceed;
private boolean hasBotBindings;
private boolean hasWorkflowUsages;
private List<OfflineImpactBindingVo> botBindings = new ArrayList<>();
private List<OfflineImpactBindingVo> workflowUsages = new ArrayList<>();
private String message;
/**
* 是否允许继续下线。
*
* @return 是否允许继续
*/
public boolean isCanProceed() {
return canProceed;
}
/**
* 设置是否允许继续下线。
*
* @param canProceed 是否允许继续
*/
public void setCanProceed(boolean canProceed) {
this.canProceed = canProceed;
}
/**
* 是否存在 Bot 绑定。
*
* @return 是否存在 Bot 绑定
*/
public boolean isHasBotBindings() {
return hasBotBindings;
}
/**
* 设置是否存在 Bot 绑定。
*
* @param hasBotBindings 是否存在 Bot 绑定
*/
public void setHasBotBindings(boolean hasBotBindings) {
this.hasBotBindings = hasBotBindings;
}
/**
* 是否存在工作流引用。
*
* @return 是否存在工作流引用
*/
public boolean isHasWorkflowUsages() {
return hasWorkflowUsages;
}
/**
* 设置是否存在工作流引用。
*
* @param hasWorkflowUsages 是否存在工作流引用
*/
public void setHasWorkflowUsages(boolean hasWorkflowUsages) {
this.hasWorkflowUsages = hasWorkflowUsages;
}
/**
* 获取 Bot 绑定列表。
*
* @return Bot 绑定列表
*/
public List<OfflineImpactBindingVo> getBotBindings() {
return botBindings;
}
/**
* 设置 Bot 绑定列表。
*
* @param botBindings Bot 绑定列表
*/
public void setBotBindings(List<OfflineImpactBindingVo> botBindings) {
this.botBindings = botBindings;
}
/**
* 获取工作流引用列表。
*
* @return 工作流引用列表
*/
public List<OfflineImpactBindingVo> getWorkflowUsages() {
return workflowUsages;
}
/**
* 设置工作流引用列表。
*
* @param workflowUsages 工作流引用列表
*/
public void setWorkflowUsages(List<OfflineImpactBindingVo> workflowUsages) {
this.workflowUsages = workflowUsages;
}
/**
* 获取提示信息。
*
* @return 提示信息
*/
public String getMessage() {
return message;
}
/**
* 设置提示信息。
*
* @param message 提示信息
*/
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -11,11 +11,15 @@ import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Component;
import tech.easyflow.approval.annotation.ApprovalAction;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.service.ApprovalSubjectHandler;
import tech.easyflow.approval.service.ApprovalActionFacade;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.List;
/**
* 提审动作切面。
@@ -25,11 +29,14 @@ import java.math.BigInteger;
public class ApprovalActionAspect {
private final ApprovalActionFacade approvalActionFacade;
private final List<ApprovalSubjectHandler> handlers;
private final ExpressionParser expressionParser = new SpelExpressionParser();
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
public ApprovalActionAspect(ApprovalActionFacade approvalActionFacade) {
public ApprovalActionAspect(ApprovalActionFacade approvalActionFacade,
List<ApprovalSubjectHandler> handlers) {
this.approvalActionFacade = approvalActionFacade;
this.handlers = handlers;
}
/**
@@ -37,7 +44,7 @@ public class ApprovalActionAspect {
*
* @param joinPoint 切点
* @param approvalAction 注解
* @return 审批实例 ID
* @return 动作执行结果
* @throws Throwable 执行异常
*/
@Around("@annotation(approvalAction)")
@@ -45,12 +52,14 @@ public class ApprovalActionAspect {
Object identifier = resolveIdentifier(joinPoint, approvalAction.idExpr());
BigInteger resourceId = identifier == null ? null : new BigInteger(String.valueOf(identifier));
joinPoint.proceed();
return approvalActionFacade.submit(
approvalAction.resourceType(),
ApprovalSubjectHandler handler = getHandler(approvalAction.resourceType());
ApprovalSubmitRequest request = handler.buildSubmitRequest(
resourceId,
approvalAction.actionType(),
SaTokenUtil.getLoginAccount().getId()
);
ApprovalActionResult result = approvalActionFacade.submit(request);
return result;
}
private Object resolveIdentifier(ProceedingJoinPoint joinPoint, String idExpr) {
@@ -63,4 +72,11 @@ public class ApprovalActionAspect {
);
return expressionParser.parseExpression(idExpr).getValue(context);
}
private ApprovalSubjectHandler getHandler(String resourceType) {
return handlers.stream()
.filter(item -> item.resourceType().equals(resourceType))
.findFirst()
.orElseThrow(() -> new IllegalStateException("未找到审批处理器: " + resourceType));
}
}

View File

@@ -0,0 +1,53 @@
package tech.easyflow.approval.entity.vo;
import java.math.BigInteger;
/**
* 审批动作执行结果。
*/
public class ApprovalActionResult {
private boolean approvalRequired;
private BigInteger instanceId;
/**
* 构造需要审批的结果。
*
* @param instanceId 审批实例 ID
* @return 审批结果
*/
public static ApprovalActionResult required(BigInteger instanceId) {
ApprovalActionResult result = new ApprovalActionResult();
result.setApprovalRequired(true);
result.setInstanceId(instanceId);
return result;
}
/**
* 构造直接执行完成的结果。
*
* @return 审批结果
*/
public static ApprovalActionResult direct() {
ApprovalActionResult result = new ApprovalActionResult();
result.setApprovalRequired(false);
return result;
}
public boolean isApprovalRequired() {
return approvalRequired;
}
public void setApprovalRequired(boolean approvalRequired) {
this.approvalRequired = approvalRequired;
}
public BigInteger getInstanceId() {
return instanceId;
}
public void setInstanceId(BigInteger instanceId) {
this.instanceId = instanceId;
}
}

View File

@@ -33,6 +33,8 @@ public class ApprovalInstanceDetailVo {
private String applicantName;
private String applicantAccount;
private Date submittedAt;
private Date finishedAt;
@@ -137,6 +139,14 @@ public class ApprovalInstanceDetailVo {
this.applicantName = applicantName;
}
public String getApplicantAccount() {
return applicantAccount;
}
public void setApplicantAccount(String applicantAccount) {
this.applicantAccount = applicantAccount;
}
public Date getSubmittedAt() {
return submittedAt;
}

View File

@@ -15,6 +15,8 @@ public class ApprovalLogVo {
private BigInteger operatorId;
private String operatorAccount;
private String operatorName;
private Date created;
@@ -45,6 +47,14 @@ public class ApprovalLogVo {
this.operatorId = operatorId;
}
public String getOperatorAccount() {
return operatorAccount;
}
public void setOperatorAccount(String operatorAccount) {
this.operatorAccount = operatorAccount;
}
public String getOperatorName() {
return operatorName;
}

View File

@@ -9,6 +9,7 @@ import java.util.Locale;
public enum ApprovalActionType {
PUBLISH("PUBLISH"),
OFFLINE("OFFLINE"),
DELETE("DELETE");
private final String code;

View File

@@ -1,6 +1,8 @@
package tech.easyflow.approval.service;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import java.math.BigInteger;
@@ -12,13 +14,10 @@ public interface ApprovalActionFacade {
/**
* 提交审批。
*
* @param resourceType 资源类型
* @param resourceId 资源 ID
* @param actionType 动作类型
* @param operatorId 操作人 ID
* @return 审批实例 ID
* @param request 审批提交请求
* @return 动作执行结果
*/
BigInteger submit(String resourceType, BigInteger resourceId, String actionType, BigInteger operatorId);
ApprovalActionResult submit(ApprovalSubmitRequest request);
/**
* 处理审批通过后的业务回调。

View File

@@ -15,4 +15,12 @@ public interface ApprovalMatchService {
* @return 命中的流程详情
*/
ApprovalFlowDetailVo matchFlow(ApprovalSubmitRequest request);
/**
* 根据资源上下文匹配审批流程,未命中时返回 {@code null}。
*
* @param request 审批提交请求
* @return 命中的流程详情,未命中时返回 {@code null}
*/
ApprovalFlowDetailVo matchFlowOrNull(ApprovalSubmitRequest request);
}

View File

@@ -0,0 +1,38 @@
package tech.easyflow.approval.service;
import tech.easyflow.approval.entity.ApprovalInstance;
import java.math.BigInteger;
/**
* 审批结果业务处理器。
*/
public interface ApprovalResultHandler {
/**
* 处理审批通过后的业务回调。
*
* @param instance 审批实例
* @param operatorId 操作人 ID
* @param comment 审批意见
*/
void handleApproved(ApprovalInstance instance, BigInteger operatorId, String comment);
/**
* 处理审批驳回后的业务回调。
*
* @param instance 审批实例
* @param operatorId 操作人 ID
* @param comment 审批意见
*/
void handleRejected(ApprovalInstance instance, BigInteger operatorId, String comment);
/**
* 处理审批撤回后的业务回调。
*
* @param instance 审批实例
* @param operatorId 操作人 ID
* @param comment 审批意见
*/
void handleRevoked(ApprovalInstance instance, BigInteger operatorId, String comment);
}

View File

@@ -1,7 +1,5 @@
package tech.easyflow.approval.service;
import tech.easyflow.approval.entity.vo.ApprovalCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import java.math.BigInteger;
@@ -28,34 +26,6 @@ public interface ApprovalSubjectHandler {
*/
ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId);
/**
* 审批提交完成后的回调。
*
* @param context 回调上下文
*/
void onSubmitted(ApprovalSubmitCallbackContext context);
/**
* 审批通过后的回调。
*
* @param context 回调上下文
*/
void onApproved(ApprovalCallbackContext context);
/**
* 审批驳回后的回调。
*
* @param context 回调上下文
*/
void onRejected(ApprovalCallbackContext context);
/**
* 审批撤回后的回调。
*
* @param context 回调上下文
*/
void onRevoked(ApprovalCallbackContext context);
/**
* 校验资源是否已发布。
*

View File

@@ -1,14 +1,15 @@
package tech.easyflow.approval.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalSubmitCallbackContext;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.service.ApprovalActionFacade;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalMatchService;
import tech.easyflow.approval.service.ApprovalResultHandler;
import tech.easyflow.approval.service.ApprovalSubjectHandler;
import java.math.BigInteger;
@@ -22,31 +23,30 @@ public class ApprovalActionFacadeImpl implements ApprovalActionFacade {
private final List<ApprovalSubjectHandler> handlers;
private final ApprovalInstanceService approvalInstanceService;
private final ApprovalMatchService approvalMatchService;
private final ApprovalResultHandler approvalResultHandler;
public ApprovalActionFacadeImpl(List<ApprovalSubjectHandler> handlers,
ApprovalInstanceService approvalInstanceService) {
ApprovalInstanceService approvalInstanceService,
ApprovalMatchService approvalMatchService,
ApprovalResultHandler approvalResultHandler) {
this.handlers = handlers;
this.approvalInstanceService = approvalInstanceService;
this.approvalMatchService = approvalMatchService;
this.approvalResultHandler = approvalResultHandler;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public BigInteger submit(String resourceType, BigInteger resourceId, String actionType, BigInteger operatorId) {
ApprovalSubjectHandler handler = getHandler(resourceType);
ApprovalSubmitRequest request = handler.buildSubmitRequest(resourceId, actionType, operatorId);
public ApprovalActionResult submit(ApprovalSubmitRequest request) {
ApprovalFlowDetailVo flow = approvalMatchService.matchFlowOrNull(request);
if (flow == null) {
return ApprovalActionResult.direct();
}
BigInteger instanceId = approvalInstanceService.submitApproval(request);
ApprovalSubmitCallbackContext context = new ApprovalSubmitCallbackContext();
context.setInstanceId(instanceId);
context.setResourceType(request.getResourceType());
context.setResourceId(request.getResourceId());
context.setActionType(request.getActionType());
context.setOperatorId(operatorId);
handler.onSubmitted(context);
return instanceId;
return ApprovalActionResult.required(instanceId);
}
/**
@@ -54,8 +54,7 @@ public class ApprovalActionFacadeImpl implements ApprovalActionFacade {
*/
@Override
public void handleApproved(ApprovalInstance instance, BigInteger operatorId, String comment) {
ApprovalSubjectHandler handler = getHandler(instance.getResourceType());
handler.onApproved(buildCallbackContext(instance, operatorId, comment));
approvalResultHandler.handleApproved(instance, operatorId, comment);
}
/**
@@ -63,8 +62,7 @@ public class ApprovalActionFacadeImpl implements ApprovalActionFacade {
*/
@Override
public void handleRejected(ApprovalInstance instance, BigInteger operatorId, String comment) {
ApprovalSubjectHandler handler = getHandler(instance.getResourceType());
handler.onRejected(buildCallbackContext(instance, operatorId, comment));
approvalResultHandler.handleRejected(instance, operatorId, comment);
}
/**
@@ -72,8 +70,7 @@ public class ApprovalActionFacadeImpl implements ApprovalActionFacade {
*/
@Override
public void handleRevoked(ApprovalInstance instance, BigInteger operatorId, String comment) {
ApprovalSubjectHandler handler = getHandler(instance.getResourceType());
handler.onRevoked(buildCallbackContext(instance, operatorId, comment));
approvalResultHandler.handleRevoked(instance, operatorId, comment);
}
/**
@@ -85,14 +82,6 @@ public class ApprovalActionFacadeImpl implements ApprovalActionFacade {
handler.assertPublishedAccess(identifier, denyMessage);
}
private ApprovalCallbackContext buildCallbackContext(ApprovalInstance instance, BigInteger operatorId, String comment) {
ApprovalCallbackContext context = new ApprovalCallbackContext();
context.setInstance(instance);
context.setOperatorId(operatorId);
context.setComment(comment);
return context;
}
private ApprovalSubjectHandler getHandler(String resourceType) {
String normalized = ApprovalResourceType.from(resourceType).getCode();
return handlers.stream()

View File

@@ -53,6 +53,18 @@ public class ApprovalMatchServiceImpl implements ApprovalMatchService {
*/
@Override
public ApprovalFlowDetailVo matchFlow(ApprovalSubmitRequest request) {
ApprovalFlowDetailVo matchedFlow = matchFlowOrNull(request);
if (matchedFlow == null) {
throw new BusinessException("当前资源上下文未命中审批流程");
}
return matchedFlow;
}
/**
* {@inheritDoc}
*/
@Override
public ApprovalFlowDetailVo matchFlowOrNull(ApprovalSubmitRequest request) {
ApprovalSubmitRequest normalized = normalizeRequest(request);
QueryWrapper flowWrapper = QueryWrapper.create()
.eq(ApprovalFlow::getResourceType, normalized.getResourceType())
@@ -60,7 +72,7 @@ public class ApprovalMatchServiceImpl implements ApprovalMatchService {
.eq(ApprovalFlow::getStatus, ApprovalFlowStatus.ENABLED.getCode());
List<ApprovalFlow> flows = approvalFlowMapper.selectListByQuery(flowWrapper);
if (CollectionUtil.isEmpty(flows)) {
throw new BusinessException("未找到可用的审批流程");
return null;
}
List<BigInteger> flowIds = flows.stream().map(ApprovalFlow::getId).collect(Collectors.toList());
Map<BigInteger, List<ApprovalFlowScope>> scopeMap = approvalFlowScopeMapper.selectListByQuery(
@@ -76,7 +88,7 @@ public class ApprovalMatchServiceImpl implements ApprovalMatchService {
}
}
if (matchedFlows.isEmpty()) {
throw new BusinessException("当前资源上下文未命中审批流程");
return null;
}
matchedFlows.sort(Comparator

View File

@@ -154,8 +154,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
List<ApprovalLog> logs = approvalLogMapper.selectListByQuery(
QueryWrapper.create().eq(ApprovalLog::getInstanceId, instanceId));
Map<Integer, ApprovalFlowStepVo> frozenStepMap = resolveFrozenStepMap(instance);
Map<BigInteger, String> accountNameMap = loadAccountNameMap(instance, tasks, logs);
detail.setApplicantName(accountNameMap.get(instance.getApplicantId()));
Map<BigInteger, SysAccount> accountMap = loadAccountMap(instance, tasks, logs);
detail.setApplicantName(resolveAccountName(accountMap.get(instance.getApplicantId())));
detail.setApplicantAccount(resolveAccountLoginName(accountMap.get(instance.getApplicantId())));
detail.setTasks(tasks.stream()
.sorted(Comparator.comparing(ApprovalTask::getStepNo))
@@ -171,7 +172,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
taskVo.setAssigneeTargetCode(item.getAssigneeTargetCode());
taskVo.setAssigneeTargetName(item.getAssigneeTargetName());
taskVo.setActedBy(item.getActedBy());
taskVo.setActedByName(accountNameMap.get(item.getActedBy()));
taskVo.setActedByName(resolveAccountName(accountMap.get(item.getActedBy())));
taskVo.setActedAt(item.getActedAt());
taskVo.setComment(item.getComment());
return taskVo;
@@ -185,7 +186,8 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
logVo.setId(item.getId());
logVo.setEventType(item.getEventType());
logVo.setOperatorId(item.getOperatorId());
logVo.setOperatorName(accountNameMap.get(item.getOperatorId()));
logVo.setOperatorAccount(resolveAccountLoginName(accountMap.get(item.getOperatorId())));
logVo.setOperatorName(resolveAccountName(accountMap.get(item.getOperatorId())));
logVo.setCreated(item.getCreated());
logVo.setPayloadJson(item.getPayloadJson());
return logVo;
@@ -205,14 +207,14 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
}
/**
* 批量加载审批详情里涉及到的账号显示名称
* 批量加载审批详情里涉及到的账号信息
*
* @param instance 审批实例
* @param tasks 审批任务列表
* @param logs 审批日志列表
* @return 账号 ID 到展示名称的映射
* @return 账号 ID 到账号实体的映射
*/
private Map<BigInteger, String> loadAccountNameMap(ApprovalInstance instance, List<ApprovalTask> tasks,
private Map<BigInteger, SysAccount> loadAccountMap(ApprovalInstance instance, List<ApprovalTask> tasks,
List<ApprovalLog> logs) {
Set<BigInteger> accountIds = new HashSet<>();
if (instance.getApplicantId() != null) {
@@ -232,7 +234,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
return sysAccountService.listByIds(accountIds).stream()
.collect(Collectors.toMap(
SysAccount::getId,
this::resolveAccountName,
account -> account,
(left, right) -> left,
LinkedHashMap::new));
}
@@ -256,6 +258,19 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
return null;
}
/**
* 解析账号登录名。
*
* @param account 账号实体
* @return 登录账号
*/
private String resolveAccountLoginName(SysAccount account) {
if (account == null || !StringUtils.hasText(account.getLoginName())) {
return null;
}
return account.getLoginName().trim();
}
private QueryWrapper buildBaseQuery(String resourceType, String actionType, String keyword) {
QueryWrapper queryWrapper = QueryWrapper.create();
if (StringUtils.hasText(resourceType)) {

View File

@@ -15,7 +15,11 @@ import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -32,6 +36,16 @@ public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> impl
@Resource
private SysAccountRoleService sysAccountRoleService;
/**
* 根据账号查询菜单,并自动补齐已授权节点的父级菜单链。
* <p>
* 这样当角色只勾选了某个页面下的按钮权限或子能力时,
* 其所属的页面菜单仍能正常出现在侧边栏中,避免出现“有子权限但无入口”的问题。
*
* @param entity 菜单过滤条件
* @param accountId 账号 ID
* @return 当前账号可访问的菜单集合
*/
@Override
public List<SysMenu> getMenusByAccountId(SysMenu entity, BigInteger accountId) {
// 查询用户对应角色id集合
@@ -48,11 +62,53 @@ public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> impl
if (CollectionUtil.isEmpty(menuIds)) {
return new ArrayList<>();
}
List<BigInteger> fullMenuIds = collectMenuIdsWithParents(menuIds);
// 查询当前用户拥有的菜单
SqlOperators ops = SqlOperatorsUtil.build(SysMenu.class);
QueryWrapper queryWrapper = QueryWrapper.create(entity, ops);
queryWrapper.in(SysMenu::getId, menuIds);
queryWrapper.in(SysMenu::getId, fullMenuIds);
queryWrapper.orderBy("sort_no asc");
return list(queryWrapper);
}
/**
* 收集菜单自身及其所有父级菜单 ID。
*
* @param menuIds 已授权的菜单 ID 列表
* @return 包含父级链路的完整菜单 ID 列表
*/
private List<BigInteger> collectMenuIdsWithParents(List<BigInteger> menuIds) {
List<SysMenu> allMenus = list();
if (CollectionUtil.isEmpty(allMenus)) {
return menuIds;
}
Map<BigInteger, SysMenu> menuMap = new HashMap<>();
for (SysMenu menu : allMenus) {
menuMap.put(menu.getId(), menu);
}
Set<BigInteger> result = new HashSet<>(menuIds);
for (BigInteger menuId : menuIds) {
appendParentMenuIds(menuId, menuMap, result);
}
return new ArrayList<>(result);
}
/**
* 递归追加父级菜单 ID直到根节点或无父节点为止。
*
* @param menuId 当前菜单 ID
* @param menuMap 全量菜单映射
* @param result 结果集合
*/
private void appendParentMenuIds(BigInteger menuId, Map<BigInteger, SysMenu> menuMap, Set<BigInteger> result) {
SysMenu current = menuMap.get(menuId);
if (current == null) {
return;
}
BigInteger parentId = current.getParentId();
if (parentId == null || BigInteger.ZERO.equals(parentId) || !result.add(parentId)) {
return;
}
appendParentMenuIds(parentId, menuMap, result);
}
}

View File

@@ -0,0 +1,64 @@
SET NAMES utf8mb4;
UPDATE `tb_sys_menu`
SET
`menu_url` = '/sys/approval',
`component` = '/system/approval/ApprovalManage',
`menu_icon` = 'svg:approval',
`is_show` = 1,
`modified` = NOW(),
`modified_by` = 1,
`remark` = '审批管理菜单'
WHERE `id` = 367100000000000001;
UPDATE `tb_sys_menu`
SET
`menu_type` = 1,
`menu_url` = '',
`component` = '',
`menu_icon` = '',
`is_show` = 0,
`permission_tag` = '/page/approval/flow',
`modified` = NOW(),
`modified_by` = 1,
`remark` = '审批管理-流程配置页签权限'
WHERE `id` = 367100000000000002;
UPDATE `tb_sys_menu`
SET
`menu_type` = 1,
`menu_url` = '',
`component` = '',
`menu_icon` = '',
`is_show` = 0,
`permission_tag` = '/page/approval/pending',
`modified` = NOW(),
`modified_by` = 1,
`remark` = '审批管理-待审批页签权限'
WHERE `id` = 367100000000000003;
UPDATE `tb_sys_menu`
SET
`menu_type` = 1,
`menu_url` = '',
`component` = '',
`menu_icon` = '',
`is_show` = 0,
`permission_tag` = '/page/approval/processed',
`modified` = NOW(),
`modified_by` = 1,
`remark` = '审批管理-已审批页签权限'
WHERE `id` = 367100000000000004;
UPDATE `tb_sys_menu`
SET
`menu_type` = 1,
`menu_url` = '',
`component` = '',
`menu_icon` = '',
`is_show` = 0,
`permission_tag` = '/page/approval/initiated',
`modified` = NOW(),
`modified_by` = 1,
`remark` = '审批管理-我发起页签权限'
WHERE `id` = 367100000000000005;

View File

@@ -61,6 +61,14 @@ export const submitBotPublishApproval = (id: string) => {
);
};
/** 提交 Bot 下线审批 */
export const submitBotOfflineApproval = (id: string) => {
return api.post<RequestResult<number | string>>(
'/api/v1/bot/submitOfflineApproval',
{ id },
);
};
/** 提交 Bot 删除审批 */
export const submitBotDeleteApproval = (id: string) => {
return api.post<RequestResult<number | string>>(

View File

@@ -28,6 +28,7 @@ export interface ActionButton {
permission?: string;
placement?: ActionPlacement;
tone?: ActionTone;
visible?: ((row: any) => boolean) | boolean;
onClick: (row: any) => void;
}
@@ -77,6 +78,13 @@ function hasPermission(permission?: string) {
return !permission || hasAccessByCodes([permission]);
}
function isActionVisible(action: ActionButton, row: any) {
if (typeof action.visible === 'function') {
return action.visible(row);
}
return action.visible !== false;
}
const resolvedPrimaryAction = computed(() => {
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
return undefined;
@@ -109,24 +117,6 @@ const resolvedActions = computed<ResolvedActionButton[]>(() => {
}));
});
const inlineActions = computed(() => {
return resolvedActions.value.filter(
(action) => action.placement === 'inline',
);
});
const menuActions = computed(() => {
return resolvedActions.value.filter((action) => action.placement === 'menu');
});
const showFooter = computed(() => {
return Boolean(
resolvedPrimaryAction.value ||
inlineActions.value.length > 0 ||
menuActions.value.length > 0,
);
});
function handlePrimaryAction(item: any) {
resolvedPrimaryAction.value?.onClick(item);
}
@@ -139,6 +129,24 @@ function handleActionClick(event: Event, action: ActionButton, item: any) {
function resolveActionText(action: ActionButton, item: any) {
return typeof action.text === 'function' ? action.text(item) : action.text;
}
function resolveInlineActions(item: any) {
return resolvedActions.value.filter(
(action) => action.placement === 'inline' && isActionVisible(action, item),
);
}
function resolveMenuActions(item: any) {
return resolvedActions.value.filter(
(action) => action.placement === 'menu' && isActionVisible(action, item),
);
}
function hasVisibleActions(item: any) {
return (
resolveInlineActions(item).length > 0 || resolveMenuActions(item).length > 0
);
}
</script>
<template>
@@ -198,7 +206,7 @@ function resolveActionText(action: ActionButton, item: any) {
</div>
</div>
<template v-if="showFooter" #footer>
<template v-if="resolvedPrimaryAction || hasVisibleActions(item)" #footer>
<div class="card-footer">
<div v-if="resolvedPrimaryAction" class="card-primary-hint">
<div class="primary-label">
@@ -214,12 +222,12 @@ function resolveActionText(action: ActionButton, item: any) {
</div>
<div
v-if="inlineActions.length > 0 || menuActions.length > 0"
v-if="hasVisibleActions(item)"
class="card-actions"
@click.stop
>
<ElButton
v-for="(action, actionIndex) in inlineActions"
v-for="(action, actionIndex) in resolveInlineActions(item)"
:key="`${item.id ?? index}-inline-${actionIndex}`"
:icon="typeof action.icon === 'string' ? undefined : action.icon"
size="small"
@@ -235,7 +243,7 @@ function resolveActionText(action: ActionButton, item: any) {
</ElButton>
<ElDropdown
v-if="menuActions.length > 0"
v-if="resolveMenuActions(item).length > 0"
trigger="click"
placement="bottom-end"
>
@@ -248,7 +256,7 @@ function resolveActionText(action: ActionButton, item: any) {
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="(action, actionIndex) in menuActions"
v-for="(action, actionIndex) in resolveMenuActions(item)"
:key="`${item.id ?? index}-menu-${actionIndex}`"
:class="{
'card-menu-item--danger': action.tone === 'danger',

View File

@@ -65,10 +65,16 @@
"publishStatusDraft": "Draft",
"publishStatusPublishPending": "Publish Pending",
"publishStatusPublished": "Published",
"publishStatusOfflinePending": "Offline Pending",
"publishStatusOffline": "Offline",
"publishStatusDeletePending": "Delete Pending",
"publishStatusLabel": "Release",
"submitPublishApprovalConfirm": "The current draft will enter the publish approval flow. It becomes externally available only after approval.",
"submitDeleteApprovalConfirm": "The workflow will enter the delete approval flow. It will be physically deleted only after approval.",
"submitPublishApprovalConfirm": "Publish the current workflow now?",
"submitRepublishApprovalConfirm": "Republish the current workflow now?",
"submitOfflineApprovalConfirm": "Take the current workflow offline?",
"submitDeleteApprovalConfirm": "Delete the current workflow?",
"offlineImpactBoundBotsIntro": "This workflow is currently bound to the following bots:",
"offlineImpactBoundBotsFooter": "After the workflow goes offline, the system will automatically remove it from these bots.",
"publishPendingHint": "There is already an approval in progress for this workflow.",
"deletePendingHint": "There is already an approval in progress for this workflow.",
"check": "Check",

View File

@@ -13,6 +13,7 @@
},
"action": {
"publish": "Publish",
"offline": "Offline",
"delete": "Delete",
"addFlow": "New Flow",
"editFlow": "Edit Flow",
@@ -79,7 +80,7 @@
"actedAt": "Acted At",
"comment": "Comment",
"eventType": "Event Type",
"operatorId": "Operator ID",
"operatorId": "Operator Account",
"operatorName": "Operator Name",
"createdAt": "Created At",
"eventInfo": "Event Info",

View File

@@ -18,10 +18,16 @@
"publishStatusPublishPendingDesc": "The assistant will switch to the new release after approval.",
"publishStatusPublished": "Published",
"publishStatusPublishedDesc": "The current release is externally available. Ongoing edits still stay in draft.",
"publishStatusOfflinePending": "Offline Pending",
"publishStatusOfflinePendingDesc": "The current release stays available until approval completes, but it is hidden from new binding candidates.",
"publishStatusOffline": "Offline",
"publishStatusOfflineDesc": "The current release is offline. External chat, Public API, and new bindings are unavailable.",
"publishStatusDeletePending": "Delete Pending",
"publishStatusDeletePendingDesc": "The current release remains available, but it is no longer offered as a new binding candidate.",
"submitPublishApprovalConfirm": "Submit the current draft to publish approval. It becomes externally available only after approval.",
"submitDeleteApprovalConfirm": "Submit the bot to delete approval. It will be physically deleted only after approval.",
"submitPublishApprovalConfirm": "Publish the current assistant now?",
"submitRepublishApprovalConfirm": "Republish the current assistant now?",
"submitOfflineApprovalConfirm": "Take the current assistant offline?",
"submitDeleteApprovalConfirm": "Delete the current assistant?",
"publishPendingHint": "There is already an approval in progress for this bot.",
"deletePendingHint": "There is already an approval in progress for this bot.",
"publishRequiredHint": "There is no released version yet. Submit publish approval first.",

View File

@@ -41,7 +41,9 @@
"markAsResolved": "MarkAsResolved",
"optimizing": "Optimizing",
"regenerate": "Regenerate",
"publish": "Publish",
"republish": "Republish",
"offline": "Offline",
"hide": "Hide",
"more": "Mode",
"submitDeleteApproval": "Submit Delete Approval",

View File

@@ -38,10 +38,18 @@
"publishStatusDraft": "Draft",
"publishStatusPublishPending": "Publish Pending",
"publishStatusPublished": "Published",
"publishStatusOfflinePending": "Offline Pending",
"publishStatusOffline": "Offline",
"publishStatusDeletePending": "Delete Pending",
"publishStatusLabel": "Release",
"submitPublishApprovalConfirm": "The knowledge base will enter the publish approval flow. It can be referenced by bots only after approval.",
"submitDeleteApprovalConfirm": "The knowledge base will enter the delete approval flow. It will be physically deleted only after approval.",
"submitPublishApprovalConfirm": "Publish the current knowledge base now?",
"submitRepublishApprovalConfirm": "Republish the current knowledge base now?",
"submitOfflineApprovalConfirm": "Take the current knowledge base offline?",
"submitDeleteApprovalConfirm": "Delete the current knowledge base?",
"offlineImpactBoundBotsIntro": "This knowledge base is currently bound to the following bots:",
"offlineImpactBoundBotsFooter": "After the knowledge base goes offline, the system will automatically remove it from these bots.",
"offlineImpactWorkflowBlockedIntro": "This knowledge base is still used by the following workflows:",
"offlineImpactWorkflowBlockedFooter": "Please update those workflow nodes before taking the knowledge base offline.",
"publishPendingHint": "There is already an approval in progress for this knowledge base.",
"deletePendingHint": "There is already an approval in progress for this knowledge base.",
"createdModifyTime": "Creation/update time",

View File

@@ -65,10 +65,16 @@
"publishStatusDraft": "草稿",
"publishStatusPublishPending": "发布审批中",
"publishStatusPublished": "已发布",
"publishStatusOfflinePending": "下线审批中",
"publishStatusOffline": "已下线",
"publishStatusDeletePending": "删除审批中",
"publishStatusLabel": "发布状态",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后新版本才会正式对外可用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"submitPublishApprovalConfirm": "确认发布当前工作流吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?",
"submitDeleteApprovalConfirm": "确认删除当前工作流吗?",
"offlineImpactBoundBotsIntro": "当前工作流被以下聊天助手绑定:",
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该工作流。",
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"check": "检查",

View File

@@ -13,6 +13,7 @@
},
"action": {
"publish": "发布",
"offline": "下线",
"delete": "删除",
"addFlow": "新建流程",
"editFlow": "编辑流程",
@@ -79,7 +80,7 @@
"actedAt": "处理时间",
"comment": "处理意见",
"eventType": "事件类型",
"operatorId": "操作人ID",
"operatorId": "操作人账号",
"operatorName": "操作人名称",
"createdAt": "创建时间",
"eventInfo": "事件信息",

View File

@@ -18,10 +18,16 @@
"publishStatusPublishPendingDesc": "审批通过后,聊天助手会切换为新的正式版本。",
"publishStatusPublished": "已发布",
"publishStatusPublishedDesc": "当前正式版本已可对外使用,编辑中的草稿不会立即影响线上。",
"publishStatusOfflinePending": "下线审批中",
"publishStatusOfflinePendingDesc": "审批完成前当前正式版本仍可访问,但不会继续作为新的绑定候选。",
"publishStatusOffline": "已下线",
"publishStatusOfflineDesc": "当前正式版本已下线外链聊天、Public API 和新的资源绑定都不可用。",
"publishStatusDeletePending": "删除审批中",
"publishStatusDeletePendingDesc": "当前正式版本仍可访问,但不会继续作为新的绑定候选。",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后聊天助手才会正式对外可用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"submitPublishApprovalConfirm": "确认发布当前聊天助手吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前聊天助手吗?",
"submitOfflineApprovalConfirm": "确认下线当前聊天助手吗?",
"submitDeleteApprovalConfirm": "确认删除当前聊天助手吗?",
"publishPendingHint": "当前聊天助手已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前聊天助手已有进行中的审批,请等待处理完成。",
"publishRequiredHint": "当前还没有正式发布版本,请先提交发布审批。",

View File

@@ -41,7 +41,9 @@
"markAsResolved": "标记已处理",
"optimizing": "正在优化中...",
"regenerate": "重新生成",
"publish": "发布",
"republish": "重新发布",
"offline": "下线",
"hide": "隐藏",
"more": "更多",
"submitDeleteApproval": "提交删除审批",

View File

@@ -38,10 +38,18 @@
"publishStatusDraft": "草稿",
"publishStatusPublishPending": "发布审批中",
"publishStatusPublished": "已发布",
"publishStatusOfflinePending": "下线审批中",
"publishStatusOffline": "已下线",
"publishStatusDeletePending": "删除审批中",
"publishStatusLabel": "发布状态",
"submitPublishApprovalConfirm": "提交后会进入发布审批,审批通过后该知识库才可作为正式版本被聊天助手引用。",
"submitDeleteApprovalConfirm": "提交后会进入删除审批,审批通过后将执行真实删除。",
"submitPublishApprovalConfirm": "确认发布当前知识库吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前知识库吗?",
"submitOfflineApprovalConfirm": "确认下线当前知识库吗?",
"submitDeleteApprovalConfirm": "确认删除当前知识库吗?",
"offlineImpactBoundBotsIntro": "当前知识库被以下聊天助手绑定:",
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该知识库。",
"offlineImpactWorkflowBlockedIntro": "当前知识库仍被以下工作流使用:",
"offlineImpactWorkflowBlockedFooter": "请先在工作流中调整相关知识库节点后再下线。",
"publishPendingHint": "当前知识库已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前知识库已有进行中的审批,请等待处理完成。",
"createdModifyTime": "创建/更新时间",

View File

@@ -243,6 +243,10 @@ function setupAccessGuard(router: Router) {
return true;
}
// 页面菜单与按钮权限码是两套数据源。每次重新构建动态菜单时,
// 同步刷新一次 accessCodes避免后端权限模型调整后页面仍持有旧按钮权限。
await authStore.fetchAccessCodes();
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userRoles = userInfo.roles ?? [];

View File

@@ -3,6 +3,74 @@ import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'ApprovalFlowPage',
path: '/sys/approval/flow',
redirect: {
path: '/sys/approval',
query: {
tab: 'flow',
},
},
meta: {
title: $t('approval.tab.flow'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
{
name: 'ApprovalPendingPage',
path: '/sys/approval/pending',
redirect: {
path: '/sys/approval',
query: {
tab: 'pending',
},
},
meta: {
title: $t('approval.tab.pending'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
{
name: 'ApprovalProcessedPage',
path: '/sys/approval/processed',
redirect: {
path: '/sys/approval',
query: {
tab: 'processed',
},
},
meta: {
title: $t('approval.tab.processed'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
{
name: 'ApprovalInitiatedPage',
path: '/sys/approval/initiated',
redirect: {
path: '/sys/approval',
query: {
tab: 'initiated',
},
},
meta: {
title: $t('approval.tab.initiated'),
hideInMenu: true,
hideInBreadcrumb: true,
hideInTab: true,
activePath: '/sys/approval',
},
},
{
name: 'ApprovalDetail',
path: '/sys/approval/detail/:id',

View File

@@ -154,6 +154,12 @@ export const useAuthStore = defineStore('auth', () => {
return userInfo;
}
async function fetchAccessCodes() {
const accessCodes = await getAccessCodesApi();
accessStore.setAccessCodes(accessCodes);
return accessCodes;
}
function $reset() {
loginLoading.value = false;
}
@@ -162,6 +168,7 @@ export const useAuthStore = defineStore('auth', () => {
$reset,
authDevLogin,
authLogin,
fetchAccessCodes,
fetchUserInfo,
loginLoading,
logout,

View File

@@ -26,7 +26,11 @@ import {
} from 'element-plus';
import { tryit } from 'radash';
import { submitBotDeleteApproval, submitBotPublishApproval } from '#/api';
import {
submitBotDeleteApproval,
submitBotOfflineApproval,
submitBotPublishApproval,
} from '#/api';
import { api } from '#/api/request';
import defaultAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
@@ -34,9 +38,12 @@ import CardList from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import { useDictStore } from '#/store';
@@ -105,35 +112,57 @@ const actions: ActionButton[] = [
{
icon: Promotion,
text: (row: BotInfo) =>
isAiResourcePublished(row.publishStatus)
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
: $t('button.publish'),
permission: '/api/v1/bot/save',
placement: 'inline',
visible: (row: BotInfo) =>
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
onClick(row: BotInfo) {
handleSubmitPublishApproval(row);
handlePublishAction(row);
},
},
{
icon: Promotion,
text: $t('button.offline'),
permission: '/api/v1/bot/save',
placement: 'menu',
visible: (row: BotInfo) => canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick(row: BotInfo) {
handleOfflineAction(row);
},
},
{
icon: Delete,
text: $t('button.submitDeleteApproval'),
text: $t('button.delete'),
tone: 'danger',
permission: '/api/v1/bot/remove',
placement: 'menu',
visible: (row: BotInfo) => canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick(row: BotInfo) {
handleSubmitDeleteApproval(row);
},
},
];
const handleSubmitPublishApproval = async (bot: BotInfo) => {
if (isAiResourceApprovalPending(bot.publishStatus)) {
function isRepublishAction(bot: BotInfo) {
return canAiResourceRepublish(bot.displayPublishStatus, bot.publishStatus);
}
const handlePublishAction = async (bot: BotInfo) => {
if (
isAiResourceApprovalPending(bot.displayPublishStatus, bot.publishStatus)
) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitPublishApprovalConfirm'),
isRepublishAction(bot)
? $t('bot.submitRepublishApprovalConfirm')
: $t('bot.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -150,8 +179,36 @@ const handleSubmitPublishApproval = async (bot: BotInfo) => {
pageDataRef.value?.reload?.();
}
};
const handleOfflineAction = async (bot: BotInfo) => {
if (
isAiResourceApprovalPending(bot.displayPublishStatus, bot.publishStatus)
) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitOfflineApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await submitBotOfflineApproval(String(bot.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
};
const handleSubmitDeleteApproval = async (bot: BotInfo) => {
if (isAiResourceApprovalPending(bot.publishStatus)) {
if (
isAiResourceApprovalPending(bot.displayPublishStatus, bot.publishStatus)
) {
ElMessage.warning($t('bot.deletePendingHint'));
return;
}
@@ -174,8 +231,11 @@ const handleSubmitDeleteApproval = async (bot: BotInfo) => {
pageDataRef.value?.reload?.();
}
};
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
function resolvePublishStatusMetaByInstance(
displayPublishStatus?: string,
publishStatus?: string,
) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'PUBLISHED':
return {
label: $t('bot.publishStatusPublished'),
@@ -186,6 +246,16 @@ function resolvePublishStatusMeta(status?: string) {
label: $t('bot.publishStatusPublishPending'),
type: 'warning' as const,
};
case 'OFFLINE_PENDING':
return {
label: $t('bot.publishStatusOfflinePending'),
type: 'warning' as const,
};
case 'OFFLINE':
return {
label: $t('bot.publishStatusOffline'),
type: 'info' as const,
};
case 'DELETE_PENDING':
return {
label: $t('bot.publishStatusDeletePending'),
@@ -378,9 +448,9 @@ const getSideList = async () => {
size="small"
effect="plain"
round
:type="resolvePublishStatusMeta(item.publishStatus).type"
:type="resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).type"
>
{{ resolvePublishStatusMeta(item.publishStatus).label }}
{{ resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).label }}
</ElTag>
</template>
</CardList>

View File

@@ -42,6 +42,7 @@ import { tryit } from 'radash';
import {
getPerQuestions,
submitBotDeleteApproval,
submitBotOfflineApproval,
submitBotPublishApproval,
updateBotApi,
updateBotOptions,
@@ -56,10 +57,13 @@ import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDa
import DictSelect from '#/components/dict/DictSelect.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
isAiResourceExternallyVisible,
isAiResourcePublished,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
interface SelectedMcpTool {
@@ -163,18 +167,42 @@ const publicChatUrl = computed(() => {
const publicChatEmbedUrl = computed(() => {
return buildPublicChatUrl(true);
});
const botDisplayPublishStatus = computed(() =>
resolveAiResourceDisplayStatus(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
),
);
const botApprovalPending = computed(() =>
isAiResourceApprovalPending(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
),
);
const publishStatusMeta = computed<{
description: string;
label: string;
type: 'danger' | 'info' | 'success' | 'warning';
}>(() => {
switch (normalizeAiPublishStatus(botInfo.value?.publishStatus)) {
switch (botDisplayPublishStatus.value) {
case 'PUBLISHED':
return {
label: $t('bot.publishStatusPublished'),
type: 'success',
description: $t('bot.publishStatusPublishedDesc'),
};
case 'OFFLINE_PENDING':
return {
label: $t('bot.publishStatusOfflinePending'),
type: 'warning',
description: $t('bot.publishStatusOfflinePendingDesc'),
};
case 'OFFLINE':
return {
label: $t('bot.publishStatusOffline'),
type: 'info',
description: $t('bot.publishStatusOfflineDesc'),
};
case 'PUBLISH_PENDING':
return {
label: $t('bot.publishStatusPublishPending'),
@@ -199,9 +227,44 @@ const canUsePublicAccess = computed(() =>
isAiResourceExternallyVisible(botInfo.value?.publishStatus),
);
const publishPrimaryActionLabel = computed(() =>
isAiResourcePublished(botInfo.value?.publishStatus)
canAiResourceRepublish(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
)
? $t('button.republish')
: $t('button.submitPublishApproval'),
: $t('button.publish'),
);
const canShowPublishPrimaryAction = computed(() =>
canAiResourcePublish(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
) ||
canAiResourceRepublish(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
),
);
const secondaryActionLabel = computed(() => {
if (
canAiResourceOffline(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
)
) {
return $t('button.offline');
}
if (
canAiResourceDelete(
botInfo.value?.displayPublishStatus,
botInfo.value?.publishStatus,
)
) {
return $t('button.delete');
}
return '';
});
const canShowSecondaryAction = computed(() =>
Boolean(secondaryActionLabel.value),
);
const iframeCode = computed(() => {
if (!publicChatEmbedUrl.value) {
@@ -820,17 +883,24 @@ const handleDeletePresetQuestion = (item: any) => {
const handlePublishWx = () => {
publishWxRef.value.openDialog(botId.value, botInfo.value?.options || {});
};
const handleSubmitPublishApproval = async () => {
const handleLifecycleAction = async () => {
if (!botInfo.value) {
return;
}
if (isAiResourceApprovalPending(botInfo.value.publishStatus)) {
if (
botApprovalPending.value
) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitPublishApprovalConfirm'),
canAiResourceRepublish(
botInfo.value.displayPublishStatus,
botInfo.value.publishStatus,
)
? $t('bot.submitRepublishApprovalConfirm')
: $t('bot.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -849,17 +919,32 @@ const handleSubmitPublishApproval = async () => {
ElMessage.error(res.message || $t('message.saveFailMessage'));
}
};
const handleSubmitDeleteApproval = async () => {
const handleSecondaryAction = async () => {
if (!botInfo.value) {
return;
}
if (isAiResourceApprovalPending(botInfo.value.publishStatus)) {
ElMessage.warning($t('bot.deletePendingHint'));
if (
botApprovalPending.value
) {
ElMessage.warning($t('bot.publishPendingHint'));
return;
}
const canOffline = canAiResourceOffline(
botInfo.value.displayPublishStatus,
botInfo.value.publishStatus,
);
const canDelete = canAiResourceDelete(
botInfo.value.displayPublishStatus,
botInfo.value.publishStatus,
);
if (!canOffline && !canDelete) {
return;
}
try {
await ElMessageBox.confirm(
$t('bot.submitDeleteApprovalConfirm'),
canOffline
? $t('bot.submitOfflineApprovalConfirm')
: $t('bot.submitDeleteApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -870,7 +955,9 @@ const handleSubmitDeleteApproval = async () => {
} catch {
return;
}
const res = await submitBotDeleteApproval(String(botInfo.value.id));
const res = canOffline
? await submitBotOfflineApproval(String(botInfo.value.id))
: await submitBotDeleteApproval(String(botInfo.value.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
getBotDetail();
@@ -1485,19 +1572,21 @@ const handleBasicInfoChange = async (
</div>
<div class="publish-summary-actions">
<ElButton
v-if="canShowPublishPrimaryAction"
type="primary"
:disabled="!hasSavePermission"
@click="handleSubmitPublishApproval"
@click="handleLifecycleAction"
>
{{ publishPrimaryActionLabel }}
</ElButton>
<ElButton
v-if="canShowSecondaryAction"
plain
type="danger"
:type="secondaryActionLabel === $t('button.delete') ? 'danger' : 'default'"
:disabled="!hasSavePermission"
@click="handleSubmitDeleteApproval"
@click="handleSecondaryAction"
>
{{ $t('button.submitDeleteApproval') }}
{{ secondaryActionLabel }}
</ElButton>
</div>
</div>

View File

@@ -46,9 +46,16 @@ import PageSide from '#/components/page/PageSide.vue';
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import {
buildOfflineImpactMessage,
type OfflineImpactCheck,
} from '#/views/ai/shared/offline-impact';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
const router = useRouter();
@@ -186,24 +193,41 @@ const actions: ActionButton[] = [
{
icon: Promotion,
text: (row) =>
isAiResourcePublished(row.publishStatus)
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
: $t('button.publish'),
permission: '/api/v1/documentCollection/save',
placement: 'inline',
visible: (row) =>
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
}
submitPublishApproval(row);
submitPublishAction(row);
},
},
{
text: $t('button.submitDeleteApproval'),
icon: Promotion,
text: $t('button.offline'),
permission: '/api/v1/documentCollection/save',
placement: 'menu',
visible: (row) => canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
}
submitOfflineAction(row);
},
},
{
text: $t('button.delete'),
icon: Delete,
tone: 'danger',
permission: '/api/v1/documentCollection/remove',
placement: 'menu',
visible: (row) => canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick(row) {
if (!ensureManageKnowledgeItem(row)) {
return;
@@ -216,14 +240,22 @@ const actions: ActionButton[] = [
onMounted(() => {
getCategoryList();
});
const submitPublishApproval = async (item: any) => {
if (isAiResourceApprovalPending(item.publishStatus)) {
function isRepublishAction(item: any) {
return canAiResourceRepublish(item.displayPublishStatus, item.publishStatus);
}
const submitPublishAction = async (item: any) => {
if (
isAiResourceApprovalPending(item.displayPublishStatus, item.publishStatus)
) {
ElMessage.warning($t('documentCollection.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('documentCollection.submitPublishApprovalConfirm'),
isRepublishAction(item)
? $t('documentCollection.submitRepublishApprovalConfirm')
: $t('documentCollection.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -245,8 +277,71 @@ const submitPublishApproval = async (item: any) => {
reloadKnowledgeList();
}
};
const submitOfflineAction = async (item: any) => {
if (
isAiResourceApprovalPending(item.displayPublishStatus, item.publishStatus)
) {
ElMessage.warning($t('documentCollection.publishPendingHint'));
return;
}
const impactRes = await api.get<{
data: OfflineImpactCheck;
errorCode: number;
}>(
'/api/v1/documentCollection/offlineImpactCheck',
{
params: { id: item.id },
},
);
if (impactRes.errorCode !== 0) {
return;
}
if (impactRes.data?.hasWorkflowUsages) {
await ElMessageBox.alert(
buildOfflineImpactMessage(
$t('documentCollection.offlineImpactWorkflowBlockedIntro'),
impactRes.data.workflowUsages,
$t('documentCollection.offlineImpactWorkflowBlockedFooter'),
),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
type: 'warning',
},
);
return;
}
try {
await ElMessageBox.confirm(
impactRes.data?.hasBotBindings
? buildOfflineImpactMessage(
$t('documentCollection.offlineImpactBoundBotsIntro'),
impactRes.data.botBindings,
$t('documentCollection.offlineImpactBoundBotsFooter'),
)
: $t('documentCollection.submitOfflineApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await api.post('/api/v1/documentCollection/submitOfflineApproval', {
id: item.id,
});
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
reloadKnowledgeList();
}
};
const submitDeleteApproval = async (item: any) => {
if (isAiResourceApprovalPending(item.publishStatus)) {
if (
isAiResourceApprovalPending(item.displayPublishStatus, item.publishStatus)
) {
ElMessage.warning($t('documentCollection.deletePendingHint'));
return;
}
@@ -274,14 +369,29 @@ const submitDeleteApproval = async (item: any) => {
reloadKnowledgeList();
}
};
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
function resolvePublishStatusMeta(
displayPublishStatus?: string,
publishStatus?: string,
) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'DELETE_PENDING': {
return {
label: $t('documentCollection.publishStatusDeletePending'),
tone: 'danger',
};
}
case 'OFFLINE_PENDING': {
return {
label: $t('documentCollection.publishStatusOfflinePending'),
tone: 'pending',
};
}
case 'OFFLINE': {
return {
label: $t('documentCollection.publishStatusOffline'),
tone: 'draft',
};
}
case 'PUBLISH_PENDING': {
return {
label: $t('documentCollection.publishStatusPublishPending'),
@@ -553,11 +663,11 @@ function changeCategory(category: any) {
<template #publish>
<div
class="knowledge-publish-chip"
:class="`knowledge-publish-chip--${resolvePublishStatusMeta(item.publishStatus).tone}`"
:class="`knowledge-publish-chip--${resolvePublishStatusMeta(item.displayPublishStatus, item.publishStatus).tone}`"
>
<span class="knowledge-publish-chip__dot"></span>
<span>{{
resolvePublishStatusMeta(item.publishStatus).label
resolvePublishStatusMeta(item.displayPublishStatus, item.publishStatus).label
}}</span>
</div>
</template>

View File

@@ -1,22 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { EasyFlowPanelModal } from '@easyflow/common-ui';
import { ElImage } from 'element-plus';
import { ElButton, ElEmpty, ElImage, ElScrollbar } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
defineExpose({
openDialog,
});
const dialogVisible = ref(false);
const data = ref<any>();
function openDialog(row: any) {
const docPreviewLoading = ref(false);
const docPreviewContent = ref('');
const docPreviewTruncated = ref(false);
const docPreviewError = ref('');
let previewRequestId = 0;
const isDocument = computed(() => data.value?.resourceType === 3);
const fileName = computed(() => {
const resourceName = data.value?.resourceName || '';
const suffix = data.value?.suffix || '';
return suffix ? `${resourceName}.${suffix}` : resourceName;
});
const previewWidth = computed(() => (isDocument.value ? 'xl' : 'md'));
async function openDialog(row: any) {
data.value = row;
dialogVisible.value = true;
resetDocumentPreview();
if (row?.resourceType === 3) {
await loadDocumentPreview(row);
}
}
function closeDialog() {
dialogVisible.value = false;
}
function resetDocumentPreview() {
docPreviewLoading.value = false;
docPreviewContent.value = '';
docPreviewTruncated.value = false;
docPreviewError.value = '';
}
async function loadDocumentPreview(row: any) {
if (!row?.id) {
docPreviewError.value = '当前素材缺少预览标识,请下载后查看';
return;
}
const currentRequestId = ++previewRequestId;
docPreviewLoading.value = true;
try {
const res = await api.get('/api/v1/resource/previewContent', {
params: { id: row.id },
});
if (currentRequestId !== previewRequestId) {
return;
}
docPreviewContent.value = res.data?.content || '';
docPreviewTruncated.value = !!res.data?.truncated;
if (!docPreviewContent.value) {
docPreviewError.value = '暂未提取到可预览内容,请下载后查看';
}
} catch {
if (currentRequestId !== previewRequestId) {
return;
}
docPreviewError.value = '文档预览加载失败,请下载后查看';
} finally {
if (currentRequestId === previewRequestId) {
docPreviewLoading.value = false;
}
}
}
function openSourceFile() {
if (data.value?.resourceUrl) {
window.open(data.value.resourceUrl, '_blank');
}
}
</script>
<template>
@@ -24,25 +89,119 @@ function closeDialog() {
v-model:open="dialogVisible"
:title="$t('message.preview')"
:before-close="closeDialog"
width="md"
:width="previewWidth"
:show-footer="false"
>
<div class="flex justify-center">
<div class="resource-preview flex justify-center">
<ElImage
v-if="data.resourceType === 0"
style="width: 200px"
:preview-src-list="[data.resourceUrl]"
:src="data.resourceUrl"
/>
<video v-if="data.resourceType === 1" controls width="640" height="360">
<video
v-else-if="data.resourceType === 1"
controls
width="640"
height="360"
>
<source :src="data.resourceUrl" type="video/mp4" />
{{ $t('message.notVideo') }}
</video>
<audio v-if="data.resourceType === 2" controls :src="data.resourceUrl">
<audio
v-else-if="data.resourceType === 2"
controls
class="mt-8 w-full max-w-[640px]"
:src="data.resourceUrl"
>
{{ $t('message.notAudio') }}
</audio>
<div
v-else-if="isDocument"
v-loading="docPreviewLoading"
:element-loading-text="$t('message.loading')"
class="resource-preview__document bg-background border-border w-full rounded-xl border"
>
<div
class="resource-preview__toolbar border-border flex items-center justify-between gap-3 border-b px-5 py-4"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ fileName }}</div>
<div
v-if="docPreviewTruncated"
class="text-muted-foreground mt-1 text-xs"
>
内容较长当前仅展示前 20000 个字符
</div>
</div>
<ElButton link type="primary" @click="openSourceFile">
{{ $t('button.download') }}
</ElButton>
</div>
<div class="resource-preview__body">
<ElEmpty
v-if="docPreviewError"
:description="docPreviewError"
class="resource-preview__empty"
>
<ElButton link type="primary" @click="openSourceFile">
{{ $t('button.download') }}
</ElButton>
</ElEmpty>
<ElScrollbar
v-else
class="resource-preview__scrollbar"
wrap-class="resource-preview__scrollbar-wrap"
>
<pre class="resource-preview__content">{{
docPreviewContent
}}</pre>
</ElScrollbar>
</div>
</div>
</div>
</EasyFlowPanelModal>
</template>
<style scoped></style>
<style scoped>
.resource-preview {
min-height: 220px;
}
.resource-preview__document {
min-height: 540px;
overflow: hidden;
}
.resource-preview__toolbar {
min-height: 72px;
}
.resource-preview__body {
height: 468px;
}
.resource-preview__scrollbar {
height: 100%;
}
:deep(.resource-preview__scrollbar-wrap) {
padding: 20px;
}
.resource-preview__content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family:
'SFMono-Regular', 'JetBrains Mono', 'Fira Code', Consolas, 'Liberation Mono',
monospace;
font-size: 13px;
line-height: 1.75;
color: hsl(var(--foreground));
}
.resource-preview__empty {
height: 100%;
}
</style>

View File

@@ -0,0 +1,38 @@
import { h } from 'vue';
export interface OfflineImpactBinding {
id?: number | string;
title?: string;
}
export interface OfflineImpactCheck {
canProceed: boolean;
hasBotBindings: boolean;
hasWorkflowUsages: boolean;
botBindings: OfflineImpactBinding[];
workflowUsages: OfflineImpactBinding[];
message?: string;
}
function resolveTitle(item: OfflineImpactBinding) {
return item.title || String(item.id || '');
}
export function joinOfflineImpactTitles(items: OfflineImpactBinding[] = []) {
return items.map(resolveTitle).filter(Boolean).join('、');
}
export function buildOfflineImpactMessage(
intro: string,
items: OfflineImpactBinding[] = [],
footer?: string,
) {
return h('div', [
h('p', intro),
h(
'ul',
items.map((item) => h('li', { key: String(item.id || item.title || '') }, resolveTitle(item))),
),
footer ? h('p', footer) : null,
]);
}

View File

@@ -1,6 +1,8 @@
export type AiPublishStatus =
| 'DELETE_PENDING'
| 'DRAFT'
| 'OFFLINE'
| 'OFFLINE_PENDING'
| 'PUBLISHED'
| 'PUBLISH_PENDING';
@@ -13,6 +15,8 @@ export function normalizeAiPublishStatus(
switch (value) {
case 'PUBLISHED':
case 'PUBLISH_PENDING':
case 'OFFLINE':
case 'OFFLINE_PENDING':
case 'DELETE_PENDING':
return value;
default:
@@ -20,6 +24,17 @@ export function normalizeAiPublishStatus(
}
}
/**
* 解析用于页面展示的发布状态。
* 已发布资源若存在当前审批实例,则视为“发布审批中”,用于统一状态文案与动作禁用。
*/
export function resolveAiResourceDisplayStatus(
displayValue?: null | string,
_fallbackValue?: null | string,
): AiPublishStatus {
return normalizeAiPublishStatus(displayValue);
}
/**
* 当前资源是否已有正式线上版本。
*/
@@ -27,12 +42,23 @@ export function isAiResourcePublished(value?: null | string) {
return normalizeAiPublishStatus(value) === 'PUBLISHED';
}
/**
* 当前资源是否处于已下线状态。
*/
export function isAiResourceOffline(value?: null | string) {
return normalizeAiPublishStatus(value) === 'OFFLINE';
}
/**
* 当前资源是否允许对外可见。
*/
export function isAiResourceExternallyVisible(value?: null | string) {
const normalized = normalizeAiPublishStatus(value);
return normalized === 'PUBLISHED' || normalized === 'DELETE_PENDING';
return (
normalized === 'PUBLISHED' ||
normalized === 'DELETE_PENDING' ||
normalized === 'OFFLINE_PENDING'
);
}
/**
@@ -45,7 +71,68 @@ export function isAiResourceSelectableForBot(value?: null | string) {
/**
* 当前资源是否处于审批处理中。
*/
export function isAiResourceApprovalPending(value?: null | string) {
const normalized = normalizeAiPublishStatus(value);
return normalized === 'PUBLISH_PENDING' || normalized === 'DELETE_PENDING';
export function isAiResourceApprovalPending(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
const normalized = resolveAiResourceDisplayStatus(displayValue);
return (
normalized === 'PUBLISH_PENDING' ||
normalized === 'OFFLINE_PENDING' ||
normalized === 'DELETE_PENDING'
);
}
/**
* 当前资源是否允许发起发布。
*/
export function canAiResourcePublish(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
if (isAiResourceApprovalPending(displayValue)) {
return false;
}
const normalized = resolveAiResourceDisplayStatus(displayValue);
return normalized === 'DRAFT' || normalized === 'OFFLINE';
}
/**
* 当前资源是否允许重新发布。
*/
export function canAiResourceRepublish(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
if (isAiResourceApprovalPending(displayValue)) {
return false;
}
return resolveAiResourceDisplayStatus(displayValue) === 'PUBLISHED';
}
/**
* 当前资源是否允许发起下线。
*/
export function canAiResourceOffline(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
if (isAiResourceApprovalPending(displayValue)) {
return false;
}
return resolveAiResourceDisplayStatus(displayValue) === 'PUBLISHED';
}
/**
* 当前资源是否允许发起删除。
*/
export function canAiResourceDelete(
displayValue?: null | string,
_fallbackValue?: null | string,
) {
if (isAiResourceApprovalPending(displayValue)) {
return false;
}
const normalized = resolveAiResourceDisplayStatus(displayValue);
return normalized === 'DRAFT' || normalized === 'OFFLINE';
}

View File

@@ -26,8 +26,9 @@ import { $t } from '#/locales';
import { router } from '#/router';
import { getIconByValue } from '#/views/ai/model/modelUtils/defaultIcon';
import {
canAiResourceRepublish,
isAiResourceApprovalPending,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
@@ -251,18 +252,26 @@ const updatePluginNode = ref<any>(null);
const pageLoading = ref(false);
const chainInfo = ref<any>(null);
const publishActionText = computed(() => {
switch (normalizeAiPublishStatus(workflowInfo.value?.publishStatus)) {
switch (
resolveAiResourceDisplayStatus(
workflowInfo.value?.displayPublishStatus,
workflowInfo.value?.publishStatus,
)
) {
case 'DELETE_PENDING': {
return $t('aiWorkflow.publishStatusDeletePending');
}
case 'OFFLINE_PENDING': {
return $t('aiWorkflow.publishStatusOfflinePending');
}
case 'PUBLISH_PENDING': {
return $t('aiWorkflow.publishStatusPublishPending');
}
case 'PUBLISHED': {
return `${$t('aiWorkflow.publishStatusPublished')} · ${$t('button.republish')}`;
return $t('button.republish');
}
default: {
return `${$t('aiWorkflow.publishStatusDraft')} · ${$t('button.submitPublishApproval')}`;
return $t('button.publish');
}
}
});
@@ -272,7 +281,10 @@ const publishActionDisabled = computed(
saveLoading.value ||
checkLoading.value ||
publishLoading.value ||
isAiResourceApprovalPending(workflowInfo.value?.publishStatus),
isAiResourceApprovalPending(
workflowInfo.value?.displayPublishStatus,
workflowInfo.value?.publishStatus,
),
);
function syncNavTitle(title: string) {
@@ -498,17 +510,27 @@ function closeCheckIssues() {
async function handleCheck() {
await runCheck('PRE_EXECUTE');
}
async function handlePublish() {
async function handlePublishAction() {
if (publishLoading.value) {
return;
}
if (isAiResourceApprovalPending(workflowInfo.value?.publishStatus)) {
if (
isAiResourceApprovalPending(
workflowInfo.value?.displayPublishStatus,
workflowInfo.value?.publishStatus,
)
) {
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('aiWorkflow.submitPublishApprovalConfirm'),
canAiResourceRepublish(
workflowInfo.value?.displayPublishStatus,
workflowInfo.value?.publishStatus,
)
? $t('aiWorkflow.submitRepublishApprovalConfirm')
: $t('aiWorkflow.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -680,8 +702,8 @@ function onAsyncExecute(info: any) {
:loading="publishLoading"
:disabled="publishActionDisabled"
class="workflow-publish-button"
:class="`workflow-publish-button--${normalizeAiPublishStatus(workflowInfo?.publishStatus)}`"
@click="handlePublish"
:class="`workflow-publish-button--${resolveAiResourceDisplayStatus(workflowInfo?.displayPublishStatus, workflowInfo?.publishStatus)}`"
@click="handlePublishAction"
>
{{ publishActionText }}
</ElButton>
@@ -809,12 +831,24 @@ function onAsyncExecute(info: any) {
border-color: hsl(var(--warning) / 24%);
}
:deep(.workflow-publish-button--OFFLINE_PENDING.el-button) {
color: hsl(var(--warning));
background: hsl(var(--warning) / 18%);
border-color: hsl(var(--warning) / 24%);
}
:deep(.workflow-publish-button--PUBLISHED.el-button) {
color: hsl(var(--success));
background: hsl(var(--success) / 18%);
border-color: hsl(var(--success) / 24%);
}
:deep(.workflow-publish-button--OFFLINE.el-button) {
color: hsl(var(--foreground) / 78%);
background: hsl(var(--muted) / 62%);
border-color: hsl(var(--foreground) / 14%);
}
:deep(.workflow-publish-button--DELETE_PENDING.el-button) {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 16%);

View File

@@ -50,9 +50,16 @@ import { router } from '#/router';
import { useDictStore } from '#/store';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import {
buildOfflineImpactMessage,
type OfflineImpactCheck,
} from '#/views/ai/shared/offline-impact';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
isAiResourcePublished,
normalizeAiPublishStatus,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import WorkflowModal from './WorkflowModal.vue';
@@ -175,21 +182,35 @@ const actions: ActionButton[] = [
{
icon: Promotion,
text: (row: any) =>
isAiResourcePublished(row.publishStatus)
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? $t('button.republish')
: $t('button.submitPublishApproval'),
: $t('button.publish'),
permission: '/api/v1/workflow/save',
placement: 'inline',
visible: (row: any) =>
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
onClick: (row: any) => {
submitPublishApproval(row);
submitPublishAction(row);
},
},
{
icon: Promotion,
text: $t('button.offline'),
permission: '/api/v1/workflow/save',
placement: 'menu',
visible: (row: any) => canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick: (row: any) => {
submitOfflineAction(row);
},
},
{
icon: Delete,
text: $t('button.submitDeleteApproval'),
text: $t('button.delete'),
tone: 'danger',
permission: '/api/v1/workflow/remove',
placement: 'menu',
visible: (row: any) => canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick: (row: any) => {
submitDeleteApproval(row);
},
@@ -282,14 +303,22 @@ function showDialog(row: any, importMode = false) {
function resolveNavTitle(row: any) {
return row?.title || row?.name || '';
}
async function submitPublishApproval(row: any) {
if (isAiResourceApprovalPending(row.publishStatus)) {
function isRepublishAction(row: any) {
return canAiResourceRepublish(row.displayPublishStatus, row.publishStatus);
}
async function submitPublishAction(row: any) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
return;
}
try {
await ElMessageBox.confirm(
$t('aiWorkflow.submitPublishApprovalConfirm'),
isRepublishAction(row)
? $t('aiWorkflow.submitRepublishApprovalConfirm')
: $t('aiWorkflow.submitPublishApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
@@ -300,7 +329,56 @@ async function submitPublishApproval(row: any) {
} catch {
return;
}
const res = await api.post('/api/v1/workflow/submitPublishApproval', {
const res = await api.post(
'/api/v1/workflow/submitPublishApproval',
{
id: row.id,
},
);
if (res.errorCode === 0) {
ElMessage.success(res.message || $t('message.saveOkMessage'));
pageDataRef.value?.reload?.();
}
}
async function submitOfflineAction(row: any) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning($t('aiWorkflow.publishPendingHint'));
return;
}
const impactRes = await api.get<{
data: OfflineImpactCheck;
errorCode: number;
}>(
'/api/v1/workflow/offlineImpactCheck',
{
params: { id: row.id },
},
);
if (impactRes.errorCode !== 0) {
return;
}
try {
await ElMessageBox.confirm(
impactRes.data?.hasBotBindings
? buildOfflineImpactMessage(
$t('aiWorkflow.offlineImpactBoundBotsIntro'),
impactRes.data.botBindings,
$t('aiWorkflow.offlineImpactBoundBotsFooter'),
)
: $t('aiWorkflow.submitOfflineApprovalConfirm'),
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
},
);
} catch {
return;
}
const res = await api.post('/api/v1/workflow/submitOfflineApproval', {
id: row.id,
});
if (res.errorCode === 0) {
@@ -309,7 +387,9 @@ async function submitPublishApproval(row: any) {
}
}
async function submitDeleteApproval(row: any) {
if (isAiResourceApprovalPending(row.publishStatus)) {
if (
isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)
) {
ElMessage.warning($t('aiWorkflow.deletePendingHint'));
return;
}
@@ -334,14 +414,29 @@ async function submitDeleteApproval(row: any) {
pageDataRef.value?.reload?.();
}
}
function resolvePublishStatusMeta(status?: string) {
switch (normalizeAiPublishStatus(status)) {
function resolvePublishStatusMetaByInstance(
displayPublishStatus?: string,
publishStatus?: string,
) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'DELETE_PENDING': {
return {
label: $t('aiWorkflow.publishStatusDeletePending'),
tone: 'danger',
};
}
case 'OFFLINE_PENDING': {
return {
label: $t('aiWorkflow.publishStatusOfflinePending'),
tone: 'pending',
};
}
case 'OFFLINE': {
return {
label: $t('aiWorkflow.publishStatusOffline'),
tone: 'draft',
};
}
case 'PUBLISH_PENDING': {
return {
label: $t('aiWorkflow.publishStatusPublishPending'),
@@ -572,11 +667,11 @@ function handleHeaderButtonClick(data: any) {
<template #publish>
<div
class="workflow-publish-chip"
:class="`workflow-publish-chip--${resolvePublishStatusMeta(item.publishStatus).tone}`"
:class="`workflow-publish-chip--${resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).tone}`"
>
<span class="workflow-publish-chip__dot"></span>
<span>{{
resolvePublishStatusMeta(item.publishStatus).label
resolvePublishStatusMetaByInstance(item.displayPublishStatus, item.publishStatus).label
}}</span>
</div>
</template>

View File

@@ -36,6 +36,7 @@ const resourceLabelMap: Record<string, string> = {
const actionLabelMap: Record<string, string> = {
DELETE: $t('approval.action.delete'),
OFFLINE: $t('approval.action.offline'),
PUBLISH: $t('approval.action.publish'),
};
@@ -138,18 +139,28 @@ function formatPayload(payload: Record<string, any>) {
return JSON.stringify(payload || {}, null, 2);
}
function formatAccountDisplay(name?: string, id?: null | number | string) {
if (name && id) {
return `${name}${id}`;
function formatAccountDisplay(
name?: string,
account?: null | string,
fallbackId?: null | number | string,
) {
if (name && account) {
return `${name}${account}`;
}
if (name) {
return name;
}
return id || '-';
if (account) {
return account;
}
return fallbackId || '-';
}
function formatOperatorId(id?: null | number | string) {
return id || '-';
function formatOperatorId(
account?: null | string,
fallbackId?: null | number | string,
) {
return account || fallbackId || '-';
}
function formatOperatorName(name?: null | string) {
@@ -276,7 +287,13 @@ function formatEventInfo(row: Record<string, any>) {
{{ detail.id || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.applicant')">
{{ formatAccountDisplay(detail.applicantName, detail.applicantId) }}
{{
formatAccountDisplay(
detail.applicantName,
detail.applicantAccount,
detail.applicantId,
)
}}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('approval.fields.submittedAt')">
{{ detail.submittedAt || '-' }}
@@ -347,7 +364,7 @@ function formatEventInfo(row: Record<string, any>) {
</ElTableColumn>
<ElTableColumn :label="$t('approval.fields.operatorId')" width="140">
<template #default="{ row }">
{{ formatOperatorId(row.operatorId) }}
{{ formatOperatorId(row.operatorAccount, row.operatorId) }}
</template>
</ElTableColumn>
<ElTableColumn

View File

@@ -31,7 +31,7 @@ defineExpose({
});
type ResourceType = '' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW';
type ActionType = '' | 'DELETE' | 'PUBLISH';
type ActionType = '' | 'DELETE' | 'OFFLINE' | 'PUBLISH';
type AssigneeType = 'ROLE' | 'USER';
type ScopeType = 'CATEGORY' | 'DEPT';
type FlowStatus = 'DISABLED' | 'ENABLED';
@@ -77,6 +77,7 @@ const RESOURCE_OPTIONS = [
const ACTION_OPTIONS = [
{ label: $t('approval.action.publish'), value: 'PUBLISH' },
{ label: $t('approval.action.offline'), value: 'OFFLINE' },
{ label: $t('approval.action.delete'), value: 'DELETE' },
];

View File

@@ -23,6 +23,7 @@ import {
} from 'element-plus';
import { api } from '#/api/request';
import { hasPermission } from '#/api/common/hasPermission';
import ListPageShell from '#/components/page/ListPageShell.vue';
import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
@@ -40,6 +41,7 @@ const RESOURCE_OPTIONS = [
const ACTION_OPTIONS = [
{ label: $t('approval.action.publish'), value: 'PUBLISH' },
{ label: $t('approval.action.offline'), value: 'OFFLINE' },
{ label: $t('approval.action.delete'), value: 'DELETE' },
];
@@ -66,21 +68,25 @@ const TAB_CONFIG = [
label: $t('approval.tab.flow'),
name: 'flow',
path: '/sys/approval/flow',
permission: '/page/approval/flow',
},
{
label: $t('approval.tab.pending'),
name: 'pending',
path: '/sys/approval/pending',
permission: '/page/approval/pending',
},
{
label: $t('approval.tab.processed'),
name: 'processed',
path: '/sys/approval/processed',
permission: '/page/approval/processed',
},
{
label: $t('approval.tab.initiated'),
name: 'initiated',
path: '/sys/approval/initiated',
permission: '/page/approval/initiated',
},
] as const;
@@ -115,14 +121,23 @@ const pendingBadgeText = computed(() =>
const hasApprovalRootMenu = computed(
() => !!accessStore.getMenuByPath('/sys/approval'),
);
const hasLegacyTabMenus = computed(() =>
TAB_CONFIG.some((item) => !!accessStore.getMenuByPath(item.path)),
);
const hasButtonTabPermissions = computed(() =>
TAB_CONFIG.some((item) => hasPermission([item.permission])),
);
const visibleTabs = computed(() => {
const matchedTabs = TAB_CONFIG.filter((item) =>
accessStore.getMenuByPath(item.path),
);
if (matchedTabs.length > 0) {
return matchedTabs;
if (!hasApprovalRootMenu.value) {
return [];
}
return hasApprovalRootMenu.value ? [...TAB_CONFIG] : [];
if (hasButtonTabPermissions.value) {
return TAB_CONFIG.filter((item) => hasPermission([item.permission]));
}
if (hasLegacyTabMenus.value) {
return TAB_CONFIG.filter((item) => accessStore.getMenuByPath(item.path));
}
return [];
});
const visibleTabNames = computed(() =>
visibleTabs.value.map((item) => item.name),
@@ -139,7 +154,7 @@ const instanceStatusOptions = computed(() => {
});
onMounted(() => {
void syncRouteTab(route.path);
void syncRouteTab();
});
watch(activeTab, () => {
@@ -153,9 +168,9 @@ watch(activeTab, () => {
}
});
watch(
[() => route.path, visibleTabNames],
async ([path]) => {
await syncRouteTab(path);
[() => route.fullPath, visibleTabNames],
async () => {
await syncRouteTab();
},
{ immediate: true },
);
@@ -177,19 +192,55 @@ function resolveTabNameByPath(path: string): ApprovalTabName {
return 'flow';
}
async function syncRouteTab(path: string) {
function resolveTabNameByRoute() {
const queryTab = String(route.query.tab || '');
if (
queryTab === 'flow' ||
queryTab === 'pending' ||
queryTab === 'processed' ||
queryTab === 'initiated'
) {
return queryTab as ApprovalTabName;
}
return resolveTabNameByPath(route.path);
}
async function syncRouteTab() {
if (visibleTabs.value.length === 0) {
activeTab.value = 'flow';
pendingBadgeCount.value = 0;
return;
}
const expectedTab = resolveTabNameByPath(path);
const fallbackTab = visibleTabs.value[0];
const queryTab = String(route.query.tab || '');
// 进入审批管理根路径时,默认落到当前可见页签中排序第一个的页签。
if (route.path === '/sys/approval' && !queryTab && fallbackTab) {
activeTab.value = fallbackTab.name;
if (fallbackTab.name !== 'flow') {
await router.replace({
path: '/sys/approval',
query: { tab: fallbackTab.name },
});
return;
}
}
const expectedTab = resolveTabNameByRoute();
if (visibleTabNames.value.includes(expectedTab)) {
activeTab.value = expectedTab;
} else {
if (fallbackTab && path !== fallbackTab.path) {
await router.replace(fallbackTab.path);
const fallbackQuery =
fallbackTab?.name === 'flow' ? {} : { tab: fallbackTab?.name };
const currentQueryTab = String(route.query.tab || '');
if (
fallbackTab &&
(route.path !== '/sys/approval' || currentQueryTab !== String(fallbackTab.name))
) {
await router.replace({
path: '/sys/approval',
query: fallbackQuery,
});
return;
}
activeTab.value = fallbackTab?.name || 'flow';
@@ -206,10 +257,16 @@ function handleTabChange(name: number | string) {
return;
}
const target = visibleTabs.value.find((item) => item.name === name);
if (!target || route.path === target.path) {
const nextQuery = name === 'flow' ? {} : { tab: name };
const currentQueryTab = String(route.query.tab || '');
const currentTab = currentQueryTab || 'flow';
if (!target || currentTab === name) {
return;
}
router.push(target.path);
router.replace({
path: '/sys/approval',
query: nextQuery,
});
}
function reloadCurrentTab() {

View File

@@ -611,6 +611,9 @@ function getTabKey(tab: RouteLocationNormalized | RouteRecordNormalized) {
meta: { fullPathKey } = {},
query = {},
} = tab as RouteLocationNormalized;
if (path === '/sys/approval') {
return path;
}
// pageKey可能是数组查询参数重复时可能出现
const pageKey = Array.isArray(query.pageKey)
? query.pageKey[0]

View File

@@ -1,7 +1,10 @@
interface BotInfo {
alias: string;
anonymousEnabled: boolean;
approvalPending?: boolean;
currentApprovalInstanceId?: number | string;
currentApprovalActionType?: string;
displayPublishStatus?: string;
created: string;
createdBy: number;
deptId: number;