Compare commits

1 Commits

1846 changed files with 13252 additions and 270214 deletions

2
.gitignore vendored
View File

@@ -35,8 +35,6 @@ build/
### Mac OS ###
.DS_Store
/.logs/
/logs/
/agent-workspaces/
/.idea/
.logs
.idea

View File

@@ -1,5 +1,5 @@
# 后端构建脚本
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
@@ -9,15 +9,12 @@ ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
ENV NPM_CONFIG_REGISTRY=https://registry.npmjs.org
ENV PIP_INDEX_URL=https://pypi.org/simple
ENV PYTHONPATH=/opt/easyflow/python-packages
ENV NODE_PATH=/app/node_modules
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
WORKDIR /app
ARG DEBIAN_FRONTEND=noninteractive
RUN useradd --system --create-home easyflow && \
apt-get update && \
apt-get install -y --no-install-recommends \
@@ -32,106 +29,26 @@ RUN useradd --system --create-home easyflow && \
rm -f /tmp/nodesource.gpg.key && \
apt-get update && \
apt-get install -y --no-install-recommends \
coreutils \
diffutils \
file \
findutils \
fontconfig \
fonts-liberation2 \
fonts-noto-cjk \
gawk \
grep \
gzip \
inotify-tools \
jq \
libdigest-sha-perl \
libreoffice-calc \
libreoffice-impress \
libreoffice-writer \
nodejs \
pandoc \
poppler-utils \
procps \
python3 \
python3-pip \
python3-venv \
qpdf \
ripgrep \
sed \
tar \
tree \
unzip \
util-linux \
zip \
tini && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/pip "${PYTHONPATH}" /opt/easyflow/node-runtime && \
ln -sf /usr/bin/python3 /usr/local/bin/python && \
ln -sf /usr/bin/pip3 /usr/local/bin/pip && \
npm config set registry "${NPM_CONFIG_REGISTRY}" && \
printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \
npm install -g pnpm@10.17.1 && \
pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \
printf "[global]\nindex-url = %s\n" "${PIP_INDEX_URL}" > /etc/pip.conf
RUN python3 -m pip install --no-cache-dir --target "${PYTHONPATH}" \
python-docx==1.2.0 \
python-pptx==1.0.2 \
openpyxl==3.1.5 \
xlsxwriter==3.2.9 \
lxml==6.1.1 \
defusedxml==0.7.1 \
pillow==12.3.0 \
pypdf==6.16.1 \
pdfplumber==0.11.10 \
pdf2image==1.17.0 \
reportlab==5.0.0 \
numpy==2.5.2 \
pandas==3.0.5 \
matplotlib==3.11.1 \
seaborn==0.13.2 \
pyyaml==6.0.3 \
jsonschema==4.26.0 \
jinja2==3.1.6 \
beautifulsoup4==4.15.0 \
pydantic==2.13.4 \
python-dateutil==2.9.0.post0 \
tabulate==0.10.0 \
markdown==3.10.3 \
charset-normalizer==3.5.1 \
tenacity==9.1.4 && \
PYTHONPATH="${PYTHONPATH}" python3 -c "import bs4, defusedxml, docx, jsonschema, lxml, matplotlib, numpy, openpyxl, pandas, pdfplumber, PIL, pptx, pydantic, pypdf, reportlab, seaborn, yaml"
RUN npm install --prefix /opt/easyflow/node-runtime --omit=dev --no-audit --no-fund --save-exact \
docx@9.7.1 \
pptxgenjs@4.0.1 \
sharp@0.35.3 \
pdf-lib@1.17.1 \
pdfjs-dist@6.2.108 \
zod@4.4.3 \
ajv@8.20.0 \
yaml@2.9.0 \
csv-parse@7.0.2 \
csv-stringify@6.8.3 \
fast-xml-parser@5.10.1 \
marked@18.0.9 \
sanitize-html@2.17.7 \
cheerio@1.2.0 \
dayjs@1.11.21 \
handlebars@4.7.9 \
jszip@3.10.1 && \
ln -s /opt/easyflow/node-runtime/node_modules /app/node_modules && \
node -e "for (const name of ['docx','pptxgenjs','sharp','pdf-lib','pdfjs-dist/package.json','zod','ajv','yaml','csv-parse','csv-stringify','fast-xml-parser','marked','sanitize-html','cheerio','dayjs','handlebars','jszip']) require.resolve(name)" && \
npm cache clean --force
RUN fc-cache -f && \
mkdir -p /etc/pip && \
printf "[global]\nindex-url = %s\ntrusted-host = %s\n" "${PIP_INDEX_URL}" "${PIP_TRUSTED_HOST}" > /etc/pip.conf && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/logs /app/artifacts /app/data && \
chown -R easyflow:easyflow /app
COPY docker-soffice-wrapper.sh /usr/local/bin/soffice
COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh
RUN chmod 755 /usr/local/bin/soffice /usr/local/bin/easyflow-entrypoint.sh
RUN chmod 755 /usr/local/bin/easyflow-entrypoint.sh
VOLUME ["/app/logs", "/app/data"]
EXPOSE 8111

View File

@@ -58,14 +58,6 @@ pnpm install
pnpm dev
```
管理端开发环境与生产环境统一使用 `/flow/` 基路径和 Hash 路由,默认访问地址:
```text
http://127.0.0.1:5090/flow/#/
```
直接访问 `http://127.0.0.1:5090``/flow` 时,开发服务器会自动补齐 `/flow/`
用户中心:
```bash
@@ -76,13 +68,6 @@ pnpm dev
默认测试账号:`admin / Easy@2026`
管理端发布前可执行以下命令,验证环境契约、生产镜像和 Nginx 路由:
```bash
cd easyflow-ui-admin
pnpm verify:deployment
```
## 后端 Jar 包构建与部署
### 构建 Jar

View File

@@ -72,8 +72,6 @@ services:
TZ: Asia/Shanghai
MINIO_ROOT_USER: easyflowadmin
MINIO_ROOT_PASSWORD: easyflowadmin123
MINIO_API_STALE_UPLOADS_EXPIRY: 24h
MINIO_API_STALE_UPLOADS_CLEANUP_INTERVAL: 6h
ports:
- "9000:9000"
- "9001:9001"
@@ -90,7 +88,7 @@ services:
MINIO_ROOT_USER: easyflowadmin
MINIO_ROOT_PASSWORD: easyflowadmin123
MINIO_ENDPOINT: http://minio:9000
MINIO_BUCKETS: easyflow,milvus,easyflow-agent-media
MINIO_BUCKETS: easyflow,milvus
MINIO_PUBLIC_BUCKETS: easyflow
MINIO_ALIAS: local
volumes:

View File

@@ -22,7 +22,6 @@ services:
- easyflow-net
volumes:
- ./attachment:/www/easyflow/attachment
- ./logs:/app/logs
depends_on:
mysql:
condition: service_healthy

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env sh
set -eu
profile_parent="${TMPDIR:-/tmp}"
profile_dir="$(mktemp -d "${profile_parent%/}/easyflow-soffice-XXXXXX")"
cleanup() {
rm -rf -- "$profile_dir"
}
trap cleanup EXIT HUP INT TERM
/usr/bin/soffice \
-env:UserInstallation="file://${profile_dir}" \
--headless \
--safe-mode \
--nologo \
--nodefault \
--nolockcheck \
--norestore \
"$@"

View File

@@ -24,10 +24,6 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-skill</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId>
@@ -40,10 +36,6 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-job</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-dataspace</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-captcha</artifactId>

View File

@@ -1,109 +0,0 @@
package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import tech.easyflow.agent.entity.AgentArtifact;
import tech.easyflow.agent.runtime.artifact.AgentArtifactService;
import tech.easyflow.agent.runtime.artifact.AgentArtifactView;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
/**
* Agent Artifact 安全元数据与鉴权下载控制器。
*/
@RestController
@RequestMapping("/api/v1/agent/artifacts")
public class AgentArtifactController {
private final AgentArtifactService artifactService;
/**
* 创建 Artifact 控制器。
*
* @param artifactService Artifact 服务
*/
public AgentArtifactController(AgentArtifactService artifactService) {
this.artifactService = artifactService;
}
/**
* 查询一个已鉴权 Artifact 的安全元数据。
*
* @param artifactId 稳定 Artifact ID
* @return 安全元数据
*/
@GetMapping("/{artifactId}")
@SaCheckPermission(value = {"/api/v1/agent/session/query", "/api/v1/agent/save"}, mode = SaMode.OR)
public Result<AgentArtifactView> metadata(@PathVariable String artifactId,
@RequestParam BigInteger agentId,
@RequestParam String mode,
@RequestParam(required = false) BigInteger sessionId,
@RequestParam(required = false) String runtimeSessionId) {
AgentArtifact artifact = artifactService.requireDownload(
artifactId, requireAccount(), agentId, mode, sessionId, runtimeSessionId);
return Result.ok(artifactService.toView(artifact));
}
/**
* 通过后端鉴权代理流式下载私有 Artifact。
*
* @param artifactId 稳定 Artifact ID
* @return 私有流式响应
*/
@GetMapping("/{artifactId}/content")
@SaCheckPermission(value = {"/api/v1/agent/session/query", "/api/v1/agent/save"}, mode = SaMode.OR)
public ResponseEntity<StreamingResponseBody> content(@PathVariable String artifactId,
@RequestParam BigInteger agentId,
@RequestParam String mode,
@RequestParam(required = false) BigInteger sessionId,
@RequestParam(required = false) String runtimeSessionId) {
AgentArtifact artifact = artifactService.requireDownload(
artifactId, requireAccount(), agentId, mode, sessionId, runtimeSessionId);
StreamingResponseBody body = output -> {
try (InputStream input = artifactService.openDownload(artifact)) {
input.transferTo(output);
}
};
String mimeType = artifact.getMimeType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : artifact.getMimeType();
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(artifact.getFileName(), StandardCharsets.UTF_8).build().toString())
.header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.parseMediaType(mimeType))
.contentLength(artifact.getSizeBytes() == null ? 0L : artifact.getSizeBytes())
.body(body);
}
private LoginAccount requireAccount() {
try {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
} catch (BusinessException error) {
throw error;
} catch (Exception error) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
}
}

View File

@@ -1,27 +1,16 @@
package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import io.agentscope.core.agui.model.RunAgentInput;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
@@ -30,44 +19,30 @@ import tech.easyflow.agent.publish.AgentPublishAppService;
import tech.easyflow.agent.runtime.AgentChatRequest;
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
import tech.easyflow.agent.runtime.AgentRunService;
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatusView;
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
import tech.easyflow.agent.runtime.document.AgentDocumentResource;
import tech.easyflow.agent.runtime.document.AgentDocumentService;
import tech.easyflow.agent.runtime.document.AgentDocumentUploadView;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
import com.easyagents.agent.runtime.media.AgentMediaResource;
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
import tech.easyflow.agent.service.AgentApprovalStateService;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.agent.service.AgentSkillBindingService;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.agent.vo.AgentResourceOptionsView;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.log.annotation.LogReporterDisabled;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import static tech.easyflow.agent.entity.table.AgentTableDef.AGENT;
/**
* Agent 管理端控制器。
*/
@@ -80,25 +55,17 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
@Resource
private AgentKnowledgeBindingService agentKnowledgeBindingService;
@Resource
private AgentSkillBindingService agentSkillBindingService;
@Resource
private AgentRunService agentRunService;
@Resource
private AgentPublishAppService agentPublishAppService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
private AgentApprovalStateService agentApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Resource
private AgentMediaService agentMediaService;
@Resource
private AgentDocumentService agentDocumentService;
@Resource
private AgentComposerDraftService agentComposerDraftService;
@Resource
private AgentOptionQueryService agentOptionQueryService;
@Resource
private AgentVisibilityQueryHelper agentVisibilityQueryHelper;
/**
* 创建 Agent 控制器。
@@ -109,16 +76,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
super(service);
}
/**
* 获取 Agent 列表关键字搜索字段。
*
* @return Agent 名称和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"name", "description"};
}
/**
* 获取 Agent 详情。
*
@@ -126,11 +83,10 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return Agent 详情
*/
@GetMapping("/getDetail")
public Result<AgentDetailView> getDetail(BigInteger id) {
public Result<Agent> getDetail(BigInteger id) {
Agent agent = service.getDetail(id);
agentApprovalStateService.fillAgentApprovalState(agent);
aiResourceCreatorNameSupport.fillAgentCreatorNames(List.of(agent));
return Result.ok(AgentDetailView.from(agent));
return Result.ok(agent);
}
/**
@@ -142,8 +98,7 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
@Override
@PostMapping("save")
public Result<?> save(@JsonBody Agent agent) {
Agent saved = service.saveDraft(agent);
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
return Result.ok(service.saveDraft(agent));
}
/**
@@ -155,45 +110,7 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
@Override
@PostMapping("update")
public Result<?> update(@JsonBody Agent agent) {
Agent saved = service.updateDraft(agent);
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
}
/**
* 原子保存 Agent 草稿及本次发生变化的绑定组。
*
* @param request 设计器保存请求
* @return 保存后的 Agent 与本次替换的绑定
*/
@PostMapping("/draft/save")
@SaCheckPermission("/api/v1/agent/save")
public Result<AgentDetailView> saveDraft(@JsonBody(required = true, skipConvertError = false)
AgentDraftSaveRequest request) {
if (request == null || request.getAgent() == null) {
throw new BusinessException("Agent 草稿不能为空");
}
Agent saved = service.saveDraftGraph(
request.getAgent(),
request.getToolBindings(),
request.isReplaceToolBindings(),
request.getKnowledgeBindings(),
request.isReplaceKnowledgeBindings(),
request.toSkillBindings(),
request.isReplaceSkillBindings());
return Result.ok(AgentDetailView.from(saved));
}
/**
* 更新 Agent 可见范围。
*
* @param agent 包含 Agent ID 和可见范围的请求数据
* @return 更新后的 Agent
*/
@PostMapping("visibilityScope/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<AgentDetailView> updateVisibilityScope(@JsonBody Agent agent) {
return Result.ok(AgentDetailView.from(
service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope())));
return Result.ok(service.updateDraft(agent));
}
/**
@@ -209,67 +126,20 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
public Result<List<Agent>> list(Agent entity, Boolean asTree, String sortKey, String sortType) {
HttpServletRequest request = currentRequest();
QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request);
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
if (!applyCategoryPermission(queryWrapper)) {
return Result.ok(Collections.emptyList());
}
applyPublishedOnlyFilter(queryWrapper);
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<Agent> agents = service.list(queryWrapper);
if (isPublishedOnlyRequest()) {
agents = agents.stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList();
}
agents.forEach(this::sanitizeListItem);
agentApprovalStateService.fillAgentApprovalState(agents);
aiResourceCreatorNameSupport.fillAgentCreatorNames(agents);
return Result.ok(agents);
}
/**
* 查询 Agent 安全选择项。
*
* @param publishedOnly 是否仅返回已发布 Agent
* @return Agent 安全选择项
*/
@GetMapping("/options")
@SaCheckPermission("/api/v1/agent/query")
public Result<List<AgentOptionView>> options(
@RequestParam(value = "publishedOnly", defaultValue = "false") boolean publishedOnly) {
return Result.ok(agentOptionQueryService.listAgentOptions(publishedOnly));
}
/**
* 查询 Agent 设计器的安全资源选项。
*
* @return 设计器资源选项
*/
@GetMapping("/resourceOptions")
@SaCheckPermission("/api/v1/agent/save")
public Result<AgentResourceOptionsView> resourceOptions() {
return Result.ok(agentOptionQueryService.listDesignerResourceOptions());
}
/**
* 查询 Agent 会话可使用的知识库安全选项。
*
* @return 知识库选项
*/
@GetMapping("/knowledgeOptions")
@SaCheckPermission("/api/v1/agent/query")
public Result<List<AgentResourceOptionsView.ResourceOption>> knowledgeOptions() {
return Result.ok(agentOptionQueryService.listKnowledgeOptions());
}
/**
* 查询指定 MCP 的安全工具列表。
*
* @param id MCP ID
* @return MCP 工具列表
*/
@GetMapping("/mcpToolOptions")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentResourceOptionsView.McpToolOption>> mcpToolOptions(
@RequestParam BigInteger id) {
return Result.ok(agentOptionQueryService.listMcpTools(id));
}
/**
* 运行 Agent 纯文本聊天。
*
@@ -277,7 +147,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return SSE Emitter
*/
@PostMapping("chat")
@SaCheckPermission("/api/v1/agent/session/query")
public SseEmitter chat(@JsonBody AgentChatRequest request) {
return agentRunService.chat(request);
}
@@ -289,327 +158,10 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return SSE Emitter
*/
@PostMapping("/chat/draft")
@SaCheckPermission("/api/v1/agent/save")
public SseEmitter chatDraft(@JsonBody AgentDraftChatRequest request) {
return agentRunService.chatDraft(request);
}
/**
* 通过 AG-UI 协议运行正式 Agent 聊天。
*
* @param agentId URL 中的 Agent ID
* @param input AG-UI 运行输入
* @return 原生 AG-UI SSE
*/
@PostMapping("/{agentId}/agui/run")
@SaCheckPermission("/api/v1/agent/session/query")
public SseEmitter chatAgui(@PathVariable BigInteger agentId,
@RequestBody RunAgentInput input) {
return agentRunService.chatAgui(agentId, input);
}
/**
* 通过 AG-UI 协议运行草稿 Agent 试用。
*
* @param input AG-UI 运行输入
* @return 原生 AG-UI SSE
*/
@PostMapping("/agui/run/draft")
@SaCheckPermission("/api/v1/agent/save")
public SseEmitter chatDraftAgui(@RequestBody RunAgentInput input) {
return agentRunService.chatDraftAgui(input);
}
/**
* 查询可重连 AG-UI 运行状态。
*
* @param runId 客户端运行 ID
* @return 运行状态
*/
@GetMapping("/agui/run/{runId}/status")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentAguiRunStatusView> getAguiRunStatus(@PathVariable String runId) {
return Result.ok(agentRunService.getAguiRunStatus(runId));
}
/**
* 从指定游标继续订阅 AG-UI 运行事件。
*
* @param runId 客户端运行 ID
* @param after 已消费的最后事件游标
* @return 增量重放 SSE
*/
@GetMapping("/agui/run/{runId}/events")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public SseEmitter subscribeAguiRun(@PathVariable String runId,
@RequestParam(defaultValue = "0") long after) {
return agentRunService.subscribeAguiRun(runId, after);
}
/**
* 显式取消单次 AG-UI 运行。
*
* @param runId 客户端运行 ID
* @return 操作结果
*/
@PostMapping("/agui/run/{runId}/cancel")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> cancelAguiRun(@PathVariable String runId) {
agentRunService.cancelAguiRun(runId);
return Result.ok();
}
/**
* 处理 AG-UI 自定义 HITL 兼容桥审批。
*
* @param request 审批请求
* @return 操作结果
*/
@PostMapping("/agui/hitl/resolve")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> resolveAguiApproval(@RequestBody AgentAguiHitlResolveRequest request) {
agentRunService.resolveAguiApproval(request);
return Result.ok();
}
/**
* 上传一张 Agent 聊天临时图片。
*
* @param file 图片文件
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @return 上传结果
*/
@PostMapping(value = "/media/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentMediaUploadView> uploadMedia(@RequestParam("file") MultipartFile file,
@RequestParam("mode") String mode,
@RequestParam("agentId") String agentId,
@RequestParam("sessionId") String sessionId) {
return Result.ok(agentMediaService.upload(file, mode, agentId, sessionId, SaTokenUtil.getLoginAccount()));
}
/**
* 删除当前账号尚未发送的临时图片。
*
* @param uploadId 上传 ID
* @return 操作结果
*/
@PostMapping("/media/delete")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> deleteMedia(@JsonBody(value = "uploadId", required = true) String uploadId) {
agentMediaService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount());
return Result.ok();
}
/**
* 通过鉴权代理读取 Agent 私有聊天图片。
*
* @param reference 稳定图片引用
* @return 图片响应
*/
@GetMapping("/media/content")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
@LogReporterDisabled
public ResponseEntity<byte[]> mediaContent(@RequestParam("reference") String reference) {
AgentMediaResource resource = agentMediaService.load(reference, SaTokenUtil.getLoginAccount());
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "private, no-store")
.header(HttpHeaders.CONTENT_DISPOSITION, "inline")
.contentType(MediaType.parseMediaType(resource.mimeType()))
.contentLength(resource.bytes().length)
.body(resource.bytes());
}
/**
* 上传一份 Agent 聊天文档并异步触发轻量读取。
*
* @param file 文档文件
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param uploadId 客户端生成的幂等上传 ID
* @return 上传与读取状态
*/
@PostMapping(value = "/media/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentDocumentUploadView> uploadDocument(@RequestParam("file") MultipartFile file,
@RequestParam("mode") String mode,
@RequestParam("agentId") String agentId,
@RequestParam("sessionId") String sessionId,
@RequestParam(value = "uploadId", required = false)
String uploadId) {
return Result.ok(agentDocumentService.upload(
file, mode, agentId, sessionId, uploadId, SaTokenUtil.getLoginAccount()));
}
/**
* 查询当前账号一个上传文档的读取状态。
*
* @param uploadId 上传 ID
* @return 最新状态
*/
@GetMapping("/media/document/status")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentDocumentUploadView> documentStatus(@RequestParam("uploadId") String uploadId) {
return Result.ok(agentDocumentService.status(uploadId, SaTokenUtil.getLoginAccount()));
}
/**
* 重试一次明确失败的文档读取。
*
* @param uploadId 上传 ID
* @return 重试后的状态
*/
@PostMapping("/media/document/retry")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentDocumentUploadView> retryDocument(
@JsonBody(value = "uploadId", required = true) String uploadId) {
return Result.ok(agentDocumentService.retry(uploadId, SaTokenUtil.getLoginAccount()));
}
/**
* 删除当前账号尚未发送的临时文档。
*
* @param uploadId 上传 ID
* @return 操作结果
*/
@PostMapping("/media/document/delete")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> deleteDocument(
@JsonBody(value = "uploadId", required = true) String uploadId) {
agentDocumentService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount());
return Result.ok();
}
/**
* 通过鉴权代理流式下载 Agent 私有聊天文档。
*
* @param reference 稳定文档引用
* @return 文档流
*/
@GetMapping("/media/document/content")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
@LogReporterDisabled
public ResponseEntity<StreamingResponseBody> documentContent(
@RequestParam("reference") String reference) {
AgentDocumentResource resource = agentDocumentService.load(
reference, SaTokenUtil.getLoginAccount());
StreamingResponseBody body = output -> {
try (var input = resource.inputStream()) {
input.transferTo(output);
}
};
ContentDisposition disposition = ContentDisposition.attachment()
.filename(resource.name(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "private, no-store")
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType(resource.mimeType()))
.contentLength(resource.size())
.body(body);
}
/**
* 为输入框预分配稳定会话 ID。
*
* @param mode 聊天模式
* @return 会话信息
*/
@PostMapping("/composer/session")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentComposerSession> allocateComposerSession(
@JsonBody(value = "mode", required = true) String mode) {
return Result.ok(agentComposerDraftService.allocateSession(mode));
}
/**
* 保存 Agent 输入草稿。
*
* @param draft 输入草稿
* @return 保存后的草稿
*/
@PostMapping("/composer/draft/persist")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentComposerDraft> saveComposerDraft(@JsonBody AgentComposerDraft draft) {
return Result.ok(agentComposerDraftService.save(draft, SaTokenUtil.getLoginAccount()));
}
/**
* 获取当前会话或最近未发送会话的输入草稿。
*
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID可为空
* @return 输入草稿
*/
@GetMapping("/composer/draft")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentComposerDraft> getComposerDraft(@RequestParam("mode") String mode,
@RequestParam("agentId") String agentId,
@RequestParam(value = "sessionId", required = false) String sessionId) {
return Result.ok(agentComposerDraftService.get(mode, agentId, sessionId, SaTokenUtil.getLoginAccount())
.orElse(null));
}
/**
* 删除已发送或主动清空的输入草稿。
*
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param imageUploadIds 调用方仍持有的上传 ID
* @param documentUploadIds 调用方仍持有的文档上传 ID
* @param deleteUploads 是否同时删除临时附件
* @return 操作结果
*/
@PostMapping("/composer/draft/delete")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> deleteComposerDraft(@JsonBody(value = "mode", required = true) String mode,
@JsonBody(value = "agentId", required = true) String agentId,
@JsonBody(value = "sessionId", required = true) String sessionId,
@JsonBody(value = "imageUploadIds") List<String> imageUploadIds,
@JsonBody(value = "documentUploadIds") List<String> documentUploadIds,
@JsonBody(value = "deleteUploads") Boolean deleteUploads) {
agentComposerDraftService.delete(mode, agentId, sessionId, imageUploadIds, documentUploadIds,
!Boolean.FALSE.equals(deleteUploads), SaTokenUtil.getLoginAccount());
return Result.ok();
}
/**
* 清理 Agent 草稿试运行会话。
*
@@ -617,7 +169,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果
*/
@PostMapping("/chat/draft/clear")
@SaCheckPermission("/api/v1/agent/save")
public Result<Void> clearDraftSession(@JsonBody(value = "sessionId", required = true) String sessionId) {
agentRunService.clearDraftSession(sessionId);
return Result.ok();
@@ -631,9 +182,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果
*/
@PostMapping("/run/approve")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> approve(@JsonBody("requestId") String requestId,
@JsonBody(value = "resumeToken", required = true) String resumeToken) {
agentRunService.approve(requestId, resumeToken);
@@ -649,9 +197,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
* @return 操作结果
*/
@PostMapping("/run/reject")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> reject(@JsonBody("requestId") String requestId,
@JsonBody(value = "resumeToken", required = true) String resumeToken,
@JsonBody("reason") String reason) {
@@ -687,26 +232,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
return Result.ok(agentKnowledgeBindingService.replaceBindings(agentId, bindings));
}
/**
* 原子替换 Agent 的全部 Skill 草稿绑定。
*
* @param request 白名单 Skill 引用请求
* @return 服务端生成的安全 Skill 摘要
*/
@PostMapping("/skillBinding/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<List<AgentDetailView.SkillBindingView>> updateSkillBinding(
@JsonBody(required = true, skipConvertError = false) AgentSkillBindingUpdateRequest request) {
if (request == null || request.getAgentId() == null) {
throw new BusinessException("Agent ID 不能为空");
}
List<tech.easyflow.agent.entity.AgentSkillBinding> bindings = request.getBindings() == null
? List.of()
: request.getBindings().stream().map(AgentSkillBindingUpdateRequest.Binding::toEntity).toList();
return Result.ok(agentSkillBindingService.replaceBindings(request.getAgentId(), bindings)
.stream().map(AgentDetailView.SkillBindingView::from).toList());
}
/**
* 提交发布审批。
*
@@ -744,13 +269,16 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
}
@Override
public Result<?> remove(Serializable id) {
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
for (Serializable id : ids) {
Agent agent = service.getById(String.valueOf(id));
if (agent != null) {
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限删除该 Agent");
}
@Override
public Result<?> removeBatch(Collection<Serializable> ids) {
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
}
agentToolBindingService.remove(QueryWrapper.create().in("agent_id", ids));
agentKnowledgeBindingService.remove(QueryWrapper.create().in("agent_id", ids));
return super.onRemoveBefore(ids);
}
/**
@@ -762,55 +290,38 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
*/
@Override
protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) {
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
if (!applyCategoryPermission(queryWrapper)) {
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
}
applyPublishedOnlyFilter(queryWrapper);
Page<Agent> result = service.page(page, queryWrapper);
Page<Agent> result = super.queryPage(page, queryWrapper);
if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList());
}
result.getRecords().forEach(this::sanitizeListItem);
agentApprovalStateService.fillAgentApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillAgentCreatorNames(result.getRecords());
return result;
}
/**
* 清理列表无需返回的配置和发布快照,避免敏感运行配置进入浏览器。
*
* @param agent Agent 列表项
*/
private void sanitizeListItem(Agent agent) {
if (agent == null) {
return;
private boolean applyCategoryPermission(QueryWrapper queryWrapper) {
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
if (!access.isRestricted()) {
return true;
}
agent.setModelConfigJson(Collections.emptyMap());
agent.setGenerationConfigJson(Collections.emptyMap());
agent.setPromptConfigJson(Collections.emptyMap());
agent.setMemoryConfigJson(Collections.emptyMap());
agent.setExecutionConfigJson(Collections.emptyMap());
agent.setInteractionConfigJson(Collections.emptyMap());
agent.setPublishedSnapshotJson(Collections.emptyMap());
agent.setToolBindings(null);
agent.setKnowledgeBindings(null);
agent.setSkillBindings(null);
if (access.getCategoryIds().isEmpty()) {
queryWrapper.eq(Agent::getCreatedBy, access.getAccountId());
return true;
}
queryWrapper.and(AGENT.CREATED_BY.eq(access.getAccountId()).or(AGENT.CATEGORY_ID.in(access.getCategoryIds())));
return true;
}
/**
* 为仅发布查询追加发布状态条件。
*
* @param queryWrapper Agent 查询条件
*/
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
if (isPublishedOnlyRequest()) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
}
}
/**
* 判断当前请求是否只查询已发布 Agent。
*
* @return 是否仅查询已发布 Agent
*/
private boolean isPublishedOnlyRequest() {
HttpServletRequest request = currentRequest();
if (request == null) {
@@ -832,14 +343,6 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
return attributes.getRequest();
}
/**
* 将审批执行结果转换为统一响应。
*
* @param actionResult 审批动作结果
* @param approvalMessage 进入审批时的提示
* @param directMessage 直接执行时的提示
* @return 审批实例响应
*/
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {

View File

@@ -1,114 +0,0 @@
package tech.easyflow.admin.controller.agent;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 管理端 Agent 草稿安全详情。
*
* <p>该视图明确排除发布快照以及各绑定的内部资源快照。</p>
*/
public record AgentDetailView(
BigInteger id,
BigInteger deptId,
String name,
String description,
String avatar,
BigInteger categoryId,
BigInteger modelId,
Map<String, Object> modelConfigJson,
Map<String, Object> generationConfigJson,
Map<String, Object> promptConfigJson,
Map<String, Object> memoryConfigJson,
Map<String, Object> executionConfigJson,
Map<String, Object> interactionConfigJson,
Integer status,
String visibilityScope,
String publishStatus,
BigInteger currentApprovalInstanceId,
Date publishedAt,
BigInteger publishedBy,
Date created,
BigInteger createdBy,
Date modified,
BigInteger modifiedBy,
Boolean approvalPending,
String currentApprovalActionType,
String displayPublishStatus,
String createdByName,
List<ToolBindingView> toolBindings,
List<KnowledgeBindingView> knowledgeBindings,
List<SkillBindingView> skillBindings) {
/**
* 从领域实体构造安全详情。
*
* @param agent Agent 领域实体
* @return 安全详情
*/
public static AgentDetailView from(Agent agent) {
return new AgentDetailView(agent.getId(), agent.getDeptId(), agent.getName(), agent.getDescription(),
agent.getAvatar(), agent.getCategoryId(), agent.getModelId(), agent.getModelConfigJson(),
agent.getGenerationConfigJson(), agent.getPromptConfigJson(), agent.getMemoryConfigJson(),
agent.getExecutionConfigJson(), agent.getInteractionConfigJson(), agent.getStatus(),
agent.getVisibilityScope(), agent.getPublishStatus(), agent.getCurrentApprovalInstanceId(),
agent.getPublishedAt(), agent.getPublishedBy(), agent.getCreated(), agent.getCreatedBy(),
agent.getModified(), agent.getModifiedBy(), agent.getApprovalPending(),
agent.getCurrentApprovalActionType(), agent.getDisplayPublishStatus(), agent.getCreatedByName(),
mapTools(agent.getToolBindings()), mapKnowledges(agent.getKnowledgeBindings()),
mapSkills(agent.getSkillBindings()));
}
private static List<ToolBindingView> mapTools(List<AgentToolBinding> bindings) {
return bindings == null ? List.of() : bindings.stream().map(ToolBindingView::from).toList();
}
private static List<KnowledgeBindingView> mapKnowledges(List<AgentKnowledgeBinding> bindings) {
return bindings == null ? List.of() : bindings.stream().map(KnowledgeBindingView::from).toList();
}
private static List<SkillBindingView> mapSkills(List<AgentSkillBinding> bindings) {
return bindings == null ? List.of() : bindings.stream().map(SkillBindingView::from).toList();
}
/** Agent 直接 Tool 草稿绑定。 */
public record ToolBindingView(BigInteger id, String toolType, BigInteger targetId, String toolName,
Boolean enabled, Boolean hitlEnabled, Map<String, Object> hitlConfigJson,
Map<String, Object> optionsJson, Integer sortNo,
Map<String, Object> resourceSummary) {
/** @param value 实体 @return 安全绑定 */
static ToolBindingView from(AgentToolBinding value) {
return new ToolBindingView(value.getId(), value.getToolType(), value.getTargetId(), value.getToolName(),
value.getEnabled(), value.getHitlEnabled(), value.getHitlConfigJson(), value.getOptionsJson(),
value.getSortNo(), value.getResourceSummary());
}
}
/** Agent 知识库草稿绑定。 */
public record KnowledgeBindingView(BigInteger id, BigInteger knowledgeId, String retrievalMode,
Boolean enabled, Map<String, Object> optionsJson, Integer sortNo,
Map<String, Object> resourceSummary) {
/** @param value 实体 @return 安全绑定 */
static KnowledgeBindingView from(AgentKnowledgeBinding value) {
return new KnowledgeBindingView(value.getId(), value.getKnowledgeId(), value.getRetrievalMode(),
value.getEnabled(), value.getOptionsJson(), value.getSortNo(), value.getResourceSummary());
}
}
/** Agent Skill 草稿绑定。 */
public record SkillBindingView(BigInteger id, BigInteger skillId, Integer sortNo,
Map<String, Object> resourceSummary) {
/** @param value 实体 @return 安全绑定 */
static SkillBindingView from(AgentSkillBinding value) {
return new SkillBindingView(value.getId(), value.getSkillId(), value.getSortNo(),
value.getResourceSummary());
}
}
}

View File

@@ -1,109 +0,0 @@
package tech.easyflow.admin.controller.agent;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentSkillBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import java.util.List;
/**
* Agent 设计器原子保存请求。
*
* <p>绑定变更标记由设计器基于加载后的稳定业务字段计算。服务端仍会执行权限、状态与幂等比较,
* 标记为未变化的绑定不会进入查询、外部资源校验或整组重写流程。</p>
*/
public class AgentDraftSaveRequest {
private Agent agent;
private List<AgentToolBinding> toolBindings;
private boolean replaceToolBindings;
private List<AgentKnowledgeBinding> knowledgeBindings;
private boolean replaceKnowledgeBindings;
private List<AgentSkillBindingUpdateRequest.Binding> skillBindings;
private boolean replaceSkillBindings;
/** 创建空请求。 */
public AgentDraftSaveRequest() {
}
/** @return Agent 草稿 */
public Agent getAgent() {
return agent;
}
/** @param agent Agent 草稿 */
public void setAgent(Agent agent) {
this.agent = agent;
}
/** @return 工具绑定 */
public List<AgentToolBinding> getToolBindings() {
return toolBindings;
}
/** @param toolBindings 工具绑定 */
public void setToolBindings(List<AgentToolBinding> toolBindings) {
this.toolBindings = toolBindings;
}
/** @return 是否替换工具绑定 */
public boolean isReplaceToolBindings() {
return replaceToolBindings;
}
/** @param replaceToolBindings 是否替换工具绑定 */
public void setReplaceToolBindings(boolean replaceToolBindings) {
this.replaceToolBindings = replaceToolBindings;
}
/** @return 知识库绑定 */
public List<AgentKnowledgeBinding> getKnowledgeBindings() {
return knowledgeBindings;
}
/** @param knowledgeBindings 知识库绑定 */
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) {
this.knowledgeBindings = knowledgeBindings;
}
/** @return 是否替换知识库绑定 */
public boolean isReplaceKnowledgeBindings() {
return replaceKnowledgeBindings;
}
/** @param replaceKnowledgeBindings 是否替换知识库绑定 */
public void setReplaceKnowledgeBindings(boolean replaceKnowledgeBindings) {
this.replaceKnowledgeBindings = replaceKnowledgeBindings;
}
/** @return Skill 绑定 */
public List<AgentSkillBindingUpdateRequest.Binding> getSkillBindings() {
return skillBindings;
}
/** @param skillBindings Skill 绑定 */
public void setSkillBindings(List<AgentSkillBindingUpdateRequest.Binding> skillBindings) {
this.skillBindings = skillBindings;
}
/** @return 是否替换 Skill 绑定 */
public boolean isReplaceSkillBindings() {
return replaceSkillBindings;
}
/** @param replaceSkillBindings 是否替换 Skill 绑定 */
public void setReplaceSkillBindings(boolean replaceSkillBindings) {
this.replaceSkillBindings = replaceSkillBindings;
}
/**
* 将 Skill 白名单引用转换为领域绑定。
*
* @return 最小 Skill 绑定列表
*/
public List<AgentSkillBinding> toSkillBindings() {
return skillBindings == null
? List.of() : skillBindings.stream().map(AgentSkillBindingUpdateRequest.Binding::toEntity).toList();
}
}

View File

@@ -1,21 +1,16 @@
package tech.easyflow.admin.controller.agent;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
import tech.easyflow.admin.service.agent.AgentSessionService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.agent.vo.AgentResourceOptionsView;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
@@ -26,42 +21,17 @@ import java.util.List;
*/
@RestController
@RequestMapping("/api/v1/agent/session")
@SaCheckPermission("/api/v1/agent/session/query")
public class AgentSessionController {
private final AgentSessionService agentSessionService;
private final AgentOptionQueryService agentOptionQueryService;
/**
* 创建 Agent 管理端会话控制器。
*
* @param agentSessionService Agent 会话服务
* @param agentOptionQueryService Agent 安全选项服务
*/
public AgentSessionController(AgentSessionService agentSessionService,
AgentOptionQueryService agentOptionQueryService) {
public AgentSessionController(AgentSessionService agentSessionService) {
this.agentSessionService = agentSessionService;
this.agentOptionQueryService = agentOptionQueryService;
}
/**
* 查询正式聊天可使用的已发布 Agent。
*
* @return Agent 安全选项
*/
@GetMapping("/options")
public Result<List<AgentOptionView>> options() {
return Result.ok(agentOptionQueryService.listAgentOptions(true));
}
/**
* 查询正式聊天可附加的知识库。
*
* @return 知识库安全选项
*/
@GetMapping("/knowledgeOptions")
public Result<List<AgentResourceOptionsView.ResourceOption>> knowledgeOptions() {
return Result.ok(agentOptionQueryService.listKnowledgeOptions());
}
/**
@@ -160,17 +130,7 @@ public class AgentSessionController {
return Result.ok();
}
/**
* 获取当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录信息失效时抛出
*/
private LoginAccount currentAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
return SaTokenUtil.getLoginAccount();
}
}

View File

@@ -1,84 +0,0 @@
package tech.easyflow.admin.controller.agent;
import tech.easyflow.agent.entity.AgentSkillBinding;
import java.math.BigInteger;
import java.util.List;
/**
* Agent Skill 整组替换请求。
*
* <p>使用标准 JavaBean 以兼容 {@code @JsonBody} 的 Fastjson 1 嵌套列表转换。</p>
*/
public class AgentSkillBindingUpdateRequest {
private BigInteger agentId;
private List<Binding> bindings;
/** 创建空请求。 */
public AgentSkillBindingUpdateRequest() {
}
/**
* 创建 Agent Skill 绑定请求。
*
* @param agentId Agent ID
* @param bindings Skill 引用
*/
public AgentSkillBindingUpdateRequest(BigInteger agentId, List<Binding> bindings) {
this.agentId = agentId;
this.bindings = bindings;
}
/** @return Agent ID */
public BigInteger getAgentId() { return agentId; }
/** @param agentId Agent ID */
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
/** @return Skill 引用 */
public List<Binding> getBindings() { return bindings; }
/** @param bindings Skill 引用 */
public void setBindings(List<Binding> bindings) { this.bindings = bindings; }
/** 客户端允许提交的最小 Skill 引用。 */
public static class Binding {
private BigInteger skillId;
private Integer sortNo;
/** 创建空绑定。 */
public Binding() {
}
/**
* 创建最小 Skill 绑定。
*
* @param skillId Skill ID
* @param sortNo 排序号
*/
public Binding(BigInteger skillId, Integer sortNo) {
this.skillId = skillId;
this.sortNo = sortNo;
}
/** @return Skill ID */
public BigInteger getSkillId() { return skillId; }
/** @param skillId Skill ID */
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
/** @return 排序号 */
public Integer getSortNo() { return sortNo; }
/** @param sortNo 排序号 */
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
/**
* 转换为不含任何服务端快照的领域引用。
*
* @return 最小 Skill 绑定
*/
public AgentSkillBinding toEntity() {
AgentSkillBinding value = new AgentSkillBinding();
value.setSkillId(skillId);
value.setSortNo(sortNo);
return value;
}
}
}

View File

@@ -18,7 +18,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.service.ai.ChatWorkspaceService;
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
@@ -82,9 +82,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
@Resource
private ChatRoundOperateService chatRoundOperateService;
@Resource
private BotApprovalStateService botApprovalStateService;
private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource
private BotResourceCreatorNameSupport botResourceCreatorNameSupport;
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Resource
private ChatWorkspaceService chatWorkspaceService;
@@ -97,16 +97,6 @@ public class BotController extends BaseCurdController<BotService, Bot> {
this.botMessageService = botMessageService;
}
/**
* 获取智能体列表关键字搜索字段。
*
* @return 标题和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title", "description"};
}
@Resource
private BotPluginService botPluginService;
@@ -250,7 +240,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
bot = botService.toPublishedView(rawBot);
}
if (StpUtil.isLogin()) {
botApprovalStateService.fillApprovalState(bot);
aiResourceApprovalStateService.fillBotApprovalState(bot);
}
return Result.ok(bot);
}
@@ -285,7 +275,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (data.getModelId() == null) {
if (StpUtil.isLogin()) {
botApprovalStateService.fillApprovalState(data);
aiResourceApprovalStateService.fillBotApprovalState(data);
}
return Result.ok(data);
}
@@ -296,7 +286,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (llm == null) {
data.setModelId(null);
if (StpUtil.isLogin()) {
botApprovalStateService.fillApprovalState(data);
aiResourceApprovalStateService.fillBotApprovalState(data);
}
return Result.ok(data);
}
@@ -312,43 +302,21 @@ public class BotController extends BaseCurdController<BotService, Bot> {
}
if (StpUtil.isLogin()) {
botApprovalStateService.fillApprovalState(data);
aiResourceApprovalStateService.fillBotApprovalState(data);
}
return Result.ok(data);
}
/**
* 提交聊天助手发布审批。
*
* @param id 助手 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
botPublishAppService.submitPublishApproval(id, applicationReason),
botPublishAppService.submitPublishApproval(id),
"已提交发布审批",
"已直接发布"
);
}
/**
* 预检聊天助手发布是否命中审批流。
*
* @param id 助手 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/bot/save")
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(botPublishAppService.isPublishApprovalRequired(id));
}
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/bot/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
@@ -379,7 +347,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (isPublishedOnlyRequest()) {
bots = bots.stream().map(botService::toPublishedView).toList();
}
botApprovalStateService.fillApprovalState(bots);
aiResourceApprovalStateService.fillBotApprovalState(bots);
return Result.ok(bots);
}
@@ -391,8 +359,8 @@ public class BotController extends BaseCurdController<BotService, Bot> {
if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList());
}
botApprovalStateService.fillApprovalState(result.getRecords());
botResourceCreatorNameSupport.fillCreatorNames(result.getRecords());
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords());
return result;
}

View File

@@ -1,64 +0,0 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.service.BotPluginService;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
import java.util.List;
/**
* 旧 Bot 插件工具绑定查询控制器。
*
* <p>保留历史接口地址,同时将 Bot 表依赖限制在 Bot 专属代码中。</p>
*/
@RestController
@RequestMapping("/api/v1/pluginItem")
@UsePermission(moduleName = "/api/v1/plugin")
public class BotPluginItemController {
private final BotPluginService botPluginService;
/**
* 创建 Bot 插件工具绑定查询控制器。
*
* @param botPluginService Bot 插件绑定服务
*/
public BotPluginItemController(BotPluginService botPluginService) {
this.botPluginService = botPluginService;
}
/**
* 查询插件工具,并标记指定 Bot 已绑定的工具。
*
* @param pluginId 插件 ID
* @param botId Bot ID
* @return 插件工具列表
*/
@PostMapping("/toolsList")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> searchPluginTools(
@JsonBody(value = "pluginId", required = true) BigInteger pluginId,
@JsonBody(value = "botId", required = false) BigInteger botId) {
return Result.ok(botPluginService.searchPluginTools(pluginId, botId));
}
/**
* 查询指定 Bot 已绑定的插件工具。
*
* @param botId Bot ID
* @return 已绑定插件工具列表
*/
@PostMapping("/tool/list")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> getPluginTools(
@JsonBody(value = "botId", required = true) BigInteger botId) {
return Result.ok(botPluginService.getPluginTools(botId));
}
}

View File

@@ -1,13 +1,10 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
@@ -19,133 +16,46 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.service.CategoryPermissionService;
import java.math.BigInteger;
import java.util.List;
@RestController
@RequestMapping("/api/v1/chatHistory")
@SaCheckPermission("/api/v1/chatHistory/query")
public class ChatHistoryController {
private final ChatHistoryManageService chatHistoryManageService;
private final CategoryPermissionService categoryPermissionService;
private final AgentOptionQueryService agentOptionQueryService;
/**
* 创建聊天历史控制器。
*
* @param chatHistoryManageService 聊天历史管理服务
* @param categoryPermissionService 账号权限服务
* @param agentOptionQueryService Agent 安全选项服务
*/
public ChatHistoryController(ChatHistoryManageService chatHistoryManageService,
CategoryPermissionService categoryPermissionService,
AgentOptionQueryService agentOptionQueryService) {
public ChatHistoryController(ChatHistoryManageService chatHistoryManageService) {
this.chatHistoryManageService = chatHistoryManageService;
this.categoryPermissionService = categoryPermissionService;
this.agentOptionQueryService = agentOptionQueryService;
}
/**
* 查询聊天记录筛选可使用的 Agent。
*
* @return Agent 安全选项
*/
@GetMapping("/agentOptions")
public Result<List<AgentOptionView>> agentOptions() {
return Result.ok(agentOptionQueryService.listAgentOptions(false));
}
/**
* 分页查询当前账号可见的 Agent 会话。
*
* @param query 会话筛选条件
* @return 会话分页结果
*/
@GetMapping("/sessions")
public Result<ChatSessionPage> listSessions(ChatSessionFilterQuery query) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.queryAdminSessions(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
query
));
return Result.ok(chatHistoryManageService.queryAdminSessions(query));
}
/**
* 获取当前账号可见的 Agent 会话详情。
*
* @param sessionId 会话 ID
* @return 会话详情
*/
@GetMapping("/sessions/{sessionId}")
public Result<ChatSessionSummary> getSession(@PathVariable BigInteger sessionId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.getAdminSession(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId
));
return Result.ok(chatHistoryManageService.getAdminSession(sessionId));
}
/**
* 分页查询当前账号可见会话的消息。
*
* @param sessionId 会话 ID
* @param query 消息分页条件
* @return 消息分页结果
*/
@GetMapping("/sessions/{sessionId}/messages")
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.queryAdminMessages(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId,
query
));
return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query));
}
/**
* 查询当前账号可见会话的答案版本。
*
* @param sessionId 会话 ID
* @param roundId 对话轮次 ID
* @return 答案版本列表
*/
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.listAdminRoundVariants(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId,
roundId
));
return Result.ok(chatHistoryManageService.listAdminRoundVariants(sessionId, roundId));
}
/**
* 选择当前账号可见会话的答案版本。
*
* @param sessionId 会话 ID
* @param roundId 对话轮次 ID
* @param variantIndex 目标版本索引
* @return 选中的答案记录
*/
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
@PathVariable BigInteger roundId,
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.selectAdminRoundVariant(
account.getId(),
categoryPermissionService.isSuperAdmin(account),
sessionId,
roundId,
variantIndex
));
return Result.ok(chatHistoryManageService.selectAdminRoundVariant(sessionId, roundId, variantIndex, account.getId()));
}
}

View File

@@ -1,18 +1,23 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseController;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import com.easyagents.core.document.Document;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -23,8 +28,12 @@ import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.ResourceLookup;
import tech.easyflow.system.permission.resource.RequireResourceAccess;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 控制层。
@@ -35,12 +44,19 @@ import java.util.List;
@RestController
@RequestMapping("/api/v1/documentChunk")
@UsePermission(moduleName = "/api/v1/documentCollection")
public class DocumentChunkController extends BaseController {
public class DocumentChunkController extends BaseCurdController<DocumentChunkService, DocumentChunk> {
private final DocumentChunkService documentChunkService;
@Resource
DocumentCollectionService documentCollectionService;
@Resource
ModelService modelService;
@Resource
DocumentChunkService documentChunkService;
public DocumentChunkController(DocumentChunkService service) {
this.documentChunkService = service;
super(service);
}
@GetMapping("page")
@@ -52,30 +68,9 @@ public class DocumentChunkController extends BaseController {
idExpr = "#request.getParameter('documentId')",
denyMessage = "无权限访问知识库"
)
public Result<Page<DocumentChunk>> page(
HttpServletRequest request,
Long pageNumber,
Long pageSize
) {
String documentIdValue = request.getParameter("documentId");
if (documentIdValue == null || documentIdValue.isBlank()) {
return Result.<Page<DocumentChunk>>fail("documentId不能为空", null);
}
BigInteger documentId;
try {
documentId = new BigInteger(documentIdValue);
} catch (NumberFormatException e) {
return Result.<Page<DocumentChunk>>fail("documentId格式不正确", null);
}
long normalizedPageNumber = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
long normalizedPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
QueryWrapper query = QueryWrapper.create()
.eq(DocumentChunk::getDocumentId, documentId)
.orderBy("sorting asc");
return Result.ok(documentChunkService.page(
new Page<>(normalizedPageNumber, normalizedPageSize),
query
));
@Override
public Result<Page<DocumentChunk>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
return super.page(request, sortKey, sortType, pageNumber, pageSize);
}
@PostMapping("update")
@@ -84,23 +79,43 @@ public class DocumentChunkController extends BaseController {
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
idExpr = "#request.id",
idExpr = "#documentChunk.id",
denyMessage = "无权限管理知识库"
)
public Result<?> update(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkContentUpdateRequest request
) {
DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null) {
return Result.fail(1, "记录不存在");
public Result<?> update(@JsonBody DocumentChunk documentChunk) {
boolean success = service.updateById(documentChunk);
if (success){
DocumentChunk record = documentChunkService.getById(documentChunk.getId());
DocumentCollection knowledge = documentCollectionService.getById(record.getDocumentCollectionId());
if (knowledge == null) {
return Result.fail(1, "知识库不存在");
}
DocumentChunk updated = documentChunkService.updateContent(
current.getDocumentCollectionId(),
current.getId(),
request.getContent()
);
return Result.ok(updated);
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
try {
// 设置向量模型
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
Document document = Document.of(documentChunk.getContent());
document.setId(documentChunk.getId());
Map<String, Object> metadata = new HashMap<>();
metadata.put("keywords", documentChunk.getMetadataKeyWords());
metadata.put("questions", documentChunk.getMetadataQuestions());
document.setMetadataMap(metadata);
StoreResult result = documentStore.update(document, options); // 更新已有记录
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
}
return Result.ok(false);
}
@PostMapping("removeChunk")
@@ -112,58 +127,36 @@ public class DocumentChunkController extends BaseController {
idExpr = "#chunkId",
denyMessage = "无权限管理知识库"
)
public Result<?> removeChunk(@JsonBody(value = "id", required = true) BigInteger chunkId) {
public Result<?> remove(@JsonBody(value = "id", required = true) BigInteger chunkId) {
DocumentChunk docChunk = documentChunkService.getById(chunkId);
if (docChunk == null) {
return Result.fail(1, "记录不存在");
}
return Result.ok(documentChunkService.deleteChunk(
docChunk.getDocumentCollectionId(),
chunkId
));
DocumentCollection knowledge = documentCollectionService.getById(docChunk.getDocumentCollectionId());
if (knowledge == null) {
return Result.fail(2, "知识库不存在");
}
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(3, "知识库没有配置向量库");
}
try {
// 设置向量模型
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
return Result.fail(4, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
List<BigInteger> deleteList = new ArrayList<>();
deleteList.add(chunkId);
documentStore.delete(deleteList, options);
documentChunkService.removeChunk(knowledge, chunkId);
@PostMapping("syncStatus")
@SaCheckPermission("/api/v1/documentCollection/query")
@RequireResourceAccess(
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.READ,
lookup = ResourceLookup.DOCUMENT_ID,
idExpr = "#request.documentId",
denyMessage = "无权限访问知识库"
)
public Result<List<DocumentChunkSyncStatus>> syncStatus(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkSyncStatusRequest request
) {
return Result.ok(documentChunkService.listIndexSyncStatus(
null,
request.getDocumentId(),
request.getIds()
));
return super.remove(chunkId);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
@PostMapping("retrySync")
@SaCheckPermission("/api/v1/documentCollection/save")
@RequireResourceAccess(
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
idExpr = "#request.id",
denyMessage = "无权限管理知识库"
)
public Result<?> retrySync(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkSyncRetryRequest request
) {
DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null || request.getIndexSyncVersion() == null) {
return Result.fail(1, "记录不存在或同步版本缺失");
}
return Result.ok(documentChunkService.retryIndexSync(
current.getDocumentCollectionId(),
current.getId(),
request.getIndexSyncVersion()
));
}
}

View File

@@ -14,11 +14,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.BotDocumentCollection;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.PublishStatus;
@@ -28,6 +27,7 @@ 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;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
@@ -68,7 +68,7 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
private final ModelService llmService;
@Resource
private AgentKnowledgeBindingService agentKnowledgeBindingService;
private BotDocumentCollectionService botDocumentCollectionService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
@@ -86,16 +86,6 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
this.llmService = llmService;
}
/**
* 获取知识库列表关键字搜索字段。
*
* @return 标题和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title", "description"};
}
@Override
protected Result<?> onSaveOrUpdateBefore(DocumentCollection entity, boolean isSave) {
normalizeVisibilityScope(entity, isSave);
@@ -179,11 +169,11 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
}
QueryWrapper queryWrapper = QueryWrapper.create();
queryWrapper.in(AgentKnowledgeBinding::getKnowledgeId, ids);
queryWrapper.in(BotDocumentCollection::getDocumentCollectionId, ids);
boolean exists = agentKnowledgeBindingService.exists(queryWrapper);
boolean exists = botDocumentCollectionService.exists(queryWrapper);
if (exists){
throw new BusinessException("此知识库仍被智能体使用,请先取消绑定后再删除");
throw new BusinessException("此知识库还关联着bot请先取消关联");
}
return null;
@@ -213,34 +203,18 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
* 提交发布审批。
*
* @param id 知识库 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
knowledgePublishAppService.submitPublishApproval(id, applicationReason),
knowledgePublishAppService.submitPublishApproval(id),
"已提交发布审批",
"已直接发布"
);
}
/**
* 预检知识库发布是否命中审批流。
*
* @param id 知识库 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(knowledgePublishAppService.isPublishApprovalRequired(id));
}
/**
* 提交下线审批。
*

View File

@@ -11,11 +11,8 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.task.DocumentImportBatchAppService;
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentCollection;
@@ -86,9 +83,6 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
@Autowired
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
@Autowired
private DocumentImportBatchAppService documentImportBatchAppService;
@Value("${easyflow.storage.local.root:}")
private String fileUploadPath;
@@ -99,16 +93,6 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
super(service);
this.knowledgeService = knowledgeService;
}
/**
* 获取知识库文档关键字搜索字段。
*
* @return 文件标题属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title"};
}
@PostMapping("removeDoc")
@Transactional
@SaCheckPermission("/api/v1/documentCollection/remove")
@@ -118,10 +102,12 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
List<Serializable> ids = Collections.singletonList(id);
Result<?> result = onRemoveBefore(ids);
if (result != null) return result;
boolean success = documentService.removeDoc(id);
if (success) {
onRemoveAfter(ids);
boolean isSuccess = documentService.removeDoc(id);
if (!isSuccess){
return Result.ok(false);
}
boolean success = service.removeById(id);
onRemoveAfter(ids);
return Result.ok(success);
}
@@ -156,19 +142,13 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
@GetMapping("documentList")
@SaCheckPermission("/api/v1/documentCollection/query")
public Result<Page<Document>> documentList(
@RequestParam(name = "keyword", required = false) String keyword,
@RequestParam(name = "title", required = false) String legacyTitle,
@RequestParam(name = "pageSize") int pageSize,
@RequestParam(name = "pageNumber") int pageNumber) {
public Result<Page<Document>> documentList(@RequestParam(name="title", required = false) String fileName, @RequestParam(name="pageSize") int pageSize, @RequestParam(name = "pageNumber") int pageNumber) {
String kbSlug = RequestUtil.getParamAsString("id");
if (StringUtil.noText(kbSlug)) {
throw new BusinessException("知识库id不能为空");
}
DocumentCollection knowledge = getDocumentCollection(kbSlug, ResourceAction.READ, "无权限访问知识库");
String effectiveKeyword = StringUtil.hasText(keyword) ? keyword : legacyTitle;
Page<Document> documentList = documentService.getDocumentList(
knowledge.getId().toString(), pageSize, pageNumber, effectiveKeyword);
Page<Document> documentList = documentService.getDocumentList(knowledge.getId().toString(), pageSize, pageNumber,fileName);
return Result.ok(documentList);
}
@@ -332,142 +312,6 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
return documentService.retryIndexTask(request);
}
/**
* 创建文档批量上传清单。
*
* @param request 文件清单
* @return 批次与服务端文件项
*/
@PostMapping("import/batch/create")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportBatchDtos.CreateResponse> createImportBatch(
@JsonBody DocumentImportBatchDtos.CreateRequest request) {
if (request == null || request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return Result.ok(documentImportBatchAppService.createBatch(request));
}
/**
* 上传一个批次文件。
*
* @param batchId 批次 ID
* @param itemId 文件项 ID
* @param knowledgeId 知识库 ID
* @param file 上传文件
* @return 文件项状态
*/
@PostMapping(value = "import/batch/{batchId}/item/{itemId}/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportBatchDtos.ItemResponse> uploadImportBatchItem(
@PathVariable BigInteger batchId,
@PathVariable BigInteger itemId,
@RequestParam BigInteger knowledgeId,
@RequestPart("file") MultipartFile file) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
return Result.ok(documentImportBatchAppService.uploadItem(
knowledgeId, batchId, itemId, file));
}
/**
* 启动手动或自动批量导入。
*
* @param request 启动请求
* @return 批次状态
*/
@PostMapping("import/batch/start")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportBatchDtos.StatusResponse> startImportBatch(
@JsonBody DocumentImportBatchDtos.StartRequest request) {
if (request == null || request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return Result.ok(documentImportBatchAppService.startBatch(request));
}
/**
* 取消一个尚未启动的上传批次。
*
* @param knowledgeId 知识库 ID
* @param batchId 批次 ID
* @return 空结果
*/
@PostMapping("import/batch/cancel")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<Void> cancelImportBatch(
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(value = "batchId", required = true) BigInteger batchId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
documentImportBatchAppService.cancelBatch(knowledgeId, batchId);
return Result.ok();
}
/**
* 查询批次状态。
*
* @param knowledgeId 知识库 ID
* @param batchId 批次 ID
* @return 批次状态
*/
@GetMapping("import/batch/status")
@SaCheckPermission("/api/v1/documentCollection/query")
public Result<DocumentImportBatchDtos.StatusResponse> getImportBatchStatus(
@RequestParam BigInteger knowledgeId,
@RequestParam BigInteger batchId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
return Result.ok(documentImportBatchAppService.getBatchStatus(knowledgeId, batchId));
}
/**
* 查询知识库最近一个自动导入批次。
*
* @param knowledgeId 知识库 ID
* @return 最近批次状态
*/
@GetMapping("import/batch/current")
@SaCheckPermission("/api/v1/documentCollection/query")
public Result<DocumentImportBatchDtos.StatusResponse> getCurrentImportBatch(
@RequestParam BigInteger knowledgeId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
return Result.ok(documentImportBatchAppService.getLatestAutoBatch(knowledgeId));
}
/**
* 继续中断或部分失败的自动导入批次。
*
* @param knowledgeId 知识库 ID
* @param batchId 批次 ID
* @return 继续后的批次状态
*/
@PostMapping("import/batch/continue")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportBatchDtos.StatusResponse> continueImportBatch(
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(value = "batchId", required = true) BigInteger batchId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
return Result.ok(documentImportBatchAppService.continueBatch(knowledgeId, batchId));
}
/**
* 根据解析、分块或向量化失败阶段统一重试。
*
* @param request 重试请求
* @return 重试任务状态
*/
@PostMapping("import/task/retry")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryImportTask(
@JsonBody DocumentImportDtos.TaskRetryRequest request) {
if (request == null || request.getKnowledgeId() == null || request.getDocumentId() == null) {
throw new BusinessException("重试信息不完整");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.retryFailedTask(request);
}
/**
* 更新 entity
*

View File

@@ -2,7 +2,6 @@ package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -48,7 +47,7 @@ import java.util.Set;
@UsePermission(moduleName = "/api/v1/documentCollection")
public class FaqItemController extends BaseCurdController<FaqItemService, FaqItem> {
private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024L * 1024L;
private static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024L * 1024L;
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
"image/jpeg",
"image/png",
@@ -115,14 +114,9 @@ public class FaqItemController extends BaseCurdController<FaqItemService, FaqIte
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, collectionId);
String keyword = normalizeSearchKeyword(request.getParameter("keyword"));
String question = normalizeSearchKeyword(request.getParameter("question"));
if (StringUtils.hasText(keyword)) {
queryWrapper.and(buildLiteralContainsCondition(
keyword, new QueryColumn("question"), new QueryColumn("answer_text")));
} else if (StringUtils.hasText(question)) {
// 兼容旧客户端仅按问题字段搜索。
queryWrapper.and(buildLiteralContainsCondition(question, new QueryColumn("question")));
String question = request.getParameter("question");
if (question != null && !question.trim().isEmpty()) {
queryWrapper.like(FaqItem::getQuestion, question.trim());
}
String categoryIdText = request.getParameter("categoryId");
@@ -221,7 +215,7 @@ public class FaqItemController extends BaseCurdController<FaqItemService, FaqIte
throw new BusinessException("图片不能为空");
}
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
throw new BusinessException("图片大小不能超过20MB");
throw new BusinessException("图片大小不能超过5MB");
}
if (!isAllowedImageType(file)) {
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");

View File

@@ -9,19 +9,16 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.entity.BotMcp;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.BotMcpService;
import tech.easyflow.ai.service.McpService;
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.common.web.jsonbody.JsonBody;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 控制层。
@@ -36,18 +33,8 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
super(service);
}
/**
* 获取 MCP 列表关键字搜索字段。
*
* @return 标题和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title", "description"};
}
@Resource
private AgentResourceReferenceService agentResourceReferenceService;
private BotMcpService botMcpService;
@Override
public Result<?> save(Mcp entity) {
return service.saveMcp(entity);
@@ -58,29 +45,11 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
return service.updateMcp(entity);
}
/**
* 删除未被 Agent 绑定的 MCP。
*
* @param id MCP ID
* @return 删除结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
@Transactional
public Result<?> remove(Serializable id) {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
// 锁定 MCP 资源行,与 Agent 绑定校验串行,避免检查后并发写入绑定。
Mcp mcp = service.getOne(QueryWrapper.create()
.eq(Mcp::getId, id)
.eq(Mcp::getTenantId, account.getTenantId())
.forUpdate());
if (mcp == null) {
throw new BusinessException("MCP 不存在或无权删除");
}
agentResourceReferenceService.assertMcpUnused(new BigInteger(String.valueOf(id)));
service.removeMcp(id);
botMcpService.remove(QueryWrapper.create().eq(BotMcp::getMcpId, id));
return Result.ok();
}

View File

@@ -9,21 +9,15 @@ import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.entity.table.ModelTableDef;
import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.admin.model.ai.ModelGatewayConfigView;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.entity.SysOption;
import tech.easyflow.system.service.SysOptionService;
import tech.easyflow.ai.service.capability.ModelCapabilityResolution;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.tree.Tree;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import javax.annotation.Resource;
@@ -32,6 +26,7 @@ import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
@@ -44,11 +39,6 @@ import java.util.stream.Collectors;
@RequestMapping("/api/v1/model")
public class ModelController extends BaseCurdController<ModelService, Model> {
private static final String CHAT_PUBLISH_BASE_URL = "chat_publish_base_url";
@Autowired
private SysOptionService sysOptionService;
public ModelController(ModelService service) {
super(service);
}
@@ -56,30 +46,22 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
@Autowired
ModelService modelService;
/**
* 查询模型统一网关页面所需的安全配置。
*
* @return 仅包含模型发布基础地址的配置
*/
@GetMapping("/gatewayConfig")
@SaCheckPermission("/api/v1/model/query")
public Result<ModelGatewayConfigView> gatewayConfig() {
SysOption option = sysOptionService.getByOptionKey(
CHAT_PUBLISH_BASE_URL,
SaTokenUtil.getLoginAccount().getTenantId()
);
return Result.ok(new ModelGatewayConfigView(option == null ? null : option.getValue()));
}
@Resource
ModelMapper modelMapper;
@Resource
AgentResourceReferenceService agentResourceReferenceService;
@GetMapping("list")
@SaCheckPermission("/api/v1/model/query")
public Result<List<Model>> list(Model entity, Boolean asTree, String sortKey, String sortType) {
return Result.ok(service.listSelectableModels(entity, asTree, sortKey, sortType));
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<Model> list = Tree.tryToTree(modelMapper.selectListWithRelationsByQuery(queryWrapper), asTree);
list.forEach(item -> {
String providerName = Optional.ofNullable(item.getModelProvider())
.map(ModelProvider::getProviderName)
.orElse("-");
item.setTitle(providerName + "/" + item.getTitle());
});
return Result.ok(list);
}
@GetMapping("getList")
@@ -110,39 +92,9 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
return Result.ok(modelService.verifyModelConfig(model));
}
/**
* 根据模型 ID 返回自动识别的类型和能力。
*
* @param providerId 供应商 ID
* @param modelName 模型 ID
* @return 模型能力识别结果
*/
@GetMapping("capabilities")
@SaCheckPermission("/api/v1/model/query")
public Result<ModelCapabilityResolution> resolveCapabilities(
@RequestParam(required = false) BigInteger providerId,
@RequestParam String modelName) {
return Result.ok(modelService.resolveModelCapabilities(providerId, modelName));
}
@PostMapping("/removeByEntity")
@SaCheckPermission("/api/v1/model/remove")
@Transactional(rollbackFor = Exception.class)
public Result<?> removeByEntity(@RequestBody Model entity) {
LoginAccount account = requireAccount();
List<Model> models = service.list(QueryWrapper.create()
.select(Model::getId)
.eq(Model::getProviderId, entity.getProviderId())
.eq(Model::getGroupName, entity.getGroupName())
.eq(Model::getTenantId, account.getTenantId())
.orderBy(Model::getId, true)
.forUpdate());
if (models.isEmpty()) {
throw new BusinessException("模型不存在或无权删除");
}
agentResourceReferenceService.assertModelsUnused(
models.stream().map(Model::getId).toList());
entity.setTenantId(account.getTenantId());
modelService.removeByEntity(entity);
return Result.ok();
}
@@ -193,10 +145,8 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
QueryWrapper queryWrapper = QueryWrapper.create();
queryWrapper.eq(Model::getProviderId, providerId);
queryWrapper.eq(Model::getModelType, modelType);
String keyword = normalizeSearchKeyword(selectText);
if (StringUtils.hasText(keyword)) {
queryWrapper.and(buildLiteralContainsCondition(
keyword, ModelTableDef.MODEL.TITLE, ModelTableDef.MODEL.MODEL_NAME));
if (StringUtils.hasLength(selectText)) {
queryWrapper.and(ModelTableDef.MODEL.TITLE.like(selectText).or(ModelTableDef.MODEL.MODEL_NAME.like(selectText)));
}
List<Model> totalList = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
Map<String, List<Model>> groupList = totalList.stream().collect(Collectors.groupingBy(Model::getGroupName));
@@ -217,39 +167,11 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
}
@PostMapping("removeLlmByIds")
@SaCheckPermission("/api/v1/model/remove")
@Transactional(rollbackFor = Exception.class)
@Transactional
public Result<?> removeLlm(@JsonBody(value = "id", required = true) Serializable id) {
LoginAccount account = requireAccount();
List<Serializable> ids = Collections.singletonList(id);
QueryWrapper queryWrapper = QueryWrapper.create()
.in(Model::getId, ids)
.eq(Model::getTenantId, account.getTenantId())
.orderBy(Model::getId, true)
.forUpdate();
List<Model> models = service.list(queryWrapper);
if (models.isEmpty()) {
throw new BusinessException("模型不存在或无权删除");
}
agentResourceReferenceService.assertModelsUnused(
models.stream().map(Model::getId).toList());
service.remove(QueryWrapper.create()
.in(Model::getId, ids)
.eq(Model::getTenantId, account.getTenantId()));
QueryWrapper queryWrapper = QueryWrapper.create().in(Model::getId, ids);
service.remove(queryWrapper);
return Result.ok();
}
/**
* 获取带租户信息的当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录状态无效时抛出
*/
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
}

View File

@@ -1,33 +1,21 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.dto.RemoteModelImportRequest;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.service.ModelProviderService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.discovery.RemoteModelDiscoveryService;
import tech.easyflow.ai.service.discovery.RemoteModelImportResult;
import tech.easyflow.ai.service.discovery.RemoteModelImportService;
import tech.easyflow.ai.service.discovery.RemoteModelListResult;
import tech.easyflow.common.annotation.UsePermission;
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.common.web.jsonbody.JsonBody;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 控制层。
@@ -40,33 +28,12 @@ import java.math.BigInteger;
@UsePermission(moduleName = "/api/v1/model")
public class ModelProviderController extends BaseCurdController<ModelProviderService, ModelProvider> {
private final ModelService modelService;
private final RemoteModelDiscoveryService remoteModelDiscoveryService;
private final RemoteModelImportService remoteModelImportService;
/**
* 创建模型服务商控制器。
*
* @param service 模型服务商服务
* @param modelService 模型服务
* @param remoteModelDiscoveryService 远端模型发现服务
* @param remoteModelImportService 远端模型一键添加服务
*/
public ModelProviderController(ModelProviderService service,
ModelService modelService,
RemoteModelDiscoveryService remoteModelDiscoveryService,
RemoteModelImportService remoteModelImportService) {
public ModelProviderController(ModelProviderService service, ModelService modelService) {
super(service);
this.modelService = modelService;
this.remoteModelDiscoveryService = remoteModelDiscoveryService;
this.remoteModelImportService = remoteModelImportService;
}
/**
* 删除没有子模型的服务商。
*
* @param id 服务商 ID
* @return 删除结果
*/
@Override
@PostMapping("remove")
@Transactional
@@ -78,35 +45,4 @@ public class ModelProviderController extends BaseCurdController<ModelProviderSer
}
return Result.ok(service.removeById(id));
}
/**
* 主动获取当前服务商的远端模型列表。
*
* @param providerId 服务商 ID
* @return 已补全能力和本地添加状态的远端模型列表
*/
@GetMapping("{providerId}/remoteModels")
@SaCheckPermission("/api/v1/model/save")
public Result<RemoteModelListResult> remoteModels(@PathVariable BigInteger providerId) {
return Result.ok(remoteModelDiscoveryService.discover(providerId));
}
/**
* 幂等添加单个远端模型。
*
* @param providerId 服务商 ID
* @param request 一键添加请求
* @return 创建或已存在结果
*/
@PostMapping("{providerId}/remoteModels/import")
@SaCheckPermission("/api/v1/model/save")
public Result<RemoteModelImportResult> importRemoteModel(
@PathVariable BigInteger providerId,
@RequestBody RemoteModelImportRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
Model model = new Model();
commonFiled(model, account.getId(), account.getTenantId(), account.getDeptId());
String modelId = request == null ? null : request.getModelId();
return Result.ok(remoteModelImportService.importModel(providerId, modelId, model));
}
}

View File

@@ -2,7 +2,6 @@ package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
@@ -13,7 +12,6 @@ import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.WorkflowService;
@@ -49,24 +47,8 @@ import static tech.easyflow.ai.entity.table.PluginTableDef.PLUGIN;
@RestController
@RequestMapping("/api/v1/plugin")
public class PluginController extends BaseCurdController<PluginService, Plugin> {
/**
* 创建插件控制器。
*
* @param service 插件服务
*/
public PluginController(PluginService service) {
super(service);
this.pluginService = service;
}
/**
* 获取插件列表关键字搜索字段。
*
* @return 插件名称和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"name", "description"};
}
@Resource
@@ -107,15 +89,10 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
return Result.ok(pluginService.updatePlugin(plugin));
}
/**
* 删除插件。
*
* @param id 插件 ID
* @return 删除结果
*/
@PostMapping("/plugin/remove")
@SaCheckPermission("/api/v1/plugin/remove")
public Result<Boolean> removePlugin(@JsonBody(value = "id", required = true) String id){
return Result.ok(pluginService.removePlugin(id));
}
@@ -128,24 +105,9 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
return Result.ok(pluginService.preparePluginsForCurrentUser(plugins, true, false));
}
/**
* 按分类分页查询插件,并支持按名称、描述模糊查询。
*
* @param request 当前请求
* @param sortKey 排序字段
* @param sortType 排序方向
* @param pageNumber 页码
* @param pageSize 每页数量
* @param category 分类 ID0 表示全部分类
* @param keyword 插件名称或描述关键字
* @param name 兼容旧客户端的插件名称关键字
* @return 插件分页结果
*/
@GetMapping("/pageByCategory")
@SaCheckPermission("/api/v1/plugin/query")
public Result<Page<Plugin>> pageByCategory(HttpServletRequest request, String sortKey, String sortType,
Long pageNumber, Long pageSize, int category,
String keyword, String name) {
public Result<Page<Plugin>> pageByCategory(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize, int category) {
if (pageNumber == null || pageNumber < 1) {
pageNumber = 1L;
}
@@ -158,10 +120,7 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(queryPage(new Page<>(pageNumber, pageSize), queryWrapper));
} else {
String effectiveKeyword = normalizeSearchKeyword(
keyword == null || keyword.isBlank() ? name : keyword);
Result<Page<Plugin>> result = pluginService.pageByCategory(
pageNumber, pageSize, category, effectiveKeyword);
Result<Page<Plugin>> result = pluginService.pageByCategory(pageNumber, pageSize, category);
if (result != null && result.getData() != null) {
aiResourceCreatorNameSupport.fillPluginCreatorNames(result.getData().getRecords());
}
@@ -176,7 +135,7 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
queryWrapper.eq("publish_status", tech.easyflow.ai.enums.PublishStatus.PUBLISHED.getCode());
if (keyword != null && !keyword.isBlank()) {
queryWrapper.and(buildLiteralContainsCondition(keyword, new QueryColumn("title")));
queryWrapper.like("title", keyword.trim());
}
queryWrapper.orderBy("modified desc");
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();

View File

@@ -18,24 +18,20 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.BotPluginService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.annotation.UsePermission;
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;
@@ -46,10 +42,8 @@ import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 控制层。
@@ -61,48 +55,27 @@ import java.util.Set;
@RequestMapping("/api/v1/pluginItem")
@UsePermission(moduleName = "/api/v1/plugin")
public class PluginItemController extends BaseCurdController<PluginItemService, PluginItem> {
/**
* 创建插件工具控制器。
*
* @param service 插件工具服务
*/
public PluginItemController(PluginItemService service) {
super(service);
}
/**
* 获取插件工具列表关键字搜索字段。
*
* @return 工具名称和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"name", "description"};
}
@Resource
private PluginItemService pluginItemService;
@Resource
private AgentResourceReferenceService agentResourceReferenceService;
private BotPluginService botPluginService;
@Resource
private PluginService pluginService;
@Resource
private PluginVisibilityService pluginVisibilityService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource
private WorkflowService workflowService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@Resource
private ChainExecutor chainExecutor;
@Resource
private TinyFlowService tinyFlowService;
@Resource
private WorkflowCheckService workflowCheckService;
@Resource
private WorkflowResumeService workflowResumeService;
@PostMapping("/tool/save")
@SaCheckPermission("/api/v1/plugin/save")
@@ -118,12 +91,25 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return pluginItemService.searchPlugin(aiPluginToolId);
}
@PostMapping("/toolsList")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> searchPluginToolByPluginId(@JsonBody(value = "pluginId", required = true) BigInteger pluginId,
@JsonBody(value = "botId", required = false) BigInteger botId){
return Result.ok(pluginItemService.searchPluginToolByPluginId(pluginId, botId));
}
@PostMapping("/tool/update")
@SaCheckPermission("/api/v1/plugin/save")
public Result<Boolean> updatePlugin(@JsonBody PluginItem pluginItem){
return Result.ok(pluginItemService.updatePlugin(pluginItem));
}
@PostMapping("/tool/list")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<PluginItem>> getPluginToolList(@JsonBody(value = "botId", required = true) BigInteger botId){
return Result.ok(pluginItemService.getPluginToolList(botId));
}
@GetMapping("/getTinyFlowData")
@SaCheckPermission("/api/v1/plugin/query")
public Result<?> getTinyFlowData(BigInteger id) {
@@ -222,7 +208,6 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@SaCheckPermission("/api/v1/plugin/query")
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) {
assertPluginTestExecutionOwnership(executeId);
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
}
@@ -237,33 +222,10 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@SaCheckPermission("/api/v1/plugin/query")
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
assertPluginTestExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
chainExecutor.resumeAsync(executeId, confirmParams);
return Result.ok();
}
/**
* 校验插件试运行实例由当前登录用户发起。
*
* @param executeId 执行实例 ID
*/
private void assertPluginTestExecutionOwnership(String executeId) {
if (StrUtil.isBlank(executeId)) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = workflowExecResultService.getByExecKey(executeId);
if (record == null) {
throw new BusinessException(404, 404, "工作流执行记录不存在或已过期");
}
LoginAccount currentAccount = SaTokenUtil.getLoginAccount();
if (currentAccount == null
|| currentAccount.getId() == null
|| record.getCreatedBy() == null
|| !currentAccount.getId().toString().equals(record.getCreatedBy())) {
throw new BusinessException(403, 403, "无权限访问当前插件试运行实例");
}
}
private void handleArray(JSONArray array) {
for (Object o : array) {
JSONObject obj = (JSONObject) o;
@@ -313,64 +275,26 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return plugin;
}
/**
* 删除插件工具前锁定资源并校验 Agent 绑定。
*
* @param ids 插件工具 ID 集合
* @return 校验失败结果;允许删除时返回 {@code null}
*/
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
Set<BigInteger> uniquePluginItemIds = new LinkedHashSet<>();
try {
for (Serializable id : ids) {
if (id == null) {
throw new NumberFormatException("null");
}
uniquePluginItemIds.add(new BigInteger(String.valueOf(id)));
}
} catch (NumberFormatException exception) {
throw new BusinessException("插件工具 ID 不合法");
}
// BaseCurdController#remove 已开启事务;锁定工具行后校验权限和 Agent 引用。
List<PluginItem> lockedPluginItems = pluginItemService.list(QueryWrapper.create()
.in(PluginItem::getId, uniquePluginItemIds)
.orderBy(PluginItem::getId, true)
.forUpdate());
if (lockedPluginItems == null || lockedPluginItems.size() != uniquePluginItemIds.size()) {
throw new BusinessException("插件工具不存在或已被删除");
}
QueryWrapper queryWrapper = QueryWrapper.create();
queryWrapper.in(BotPlugin::getPluginItemId, ids);
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || loginAccount.getTenantId() == null) {
throw new BusinessException("当前登录信息无效");
boolean exists = botPluginService.exists(queryWrapper);
if (exists){
return Result.fail(1, "此工具还关联着bot请先取消关联");
}
Map<BigInteger, Plugin> plugins = new HashMap<>();
for (PluginItem pluginItem : lockedPluginItems) {
if (pluginItem.getPluginId() == null) {
throw new BusinessException("插件工具关联的插件不存在");
}
Plugin plugin = plugins.get(pluginItem.getPluginId());
if (plugin == null) {
plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin == null || plugin.getTenantId() == null
|| !loginAccount.getTenantId().toString().equals(plugin.getTenantId().toString())) {
throw new BusinessException("无权限删除该插件工具");
}
pluginVisibilityService.assertPluginVisible(
plugin.getCreatedBy(),
plugin.getId(),
"无权限删除该插件工具"
);
plugins.put(pluginItem.getPluginId(), plugin);
}
if (PluginType.isWorkflow(plugin.getType())) {
if (ids.size() == 1) {
PluginItem pluginItem = pluginItemService.getById(ids.iterator().next());
if (pluginItem != null) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
return Result.fail(1, "工作流插件工具由系统自动维护,不支持删除");
}
}
}
agentResourceReferenceService.assertPluginItemsUnused(List.copyOf(uniquePluginItemIds));
return null;
}
}

View File

@@ -45,16 +45,6 @@ public class ResourceController extends BaseCurdController<ResourceService, Reso
super(service);
}
/**
* 获取资源列表关键字搜索字段。
*
* @return 资源名称属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"resourceName"};
}
@Override
protected Result<?> onSaveOrUpdateBefore(Resource entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount();

View File

@@ -1,8 +1,11 @@
package tech.easyflow.admin.controller.ai;
import cn.hutool.core.io.IoUtil;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -18,11 +21,6 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.dto.DocumentChunkDeleteResult;
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
import tech.easyflow.ai.dto.KnowledgeShareLimitedConfigRequest;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.Document;
@@ -44,11 +42,11 @@ import tech.easyflow.ai.service.KnowledgeEmbeddingService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -62,6 +60,7 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
@@ -76,7 +75,7 @@ import java.util.Set;
@RequestMapping("/api/v1/share/knowledge")
public class ShareKnowledgeController {
private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024L * 1024L;
private static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024L * 1024L;
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
"image/jpeg",
"image/png",
@@ -504,27 +503,43 @@ public class ShareKnowledgeController {
@PostMapping("/documentChunk/update")
public Result<?> updateDocumentChunk(
@RequestParam String shareKey,
@JsonBody(required = true, skipConvertError = false)
DocumentChunkContentUpdateRequest request
@JsonBody DocumentChunk documentChunk
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
DocumentChunk current = documentChunkService.getById(request.getId());
DocumentChunk current = documentChunkService.getById(documentChunk.getId());
if (current == null || current.getDocumentCollectionId() == null
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("记录不存在");
}
DocumentChunk updated = documentChunkService.updateContent(
context.getKnowledge().getId(),
current.getId(),
request.getContent()
);
boolean success = documentChunkService.updateById(documentChunk);
if (success) {
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
try {
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(documentChunk.getId());
StoreResult result = documentStore.update(doc, options);
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
return Result.ok(updated);
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
}
return Result.ok(false);
}
/**
@@ -545,50 +560,25 @@ public class ShareKnowledgeController {
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
return Result.fail(1, "记录不存在");
}
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
context.getKnowledge().getId(),
chunkId
);
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
try {
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
return Result.ok(removed);
return Result.ok(true);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
@PostMapping("/documentChunk/syncStatus")
public Result<List<DocumentChunkSyncStatus>> documentChunkSyncStatus(
@RequestParam String shareKey,
@JsonBody DocumentChunkSyncStatusRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey, null, KnowledgeShareActionScope.VIEW.name()
);
Document document = documentService.getById(request.getDocumentId());
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
return Result.ok(documentChunkService.listIndexSyncStatus(
context.getKnowledge().getId(), request.getDocumentId(), request.getIds()
));
}
@PostMapping("/documentChunk/retrySync")
public Result<DocumentChunk> retryDocumentChunkSync(
@RequestParam String shareKey,
@JsonBody DocumentChunkSyncRetryRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey, null, KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
if (request.getIndexSyncVersion() == null) {
throw new BusinessException("同步版本不能为空");
}
DocumentChunk chunk = documentChunkService.retryIndexSync(
context.getKnowledge().getId(), request.getId(), request.getIndexSyncVersion()
);
audit(context, "重试分享文档 Chunk 索引同步", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
return Result.ok(chunk);
}
/**
@@ -678,15 +668,9 @@ public class ShareKnowledgeController {
faqCategoryService.ensureDefaultCategory(context.getKnowledge().getId());
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, context.getKnowledge().getId());
String keyword = request.getParameter("keyword");
String question = request.getParameter("question");
if (StringUtils.hasText(keyword)) {
String pattern = SearchKeywordUtil.literalContainsPattern(keyword);
queryWrapper.and(new QueryColumn("question").likeRaw(pattern)
.or(new QueryColumn("answer_text").likeRaw(pattern)));
} else if (StringUtils.hasText(question)) {
queryWrapper.and(new QueryColumn("question")
.likeRaw(SearchKeywordUtil.literalContainsPattern(question)));
if (StringUtils.hasText(question)) {
queryWrapper.like(FaqItem::getQuestion, question.trim());
}
String categoryId = request.getParameter("categoryId");
if (StringUtils.hasText(categoryId)) {
@@ -801,7 +785,7 @@ public class ShareKnowledgeController {
throw new BusinessException("图片不能为空");
}
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
throw new BusinessException("图片大小不能超过20MB");
throw new BusinessException("图片大小不能超过5MB");
}
if (!isAllowedImageType(file)) {
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");

View File

@@ -1,45 +1,88 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.IdUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.node.EndNode;
import com.easyagents.flow.core.node.StartNode;
import com.easyagents.flow.core.parser.ChainParser;
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.service.ai.WorkflowDesignerOptionService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import javax.annotation.Resource;
import java.util.List;
/**
* 工作流节点兼容接口。
*/
@RequestMapping("/api/v1/workflowNode")
@RestController
public class WorkFlowNodeController {
private final WorkflowDesignerOptionService workflowDesignerOptionService;
@Resource
private WorkflowService workflowService;
@Resource
private ChainParser chainParser;
@Resource
private WorkflowDatacenterContentService workflowDatacenterContentService;
/**
* 创建工作流节点兼容控制器。
*
* @param workflowDesignerOptionService 工作流设计器选项服务
*/
public WorkFlowNodeController(WorkflowDesignerOptionService workflowDesignerOptionService) {
this.workflowDesignerOptionService = workflowDesignerOptionService;
@GetMapping("/getChainParams")
public Result<?> getChainParams(String currentId, String workflowId) {
if (workflowId.equals(currentId)) {
throw new BusinessException("工作流不能作为自身子节点");
}
JSONObject nodeData = new JSONObject();
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
throw new BusinessException("工作流不存在: " + workflowId);
}
nodeData.put("workflowId", workflow.getId());
nodeData.put("workflowName", workflow.getTitle());
ChainDefinition definition = chainParser.parse(workflowDatacenterContentService.prepareContent(workflow.getContent()));
List<Node> nodes = definition.getNodes();
JSONArray inputs = new JSONArray();
JSONArray outputs = new JSONArray();
for (Node node : nodes) {
if (node instanceof StartNode) {
inputs = JSON.parseArray(JSON.toJSONString(node.getParameters()));
handleArray(inputs);
}
if (node instanceof EndNode) {
outputs = JSON.parseArray(JSON.toJSONString(((EndNode) node).getOutputDefs()));
handleArray(outputs);
}
if (node instanceof ConfirmNode) {
throw new BusinessException("工作流存在【确认节点】,暂不支持作为子节点");
}
}
nodeData.put("parameters", inputs);
nodeData.put("outputDefs", outputs);
return Result.ok(nodeData);
}
/**
* 查询子流程输入输出定义。
*
* @param currentId 当前工作流 ID
* @param workflowId 子流程 ID
* @return 子流程节点配置
* @deprecated 请使用 {@code /api/v1/workflow/designer/childWorkflow}
*/
@Deprecated
@GetMapping("/getChainParams")
@SaCheckPermission("/api/v1/workflow/query")
public Result<JSONObject> getChainParams(BigInteger currentId, BigInteger workflowId) {
return Result.ok(workflowDesignerOptionService.getChildWorkflowNodeData(currentId, workflowId));
private void handleArray(JSONArray array) {
if (array != null) {
for (Object o : array) {
JSONObject obj = (JSONObject) o;
obj.put("id", IdUtil.simpleUUID());
obj.put("nameDisabled", true);
obj.put("dataTypeDisabled", true);
obj.put("deleteDisabled", true);
obj.put("addChildDisabled", true);
obj.put("refType", "ref");
JSONArray children = obj.getJSONArray("children");
if (children != null) {
handleArray(children);
}
}
}
}
}

View File

@@ -1,345 +0,0 @@
package tech.easyflow.admin.controller.ai;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 工作流管理端与分享端的对话运行接口。
*/
@RestController
@RequestMapping("/api/v1/workflowChat")
public class WorkflowChatController {
@Resource
private WorkflowService workflowService;
@Resource
private WorkflowShareService workflowShareService;
@Resource
private WorkflowCheckService workflowCheckService;
@Resource
private WorkflowRunningParameterResolver parameterResolver;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private WorkflowChatEventStream eventStream;
@Resource
private ChainExecutor chainExecutor;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowExecResultService execResultService;
@Resource
private WorkflowExecStepService execStepService;
/**
* 获取工作流的对话运行描述和输入表单。
*
* @param workflowId 工作流 ID
* @param request HTTP 请求
* @return 对话运行描述
*/
@GetMapping("/descriptor")
public Result<Map<String, Object>> descriptor(
BigInteger workflowId,
HttpServletRequest request
) {
Workflow workflow = loadRunnableWorkflow(workflowId, request);
workflowCheckService.checkOrThrow(
workflow.getContent(),
WorkflowCheckStage.PRE_EXECUTE,
workflow.getId()
);
Map<String, Object> descriptor =
parameterResolver.buildRunningParametersView(workflow);
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
descriptor.put("workflowId", workflow.getId());
descriptor.put("publishStatus", workflow.getPublishStatus());
descriptor.put("shareable", isStrictlyPublished(workflow));
return Result.ok(descriptor);
}
/**
* 启动工作流,并流式返回全部可见输出。
*
* @param workflowId 工作流 ID
* @param variables 工作流运行变量
* @param request HTTP 请求
* @return 工作流 SSE 事件流
*/
@PostMapping(
value = "/run",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public SseEmitter run(
@JsonBody(value = "workflowId", required = true)
BigInteger workflowId,
@JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request
) {
Workflow workflow = loadRunnableWorkflow(workflowId, request);
workflowCheckService.checkOrThrow(
workflow.getContent(),
WorkflowCheckStage.PRE_EXECUTE,
workflow.getId()
);
Map<String, Object> normalizedVariables =
parameterResolver.normalizeRuntimeVariables(
workflow.getContent(),
variables
);
LoginAccount account = SaTokenUtil.getLoginAccount();
normalizedVariables.put(Constants.LOGIN_USER_KEY, account);
normalizedVariables.put(
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
hasChatShareKey(request)
? WorkFlowUtil.WORKFLOW_CHAT_SHARE
: WorkFlowUtil.WORKFLOW_CHAT
);
return eventStream.start(
isStrictlyPublished(workflow)
? PublishedWorkflowDefinitionIds.published(workflowId.toString())
: workflowId.toString(),
normalizedVariables
);
}
/**
* 取消当前用户发起的工作流执行。
*
* @param executeId 执行实例 ID
* @return 是否完成取消状态转换
*/
@PostMapping("/cancel")
public Result<Boolean> cancel(
@JsonBody(value = "executeId", required = true)
String executeId
) {
assertExecutionOwnership(executeId);
return Result.ok(chainExecutor.cancel(executeId, "用户已中止运行"));
}
/**
* 恢复当前用户发起并等待确认的工作流执行。
*
* @param executeId 执行实例 ID
* @param confirmParams 确认参数
* @return 空结果
*/
@PostMapping("/resume")
public Result<Void> resume(
@JsonBody(value = "executeId", required = true)
String executeId,
@JsonBody("confirmParams")
Map<String, Object> confirmParams
) {
assertExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
/**
* 获取当前用户工作流执行的运行详情。
*
* @param executeId 执行实例 ID
* @return 执行记录和有序节点步骤
*/
@GetMapping("/execution")
public Result<Map<String, Object>> detail(String executeId) {
WorkflowExecResult record = assertExecutionOwnership(executeId);
List<WorkflowExecStep> steps = execStepService.list(
QueryWrapper.create()
.eq(WorkflowExecStep::getRecordId, record.getId())
.orderBy(WorkflowExecStep::getStartTime, true)
);
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
for (WorkflowExecStep step : steps) {
Map<String, Object> view = new LinkedHashMap<>();
view.put("id", step.getId());
view.put("attemptKey", step.getExecKey());
view.put("nodeId", step.getNodeId());
view.put("nodeName", step.getNodeName());
view.put("input", step.getInput());
view.put("output", step.getOutput());
view.put("status", step.getStatus());
view.put("errorInfo", step.getErrorInfo());
view.put("startTime", step.getStartTime());
view.put("endTime", step.getEndTime());
view.put("execTime", step.getExecTime());
stepViews.add(view);
}
Map<String, Object> recordView = new LinkedHashMap<>();
recordView.put("executeId", record.getExecKey());
recordView.put("workflowId", record.getWorkflowId());
recordView.put("title", record.getTitle());
recordView.put("status", record.getStatus());
recordView.put("input", record.getInput());
recordView.put("output", record.getOutput());
recordView.put("errorInfo", record.getErrorInfo());
recordView.put("startTime", record.getStartTime());
recordView.put("endTime", record.getEndTime());
recordView.put("execTime", record.getExecTime());
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("record", recordView);
detail.put("steps", stepViews);
detail.put("runtime", eventStream.runtimeView(executeId));
return Result.ok(detail);
}
/**
* 加载可运行工作流,并校验直接访问或对话分享权限。
* 管理端直接运行时,已发布工作流使用发布快照,未发布工作流使用当前内容;
* 分享运行始终要求严格发布快照。
*
* @param workflowId 工作流 ID
* @param request HTTP 请求
* @return 可运行工作流视图
*/
private Workflow loadRunnableWorkflow(
BigInteger workflowId,
HttpServletRequest request
) {
if (workflowId == null) {
throw new BusinessException("工作流ID不能为空");
}
LoginAccount account = SaTokenUtil.getLoginAccount();
Workflow current = workflowService.getById(workflowId);
if (current == null) {
throw new BusinessException("工作流不存在");
}
boolean sharedRequest = hasChatShareKey(request);
if (sharedRequest) {
workflowShareService.assertChatShareAccess(
request.getHeader(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
),
workflowId,
account.getTenantId()
);
} else {
resourceAccessService.assertAccess(
CategoryResourceType.WORKFLOW,
current,
ResourceAction.USE,
"无权限运行工作流"
);
}
Workflow published = workflowService.getPublishedById(workflowId);
if (isStrictlyPublished(published)) {
return published;
}
if (sharedRequest) {
throw new BusinessException(
409,
409,
"工作流尚未发布或已下线"
);
}
return current;
}
/**
* 校验执行记录属于当前用户发起的工作流对话。
*
* @param executeId 执行实例 ID
* @return 执行记录
*/
private WorkflowExecResult assertExecutionOwnership(String executeId) {
if (executeId == null || executeId.isBlank()) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = execResultService.getByExecKey(executeId);
if (record == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
}
LoginAccount account = SaTokenUtil.getLoginAccount();
boolean chatSource = WorkFlowUtil.WORKFLOW_CHAT.equals(
record.getCreatedKey()
) || WorkFlowUtil.WORKFLOW_CHAT_SHARE.equals(record.getCreatedKey());
if (!chatSource
|| account.getId() == null
|| !account.getId().toString().equals(
record.getCreatedBy()
)) {
throw new BusinessException(
403,
403,
"无权限访问当前工作流执行记录"
);
}
return record;
}
/**
* 判断请求是否携带对话分享密钥。
*
* @param request HTTP 请求
* @return 携带非空对话分享密钥时返回 {@code true}
*/
private boolean hasChatShareKey(HttpServletRequest request) {
String shareKey = request == null
? null
: request.getHeader(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
);
return shareKey != null && !shareKey.isBlank();
}
/**
* 判断工作流是否可按严格发布快照运行。
*
* @param workflow 工作流
* @return 已发布且存在快照时返回 {@code true}
*/
private boolean isStrictlyPublished(Workflow workflow) {
return workflow != null
&& PublishStatus.PUBLISHED.getCode().equals(
workflow.getPublishStatus()
)
&& workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty();
}
}

View File

@@ -1,27 +1,18 @@
package tech.easyflow.admin.controller.ai;
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.IdUtil;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
import tech.easyflow.admin.service.ai.WorkflowDesignerOptionService;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.service.AgentToolBindingService;
import tech.easyflow.ai.permission.WorkflowShareResourceAccessGrantProvider;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
@@ -32,13 +23,13 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
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;
import tech.easyflow.common.constant.Constants;
@@ -48,7 +39,6 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.datacenter.execution.model.DatasetRef;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.ResourceLookup;
@@ -63,7 +53,6 @@ import java.io.Serializable;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -82,7 +71,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@Resource
private SysApiKeyService apiKeyService;
@Resource
private AgentToolBindingService agentToolBindingService;
private BotWorkflowService botWorkflowService;
@Resource
private ChainExecutor chainExecutor;
@Resource
@@ -96,8 +85,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@@ -107,130 +94,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Resource
private WorkflowShareResourceAccessGrantProvider workflowShareGrantProvider;
@Resource
private WorkflowDesignerOptionService workflowDesignerOptionService;
public WorkflowController(WorkflowService service, ModelService modelService) {
super(service);
this.modelService = modelService;
}
/**
* 获取工作流列表关键字搜索字段。
*
* @return 标题和描述属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"title", "description"};
}
/**
* 查询工作流设计器初始化所需的安全选项。
*
* @return 模型、知识库和代码引擎选项
*/
@GetMapping("/designer/options")
@SaCheckPermission("/api/v1/workflow/query")
public Result<WorkflowDesignerOptionsView> designerOptions() {
return Result.ok(workflowDesignerOptionService.listOptions(
codeEngineCapabilityService.listSupportedCodeEngines()));
}
/**
* 分页查询工作流设计器可用插件。
*
* @param pageNumber 页码
* @param pageSize 每页数量
* @param keyword 插件名称或描述关键字
* @return 插件安全选项分页
*/
@GetMapping("/designer/plugins")
@SaCheckPermission("/api/v1/workflow/query")
public Result<Page<WorkflowDesignerOptionsView.PluginOption>> designerPlugins(
Long pageNumber,
Long pageSize,
String keyword) {
return Result.ok(workflowDesignerOptionService.pagePlugins(pageNumber, pageSize, keyword));
}
/**
* 查询工作流插件节点配置。
*
* @param id 插件工具 ID
* @return 插件节点配置
*/
@GetMapping("/designer/pluginTinyFlow")
@SaCheckPermission("/api/v1/workflow/query")
public Result<?> designerPluginTinyFlow(BigInteger id) {
return Result.ok(workflowDesignerOptionService.getPluginTinyFlowData(id));
}
/**
* 查询工作流设计器的子流程节点配置。
*
* @param currentId 当前工作流 ID
* @param workflowId 子流程 ID
* @return 子流程输入输出定义
*/
@GetMapping("/designer/childWorkflow")
@SaCheckPermission("/api/v1/workflow/query")
public Result<JSONObject> designerChildWorkflow(BigInteger currentId, BigInteger workflowId) {
return Result.ok(workflowDesignerOptionService.getChildWorkflowNodeData(currentId, workflowId));
}
/**
* 查询工作流数据节点可见的数据源。
*
* @return 数据源安全选项
*/
@GetMapping("/designer/dataSources")
@SaCheckPermission("/api/v1/workflow/query")
public Result<List<WorkflowDesignerOptionsView.DataSourceOption>> designerDataSources() {
return Result.ok(workflowDesignerOptionService.listDataSources());
}
/**
* 查询工作流数据节点的数据目录。
*
* @param sourceId 数据源 ID
* @return 目录安全选项
*/
@GetMapping("/designer/catalogs")
@SaCheckPermission("/api/v1/workflow/query")
public Result<List<WorkflowDesignerOptionsView.CatalogOption>> designerCatalogs(BigInteger sourceId) {
return Result.ok(workflowDesignerOptionService.listCatalogs(sourceId));
}
/**
* 查询工作流数据节点的已接入数据集。
*
* @param sourceId 数据源 ID
* @param catalogId 目录 ID
* @return 数据集安全选项
*/
@GetMapping("/designer/managedTables")
@SaCheckPermission("/api/v1/workflow/query")
public Result<List<WorkflowDesignerOptionsView.DatasetOption>> designerManagedTables(
BigInteger sourceId,
BigInteger catalogId) {
return Result.ok(workflowDesignerOptionService.listManagedDatasets(sourceId, catalogId));
}
/**
* 查询工作流数据节点的数据集结构。
*
* @param datasetRef 数据集引用
* @return 数据集结构安全视图
*/
@GetMapping("/designer/schema")
@SaCheckPermission("/api/v1/workflow/query")
public Result<WorkflowDesignerOptionsView.DatasetSchemaOption> designerSchema(DatasetRef datasetRef) {
return Result.ok(workflowDesignerOptionService.getDatasetSchema(datasetRef));
}
/**
* 节点单独运行
*/
@@ -252,9 +121,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
if (workflow == null) {
return Result.fail(1, "工作流不存在");
}
workflowCheckService.checkOrThrow(
workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
if (variables == null) {
variables = new HashMap<>();
}
@@ -262,7 +128,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
WorkflowExecutionErrorMapper.installRequestProfile();
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res);
}
@@ -289,7 +154,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
throw new RuntimeException("工作流不存在");
}
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
@@ -329,7 +193,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
)
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
workflowResumeService.resume(executeId, confirmParams);
chainExecutor.resumeAsync(executeId, confirmParams);
return Result.ok();
}
@@ -381,7 +245,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
return Result.fail(1, "can not find the workflow by id: " + id);
}
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (res == null) {
return Result.fail(2, "节点配置错误,请检查! ");
@@ -393,48 +256,18 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
* 提交发布审批。
*
* @param id 工作流 ID
* @param applicationReason 审批说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限发布工作流"
)
public Result<BigInteger> submitPublishApproval(
@JsonBody("id") BigInteger id,
@JsonBody("applicationReason") String applicationReason
) {
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
workflowPublishAppService.submitPublishApproval(id, applicationReason),
workflowPublishAppService.submitPublishApproval(id),
"已提交发布审批",
"已直接发布"
);
}
/**
* 预检工作流发布是否命中审批流。
*
* @param id 工作流 ID
* @return 是否需要审批
*/
@GetMapping("/publishApprovalRequirement")
@SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限发布工作流"
)
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
return Result.ok(workflowPublishAppService.isPublishApprovalRequired(id));
}
/**
* 提交下线审批。
*
@@ -443,13 +276,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/workflow/save")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限下线工作流"
)
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
workflowPublishAppService.submitOfflineApproval(id),
@@ -485,13 +311,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
*/
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/workflow/remove")
@RequireResourceAccess(
resource = CategoryResourceType.WORKFLOW,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.WORKFLOW_ID,
idExpr = "#id",
denyMessage = "无权限删除工作流"
)
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(
workflowPublishAppService.submitDeleteApproval(id),
@@ -559,76 +378,20 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
}
workflow.setId(null);
workflow.setAlias(IdUtil.fastSimpleUUID());
workflow.setRevision(0);
commonFiled(workflow, account.getId(), account.getTenantId(), account.getDeptId());
workflowDesignerOptionService.assertContentReferences(workflow.getContent());
service.save(workflow);
return Result.ok();
}
/**
* 更新工作流;分享访问只允许按修订号更新设计内容。
*
* @param entity 工作流更新内容
* @return 更新结果
*/
@Override
@PostMapping("update")
@Transactional(rollbackFor = Exception.class)
public Result<?> update(@JsonBody Workflow entity) {
if (entity == null || entity.getId() == null) {
throw new BusinessException("工作流 ID 不能为空");
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
boolean sharedRequest = workflowShareGrantProvider.isSharedRequestFor(
entity.getId(),
loginAccount
);
if (sharedRequest && entity.getContent() == null) {
throw new BusinessException(403, 403, "分享链接仅允许编辑工作流内容");
}
if (entity.getContent() == null) {
return super.update(entity);
}
if (entity.getRevision() == null) {
throw workflowRevisionConflict();
}
Result<?> beforeResult = onSaveOrUpdateBefore(entity, false);
if (beforeResult != null) {
return beforeResult;
}
boolean updated = service.updateContentByRevision(
entity.getId(),
entity.getContent(),
entity.getRevision(),
new Date(),
loginAccount.getId()
);
if (!updated) {
throw workflowRevisionConflict();
}
entity.setRevision(entity.getRevision() + 1);
if (!sharedRequest) {
entity.setContent(null);
service.updateById(entity);
}
onSaveOrUpdateAfter(entity, false);
return Result.ok(Map.of("revision", entity.getRevision()));
}
@Override
protected Result onSaveOrUpdateBefore(Workflow entity, boolean isSave) {
normalizeVisibilityScope(entity, isSave);
if (isSave && entity.getRevision() == null) {
entity.setRevision(0);
}
if (!isSave && entity.getId() != null) {
Workflow existed = requireWorkflow(String.valueOf(entity.getId()));
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, existed, ResourceAction.MANAGE, "无权限管理工作流");
}
if (StringUtils.hasLength(entity.getContent())) {
workflowCheckService.checkOrThrow(entity.getContent(), WorkflowCheckStage.SAVE, entity.getId());
workflowDesignerOptionService.assertContentReferences(entity.getContent());
}
String alias = entity.getAlias();
@@ -692,12 +455,11 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
Workflow workflow = requireWorkflow(String.valueOf(id));
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.MANAGE, "无权限管理工作流");
}
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(AgentToolBinding::getToolType, AgentToolType.WORKFLOW.name())
.in(AgentToolBinding::getTargetId, ids);
boolean exists = agentToolBindingService.exists(queryWrapper);
QueryWrapper queryWrapper = QueryWrapper.create();
queryWrapper.in("workflow_id", ids);
boolean exists = botWorkflowService.exists(queryWrapper);
if (exists) {
return Result.fail(1, "此工作流仍被智能体使用,请先取消绑定后再删除");
return Result.fail(1, "此工作流还关联有bot,请先取消关联后再删除");
}
return null;
}
@@ -723,15 +485,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
return workflow;
}
/**
* 构建工作流内容修订冲突异常。
*
* @return HTTP 409 业务异常
*/
private BusinessException workflowRevisionConflict() {
return new BusinessException(409, 409, "工作流已被其他人更新,请刷新后重新编辑");
}
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {

View File

@@ -17,7 +17,6 @@ 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.log.annotation.LogRecord;
import javax.annotation.Resource;
import java.math.BigInteger;
@@ -37,20 +36,9 @@ public class WorkflowExecResultController extends BaseCurdController<WorkflowExe
super(service);
}
/**
* 获取工作流执行记录关键字搜索字段。
*
* @return 执行 Key 属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"execKey"};
}
@GetMapping("/del")
@Transactional(rollbackFor = Exception.class)
@SaCheckPermission("/api/v1/workflow/remove")
@LogRecord("删除工作流执行记录")
public Result<Void> del(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount();
WorkflowExecResult record = service.getById(id);

View File

@@ -37,16 +37,6 @@ public class WorkflowExecStepController extends BaseCurdController<WorkflowExecS
super(service);
}
/**
* 获取执行步骤关键字搜索字段。
*
* @return 节点名称和节点 ID 属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"nodeName", "nodeId"};
}
@GetMapping("/getListByRecordId")
public Result<List<WorkflowExecStep>> getListByRecordId(BigInteger recordId) {
if (recordId == null) {

View File

@@ -1,117 +0,0 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.service.ai.WorkflowPublicChatService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.util.Map;
/**
* 工作流对话匿名分享接口。
*/
@SaIgnore
@RestController
@RequestMapping("/api/v1/workflowChat/public")
public class WorkflowPublicChatController {
private final WorkflowPublicChatService publicChatService;
public WorkflowPublicChatController(
WorkflowPublicChatService publicChatService
) {
this.publicChatService = publicChatService;
}
@GetMapping("/descriptor")
public Result<Map<String, Object>> descriptor(HttpServletRequest request) {
return Result.ok(publicChatService.descriptor(
shareKey(request),
visitorId(request)
));
}
@PostMapping(value = "/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter run(
@JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request
) {
return publicChatService.run(
shareKey(request),
visitorId(request),
variables
);
}
@GetMapping("/execution")
public Result<Map<String, Object>> execution(
@RequestParam String executeId,
HttpServletRequest request
) {
return Result.ok(publicChatService.detail(
shareKey(request),
visitorId(request),
executeId
));
}
@PostMapping("/cancel")
public Result<Boolean> cancel(
@JsonBody(value = "executeId", required = true) String executeId,
HttpServletRequest request
) {
return Result.ok(publicChatService.cancel(
shareKey(request),
visitorId(request),
executeId
));
}
@PostMapping("/resume")
public Result<Void> resume(
@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams,
HttpServletRequest request
) {
publicChatService.resume(
shareKey(request),
visitorId(request),
executeId,
confirmParams
);
return Result.ok();
}
@PostMapping(value = "/upload", produces = MediaType.APPLICATION_JSON_VALUE)
public Result<UploadResVo> upload(
@RequestParam("file") MultipartFile file,
@RequestParam("parameterName") String parameterName,
HttpServletRequest request
) {
return Result.ok(publicChatService.upload(
shareKey(request),
visitorId(request),
parameterName,
file
));
}
private String shareKey(HttpServletRequest request) {
return request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER);
}
private String visitorId(HttpServletRequest request) {
return request.getHeader(WorkflowSharePolicy.CHAT_VISITOR_HEADER);
}
}

View File

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

View File

@@ -1,14 +1,12 @@
package tech.easyflow.admin.controller.ai.support;
import org.springframework.stereotype.Component;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.entity.SysAccount;
import javax.annotation.Resource;
import java.math.BigInteger;
@@ -42,6 +40,15 @@ public class AiResourceCreatorNameSupport {
fillCreatorNames(workflows, Workflow::getCreatedBy, Workflow::setCreatedByName);
}
/**
* 批量填充聊天助手创建人名称。
*
* @param bots 聊天助手集合
*/
public void fillBotCreatorNames(Collection<Bot> bots) {
fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName);
}
/**
* 批量填充知识库创建人名称。
*
@@ -69,46 +76,6 @@ public class AiResourceCreatorNameSupport {
fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName);
}
/**
* 批量填充 Skill 创建人名称。
*
* @param skills Skill 集合
*/
public void fillSkillCreatorNames(Collection<Skill> skills) {
if (skills == null || skills.isEmpty()) {
return;
}
LinkedHashSet<BigInteger> creatorIds = skills.stream().map(Skill::getCreatedBy)
.filter(Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
if (creatorIds.isEmpty()) {
return;
}
Map<BigInteger, String> labels = sysAccountService.list(QueryWrapper.create()
.select(SysAccount::getId, SysAccount::getNickname, SysAccount::getLoginName)
.in(SysAccount::getId, creatorIds)).stream()
.collect(java.util.stream.Collectors.toMap(SysAccount::getId, this::skillCreatorLabel,
(left, right) -> left, java.util.LinkedHashMap::new));
skills.forEach(skill -> skill.setCreatedByName(
labels.getOrDefault(skill.getCreatedBy(), String.valueOf(skill.getCreatedBy()))));
}
/**
* 将 Skill 创建人格式化为“昵称(账号)”。
*
* @param account 创建人账号
* @return 创建人展示标签
*/
private String skillCreatorLabel(SysAccount account) {
String nickname = account.getNickname() == null ? "" : account.getNickname().trim();
String loginName = account.getLoginName() == null ? "" : account.getLoginName().trim();
if (nickname.isBlank()) {
return loginName.isBlank() ? String.valueOf(account.getId()) : loginName;
}
return loginName.isBlank() || nickname.equals(loginName)
? nickname : nickname + "" + loginName + "";
}
/**
* 通用的创建人名称填充逻辑。
*
@@ -117,7 +84,7 @@ public class AiResourceCreatorNameSupport {
* @param createdByNameSetter 创建人名称回填函数
* @param <T> 资源类型
*/
<T> void fillCreatorNames(
private <T> void fillCreatorNames(
Collection<T> resources,
Function<T, Number> createdByGetter,
BiConsumer<T, String> createdByNameSetter

View File

@@ -1,33 +0,0 @@
package tech.easyflow.admin.controller.ai.support;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Bot;
import java.util.Collection;
/**
* 为旧 Bot 资源批量补充创建人展示名称。
*/
@Component
public class BotResourceCreatorNameSupport {
private final AiResourceCreatorNameSupport creatorNameSupport;
/**
* 创建 Bot 创建人名称填充组件。
*
* @param creatorNameSupport 通用 AI 资源创建人名称组件
*/
public BotResourceCreatorNameSupport(AiResourceCreatorNameSupport creatorNameSupport) {
this.creatorNameSupport = creatorNameSupport;
}
/**
* 批量填充 Bot 创建人名称。
*
* @param bots Bot 集合
*/
public void fillCreatorNames(Collection<Bot> bots) {
creatorNameSupport.fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName);
}
}

View File

@@ -6,7 +6,6 @@ import tech.easyflow.common.dict.Dict;
import tech.easyflow.common.dict.DictItem;
import tech.easyflow.common.dict.DictLoader;
import tech.easyflow.common.dict.DictManager;
import tech.easyflow.common.web.exceptions.BusinessException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -17,30 +16,18 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 提供已注册静态字典的查询接口。
*/
@RestController
@RequestMapping("/api/v1/dict/")
public class DictController {
@Resource
private DictManager dictManager;
DictManager dictManager;
/**
* 查询指定静态字典的选项。
*
* @param code 字典编码
* @param keyword 搜索关键字
* @param request HTTP 请求
* @return 字典选项
* @throws BusinessException 字典未注册时抛出
*/
@GetMapping("/items/{code}")
public Result<List<DictItem>> items(@PathVariable("code") String code, String keyword, HttpServletRequest request) {
DictLoader loader = dictManager.getLoader(code);
if (loader == null) {
throw new BusinessException("字典不存在或不支持公共查询");
return Result.ok(Collections.emptyList());
}
Map<String, String[]> parameterMap = request.getParameterMap();
Dict dict = loader.load(keyword, parameterMap);

View File

@@ -10,8 +10,6 @@ import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.admin.service.dashboard.DashboardService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.agent.vo.AgentOptionView;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
@@ -30,29 +28,9 @@ import java.util.List;
public class DashboardController {
private final DashboardService dashboardService;
private final AgentOptionQueryService agentOptionQueryService;
/**
* 创建管理端工作台控制器。
*
* @param dashboardService 工作台统计服务
* @param agentOptionQueryService Agent 安全选项服务
*/
public DashboardController(DashboardService dashboardService,
AgentOptionQueryService agentOptionQueryService) {
public DashboardController(DashboardService dashboardService) {
this.dashboardService = dashboardService;
this.agentOptionQueryService = agentOptionQueryService;
}
/**
* 查询工作台筛选可使用的 Agent。
*
* @return Agent 安全选项
*/
@GetMapping("/agentOptions")
@SaCheckPermission("/api/v1/dashboard/query")
public Result<List<AgentOptionView>> agentOptions() {
return Result.ok(agentOptionQueryService.listAgentOptions(false));
}
@GetMapping("/overview")

View File

@@ -24,11 +24,6 @@ import java.util.List;
@RequestMapping("/api/v1/datacenterDataset")
public class DatacenterDatasetController {
/** 对外 Schema 接口的默认字段页码。 */
private static final long DEFAULT_FIELD_PAGE_NUMBER = 1L;
/** 对外 Schema 接口的默认字段页大小。 */
private static final long DEFAULT_FIELD_PAGE_SIZE = 200L;
@Resource
private DatacenterDatasetQueryService queryService;
@Resource
@@ -37,18 +32,13 @@ public class DatacenterDatasetController {
@PostMapping("/queryPage")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Page<Row>> queryPage(@RequestBody DatacenterQueryRequest request) {
return Result.ok(queryService.queryPage(
request, SaTokenUtil.getLoginAccount()));
return Result.ok(queryService.queryPage(request));
}
@GetMapping("/schema")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterSchemaResponse> schema(
DatasetRef datasetRef,
@RequestParam(defaultValue = "1") Long fieldPageNumber,
@RequestParam(defaultValue = "200") Long fieldPageSize) {
return Result.ok(queryService.getSchema(
datasetRef, fieldPageNumber, fieldPageSize));
public Result<DatacenterSchemaResponse> schema(DatasetRef datasetRef) {
return Result.ok(queryService.getSchema(datasetRef));
}
@GetMapping("/managedTables")
@@ -73,13 +63,6 @@ public class DatacenterDatasetController {
request == null ? List.of() : request.getFields(),
account
);
return Result.ok(queryService.getSchema(
registryService.resolveDatasetRef(table.getId()),
request == null || request.getFieldPageNumber() == null
? DEFAULT_FIELD_PAGE_NUMBER
: request.getFieldPageNumber(),
request == null || request.getFieldPageSize() == null
? DEFAULT_FIELD_PAGE_SIZE
: request.getFieldPageSize()));
return Result.ok(queryService.getSchema(registryService.resolveDatasetRef(table.getId())));
}
}

View File

@@ -6,7 +6,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.domain.Result;
@@ -33,21 +32,11 @@ public class DatacenterExcelController {
@Resource
private DatacenterExcelImportService excelImportService;
/**
* 上传并导入 Excel 工作簿。
*
* @param file Excel 工作簿
* @param sourceName 数据源名称,留空时使用文件名
* @return 导入任务及新建数据源标识
* @throws Exception 文件解析或数据写入失败时抛出
*/
@PostMapping("/import")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterImportJob> importWorkbook(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "sourceName", required = false) String sourceName) throws Exception {
public Result<DatacenterImportJob> importWorkbook(MultipartFile file) throws Exception {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(excelImportService.importWorkbook(file, sourceName, account));
return Result.ok(excelImportService.importWorkbook(file, account));
}
@PostMapping("/split")

View File

@@ -1,84 +0,0 @@
package tech.easyflow.admin.controller.datacenter;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleRequest;
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleResult;
import tech.easyflow.datacenter.execution.model.DatacenterSqlCancelRequest;
import tech.easyflow.datacenter.federation.DatacenterFederationQueryService;
import tech.easyflow.datacenter.federation.DatacenterFederationQueryCancellationService;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
/**
* 数据中枢管理端只读 SQL 控制台。
*/
@RestController
@RequestMapping("/api/v1/datacenterQuery")
public class DatacenterQueryController {
private final DatacenterDatasetRegistryService registryService;
private final DatacenterFederationQueryService queryService;
private final DatacenterFederationQueryCancellationService cancellationService;
/**
* 创建查询 Controller。
*
* @param registryService 数据集注册服务
* @param queryService Federation 查询服务
* @param cancellationService 跨节点查询取消服务
*/
public DatacenterQueryController(
DatacenterDatasetRegistryService registryService,
DatacenterFederationQueryService queryService,
DatacenterFederationQueryCancellationService cancellationService) {
this.registryService = registryService;
this.queryService = queryService;
this.cancellationService = cancellationService;
}
/**
* 执行一条受 Calcite 与业务 Policy 校验的只读 SQL。
*
* @param request 查询请求
* @return 有界查询结果
*/
@PostMapping("/execute")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterSqlConsoleResult> execute(
@RequestBody DatacenterSqlConsoleRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
DatacenterSource source = registryService.getSourceRequired(
request == null ? null : request.sourceId());
return Result.ok(queryService.execute(
source,
request == null ? null : request.sql(),
java.util.List.of(),
request == null ? null : request.maxRows(),
account,
"MANUAL",
account == null || account.getId() == null
? null : account.getId().toString(),
request == null ? null : request.queryId()));
}
/**
* 取消当前租户在任一节点执行的 SQL 查询。
*
* @param request 取消请求
* @return 是否在本地或集群中接受取消提示
*/
@PostMapping("/cancel")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Boolean> cancel(@RequestBody DatacenterSqlCancelRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(cancellationService.cancel(
request == null ? null : request.queryId(), account));
}
}

View File

@@ -8,16 +8,10 @@ import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.execution.model.DatacenterConnectionTestResult;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.model.DatacenterBatchRegisterRequest;
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
import tech.easyflow.datacenter.meta.model.DatacenterMetadataPage;
import tech.easyflow.datacenter.meta.model.DatacenterRemoveSourceRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceActivateRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateMetadataRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateCatalogRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceDraftRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceReconfigureRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceView;
import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta;
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
@@ -25,9 +19,6 @@ import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.List;
/**
* 数据源绑定、生命周期与元数据浏览接口。
*/
@RestController
@RequestMapping("/api/v1/datacenterSource")
public class DatacenterSourceController {
@@ -35,100 +26,23 @@ public class DatacenterSourceController {
@Resource
private DatacenterSourceService sourceService;
@PostMapping("/draft")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> saveDraft(@RequestBody DatacenterSourceDraftRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.saveDraft(request, account));
}
@PostMapping("/{sourceId}/probe")
@PostMapping("/testConnection")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterConnectionTestResult> probe(@PathVariable BigInteger sourceId) {
public Result<DatacenterConnectionTestResult> testConnection(@RequestBody DatacenterSource source) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.probe(sourceId, account));
return Result.ok(sourceService.testConnection(source, account));
}
@PostMapping("/activate")
@PostMapping("/save")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> activate(@RequestBody DatacenterSourceActivateRequest request) {
public Result<DatacenterSource> save(@RequestBody DatacenterSource source) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.activate(request, account));
}
/**
* 探测活动数据源的未发布候选配置。
*
* @param request 候选连接配置
* @return 连接探测结果
*/
@PostMapping("/candidate/probe")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterConnectionTestResult> probeCandidate(
@RequestBody DatacenterSourceDraftRequest request) {
return Result.ok(sourceService.probeCandidate(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 浏览活动数据源候选配置可访问的命名空间。
*
* @param request 候选连接配置
* @return 命名空间列表
*/
@PostMapping("/candidate/catalogs")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<List<DatacenterCatalogMeta>> candidateCatalogs(
@RequestBody DatacenterSourceDraftRequest request) {
return Result.ok(sourceService.listCandidateCatalogs(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 分页浏览候选配置可访问的命名空间。
*
* @param request 候选配置和分页条件
* @return 有界命名空间列表
*/
@PostMapping("/candidate/catalogs/page")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> candidateCatalogsPage(
@RequestBody DatacenterSourceCandidateCatalogRequest request) {
return Result.ok(sourceService.listCandidateCatalogsPage(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 分页浏览活动数据源候选配置可访问的表。
*
* @param request 候选配置与分页条件
* @return 有界表列表
*/
@PostMapping("/candidate/tables")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterMetadataPage<DatacenterTable>> candidateTables(
@RequestBody DatacenterSourceCandidateMetadataRequest request) {
return Result.ok(sourceService.listCandidateTables(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 原地发布活动数据源的新连接配置和纳管范围。
*
* @param request 重配置请求
* @return 发布后的数据源视图
*/
@PostMapping("/reconfigure")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> reconfigure(
@RequestBody DatacenterSourceReconfigureRequest request) {
return Result.ok(sourceService.reconfigure(
request, SaTokenUtil.getLoginAccount()));
return Result.ok(sourceService.saveSource(source, account));
}
@GetMapping("/page")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Page<DatacenterSourceView>> page(Long pageNumber, Long pageSize) {
public Result<Page<DatacenterSource>> page(Long pageNumber, Long pageSize) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.pageSources(pageNumber, pageSize, account));
}
@@ -140,53 +54,19 @@ public class DatacenterSourceController {
return Result.ok(sourceService.listCatalogs(sourceId, account));
}
/**
* 分页浏览当前数据源的命名空间。
*
* @param sourceId 数据源 ID
* @param keyword 名称搜索词
* @param pageNumber 页码
* @param pageSize 每页大小
* @return 有界命名空间列表
*/
@GetMapping("/catalogs/page")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> catalogsPage(
BigInteger sourceId,
String keyword,
Long pageNumber,
Long pageSize) {
return Result.ok(sourceService.listCatalogsPage(
sourceId, keyword, pageNumber, pageSize,
SaTokenUtil.getLoginAccount()));
}
@GetMapping("/tables")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterMetadataPage<DatacenterTable>> tables(
BigInteger sourceId,
String catalogName,
String keyword,
Long pageNumber,
Long pageSize) {
public Result<List<DatacenterTable>> tables(BigInteger sourceId, String catalogName) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.listTables(
sourceId, catalogName, keyword, pageNumber, pageSize, account));
return Result.ok(sourceService.listTables(sourceId, catalogName, account));
}
@GetMapping("/tableDetail")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterTableDetailMeta> tableDetail(
BigInteger sourceId,
String catalogName,
String tableName,
@RequestParam(defaultValue = "false") boolean register,
Long fieldPageNumber,
Long fieldPageSize) {
public Result<DatacenterTableDetailMeta> tableDetail(BigInteger sourceId, String catalogName, String tableName,
@RequestParam(defaultValue = "false") boolean register) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.getTableDetail(
sourceId, catalogName, tableName, register,
fieldPageNumber, fieldPageSize, account));
return Result.ok(sourceService.getTableDetail(sourceId, catalogName, tableName, register, account));
}
@PostMapping("/registerBatch")
@@ -203,44 +83,4 @@ public class DatacenterSourceController {
sourceService.removeSource(request == null ? null : request.getSourceId(), account);
return Result.ok();
}
/**
* 停用活动数据源。
*
* @param sourceId 数据源 ID
* @return 停用后的数据源视图
*/
@PostMapping("/{sourceId}/disable")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> disable(@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.disable(
sourceId, SaTokenUtil.getLoginAccount()));
}
/**
* 重新启用已停用数据源。
*
* @param sourceId 数据源 ID
* @return 启用后的数据源视图
*/
@PostMapping("/{sourceId}/enable")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> enable(@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.enable(
sourceId, SaTokenUtil.getLoginAccount()));
}
/**
* 刷新已纳管对象的 JDBC 元数据观测状态。
*
* @param sourceId 数据源 ID
* @return 刷新后的数据源视图
*/
@PostMapping("/{sourceId}/metadata/refresh")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> refreshMetadata(
@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.refreshMetadata(
sourceId, SaTokenUtil.getLoginAccount()));
}
}

View File

@@ -1,161 +0,0 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import java.math.BigInteger;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.dataspace.model.ConnectionDefinition;
import tech.easyflow.dataspace.model.ConnectionView;
import tech.easyflow.dataspace.model.ObjectView;
import tech.easyflow.dataspace.provider.DataspaceProbe;
import tech.easyflow.dataspace.service.DataspaceConnectionService;
/**
* 数据空间物理连接管理端 API。
*/
@RestController
@RequestMapping("/api/v1/dataspaceConnection")
public class DataspaceConnectionController {
private final DataspaceConnectionService connectionService;
/**
* 创建连接控制器。
*
* @param connectionService 连接服务
*/
public DataspaceConnectionController(DataspaceConnectionService connectionService) {
this.connectionService = connectionService;
}
/**
* 查询当前租户连接列表。
*
* @param keyword 搜索关键词
* @return 连接列表
*/
@GetMapping("/list")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<List<ConnectionView>> list(String keyword) {
return Result.ok(connectionService.list(keyword));
}
/**
* 获取连接详情。
*
* @param id 连接 ID
* @return 连接详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<ConnectionView> detail(BigInteger id) {
return Result.ok(connectionService.detail(id));
}
/**
* 测试候选或已保存连接。
*
* @param definition 连接定义
* @return 测试结果
*/
@PostMapping("/test")
@SaCheckPermission("/api/v1/dataspaceConnection/test")
public Result<DataspaceProbe> test(
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
return Result.ok(connectionService.test(definition));
}
/**
* 创建或更新连接。
*
* @param definition 连接定义
* @return 保存后的连接
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/dataspaceConnection/save")
public Result<ConnectionView> save(
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
return Result.ok(connectionService.save(definition));
}
/**
* 启用或禁用连接。
*
* @param request 状态变更请求
* @return 变更后的连接
*/
@PostMapping("/status")
@SaCheckPermission("/api/v1/dataspaceConnection/save")
public Result<ConnectionView> status(
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
if (request == null || request.enabled() == null) {
throw new BusinessException("连接状态不能为空");
}
return Result.ok(connectionService.setEnabled(request.id(), request.enabled()));
}
/**
* 查询当前连接的对象树数据。
*
* @param connectionId 连接 ID
* @param keyword Schema 或表名关键词
* @return 对象列表
*/
@GetMapping("/objects")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<List<ObjectView>> objects(BigInteger connectionId, String keyword) {
return Result.ok(connectionService.objects(connectionId, keyword));
}
/**
* 刷新连接元数据。
*
* @param request 刷新请求
* @return 新 revision 对象列表
*/
@PostMapping("/refreshMetadata")
@SaCheckPermission("/api/v1/dataspaceConnection/metadata")
public Result<List<ObjectView>> refreshMetadata(
@JsonBody(required = true, skipConvertError = false) RefreshRequest request) {
return Result.ok(connectionService.refreshMetadata(
request.connectionId(), request.expectedRevision()));
}
/**
* 删除未被引用的连接。
*
* @param id 连接 ID
* @return 成功结果
*/
@PostMapping("/remove")
@SaCheckPermission("/api/v1/dataspaceConnection/remove")
public Result<Void> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
connectionService.remove(id);
return Result.ok();
}
/**
* 元数据刷新请求。
*
* @param connectionId 连接 ID
* @param expectedRevision 期望 revision
*/
public record RefreshRequest(BigInteger connectionId, long expectedRevision) {
}
/**
* 连接状态变更请求。
*
* @param id 连接 ID
* @param enabled 是否启用
*/
public record StatusRequest(BigInteger id, Boolean enabled) {
}
}

View File

@@ -1,113 +0,0 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import java.math.BigInteger;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.dataspace.model.DataspaceDefinition;
import tech.easyflow.dataspace.model.DataspaceSummary;
import tech.easyflow.dataspace.model.DataspaceView;
import tech.easyflow.dataspace.service.DataspaceService;
/**
* 虚拟数据空间管理端 API。
*/
@RestController
@RequestMapping("/api/v1/dataspace")
public class DataspaceController {
private final DataspaceService dataspaceService;
/**
* 创建数据空间控制器。
*
* @param dataspaceService 数据空间服务
*/
public DataspaceController(DataspaceService dataspaceService) {
this.dataspaceService = dataspaceService;
}
/**
* 查询数据空间列表。
*
* @param keyword 搜索关键词
* @return 数据空间摘要
*/
@GetMapping("/list")
@SaCheckPermission("/api/v1/dataspace/query")
public Result<List<DataspaceSummary>> list(String keyword) {
return Result.ok(dataspaceService.list(keyword));
}
/**
* 获取数据空间当前 revision 详情。
*
* @param id 数据空间 ID
* @return 数据空间详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/dataspace/detail")
public Result<DataspaceView> detail(BigInteger id) {
return Result.ok(dataspaceService.detail(id));
}
/**
* 保存数据空间并生成新 revision。
*
* @param definition 数据空间定义
* @return 保存后的详情
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/dataspace/save")
public Result<DataspaceView> save(
@RequestBody DataspaceDefinition definition) {
return Result.ok(dataspaceService.save(definition));
}
/**
* 启用或禁用数据空间。
*
* @param request 状态变更请求
* @return 成功结果
*/
@PostMapping("/status")
@SaCheckPermission("/api/v1/dataspace/save")
public Result<Void> status(
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
if (request == null || request.enabled() == null) {
throw new BusinessException("数据空间状态不能为空");
}
dataspaceService.setEnabled(request.id(), request.enabled());
return Result.ok();
}
/**
* 逻辑删除数据空间。
*
* @param id 数据空间 ID
* @return 成功结果
*/
@PostMapping("/remove")
@SaCheckPermission("/api/v1/dataspace/remove")
public Result<Void> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
dataspaceService.remove(id);
return Result.ok();
}
/**
* 数据空间状态变更请求。
*
* @param id 数据空间 ID
* @param enabled 是否启用
*/
public record StatusRequest(BigInteger id, Boolean enabled) {
}
}

View File

@@ -1,86 +0,0 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.dataspace.model.DataspaceExplainResult;
import tech.easyflow.dataspace.model.DataspaceQueryRequest;
import tech.easyflow.dataspace.model.DataspaceQueryResult;
import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest;
import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult;
import tech.easyflow.dataspace.service.DataspaceQueryService;
/**
* 数据空间 SQL 工作台 Query、Explain、Complete 与 Cancel API。
*/
@RestController
@RequestMapping("/api/v1/dataspaceSql")
public class DataspaceSqlController {
private final DataspaceQueryService queryService;
/**
* 创建 SQL 控制器。
*
* @param queryService 查询服务
*/
public DataspaceSqlController(DataspaceQueryService queryService) {
this.queryService = queryService;
}
/**
* 执行只读 SQL。
*
* @param request 查询请求
* @return 查询结果与指标
*/
@PostMapping("/query")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<DataspaceQueryResult> query(
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
return Result.ok(queryService.query(request));
}
/**
* 显式执行非 ANALYZE Explain。
*
* @param request Explain 请求
* @return Explain 与索引信息
*/
@PostMapping("/explain")
@SaCheckPermission("/api/v1/dataspaceSql/explain")
public Result<DataspaceExplainResult> explain(
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
return Result.ok(queryService.explain(request));
}
/**
* 返回当前数据空间内的 Calcite SQL 补全候选。
*
* @param request 补全请求
* @return 补全替换区间与候选
*/
@PostMapping("/complete")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<DataspaceSqlCompletionResult> complete(
@JsonBody(required = true, skipConvertError = false)
DataspaceSqlCompletionRequest request) {
return Result.ok(queryService.complete(request));
}
/**
* 尝试取消当前节点查询。
*
* @param queryId 查询 ID
* @return 是否找到并发起取消
*/
@PostMapping("/cancel")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<Boolean> cancel(
@JsonBody(value = "queryId", required = true, skipConvertError = false) String queryId) {
return Result.ok(queryService.cancel(queryId));
}
}

View File

@@ -1,49 +1,25 @@
package tech.easyflow.admin.controller.job;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.easyagents.flow.core.chain.Parameter;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import cn.hutool.core.date.DateUtil;
import org.quartz.CronExpression;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
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.common.web.jsonbody.JsonBody;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.service.SysJobService;
import tech.easyflow.job.support.SysJobWorkflowReferenceSupport;
import tech.easyflow.log.annotation.LogRecord;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import tech.easyflow.common.entity.LoginAccount;
import java.io.Serializable;
import java.math.BigInteger;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 系统任务表 控制层。
@@ -54,356 +30,52 @@ import java.util.Objects;
@RestController
@RequestMapping("/api/v1/sysJob")
public class SysJobController extends BaseCurdController<SysJobService, SysJob> {
/** 工作流服务。 */
private final WorkflowService workflowService;
/** 工作流使用权限校验服务。 */
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
/** 资源访问控制服务。 */
private final ResourceAccessService resourceAccessService;
/** 工作流运行参数解析器。 */
private final WorkflowRunningParameterResolver workflowRunningParameterResolver;
/** 与调度计算一致的 Cron 预览格式化器。 */
private final DateTimeFormatter jobTimeFormatter;
/**
* 创建定时任务控制器。
*
* @param service 定时任务服务
* @param workflowService 工作流服务
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
* @param resourceAccessService 资源访问控制服务
* @param workflowRunningParameterResolver 工作流运行参数解析器
* @param jobTimezone 定时任务业务时区
*/
public SysJobController(SysJobService service,
WorkflowService workflowService,
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
ResourceAccessService resourceAccessService,
WorkflowRunningParameterResolver workflowRunningParameterResolver,
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
public SysJobController(SysJobService service) {
super(service);
this.workflowService = workflowService;
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
this.resourceAccessService = resourceAccessService;
this.workflowRunningParameterResolver = workflowRunningParameterResolver;
this.jobTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of(jobTimezone));
}
/**
* 获取定时任务关键字搜索字段。
*
* @return 任务名称和备注属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"jobName", "remark"};
}
@GetMapping("/start")
@SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("启动定时任务")
public Result<Void> start(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount();
SysJob job = requireExistingJob(id);
validateWorkflowReference(job, account);
service.startJob(id);
return Result.ok();
}
@GetMapping("/stop")
@SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("停止定时任务")
public Result<Void> stop(BigInteger id) {
service.stopJob(id);
return Result.ok();
}
@GetMapping("/trigger")
@SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("立即执行定时任务")
public Result<String> trigger(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount();
SysJob job = requireExistingJob(id);
validateWorkflowReference(job, account);
return Result.ok(service.triggerNow(id));
}
@GetMapping("/getNextTimes")
@SaCheckPermission("/api/v1/sysJob/save")
public Result<List<String>> getNextTimes(String cronExpression) {
return Result.ok(service.nextFireTimes(cronExpression, 5).stream()
.map(Date::toInstant)
.map(jobTimeFormatter::format)
.toList());
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{
CronExpression ex = new CronExpression(cronExpression);
List<String> times = new ArrayList<>();
Date date = new Date();
for (int i = 0; i < 5; i++) {
Date next = ex.getNextValidTimeAfter(date);
times.add(DateUtil.formatDateTime(next));
date = next;
}
@Override
@PostMapping("remove")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
service.deleteJob(List.of(id));
return Result.ok(true);
}
@Override
@PostMapping("removeBatch")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Result<?> removeBatch(
@JsonBody(value = "ids", required = true) Collection<Serializable> ids) {
if (ids == null || ids.isEmpty()) {
return Result.fail("id不能为空");
}
service.deleteJob(ids);
return Result.ok(true);
}
/**
* 查询当前账号可用于定时任务的工作流。
*
* @return 工作流安全选项
*/
@GetMapping("/workflowOptions")
@SaCheckPermission("/api/v1/sysJob/save")
public Result<List<SysJobWorkflowOptionView>> workflowOptions() {
LoginAccount account = SaTokenUtil.getLoginAccount();
List<SysJobWorkflowOptionView> options = workflowService.list(QueryWrapper.create()
.eq(Workflow::getTenantId, account.getTenantId())
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.orderBy(Workflow::getModified, false))
.stream()
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
.filter(workflow -> workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty())
.filter(workflow -> resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE))
.map(workflowService::toPublishedView)
.filter(Objects::nonNull)
.map(workflow -> new SysJobWorkflowOptionView(
workflow.getId(),
workflow.getTitle(),
workflow.getDescription()))
.toList();
return Result.ok(options);
}
/**
* 查询定时任务所选工作流的运行参数。
*
* @param id 工作流 ID
* @return 工作流运行参数
* @throws BusinessException 工作流不存在或无运行权限时抛出
*/
@GetMapping("/workflowRunningParameters")
@SaCheckPermission("/api/v1/sysJob/save")
public Result<Map<String, Object>> workflowRunningParameters(BigInteger id) {
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
id,
SaTokenUtil.getLoginAccount(),
"工作流不存在、未发布或无权运行");
Map<String, Object> result = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (result == null) {
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
}
return Result.ok(result);
return Result.ok(times);
}
@Override
protected Result onSaveOrUpdateBefore(SysJob entity, boolean isSave) {
if (entity == null) {
throw new BusinessException("定时任务不能为空");
}
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
SysJob effectiveEntity = entity;
if (isSave) {
// 新任务固定从 STOP 和第 0 代开始,禁止请求绕过启动协议。
entity.setStatus(EnumJobStatus.STOP.getCode());
entity.setScheduleGeneration(0L);
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
} else {
SysJob existing = requireExistingJob(entity.getId());
preserveServerControlledFields(entity, existing);
effectiveEntity = mergeForValidation(entity, existing);
entity.setModified(new Date());
entity.setModifiedBy(loginUser.getId());
}
validateWorkflowReference(effectiveEntity, loginUser);
validateCronExpression(effectiveEntity.getCronExpression());
validateMisfirePolicy(effectiveEntity.getMisfirePolicy());
return super.onSaveOrUpdateBefore(entity, isSave);
}
@Override
protected void onSaveOrUpdateAfter(SysJob entity, boolean isSave) {
service.syncJob(entity.getId());
protected Result onRemoveBefore(Collection<Serializable> ids) {
service.deleteJob(ids);
return super.onRemoveBefore(ids);
}
@Override
@PostMapping("update")
public Result<?> update(@JsonBody SysJob entity) {
Result<?> result = onSaveOrUpdateBefore(entity, false);
if (result != null) return result;
service.updateJobDefinition(entity);
return Result.ok();
}
/**
* 校验工作流类型任务引用的工作流可被当前用户运行。
*
* @param entity 待保存的定时任务
* @param account 当前账号
* @throws BusinessException 工作流不存在、参数非法或无运行权限时抛出
*/
private void validateWorkflowReference(SysJob entity, LoginAccount account) {
if (entity == null
|| !Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(entity.getJobType())) {
return;
}
BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(entity);
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
workflowId,
account,
"工作流不存在、未发布或无权运行");
validateRequiredWorkflowParams(entity, workflow);
}
/**
* 获取当前租户内存在的定时任务。
*
* @param id 定时任务 ID
* @return 已存在的定时任务
* @throws BusinessException ID 缺失或任务不存在时抛出
*/
private SysJob requireExistingJob(BigInteger id) {
if (id == null) {
throw new BusinessException("定时任务ID不能为空");
}
SysJob existing = service.getById(id);
if (existing == null) {
throw new BusinessException("定时任务不存在");
}
return existing;
}
/**
* 保留更新请求不能修改的服务端控制字段。
*
* @param entity 更新请求
* @param existing 数据库中的定时任务
*/
private void preserveServerControlledFields(SysJob entity, SysJob existing) {
entity.setTenantId(existing.getTenantId());
entity.setDeptId(existing.getDeptId());
entity.setCreated(existing.getCreated());
entity.setCreatedBy(existing.getCreatedBy());
entity.setStatus(existing.getStatus());
entity.setScheduleGeneration(existing.getScheduleGeneration());
}
/**
* 合并部分更新请求与原记录,生成用于权限和参数校验的有效任务状态。
*
* @param entity 更新请求
* @param existing 数据库中的定时任务
* @return 合并后的校验对象
*/
private SysJob mergeForValidation(SysJob entity, SysJob existing) {
SysJob effective = new SysJob();
effective.setJobType(entity.getJobType() == null
? existing.getJobType()
: entity.getJobType());
effective.setJobParams(entity.getJobParams() == null
? existing.getJobParams()
: entity.getJobParams());
effective.setCronExpression(entity.getCronExpression() == null
? existing.getCronExpression()
: entity.getCronExpression());
effective.setMisfirePolicy(entity.getMisfirePolicy() == null
? existing.getMisfirePolicy()
: entity.getMisfirePolicy());
return effective;
}
private void validateMisfirePolicy(Integer misfirePolicy) {
if (!Integer.valueOf(EnumMisfirePolicy.FIRE_ONCE_NOW.getCode()).equals(misfirePolicy)
&& !Integer.valueOf(EnumMisfirePolicy.SKIP.getCode()).equals(misfirePolicy)) {
throw new BusinessException("错过策略只支持恢复后补执行一次或跳过本次");
}
}
private void validateCronExpression(String cronExpression) {
try {
service.nextFireTimes(cronExpression, 1);
} catch (RuntimeException exception) {
throw new BusinessException(400, 1, "Cron 表达式无效", exception);
}
}
/**
* 校验定时任务已填写工作流的全部必填运行参数。
*
* @param entity 待保存定时任务
* @param workflow 关联工作流
* @throws BusinessException 工作流参数配置无效或必填值缺失时抛出
*/
private void validateRequiredWorkflowParams(SysJob entity, Workflow workflow) {
List<Parameter> parameters =
workflowRunningParameterResolver.resolveStartParameters(workflow.getContent());
if (parameters == null) {
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
}
Map<String, Object> jobParams = entity.getJobParams();
Object rawWorkflowParams = jobParams == null
? null
: jobParams.get(JobConstant.WORKFLOW_PARAMS_KEY);
Map<?, ?> workflowParams = rawWorkflowParams instanceof Map<?, ?> map
? map
: Map.of();
for (Parameter parameter : parameters) {
if (parameter == null || !parameter.isRequired()) {
continue;
}
String name = parameter.getName();
if (!StringUtils.hasText(name)) {
throw new BusinessException("工作流存在无效必填参数配置,请检查工作流后重试");
}
if (!hasRequiredValue(workflowParams.get(name))) {
String label = StringUtils.hasText(parameter.getFormLabel())
? parameter.getFormLabel()
: name;
throw new BusinessException("工作流必填参数“" + label + "”不能为空");
}
}
}
/**
* 判断必填参数值是否有效。
*
* @param value 参数值
* @return 非空时为 true
*/
private boolean hasRequiredValue(Object value) {
if (value == null) {
return false;
}
if (value instanceof CharSequence sequence) {
return StringUtils.hasText(sequence);
}
if (value instanceof Collection<?> collection) {
return !collection.isEmpty();
}
if (value instanceof Map<?, ?> map) {
return !map.isEmpty();
}
return true;
}
}

View File

@@ -1,29 +1,15 @@
package tech.easyflow.admin.controller.job;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.service.SysJobLogService;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.Date;
import java.util.List;
/**
* 系统任务日志 控制层。
*
@@ -34,112 +20,16 @@ import java.util.List;
@RequestMapping("/api/v1/sysJobLog")
@UsePermission(moduleName = "/api/v1/sysJob")
public class SysJobLogController extends BaseCurdController<SysJobLogService, SysJobLog> {
private static final long DEFAULT_PAGE_SIZE = 10L;
private static final long MAX_PAGE_SIZE = 100L;
private static final DateTimeFormatter QUERY_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ZoneId jobZoneId;
public SysJobLogController(
SysJobLogService service,
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
public SysJobLogController(SysJobLogService service) {
super(service);
this.jobZoneId = ZoneId.of(jobTimezone);
}
/**
* 构造日志筛选条件,并追加计划触发时间和实际触发时间范围。
*/
@Override
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
QueryWrapper queryWrapper = super.buildQueryWrapper(request);
Date scheduledStart = parseQueryTime(
request.getParameter("scheduledStart"), "计划触发开始时间");
Date scheduledEnd = parseQueryTime(
request.getParameter("scheduledEnd"), "计划触发结束时间");
Date actualStart = parseQueryTime(
request.getParameter("actualStart"), "实际触发开始时间");
Date actualEnd = parseQueryTime(
request.getParameter("actualEnd"), "实际触发结束时间");
validateTimeRange(scheduledStart, scheduledEnd, "计划触发时间");
validateTimeRange(actualStart, actualEnd, "实际触发时间");
if (scheduledStart != null) {
queryWrapper.ge(SysJobLog::getScheduledFireTime, scheduledStart);
}
if (scheduledEnd != null) {
queryWrapper.le(SysJobLog::getScheduledFireTime, scheduledEnd);
}
if (actualStart != null) {
queryWrapper.ge(SysJobLog::getActualFireTime, actualStart);
}
if (actualEnd != null) {
queryWrapper.le(SysJobLog::getActualFireTime, actualEnd);
}
return queryWrapper;
}
/**
* 自动刷新只读取当前第一页,不执行分页总数统计。
*/
@GetMapping("refresh")
public Result<List<SysJobLog>> refresh(HttpServletRequest request, Long pageSize) {
QueryWrapper queryWrapper = buildQueryWrapper(request);
queryWrapper.orderBy(buildOrderBy(null, null, getDefaultOrderBy()));
queryWrapper.limit(resolvePageSize(pageSize));
return Result.ok(service.list(queryWrapper));
}
/**
* 最新计划触发记录优先,并用主键保证毫秒时间相同时顺序稳定。
*/
@Override
protected String getDefaultOrderBy() {
return "scheduled_fire_time desc, id desc";
}
@Override
protected Page<SysJobLog> queryPage(
Page<SysJobLog> page, QueryWrapper queryWrapper) {
page.setPageSize(resolvePageSize(page.getPageSize()));
return super.queryPage(page, queryWrapper);
}
@Override
protected Result onSaveOrUpdateBefore(SysJobLog entity, boolean isSave) {
throw new IllegalStateException("定时任务执行记录由系统维护,禁止外部写入");
}
@Override
protected Result onRemoveBefore(Collection<Serializable> ids) {
service.requireTerminal(ids);
return super.onRemoveBefore(ids);
}
private long resolvePageSize(Long pageSize) {
if (pageSize == null || pageSize < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(pageSize, MAX_PAGE_SIZE);
}
private Date parseQueryTime(String value, String fieldName) {
if (!StringUtil.hasText(value)) {
return null;
}
try {
LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER);
return Date.from(dateTime.atZone(jobZoneId).toInstant());
} catch (DateTimeParseException exception) {
throw new BusinessException(
400, 400, fieldName + "格式不正确", exception);
}
}
private void validateTimeRange(Date start, Date end, String fieldName) {
if (start != null && end != null && start.after(end)) {
throw new BusinessException(400, 400, fieldName + "范围不正确");
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
if (isSave) {
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
}
return super.onSaveOrUpdateBefore(entity, isSave);
}
}

View File

@@ -1,241 +0,0 @@
package tech.easyflow.admin.controller.skill;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.skill.entity.SkillCategory;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Skill 分类管理控制器。
*/
@RestController
@RequestMapping("/api/v1/skill/category")
@UsePermission(moduleName = "/api/v1/skill")
public class SkillCategoryController {
private static final Set<String> SORT_COLUMNS = Set.of(
"id", "category_name", "parent_id", "level_no", "sort_no", "status", "created", "modified");
private final SkillCategoryService service;
@javax.annotation.Resource
private CategoryPermissionService categoryPermissionService;
/**
* 创建 Skill 分类管理控制器。
*
* @param service Skill 分类服务
*/
public SkillCategoryController(SkillCategoryService service) {
this.service = service;
}
/**
* 查询当前用户可见的 Skill 分类。
*
* @param entity 查询条件
* @param asTree 是否转树
* @param sortKey 排序字段
* @param sortType 排序方式
* @return 可见分类列表
*/
@GetMapping("visibleList")
@SaCheckPermission("/api/v1/skill/query")
public Result<List<SkillCategory>> visibleList(SkillCategory entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(SkillCategory::getTenantId, currentAccount().getTenantId());
if (entity != null) {
queryWrapper.eq(SkillCategory::getId, entity.getId(), entity.getId() != null)
.eq(SkillCategory::getParentId, entity.getParentId(), entity.getParentId() != null)
.eq(SkillCategory::getLevelNo, entity.getLevelNo(), entity.getLevelNo() != null)
.eq(SkillCategory::getStatus, entity.getStatus(), entity.getStatus() != null);
if (entity.getCategoryName() != null && !entity.getCategoryName().isBlank()) {
queryWrapper.and("category_name LIKE ?",
SearchKeywordUtil.literalContainsPattern(entity.getCategoryName()));
}
}
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
queryWrapper.orderBy(resolveOrderBy(sortKey, sortType));
List<SkillCategory> categories = service.list(queryWrapper);
if (access.isRestricted()) {
Set<BigInteger> visibleIds = new java.util.LinkedHashSet<>(access.getCategoryIds());
categories.stream().filter(category -> access.getCategoryIds().contains(category.getId()))
.map(SkillCategory::getAncestors).filter(value -> value != null && !value.isBlank())
.flatMap(value -> java.util.Arrays.stream(value.split(",")))
.map(String::trim).filter(value -> !value.isBlank() && !"0".equals(value))
.map(BigInteger::new).forEach(visibleIds::add);
categories = categories.stream().filter(category -> visibleIds.contains(category.getId())).toList();
}
return Result.ok(Boolean.FALSE.equals(asTree) ? categories : toTree(categories));
}
/**
* 查询当前租户完整分类管理树,包含停用分类。
*
* @return 分类树
*/
@GetMapping("tree")
@SaCheckPermission("/api/v1/skill/category")
public Result<List<SkillCategory>> tree() {
List<SkillCategory> categories = service.list(QueryWrapper.create()
.eq(SkillCategory::getTenantId, currentAccount().getTenantId())
.orderBy("sort_no asc, id asc"));
return Result.ok(toTree(categories));
}
/**
* 移动 Skill 分类到新的父级。
*
* @param id 分类 ID
* @param parentId 新父级 ID根分类为空
* @return 更新结果
*/
@PostMapping("move")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> move(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id,
@JsonBody(value = "parentId", skipConvertError = false) BigInteger parentId) {
SkillCategory category = service.getOne(QueryWrapper.create()
.eq(SkillCategory::getId, id)
.eq(SkillCategory::getTenantId, currentAccount().getTenantId()));
if (category == null) {
throw new BusinessException(404, 404, "Skill 分类不存在");
}
category.setParentId(parentId);
if (!service.updateById(category)) {
throw new BusinessException(500, 500, "移动 Skill 分类失败,请稍后重试");
}
return Result.ok();
}
/**
* 创建 Skill 分类。
*
* @param entity 分类
* @return 保存结果
*/
@PostMapping("save")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> save(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) {
if (entity != null) {
entity.setId(null);
entity.setTenantId(null);
entity.setAncestors(null);
entity.setLevelNo(null);
entity.setCreated(null);
entity.setCreatedBy(null);
entity.setModified(null);
entity.setModifiedBy(null);
}
if (!service.save(entity)) {
throw new BusinessException(500, 500, "创建 Skill 分类失败,请稍后重试");
}
return Result.ok(entity);
}
/**
* 更新 Skill 分类。
*
* @param entity 分类
* @return 更新结果
*/
@PostMapping("update")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> update(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) {
if (entity != null) {
entity.setTenantId(null);
entity.setAncestors(null);
entity.setLevelNo(null);
entity.setCreated(null);
entity.setCreatedBy(null);
entity.setModified(null);
entity.setModifiedBy(null);
}
if (entity == null || entity.getId() == null) {
throw new BusinessException("Skill 分类 ID 不能为空");
}
if (!service.updateById(entity)) {
throw new BusinessException(500, 500, "更新 Skill 分类失败,请稍后重试");
}
return Result.ok(entity);
}
/**
* 删除 Skill 分类。
*
* @param id 分类 ID
* @return 删除结果
*/
@PostMapping("remove")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) Serializable id) {
if (!service.removeById(id)) {
throw new BusinessException(500, 500, "删除 Skill 分类失败,请稍后重试");
}
return Result.ok();
}
private LoginAccount currentAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
/**
* 将分类排序参数收敛到固定字段白名单,禁止原始 SQL 片段进入查询。
*
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 安全排序表达式
*/
String resolveOrderBy(String sortKey, String sortType) {
String snake = sortKey == null ? "" : sortKey
.replaceAll("([a-z0-9])([A-Z])", "$1_$2")
.toLowerCase(Locale.ROOT);
String column = SORT_COLUMNS.contains(snake) ? snake : "sort_no";
String direction = "desc".equalsIgnoreCase(sortType) ? "desc" : "asc";
return column + " " + direction + ("id".equals(column) ? "" : ", id asc");
}
private List<SkillCategory> toTree(List<SkillCategory> categories) {
Map<java.math.BigInteger, SkillCategory> byId = new LinkedHashMap<>();
categories.forEach(category -> {
category.setChildren(null);
byId.put(category.getId(), category);
});
List<SkillCategory> roots = new java.util.ArrayList<>();
for (SkillCategory category : categories) {
SkillCategory parent = category.getParentId() == null ? null : byId.get(category.getParentId());
if (parent == null) {
roots.add(category);
} else {
parent.getChildren().add(category);
}
}
return roots;
}
}

View File

@@ -1,755 +0,0 @@
package tech.easyflow.admin.controller.skill;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
import tech.easyflow.admin.controller.skill.vo.SkillGitRepositoryPrepareRequest;
import tech.easyflow.admin.controller.skill.vo.SkillGitRepositoryScanRequest;
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
import tech.easyflow.admin.controller.skill.vo.SkillView;
import tech.easyflow.admin.controller.skill.vo.SkillToolBindingUpdateRequest;
import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.file.SkillFileContent;
import tech.easyflow.skill.file.SkillFileNode;
import tech.easyflow.skill.file.SkillFileRenameRequest;
import tech.easyflow.skill.file.SkillFileSaveRequest;
import tech.easyflow.skill.file.SkillFileService;
import tech.easyflow.skill.imports.SkillExportRequest;
import tech.easyflow.skill.imports.SkillExportArtifact;
import tech.easyflow.skill.imports.SkillExportService;
import tech.easyflow.skill.imports.SkillImportConfirmRequest;
import tech.easyflow.skill.imports.SkillImportPreview;
import tech.easyflow.skill.imports.SkillImportService;
import tech.easyflow.skill.gitimport.SkillGitImportService;
import tech.easyflow.skill.gitimport.SkillGitScanResult;
import tech.easyflow.skill.publish.SkillPublishAppService;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.service.SkillApprovalStateService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.service.SkillToolBindingService;
import tech.easyflow.skill.service.SkillToolOptionQueryService;
import tech.easyflow.skill.vo.SkillMcpToolManifestView;
import tech.easyflow.skill.vo.SkillToolOptionPage;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* Skill 管理端 API统一负责轻量查询、白名单写入、文件工作台和标准包导入导出。
*/
@RestController
@RequestMapping("/api/v1/skill")
public class SkillController {
private static final Set<String> PAGE_SORT_COLUMNS = Set.of(
"id", "name", "display_name", "created", "modified", "publish_status");
private final SkillService skillService;
private final SkillApprovalStateService skillApprovalStateService;
private final SkillPublishAppService skillPublishAppService;
private final SkillImportService skillImportService;
private final SkillGitImportService skillGitImportService;
private final SkillExportService skillExportService;
private final SkillFileService skillFileService;
private final SkillToolBindingService skillToolBindingService;
private final SkillToolOptionQueryService skillToolOptionQueryService;
private final ResourceAccessService resourceAccessService;
private final CategoryPermissionService categoryPermissionService;
private final SkillVisibilityQueryHelper visibilityQueryHelper;
private final AiResourceCreatorNameSupport creatorNameSupport;
/**
* 创建 Skill 管理控制器。
*
* @param skillService Skill 服务
* @param skillApprovalStateService 审批状态服务
* @param skillPublishAppService 发布服务
* @param skillImportService 导入服务
* @param skillGitImportService Git 仓库导入服务
* @param skillExportService 导出服务
* @param skillFileService 文件服务
* @param skillToolBindingService Skill Tool 绑定服务
* @param skillToolOptionQueryService Skill Tool 候选查询服务
* @param resourceAccessService 资源权限服务
* @param categoryPermissionService 分类权限服务
* @param visibilityQueryHelper 可见性查询助手
* @param creatorNameSupport 创建人名称助手
*/
public SkillController(SkillService skillService,
SkillApprovalStateService skillApprovalStateService,
SkillPublishAppService skillPublishAppService,
SkillImportService skillImportService,
SkillGitImportService skillGitImportService,
SkillExportService skillExportService,
SkillFileService skillFileService,
SkillToolBindingService skillToolBindingService,
SkillToolOptionQueryService skillToolOptionQueryService,
ResourceAccessService resourceAccessService,
CategoryPermissionService categoryPermissionService,
SkillVisibilityQueryHelper visibilityQueryHelper,
AiResourceCreatorNameSupport creatorNameSupport) {
this.skillService = skillService;
this.skillApprovalStateService = skillApprovalStateService;
this.skillPublishAppService = skillPublishAppService;
this.skillImportService = skillImportService;
this.skillGitImportService = skillGitImportService;
this.skillExportService = skillExportService;
this.skillFileService = skillFileService;
this.skillToolBindingService = skillToolBindingService;
this.skillToolOptionQueryService = skillToolOptionQueryService;
this.resourceAccessService = resourceAccessService;
this.categoryPermissionService = categoryPermissionService;
this.visibilityQueryHelper = visibilityQueryHelper;
this.creatorNameSupport = creatorNameSupport;
}
/**
* 分页查询当前用户可读的 Skill 描述信息。
*
* @param pageNumber 页码
* @param pageSize 每页数量
* @param categoryId 分类 ID
* @param categoryScope 分类范围UNCATEGORIZED 表示未分类
* @param name 名称关键词
* @param displayName 展示名称关键词
* @param keyword 名称、用途或创建人模糊关键词
* @param publishStatus 发布状态
* @param visibilityScope 使用范围
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 轻量分页结果
*/
@GetMapping("/page")
@SaCheckPermission("/api/v1/skill/query")
public Result<Page<SkillView>> page(Long pageNumber, Long pageSize, BigInteger categoryId, String categoryScope,
String name, String displayName, String keyword, String publishStatus,
String visibilityScope, String sortKey, String sortType) {
long normalizedPage = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
long normalizedSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
QueryWrapper query = descriptorQuery();
visibilityQueryHelper.applyReadableAccess(query);
if ("UNCATEGORIZED".equalsIgnoreCase(categoryScope)) {
query.isNull("category_id");
} else {
query.eq("category_id", categoryId, categoryId != null);
}
query.eq("publish_status", publishStatus, hasText(publishStatus))
.eq("visibility_scope", visibilityScope, hasText(visibilityScope));
String effectiveKeyword = hasText(keyword) ? keyword : hasText(displayName) ? displayName : name;
if (hasText(effectiveKeyword)) {
String pattern = SearchKeywordUtil.literalContainsPattern(effectiveKeyword);
query.and("(name LIKE ? ESCAPE '\\\\' OR display_name LIKE ? ESCAPE '\\\\' "
+ "OR description LIKE ? ESCAPE '\\\\' OR EXISTS (SELECT 1 FROM tb_sys_account a "
+ "WHERE a.id = tb_skill.created_by AND a.tenant_id = tb_skill.tenant_id "
+ "AND (a.nickname LIKE ? ESCAPE '\\\\' OR a.login_name LIKE ? ESCAPE '\\\\'))) ",
pattern, pattern, pattern, pattern, pattern);
}
query.orderBy(resolveSortColumn(sortKey) + ("asc".equalsIgnoreCase(sortType) ? " asc" : " desc"));
Page<Skill> source = skillService.page(new Page<>(normalizedPage, normalizedSize), query);
fillListState(source.getRecords());
LoginAccount account = SaTokenUtil.getLoginAccount();
boolean superAdmin = account != null && categoryPermissionService.isSuperAdmin(account);
List<SkillView> records = source.getRecords().stream()
.map(skill -> toPageView(skill, account, superAdmin)).toList();
return Result.ok(new Page<>(records, source.getPageNumber(), source.getPageSize(), source.getTotalRow()));
}
/**
* 获取 Skill 完整管理详情。
*
* @param id Skill ID
* @return Skill 详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillView> detail(BigInteger id) {
Skill skill = skillService.getManagementDetail(id);
fillListState(List.of(skill));
return Result.ok(toView(skill));
}
/**
* 查询 Skill 可绑定的 Tool 候选。
*
* @param keyword 名称或描述关键词
* @param toolType 类型过滤
* @param pageNum 页码
* @param pageSize 每页数量
* @return 安全候选分页
*/
@GetMapping("/toolOptions")
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
public Result<SkillToolOptionPage> toolOptions(String keyword, String toolType,
Long pageNum, Long pageSize) {
return Result.ok(skillToolOptionQueryService.page(keyword, toolType,
pageNum == null ? 1 : pageNum, pageSize == null ? 20 : pageSize));
}
/**
* 按需读取指定 MCP 的脱敏 Tool 清单。
*
* @param mcpId MCP ID
* @return MCP Tool 清单
*/
@GetMapping("/mcpTools")
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
public Result<SkillMcpToolManifestView> mcpTools(BigInteger mcpId) {
return Result.ok(skillToolOptionQueryService.mcpTools(mcpId));
}
/**
* 原子替换 Skill 的全部平台 Tool 草稿绑定。
*
* @param request 白名单绑定请求
* @return 服务端规范化的安全绑定摘要
*/
@PostMapping("/toolBinding/update")
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
public Result<List<SkillView.ToolBindingView>> updateToolBindings(
@JsonBody(required = true, skipConvertError = false) SkillToolBindingUpdateRequest request) {
if (request == null || request.getSkillId() == null) {
throw new BusinessException("Skill ID 不能为空");
}
List<tech.easyflow.skill.entity.SkillToolBinding> bindings = request.getBindings() == null
? List.of() : request.getBindings().stream().map(SkillToolBindingUpdateRequest.Binding::toEntity).toList();
return Result.ok(skillToolBindingService.replaceBindings(request.getSkillId(), bindings)
.stream().map(SkillView.ToolBindingView::from).toList());
}
/**
* 创建 Skill 草稿。
*
* @param request 草稿白名单请求
* @return 创建后的 Skill
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillView> save(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
if (request == null || request.id() != null) {
throw new BusinessException("创建 Skill 时不能指定 ID");
}
return Result.ok(toView(skillService.saveDraft(request.toEntity())));
}
/**
* 更新 Skill 草稿。
*
* @param request 草稿白名单请求
* @return 更新后的 Skill
*/
@PostMapping("/update")
@SaCheckPermission("/api/v1/skill/update")
public Result<SkillView> update(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
if (request == null || request.id() == null) {
throw new BusinessException("Skill ID 不能为空");
}
return Result.ok(toView(skillService.updateDraft(request.toUpdateEntity())));
}
/**
* 复制已有 Skill 为当前用户拥有的新草稿。
*
* @param request 复制请求
* @return 新建的 Skill 草稿
*/
@PostMapping("/copy")
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillView> copy(@JsonBody(required = true, skipConvertError = false) SkillCopyRequest request) {
if (request == null) {
throw new BusinessException("复制参数不能为空");
}
return Result.ok(toView(skillService.copyDraft(request.sourceId(), request.name(),
request.displayName(), request.categoryId())));
}
/**
* 在展示发布确认前执行发布级全量校验。
*
* @param id Skill ID
* @return 标准包结构化校验结果
*/
@PostMapping("/validatePublish")
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
public Result<SkillValidationResult> validatePublish(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return Result.ok(skillService.validateSkill(id, true));
}
/**
* 获取 Skill 文件树。
*
* @param skillId Skill ID
* @return 文件树
*/
@GetMapping("/file/tree")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<List<SkillFileNode>> fileTree(BigInteger skillId) {
return Result.ok(skillFileService.tree(skillId));
}
/**
* 获取 Skill 文本文件内容或二进制摘要。
*
* @param skillId Skill ID
* @param path 包内路径
* @return 文件内容
*/
@GetMapping("/file/content")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillFileContent> fileContent(BigInteger skillId, String path) {
return Result.ok(skillFileService.getContent(skillId, path));
}
/**
* 保存已有文本文件。
*
* @param request 保存请求
* @return 最新文件内容
*/
@PostMapping("/file/save")
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> saveFile(
@JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
return Result.ok(skillFileService.saveContent(request));
}
/**
* 创建文本文件。
*
* @param request 创建请求
* @return 文件内容
*/
@PostMapping("/file/create")
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> createFile(
@JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
return Result.ok(skillFileService.createTextFile(request));
}
/**
* 重命名文件。
*
* @param request 重命名请求
* @return 最新文件内容
*/
@PostMapping("/file/rename")
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> renameFile(
@JsonBody(required = true, skipConvertError = false) SkillFileRenameRequest request) {
return Result.ok(skillFileService.renameFile(request));
}
/**
* 删除包内文件。
*
* @param skillId Skill ID
* @param path 文件路径
* @return 空结果
*/
@PostMapping("/file/delete")
@SaCheckPermission("/api/v1/skill/file")
public Result<Void> deleteFile(
@JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId,
@JsonBody(value = "path", required = true, skipConvertError = false) String path,
@JsonBody(value = "expectedContentHash", required = true, skipConvertError = false)
String expectedContentHash) {
skillFileService.deleteFile(skillId, path, expectedContentHash);
return Result.ok();
}
/**
* 上传任意包内二进制资源。
*
* @param skillId Skill ID
* @param path 文件路径
* @param expectedContentHash 目标文件预期内容哈希,替换时必填
* @param file 上传文件
* @return 文件摘要
*/
@PostMapping(value = "/file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> uploadFile(BigInteger skillId,
String path,
String expectedContentHash,
MultipartFile file) {
return Result.ok(skillFileService.uploadResource(skillId, path, file, expectedContentHash));
}
/**
* 下载包内文件。
*
* @param skillId Skill ID
* @param path 文件路径
* @param response HTTP 响应
* @throws IOException 响应写入失败
*/
@GetMapping("/file/download")
@SaCheckPermission("/api/v1/skill/getDetail")
public void downloadFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
transferFile(skillId, path, response, false);
}
/**
* 安全预览包内文件;主动内容强制下载。
*
* @param skillId Skill ID
* @param path 文件路径
* @param response HTTP 响应
* @throws IOException 响应写入失败
*/
@GetMapping("/file/preview")
@SaCheckPermission("/api/v1/skill/getDetail")
public void previewFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
transferFile(skillId, path, response, true);
}
/**
* 批量预览标准 ZIP 导入内容。
*
* @param files 导入文件
* @param file 兼容单文件字段
* @return 每个文件的 token 化预览
*/
@PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillImportPreview>> importPreview(
@RequestPart(value = "files", required = false) List<MultipartFile> files,
@RequestPart(value = "file", required = false) MultipartFile file) {
List<MultipartFile> uploads = new java.util.ArrayList<>(files == null ? List.of() : files);
if (file != null) {
uploads.add(file);
}
if (uploads.isEmpty()) {
throw new BusinessException("请选择要导入的标准 Skill ZIP");
}
if (uploads.size() > SkillImportService.MAX_BATCH_SKILL_COUNT) {
throw new BusinessException("单次最多选择 "
+ SkillImportService.MAX_BATCH_SKILL_COUNT + " 个 Skill ZIP");
}
return Result.ok(skillImportService.previewBatch(uploads));
}
/**
* 扫描 HTTPS Git 仓库中的标准 Skill 候选。
*
* @param request 仓库地址请求
* @return 固定提交的候选列表
*/
@PostMapping("/import/repository/scan")
@SaCheckPermission("/api/v1/skill/import")
public Result<SkillGitScanResult> scanGitRepository(
@JsonBody(required = true, skipConvertError = false) SkillGitRepositoryScanRequest request) {
if (request == null) {
throw new BusinessException("请输入 Git 仓库地址");
}
return Result.ok(skillGitImportService.scan(request.repositoryUrl()));
}
/**
* 将选中的 Git Skill 候选转换为既有标准 ZIP 导入预览。
*
* @param request 扫描令牌与候选 ID
* @return 与本地 ZIP 导入一致的预览列表
*/
@PostMapping("/import/repository/prepare")
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillImportPreview>> prepareGitRepositoryImport(
@JsonBody(required = true, skipConvertError = false) SkillGitRepositoryPrepareRequest request) {
if (request == null) {
throw new BusinessException("请选择要导入的 Git Skill");
}
return Result.ok(skillGitImportService.prepare(request.scanToken(), request.candidateIds()));
}
/**
* 使用一次性 token 确认导入。
*
* @param request 导入确认请求
* @return 导入后的 Skill
*/
@PostMapping("/import/confirm")
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillView>> importConfirm(
@JsonBody(required = true, skipConvertError = false) SkillImportConfirmRequest request) {
return Result.ok(skillImportService.confirm(request).stream().map(this::toView).toList());
}
/**
* 独立确认多个已预检的标准 Skill ZIP单包业务失败不回滚其他包。
*
* @param requests 导入确认请求,按预检 token 一一对应
* @return 各包独立导入结果
*/
@PostMapping("/import/confirmBatch")
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillImportBatchResultView>> importConfirmBatch(
@JsonBody(required = true, skipConvertError = false) List<SkillImportConfirmRequest> requests) {
if (requests == null || requests.isEmpty()) {
throw new BusinessException("请选择要确认导入的 Skill");
}
if (requests.size() > SkillImportService.MAX_BATCH_SKILL_COUNT) {
throw new BusinessException("单次最多确认导入 "
+ SkillImportService.MAX_BATCH_SKILL_COUNT + " 个 Skill");
}
List<SkillImportBatchResultView> results = new java.util.ArrayList<>(requests.size());
for (SkillImportConfirmRequest request : requests) {
String token = request == null ? null : request.getImportToken();
try {
List<SkillView> skills = skillImportService.confirm(request).stream().map(this::toView).toList();
results.add(SkillImportBatchResultView.succeeded(token, skills));
} catch (BusinessException exception) {
results.add(SkillImportBatchResultView.failed(token, exception.getMessage()));
}
}
return Result.ok(results);
}
/**
* 取消导入并清理临时包。
*
* @param importToken 导入 token
* @return 空结果
*/
@PostMapping("/import/cancel")
@SaCheckPermission("/api/v1/skill/import")
public Result<Void> importCancel(
@JsonBody(value = "importToken", required = true, skipConvertError = false) String importToken) {
skillImportService.cancel(importToken);
return Result.ok();
}
/**
* 导出标准 Skill ZIP。
*
* @param request 导出请求
* @param response HTTP 响应
*/
@PostMapping("/export")
@SaCheckPermission("/api/v1/skill/export")
public void export(@JsonBody(required = true, skipConvertError = false) SkillExportRequest request,
HttpServletResponse response) {
if (request == null || request.getIds().isEmpty()) {
throw new BusinessException("请选择要导出的 Skill");
}
if (request.getIds().size() > 100) {
throw new BusinessException("单次最多导出 100 个 Skill");
}
try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds())) {
response.setContentType(artifact.getMediaType());
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
artifact.transferTo(output(response));
}
}
/**
* 导出单个标准 Skill 包。
*
* @param id Skill ID
* @param response HTTP 响应
*/
@GetMapping("/export")
@SaCheckPermission("/api/v1/skill/export")
public void exportOne(BigInteger id, HttpServletResponse response) {
if (id == null) {
throw new BusinessException("Skill ID 不能为空");
}
writeExport(List.of(id), response);
}
/**
* 提交发布审批。
*
* @param id Skill ID
* @param applicationReason 发布说明
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
public Result<BigInteger> submitPublishApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id,
@JsonBody(value = "applicationReason", required = true, skipConvertError = false)
String applicationReason) {
return approvalResult(skillPublishAppService.submitPublishApproval(id, applicationReason),
"已提交发布审批", "已直接发布");
}
/**
* 提交下线审批。
*
* @param id Skill ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/skill/submitOfflineApproval")
public Result<BigInteger> submitOfflineApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return approvalResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
}
/**
* 提交删除审批。
*
* @param id Skill ID
* @return 审批实例 ID
*/
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/skill/submitDeleteApproval")
public Result<BigInteger> submitDeleteApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return approvalResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
}
/**
* 查询 Skill 发布和审批派生状态。
*
* @param id Skill ID
* @return 发布状态
*/
@GetMapping("/publish/status")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillPublishStatusView> publishStatus(BigInteger id) {
QueryWrapper query = descriptorQuery().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
Skill skill = skillService.getOne(query);
if (skill == null) {
throw new BusinessException(404, 404, "Skill 不存在");
}
fillListState(List.of(skill));
return Result.ok(new SkillPublishStatusView(skill.getId(), skill.getPublishStatus(),
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
skill.getCurrentApprovalInstanceId()));
}
private QueryWrapper descriptorQuery() {
return QueryWrapper.create().select("id", "tenant_id", "dept_id", "category_id", "name", "display_name", "description",
"visibility_scope", "package_hash", "snapshot_hash",
"publish_status", "current_approval_instance_id", "created", "created_by", "modified", "modified_by");
}
private void writeExport(List<BigInteger> ids, HttpServletResponse response) {
try (SkillExportArtifact artifact = skillExportService.prepare(ids)) {
response.setContentType(artifact.getMediaType());
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
artifact.transferTo(output(response));
}
}
private void fillListState(List<Skill> skills) {
skillApprovalStateService.fillSkillApprovalState(skills);
creatorNameSupport.fillSkillCreatorNames(skills);
}
private SkillView toView(Skill skill) {
boolean readable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ);
boolean manageable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE);
return SkillView.from(skill, readable, manageable);
}
private SkillView toPageView(Skill skill, LoginAccount account, boolean superAdmin) {
boolean sameTenant = account != null && account.getTenantId() != null
&& Objects.equals(account.getTenantId(), skill.getTenantId());
boolean manageable = sameTenant && (superAdmin || Objects.equals(account.getId(), skill.getCreatedBy()));
return SkillView.from(skill, sameTenant, manageable);
}
private void transferFile(BigInteger skillId, String path, HttpServletResponse response, boolean preview) throws IOException {
SkillFileContent content = skillFileService.getContent(skillId, path);
String mediaType = content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType();
boolean inline = preview && isSafeInline(mediaType);
response.setContentType(inline ? mediaType : MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
response.setHeader("Content-Disposition", (inline ? "inline" : "attachment") + filenameParameter(fileName(path)));
if (Boolean.TRUE.equals(content.getIsText())) {
response.getOutputStream().write((content.getContent() == null ? "" : content.getContent())
.getBytes(StandardCharsets.UTF_8));
return;
}
try (InputStream inputStream = skillFileService.openResource(skillId, path)) {
StreamUtils.copy(inputStream, response.getOutputStream());
}
}
private boolean isSafeInline(String mediaType) {
String normalized = mediaType.toLowerCase(Locale.ROOT).split(";", 2)[0];
return normalized.equals("application/pdf") || normalized.equals("text/plain")
|| normalized.equals("text/markdown") || normalized.equals("image/png")
|| normalized.equals("image/jpeg") || normalized.equals("image/gif")
|| normalized.equals("image/webp") || normalized.equals("image/avif");
}
private String resolveSortColumn(String sortKey) {
if (!hasText(sortKey)) {
return "modified";
}
String snake = sortKey.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
return PAGE_SORT_COLUMNS.contains(snake) ? snake : "modified";
}
private String attachment(String fileName) {
return "attachment" + filenameParameter(fileName);
}
private String filenameParameter(String fileName) {
String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
return "; filename*=UTF-8''" + encoded;
}
private String fileName(String path) {
if (!hasText(path)) {
return "resource.bin";
}
int index = path.lastIndexOf('/');
return index < 0 ? path : path.substring(index + 1);
}
private Result<BigInteger> approvalResult(ApprovalActionResult result, String approvalMessage, String directMessage) {
return Result.ok(result.isApprovalRequired() ? approvalMessage : directMessage, result.getInstanceId());
}
private java.io.OutputStream output(HttpServletResponse response) {
try {
return response.getOutputStream();
} catch (IOException exception) {
throw new BusinessException(500, 500, "创建 Skill 导出响应失败", exception);
}
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@@ -1,17 +0,0 @@
package tech.easyflow.admin.controller.skill.vo;
import java.math.BigInteger;
/**
* Skill 复制请求白名单。
*
* @param sourceId 源 Skill ID
* @param name 新 Skill 标准名称
* @param displayName 新 Skill 展示名称
* @param categoryId 目标分类 ID可为空
*/
public record SkillCopyRequest(BigInteger sourceId,
String name,
String displayName,
BigInteger categoryId) {
}

View File

@@ -1,47 +0,0 @@
package tech.easyflow.admin.controller.skill.vo;
import tech.easyflow.skill.entity.Skill;
import java.math.BigInteger;
/**
* Skill 草稿写入白名单,拒绝客户端覆盖租户、归属人、发布态、快照和 hash 等服务端字段。
*
* @param id Skill ID创建时为空
* @param categoryId 分类 ID
* @param displayName 展示名称
* @param skillContent SKILL.md 内容,仅创建时使用;已有草稿正文通过文件接口原子保存
* @param visibilityScope 可见范围
*/
public record SkillDraftRequest(BigInteger id,
BigInteger categoryId,
String displayName,
String skillContent,
String visibilityScope) {
/**
* 转换为仅包含可写字段的业务实体。
*
* @return Skill 草稿实体
*/
public Skill toEntity() {
Skill skill = new Skill();
skill.setId(id);
skill.setCategoryId(categoryId);
skill.setDisplayName(displayName);
skill.setSkillContent(skillContent);
skill.setVisibilityScope(visibilityScope);
return skill;
}
/**
* 转换为不包含 SKILL.md 正文的基础配置更新实体。
*
* @return Skill 基础配置实体
*/
public Skill toUpdateEntity() {
Skill skill = toEntity();
skill.setSkillContent(null);
return skill;
}
}

View File

@@ -1,12 +0,0 @@
package tech.easyflow.admin.controller.skill.vo;
import java.util.List;
/**
* 选中 Git Skill 候选的标准导入预览准备请求。
*
* @param scanToken 短期扫描令牌
* @param candidateIds 选中的候选 ID
*/
public record SkillGitRepositoryPrepareRequest(String scanToken, List<String> candidateIds) {
}

View File

@@ -1,9 +0,0 @@
package tech.easyflow.admin.controller.skill.vo;
/**
* Git 仓库 Skill 扫描请求。
*
* @param repositoryUrl HTTPS Git 仓库地址,可省略 .git 后缀
*/
public record SkillGitRepositoryScanRequest(String repositoryUrl) {
}

View File

@@ -1,41 +0,0 @@
package tech.easyflow.admin.controller.skill.vo;
import java.util.List;
/**
* 单个标准 Skill ZIP 的独立导入结果。
*
* @param importToken 预检 token
* @param success 是否成功
* @param message 失败原因,成功时为空
* @param skills 导入成功的 Skill
*/
public record SkillImportBatchResultView(
String importToken,
boolean success,
String message,
List<SkillView> skills
) {
/**
* 构造成功结果。
*
* @param importToken 预检 token
* @param skills 导入的 Skill
* @return 成功结果
*/
public static SkillImportBatchResultView succeeded(String importToken, List<SkillView> skills) {
return new SkillImportBatchResultView(importToken, true, null, List.copyOf(skills));
}
/**
* 构造失败结果。
*
* @param importToken 预检 token
* @param message 失败原因
* @return 失败结果
*/
public static SkillImportBatchResultView failed(String importToken, String message) {
return new SkillImportBatchResultView(importToken, false, message, List.of());
}
}

View File

@@ -1,21 +0,0 @@
package tech.easyflow.admin.controller.skill.vo;
import java.math.BigInteger;
/**
* Skill 发布和审批派生状态。
*
* @param id Skill ID
* @param publishStatus 真实发布状态
* @param approvalPending 是否存在进行中审批
* @param currentApprovalActionType 当前审批动作
* @param displayPublishStatus 前端展示状态
* @param currentApprovalInstanceId 当前审批实例 ID
*/
public record SkillPublishStatusView(BigInteger id,
String publishStatus,
Boolean approvalPending,
String currentApprovalActionType,
String displayPublishStatus,
BigInteger currentApprovalInstanceId) {
}

View File

@@ -1,112 +0,0 @@
package tech.easyflow.admin.controller.skill.vo;
import tech.easyflow.skill.entity.SkillToolBinding;
import java.math.BigInteger;
import java.util.List;
/**
* Skill Tool 整组替换请求。
*
* <p>{@code @JsonBody} 当前由 Fastjson 1 完成转换,使用标准 JavaBean 可确保嵌套列表元素
* 按声明类型转换,避免嵌套 record 被保留为 {@code JSONObject}。</p>
*/
public class SkillToolBindingUpdateRequest {
private BigInteger skillId;
private List<Binding> bindings;
/** 创建空请求。 */
public SkillToolBindingUpdateRequest() {
}
/**
* 创建 Skill Tool 绑定请求。
*
* @param skillId Skill ID
* @param bindings 绑定引用
*/
public SkillToolBindingUpdateRequest(BigInteger skillId, List<Binding> bindings) {
this.skillId = skillId;
this.bindings = bindings;
}
/** @return Skill ID */
public BigInteger getSkillId() { return skillId; }
/** @param skillId Skill ID */
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
/** @return 绑定引用 */
public List<Binding> getBindings() { return bindings; }
/** @param bindings 绑定引用 */
public void setBindings(List<Binding> bindings) { this.bindings = bindings; }
/** 客户端允许提交的最小绑定字段。 */
public static class Binding {
private String toolType;
private BigInteger targetId;
private Boolean hitlEnabled;
private Integer sortNo;
private String mcpToolManifestHash;
/** 创建空绑定。 */
public Binding() {
}
/**
* 创建最小 Tool 绑定。
*
* @param toolType Tool 类型
* @param targetId 目标资源 ID
* @param hitlEnabled 是否调用前确认
* @param sortNo 排序号
* @param mcpToolManifestHash MCP Tool 清单 hash
*/
public Binding(String toolType, BigInteger targetId, Boolean hitlEnabled,
Integer sortNo, String mcpToolManifestHash) {
this.toolType = toolType;
this.targetId = targetId;
this.hitlEnabled = hitlEnabled;
this.sortNo = sortNo;
this.mcpToolManifestHash = mcpToolManifestHash;
}
/** @return Tool 类型 */
public String getToolType() { return toolType; }
/** @param toolType Tool 类型 */
public void setToolType(String toolType) { this.toolType = toolType; }
/** @return 目标资源 ID */
public BigInteger getTargetId() { return targetId; }
/** @param targetId 目标资源 ID */
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
/** @return 是否调用前确认 */
public Boolean getHitlEnabled() { return hitlEnabled; }
/** @param hitlEnabled 是否调用前确认 */
public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; }
/** @return 排序号 */
public Integer getSortNo() { return sortNo; }
/** @param sortNo 排序号 */
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
/** @return MCP Tool 清单 hash */
public String getMcpToolManifestHash() { return mcpToolManifestHash; }
/** @param mcpToolManifestHash MCP Tool 清单 hash */
public void setMcpToolManifestHash(String mcpToolManifestHash) {
this.mcpToolManifestHash = mcpToolManifestHash;
}
/**
* 转换为领域绑定引用。
*
* @return 最小 Tool 绑定
*/
public SkillToolBinding toEntity() {
SkillToolBinding value = new SkillToolBinding();
value.setToolType(toolType);
value.setTargetId(targetId);
value.setHitlEnabled(hitlEnabled);
value.setSortNo(sortNo);
value.setMcpToolManifestHash(mcpToolManifestHash);
return value;
}
}
}

View File

@@ -1,152 +0,0 @@
package tech.easyflow.admin.controller.skill.vo;
import com.easyagents.skill.util.SkillResources;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.entity.SkillToolBinding;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
/**
* 管理端 Skill 安全视图。
*
* @param id Skill ID
* @param categoryId 分类 ID
* @param name 标准名称
* @param displayName 展示名称
* @param description 用途描述
* @param visibilityScope 使用范围
* @param packageHash 标准包哈希
* @param snapshotHash 发布快照哈希
* @param publishStatus 发布状态
* @param currentApprovalInstanceId 当前审批实例 ID
* @param approvalPending 是否审批中
* @param currentApprovalActionType 当前审批动作
* @param displayPublishStatus 展示状态
* @param created 创建时间
* @param modified 修改时间
* @param createdByName 创建人昵称与账号
* @param readable 是否可读
* @param manageable 是否可管理
* @param resources 资源摘要
* @param toolBindings 平台 Tool 草稿绑定摘要
* @param toolCount 实际 Tool 数
* @param hasToolUpdate Tool 草稿是否与线上快照不同
*/
public record SkillView(BigInteger id,
BigInteger categoryId,
String name,
String displayName,
String description,
String visibilityScope,
String packageHash,
String snapshotHash,
String publishStatus,
BigInteger currentApprovalInstanceId,
Boolean approvalPending,
String currentApprovalActionType,
String displayPublishStatus,
Date created,
Date modified,
String createdByName,
boolean readable,
boolean manageable,
List<ResourceView> resources,
List<ToolBindingView> toolBindings,
int toolCount,
boolean hasToolUpdate) {
/**
* 从领域实体构造管理端视图。
*
* @param skill Skill 实体
* @param readable 是否可读
* @param manageable 是否可管理
* @return 管理端视图
*/
public static SkillView from(Skill skill, boolean readable, boolean manageable) {
List<ResourceView> resources = skill.getResources() == null ? null
: skill.getResources().stream().map(ResourceView::from).toList();
List<ToolBindingView> toolBindings = skill.getToolBindings() == null ? null
: skill.getToolBindings().stream().map(ToolBindingView::from).toList();
int toolCount = skill.getToolBindings() == null ? 0 : skill.getToolBindings().stream()
.mapToInt(binding -> "MCP".equalsIgnoreCase(binding.getToolType())
? Math.max(0, binding.getMcpToolCount() == null ? 0 : binding.getMcpToolCount()) : 1)
.sum();
return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(),
skill.getDescription(), skill.getVisibilityScope(), skill.getPackageHash(),
skill.getSnapshotHash(), skill.getPublishStatus(), skill.getCurrentApprovalInstanceId(),
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
skill.getCreated(), skill.getModified(), skill.getCreatedByName(), readable, manageable, resources,
toolBindings, toolCount, hasToolUpdate(skill));
}
/**
* Skill 包内资源摘要。
*
* @param id 资源 ID
* @param path 标准相对路径
* @param kind 按路径派生的语义类型
* @param mediaType 媒体类型
* @param isText 是否文本
* @param contentHash 内容哈希
* @param size 字节数
*/
public record ResourceView(BigInteger id, String path, String kind, String mediaType,
Boolean isText, String contentHash, Long size) {
/**
* 转换资源实体。
*
* @param resource 资源实体
* @return 资源摘要
*/
public static ResourceView from(SkillResource resource) {
String path = resource.getNormalizedPath();
return new ResourceView(resource.getId(), path, SkillResources.classify(path).name(),
resource.getMediaType(), resource.getIsText(), resource.getContentHash(), resource.getSize());
}
}
private static boolean hasToolUpdate(Skill skill) {
if (skill.getToolBindings() == null) {
return false;
}
Object published = skill.getPublishedToolBindingsJson() == null
? null : skill.getPublishedToolBindingsJson().get("bindings");
List<String> currentKeys = skill.getToolBindings().stream().map(SkillView::bindingKey).toList();
if (!(published instanceof List<?> list)) {
return !currentKeys.isEmpty();
}
List<String> publishedKeys = list.stream().map(item -> {
if (!(item instanceof java.util.Map<?, ?> map)) {
return "INVALID";
}
return String.valueOf(map.get("toolType")) + ":" + map.get("targetId") + ":"
+ Boolean.TRUE.equals(map.get("hitlEnabled")) + ":" + map.get("mcpToolManifestHash");
}).toList();
return !currentKeys.equals(publishedKeys);
}
private static String bindingKey(SkillToolBinding binding) {
return binding.getToolType() + ":" + binding.getTargetId() + ":"
+ Boolean.TRUE.equals(binding.getHitlEnabled()) + ":" + binding.getMcpToolManifestHash();
}
/**
* Skill 平台 Tool 草稿绑定安全摘要。
*/
public record ToolBindingView(BigInteger id, String toolType, BigInteger targetId,
Boolean hitlEnabled, Integer mcpToolCount,
String mcpToolManifestHash, Integer sortNo,
java.util.Map<String, Object> resourceSummary) {
/** @param binding 绑定实体 @return 安全摘要 */
public static ToolBindingView from(SkillToolBinding binding) {
return new ToolBindingView(binding.getId(), binding.getToolType(), binding.getTargetId(),
binding.getHitlEnabled(), binding.getMcpToolCount(), binding.getMcpToolManifestHash(),
binding.getSortNo(), binding.getResourceSummary());
}
}
}

View File

@@ -10,8 +10,6 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.admin.model.SystemFormOptionsView;
import tech.easyflow.admin.service.system.SystemFormOptionService;
import tech.easyflow.approval.entity.vo.ApprovalAssigneeOptionVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalFlowPageVo;
@@ -38,20 +36,6 @@ public class ApprovalFlowController {
@Resource
private ApprovalAssigneeService approvalAssigneeService;
@Resource
private SystemFormOptionService systemFormOptionService;
/**
* 查询审批流程配置所需的资源范围选项。
*
* @return 非 Bot 分类和部门树
*/
@GetMapping("/resourceScopeOptions")
@SaCheckPermission("/api/v1/approvalFlow/save")
public Result<SystemFormOptionsView.ApprovalResourceScopeOptions> resourceScopeOptions() {
assertSuperAdmin();
return Result.ok(systemFormOptionService.approvalResourceScopeOptions());
}
/**
* 分页查询审批流程。
@@ -118,9 +102,6 @@ public class ApprovalFlowController {
@SaCheckPermission("/api/v1/approvalFlow/save")
public Result<BigInteger> save(@JsonBody ApprovalFlowDetailVo request) {
assertSuperAdmin();
systemFormOptionService.validateApprovalScopes(
request == null ? null : request.getResourceType(),
request == null ? null : request.getScopes());
BigInteger operatorId = SaTokenUtil.getLoginAccount().getId();
return Result.ok(approvalFlowService.saveFlow(request, operatorId));
}
@@ -135,9 +116,6 @@ public class ApprovalFlowController {
@SaCheckPermission("/api/v1/approvalFlow/save")
public Result<Void> update(@JsonBody ApprovalFlowDetailVo request) {
assertSuperAdmin();
systemFormOptionService.validateApprovalScopes(
request == null ? null : request.getResourceType(),
request == null ? null : request.getScopes());
BigInteger operatorId = SaTokenUtil.getLoginAccount().getId();
approvalFlowService.updateFlow(request, operatorId);
return Result.ok();

View File

@@ -5,13 +5,9 @@ import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryCondition;
import com.mybatisflex.core.query.QueryMethods;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -19,8 +15,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.auth.entity.EncryptedCredentialDTO;
import tech.easyflow.auth.service.AuthCredentialKeyService;
import tech.easyflow.auth.service.AuthService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumAccountType;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result;
@@ -30,33 +24,19 @@ import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.log.annotation.LogRecord;
import tech.easyflow.admin.controller.system.vo.SysAccountProfileVo;
import tech.easyflow.admin.model.SystemFormOptionsView;
import tech.easyflow.admin.service.system.SystemFormOptionService;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.entity.vo.SysAccountBatchActionResultVo;
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysRoleService;
import tech.easyflow.system.util.SysPasswordPolicy;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static tech.easyflow.system.entity.table.SysAccountRoleTableDef.SYS_ACCOUNT_ROLE;
import static tech.easyflow.system.entity.table.SysAccountTableDef.SYS_ACCOUNT;
import static tech.easyflow.system.entity.table.SysRoleTableDef.SYS_ROLE;
/**
* 用户表 控制层。
@@ -67,123 +47,25 @@ import static tech.easyflow.system.entity.table.SysRoleTableDef.SYS_ROLE;
@RestController("sysAccountController")
@RequestMapping("/api/v1/sysAccount")
public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> {
private static final String ACCOUNT_SEARCH_KEYWORD_PARAM = "keyword";
private static final String SUPER_ADMIN_HOME_PATH = "/dashboard/workspace";
private static final String USER_HOME_PATH = "/ai/agent-chat";
private final AuthCredentialKeyService credentialKeyService;
private final SysRoleService sysRoleService;
private final SystemFormOptionService systemFormOptionService;
@Resource
private AuthService authService;
/**
* 创建用户管理控制器。
*
* @param service 用户服务
* @param credentialKeyService 凭证密钥服务
* @param sysRoleService 角色服务
* @param systemFormOptionService 用户表单安全选项服务
*/
public SysAccountController(SysAccountService service,
AuthCredentialKeyService credentialKeyService,
SysRoleService sysRoleService,
SystemFormOptionService systemFormOptionService) {
public SysAccountController(SysAccountService service, AuthCredentialKeyService credentialKeyService) {
super(service);
this.credentialKeyService = credentialKeyService;
this.sysRoleService = sysRoleService;
this.systemFormOptionService = systemFormOptionService;
}
/**
* 构造账号列表查询条件。
*
* <p>统一关键字同时匹配账号、昵称、电话、邮件和已关联角色名称。角色条件使用相关
* {@code EXISTS} 子查询,避免多角色关联导致分页记录重复。</p>
*
* @param request 当前 HTTP 请求
* @return 账号列表查询条件
*/
@Override
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
String keyword = request.getParameter(ACCOUNT_SEARCH_KEYWORD_PARAM);
if (!StringUtil.hasText(keyword)) {
return super.buildQueryWrapper(request);
}
String likePattern = buildLiteralContainsPattern(normalizeSearchKeyword(keyword));
QueryWrapper roleExistsQuery = QueryMethods.selectOne()
.from(SYS_ACCOUNT_ROLE)
.innerJoin(SYS_ROLE)
.on(SYS_ACCOUNT_ROLE.ROLE_ID.eq(SYS_ROLE.ID))
.where(SYS_ACCOUNT_ROLE.ACCOUNT_ID.eq(SYS_ACCOUNT.ID))
// 关联角色必须与账号属于同一租户,避免异常关系数据跨租户命中。
.and(SYS_ROLE.TENANT_ID.eq(SYS_ACCOUNT.TENANT_ID))
.and(SYS_ROLE.ROLE_NAME.likeRaw(likePattern));
QueryCondition keywordCondition = SYS_ACCOUNT.LOGIN_NAME.likeRaw(likePattern)
.or(SYS_ACCOUNT.NICKNAME.likeRaw(likePattern))
.or(SYS_ACCOUNT.MOBILE.likeRaw(likePattern))
.or(SYS_ACCOUNT.EMAIL.likeRaw(likePattern))
.or(QueryMethods.exists(roleExistsQuery));
return super.buildQueryWrapper(request).and(keywordCondition);
}
@Override
@LogRecord("分页查询")
protected Page<SysAccount> queryPage(Page<SysAccount> page, QueryWrapper queryWrapper) {
Page<SysAccount> result = service.getMapper().paginateWithRelations(page, queryWrapper);
fillRoleNames(result.getRecords());
return result;
}
/**
* 按当前分页内的角色 ID 批量补全角色名称。
*
* @param accounts 当前页账号
*/
private void fillRoleNames(List<SysAccount> accounts) {
if (accounts == null || accounts.isEmpty()) {
return;
}
Set<BigInteger> roleIds = accounts.stream()
.map(SysAccount::getRoleIds)
.filter(java.util.Objects::nonNull)
.flatMap(Collection::stream)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (roleIds.isEmpty()) {
accounts.forEach(account -> account.setRoleNames(List.of()));
return;
}
Map<BigInteger, String> roleNameMap = sysRoleService.listByIds(roleIds).stream()
.filter(role -> role.getId() != null && StringUtil.hasText(role.getRoleName()))
.collect(Collectors.toMap(
SysRole::getId,
SysRole::getRoleName,
(first, ignored) -> first
));
accounts.forEach(account -> {
List<BigInteger> accountRoleIds = account.getRoleIds();
if (accountRoleIds == null || accountRoleIds.isEmpty()) {
account.setRoleNames(List.of());
return;
}
List<String> roleNames = accountRoleIds.stream()
.map(roleNameMap::get)
.filter(StringUtil::hasText)
.distinct()
.collect(Collectors.toList());
account.setRoleNames(roleNames);
});
return service.getMapper().paginateWithRelations(page, queryWrapper);
}
@Override
protected Result onSaveOrUpdateBefore(SysAccount entity, boolean isSave) {
systemFormOptionService.validateAccountReferences(entity);
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
BigInteger tenantId = loginUser.getTenantId();
if (isSave) {
commonFiled(entity, loginUser.getId(), tenantId, loginUser.getDeptId());
// 查询用户名是否存在
// long count = Db.selectCount(SqlPrepare.COUNT_ACCOUNT_BY_UNI_KEY, entity.getLoginName(), tenantId);
QueryWrapper w = QueryWrapper.create();
@@ -192,10 +74,6 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
if (count > 0) {
return Result.fail(1, "用户名已存在");
}
Result<?> roleValidation = validateCreateRoles(entity);
if (roleValidation != null) {
return roleValidation;
}
String password = decryptInitialPassword(entity.getPasswordCredential());
if (!StringUtil.hasText(password)) {
return Result.fail(1, "密码不能为空");
@@ -227,34 +105,6 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return null;
}
/**
* 查询账号表单所需的部门、角色和岗位选项。
*
* @return 账号表单安全选项
*/
@GetMapping("/formOptions")
@SaCheckPermission("/api/v1/sysAccount/save")
public Result<SystemFormOptionsView.AccountFormOptions> formOptions() {
return Result.ok(systemFormOptionService.accountFormOptions());
}
/**
* 填充账号创建的公共字段。
*
* <p>账号部门由管理端表单指定;只有未提交部门时才沿用通用创建流程的默认部门。</p>
*
* @param entity 待创建的账号
* @param loginAccount 当前登录账号
*/
@Override
protected void fillCreateCommonFields(SysAccount entity, LoginAccount loginAccount) {
BigInteger selectedDeptId = entity.getDeptId();
super.fillCreateCommonFields(entity, loginAccount);
if (selectedDeptId != null) {
entity.setDeptId(selectedDeptId);
}
}
@Override
protected void onSaveOrUpdateAfter(SysAccount entity, boolean isSave) {
service.syncRelations(entity);
@@ -275,24 +125,11 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return super.onRemoveBefore(ids);
}
/**
* 获取当前账号资料、角色标识与默认首页。
*
* @return 当前账号资料视图
*/
@GetMapping("/myProfile")
public Result<SysAccountProfileVo> myProfile() {
public Result<SysAccount> myProfile() {
LoginAccount account = SaTokenUtil.getLoginAccount();
SysAccount sysAccount = service.getById(account.getId());
List<String> roles = sysRoleService.getRolesByAccountId(account.getId()).stream()
.map(SysRole::getRoleKey)
.filter(StringUtil::hasText)
.distinct()
.collect(Collectors.toList());
String homePath = roles.contains(Constants.SUPER_ADMIN_ROLE_CODE)
? SUPER_ADMIN_HOME_PATH
: USER_HOME_PATH;
return Result.ok(SysAccountProfileVo.from(sysAccount, roles, homePath));
return Result.ok(sysAccount);
}
@PostMapping("/updateProfile")
@@ -313,8 +150,7 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
/**
* 修改密码,用于修改用户自己的密码
*
* @param encryptedCredential 加密后的当前密码、新密码与确认密码
* @return 密码修改结果
* @param encryptedCredential 加密后的密码、新密码与确认密码
*/
@PostMapping("/updatePassword")
public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) {
@@ -323,13 +159,25 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
String newPassword = payload.getString("newPassword");
String confirmPassword = payload.getString("confirmPassword");
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
authService.updateOwnPassword(
loginAccountId,
password,
newPassword,
confirmPassword,
StpUtil.getLoginDevice()
);
SysAccount record = service.getById(loginAccountId);
if (record == null) {
return Result.fail("修改失败");
}
String pwdDb = record.getPassword();
if (!BCrypt.checkpw(password, pwdDb)) {
return Result.fail(1, "密码不正确");
}
if (!newPassword.equals(confirmPassword)) {
return Result.fail(2, "两次密码不一致");
}
SysPasswordPolicy.validateStrongPassword(newPassword);
SysAccount update = new SysAccount();
update.setId(loginAccountId);
update.setPassword(BCrypt.hashpw(newPassword));
update.setPasswordResetRequired(false);
update.setModified(new Date());
update.setModifiedBy(loginAccountId);
service.updateById(update);
return Result.ok();
}
@@ -355,17 +203,11 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return value == null ? null : String.valueOf(value);
}
/**
* 将指定账号密码重置为系统默认强密码。
*
* @param id 账号 ID
* @return 本次重置后使用的明文密码
*/
@PostMapping("/resetPassword")
@SaCheckPermission("/api/v1/sysAccount/save")
public Result<String> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
String password = service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
return Result.ok(password);
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
return Result.ok();
}
@PostMapping("/removeBatchWithResult")
@@ -406,12 +248,8 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
service.writeImportTemplate(response.getOutputStream());
}
/**
* {@inheritDoc}
*/
@Override
@PostMapping("save")
@Transactional(rollbackFor = Exception.class)
public Result<?> save(@JsonBody SysAccount entity) {
try {
return super.save(entity);
@@ -419,33 +257,4 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return Result.fail(1, "用户名已存在");
}
}
/**
* 校验创建账号时提交的角色,并归一化角色 ID。
*
* @param entity 待创建账号
* @return 校验失败结果,校验通过返回 null
*/
private Result<?> validateCreateRoles(SysAccount entity) {
List<BigInteger> roleIds = entity == null ? null : entity.getRoleIds();
Set<BigInteger> uniqueRoleIds = new LinkedHashSet<>();
if (roleIds != null) {
roleIds.stream()
.filter(java.util.Objects::nonNull)
.forEach(uniqueRoleIds::add);
}
if (uniqueRoleIds.isEmpty()) {
return Result.fail(1, "角色不能为空");
}
List<SysRole> roles = sysRoleService.listByIds(uniqueRoleIds);
boolean valid = roles.size() == uniqueRoleIds.size()
&& roles.stream().allMatch(role ->
EnumDataStatus.AVAILABLE.getCode().equals(role.getStatus()));
if (!valid) {
return Result.fail(1, "角色不存在或已禁用");
}
entity.setRoleIds(new ArrayList<>(uniqueRoleIds));
return null;
}
}

View File

@@ -6,21 +6,18 @@ import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.table.TableInfo;
import com.mybatisflex.core.table.TableInfoFactory;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.WorkflowApiPermissionService;
import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.IdUtil;
import tech.easyflow.common.vo.PkVo;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.entity.SysApiKeyResourceMapping;
import tech.easyflow.system.service.SysApiKeyResourceMappingService;
@@ -32,7 +29,6 @@ import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Set;
/**
* 控制层。
@@ -43,31 +39,10 @@ import java.util.Set;
@RestController
@RequestMapping("/api/v1/sysApiKey")
public class SysApiKeyController extends BaseCurdController<SysApiKeyService, SysApiKey> {
/**
* 访问令牌名称最大长度。
*/
private static final int API_KEY_NAME_MAX_LENGTH = 100;
/**
* 兼容旧客户端时使用的默认名称前缀。
*/
private static final String DEFAULT_API_KEY_NAME_PREFIX = "访问令牌-";
public SysApiKeyController(SysApiKeyService service) {
super(service);
}
/**
* 获取 API Key 列表关键字搜索字段。
*
* @return 名称和 Key 属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"name", "apiKey"};
}
@Resource
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
@Resource
@@ -77,19 +52,13 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
/**
* 添加(保存)数据
*
* @param name 访问令牌名称
* @return {@code Result.errorCode == 0} 添加成功,否则添加失败
*/
@PostMapping("/key/save")
@SaCheckPermission("/api/v1/sysApiKey/save")
public Result<PkVo> save(@JsonBody(value = "name", required = false) String name) {
public Result<PkVo> save() {
String apiKey = IdUtil.generateUUID();
String normalizedName = normalizeCreateName(name, apiKey);
if (normalizedName.length() > API_KEY_NAME_MAX_LENGTH) {
return Result.fail("访问令牌名称不能超过100个字符", null);
}
SysApiKey entity = new SysApiKey();
entity.setName(normalizedName);
entity.setApiKey(apiKey);
entity.setCreated(new Date());
entity.setStatus(1);
@@ -114,61 +83,12 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
return Result.ok(new PkVo(pkArgs));
}
/**
* 更新访问令牌基础信息与授权。
*
* <p>权限开关不映射数据库列,权限更新请求可能只包含主键与权限字段。
* 此时跳过主表更新,避免 MyBatis-Flex 生成空的 {@code SET} 子句。</p>
*
* @param entity 待更新的访问令牌
* @return 更新结果
*/
@Override
@PostMapping("/update")
@Transactional(rollbackFor = Exception.class)
public Result<?> update(@JsonBody SysApiKey entity) {
if (entity == null || entity.getId() == null) {
return Result.fail("访问令牌 ID 不能为空");
}
Result<?> nameValidationResult = normalizeAndValidateUpdateName(entity);
if (nameValidationResult != null) {
return nameValidationResult;
}
if (!hasPersistentUpdateFields(entity) && !hasPermissionUpdateFields(entity)) {
return Result.fail("没有可更新的访问令牌字段");
}
if (hasNewKnowledgePermissionFields(entity)
&& !hasCompleteKnowledgePermissionFields(entity)) {
return Result.fail("知识库读取、导入、维护权限必须同时提交");
}
if (service.getById(entity.getId()) == null) {
return Result.fail("访问令牌不存在");
}
Result<?> beforeResult = onSaveOrUpdateBefore(entity, false);
if (beforeResult != null) {
return beforeResult;
}
if (hasPersistentUpdateFields(entity)) {
service.updateById(entity);
}
onSaveOrUpdateAfter(entity, false);
return Result.ok();
}
@Override
protected void onSaveOrUpdateAfter(SysApiKey entity, boolean isSave) {
if (entity.getPermissionIds() != null) {
sysApiKeyResourceMappingService.authInterface(entity);
}
if (hasNewKnowledgePermissionFields(entity)) {
knowledgeSharePermissionService.replaceApiPermissions(
entity.getId(),
Boolean.TRUE.equals(entity.getKnowledgeReadEnabled()),
Boolean.TRUE.equals(entity.getKnowledgeImportEnabled()),
Boolean.TRUE.equals(entity.getKnowledgeMaintenanceEnabled())
);
} else if (entity.getKnowledgeShareEnabled() != null) {
// 兼容旧客户端:开启旧总开关只授予读取和导入,维护权限保持关闭。
if (entity.getKnowledgeShareEnabled() != null) {
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
}
if (entity.getWorkflowApiEnabled() != null) {
@@ -210,18 +130,11 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class);
entity.setPermissionIds(resourceIds);
Set<String> knowledgeScopes =
knowledgeSharePermissionService.getApiPermissionScopes(entity.getId());
boolean readEnabled =
knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
boolean importEnabled =
knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
boolean maintenanceEnabled =
knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
entity.setKnowledgeReadEnabled(readEnabled);
entity.setKnowledgeImportEnabled(importEnabled);
entity.setKnowledgeMaintenanceEnabled(maintenanceEnabled);
entity.setKnowledgeShareEnabled(readEnabled || importEnabled || maintenanceEnabled);
QueryWrapper knowledgeWrapper = QueryWrapper.create()
.select(SysApiKeyResourceMapping::getId)
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0);
QueryWrapper workflowWrapper = QueryWrapper.create()
.select(SysApiKeyResourceMapping::getId)
@@ -229,96 +142,4 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
.eq(SysApiKeyResourceMapping::getResourceType, WorkflowApiPermissionService.RESOURCE_TYPE_WORKFLOW);
entity.setWorkflowApiEnabled(sysApiKeyResourceMappingService.count(workflowWrapper) > 0);
}
/**
* 判断请求是否提交了任一新版知识库权限字段。
*
* @param entity 访问令牌
* @return 是否提交新版字段
*/
private boolean hasNewKnowledgePermissionFields(SysApiKey entity) {
return entity.getKnowledgeReadEnabled() != null
|| entity.getKnowledgeImportEnabled() != null
|| entity.getKnowledgeMaintenanceEnabled() != null;
}
/**
* 判断请求是否完整提交三个新版知识库权限字段。
*
* @param entity 访问令牌
* @return 三个字段是否均已提交
*/
private boolean hasCompleteKnowledgePermissionFields(SysApiKey entity) {
return entity.getKnowledgeReadEnabled() != null
&& entity.getKnowledgeImportEnabled() != null
&& entity.getKnowledgeMaintenanceEnabled() != null;
}
/**
* 判断请求是否包含主表可持久化字段。
*
* @param entity 访问令牌
* @return 是否需要更新访问令牌主表
*/
private boolean hasPersistentUpdateFields(SysApiKey entity) {
return entity.getName() != null
|| entity.getApiKey() != null
|| entity.getCreated() != null
|| entity.getStatus() != null
|| entity.getDeptId() != null
|| entity.getTenantId() != null
|| entity.getExpiredAt() != null
|| entity.getCreatedBy() != null;
}
/**
* 判断请求是否包含任一非主表权限字段。
*
* @param entity 访问令牌
* @return 是否需要更新权限映射
*/
private boolean hasPermissionUpdateFields(SysApiKey entity) {
return entity.getPermissionIds() != null
|| entity.getKnowledgeShareEnabled() != null
|| hasNewKnowledgePermissionFields(entity)
|| entity.getWorkflowApiEnabled() != null;
}
/**
* 标准化新建访问令牌的名称。
*
* <p>未传名称时生成可识别的兼容名称,避免旧客户端在升级期间创建空名称记录。</p>
*
* @param name 客户端提交的名称
* @param apiKey 新生成的访问令牌
* @return 标准化后的名称
*/
private String normalizeCreateName(String name, String apiKey) {
if (name != null && !name.trim().isEmpty()) {
return name.trim();
}
int suffixStart = Math.max(0, apiKey.length() - 6);
return DEFAULT_API_KEY_NAME_PREFIX + apiKey.substring(suffixStart);
}
/**
* 标准化并校验更新请求中的访问令牌名称。
*
* @param entity 待更新的访问令牌
* @return 校验失败结果;无需校验或校验成功时返回 {@code null}
*/
private Result<?> normalizeAndValidateUpdateName(SysApiKey entity) {
if (entity.getName() == null) {
return null;
}
String normalizedName = entity.getName().trim();
if (normalizedName.isEmpty()) {
return Result.fail("访问令牌名称不能为空");
}
if (normalizedName.length() > API_KEY_NAME_MAX_LENGTH) {
return Result.fail("访问令牌名称不能超过100个字符");
}
entity.setName(normalizedName);
return null;
}
}

View File

@@ -26,16 +26,6 @@ public class SysApiKeyResourceController extends BaseCurdController<SysApiKeyRes
super(service);
}
/**
* 获取接口授权资源关键字搜索字段。
*
* @return 请求接口和标题属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"requestInterface", "title"};
}
/**
* 查询普通 API Key 接口授权资源。
*

View File

@@ -1,39 +1,27 @@
package tech.easyflow.admin.controller.system;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.approval.entity.ApprovalFlowScope;
import tech.easyflow.approval.entity.ApprovalFlowStepAssignee;
import tech.easyflow.approval.enums.ApprovalAssigneeType;
import tech.easyflow.approval.enums.ApprovalScopeType;
import tech.easyflow.approval.mapper.ApprovalFlowScopeMapper;
import tech.easyflow.approval.mapper.ApprovalFlowStepAssigneeMapper;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.tree.Tree;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 部门表 控制层。
@@ -45,154 +33,30 @@ import java.util.Set;
@RequestMapping("/api/v1/sysDept")
public class SysDeptController extends BaseCurdController<SysDeptService, SysDept> {
private final SysAccountService sysAccountService;
private final ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper;
private final ApprovalFlowScopeMapper approvalFlowScopeMapper;
@Resource
private SysAccountService sysAccountService;
/**
* 创建部门管理控制器。
*
* @param service 部门服务
* @param sysAccountService 用户服务
* @param approvalFlowStepAssigneeMapper 审批步骤对象 Mapper
* @param approvalFlowScopeMapper 审批范围 Mapper
*/
public SysDeptController(SysDeptService service,
SysAccountService sysAccountService,
ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper,
ApprovalFlowScopeMapper approvalFlowScopeMapper) {
public SysDeptController(SysDeptService service) {
super(service);
this.sysAccountService = sysAccountService;
this.approvalFlowStepAssigneeMapper = approvalFlowStepAssigneeMapper;
this.approvalFlowScopeMapper = approvalFlowScopeMapper;
}
/**
* 获取部门列表默认排序规则。
*
* @return 默认排序表达式
*/
@Override
protected String getDefaultOrderBy() {
return "sort_no asc";
}
/**
* 查询部门列表并组装为树形结构。
*
* @param entity 查询条件
* @param asTree 是否返回树形结构
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 部门树
*/
@Override
@GetMapping("list")
public Result<List<SysDept>> list(SysDept entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
String keyword = entity == null ? "" : normalizeSearchKeyword(entity.getKeyword());
if (tech.easyflow.common.util.StringUtil.hasText(keyword)) {
queryWrapper.and(buildLiteralContainsCondition(
keyword, new QueryColumn("dept_name"), new QueryColumn("dept_code")));
}
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<SysDept> matchedDepartments = service.list(queryWrapper);
if (!tech.easyflow.common.util.StringUtil.hasText(keyword)) {
return Result.ok(Tree.tryToTree(matchedDepartments, "id", "parentId"));
}
if (matchedDepartments.isEmpty()) {
return Result.ok(List.of());
List<SysDept> sysMenus = service.list(queryWrapper);
return Result.ok(Tree.tryToTree(sysMenus, "id", "parentId"));
}
// 搜索结果保留所有重名命中项,并补齐各自祖先节点以维持可定位的树结构。
Set<BigInteger> visibleIds = new LinkedHashSet<>();
for (SysDept department : matchedDepartments) {
visibleIds.add(department.getId());
addAncestorIds(visibleIds, department.getAncestors());
}
QueryWrapper visibleDepartmentQuery = QueryWrapper.create()
.in(SysDept::getId, visibleIds)
.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
List<SysDept> visibleDepartments = service.list(visibleDepartmentQuery);
return Result.ok(Tree.tryToTree(visibleDepartments, "id", "parentId"));
}
/**
* 将逗号分隔的祖先 ID 加入可见集合。
*
* @param visibleIds 可见部门 ID 集合
* @param ancestors 祖先路径
*/
private void addAncestorIds(Set<BigInteger> visibleIds, String ancestors) {
if (!tech.easyflow.common.util.StringUtil.hasText(ancestors)) {
return;
}
for (String ancestor : ancestors.split(",")) {
String normalized = ancestor.trim();
if (!normalized.isEmpty() && !"0".equals(normalized)) {
visibleIds.add(new BigInteger(normalized));
}
}
}
/**
* 批量修改部门状态。
*
* @param ids 部门主键集合
* @param status 目标状态
* @return 实际更新数量
*/
@PostMapping("changeStatusBatch")
@SaCheckPermission("/api/v1/sysDept/save")
@Transactional(rollbackFor = Exception.class)
public Result<Integer> changeStatusBatch(
@JsonBody(value = "ids", required = true) List<BigInteger> ids,
@JsonBody(value = "status", required = true) Integer status) {
Set<BigInteger> uniqueIds = normalizeIds(ids);
if (uniqueIds.isEmpty()) {
return Result.fail("请选择需要操作的部门", null);
}
if (!EnumDataStatus.AVAILABLE.getCode().equals(status)
&& !EnumDataStatus.UNAVAILABLE.getCode().equals(status)) {
return Result.fail("部门状态不合法", null);
}
List<SysDept> records = service.listByIds(uniqueIds);
if (records.size() != uniqueIds.size()) {
return Result.fail("部分部门不存在或已删除,请刷新后重试", null);
}
if (EnumDataStatus.UNAVAILABLE.getCode().equals(status) && containsRootDept(records)) {
return Result.fail("根部门不能禁用", null);
}
if (EnumDataStatus.UNAVAILABLE.getCode().equals(status)
&& isUsedByApprovalFlow(uniqueIds)) {
return Result.fail("所选部门已被审批流程使用,不能禁用", null);
}
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
SysDept update = new SysDept();
update.setStatus(status);
update.setModified(new Date());
update.setModifiedBy(loginUser.getId());
QueryWrapper updateWrapper = QueryWrapper.create();
updateWrapper.in(SysDept::getId, uniqueIds);
int updated = service.getMapper().updateByQuery(update, updateWrapper);
if (updated <= 0) {
return Result.fail("部门状态修改失败", null);
}
return Result.ok(updated);
}
/**
* {@inheritDoc}
*/
@Override
protected Result onSaveOrUpdateBefore(SysDept entity, boolean isSave) {
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
if (isSave && entity.getStatus() == null) {
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
}
BigInteger parentId = entity.getParentId();
if (parentId.equals(BigInteger.ZERO)) {
entity.setAncestors(parentId.toString());
@@ -201,7 +65,7 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
entity.setAncestors(parent.getAncestors() + "," + parentId);
}
if (isSave) {
commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
} else {
entity.setModified(new Date());
entity.setModifiedBy(loginUser.getId());
@@ -209,87 +73,20 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
return null;
}
/**
* {@inheritDoc}
*/
@Override
protected Result onRemoveBefore(Collection<Serializable> ids) {
List<SysDept> records = service.listByIds(ids);
if (records.size() != ids.size()) {
return Result.fail(1, "部分部门不存在或已删除,请刷新后重试");
}
if (containsRootDept(records)) {
for (SysDept dept : records) {
if (Constants.ROOT_DEPT.equals(dept.getDeptCode())) {
return Result.fail(1, "无法删除根部门");
}
QueryWrapper childQuery = QueryWrapper.create();
childQuery.in(SysDept::getParentId, ids);
childQuery.notIn(SysDept::getId, ids);
if (service.count(childQuery) > 0) {
return Result.fail(1, "所选部门包含未选中的下级部门,不能删除");
}
if (isUsedByApprovalFlow(ids)) {
return Result.fail(1, "所选部门已被审批流程使用,请先调整审批配置");
}
QueryWrapper accountQuery = QueryWrapper.create();
accountQuery.in(SysAccount::getDeptId, ids);
long count = sysAccountService.count(accountQuery);
QueryWrapper w = QueryWrapper.create();
w.in(SysAccount::getDeptId, ids);
long count = sysAccountService.count(w);
if (count > 0) {
return Result.fail(1, "所选部门下有员工,不能删除");
return Result.fail(1, "部门下有员工,不能删除");
}
return super.onRemoveBefore(ids);
}
/**
* 去重并过滤无效部门主键。
*
* @param ids 原始部门主键集合
* @return 有效且去重后的主键集合
*/
private Set<BigInteger> normalizeIds(Collection<BigInteger> ids) {
Set<BigInteger> uniqueIds = new LinkedHashSet<>();
if (ids == null) {
return uniqueIds;
}
for (BigInteger id : ids) {
if (id != null) {
uniqueIds.add(id);
}
}
return uniqueIds;
}
/**
* 判断部门集合中是否包含根部门。
*
* @param records 部门集合
* @return 包含根部门时返回 {@code true}
*/
private boolean containsRootDept(Collection<SysDept> records) {
return records.stream()
.anyMatch(dept -> Constants.ROOT_DEPT.equals(dept.getDeptCode()));
}
/**
* 判断部门是否仍被审批步骤或审批范围引用。
*
* @param ids 部门主键集合
* @return 存在审批流程引用时返回 {@code true}
*/
private boolean isUsedByApprovalFlow(Collection<? extends Serializable> ids) {
QueryWrapper assigneeQuery = QueryWrapper.create();
assigneeQuery.eq(
ApprovalFlowStepAssignee::getAssigneeType,
ApprovalAssigneeType.DEPT.getCode());
assigneeQuery.in(ApprovalFlowStepAssignee::getTargetId, ids);
if (approvalFlowStepAssigneeMapper.selectCountByQuery(assigneeQuery) > 0) {
return true;
}
QueryWrapper scopeQuery = QueryWrapper.create();
scopeQuery.eq(ApprovalFlowScope::getScopeType, ApprovalScopeType.DEPT.getCode());
scopeQuery.in(ApprovalFlowScope::getScopeValue, ids);
return approvalFlowScopeMapper.selectCountByQuery(scopeQuery) > 0;
}
}

View File

@@ -1,31 +1,16 @@
package tech.easyflow.admin.controller.system;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryCondition;
import com.mybatisflex.core.query.QueryMethods;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.relation.RelationManager;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.util.StringUtil;
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.log.annotation.LogRecord;
import tech.easyflow.system.entity.SysLog;
import tech.easyflow.system.service.SysLogService;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.relation.RelationManager;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.Date;
import static tech.easyflow.system.entity.table.SysAccountTableDef.SYS_ACCOUNT;
import static tech.easyflow.system.entity.table.SysLogTableDef.SYS_LOG;
/**
* 操作日志表 控制层。
@@ -36,102 +21,14 @@ import static tech.easyflow.system.entity.table.SysLogTableDef.SYS_LOG;
@RestController
@RequestMapping("/api/v1/sysLog")
public class SysLogController extends BaseCurdController<SysLogService, SysLog> {
private static final long MAX_PAGE_SIZE = 100L;
private static final DateTimeFormatter QUERY_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 创建操作日志控制器。
*
* @param service 操作日志服务
*/
public SysLogController(SysLogService service) {
super(service);
}
/**
* 构造通用查询条件,并追加操作时间范围。
*
* @param request 当前 HTTP 请求
* @return 操作日志查询条件
* @throws BusinessException 时间格式不正确或开始时间晚于结束时间时抛出
*/
@Override
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
QueryWrapper queryWrapper = super.buildQueryWrapper(request);
String keyword = normalizeSearchKeyword(request.getParameter("keyword"));
if (StringUtil.hasText(keyword)) {
LoginAccount account = SaTokenUtil.getLoginAccount();
String pattern = buildLiteralContainsPattern(keyword);
QueryCondition accountKeyword = SYS_ACCOUNT.LOGIN_NAME.likeRaw(pattern)
.or(SYS_ACCOUNT.NICKNAME.likeRaw(pattern));
QueryWrapper accountExists = QueryMethods.selectOne()
.from(SYS_ACCOUNT)
.where(SYS_ACCOUNT.ID.eq(SYS_LOG.ACCOUNT_ID))
.and(SYS_ACCOUNT.TENANT_ID.eq(account.getTenantId()))
.and(accountKeyword);
QueryCondition keywordCondition = SYS_LOG.ACTION_NAME.likeRaw(pattern)
.or(SYS_LOG.ACTION_IP.likeRaw(pattern))
.or(QueryMethods.exists(accountExists));
queryWrapper.and(keywordCondition);
}
Date createdStart = parseQueryTime(request.getParameter("createdStart"));
Date createdEnd = parseQueryTime(request.getParameter("createdEnd"));
if (createdStart != null && createdEnd != null && createdStart.after(createdEnd)) {
throw new BusinessException(400, 400, "操作时间范围不正确");
}
if (createdStart != null) {
queryWrapper.ge(SysLog::getCreated, createdStart);
}
if (createdEnd != null) {
queryWrapper.le(SysLog::getCreated, createdEnd);
}
return queryWrapper;
}
/**
* 按操作时间稳定倒序展示最新日志。
*
* @return 默认排序表达式
*/
@Override
protected String getDefaultOrderBy() {
return "created desc, id desc";
}
/**
* 限制日志单页记录数并加载操作账号关系。
*
* @param page 分页参数
* @param queryWrapper 查询条件
* @return 操作日志分页结果
*/
@Override
@LogRecord("分页查询")
protected Page<SysLog> queryPage(Page<SysLog> page, QueryWrapper queryWrapper) {
page.setPageSize(Math.min(page.getPageSize(), MAX_PAGE_SIZE));
RelationManager.setQueryRelations(Collections.singleton("account"));
return service.getMapper().paginateWithRelations(page, queryWrapper);
}
/**
* 解析日志查询时间。
*
* @param value 格式为 yyyy-MM-dd HH:mm:ss 的时间文本
* @return 解析后的时间;空文本返回 {@code null}
* @throws BusinessException 时间格式不正确时抛出
*/
private Date parseQueryTime(String value) {
if (!StringUtil.hasText(value)) {
return null;
}
try {
LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER);
return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
} catch (DateTimeParseException exception) {
throw new BusinessException(
400, 400, "操作时间格式不正确", exception);
}
}
}

View File

@@ -41,7 +41,7 @@ public class SysPositionController extends BaseCurdController<SysPositionService
/**
* 分页查询岗位列表
* <p>
* 支持按岗位名称、岗位编码统一模糊查询,状态保持精确查询。
* 支持按岗位名称模糊查询,状态、编码精确查询。
* </p>
*
* @param request 请求对象
@@ -67,23 +67,18 @@ public class SysPositionController extends BaseCurdController<SysPositionService
.from(SYS_POSITION);
// 获取查询参数
String keyword = normalizeSearchKeyword(request.getParameter("keyword"));
String positionName = request.getParameter("positionName");
String positionCode = request.getParameter("positionCode");
String status = request.getParameter("status");
if (StringUtil.hasText(keyword)) {
queryWrapper.and(buildLiteralContainsCondition(
keyword, SYS_POSITION.POSITION_NAME, SYS_POSITION.POSITION_CODE));
} else {
// 兼容仍按旧参数调用的客户端。
// 岗位名称 - 模糊查询
if (StringUtil.hasText(positionName)) {
queryWrapper.and(buildLiteralContainsCondition(positionName, SYS_POSITION.POSITION_NAME));
queryWrapper.where(SYS_POSITION.POSITION_NAME.like(positionName));
}
// 岗位编码 - 精确查询
if (StringUtil.hasText(positionCode)) {
queryWrapper.where(SYS_POSITION.POSITION_CODE.eq(positionCode));
}
}
// 状态 - 精确查询
if (StringUtil.hasText(status)) {
queryWrapper.where(SYS_POSITION.STATUS.eq(status));

View File

@@ -9,7 +9,6 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.admin.service.system.SystemFormOptionService;
import tech.easyflow.system.entity.vo.SysRoleCategoryScopeDetailVo;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysRoleCategoryScopeService;
@@ -26,8 +25,6 @@ public class SysRoleCategoryScopeController {
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
private SystemFormOptionService systemFormOptionService;
@GetMapping("/detail")
@SaCheckPermission("/api/v1/sysRole/query")
@@ -44,7 +41,6 @@ public class SysRoleCategoryScopeController {
if (request == null || request.getRoleId() == null) {
throw new BusinessException("角色ID不能为空");
}
systemFormOptionService.validateCategoryScopes(request.getScopes());
BigInteger operatorId = SaTokenUtil.getLoginAccount().getId();
sysRoleCategoryScopeService.saveRoleScopes(request.getRoleId(), request.getScopes(), operatorId);
return Result.ok();

View File

@@ -9,8 +9,6 @@ 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.jsonbody.JsonBody;
import tech.easyflow.admin.model.SystemFormOptionsView;
import tech.easyflow.admin.service.system.SystemFormOptionService;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.entity.SysRoleDept;
import tech.easyflow.system.entity.SysRoleMenu;
@@ -40,34 +38,11 @@ public class SysRoleController extends BaseCurdController<SysRoleService, SysRol
private SysRoleMenuService sysRoleMenuService;
@Resource
private SysRoleDeptService sysRoleDeptService;
@Resource
private SystemFormOptionService systemFormOptionService;
public SysRoleController(SysRoleService service) {
super(service);
}
/**
* 获取角色列表关键字搜索字段。
*
* @return 角色名称和角色标识属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"roleName", "roleKey"};
}
/**
* 查询角色表单所需的菜单和非 Bot 分类选项。
*
* @return 角色表单安全选项
*/
@GetMapping("formOptions")
@SaCheckPermission("/api/v1/sysRole/query")
public Result<SystemFormOptionsView.RoleFormOptions> formOptions() {
return Result.ok(systemFormOptionService.roleFormOptions());
}
@PostMapping("saveRoleMenu/{roleId}")
@SaCheckPermission("/api/v1/sysRole/save")
@Deprecated
@@ -110,7 +85,6 @@ public class SysRoleController extends BaseCurdController<SysRoleService, SysRol
if (entity.getId() == null) {
commonFiled(entity, loginUser.getId(), loginUser.getTenantId(), loginUser.getDeptId());
}
systemFormOptionService.validateRoleReferences(entity);
service.saveRole(entity);
return Result.ok(entity.getId());
}

View File

@@ -24,16 +24,6 @@ public class SysUserFeedbackController extends BaseCurdController<SysUserFeedbac
super(service);
}
/**
* 获取用户反馈关键字搜索字段。
*
* @return 反馈内容和联系方式属性
*/
@Override
protected String[] getKeywordSearchProperties() {
return new String[]{"feedbackContent", "contactInfo"};
}
@Override
protected Result<?> onSaveOrUpdateBefore(SysUserFeedback entity, boolean isSave) {
if (!isSave) {

View File

@@ -1,76 +0,0 @@
package tech.easyflow.admin.controller.system.vo;
import cn.hutool.core.bean.BeanUtil;
import tech.easyflow.system.entity.SysAccount;
import java.util.ArrayList;
import java.util.List;
/**
* 管理端当前账号资料视图。
*/
public class SysAccountProfileVo extends SysAccount {
private String homePath;
private List<String> roles = new ArrayList<>();
/**
* 创建空的当前账号资料视图。
*/
public SysAccountProfileVo() {
}
/**
* 根据账号实体和角色信息创建资料视图。
*
* @param account 账号实体
* @param roles 角色标识列表
* @param homePath 默认首页
* @return 当前账号资料视图
*/
public static SysAccountProfileVo from(SysAccount account, List<String> roles, String homePath) {
SysAccountProfileVo profile = new SysAccountProfileVo();
if (account != null) {
BeanUtil.copyProperties(account, profile);
}
profile.setRoles(roles);
profile.setHomePath(homePath);
return profile;
}
/**
* 获取默认首页。
*
* @return 默认首页
*/
public String getHomePath() {
return homePath;
}
/**
* 设置默认首页。
*
* @param homePath 默认首页
*/
public void setHomePath(String homePath) {
this.homePath = homePath;
}
/**
* 获取角色标识列表。
*
* @return 角色标识列表
*/
public List<String> getRoles() {
return roles;
}
/**
* 设置角色标识列表。
*
* @param roles 角色标识列表
*/
public void setRoles(List<String> roles) {
this.roles = roles == null ? new ArrayList<>() : new ArrayList<>(roles);
}
}

View File

@@ -1,20 +0,0 @@
package tech.easyflow.admin.model;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.math.BigInteger;
/**
* 定时任务可运行的工作流安全选项。
*
* @param id 工作流 ID
* @param title 工作流标题
* @param description 工作流描述
*/
public record SysJobWorkflowOptionView(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String title,
String description
) {
}

View File

@@ -1,123 +0,0 @@
package tech.easyflow.admin.model;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* 管理端系统表单所需的安全选项视图。
*/
public final class SystemFormOptionsView {
private SystemFormOptionsView() {
}
/**
* 审批流程资源范围选项。
*
* @param categories 按资源类型分组的分类选项
* @param departments 部门树
*/
public record ApprovalResourceScopeOptions(
Map<String, List<CategoryOption>> categories,
List<DepartmentOption> departments
) {
}
/**
* 角色表单选项。
*
* @param menus 菜单树
* @param categories 按资源类型分组的非 Bot 分类选项
*/
public record RoleFormOptions(
List<MenuOption> menus,
Map<String, List<CategoryOption>> categories
) {
}
/**
* 账号表单选项。
*
* @param departments 部门树
* @param roles 可用角色
* @param positions 可用岗位
*/
public record AccountFormOptions(
List<DepartmentOption> departments,
List<RoleOption> roles,
List<PositionOption> positions
) {
}
/**
* 分类安全选项。
*
* @param id 分类 ID
* @param categoryName 分类名称
*/
public record CategoryOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String categoryName
) {
}
/**
* 部门树安全选项。
*
* @param id 部门 ID
* @param parentId 上级部门 ID
* @param deptName 部门名称
* @param children 下级部门
*/
public record DepartmentOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger parentId,
String deptName,
List<DepartmentOption> children
) {
}
/**
* 菜单树安全选项。
*
* @param id 菜单 ID
* @param parentId 上级菜单 ID
* @param menuTitle 菜单标题
* @param children 下级菜单
*/
public record MenuOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger parentId,
String menuTitle,
List<MenuOption> children
) {
}
/**
* 角色安全选项。
*
* @param id 角色 ID
* @param roleName 角色名称
*/
public record RoleOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String roleName
) {
}
/**
* 岗位安全选项。
*
* @param id 岗位 ID
* @param positionName 岗位名称
*/
public record PositionOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String positionName
) {
}
}

View File

@@ -1,9 +0,0 @@
package tech.easyflow.admin.model.ai;
/**
* 模型统一网关页面所需的安全配置。
*
* @param publishBaseUrl 模型发布基础地址
*/
public record ModelGatewayConfigView(String publishBaseUrl) {
}

View File

@@ -1,180 +0,0 @@
package tech.easyflow.admin.model.ai;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* 工作流设计器所需的安全选项视图。
*
* @param models 模型选项
* @param knowledges 知识库选项
* @param codeEngines 代码执行引擎选项
*/
public record WorkflowDesignerOptionsView(
List<ModelOption> models,
List<KnowledgeOption> knowledges,
List<Map<String, Object>> codeEngines
) {
/**
* 模型安全选项。
*
* @param id 模型 ID
* @param title 模型标题
* @param description 模型描述
* @param modelProvider 供应商安全摘要
*/
public record ModelOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String title,
String description,
ProviderOption modelProvider
) {
}
/**
* 模型供应商安全摘要。
*
* @param providerName 供应商名称
* @param providerType 供应商类型
* @param icon 供应商图标
*/
public record ProviderOption(String providerName, String providerType, String icon) {
}
/**
* 知识库安全选项。
*
* @param id 知识库 ID
* @param title 知识库标题
* @param description 知识库描述
* @param vectorEmbedModelId Embedding 模型 ID
* @param dimensionOfVectorModel 向量维度
* @param vectorStoreEnabled 是否可用于向量检索
*/
public record KnowledgeOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String title,
String description,
@JsonSerialize(using = ToStringSerializer.class) BigInteger vectorEmbedModelId,
Integer dimensionOfVectorModel,
Boolean vectorStoreEnabled
) {
}
/**
* 插件安全选项。
*
* @param id 插件 ID
* @param name 插件名称
* @param description 插件描述
* @param icon 插件图标
* @param tools 可用工具
*/
public record PluginOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String name,
String description,
String icon,
List<PluginToolOption> tools
) {
}
/**
* 插件工具安全选项。
*
* @param id 工具 ID
* @param name 工具名称
* @param description 工具描述
*/
public record PluginToolOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String name,
String description
) {
}
/**
* 数据源安全选项。
*
* @param id 数据源 ID
* @param sourceName 数据源名称
* @param sourceType 数据源类型
*/
public record DataSourceOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String sourceName,
String sourceType
) {
}
/**
* 数据目录安全选项。
*
* @param id 目录 ID
* @param sourceId 数据源 ID
* @param catalogName 目录名称
* @param catalogDesc 目录描述
*/
public record CatalogOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
String catalogName,
String catalogDesc
) {
}
/**
* 已接入数据集安全选项。
*
* @param id 数据集 ID
* @param tenantId 租户 ID
* @param sourceId 数据源 ID
* @param catalogId 目录 ID
* @param tableName 数据表名称
* @param tableDesc 数据表描述
*/
public record DatasetOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger tenantId,
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
@JsonSerialize(using = ToStringSerializer.class) BigInteger catalogId,
String tableName,
String tableDesc
) {
}
/**
* 数据集字段安全视图。
*
* @param fieldName 字段名称
* @param fieldDesc 字段描述
* @param jdbcType JDBC 类型
* @param fieldType 业务字段类型
*/
public record DatasetFieldOption(
String fieldName,
String fieldDesc,
String jdbcType,
Integer fieldType
) {
}
/**
* 数据集结构安全视图。
*
* @param tableName 数据表名称
* @param tableDesc 数据表描述
* @param fields 字段列表
*/
public record DatasetSchemaOption(
String tableName,
String tableDesc,
List<DatasetFieldOption> fields
) {
}
}

View File

@@ -17,6 +17,8 @@ public class DashboardDistributionItemVo {
private Long activeUserTotal;
private Long botTotal;
private Long workflowTotal;
private Long knowledgeBaseTotal;
@@ -71,6 +73,14 @@ public class DashboardDistributionItemVo {
this.activeUserTotal = activeUserTotal;
}
public Long getBotTotal() {
return botTotal;
}
public void setBotTotal(Long botTotal) {
this.botTotal = botTotal;
}
public Long getWorkflowTotal() {
return workflowTotal;
}

View File

@@ -9,8 +9,7 @@ public class DashboardSummaryVo {
private Long activeUserTotal;
/** 智能体总数。 */
private Long agentTotal;
private Long botTotal;
private Long workflowTotal;
@@ -40,22 +39,12 @@ public class DashboardSummaryVo {
this.activeUserTotal = activeUserTotal;
}
/**
* 获取智能体总数。
*
* @return 智能体总数
*/
public Long getAgentTotal() {
return agentTotal;
public Long getBotTotal() {
return botTotal;
}
/**
* 设置智能体总数。
*
* @param agentTotal 智能体总数
*/
public void setAgentTotal(Long agentTotal) {
this.agentTotal = agentTotal;
public void setBotTotal(Long botTotal) {
this.botTotal = botTotal;
}
public Long getWorkflowTotal() {

View File

@@ -6,9 +6,6 @@ import org.springframework.util.StringUtils;
import tech.easyflow.admin.dto.chatworkspace.*;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
import tech.easyflow.agent.runtime.document.AgentDocumentService;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.PublishStatus;
@@ -25,7 +22,6 @@ import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.*;
@@ -43,11 +39,7 @@ public class AgentSessionService {
private final DocumentCollectionService documentCollectionService;
private final ResourceAccessService resourceAccessService;
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
private final AgentMediaService agentMediaService;
private final AgentComposerDraftService agentComposerDraftService;
private final ChatJsonSupport chatJsonSupport;
@Resource
private AgentDocumentService agentDocumentService;
/**
* 创建 Agent 管理端会话服务。
@@ -58,8 +50,6 @@ public class AgentSessionService {
* @param documentCollectionService 知识库服务
* @param resourceAccessService 资源访问服务
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
* @param agentMediaService Agent 媒体服务
* @param agentComposerDraftService Agent 输入草稿服务
* @param chatJsonSupport 聊天 JSON 工具
*/
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
@@ -68,8 +58,6 @@ public class AgentSessionService {
DocumentCollectionService documentCollectionService,
ResourceAccessService resourceAccessService,
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
AgentMediaService agentMediaService,
AgentComposerDraftService agentComposerDraftService,
ChatJsonSupport chatJsonSupport) {
this.chatSessionQueryService = chatSessionQueryService;
this.chatSessionCommandService = chatSessionCommandService;
@@ -77,8 +65,6 @@ public class AgentSessionService {
this.documentCollectionService = documentCollectionService;
this.resourceAccessService = resourceAccessService;
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
this.agentMediaService = agentMediaService;
this.agentComposerDraftService = agentComposerDraftService;
this.chatJsonSupport = chatJsonSupport;
}
@@ -200,75 +186,21 @@ public class AgentSessionService {
* @param sessionId 会话 ID
*/
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
// 上一次删除可能已写入删除标记但媒体清理失败,重试时继续清理当前用户目录。
deleteComposerDraft(summary, account, sessionId);
agentMediaService.deleteFormalSession(sessionId.toString(), account);
deleteFormalDocuments(sessionId, account);
return;
}
requireUserAgentSession(account, summary);
requireUserAgentSession(account, sessionId);
agentRuntimeStateCleanupService.clearChatSession(sessionId, account.getId());
chatSessionCommandService.deleteSession(sessionId, account.getId(), account.getId());
deleteComposerDraft(summary, account, sessionId);
agentMediaService.deleteFormalSession(sessionId.toString(), account);
deleteFormalDocuments(sessionId, account);
}
/**
* 幂等清理正式会话绑定的文档对象与快照。
*
* @param sessionId 会话 ID
* @param account 当前账号
*/
private void deleteFormalDocuments(BigInteger sessionId, LoginAccount account) {
if (agentDocumentService != null) {
agentDocumentService.deleteFormalSession(sessionId.toString(), account);
}
}
/**
* 删除会话对应的未发送草稿和临时图片。
*
* @param summary 会话摘要
* @param account 当前登录账号
* @param sessionId 会话 ID
*/
private void deleteComposerDraft(ChatSessionSummary summary, LoginAccount account, BigInteger sessionId) {
if (summary == null || summary.getAssistantId() == null) {
return;
}
agentComposerDraftService.delete(AgentMediaService.MODE_FORMAL,
summary.getAssistantId().toString(), sessionId.toString(), account);
}
private ChatSessionSummary requireUserAgentSession(LoginAccount account, BigInteger sessionId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
throw new BusinessException("Agent 会话不存在");
}
requireUserAgentSession(account, summary);
return summary;
}
/**
* 校验会话属于当前用户且类型为 Agent。
*
* @param account 当前登录账号
* @param summary 会话摘要
* @throws BusinessException 会话类型不匹配或不属于当前用户时抛出
*/
private void requireUserAgentSession(LoginAccount account, ChatSessionSummary summary) {
if (!ASSISTANT_CODE.equals(summary.getAssistantCode())) {
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())
|| !ASSISTANT_CODE.equals(summary.getAssistantCode())) {
throw new BusinessException("Agent 会话不存在");
}
if (!Objects.equals(summary.getUserId(), account.getId())) {
throw new BusinessException("无权访问该 Agent 会话");
}
if (!Objects.equals(summary.getTenantId(), account.getTenantId())) {
throw new BusinessException("无权访问该 Agent 会话");
}
return summary;
}
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {

View File

@@ -166,14 +166,8 @@ public class ChatWorkspaceService {
roundIds.add(record.getRoundId());
}
}
List<ChatMessageRecord> allVariants = new ArrayList<>();
for (BigInteger roundId : roundIds) {
List<ChatMessageRecord> variants = chatRoundOperateService.listVariantsUnprojected(sessionId, roundId);
variantsByRound.put(roundId.toString(), variants);
allVariants.addAll(variants);
}
if (!allVariants.isEmpty()) {
chatRoundOperateService.projectVariants(sessionId, allVariants);
variantsByRound.put(roundId.toString(), chatRoundOperateService.listVariants(sessionId, roundId));
}
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
view.setRecords(records);

View File

@@ -1,688 +0,0 @@
package tech.easyflow.admin.service.ai;
import com.alibaba.fastjson.JSON;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainConsts;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.Edge;
import com.easyagents.flow.core.chain.Event;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.chain.NodeStatus;
import com.easyagents.flow.core.chain.ExceptionSummary;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowExecutionError;
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
import com.easyagents.flow.core.chain.event.NodeEndEvent;
import com.easyagents.flow.core.chain.event.NodeStartEvent;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* 将工作流运行事件转换为对话页可消费的 SSE 事件流。
*/
@Service
public class WorkflowChatEventStream {
public Map<String, Object> runtimeView(String executeId) {
try {
ChainState state = chainExecutor.getChainStateRepository()
.load(executeId);
if (state == null || state.getStatus() == null) {
return Map.of();
}
Map<String, Object> view = new LinkedHashMap<>();
view.put("status", state.getStatus().name());
view.put("statusValue", state.getStatus().getValue());
WorkflowExecutionError error = WorkflowExecutionErrorMapper.chain(state.getError(), state.getStatus());
view.put("error", error);
view.put("message", state.getStatus() == ChainStatus.SUSPEND ? state.getMessage()
: WorkflowExecutionErrorMapper.summary(error));
if (state.getStatus() == ChainStatus.SUSPEND) {
view.put("parameters", state.getSuspendForParameters());
}
if (state.getStatus() == ChainStatus.SUCCEEDED) {
view.put(
"output",
WorkflowChatEventStream.visibleFinalOutput(
state.getExecuteResult())
);
}
return view;
} catch (RuntimeException error) {
log.warn(
"failed to load public workflow runtime state, executeId={}",
executeId,
error
);
return Map.of();
}
}
private static final Logger log =
LoggerFactory.getLogger(WorkflowChatEventStream.class);
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
private final ChainExecutor chainExecutor;
private final Map<String, StreamSession> sessions =
new ConcurrentHashMap<>();
private final ScheduledExecutorService detachedSessionCleaner =
Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(
task,
"workflow-chat-detached-session-cleaner"
);
thread.setDaemon(true);
return thread;
});
/**
* 创建工作流对话事件流服务。
*
* @param chainExecutor 工作流执行器
*/
public WorkflowChatEventStream(ChainExecutor chainExecutor) {
this.chainExecutor = chainExecutor;
}
/**
* 注册工作流全局事件监听器。
*/
@PostConstruct
public void registerListeners() {
chainExecutor.addEventListener(this::onEvent);
chainExecutor.addErrorListener(this::onChainError);
}
/**
* 关闭断开会话清理线程并释放残留外部资源。
*/
@PreDestroy
public void shutdown() {
sessions.values().forEach(this::removeSession);
detachedSessionCleaner.shutdownNow();
}
/**
* 启动工作流并返回其 SSE 连接。
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @return SSE 连接
*/
public SseEmitter start(String definitionId, Map<String, Object> variables) {
return start(definitionId, variables, () -> {
});
}
/**
* 启动工作流并在流会话结束时执行清理回调。
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @param cleanup 终态、启动失败或连接断开后的幂等清理任务
* @return SSE 连接
*/
public SseEmitter start(
String definitionId,
Map<String, Object> variables,
Runnable cleanup
) {
return start(definitionId, variables, cleanup, Duration.ZERO);
}
/**
* 启动工作流并将浏览器连接与 Runtime 生命周期分离。
*
* <p>浏览器断开后不取消工作流;在保留期内继续监听真实终态并执行清理,
* 超过保留期时由租约兜底释放资源。</p>
*
* @param definitionId 工作流定义 ID
* @param variables 运行变量
* @param cleanup 终态、启动失败或保留期结束后的幂等清理任务
* @param detachedRetention 浏览器断开后的监听保留时长
* @return SSE 连接
*/
public SseEmitter start(
String definitionId,
Map<String, Object> variables,
Runnable cleanup,
Duration detachedRetention
) {
SseEmitter emitter = createEmitter();
StreamSession session = new StreamSession(
emitter,
cleanup,
detachedRetention
);
emitter.onTimeout(() -> detach(session));
emitter.onError(error -> detach(session));
emitter.onCompletion(() -> detach(session));
try {
chainExecutor.executeAsync(
definitionId,
variables,
executeId -> {
session.attach(executeId);
if (session.cleaned.get()) {
return;
}
sessions.put(executeId, session);
session.send("execution_started", Map.of(
"executeId", executeId
));
}
);
} catch (RuntimeException | Error error) {
session.fail(error);
throw error;
}
return emitter;
}
/**
* 创建 SSE 发送器,便于验证连接生命周期。
*/
SseEmitter createEmitter() {
return new SseEmitter(SSE_TIMEOUT_MILLIS);
}
/**
* 将工作流事件转发到对应执行流。
*
* @param event 工作流事件
* @param chain 当前工作流
*/
private void onEvent(Event event, Chain chain) {
StreamSession session = findSession(chain);
if (session == null) {
return;
}
if (event instanceof NodeStartEvent nodeStartEvent) {
session.onNodeStarted(chain, nodeStartEvent);
return;
}
if (event instanceof NodeEndEvent nodeEndEvent) {
session.onNodeFinished(chain, nodeEndEvent);
return;
}
if (event instanceof EdgeTriggerEvent edgeTriggerEvent) {
session.onEdgeTriggered(chain, edgeTriggerEvent);
return;
}
if (event instanceof EdgeConditionCheckFailedEvent failedEvent) {
session.onEdgeConditionFailed(chain, failedEvent);
return;
}
if (event instanceof ChainStatusChangeEvent statusEvent
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
session.onStatusChanged(chain, statusEvent.getStatus());
}
}
/**
* 将链级异常发送到客户端。
*
* @param error 链级异常
* @param chain 当前工作流
*/
private void onChainError(Throwable error, Chain chain) {
StreamSession session = findSession(chain);
if (session != null
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.map(
chain.getState().getError(), true, null, null, false);
session.send("execution_error", Map.of("message", detail.getMessage(), "error", detail));
}
}
/**
* 查找顶级执行对应的事件流会话。
*
* @param chain 当前工作流
* @return 流会话;不存在时为 {@code null}
*/
private StreamSession findSession(Chain chain) {
if (chain == null) {
return null;
}
String auditInstanceId = chain.getAuditInstanceId();
if (auditInstanceId != null && !auditInstanceId.isBlank()) {
StreamSession session = sessions.get(auditInstanceId);
if (session != null) {
return session;
}
}
return sessions.get(chain.getStateInstanceId());
}
/**
* 分离已经断开的浏览器传输,不影响工作流 Runtime。
*
* @param session 流会话
*/
private void detach(StreamSession session) {
if (session == null || session.terminal.get()) {
return;
}
session.detachTransport();
if (session.detachedRetention.isZero()
|| session.detachedRetention.isNegative()) {
removeSession(session);
return;
}
session.scheduleDetachedCleanup();
}
/**
* 移除流会话。
*
* @param session 流会话
*/
private void removeSession(StreamSession session) {
if (session != null && session.executeId != null) {
sessions.remove(session.executeId, session);
}
if (session != null) {
session.cleanup();
}
}
/**
* 去掉顶级工作流结果中的内部状态控制字段。
*
* @param result 顶级工作流执行结果
* @return 可直接交给会话结果区展示的最终输出
*/
static Map<String, Object> visibleFinalOutput(
Map<String, Object> result
) {
Map<String, Object> visible = new LinkedHashMap<>();
if (result != null) {
visible.putAll(result);
}
visible.remove(ChainConsts.CHAIN_STATE_STATUS_KEY);
visible.remove(ChainConsts.CHAIN_STATE_MESSAGE_KEY);
visible.remove(ChainConsts.NODE_STATE_STATUS_KEY);
visible.remove(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY);
return visible;
}
/**
* 单次工作流执行的 SSE 会话。
*/
private final class StreamSession {
private final SseEmitter emitter;
private final AtomicLong sequence = new AtomicLong();
private final AtomicBoolean terminal = new AtomicBoolean(false);
private final AtomicBoolean cleaned = new AtomicBoolean(false);
private final AtomicBoolean connected = new AtomicBoolean(true);
private final Runnable cleanup;
private final Duration detachedRetention;
private volatile ScheduledFuture<?> detachedCleanup;
private volatile String executeId;
/**
* 创建流会话。
*
* @param emitter SSE 发送器
*/
private StreamSession(
SseEmitter emitter,
Runnable cleanup,
Duration detachedRetention
) {
this.emitter = emitter;
this.cleanup = cleanup == null ? () -> {
} : cleanup;
this.detachedRetention = detachedRetention == null
? Duration.ZERO
: detachedRetention;
}
/**
* 幂等释放当前流持有的外部资源。
*/
private void cleanup() {
if (!cleaned.compareAndSet(false, true)) {
return;
}
cancelDetachedCleanup();
try {
cleanup.run();
} catch (RuntimeException error) {
log.warn(
"workflow chat stream cleanup failed, executeId={}",
executeId,
error
);
}
}
/**
* 绑定执行实例。
*
* @param executeId 执行实例 ID
*/
private void attach(String executeId) {
this.executeId = executeId;
}
/**
* 标记浏览器传输已经断开,后续事件只推进 Runtime 清理。
*/
private void detachTransport() {
connected.set(false);
}
/**
* 浏览器断开后按活动租约安排会话兜底清理。
*/
private synchronized void scheduleDetachedCleanup() {
if (detachedCleanup != null || cleaned.get()) {
return;
}
detachedCleanup = detachedSessionCleaner.schedule(
() -> removeSession(this),
Math.max(1L, detachedRetention.toMillis()),
TimeUnit.MILLISECONDS
);
}
/**
* 取消尚未触发的断开会话兜底任务。
*/
private synchronized void cancelDetachedCleanup() {
if (detachedCleanup == null) {
return;
}
detachedCleanup.cancel(false);
detachedCleanup = null;
}
/**
* 处理节点开始事件。
*
* @param chain 当前工作流
* @param event 节点开始事件
*/
private void onNodeStarted(Chain chain, NodeStartEvent event) {
Node node = event.getNode();
Map<String, Object> data = new LinkedHashMap<>();
data.put("attemptKey", event.getExecutionAttemptKey());
data.put("nodeClass", node.getClass().getSimpleName());
data.put("chainInstanceId", chain.getStateInstanceId());
data.put("startedAt", System.currentTimeMillis());
data.put("input", resolveNodeInput(chain, node));
send("node_started", nodePayload(node, data));
}
/**
* 处理节点完成事件并更新运行详情。
*
* @param chain 当前工作流
* @param event 节点完成事件
*/
private void onNodeFinished(Chain chain, NodeEndEvent event) {
Node node = event.getNode();
Map<String, Object> data = new LinkedHashMap<>();
data.put("attemptKey", event.getExecutionAttemptKey());
data.put("status", event.getStatus() == null
? null
: event.getStatus().name());
data.put("chainInstanceId", chain.getStateInstanceId());
data.put("finishedAt", System.currentTimeMillis());
data.put("output", event.getResult() == null
? Map.of()
: event.getResult());
if (event.getErrorSummary() != null) {
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.node(event.getErrorSummary(),
event.getStatus() == null ? NodeStatus.FAILED : event.getStatus(), node.getId(), node.getName());
if (detail != null) {
data.put("error", detail.getMessage());
data.put("errorDetail", detail);
}
}
send("node_finished", nodePayload(node, data));
}
/**
* 记录命中条件的流转分支。
*
* @param chain 当前工作流
* @param event 边触发事件
*/
private void onEdgeTriggered(Chain chain, EdgeTriggerEvent event) {
if (event.getTrigger() == null) {
return;
}
Edge edge = chain.getDefinition().getEdgeById(
event.getTrigger().getEdgeId());
if (edge == null || edge.getCondition() == null) {
return;
}
Node sourceNode = chain.getDefinition().getNodeById(
edge.getSource());
sendEdgeTrace(chain, sourceNode, edge, "matched");
}
/**
* 记录未命中条件的流转分支。
*
* @param chain 当前工作流
* @param event 条件未命中事件
*/
private void onEdgeConditionFailed(
Chain chain,
EdgeConditionCheckFailedEvent event
) {
sendEdgeTrace(chain, event.getNode(), event.getEdge(), "skipped");
}
/**
* 发送节点的条件判断轨迹。
*
* @param chain 当前工作流
* @param sourceNode 条件来源节点
* @param edge 被判断的边
* @param outcome 判断结果
*/
private void sendEdgeTrace(
Chain chain,
Node sourceNode,
Edge edge,
String outcome
) {
if (sourceNode == null || edge == null) {
return;
}
Node targetNode = chain.getDefinition().getNodeById(
edge.getTarget());
Map<String, Object> data = new LinkedHashMap<>();
data.put(
"attemptKey",
chain.currentExecutionAttemptKey(sourceNode.getId()));
data.put("kind", "condition");
data.put("outcome", outcome);
data.put("edgeId", edge.getId());
data.put("targetNodeId", edge.getTarget());
data.put(
"targetNodeName",
targetNode == null ? edge.getTarget() : targetNode.getName());
send("node_trace", nodePayload(sourceNode, data));
}
/**
* 解析节点本次执行实际使用的输入。
*
* @param chain 当前工作流
* @param node 当前节点
* @return 可序列化的节点输入
*/
private Map<String, Object> resolveNodeInput(Chain chain, Node node) {
try {
return chain.getExecutionState()
.resolveParametersPreservingReferences(node);
} catch (RuntimeException error) {
log.warn(
"Failed to resolve workflow node input, "
+ "executeId={}, nodeId={}",
executeId,
node == null ? null : node.getId(),
error
);
return Map.of();
}
}
/**
* 处理工作流状态变化。
*
* @param chain 当前工作流
* @param status 新状态
*/
private void onStatusChanged(Chain chain, ChainStatus status) {
if (status == null) {
return;
}
if (status == ChainStatus.SUSPEND) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("message", chain.getState().getMessage());
data.put(
"parameters",
chain.getState().getSuspendForParameters()
);
send("execution_waiting", data);
return;
}
if (!status.isTerminal()) {
send("execution_status", Map.of("status", status.name()));
return;
}
if (!terminal.compareAndSet(false, true)) {
return;
}
String eventType = switch (status) {
case SUCCEEDED -> "execution_finished";
case CANCELLED -> "execution_cancelled";
default -> "execution_failed";
};
Map<String, Object> data = new LinkedHashMap<>();
data.put("status", status.name());
data.put("message", chain.getState().getMessage());
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.chain(chain.getState().getError(), status);
if (detail != null) {
data.put("error", detail);
data.put("message", WorkflowExecutionErrorMapper.summary(detail));
}
if (status == ChainStatus.SUCCEEDED) {
data.put(
"output",
visibleFinalOutput(chain.getState().getExecuteResult())
);
}
send(eventType, data);
removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.complete();
}
}
/**
* 发送 SSE 事件。
*
* @param type 事件类型
* @param data 事件数据
*/
private void send(String type, Map<String, ?> data) {
if (!connected.get()) {
return;
}
long nextSequence = sequence.incrementAndGet();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventId", executeId + ":" + nextSequence);
payload.put("sequence", nextSequence);
payload.put("executeId", executeId);
payload.put("type", type);
payload.put("data", data);
try {
emitter.send(SseEmitter.event()
.id(String.valueOf(nextSequence))
.name("workflow")
// 显式发送 JSON 文本,避免全局 CBOR 转换器将 SSE data 编码为二进制。
.data(JSON.toJSONString(payload)));
} catch (IOException | IllegalStateException error) {
log.debug(
"workflow chat stream disconnected, executeId={}",
executeId,
error
);
detach(this);
}
}
/**
* 在启动失败时关闭 SSE 会话。
*
* @param error 启动异常
*/
private void fail(Throwable error) {
if (terminal.compareAndSet(false, true)) {
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.map(
error == null ? null : new ExceptionSummary(error), true, null, null, false);
send("execution_failed", Map.of(
"status", ChainStatus.FAILED.name(),
"message", detail.getMessage(),
"error", detail
));
removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.completeWithError(error);
}
}
}
/**
* 构建带节点信息的事件数据。
*
* @param node 当前节点
* @param values 业务数据
* @return 事件数据
*/
private Map<String, Object> nodePayload(
Node node,
Map<String, ?> values
) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("nodeId", node == null ? null : node.getId());
payload.put("nodeName", node == null ? null : node.getName());
if (values != null) {
payload.putAll(values);
}
return payload;
}
}
}

View File

@@ -1,736 +0,0 @@
package tech.easyflow.admin.service.ai;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.node.EndNode;
import com.easyagents.flow.core.node.StartNode;
import com.easyagents.flow.core.parser.ChainParser;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.entity.DatacenterTableField;
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
import tech.easyflow.datacenter.execution.model.DatasetRef;
import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
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.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 查询工作流设计器所需的场景化安全选项。
*/
@Service
public class WorkflowDesignerOptionService {
private final ModelService modelService;
private final DocumentCollectionService documentCollectionService;
private final PluginService pluginService;
private final PluginItemService pluginItemService;
private final PluginVisibilityService pluginVisibilityService;
private final WorkflowService workflowService;
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
private final ChainParser chainParser;
private final WorkflowDatacenterContentService workflowDatacenterContentService;
private final ResourceAccessService resourceAccessService;
private final DatacenterSourceService datacenterSourceService;
private final DatacenterDatasetRegistryService datacenterDatasetRegistryService;
private final DatacenterDatasetQueryService datacenterDatasetQueryService;
private final WorkflowKnowledgeContractService workflowKnowledgeContractService;
/**
* 创建工作流设计器选项服务。
*
* @param modelService 模型服务
* @param documentCollectionService 知识库服务
* @param pluginService 插件服务
* @param pluginItemService 插件工具服务
* @param pluginVisibilityService 插件可见性服务
* @param workflowService 工作流服务
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
* @param workflowPluginSnapshotResolver 工作流插件快照解析器
* @param chainParser 工作流解析器
* @param workflowDatacenterContentService 工作流数据中心内容服务
* @param resourceAccessService 资源访问服务
* @param datacenterSourceService 数据源服务
* @param datacenterDatasetRegistryService 数据集注册服务
* @param datacenterDatasetQueryService 数据集查询服务
* @param workflowKnowledgeContractService 工作流知识库契约服务
*/
public WorkflowDesignerOptionService(
ModelService modelService,
DocumentCollectionService documentCollectionService,
PluginService pluginService,
PluginItemService pluginItemService,
PluginVisibilityService pluginVisibilityService,
WorkflowService workflowService,
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
ChainParser chainParser,
WorkflowDatacenterContentService workflowDatacenterContentService,
ResourceAccessService resourceAccessService,
DatacenterSourceService datacenterSourceService,
DatacenterDatasetRegistryService datacenterDatasetRegistryService,
DatacenterDatasetQueryService datacenterDatasetQueryService,
WorkflowKnowledgeContractService workflowKnowledgeContractService) {
this.modelService = modelService;
this.documentCollectionService = documentCollectionService;
this.pluginService = pluginService;
this.pluginItemService = pluginItemService;
this.pluginVisibilityService = pluginVisibilityService;
this.workflowService = workflowService;
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
this.chainParser = chainParser;
this.workflowDatacenterContentService = workflowDatacenterContentService;
this.resourceAccessService = resourceAccessService;
this.datacenterSourceService = datacenterSourceService;
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
}
/**
* 查询设计器初始化所需的模型和知识库。
*
* @param codeEngines 代码执行引擎选项
* @return 设计器初始化选项
*/
public WorkflowDesignerOptionsView listOptions(List<Map<String, Object>> codeEngines) {
LoginAccount account = requireAccount();
return new WorkflowDesignerOptionsView(
listModelOptions(account),
listKnowledgeOptions(account),
codeEngines == null ? List.of() : codeEngines
);
}
/**
* 校验工作流内容引用的场景资源是否仍允许当前账号使用。
*
* @param content 工作流内容
* @throws BusinessException 内容引用越权、跨租户或资源失效时抛出
*/
public void assertContentReferences(String content) {
if (content == null || content.isBlank()) {
return;
}
JSONObject root;
try {
root = JSON.parseObject(content);
} catch (Exception exception) {
throw new BusinessException("工作流内容不是合法JSON");
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null || nodes.isEmpty()) {
return;
}
LoginAccount account = requireAccount();
Set<BigInteger> modelIds = new HashSet<>();
Set<BigInteger> knowledgeIds = new HashSet<>();
List<List<BigInteger>> knowledgeGroups = new ArrayList<>();
Set<BigInteger> checkedPluginItemIds = new HashSet<>();
Set<BigInteger> checkedWorkflowIds = new HashSet<>();
Set<BigInteger> checkedSourceIds = new HashSet<>();
Set<String> checkedTableIds = new HashSet<>();
for (int index = 0; index < nodes.size(); index++) {
JSONObject node = nodes.getJSONObject(index);
if (node == null) {
continue;
}
JSONObject data = node.getJSONObject("data");
if (data == null) {
continue;
}
String nodeType = node.getString("type");
String dataType = data.getString("type");
if (nodeType != null && !nodeType.isBlank()
&& dataType != null && !dataType.isBlank()
&& !Objects.equals(nodeType, dataType)) {
throw new BusinessException("工作流节点类型与节点数据类型不一致");
}
if (nodeType == null || nodeType.isBlank()) {
nodeType = dataType;
}
if ("llmNode".equals(nodeType)) {
addReferenceId(modelIds, readReferenceId(data, "llmId", "模型"));
} else if ("knowledgeNode".equals(nodeType)) {
List<BigInteger> nodeKnowledgeIds = readKnowledgeReferenceIds(data);
knowledgeIds.addAll(nodeKnowledgeIds);
knowledgeGroups.add(nodeKnowledgeIds);
} else if ("plugin-node".equals(nodeType)) {
BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具");
if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) {
getPluginTinyFlowData(pluginItemId);
}
} else if ("workflow-node".equals(nodeType)) {
assertWorkflowReference(readReferenceId(data, "workflowId", "子流程"),
account, checkedWorkflowIds);
}
assertDatasetReference(data, account, checkedSourceIds, checkedTableIds);
}
assertModelReferences(modelIds, account);
assertKnowledgeReferences(knowledgeIds, account);
workflowKnowledgeContractService.assertMultiKnowledgeContracts(
knowledgeGroups, account.getTenantId());
}
/**
* 分页查询当前账号可用于工作流的插件。
*
* @param pageNumber 页码
* @param pageSize 每页数量
* @param keyword 插件名称或描述关键字
* @return 插件安全选项分页
*/
public Page<WorkflowDesignerOptionsView.PluginOption> pagePlugins(
Long pageNumber, Long pageSize, String keyword) {
LoginAccount account = requireAccount();
QueryWrapper wrapper = QueryWrapper.create()
.eq(Plugin::getTenantId, account.getTenantId().longValue())
.orderBy(Plugin::getCreated, false);
List<Plugin> plugins = pluginService.getMapper().selectListWithRelationsByQuery(wrapper);
String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase(java.util.Locale.ROOT);
List<Plugin> availablePlugins = pluginService.preparePluginsForCurrentUser(plugins, false, true).stream()
.filter(plugin -> matchesKeyword(normalizedKeyword, plugin.getName(), plugin.getDescription()))
.toList();
List<WorkflowDesignerOptionsView.PluginOption> options = availablePlugins.stream()
.map(this::toPluginOption)
.toList();
long actualPageNumber = pageNumber == null || pageNumber < 1 ? 1L : pageNumber;
long actualPageSize = pageSize == null || pageSize < 1 ? 10L : Math.min(pageSize, 100L);
int fromIndex = Math.toIntExact(Math.min(options.size(), (actualPageNumber - 1) * actualPageSize));
int toIndex = Math.toIntExact(Math.min(options.size(), fromIndex + actualPageSize));
return new Page<>(
options.subList(fromIndex, toIndex),
actualPageNumber,
actualPageSize,
options.size()
);
}
/**
* 判断任一候选文本是否包含关键字。
*
* @param keyword 已归一化的小写关键字
* @param values 候选文本
* @return 空关键字或任一文本命中时返回 {@code true}
*/
private boolean matchesKeyword(String keyword, String... values) {
if (keyword == null || keyword.isEmpty()) {
return true;
}
for (String value : values) {
if (value != null && value.toLowerCase(java.util.Locale.ROOT).contains(keyword)) {
return true;
}
}
return false;
}
/**
* 查询一个插件工具的工作流节点安全配置。
*
* @param pluginItemId 插件工具 ID
* @return 节点配置
* @throws BusinessException 插件工具不存在或不可用时抛出
*/
public JSONObject getPluginTinyFlowData(BigInteger pluginItemId) {
if (pluginItemId == null) {
throw new BusinessException("插件工具不能为空");
}
PluginItem record = pluginItemService.getById(pluginItemId);
if (record == null || !Integer.valueOf(1).equals(record.getStatus())) {
throw new BusinessException("插件工具不存在或已禁用");
}
Plugin plugin = pluginService.getById(record.getPluginId());
if (plugin == null) {
throw new BusinessException("插件不存在");
}
LoginAccount account = requireAccount();
if (!Objects.equals(plugin.getTenantId(), account.getTenantId().longValue())) {
throw new BusinessException("无权限访问插件");
}
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件");
Plugin preparedPlugin = pluginService.preparePluginForCurrentUser(plugin);
if (Boolean.FALSE.equals(preparedPlugin.getAvailable())) {
throw new BusinessException(preparedPlugin.getReasonMessage());
}
JSONObject nodeData = new JSONObject();
nodeData.put("pluginId", record.getId().toString());
nodeData.put("pluginName", record.getName());
nodeData.put("pluginType", preparedPlugin.getType());
nodeData.put("workflowId", preparedPlugin.getWorkflowId());
nodeData.put("workflowTitle", preparedPlugin.getWorkflowTitle());
nodeData.put("available", preparedPlugin.getAvailable());
nodeData.put("reasonCode", preparedPlugin.getReasonCode());
nodeData.put("reasonMessage", preparedPlugin.getReasonMessage());
nodeData.put("parameters", parseSchemaArray(record.getInputData()));
nodeData.put("outputDefs", parseSchemaArray(record.getOutputData()));
nodeData.put("schemaHash", resolveSchemaHash(record, preparedPlugin));
return nodeData;
}
/**
* 查询子流程节点所需的输入输出定义。
*
* @param currentWorkflowId 当前工作流 ID
* @param childWorkflowId 子流程 ID
* @return 子流程节点配置
* @throws BusinessException 子流程不存在、不可用或无权使用时抛出
*/
public JSONObject getChildWorkflowNodeData(
BigInteger currentWorkflowId,
BigInteger childWorkflowId) {
if (childWorkflowId == null) {
throw new BusinessException("子流程不能为空");
}
if (Objects.equals(childWorkflowId, currentWorkflowId)) {
throw new BusinessException("工作流不能作为自身子节点");
}
LoginAccount account = requireAccount();
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
childWorkflowId,
account,
"子流程不存在、未发布或无权使用");
assertContentReferences(workflow.getContent());
ChainDefinition definition = chainParser.parse(
workflowDatacenterContentService.prepareContent(workflow.getContent()));
JSONArray inputs = new JSONArray();
JSONArray outputs = new JSONArray();
for (Node node : definition.getNodes()) {
if (node instanceof StartNode) {
inputs = JSON.parseArray(JSON.toJSONString(node.getParameters()));
decorateChildWorkflowSchemaArray(inputs);
}
if (node instanceof EndNode endNode) {
outputs = JSON.parseArray(JSON.toJSONString(endNode.getOutputDefs()));
decorateChildWorkflowSchemaArray(outputs);
}
if (node instanceof ConfirmNode) {
throw new BusinessException("工作流存在【确认节点】,暂不支持作为子节点");
}
}
JSONObject nodeData = new JSONObject();
nodeData.put("workflowId", workflow.getId());
nodeData.put("workflowName", workflow.getTitle());
nodeData.put("parameters", inputs);
nodeData.put("outputDefs", outputs);
return nodeData;
}
/**
* 查询当前租户可用于工作流的数据源安全选项。
*
* @return 数据源安全选项
*/
public List<WorkflowDesignerOptionsView.DataSourceOption> listDataSources() {
LoginAccount account = requireAccount();
datacenterDatasetRegistryService.ensureBuiltinSource(DatacenterSourceType.PROJECT_MYSQL, account);
return datacenterSourceService.list(QueryWrapper.create()
.eq(DatacenterSource::getTenantId, account.getTenantId())
.orderBy(DatacenterSource::getModified, false))
.stream()
.map(source -> new WorkflowDesignerOptionsView.DataSourceOption(
source.getId(), source.getSourceName(), source.getSourceType()))
.toList();
}
/**
* 查询一个数据源的目录安全选项。
*
* @param sourceId 数据源 ID
* @return 目录安全选项
*/
public List<WorkflowDesignerOptionsView.CatalogOption> listCatalogs(BigInteger sourceId) {
LoginAccount account = requireAccount();
requireTenantSource(sourceId, account);
return datacenterSourceService.listCatalogs(sourceId, account).stream()
.map(this::toCatalogOption)
.toList();
}
/**
* 查询一个数据源目录下已接入的数据集。
*
* @param sourceId 数据源 ID
* @param catalogId 目录 ID
* @return 数据集安全选项
*/
public List<WorkflowDesignerOptionsView.DatasetOption> listManagedDatasets(
BigInteger sourceId,
BigInteger catalogId) {
LoginAccount account = requireAccount();
requireTenantSource(sourceId, account);
return datacenterDatasetRegistryService.listManagedTables(sourceId, catalogId).stream()
.filter(table -> Objects.equals(table.getTenantId(), account.getTenantId()))
.filter(table -> Objects.equals(table.getSourceId(), sourceId))
.filter(table -> catalogId == null || Objects.equals(table.getCatalogId(), catalogId))
.map(this::toDatasetOption)
.toList();
}
/**
* 查询工作流数据节点所需的数据集结构。
*
* @param datasetRef 数据集引用
* @return 数据集结构安全视图
*/
public WorkflowDesignerOptionsView.DatasetSchemaOption getDatasetSchema(DatasetRef datasetRef) {
if (datasetRef == null || datasetRef.getSourceId() == null || datasetRef.getTableId() == null) {
throw new BusinessException("数据集引用不完整");
}
LoginAccount account = requireAccount();
requireTenantSource(datasetRef.getSourceId(), account);
DatacenterTable table = datacenterDatasetRegistryService.getTableWithFields(datasetRef.getTableId());
if (table == null
|| !Objects.equals(table.getTenantId(), account.getTenantId())
|| !Objects.equals(table.getSourceId(), datasetRef.getSourceId())) {
throw new BusinessException("数据集不存在或无权访问");
}
DatacenterSchemaResponse schema = datacenterDatasetQueryService.getSchema(datasetRef);
List<DatacenterTableField> fields = schema == null || schema.getFields() == null
? Collections.emptyList()
: schema.getFields();
return new WorkflowDesignerOptionsView.DatasetSchemaOption(
table.getTableName(),
table.getTableDesc(),
fields.stream()
.map(field -> new WorkflowDesignerOptionsView.DatasetFieldOption(
field.getFieldName(),
field.getFieldDesc(),
field.getJdbcType(),
field.getFieldType()
))
.toList()
);
}
private List<WorkflowDesignerOptionsView.ModelOption> listModelOptions(LoginAccount account) {
Model query = new Model();
query.setTenantId(account.getTenantId());
query.setModelType(Model.MODEL_TYPES[0]);
return modelService.listSelectableModels(query, false, "id", "desc").stream()
.filter(model -> Objects.equals(model.getTenantId(), account.getTenantId()))
.map(model -> {
ModelProvider provider = model.getModelProvider();
WorkflowDesignerOptionsView.ProviderOption providerOption = provider == null
? null
: new WorkflowDesignerOptionsView.ProviderOption(
provider.getProviderName(),
provider.getProviderType(),
provider.getIcon()
);
return new WorkflowDesignerOptionsView.ModelOption(
model.getId(),
model.getTitle(),
model.getDescription(),
providerOption
);
})
.toList();
}
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
List<DocumentCollection> collections = documentCollectionService.list(QueryWrapper.create()
.eq(DocumentCollection::getTenantId, account.getTenantId())
.orderBy(DocumentCollection::getModified, false));
Set<BigInteger> vectorReadyIds = workflowKnowledgeContractService
.findVectorReadyKnowledgeIds(collections, account.getTenantId());
return collections.stream()
.filter(item -> resourceAccessService.canAccess(
account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
.map(item -> new WorkflowDesignerOptionsView.KnowledgeOption(
item.getId(),
item.getTitle(),
item.getDescription(),
item.getVectorEmbedModelId(),
item.getDimensionOfVectorModel(),
vectorReadyIds.contains(item.getId())))
.toList();
}
private List<BigInteger> readKnowledgeReferenceIds(JSONObject data) {
if (data.containsKey("knowledgeIds")) {
Object rawIds = data.get("knowledgeIds");
if (!(rawIds instanceof JSONArray ids) || ids.isEmpty()) {
throw new BusinessException("知识库节点至少需要选择一个知识库");
}
List<BigInteger> result = new ArrayList<>();
for (Object id : ids) {
BigInteger parsed = parseReferenceId(id, "知识库");
if (result.contains(parsed)) {
throw new BusinessException("知识库节点不能重复选择同一知识库");
}
result.add(parsed);
}
return result;
}
BigInteger legacyId = readReferenceId(data, "knowledgeId", "知识库");
return legacyId == null ? List.of() : List.of(legacyId);
}
private BigInteger parseReferenceId(Object value, String resourceName) {
if (value == null || String.valueOf(value).isBlank()) {
throw new BusinessException(resourceName + "ID不能为空");
}
try {
return new BigInteger(String.valueOf(value));
} catch (NumberFormatException exception) {
throw new BusinessException(resourceName + "ID无效");
}
}
private void addReferenceId(Set<BigInteger> resourceIds, BigInteger resourceId) {
if (resourceId != null) {
resourceIds.add(resourceId);
}
}
private void assertModelReferences(Set<BigInteger> modelIds, LoginAccount account) {
if (modelIds.isEmpty()) {
return;
}
List<Model> models = modelService.listByIds(modelIds);
boolean valid = models.size() == modelIds.size()
&& models.stream().allMatch(model ->
modelIds.contains(model.getId())
&& Objects.equals(model.getTenantId(), account.getTenantId())
&& Model.MODEL_TYPES[0].equals(model.getModelType()));
if (!valid) {
throw new BusinessException("模型不存在、已失效或无权使用");
}
}
private void assertKnowledgeReferences(Set<BigInteger> knowledgeIds, LoginAccount account) {
if (knowledgeIds.isEmpty()) {
return;
}
List<DocumentCollection> knowledges = documentCollectionService.listByIds(knowledgeIds);
boolean valid = knowledges.size() == knowledgeIds.size()
&& knowledges.stream().allMatch(knowledge ->
knowledgeIds.contains(knowledge.getId())
&& Objects.equals(knowledge.getTenantId(), account.getTenantId())
&& resourceAccessService.canAccess(
account, CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE));
if (!valid) {
throw new BusinessException("知识库不存在、已失效或无权使用");
}
}
private void assertWorkflowReference(
BigInteger workflowId,
LoginAccount account,
Set<BigInteger> checkedWorkflowIds) {
if (workflowId == null || !checkedWorkflowIds.add(workflowId)) {
return;
}
workflowUsageAuthorizationService.requireUsableWorkflow(
workflowId,
account,
"子流程不存在、未发布或无权使用");
}
private void assertDatasetReference(
JSONObject data,
LoginAccount account,
Set<BigInteger> checkedSourceIds,
Set<String> checkedTableIds) {
JSONObject datasetRef = data.getJSONObject("datasetRef");
if (datasetRef == null) {
return;
}
BigInteger sourceId = readReferenceId(datasetRef, "sourceId", "数据源");
if (sourceId != null && checkedSourceIds.add(sourceId)) {
requireTenantSource(sourceId, account);
}
BigInteger tableId = readReferenceId(datasetRef, "tableId", "数据集");
if (tableId == null) {
return;
}
if (sourceId == null) {
throw new BusinessException("数据集缺少数据源引用");
}
String checkedKey = sourceId + ":" + tableId;
if (!checkedTableIds.add(checkedKey)) {
return;
}
DatacenterTable table = datacenterDatasetRegistryService.getTableWithFields(tableId);
if (table == null
|| !Objects.equals(table.getTenantId(), account.getTenantId())
|| !Objects.equals(table.getSourceId(), sourceId)) {
throw new BusinessException("数据集不存在或无权使用");
}
}
private BigInteger readReferenceId(JSONObject data, String key, String resourceName) {
Object value = data.get(key);
if (value == null || String.valueOf(value).isBlank()) {
return null;
}
try {
return new BigInteger(String.valueOf(value));
} catch (NumberFormatException exception) {
throw new BusinessException(resourceName + "引用格式无效");
}
}
private WorkflowDesignerOptionsView.PluginOption toPluginOption(Plugin plugin) {
List<WorkflowDesignerOptionsView.PluginToolOption> tools =
plugin.getTools() == null ? List.of() : plugin.getTools().stream()
.filter(tool -> Integer.valueOf(1).equals(tool.getStatus()))
.map(tool -> new WorkflowDesignerOptionsView.PluginToolOption(
tool.getId(), tool.getName(), tool.getDescription()))
.toList();
return new WorkflowDesignerOptionsView.PluginOption(
plugin.getId(),
plugin.getName(),
plugin.getDescription(),
plugin.getIcon(),
tools
);
}
private JSONArray parseSchemaArray(String content) {
if (content == null || content.isBlank()) {
return new JSONArray();
}
JSONArray array = JSON.parseArray(content);
decorateSchemaArray(array);
return array;
}
private void decorateSchemaArray(JSONArray array) {
for (Object item : array) {
if (!(item instanceof JSONObject value)) {
continue;
}
value.put("id", cn.hutool.core.util.IdUtil.simpleUUID());
value.put("nameDisabled", true);
value.put("dataTypeDisabled", true);
value.put("deleteDisabled", true);
value.put("addChildDisabled", true);
JSONArray children = value.getJSONArray("children");
if (children != null) {
decorateSchemaArray(children);
}
}
}
/**
* 为子流程参数补充设计器只读元数据。
*
* @param array 子流程参数定义
*/
private void decorateChildWorkflowSchemaArray(JSONArray array) {
for (Object item : array) {
if (!(item instanceof JSONObject value)) {
continue;
}
value.put("id", cn.hutool.core.util.IdUtil.simpleUUID());
value.put("nameDisabled", true);
value.put("dataTypeDisabled", true);
value.put("deleteDisabled", true);
value.put("addChildDisabled", true);
value.put("refType", "ref");
JSONArray children = value.getJSONArray("children");
if (children != null) {
decorateChildWorkflowSchemaArray(children);
}
}
}
private String resolveSchemaHash(PluginItem record, Plugin plugin) {
if (record.getSchemaHash() != null && !record.getSchemaHash().isBlank()) {
return record.getSchemaHash();
}
if (!PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
return null;
}
Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
return workflow == null ? null : workflowPluginSnapshotResolver.resolveSchemaHash(workflow);
}
private WorkflowDesignerOptionsView.CatalogOption toCatalogOption(DatacenterCatalogMeta catalog) {
return new WorkflowDesignerOptionsView.CatalogOption(
catalog.getId(),
catalog.getSourceId(),
catalog.getCatalogName(),
catalog.getCatalogDesc()
);
}
private WorkflowDesignerOptionsView.DatasetOption toDatasetOption(DatacenterTable table) {
return new WorkflowDesignerOptionsView.DatasetOption(
table.getId(),
table.getTenantId(),
table.getSourceId(),
table.getCatalogId(),
table.getTableName(),
table.getTableDesc()
);
}
private DatacenterSource requireTenantSource(BigInteger sourceId, LoginAccount account) {
if (sourceId == null) {
throw new BusinessException("数据源不能为空");
}
DatacenterSource source = datacenterSourceService.getById(sourceId);
if (source == null || !Objects.equals(source.getTenantId(), account.getTenantId())) {
throw new BusinessException("数据源不存在或无权访问");
}
return source;
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
}

View File

@@ -1,165 +0,0 @@
package tech.easyflow.admin.service.ai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
/**
* 工作流匿名分享的限流与活动执行互斥保护。
*/
@Component
public class WorkflowPublicChatAccessGuard {
private static final Logger log = LoggerFactory.getLogger(
WorkflowPublicChatAccessGuard.class);
private static final String KEY_PREFIX = "easyflow:workflow-public-share:";
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
static {
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
RATE_LIMIT_SCRIPT.setScriptText(
"local visitor = redis.call('incr', KEYS[1]); "
+ "if visitor == 1 then redis.call('pexpire', KEYS[1], ARGV[3]); end; "
+ "local share = redis.call('incr', KEYS[2]); "
+ "if share == 1 then redis.call('pexpire', KEYS[2], ARGV[3]); end; "
+ "if visitor > tonumber(ARGV[1]) or share > tonumber(ARGV[2]) "
+ "then return 0 else return 1 end"
);
RATE_LIMIT_SCRIPT.setResultType(Long.class);
}
private final StringRedisTemplate redisTemplate;
private final RedisLockExecutor redisLockExecutor;
private final WorkflowPublicShareProperties properties;
public WorkflowPublicChatAccessGuard(
StringRedisTemplate redisTemplate,
RedisLockExecutor redisLockExecutor,
WorkflowPublicShareProperties properties
) {
this.redisTemplate = redisTemplate;
this.redisLockExecutor = redisLockExecutor;
this.properties = properties;
}
/**
* 检查匿名运行固定窗口限流。
*/
public void checkRun(BigInteger shareId, String visitorDigest) {
checkRate(
shareId,
visitorDigest,
"run",
properties.getRunVisitorLimit(),
properties.getRunShareLimit()
);
}
/**
* 检查匿名上传固定窗口限流。
*/
public void checkUpload(BigInteger shareId, String visitorDigest) {
checkRate(
shareId,
visitorDigest,
"upload",
properties.getUploadVisitorLimit(),
properties.getUploadShareLimit()
);
}
/**
* 获取同一分享访客的活动执行锁。
*
* @return 由 SSE 生命周期显式释放的锁句柄
*/
public RedisLockExecutor.LockHandle acquireActivity(
BigInteger shareId,
String visitorDigest
) {
try {
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
KEY_PREFIX + "{" + shareId + "}:active:" + visitorDigest,
Duration.ZERO,
properties.getActiveLease()
);
if (handle == null) {
throw new BusinessException(
409,
40931,
"当前分享访客已有工作流正在运行"
);
}
return handle;
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("匿名工作流活动锁暂不可用shareId={}", shareId, exception);
throw unavailable(exception);
}
}
/**
* 获取匿名活动执行锁的租约,用作浏览器断开后的监听保留上限。
*/
public Duration activityLease() {
return properties.getActiveLease();
}
private void checkRate(
BigInteger shareId,
String visitorDigest,
String action,
int visitorLimit,
int shareLimit
) {
String slot = "{" + shareId + "}";
List<String> keys = List.of(
KEY_PREFIX + slot + ":rate:" + action + ":visitor:" + visitorDigest,
KEY_PREFIX + slot + ":rate:" + action + ":share"
);
try {
Long allowed = redisTemplate.execute(
RATE_LIMIT_SCRIPT,
keys,
String.valueOf(visitorLimit),
String.valueOf(shareLimit),
String.valueOf(properties.getRateWindow().toMillis())
);
if (allowed == null) {
throw unavailable(new IllegalStateException(
"Redis 未返回匿名工作流限流结果"));
}
if (!Long.valueOf(1L).equals(allowed)) {
throw new BusinessException(
429,
42931,
"匿名工作流请求过于频繁,请稍后重试"
);
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("匿名工作流限流暂不可用shareId={}, action={}",
shareId, action, exception);
throw unavailable(exception);
}
}
private BusinessException unavailable(RuntimeException cause) {
return new BusinessException(
503,
50331,
"匿名工作流保护服务暂不可用,请稍后重试",
cause
);
}
}

View File

@@ -1,17 +0,0 @@
package tech.easyflow.admin.service.ai;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.common.entity.LoginAccount;
/**
* 完成匿名分享边界校验后的运行上下文。
*/
public record WorkflowPublicChatContext(
WorkflowShare share,
Workflow workflow,
LoginAccount creator,
String shareKey,
String visitorDigest
) {
}

View File

@@ -1,121 +0,0 @@
package tech.easyflow.admin.service.ai;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* 解析并校验工作流匿名分享上下文。
*/
@Service
public class WorkflowPublicChatContextResolver {
private static final Pattern VISITOR_PATTERN = Pattern.compile("[a-f0-9]{32}");
private final WorkflowShareService shareService;
private final WorkflowService workflowService;
private final SysAccountService accountService;
public WorkflowPublicChatContextResolver(
WorkflowShareService shareService,
WorkflowService workflowService,
SysAccountService accountService
) {
this.shareService = shareService;
this.workflowService = workflowService;
this.accountService = accountService;
}
/**
* 解析新运行、恢复与上传所需的当前有效上下文。
*/
public WorkflowPublicChatContext resolveActive(
String shareKey,
String visitorId
) {
String normalizedVisitor = requireVisitor(visitorId);
WorkflowShare share = shareService.resolvePublicChatShare(shareKey);
Workflow workflow = TenantManager.withoutTenantCondition(
() -> workflowService.getPublishedById(share.getWorkflowId()));
if (!isStrictlyPublished(workflow)
|| !Objects.equals(share.getTenantId(), workflow.getTenantId())) {
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
}
SysAccount account = TenantManager.withoutTenantCondition(
() -> accountService.getById(share.getCreatedBy()));
if (account == null
|| !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())
|| !Objects.equals(share.getTenantId(), account.getTenantId())) {
throw new BusinessException(
403,
40331,
"工作流分享创建者账号当前不可用"
);
}
LoginAccount creator = account.toLoginAccount();
return new WorkflowPublicChatContext(
share,
workflow,
creator,
shareKey,
WorkflowSharePolicy.hashChatVisitor(
shareKey,
normalizedVisitor
)
);
}
/**
* 解析已发起执行的详情与取消所需历史上下文。
*/
public WorkflowPublicChatContext resolveHistorical(
String shareKey,
String visitorId
) {
String normalizedVisitor = requireVisitor(visitorId);
WorkflowShare share = shareService.resolveHistoricalChatShare(shareKey);
return new WorkflowPublicChatContext(
share,
null,
null,
shareKey,
WorkflowSharePolicy.hashChatVisitor(
shareKey,
normalizedVisitor
)
);
}
private String requireVisitor(String visitorId) {
String normalized = visitorId == null ? "" : visitorId.trim();
if (!VISITOR_PATTERN.matcher(normalized).matches()) {
throw new BusinessException(
400,
40031,
"工作流分享访客标识无效"
);
}
return normalized;
}
private boolean isStrictlyPublished(Workflow workflow) {
return workflow != null
&& PublishStatus.PUBLISHED.getCode().equals(
workflow.getPublishStatus())
&& workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty();
}
}

View File

@@ -1,295 +0,0 @@
package tech.easyflow.admin.service.ai;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.tenant.TenantManager;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 工作流匿名分享对话应用服务。
*/
@Service
public class WorkflowPublicChatService {
private static final Logger log =
LoggerFactory.getLogger(WorkflowPublicChatService.class);
private final WorkflowPublicChatContextResolver contextResolver;
private final WorkflowCheckService workflowCheckService;
private final WorkflowRunningParameterResolver parameterResolver;
private final WorkflowPublicChatUploadService uploadService;
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowChatEventStream eventStream;
private final ChainExecutor chainExecutor;
private final WorkflowResumeService workflowResumeService;
private final WorkflowExecResultService execResultService;
private final WorkflowExecStepService execStepService;
public WorkflowPublicChatService(
WorkflowPublicChatContextResolver contextResolver,
WorkflowCheckService workflowCheckService,
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatUploadService uploadService,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
WorkflowResumeService workflowResumeService,
WorkflowExecResultService execResultService,
WorkflowExecStepService execStepService
) {
this.contextResolver = contextResolver;
this.workflowCheckService = workflowCheckService;
this.parameterResolver = parameterResolver;
this.uploadService = uploadService;
this.accessGuard = accessGuard;
this.eventStream = eventStream;
this.chainExecutor = chainExecutor;
this.workflowResumeService = workflowResumeService;
this.execResultService = execResultService;
this.execStepService = execStepService;
}
/**
* 获取匿名分享的发布工作流描述。
*/
public Map<String, Object> descriptor(String shareKey, String visitorId) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
checkWorkflow(context);
Map<String, Object> descriptor = parameterResolver
.buildRunningParametersView(context.workflow());
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
descriptor.put("workflowId", context.workflow().getId());
descriptor.put("publishStatus", context.workflow().getPublishStatus());
descriptor.put("shareable", false);
return descriptor;
}
/**
* 启动匿名分享工作流并返回 SSE。
*/
public SseEmitter run(
String shareKey,
String visitorId,
Map<String, Object> variables
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
accessGuard.checkRun(
context.share().getId(),
context.visitorDigest()
);
checkWorkflow(context);
Map<String, Object> normalized = parameterResolver
.normalizeRuntimeVariables(
context.workflow().getContent(),
variables
);
uploadService.assertOwnedUploads(context, normalized);
normalized.put(Constants.LOGIN_USER_KEY, context.creator());
normalized.put(
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
WorkFlowUtil.publicChatShareCreatedKey(
context.share().getId())
);
normalized.put(
WorkFlowUtil.CREATED_BY_MEMORY_KEY,
context.visitorDigest()
);
RedisLockExecutor.LockHandle activity = accessGuard.acquireActivity(
context.share().getId(),
context.visitorDigest()
);
try {
return eventStream.start(
PublishedWorkflowDefinitionIds.published(
context.workflow().getId().toString()),
normalized,
activity::release,
accessGuard.activityLease()
);
} catch (RuntimeException | Error error) {
activity.release();
throw error;
}
}
/**
* 获取当前匿名访客发起的执行详情。
*/
public Map<String, Object> detail(
String shareKey,
String visitorId,
String executeId
) {
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
shareKey, visitorId);
WorkflowExecResult record = assertExecutionOwnership(
context, executeId);
List<WorkflowExecStep> steps = TenantManager.withoutTenantCondition(
() -> execStepService.list(
QueryWrapper.create()
.eq(WorkflowExecStep::getRecordId, record.getId())
.orderBy(WorkflowExecStep::getStartTime, true)
));
return buildExecutionDetail(record, steps, eventStream.runtimeView(executeId));
}
/**
* 取消当前匿名访客发起的执行。
*/
public boolean cancel(
String shareKey,
String visitorId,
String executeId
) {
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
shareKey, visitorId);
assertExecutionOwnership(context, executeId);
return chainExecutor.cancel(executeId, "匿名访客已中止运行");
}
/**
* 恢复当前有效分享访客等待确认的执行。
*/
public void resume(
String shareKey,
String visitorId,
String executeId,
Map<String, Object> confirmParams
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
assertExecutionOwnership(context, executeId);
workflowResumeService.resume(executeId, confirmParams);
}
/**
* 上传当前发布快照声明的匿名输入文件。
*/
public UploadResVo upload(
String shareKey,
String visitorId,
String parameterName,
MultipartFile file
) {
WorkflowPublicChatContext context = contextResolver.resolveActive(
shareKey, visitorId);
return uploadService.upload(context, parameterName, file);
}
private void checkWorkflow(WorkflowPublicChatContext context) {
TenantManager.withoutTenantCondition(() -> {
workflowCheckService.checkOrThrow(
context.workflow().getContent(),
WorkflowCheckStage.PRE_EXECUTE,
context.workflow().getId()
);
return null;
});
}
private WorkflowExecResult assertExecutionOwnership(
WorkflowPublicChatContext context,
String executeId
) {
if (executeId == null || executeId.isBlank()) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = TenantManager.withoutTenantCondition(
() -> execResultService.getByExecKey(executeId));
if (record == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
}
String expectedSource = WorkFlowUtil.publicChatShareCreatedKey(
context.share().getId());
if (!Objects.equals(expectedSource, record.getCreatedKey())
|| !Objects.equals(
context.visitorDigest(),
record.getCreatedBy())
|| !Objects.equals(
context.share().getWorkflowId(),
record.getWorkflowId())) {
throw new BusinessException(
403,
40333,
"无权限访问当前工作流执行记录"
);
}
return record;
}
private Map<String, Object> buildExecutionDetail(
WorkflowExecResult record,
List<WorkflowExecStep> steps,
Map<String, Object> runtime
) {
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
for (WorkflowExecStep step : steps) {
Map<String, Object> view = new LinkedHashMap<>();
view.put("id", step.getId());
view.put("attemptKey", step.getExecKey());
view.put("nodeId", step.getNodeId());
view.put("nodeName", step.getNodeName());
view.put("input", step.getInput());
view.put("output", step.getOutput());
view.put("status", step.getStatus());
view.put("errorInfo", step.getErrorInfo());
view.put("startTime", step.getStartTime());
view.put("endTime", step.getEndTime());
view.put("execTime", step.getExecTime());
stepViews.add(view);
}
Map<String, Object> recordView = new LinkedHashMap<>();
recordView.put("executeId", record.getExecKey());
recordView.put("workflowId", record.getWorkflowId());
recordView.put("title", record.getTitle());
recordView.put("status", record.getStatus());
recordView.put("input", record.getInput());
recordView.put("output", record.getOutput());
recordView.put("errorInfo", record.getErrorInfo());
recordView.put("startTime", record.getStartTime());
recordView.put("endTime", record.getEndTime());
recordView.put("execTime", record.getExecTime());
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("record", recordView);
detail.put("steps", stepViews);
detail.put("runtime", runtime);
return detail;
}
/**
* 构建刷新恢复所需的最小 Runtime 视图。
*/
}

View File

@@ -1,325 +0,0 @@
package tech.easyflow.admin.service.ai;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 工作流匿名分享的隔离上传与运行引用校验。
*/
@Service
public class WorkflowPublicChatUploadService {
private static final Logger log = LoggerFactory.getLogger(
WorkflowPublicChatUploadService.class);
private static final long FILE_MAX_SIZE = 100L * 1024L * 1024L;
private static final long IMAGE_MAX_SIZE = 10L * 1024L * 1024L;
private static final Set<String> IMAGE_MIME_TYPES = Set.of(
"image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp");
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
"bmp", "gif", "jpeg", "jpg", "png", "webp");
private static final String GRANT_PREFIX = "easyflow:workflow-public-share:upload:";
private final WorkflowRunningParameterResolver parameterResolver;
private final WorkflowPublicChatAccessGuard accessGuard;
private final WorkflowPublicShareProperties properties;
private final StringRedisTemplate redisTemplate;
private final FileStorageService storageService;
public WorkflowPublicChatUploadService(
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowPublicShareProperties properties,
StringRedisTemplate redisTemplate,
@Qualifier("default") FileStorageService storageService
) {
this.parameterResolver = parameterResolver;
this.accessGuard = accessGuard;
this.properties = properties;
this.redisTemplate = redisTemplate;
this.storageService = storageService;
}
/**
* 上传发布快照声明的文件或图片参数。
*/
public UploadResVo upload(
WorkflowPublicChatContext context,
String parameterName,
MultipartFile file
) {
String normalizedName = requireParameterName(parameterName);
String contentType = resolveUploadContentType(context, normalizedName);
validateFile(file, contentType);
accessGuard.checkUpload(
context.share().getId(),
context.visitorDigest()
);
String path = storageService.save(
file,
"workflow-chat-share/" + context.share().getId()
+ "/" + context.visitorDigest()
);
if (!StringUtils.hasText(path)) {
throw new BusinessException(503, 50332, "匿名文件上传失败,请稍后重试");
}
try {
redisTemplate.opsForValue().set(
grantKey(context, normalizedName, path),
contentType,
grantTtl(context).toMillis(),
TimeUnit.MILLISECONDS
);
} catch (RuntimeException exception) {
try {
storageService.delete(path);
} catch (RuntimeException cleanupError) {
log.warn("匿名上传授权写入失败后清理文件失败path={}",
path, cleanupError);
}
throw new BusinessException(
503,
50332,
"匿名上传保护服务暂不可用,请稍后重试",
exception
);
}
UploadResVo response = new UploadResVo();
response.setPath(path);
return response;
}
/**
* 校验公开运行引用的上传文件均属于当前分享访客和参数。
*/
public void assertOwnedUploads(
WorkflowPublicChatContext context,
Map<String, Object> variables
) {
Map<String, String> uploadFields = resolveUploadFields(context);
for (Map.Entry<String, String> entry : uploadFields.entrySet()) {
Object value = variables.get(entry.getKey());
if (value == null) {
continue;
}
if ("image".equals(entry.getValue())) {
assertOwnedImage(context, entry.getKey(), value);
} else {
assertOwnedFiles(context, entry.getKey(), value);
}
}
}
private void assertOwnedImage(
WorkflowPublicChatContext context,
String parameterName,
Object value
) {
if (!(value instanceof Map<?, ?> image)) {
throw invalidUploadReference(parameterName);
}
String sourceType = trim(image.get("sourceType"));
if ("url".equals(sourceType)) {
String url = trim(image.get("url"));
if (isHttpUrl(url)) {
return;
}
throw invalidUploadReference(parameterName);
}
if (!"upload".equals(sourceType)) {
throw invalidUploadReference(parameterName);
}
assertGrant(
context,
parameterName,
trim(image.get("filePath")),
"image"
);
}
private void assertOwnedFiles(
WorkflowPublicChatContext context,
String parameterName,
Object value
) {
if (!(value instanceof Collection<?> files)) {
throw invalidUploadReference(parameterName);
}
for (Object item : files) {
if (!(item instanceof Map<?, ?> file)) {
throw invalidUploadReference(parameterName);
}
assertGrant(
context,
parameterName,
trim(file.get("filePath")),
"file"
);
}
}
private void assertGrant(
WorkflowPublicChatContext context,
String parameterName,
String path,
String expectedContentType
) {
if (!StringUtils.hasText(path)) {
throw invalidUploadReference(parameterName);
}
try {
String grantedContentType = redisTemplate.opsForValue().get(
grantKey(context, parameterName, path));
if (!expectedContentType.equals(grantedContentType)) {
throw invalidUploadReference(parameterName);
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BusinessException(
503,
50332,
"匿名上传保护服务暂不可用,请稍后重试",
exception
);
}
}
private String resolveUploadContentType(
WorkflowPublicChatContext context,
String parameterName
) {
String contentType = resolveUploadFields(context).get(parameterName);
if (contentType == null) {
throw new BusinessException(
400,
40032,
"当前发布工作流未声明该上传参数"
);
}
return contentType;
}
@SuppressWarnings("unchecked")
private Map<String, String> resolveUploadFields(
WorkflowPublicChatContext context
) {
Map<String, Object> descriptor = parameterResolver
.buildRunningParametersView(context.workflow());
if (descriptor == null) {
throw new BusinessException("工作流输入配置无法解析");
}
Map<String, String> fields = new java.util.LinkedHashMap<>();
Object rawSchema = descriptor.get("startFormSchema");
if (!(rawSchema instanceof Collection<?> schema)) {
return fields;
}
for (Object item : schema) {
if (!(item instanceof Map<?, ?> field)) {
continue;
}
String name = trim(field.get("key"));
String contentType = trim(field.get("contentType"));
if (StringUtils.hasText(name)
&& ("file".equals(contentType)
|| "image".equals(contentType))) {
fields.put(name, contentType);
}
}
return fields;
}
private void validateFile(MultipartFile file, String contentType) {
if (file == null || file.isEmpty()) {
throw new BusinessException("上传文件不能为空");
}
long maxSize = "image".equals(contentType)
? IMAGE_MAX_SIZE
: FILE_MAX_SIZE;
if (file.getSize() > maxSize) {
throw new BusinessException(
"image".equals(contentType)
? "单张图片不能超过 10 MiB"
: "单个文件不能超过 100 MiB"
);
}
if (!"image".equals(contentType)) {
return;
}
String mimeType = trim(file.getContentType()).toLowerCase(Locale.ROOT);
String filename = trim(file.getOriginalFilename());
int dot = filename.lastIndexOf('.');
String extension = dot < 0
? ""
: filename.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!IMAGE_MIME_TYPES.contains(mimeType)
&& !IMAGE_EXTENSIONS.contains(extension)) {
throw new BusinessException("仅支持 PNG、JPEG、WebP、GIF、BMP 图片");
}
}
private Duration grantTtl(WorkflowPublicChatContext context) {
long expiresIn = context.share().getExpiresAt().getTime()
- System.currentTimeMillis();
long ttl = Math.min(
properties.getUploadGrantTtl().toMillis(),
expiresIn
);
return Duration.ofMillis(Math.max(1L, ttl));
}
private String grantKey(
WorkflowPublicChatContext context,
String parameterName,
String path
) {
return GRANT_PREFIX + "{" + context.share().getId() + "}:"
+ context.visitorDigest() + ":"
+ WorkflowSharePolicy.hashShareKey(parameterName) + ":"
+ WorkflowSharePolicy.hashShareKey(path);
}
private String requireParameterName(String value) {
String normalized = value == null ? "" : value.trim();
if (!StringUtils.hasText(normalized)) {
throw new BusinessException("上传参数名不能为空");
}
return normalized;
}
private String trim(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private boolean isHttpUrl(String value) {
String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT);
return normalized.startsWith("http://")
|| normalized.startsWith("https://");
}
private BusinessException invalidUploadReference(String parameterName) {
return new BusinessException(
403,
40332,
"上传参数 " + parameterName + " 不属于当前分享访客"
);
}
}

View File

@@ -1,92 +0,0 @@
package tech.easyflow.admin.service.ai;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 工作流匿名分享运行保护参数。
*/
@Component
@ConfigurationProperties(prefix = "easyflow.workflow.public-share")
public class WorkflowPublicShareProperties {
private Duration rateWindow = Duration.ofMinutes(1);
private int runVisitorLimit = 5;
private int runShareLimit = 60;
private int uploadVisitorLimit = 10;
private int uploadShareLimit = 60;
private Duration activeLease = Duration.ofMinutes(35);
private Duration uploadGrantTtl = Duration.ofDays(7);
public Duration getRateWindow() {
return rateWindow;
}
public void setRateWindow(Duration rateWindow) {
this.rateWindow = requirePositive(rateWindow, "rateWindow");
}
public int getRunVisitorLimit() {
return runVisitorLimit;
}
public void setRunVisitorLimit(int runVisitorLimit) {
this.runVisitorLimit = requirePositive(runVisitorLimit, "runVisitorLimit");
}
public int getRunShareLimit() {
return runShareLimit;
}
public void setRunShareLimit(int runShareLimit) {
this.runShareLimit = requirePositive(runShareLimit, "runShareLimit");
}
public int getUploadVisitorLimit() {
return uploadVisitorLimit;
}
public void setUploadVisitorLimit(int uploadVisitorLimit) {
this.uploadVisitorLimit = requirePositive(uploadVisitorLimit, "uploadVisitorLimit");
}
public int getUploadShareLimit() {
return uploadShareLimit;
}
public void setUploadShareLimit(int uploadShareLimit) {
this.uploadShareLimit = requirePositive(uploadShareLimit, "uploadShareLimit");
}
public Duration getActiveLease() {
return activeLease;
}
public void setActiveLease(Duration activeLease) {
this.activeLease = requirePositive(activeLease, "activeLease");
}
public Duration getUploadGrantTtl() {
return uploadGrantTtl;
}
public void setUploadGrantTtl(Duration uploadGrantTtl) {
this.uploadGrantTtl = requirePositive(uploadGrantTtl, "uploadGrantTtl");
}
private static int requirePositive(int value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " 必须大于 0");
}
return value;
}
private static Duration requirePositive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " 必须大于 0");
}
return value;
}
}

View File

@@ -8,8 +8,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
@@ -33,7 +33,6 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysAccountRole;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService;
@@ -82,7 +81,7 @@ public class DashboardServiceImpl implements DashboardService {
private ChatDashboardQueryService chatDashboardQueryService;
@Resource
private AgentService agentService;
private BotService botService;
@Resource
private CategoryPermissionService categoryPermissionService;
@@ -157,7 +156,7 @@ public class DashboardServiceImpl implements DashboardService {
DashboardSummaryVo summary = new DashboardSummaryVo();
summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context));
summary.setActiveUserTotal(countActiveUsers(context));
summary.setAgentTotal(countScopedTable("tb_agent", "a", false, context));
summary.setBotTotal(countScopedTable("tb_bot", "b", false, context));
summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context));
summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context));
summary.setChatMessageTotal(0L);
@@ -864,18 +863,18 @@ public class DashboardServiceImpl implements DashboardService {
if (assistantId == null) {
return null;
}
Agent agent = agentService.getById(assistantId);
if (agent == null) {
throw new BusinessException("智能体不存在或不可见");
Bot bot = botService.getById(assistantId);
if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) {
throw new BusinessException("聊天助手不存在或未启用");
}
boolean visible = categoryPermissionService.canAccessCategory(
loginAccount,
CategoryResourceType.AGENT.getCode(),
agent.getCreatedBy(),
agent.getCategoryId()
"BOT",
bot.getCreatedBy(),
bot.getCategoryId()
);
if (!visible) {
throw new BusinessException("智能体不存在或不可见");
throw new BusinessException("聊天助手不存在或未启用");
}
return assistantId;
}

View File

@@ -1,510 +0,0 @@
package tech.easyflow.admin.service.system;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.admin.model.SystemFormOptionsView;
import tech.easyflow.approval.entity.vo.ApprovalFlowScopeVo;
import tech.easyflow.approval.enums.ApprovalScopeType;
import tech.easyflow.agent.entity.AgentCategory;
import tech.easyflow.agent.service.AgentCategoryService;
import tech.easyflow.ai.entity.DocumentCollectionCategory;
import tech.easyflow.ai.entity.PluginCategory;
import tech.easyflow.ai.entity.ResourceCategory;
import tech.easyflow.ai.entity.WorkflowCategory;
import tech.easyflow.ai.service.DocumentCollectionCategoryService;
import tech.easyflow.ai.service.PluginCategoryService;
import tech.easyflow.ai.service.ResourceCategoryService;
import tech.easyflow.ai.service.WorkflowCategoryService;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.entity.SysMenu;
import tech.easyflow.system.entity.SysPosition;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.entity.vo.SysRoleCategoryScopeItemVo;
import tech.easyflow.system.service.SysDeptService;
import tech.easyflow.system.service.SysMenuService;
import tech.easyflow.system.service.SysPositionService;
import tech.easyflow.system.service.SysRoleService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
/**
* 查询并校验系统管理表单所需的安全选项。
*/
@Service
public class SystemFormOptionService {
private final AgentCategoryService agentCategoryService;
private final WorkflowCategoryService workflowCategoryService;
private final DocumentCollectionCategoryService documentCollectionCategoryService;
private final PluginCategoryService pluginCategoryService;
private final ResourceCategoryService resourceCategoryService;
private final SysDeptService sysDeptService;
private final SysMenuService sysMenuService;
private final SysRoleService sysRoleService;
private final SysPositionService sysPositionService;
/**
* 创建系统表单选项服务。
*
* @param agentCategoryService Agent 分类服务
* @param workflowCategoryService 工作流分类服务
* @param documentCollectionCategoryService 知识库分类服务
* @param pluginCategoryService 插件分类服务
* @param resourceCategoryService 素材分类服务
* @param sysDeptService 部门服务
* @param sysMenuService 菜单服务
* @param sysRoleService 角色服务
* @param sysPositionService 岗位服务
*/
public SystemFormOptionService(
AgentCategoryService agentCategoryService,
WorkflowCategoryService workflowCategoryService,
DocumentCollectionCategoryService documentCollectionCategoryService,
PluginCategoryService pluginCategoryService,
ResourceCategoryService resourceCategoryService,
SysDeptService sysDeptService,
SysMenuService sysMenuService,
SysRoleService sysRoleService,
SysPositionService sysPositionService) {
this.agentCategoryService = agentCategoryService;
this.workflowCategoryService = workflowCategoryService;
this.documentCollectionCategoryService = documentCollectionCategoryService;
this.pluginCategoryService = pluginCategoryService;
this.resourceCategoryService = resourceCategoryService;
this.sysDeptService = sysDeptService;
this.sysMenuService = sysMenuService;
this.sysRoleService = sysRoleService;
this.sysPositionService = sysPositionService;
}
/**
* 查询审批流程资源范围选项。
*
* @return 审批流程资源范围选项
*/
public SystemFormOptionsView.ApprovalResourceScopeOptions approvalResourceScopeOptions() {
Map<String, List<SystemFormOptionsView.CategoryOption>> categories = new LinkedHashMap<>();
categories.put("AGENT", listAgentCategories());
categories.put("WORKFLOW", listWorkflowCategories());
categories.put("KNOWLEDGE", listKnowledgeCategories());
return new SystemFormOptionsView.ApprovalResourceScopeOptions(
categories,
listDepartments()
);
}
/**
* 查询角色管理表单选项。
*
* @return 角色管理表单选项
*/
public SystemFormOptionsView.RoleFormOptions roleFormOptions() {
Map<String, List<SystemFormOptionsView.CategoryOption>> categories = new LinkedHashMap<>();
categories.put("AGENT", listAgentCategories());
categories.put("PLUGIN", listPluginCategories());
categories.put("WORKFLOW", listWorkflowCategories());
categories.put("KNOWLEDGE", listKnowledgeCategories());
categories.put("RESOURCE", listResourceCategories());
return new SystemFormOptionsView.RoleFormOptions(
listMenus(),
categories
);
}
/**
* 查询账号管理表单选项。
*
* @return 账号管理表单选项
*/
public SystemFormOptionsView.AccountFormOptions accountFormOptions() {
BigInteger tenantId = requireAccount().getTenantId();
List<SystemFormOptionsView.RoleOption> roles = sysRoleService.list(QueryWrapper.create()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(SysRole::getId, true))
.stream()
.map(role -> new SystemFormOptionsView.RoleOption(role.getId(), role.getRoleName()))
.toList();
List<SystemFormOptionsView.PositionOption> positions = sysPositionService.list(QueryWrapper.create()
.eq(SysPosition::getTenantId, tenantId)
.eq(SysPosition::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(SysPosition::getSortNo, true))
.stream()
.map(position -> new SystemFormOptionsView.PositionOption(
position.getId(), position.getPositionName()))
.toList();
return new SystemFormOptionsView.AccountFormOptions(
listDepartments(),
roles,
positions
);
}
/**
* 校验账号表单引用的部门、角色和岗位。
*
* @param account 账号表单
* @throws BusinessException 引用不存在或已禁用时抛出
*/
public void validateAccountReferences(SysAccount account) {
if (account == null) {
throw new BusinessException("账号信息不能为空");
}
if (account.getDeptId() == null) {
throw new BusinessException("部门不能为空");
}
SysDept dept = sysDeptService.getById(account.getDeptId());
BigInteger tenantId = requireAccount().getTenantId();
if (dept == null
|| !Objects.equals(dept.getTenantId(), tenantId)
|| !EnumDataStatus.AVAILABLE.getCode().equals(dept.getStatus())) {
throw new BusinessException("部门不存在或已禁用");
}
assertAvailableIds(
account.getRoleIds(),
sysRoleService::listByIds,
SysRole::getId,
SysRole::getStatus,
SysRole::getTenantId,
tenantId,
"角色"
);
if (account.getPositionIds() != null && !account.getPositionIds().isEmpty()) {
assertAvailableIds(
account.getPositionIds(),
sysPositionService::listByIds,
SysPosition::getId,
SysPosition::getStatus,
SysPosition::getTenantId,
tenantId,
"岗位"
);
}
}
/**
* 校验角色引用的菜单和部门。
*
* @param role 角色表单
* @throws BusinessException 引用不存在时抛出
*/
public void validateRoleReferences(SysRole role) {
if (role == null) {
throw new BusinessException("角色信息不能为空");
}
assertExistingIds(role.getMenuIds(), sysMenuService::listByIds, SysMenu::getId, "菜单");
if (role.getDeptIds() != null && !role.getDeptIds().isEmpty()) {
assertTenantIds(
role.getDeptIds(),
sysDeptService::listByIds,
SysDept::getId,
SysDept::getTenantId,
requireAccount().getTenantId(),
"部门");
}
}
/**
* 校验角色分类授权中提交的非 Bot 分类 ID。
*
* @param scopes 分类权限范围
* @throws BusinessException 分类类型或分类 ID 非法时抛出
*/
public void validateCategoryScopes(List<SysRoleCategoryScopeItemVo> scopes) {
if (scopes == null) {
return;
}
for (SysRoleCategoryScopeItemVo scope : scopes) {
if (scope == null || scope.getCategoryIds() == null || scope.getCategoryIds().isEmpty()) {
continue;
}
switch (String.valueOf(scope.getResourceType()).toUpperCase()) {
case "AGENT" -> assertTenantIds(
scope.getCategoryIds(),
agentCategoryService::listByIds,
AgentCategory::getId,
AgentCategory::getTenantId,
requireAccount().getTenantId(),
"Agent 分类");
case "PLUGIN" -> assertExistingIds(
scope.getCategoryIds(), pluginCategoryService::listByIds, PluginCategory::getId, "插件分类");
case "WORKFLOW" -> assertExistingIds(
scope.getCategoryIds(), workflowCategoryService::listByIds, WorkflowCategory::getId, "工作流分类");
case "KNOWLEDGE" -> assertExistingIds(
scope.getCategoryIds(),
documentCollectionCategoryService::listByIds,
DocumentCollectionCategory::getId,
"知识库分类");
case "RESOURCE" -> assertExistingIds(
scope.getCategoryIds(), resourceCategoryService::listByIds, ResourceCategory::getId, "素材分类");
case "BOT" -> throw new BusinessException("Bot 分类授权已停止维护");
default -> throw new BusinessException("不支持的分类资源类型");
}
}
}
/**
* 校验审批流程提交的分类和部门范围。
*
* @param resourceType 审批资源类型
* @param scopes 审批范围
* @throws BusinessException 范围引用不存在或资源类型不匹配时抛出
*/
public void validateApprovalScopes(String resourceType, List<ApprovalFlowScopeVo> scopes) {
if (scopes == null) {
return;
}
for (ApprovalFlowScopeVo scope : scopes) {
if (scope == null || scope.getScopeValue() == null) {
continue;
}
String scopeType = String.valueOf(scope.getScopeType()).toUpperCase();
if (ApprovalScopeType.DEPT.getCode().equals(scopeType)) {
assertTenantIds(
List.of(scope.getScopeValue()),
sysDeptService::listByIds,
SysDept::getId,
SysDept::getTenantId,
requireAccount().getTenantId(),
"部门");
continue;
}
if (!ApprovalScopeType.CATEGORY.getCode().equals(scopeType)) {
throw new BusinessException("不支持的审批范围类型");
}
switch (String.valueOf(resourceType).toUpperCase()) {
case "AGENT" -> assertTenantIds(
List.of(scope.getScopeValue()),
agentCategoryService::listByIds,
AgentCategory::getId,
AgentCategory::getTenantId,
requireAccount().getTenantId(),
"Agent 分类");
case "WORKFLOW" -> assertExistingIds(
List.of(scope.getScopeValue()),
workflowCategoryService::listByIds,
WorkflowCategory::getId,
"工作流分类");
case "KNOWLEDGE" -> assertExistingIds(
List.of(scope.getScopeValue()),
documentCollectionCategoryService::listByIds,
DocumentCollectionCategory::getId,
"知识库分类");
default -> throw new BusinessException("当前资源类型不支持分类审批范围");
}
}
}
private List<SystemFormOptionsView.DepartmentOption> listDepartments() {
BigInteger tenantId = requireAccount().getTenantId();
List<SysDept> departments = sysDeptService.list(QueryWrapper.create()
.eq(SysDept::getTenantId, tenantId)
.eq(SysDept::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(SysDept::getSortNo, true));
return buildDepartmentTree(departments);
}
private List<SystemFormOptionsView.MenuOption> listMenus() {
List<SysMenu> menus = sysMenuService.list(QueryWrapper.create()
.orderBy(SysMenu::getSortNo, true));
return buildMenuTree(menus);
}
private List<SystemFormOptionsView.CategoryOption> listAgentCategories() {
BigInteger tenantId = requireAccount().getTenantId();
return agentCategoryService.list(QueryWrapper.create()
.eq(AgentCategory::getTenantId, tenantId)
.eq(AgentCategory::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(AgentCategory::getSortNo, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getCategoryName()))
.toList();
}
private List<SystemFormOptionsView.CategoryOption> listWorkflowCategories() {
return workflowCategoryService.list(QueryWrapper.create()
.orderBy(WorkflowCategory::getSortNo, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getCategoryName()))
.toList();
}
private List<SystemFormOptionsView.CategoryOption> listKnowledgeCategories() {
return documentCollectionCategoryService.list(QueryWrapper.create()
.orderBy(DocumentCollectionCategory::getSortNo, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getCategoryName()))
.toList();
}
private List<SystemFormOptionsView.CategoryOption> listPluginCategories() {
return pluginCategoryService.list(QueryWrapper.create()
.orderBy(PluginCategory::getId, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getName()))
.toList();
}
private List<SystemFormOptionsView.CategoryOption> listResourceCategories() {
return resourceCategoryService.list(QueryWrapper.create()
.orderBy(ResourceCategory::getSortNo, true))
.stream()
.map(category -> new SystemFormOptionsView.CategoryOption(
category.getId(), category.getCategoryName()))
.toList();
}
private List<SystemFormOptionsView.DepartmentOption> buildDepartmentTree(List<SysDept> departments) {
Set<BigInteger> ids = new HashSet<>();
departments.forEach(item -> ids.add(item.getId()));
Map<BigInteger, List<SysDept>> children = new LinkedHashMap<>();
List<SysDept> roots = new ArrayList<>();
for (SysDept department : departments) {
BigInteger parentId = department.getParentId();
if (parentId == null || BigInteger.ZERO.equals(parentId) || !ids.contains(parentId)) {
roots.add(department);
} else {
children.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(department);
}
}
return roots.stream().map(item -> toDepartmentOption(item, children)).toList();
}
private SystemFormOptionsView.DepartmentOption toDepartmentOption(
SysDept department,
Map<BigInteger, List<SysDept>> children) {
return new SystemFormOptionsView.DepartmentOption(
department.getId(),
department.getParentId(),
department.getDeptName(),
children.getOrDefault(department.getId(), List.of()).stream()
.map(item -> toDepartmentOption(item, children))
.toList()
);
}
private List<SystemFormOptionsView.MenuOption> buildMenuTree(List<SysMenu> menus) {
Set<BigInteger> ids = new HashSet<>();
menus.forEach(item -> ids.add(item.getId()));
Map<BigInteger, List<SysMenu>> children = new LinkedHashMap<>();
List<SysMenu> roots = new ArrayList<>();
for (SysMenu menu : menus) {
BigInteger parentId = menu.getParentId();
if (parentId == null || BigInteger.ZERO.equals(parentId) || !ids.contains(parentId)) {
roots.add(menu);
} else {
children.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(menu);
}
}
return roots.stream().map(item -> toMenuOption(item, children)).toList();
}
private SystemFormOptionsView.MenuOption toMenuOption(
SysMenu menu,
Map<BigInteger, List<SysMenu>> children) {
return new SystemFormOptionsView.MenuOption(
menu.getId(),
menu.getParentId(),
menu.getMenuTitle(),
children.getOrDefault(menu.getId(), List.of()).stream()
.map(item -> toMenuOption(item, children))
.toList()
);
}
private <T> void assertAvailableIds(
Collection<BigInteger> rawIds,
Function<Collection<BigInteger>, List<T>> loader,
Function<T, BigInteger> idGetter,
Function<T, Integer> statusGetter,
Function<T, BigInteger> tenantGetter,
BigInteger tenantId,
String label) {
Set<BigInteger> ids = normalizeIds(rawIds);
if (ids.isEmpty()) {
throw new BusinessException(label + "不能为空");
}
List<T> records = loader.apply(ids);
boolean valid = records.size() == ids.size()
&& records.stream().allMatch(item ->
ids.contains(idGetter.apply(item))
&& EnumDataStatus.AVAILABLE.getCode().equals(statusGetter.apply(item))
&& Objects.equals(tenantGetter.apply(item), tenantId));
if (!valid) {
throw new BusinessException(label + "不存在或已禁用");
}
}
private <T> void assertExistingIds(
Collection<BigInteger> rawIds,
Function<Collection<BigInteger>, List<T>> loader,
Function<T, BigInteger> idGetter,
String label) {
Set<BigInteger> ids = normalizeIds(rawIds);
if (ids.isEmpty()) {
return;
}
List<T> records = loader.apply(ids);
boolean valid = records.size() == ids.size()
&& records.stream().allMatch(item -> ids.contains(idGetter.apply(item)));
if (!valid) {
throw new BusinessException(label + "不存在或无权访问");
}
}
private <T> void assertTenantIds(
Collection<BigInteger> rawIds,
Function<Collection<BigInteger>, List<T>> loader,
Function<T, BigInteger> idGetter,
Function<T, BigInteger> tenantGetter,
BigInteger tenantId,
String label) {
Set<BigInteger> ids = normalizeIds(rawIds);
if (ids.isEmpty()) {
return;
}
List<T> records = loader.apply(ids);
boolean valid = records.size() == ids.size()
&& records.stream().allMatch(item ->
ids.contains(idGetter.apply(item))
&& Objects.equals(tenantGetter.apply(item), tenantId));
if (!valid) {
throw new BusinessException(label + "不存在或无权访问");
}
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getTenantId() == null) {
throw new BusinessException("当前登录状态失效,请重新登录后再试");
}
return account;
}
private Set<BigInteger> normalizeIds(Collection<BigInteger> rawIds) {
Set<BigInteger> ids = new LinkedHashSet<>();
if (rawIds != null) {
rawIds.stream().filter(Objects::nonNull).forEach(ids::add);
}
return ids;
}
}

View File

@@ -1,103 +0,0 @@
package tech.easyflow.admin.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.admin.controller.agent.AgentSessionController;
import tech.easyflow.admin.controller.ai.ChatHistoryController;
import tech.easyflow.admin.controller.ai.ModelController;
import tech.easyflow.admin.controller.ai.WorkFlowNodeController;
import tech.easyflow.admin.controller.ai.WorkflowController;
import tech.easyflow.admin.controller.dashboard.DashboardController;
import tech.easyflow.admin.controller.job.SysJobController;
import tech.easyflow.admin.controller.system.ApprovalFlowController;
import tech.easyflow.admin.controller.system.SysAccountController;
import tech.easyflow.admin.controller.system.SysRoleController;
import java.lang.reflect.Method;
import java.util.Arrays;
/**
* 管理端页面能力接口权限归属契约测试。
*/
public class PermissionIsolationContractTest {
/**
* 验证工作流设计器依赖的选项接口只要求工作流查询权限。
*/
@Test
public void workflowDesignerOptionsBelongToWorkflowPermission() {
assertMethodPermission(
WorkflowController.class,
"designerOptions",
"/api/v1/workflow/query"
);
assertMethodPermission(
WorkflowController.class,
"designerChildWorkflow",
"/api/v1/workflow/query"
);
assertMethodPermission(
WorkFlowNodeController.class,
"getChainParams",
"/api/v1/workflow/query"
);
}
/**
* 验证各管理页面的辅助能力接口使用页面自身权限。
*/
@Test
public void pageOptionsBelongToOwningPagePermissions() {
assertMethodPermission(ModelController.class, "gatewayConfig", "/api/v1/model/query");
assertMethodPermission(DashboardController.class, "agentOptions", "/api/v1/dashboard/query");
assertMethodPermission(SysJobController.class, "workflowOptions", "/api/v1/sysJob/save");
assertMethodPermission(SysJobController.class, "getNextTimes", "/api/v1/sysJob/save");
assertMethodPermission(ApprovalFlowController.class, "resourceScopeOptions", "/api/v1/approvalFlow/save");
assertMethodPermission(SysRoleController.class, "formOptions", "/api/v1/sysRole/query");
assertMethodPermission(SysAccountController.class, "formOptions", "/api/v1/sysAccount/save");
}
/**
* 验证 Agent 会话和聊天历史接口分别使用各自页面权限。
*/
@Test
public void agentSessionAndHistoryUseIndependentPermissions() {
assertClassPermission(AgentSessionController.class, "/api/v1/agent/session/query");
assertClassPermission(ChatHistoryController.class, "/api/v1/chatHistory/query");
}
/**
* 断言控制器方法只声明指定权限。
*
* @param controllerType 控制器类型
* @param methodName 方法名
* @param expectedPermission 期望权限
*/
private void assertMethodPermission(
Class<?> controllerType,
String methodName,
String expectedPermission) {
Method method = Arrays.stream(controllerType.getDeclaredMethods())
.filter(candidate -> methodName.equals(candidate.getName()))
.findFirst()
.orElseThrow(() -> new AssertionError("未找到控制器方法:" + methodName));
SaCheckPermission permission = method.getAnnotation(SaCheckPermission.class);
Assert.assertNotNull(permission, methodName + " 缺少权限注解");
Assert.assertEquals(permission.value(), new String[]{expectedPermission});
}
/**
* 断言控制器类只声明指定权限。
*
* @param controllerType 控制器类型
* @param expectedPermission 期望权限
*/
private void assertClassPermission(Class<?> controllerType, String expectedPermission) {
SaCheckPermission permission = controllerType.getAnnotation(SaCheckPermission.class);
Assert.assertNotNull(permission, controllerType.getSimpleName() + " 缺少权限注解");
Assert.assertEquals(permission.value(), new String[]{expectedPermission});
}
}

View File

@@ -1,77 +0,0 @@
package tech.easyflow.admin.controller.agent;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.jsonbody.JsonBodyParser;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.List;
/**
* Agent Skill 绑定管理端 API 安全契约测试。
*/
public class AgentSkillBindingContractTest {
/**
* 验证请求 DTO 仅暴露 Agent ID、Skill ID 与排序号。
*
* @throws Exception 反序列化失败
*/
@Test
public void requestUsesWhitelistFieldsAndDropsServerSnapshot() throws Exception {
JSONObject json = JSON.parseObject("""
{
"agentId": 10,
"bindings": [{
"skillId": 101,
"sortNo": 2,
"resourceSnapshot": {"skillContent": "forged"},
"resourceSummary": {"displayName": "forged"}
}]
}
""");
AgentSkillBindingUpdateRequest request = (AgentSkillBindingUpdateRequest) JsonBodyParser.parseJsonBody(
json, AgentSkillBindingUpdateRequest.class, AgentSkillBindingUpdateRequest.class, "");
Assert.assertEquals(request.getAgentId(), BigInteger.TEN);
Assert.assertEquals(request.getBindings().get(0).getSkillId(), BigInteger.valueOf(101));
Assert.assertEquals(request.getBindings().get(0).getSortNo(), Integer.valueOf(2));
Assert.assertTrue(request.getBindings().get(0).toEntity().getResourceSnapshot().isEmpty());
Assert.assertTrue(request.getBindings().get(0).toEntity().getResourceSummary().isEmpty());
Assert.assertEquals(
Arrays.stream(AgentSkillBindingUpdateRequest.Binding.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers()))
.map(field -> field.getName()).toList(),
List.of("skillId", "sortNo"));
}
/**
* 验证更新入口使用白名单 DTO 并返回脱敏视图。
*
* @throws Exception 反射失败
*/
@Test
public void updateEndpointReturnsSafeSkillBindingViews() throws Exception {
Method method = AgentController.class.getMethod(
"updateSkillBinding", AgentSkillBindingUpdateRequest.class);
JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class);
ParameterizedType resultType = (ParameterizedType) method.getGenericReturnType();
ParameterizedType listType = (ParameterizedType) resultType.getActualTypeArguments()[0];
Assert.assertNotNull(jsonBody);
Assert.assertEquals(resultType.getRawType(), Result.class);
Assert.assertEquals(listType.getRawType(), List.class);
Assert.assertEquals(listType.getActualTypeArguments()[0], AgentDetailView.SkillBindingView.class);
Assert.assertFalse(Arrays.stream(AgentDetailView.SkillBindingView.class.getRecordComponents())
.anyMatch(component -> "resourceSnapshot".equals(component.getName())));
}
}

View File

@@ -6,9 +6,8 @@ import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.service.BotApprovalStateService;
import tech.easyflow.ai.service.AiResourceApprovalStateService;
import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.BotMessageService;
import tech.easyflow.ai.service.BotService;
@@ -39,7 +38,7 @@ public class BotControllerTest {
private BotDocumentCollectionService botDocumentCollectionService;
private BotMessageService botMessageService;
private CategoryPermissionService categoryPermissionService;
private BotApprovalStateService botApprovalStateService;
private AiResourceApprovalStateService aiResourceApprovalStateService;
private SysAccountService sysAccountService;
/**
@@ -53,7 +52,7 @@ public class BotControllerTest {
botDocumentCollectionService = mock(BotDocumentCollectionService.class);
botMessageService = mock(BotMessageService.class);
categoryPermissionService = mock(CategoryPermissionService.class);
botApprovalStateService = mock(BotApprovalStateService.class);
aiResourceApprovalStateService = mock(AiResourceApprovalStateService.class);
sysAccountService = mock(SysAccountService.class);
}
@@ -70,12 +69,10 @@ public class BotControllerTest {
botMessageService
);
AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport();
BotResourceCreatorNameSupport botCreatorNameSupport =
new BotResourceCreatorNameSupport(creatorNameSupport);
setField(creatorNameSupport, "sysAccountService", sysAccountService);
setField(controller, "categoryPermissionService", categoryPermissionService);
setField(controller, "botApprovalStateService", botApprovalStateService);
setField(controller, "botResourceCreatorNameSupport", botCreatorNameSupport);
setField(controller, "aiResourceApprovalStateService", aiResourceApprovalStateService);
setField(controller, "aiResourceCreatorNameSupport", creatorNameSupport);
Bot bot = new Bot();
bot.setId(BigInteger.valueOf(101));
@@ -87,7 +84,7 @@ public class BotControllerTest {
when(botService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(page);
when(sysAccountService.resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7))))
.thenReturn(Map.of(BigInteger.valueOf(7), "管理员"));
doNothing().when(botApprovalStateService).fillApprovalState(page.getRecords());
doNothing().when(aiResourceApprovalStateService).fillBotApprovalState(page.getRecords());
Page<Bot> result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create());

View File

@@ -1,85 +0,0 @@
package tech.easyflow.admin.controller.ai;
import org.mockito.MockedStatic;
import org.testng.annotations.Test;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
import tech.easyflow.chatlog.service.ChatHistoryManageService;
import tech.easyflow.agent.service.AgentOptionQueryService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.service.CategoryPermissionService;
import java.math.BigInteger;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ChatHistoryController} 数据范围测试。
*/
public class ChatHistoryControllerTest {
/**
* 验证普通账号查询时将本人范围传给服务层。
*/
@Test
public void listSessionsShouldUseCurrentUserScopeForRegularAccount() {
BigInteger accountId = BigInteger.valueOf(20);
ChatHistoryManageService service = mock(ChatHistoryManageService.class);
CategoryPermissionService permissionService = mock(CategoryPermissionService.class);
ChatHistoryController controller = new ChatHistoryController(
service, permissionService, mock(AgentOptionQueryService.class));
ChatSessionFilterQuery query = new ChatSessionFilterQuery();
LoginAccount account = loginAccount(accountId);
when(permissionService.isSuperAdmin(account)).thenReturn(false);
when(service.queryAdminSessions(accountId, false, query)).thenReturn(new ChatSessionPage());
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
controller.listSessions(query);
}
verify(service).queryAdminSessions(accountId, false, query);
}
/**
* 验证超级管理员查询详情时保留全量范围。
*/
@Test
public void getSessionShouldUseAllScopeForSuperAdmin() {
BigInteger accountId = BigInteger.ONE;
BigInteger sessionId = BigInteger.valueOf(30);
ChatHistoryManageService service = mock(ChatHistoryManageService.class);
CategoryPermissionService permissionService = mock(CategoryPermissionService.class);
ChatHistoryController controller = new ChatHistoryController(
service, permissionService, mock(AgentOptionQueryService.class));
LoginAccount account = loginAccount(accountId);
when(permissionService.isSuperAdmin(account)).thenReturn(true);
when(service.getAdminSession(accountId, true, sessionId)).thenReturn(new ChatSessionSummary());
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
controller.getSession(sessionId);
}
verify(service).getAdminSession(accountId, true, sessionId);
}
/**
* 构造登录账号。
*
* @param accountId 账号 ID
* @return 登录账号
*/
private LoginAccount loginAccount(BigInteger accountId) {
LoginAccount account = new LoginAccount();
account.setId(accountId);
return account;
}
}

View File

@@ -1,120 +0,0 @@
package tech.easyflow.admin.controller.ai;
import org.testng.Assert;
import org.testng.annotations.Test;
import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.common.web.controller.BaseController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.permission.resource.RequireResourceAccess;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 文档分块维护接口契约测试。
*/
public class DocumentChunkControllerContractTest {
@Test
public void updateEndpointsShouldOnlyAcceptChunkIdAndContent() throws Exception {
Method adminUpdate = DocumentChunkController.class.getDeclaredMethod(
"update",
DocumentChunkContentUpdateRequest.class
);
Method shareUpdate = ShareKnowledgeController.class.getDeclaredMethod(
"updateDocumentChunk",
String.class,
DocumentChunkContentUpdateRequest.class
);
Assert.assertNotNull(adminUpdate);
Assert.assertNotNull(shareUpdate);
assertStrictJsonBody(adminUpdate.getParameters()[0]);
assertStrictJsonBody(shareUpdate.getParameters()[1]);
Assert.assertEquals(
adminUpdate.getAnnotation(RequireResourceAccess.class).idExpr(),
"#request.id"
);
Set<String> fields = Arrays.stream(
DocumentChunkContentUpdateRequest.class.getDeclaredFields()
)
.map(Field::getName)
.collect(Collectors.toSet());
Assert.assertEquals(Set.of("id", "content"), fields);
Assert.assertEquals(
DocumentChunkController.class.getMethod(
"update",
DocumentChunkContentUpdateRequest.class
).getDeclaringClass(),
DocumentChunkController.class
);
Assert.assertEquals(
Arrays.stream(DocumentChunkController.class.getDeclaredMethods())
.filter(method -> method.getName().equals("update"))
.filter(method -> !method.isBridge() && !method.isSynthetic())
.count(),
1L
);
}
@Test
public void controllerShouldNotExposeGenericWriteEndpoints() {
Assert.assertEquals(DocumentChunkController.class.getSuperclass(), BaseController.class);
Set<String> postMappings = Arrays.stream(DocumentChunkController.class.getMethods())
.map(method -> method.getAnnotation(PostMapping.class))
.filter(annotation -> annotation != null)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.collect(Collectors.toSet());
Assert.assertEquals(
postMappings,
Set.of("update", "removeChunk", "syncStatus", "retrySync")
);
Assert.assertFalse(postMappings.contains("save"));
Assert.assertFalse(postMappings.contains("remove"));
Assert.assertFalse(postMappings.contains("removeBatch"));
}
@Test
public void adminUpdateAndDeleteShouldUseUnifiedMaintenanceService() {
DocumentChunkService service = mock(DocumentChunkService.class);
DocumentChunkController controller = new DocumentChunkController(service);
DocumentChunk current = new DocumentChunk();
current.setId(BigInteger.ONE);
current.setDocumentCollectionId(BigInteger.TWO);
when(service.getById(BigInteger.ONE)).thenReturn(current);
DocumentChunkContentUpdateRequest request = new DocumentChunkContentUpdateRequest();
request.setId(BigInteger.ONE);
request.setContent("updated");
controller.update(request);
controller.removeChunk(BigInteger.ONE);
verify(service).updateContent(BigInteger.TWO, BigInteger.ONE, "updated");
verify(service).deleteChunk(BigInteger.TWO, BigInteger.ONE);
verify(service, never()).updateById(any(DocumentChunk.class));
verify(service, never()).removeById(any());
}
private static void assertStrictJsonBody(Parameter parameter) {
JsonBody jsonBody = parameter.getAnnotation(JsonBody.class);
Assert.assertNotNull(jsonBody);
Assert.assertTrue(jsonBody.required());
Assert.assertFalse(jsonBody.skipConvertError());
}
}

View File

@@ -1,71 +0,0 @@
package tech.easyflow.admin.controller.ai;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import java.math.BigInteger;
import java.util.Locale;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link McpController} 删除锁测试。
*/
public class McpControllerTest {
/**
* 验证 MCP 删除先锁定资源行,再执行删除。
*/
@Test
public void removeShouldLockMcpBeforeRemoval() {
McpService mcpService = mock(McpService.class);
AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class);
when(mcpService.getOne(any(QueryWrapper.class))).thenReturn(new Mcp());
McpController controller = new McpController(mcpService);
setField(controller, "agentResourceReferenceService", referenceService);
LoginAccount loginAccount = new LoginAccount();
loginAccount.setTenantId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
controller.remove(BigInteger.TEN);
}
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(mcpService).getOne(queryCaptor.capture());
Assert.assertTrue(
queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")
);
verify(referenceService).assertMcpUnused(BigInteger.TEN);
verify(mcpService).removeMcp(BigInteger.TEN);
}
/**
* 通过反射设置字段值。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
*/
private static void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("设置测试字段失败: " + fieldName, e);
}
}
}

View File

@@ -1,65 +0,0 @@
package tech.easyflow.admin.controller.ai;
import com.mybatisflex.core.query.QueryWrapper;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.mapper.ModelMapper;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.domain.Result;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 模型列表控制器测试。
*/
public class ModelControllerTest {
/**
* 验证模型列表使用包含目录能力元数据的服务查询。
*/
@Test
public void listShouldReturnCapabilityDecoratedModels() {
ModelService service = mock(ModelService.class);
Model query = new Model();
Model decoratedModel = new Model();
decoratedModel.setContextWindowTokens(1_000_000L);
decoratedModel.setMaxOutputTokens(64_000L);
when(service.listSelectableModels(query, false, "id", "desc"))
.thenReturn(List.of(decoratedModel));
ModelController controller = new ModelController(service);
Result<List<Model>> result = controller.list(query, false, "id", "desc");
Assert.assertSame(decoratedModel, result.getData().get(0));
Assert.assertEquals(Long.valueOf(1_000_000L),
result.getData().get(0).getContextWindowTokens());
verify(service).listSelectableModels(query, false, "id", "desc");
}
/**
* 验证仅包含空白字符的选择文本不会生成模糊查询条件。
*/
@Test
public void selectLlmShouldIgnoreBlankSearchText() {
ModelService service = mock(ModelService.class);
ModelMapper mapper = mock(ModelMapper.class);
when(service.getMapper()).thenReturn(mapper);
when(mapper.selectListWithRelationsByQuery(any(QueryWrapper.class)))
.thenReturn(List.of());
ModelController controller = new ModelController(service);
Result<Map<String, List<Model>>> result = controller.selectLlmByProviderAndModelType(
"chat", BigInteger.ONE, " ");
Assert.assertTrue(result.getData().isEmpty());
verify(mapper).selectListWithRelationsByQuery(any(QueryWrapper.class));
}
}

View File

@@ -1,30 +0,0 @@
package tech.easyflow.admin.controller.ai;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.service.PluginService;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link PluginController} 删除接口测试。
*/
public class PluginControllerTest {
/**
* 插件删除接口必须委托事务服务执行完整引用校验和删除。
*/
@Test
public void removeShouldDelegateToTransactionalService() {
PluginService pluginService = mock(PluginService.class);
when(pluginService.removePlugin("10")).thenReturn(true);
PluginController controller = new PluginController(pluginService);
boolean removed = controller.removePlugin("10").getData();
Assert.assertTrue(removed);
verify(pluginService).removePlugin("10");
}
}

View File

@@ -1,162 +0,0 @@
package tech.easyflow.admin.controller.ai;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* {@link PluginItemController} 删除锁测试。
*/
public class PluginItemControllerTest {
/**
* 验证插件工具删除按稳定顺序锁定资源行。
*/
@Test
public void removeCheckShouldLockPluginItemsInStableOrder() {
PluginItemService pluginItemService = mock(PluginItemService.class);
AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class);
PluginService pluginService = mock(PluginService.class);
PluginVisibilityService visibilityService = mock(PluginVisibilityService.class);
PluginItem first = pluginItem(BigInteger.ONE, BigInteger.TEN);
PluginItem second = pluginItem(BigInteger.TWO, BigInteger.TEN);
Plugin plugin = new Plugin();
plugin.setId(BigInteger.TEN);
plugin.setTenantId(1L);
plugin.setCreatedBy(1L);
when(pluginItemService.list(any(QueryWrapper.class))).thenReturn(List.of(first, second));
when(pluginService.getById(BigInteger.TEN)).thenReturn(plugin);
PluginItemController controller = new PluginItemController(pluginItemService);
setField(controller, "pluginItemService", pluginItemService);
setField(controller, "agentResourceReferenceService", referenceService);
setField(controller, "pluginService", pluginService);
setField(controller, "pluginVisibilityService", visibilityService);
LoginAccount loginAccount = new LoginAccount();
loginAccount.setTenantId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
controller.onRemoveBefore(List.of(BigInteger.TWO, BigInteger.ONE));
}
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(pluginItemService).list(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT);
Assert.assertTrue(sql.contains("ORDER BY"));
Assert.assertTrue(sql.contains("FOR UPDATE"));
verify(referenceService).assertPluginItemsUnused(List.of(BigInteger.TWO, BigInteger.ONE));
verify(visibilityService).assertPluginVisible(1L, BigInteger.TEN, "无权限删除该插件工具");
}
/**
* 验证当前用户不能恢复其他用户发起的插件试运行实例。
*/
@Test
public void testResumeShouldRejectAnotherUsersExecution() {
PluginItemService pluginItemService = mock(PluginItemService.class);
WorkflowExecResultService execResultService = mock(WorkflowExecResultService.class);
WorkflowResumeService resumeService = mock(WorkflowResumeService.class);
WorkflowExecResult record = new WorkflowExecResult();
record.setCreatedBy(BigInteger.ONE.toString());
when(execResultService.getByExecKey("execution-1")).thenReturn(record);
PluginItemController controller = new PluginItemController(pluginItemService);
setField(controller, "workflowExecResultService", execResultService);
setField(controller, "workflowResumeService", resumeService);
LoginAccount currentAccount = new LoginAccount();
currentAccount.setId(BigInteger.TWO);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(currentAccount);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> controller.pluginToolTestResume("execution-1", Map.of())
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 403);
}
verifyNoInteractions(resumeService);
}
/**
* 验证当前用户可以恢复自己发起的插件试运行实例。
*/
@Test
public void testResumeShouldAllowExecutionOwner() {
PluginItemService pluginItemService = mock(PluginItemService.class);
WorkflowExecResultService execResultService = mock(WorkflowExecResultService.class);
WorkflowResumeService resumeService = mock(WorkflowResumeService.class);
WorkflowExecResult record = new WorkflowExecResult();
record.setCreatedBy(BigInteger.ONE.toString());
when(execResultService.getByExecKey("execution-1")).thenReturn(record);
PluginItemController controller = new PluginItemController(pluginItemService);
setField(controller, "workflowExecResultService", execResultService);
setField(controller, "workflowResumeService", resumeService);
LoginAccount currentAccount = new LoginAccount();
currentAccount.setId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(currentAccount);
controller.pluginToolTestResume("execution-1", Map.of("choice", "A"));
}
verify(resumeService).resume("execution-1", Map.of("choice", "A"));
}
/**
* 创建插件工具。
*
* @param id 工具 ID
* @param pluginId 插件 ID
* @return 插件工具
*/
private static PluginItem pluginItem(BigInteger id, BigInteger pluginId) {
PluginItem pluginItem = new PluginItem();
pluginItem.setId(id);
pluginItem.setPluginId(pluginId);
return pluginItem;
}
/**
* 通过反射设置字段值。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
*/
private static void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("设置测试字段失败: " + fieldName, e);
}
}
}

View File

@@ -1,326 +0,0 @@
package tech.easyflow.admin.controller.ai;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import jakarta.servlet.http.HttpServletRequest;
import org.mockito.MockedStatic;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.domain.Result;
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.ResourceAccessService;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowChatController} 运行来源与分享边界测试。
*/
public class WorkflowChatControllerTest {
/**
* 验证登录用户可以进入并运行未发布工作流,同时描述信息禁止分享。
*/
@Test
public void shouldRunDraftForAuthenticatedRequestWithoutSharing() {
ControllerFixture fixture = fixture(workflow(PublishStatus.DRAFT, "draft-content", false));
when(fixture.parameterResolver.buildRunningParametersView(fixture.current))
.thenReturn(new LinkedHashMap<>());
when(fixture.parameterResolver.normalizeRuntimeVariables(
eq("draft-content"),
anyMap()
)).thenReturn(new LinkedHashMap<>());
when(fixture.eventStream.start(eq("1"), anyMap())).thenReturn(new SseEmitter());
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
Result<Map<String, Object>> descriptor = fixture.controller.descriptor(
BigInteger.ONE,
request(Map.of())
);
fixture.controller.run(BigInteger.ONE, Map.of(), request(Map.of()));
Assert.assertEquals(descriptor.getData().get("shareable"), false);
Assert.assertEquals(
descriptor.getData().get("publishStatus"),
PublishStatus.DRAFT.getCode()
);
}
verify(fixture.parameterResolver).buildRunningParametersView(fixture.current);
verify(fixture.eventStream).start(eq("1"), anyMap());
}
/**
* 验证已发布工作流的管理端运行继续读取发布快照。
*/
@Test
public void shouldRunPublishedSnapshotForAuthenticatedRequest() {
Workflow current = workflow(PublishStatus.PUBLISHED, "draft-content", true);
Workflow published = workflow(PublishStatus.PUBLISHED, "published-content", true);
ControllerFixture fixture = fixture(current, published);
when(fixture.parameterResolver.buildRunningParametersView(published))
.thenReturn(new LinkedHashMap<>());
when(fixture.parameterResolver.normalizeRuntimeVariables(
eq("published-content"),
anyMap()
)).thenReturn(new LinkedHashMap<>());
when(fixture.eventStream.start(
eq(PublishedWorkflowDefinitionIds.published("1")),
anyMap()
)).thenReturn(new SseEmitter());
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
Result<Map<String, Object>> descriptor = fixture.controller.descriptor(
BigInteger.ONE,
request(Map.of())
);
fixture.controller.run(BigInteger.ONE, Map.of(), request(Map.of()));
Assert.assertEquals(descriptor.getData().get("shareable"), true);
}
verify(fixture.parameterResolver).buildRunningParametersView(published);
verify(fixture.eventStream).start(
eq(PublishedWorkflowDefinitionIds.published("1")),
anyMap()
);
}
/**
* 验证分享访问仍拒绝未发布工作流。
*/
@Test
public void shouldRejectDraftWorkflowFromShareRequest() {
ControllerFixture fixture = fixture(workflow(PublishStatus.DRAFT, "draft-content", false));
HttpServletRequest request = request(Map.of(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER.toLowerCase(Locale.ROOT),
"share-key"
));
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
Assert.expectThrows(
BusinessException.class,
() -> fixture.controller.descriptor(BigInteger.ONE, request)
);
}
verify(fixture.workflowShareService).assertChatShareAccess(
"share-key",
BigInteger.ONE,
BigInteger.ONE
);
verify(fixture.resourceAccessService, never()).assertAccess(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyString()
);
}
/**
* 创建使用同一当前视图和发布视图的测试夹具。
*
* @param current 当前工作流
* @return 控制器测试夹具
*/
private ControllerFixture fixture(Workflow current) {
return fixture(current, current);
}
/**
* 创建控制器测试夹具。
*
* @param current 当前工作流
* @param published 发布工作流视图
* @return 控制器测试夹具
*/
private ControllerFixture fixture(Workflow current, Workflow published) {
WorkflowService workflowService = mock(WorkflowService.class);
WorkflowShareService workflowShareService = mock(WorkflowShareService.class);
WorkflowCheckService workflowCheckService = mock(WorkflowCheckService.class);
WorkflowRunningParameterResolver parameterResolver =
mock(WorkflowRunningParameterResolver.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
WorkflowChatEventStream eventStream = mock(WorkflowChatEventStream.class);
WorkflowChatController controller = new WorkflowChatController();
setField(controller, "workflowService", workflowService);
setField(controller, "workflowShareService", workflowShareService);
setField(controller, "workflowCheckService", workflowCheckService);
setField(controller, "parameterResolver", parameterResolver);
setField(controller, "resourceAccessService", resourceAccessService);
setField(controller, "eventStream", eventStream);
setField(controller, "chainExecutor", mock(ChainExecutor.class));
setField(controller, "execResultService", mock(WorkflowExecResultService.class));
setField(controller, "execStepService", mock(WorkflowExecStepService.class));
when(workflowService.getById(BigInteger.ONE)).thenReturn(current);
when(workflowService.getPublishedById(BigInteger.ONE)).thenReturn(published);
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.ONE);
return new ControllerFixture(
controller,
current,
workflowService,
workflowShareService,
parameterResolver,
resourceAccessService,
eventStream,
account
);
}
/**
* 创建工作流测试视图。
*
* @param publishStatus 发布状态
* @param content 工作流内容
* @param withSnapshot 是否包含发布快照
* @return 工作流测试视图
*/
private Workflow workflow(
PublishStatus publishStatus,
String content,
boolean withSnapshot
) {
Workflow workflow = new Workflow();
workflow.setId(BigInteger.ONE);
workflow.setContent(content);
workflow.setPublishStatus(publishStatus.getCode());
if (withSnapshot) {
workflow.setPublishedSnapshotJson(Map.of("content", content));
}
return workflow;
}
/**
* 创建登录账号静态模拟。
*
* @param account 登录账号
* @return 静态模拟句柄
*/
private MockedStatic<SaTokenUtil> login(LoginAccount account) {
MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class);
login.when(SaTokenUtil::getLoginAccount).thenReturn(account);
return login;
}
/**
* 创建仅提供请求头能力的轻量 Servlet 请求代理。
*
* @param headers 小写请求头映射
* @return HTTP 请求代理
*/
private HttpServletRequest request(Map<String, String> headers) {
return (HttpServletRequest) java.lang.reflect.Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{HttpServletRequest.class},
(proxy, method, args) -> {
if ("getHeader".equals(method.getName())) {
String name = String.valueOf(args[0]).toLowerCase(Locale.ROOT);
return headers.get(name);
}
return defaultValue(method.getReturnType());
}
);
}
/**
* 通过反射设置控制器依赖。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
*/
private void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("设置测试字段失败: " + fieldName, exception);
}
}
/**
* 返回代理方法所需的基础类型默认值。
*
* @param returnType 返回类型
* @return 默认值
*/
private Object defaultValue(Class<?> returnType) {
if (!returnType.isPrimitive()) {
return null;
}
if (boolean.class == returnType) {
return false;
}
if (char.class == returnType) {
return '\0';
}
if (byte.class == returnType) {
return (byte) 0;
}
if (short.class == returnType) {
return (short) 0;
}
if (int.class == returnType) {
return 0;
}
if (long.class == returnType) {
return 0L;
}
if (float.class == returnType) {
return 0F;
}
return 0D;
}
/**
* 控制器及其测试依赖夹具。
*
* @param controller 控制器
* @param current 当前工作流
* @param workflowService 工作流服务
* @param workflowShareService 工作流分享服务
* @param parameterResolver 参数解析器
* @param resourceAccessService 资源权限服务
* @param eventStream 事件流服务
* @param account 登录账号
*/
private record ControllerFixture(
WorkflowChatController controller,
Workflow current,
WorkflowService workflowService,
WorkflowShareService workflowShareService,
WorkflowRunningParameterResolver parameterResolver,
ResourceAccessService resourceAccessService,
WorkflowChatEventStream eventStream,
LoginAccount account
) {
}
}

View File

@@ -1,163 +0,0 @@
package tech.easyflow.admin.controller.ai;
import jakarta.servlet.http.HttpServletRequest;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Locale;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowShareController} 分享地址构建测试。
*/
public class WorkflowShareControllerTest {
/**
* 验证分享解析仅依赖分享密钥,不读取当前浏览器登录租户。
*/
@Test
public void shouldResolvePublicChatShareWithoutLoginContext()
throws Exception {
WorkflowShareService shareService = mock(WorkflowShareService.class);
WorkflowShare share = new WorkflowShare();
share.setWorkflowId(BigInteger.valueOf(11));
when(shareService.resolvePublicChatShare("share-key"))
.thenReturn(share);
WorkflowShareController controller = new WorkflowShareController();
setField(controller, "workflowShareService", shareService);
BigInteger workflowId = controller.resolveUrlShare(request(Map.of(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
.toLowerCase(Locale.ROOT),
"share-key"
))).getData().get("workflowId");
Assert.assertEquals(workflowId, BigInteger.valueOf(11));
verify(shareService).resolvePublicChatShare("share-key");
}
/**
* 验证分享地址保留前端部署基路径。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void shouldPreserveFrontendBasePathFromReferer() throws Exception {
HttpServletRequest request = request(Map.of(
"referer",
"https://example.test/easyflow/ai/workflow?page=1"
));
Assert.assertEquals(
buildShareBaseUrl(request),
"https://example.test/easyflow/share/workflow"
);
}
/**
* 验证反向代理头用于构建外部 HTTPS 分享地址。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void shouldUseForwardedOriginAndPrefix() throws Exception {
HttpServletRequest request = request(Map.of(
"x-forwarded-proto", "https",
"x-forwarded-host", "example.test",
"x-forwarded-prefix", "/easyflow"
));
Assert.assertEquals(
buildShareBaseUrl(request),
"https://example.test/easyflow/share/workflow"
);
}
/**
* 调用控制器的分享基础地址构建方法。
*
* @param request 模拟 HTTP 请求
* @return 分享基础地址
* @throws Exception 反射调用失败时抛出
*/
private String buildShareBaseUrl(HttpServletRequest request) throws Exception {
Method method = WorkflowShareController.class.getDeclaredMethod(
"buildShareBaseUrl",
HttpServletRequest.class
);
method.setAccessible(true);
return (String) method.invoke(new WorkflowShareController(), request);
}
/**
* 创建仅提供请求头能力的轻量 Servlet 请求代理。
*
* @param headers 小写请求头映射
* @return HTTP 请求代理
*/
private HttpServletRequest request(Map<String, String> headers) {
return (HttpServletRequest) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{HttpServletRequest.class},
(proxy, method, args) -> {
if ("getHeader".equals(method.getName())) {
String name = String.valueOf(args[0]).toLowerCase(Locale.ROOT);
return headers.get(name);
}
return defaultValue(method.getReturnType());
}
);
}
/**
* 返回代理方法所需的基础类型默认值。
*
* @param returnType 返回值类型
* @return 对应默认值
*/
private Object defaultValue(Class<?> returnType) {
if (!returnType.isPrimitive()) {
return null;
}
if (boolean.class == returnType) {
return false;
}
if (char.class == returnType) {
return '\0';
}
if (byte.class == returnType) {
return (byte) 0;
}
if (short.class == returnType) {
return (short) 0;
}
if (int.class == returnType) {
return 0;
}
if (long.class == returnType) {
return 0L;
}
if (float.class == returnType) {
return 0F;
}
return 0D;
}
private void setField(Object target, String name, Object value)
throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -1,77 +0,0 @@
package tech.easyflow.admin.controller.dataspace;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertNull;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import org.springframework.web.bind.annotation.RequestBody;
import org.testng.annotations.Test;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.dataspace.model.DataspaceDefinition;
/**
* 数据空间管理接口请求绑定契约测试。
*/
public class DataspaceControllerContractTest {
/**
* 验证保存接口使用 Jackson 请求体绑定,避免嵌套定义残留为 JSONObject。
*
* @throws Exception 反射或 JSON 转换失败时抛出
*/
@Test
public void shouldBindNestedDataspaceDefinitionWithJackson() throws Exception {
Method method = DataspaceController.class.getMethod(
"save", DataspaceDefinition.class);
Parameter parameter = method.getParameters()[0];
assertNotNull(parameter.getAnnotation(RequestBody.class));
assertNull(parameter.getAnnotation(JsonBody.class));
String request = """
{
"name": "网点经营分析",
"tables": [
{
"clientKey": "table:outlet",
"objectId": "1001",
"sourceAlias": "MYSQL_1",
"schemaAlias": "MAIN",
"tableAlias": "outlet",
"positionX": 80,
"positionY": 120
},
{
"clientKey": "table:region",
"objectId": "1002",
"sourceAlias": "PG_1",
"schemaAlias": "PUBLIC",
"tableAlias": "outlet_region",
"positionX": 420,
"positionY": 120
}
],
"relations": [
{
"leftClientKey": "table:outlet",
"rightClientKey": "table:region",
"joinType": "INNER",
"leftColumn": "institution_id",
"rightColumn": "institution_id"
}
]
}
""";
DataspaceDefinition definition = new ObjectMapper().readValue(
request, DataspaceDefinition.class);
assertEquals(2, definition.tables().size());
assertEquals("outlet", definition.tables().get(0).tableAlias());
assertEquals("outlet_region", definition.tables().get(1).tableAlias());
assertEquals(1, definition.relations().size());
assertEquals("institution_id", definition.relations().get(0).leftColumn());
}
}

View File

@@ -1,303 +0,0 @@
package tech.easyflow.admin.controller.job;
import com.easyagents.flow.core.chain.Parameter;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.service.SysJobService;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link SysJobController} 工作流任务参数校验测试。
*/
public class SysJobControllerTest {
@Test
public void shouldQueryPublishedWorkflowOptionsAndReturnPublishedMetadata() {
BigInteger workflowId = BigInteger.valueOf(501);
LoginAccount account = account();
Workflow raw = new Workflow();
raw.setId(workflowId);
raw.setTenantId(account.getTenantId());
raw.setPublishStatus(PublishStatus.PUBLISHED.getCode());
raw.setPublishedSnapshotJson(Map.of("title", "发布标题"));
raw.setTitle("草稿标题");
Workflow withoutSnapshot = new Workflow();
withoutSnapshot.setId(BigInteger.valueOf(502));
withoutSnapshot.setTenantId(account.getTenantId());
withoutSnapshot.setPublishStatus(PublishStatus.PUBLISHED.getCode());
Workflow published = new Workflow();
published.setId(workflowId);
published.setTitle("发布标题");
published.setDescription("发布描述");
WorkflowService workflowService = mock(WorkflowService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
when(workflowService.list(any(QueryWrapper.class)))
.thenReturn(List.of(raw, withoutSnapshot));
when(resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
raw,
ResourceAction.USE)).thenReturn(true);
when(workflowService.toPublishedView(raw)).thenReturn(published);
SysJobController controller = new SysJobController(
mock(SysJobService.class),
workflowService,
mock(WorkflowUsageAuthorizationService.class),
resourceAccessService,
mock(WorkflowRunningParameterResolver.class),
"Asia/Shanghai");
List<SysJobWorkflowOptionView> options;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
options = controller.workflowOptions().getData();
}
Assert.assertEquals(options.size(), 1);
Assert.assertEquals(options.get(0).title(), "发布标题");
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(workflowService).list(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
Assert.assertTrue(sql.contains("publish_status"));
Assert.assertFalse(sql.replace("publish_status", "").matches("(?s).*\\bstatus\\b.*"));
}
@Test
public void shouldFormatCronPreviewWithConfiguredTimezone() {
SysJobService service = mock(SysJobService.class);
when(service.nextFireTimes("0 0 9 * * ?", 5))
.thenReturn(List.of(Date.from(Instant.parse("2026-01-01T01:00:00Z"))));
TimeZone previous = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
Assert.assertEquals(
controller(service).getNextTimes("0 0 9 * * ?").getData().get(0),
"2026-01-01 09:00:00");
} finally {
TimeZone.setDefault(previous);
}
}
@Test
public void shouldForceNewJobToStoppedGenerationZero() {
SysJobController controller = controller(mock(SysJobService.class));
SysJob job = validJavaJob();
job.setStatus(EnumJobStatus.RUNNING.getCode());
job.setScheduleGeneration(99L);
LoginAccount account = account();
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
controller.onSaveOrUpdateBefore(job, true);
}
Assert.assertEquals(job.getStatus(), Integer.valueOf(EnumJobStatus.STOP.getCode()));
Assert.assertEquals(job.getScheduleGeneration(), Long.valueOf(0L));
}
@Test
public void shouldRejectStatusAndGenerationMutationThroughOrdinaryUpdate() {
BigInteger id = BigInteger.valueOf(401);
SysJobService service = mock(SysJobService.class);
SysJob existing = validJavaJob();
existing.setId(id);
existing.setStatus(EnumJobStatus.STOP.getCode());
existing.setScheduleGeneration(8L);
when(service.getById(id)).thenReturn(existing);
SysJob update = validJavaJob();
update.setId(id);
update.setStatus(EnumJobStatus.RUNNING.getCode());
update.setScheduleGeneration(100L);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account());
controller(service).update(update);
}
Assert.assertEquals(update.getStatus(), Integer.valueOf(EnumJobStatus.STOP.getCode()));
Assert.assertEquals(update.getScheduleGeneration(), Long.valueOf(8L));
verify(service).updateJobDefinition(update);
}
/**
* 验证缺少工作流必填参数时拒绝保存定时任务。
*/
@Test
public void shouldRejectWorkflowJobWhenRequiredParameterIsMissing() {
BigInteger workflowId = BigInteger.valueOf(101);
SysJobService jobService = mock(SysJobService.class);
WorkflowService workflowService = mock(WorkflowService.class);
WorkflowUsageAuthorizationService workflowAuthorizationService =
mock(WorkflowUsageAuthorizationService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
WorkflowRunningParameterResolver parameterResolver =
mock(WorkflowRunningParameterResolver.class);
Workflow workflow = new Workflow();
workflow.setId(workflowId);
workflow.setContent("{}");
when(workflowAuthorizationService.requireUsableWorkflow(
org.mockito.ArgumentMatchers.eq(workflowId),
org.mockito.ArgumentMatchers.any(LoginAccount.class),
org.mockito.ArgumentMatchers.anyString()))
.thenReturn(workflow);
Parameter requiredParameter = mock(Parameter.class);
when(requiredParameter.isRequired()).thenReturn(true);
when(requiredParameter.getName()).thenReturn("user_input");
when(requiredParameter.getFormLabel()).thenReturn("用户问题");
when(parameterResolver.resolveStartParameters(workflow.getContent()))
.thenReturn(List.of(requiredParameter));
SysJobController controller = new SysJobController(
jobService,
workflowService,
workflowAuthorizationService,
resourceAccessService,
parameterResolver,
"Asia/Shanghai"
);
SysJob job = new SysJob();
job.setJobType(EnumJobType.TINY_FLOW.getCode());
job.setJobParams(Map.of(
JobConstant.WORKFLOW_KEY, workflowId.toString(),
JobConstant.WORKFLOW_PARAMS_KEY, Map.of()
));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
BusinessException exception = Assert.expectThrows(
BusinessException.class,
() -> controller.onSaveOrUpdateBefore(job, true)
);
Assert.assertTrue(exception.getMessage().contains("用户问题"));
}
}
/**
* 验证部分更新省略任务类型时仍按数据库中的工作流任务类型完成引用校验。
*/
@Test
public void shouldValidateMergedWorkflowReferenceOnPartialUpdate() {
BigInteger jobId = BigInteger.valueOf(201);
BigInteger oldWorkflowId = BigInteger.valueOf(301);
BigInteger newWorkflowId = BigInteger.valueOf(302);
SysJobService jobService = mock(SysJobService.class);
WorkflowService workflowService = mock(WorkflowService.class);
WorkflowUsageAuthorizationService workflowAuthorizationService =
mock(WorkflowUsageAuthorizationService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
WorkflowRunningParameterResolver parameterResolver =
mock(WorkflowRunningParameterResolver.class);
SysJob existing = new SysJob();
existing.setId(jobId);
existing.setJobType(EnumJobType.TINY_FLOW.getCode());
existing.setJobParams(Map.of(
JobConstant.WORKFLOW_KEY, oldWorkflowId.toString(),
JobConstant.WORKFLOW_PARAMS_KEY, Map.of()
));
when(jobService.getById(jobId)).thenReturn(existing);
when(workflowAuthorizationService.requireUsableWorkflow(
org.mockito.ArgumentMatchers.eq(newWorkflowId),
org.mockito.ArgumentMatchers.any(LoginAccount.class),
org.mockito.ArgumentMatchers.anyString()))
.thenThrow(new BusinessException("无权限运行所选工作流"));
SysJob update = new SysJob();
update.setId(jobId);
update.setJobParams(Map.of(
JobConstant.WORKFLOW_KEY, newWorkflowId.toString(),
JobConstant.WORKFLOW_PARAMS_KEY, Map.of()
));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
SysJobController controller = new SysJobController(
jobService,
workflowService,
workflowAuthorizationService,
resourceAccessService,
parameterResolver,
"Asia/Shanghai"
);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
BusinessException exception = Assert.expectThrows(
BusinessException.class,
() -> controller.onSaveOrUpdateBefore(update, false)
);
Assert.assertTrue(exception.getMessage().contains("无权限"));
verify(workflowAuthorizationService).requireUsableWorkflow(
org.mockito.ArgumentMatchers.eq(newWorkflowId),
org.mockito.ArgumentMatchers.eq(account),
org.mockito.ArgumentMatchers.anyString());
}
}
private static SysJobController controller(SysJobService service) {
return new SysJobController(
service,
mock(WorkflowService.class),
mock(WorkflowUsageAuthorizationService.class),
mock(ResourceAccessService.class),
mock(WorkflowRunningParameterResolver.class),
"Asia/Shanghai");
}
private static SysJob validJavaJob() {
SysJob job = new SysJob();
job.setJobName("generation-test");
job.setJobType(EnumJobType.JAVA_CLASS.getCode());
job.setCronExpression("0 0 0 1 1 ? 2099");
job.setMisfirePolicy(EnumMisfirePolicy.SKIP.getCode());
job.setAllowConcurrent(0);
job.setJobParams(Map.of(JobConstant.JAVA_METHOD_KEY,
"tech.easyflow.job.util.JobUtil.test()"));
return job;
}
private static LoginAccount account() {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.ONE);
account.setDeptId(BigInteger.ONE);
return account;
}
}

View File

@@ -1,103 +0,0 @@
package tech.easyflow.admin.controller.job;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.mockito.ArgumentCaptor;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.service.SysJobLogService;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link SysJobLogController} 查询与轻量刷新边界测试。
*/
public class SysJobLogControllerTest {
@Test
public void shouldBuildBothFireTimeRanges() {
SysJobLogController controller = controller(mock(SysJobLogService.class));
HttpServletRequest request = emptyRequest();
when(request.getParameter("scheduledStart")).thenReturn("2026-08-31 10:00:00");
when(request.getParameter("scheduledEnd")).thenReturn("2026-08-31 11:00:00");
when(request.getParameter("actualStart")).thenReturn("2026-08-31 10:00:01");
when(request.getParameter("actualEnd")).thenReturn("2026-08-31 11:00:01");
String sql = controller.buildQueryWrapper(request).toSQL().toLowerCase(Locale.ROOT);
Assert.assertEquals(countOccurrences(sql, "scheduled_fire_time"), 2);
Assert.assertEquals(countOccurrences(sql, "actual_fire_time"), 2);
}
@Test(expectedExceptions = BusinessException.class)
public void shouldRejectInvalidFireTime() {
SysJobLogController controller = controller(mock(SysJobLogService.class));
HttpServletRequest request = emptyRequest();
when(request.getParameter("scheduledStart")).thenReturn("2026/08/31 10:00:00");
controller.buildQueryWrapper(request);
}
@Test(expectedExceptions = BusinessException.class)
public void shouldRejectReversedActualFireTimeRange() {
SysJobLogController controller = controller(mock(SysJobLogService.class));
HttpServletRequest request = emptyRequest();
when(request.getParameter("actualStart")).thenReturn("2026-08-31 11:00:00");
when(request.getParameter("actualEnd")).thenReturn("2026-08-31 10:00:00");
controller.buildQueryWrapper(request);
}
@Test
public void shouldClampRefreshAndPageSize() {
SysJobLogService service = mock(SysJobLogService.class);
when(service.list(any(QueryWrapper.class))).thenReturn(List.of());
when(service.page(any(Page.class), any(QueryWrapper.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
SysJobLogController controller = controller(service);
HttpServletRequest request = emptyRequest();
controller.refresh(request, 500L);
Page<SysJobLog> page = controller.queryPage(
new Page<>(1, 500), QueryWrapper.create());
ArgumentCaptor<QueryWrapper> queryCaptor =
ArgumentCaptor.forClass(QueryWrapper.class);
verify(service).list(queryCaptor.capture());
String refreshSql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
Assert.assertTrue(refreshSql.contains("limit 100"));
Assert.assertEquals(page.getPageSize(), 100L);
}
@Test
public void shouldUseStableScheduledFireTimeOrder() {
Assert.assertEquals(
controller(mock(SysJobLogService.class)).getDefaultOrderBy(),
"scheduled_fire_time desc, id desc");
}
private static SysJobLogController controller(SysJobLogService service) {
return new SysJobLogController(service, "Asia/Shanghai");
}
private static HttpServletRequest emptyRequest() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getParameterMap()).thenReturn(Collections.emptyMap());
return request;
}
private static int countOccurrences(String source, String expected) {
return (source.length() - source.replace(expected, "").length())
/ expected.length();
}
}

Some files were not shown because too many files have changed in this diff Show More