Compare commits
162 Commits
v1.0
...
7aed4bcc37
| Author | SHA1 | Date | |
|---|---|---|---|
| 7aed4bcc37 | |||
| 6248e2c7b8 | |||
| 4de8cc5bd0 | |||
| 71b3d3d620 | |||
| 1870ac4028 | |||
| 2a383ef3f2 | |||
| fd64073148 | |||
| f63cd9be4d | |||
| 407b85c8a9 | |||
| 9078ca163e | |||
| 7f083a9433 | |||
| 7d654c3302 | |||
| fa07134cf8 | |||
| 310fc1fb58 | |||
| c96f772f01 | |||
| 30b2cc36fd | |||
| 7a5298c3fc | |||
| 9579619384 | |||
| 4e8640dcaf | |||
| 91d66e636d | |||
| 5de3b209c1 | |||
| 77d66e1b42 | |||
| 9be9bd7665 | |||
| 64a85c6a5b | |||
| 765006747a | |||
| fe41d62b8a | |||
| ac9e200a15 | |||
| 6c491bd893 | |||
| 742a4b1647 | |||
| 402c0f16b8 | |||
| 1c0fbfa5ff | |||
| 6bfd440214 | |||
| 9d2fa39a2d | |||
| bae7b18977 | |||
| 54d85ae460 | |||
| 0d14f1c165 | |||
| 13dec6c216 | |||
| d244a0404d | |||
| 6ad004da9b | |||
| 4b52d85512 | |||
| 7a64cfcaa4 | |||
| 7082041e6e | |||
| 170b315459 | |||
| 6544cdcff0 | |||
| 7bcaf69fc0 | |||
| a75ae9a843 | |||
| 46398d1365 | |||
| 08fe3ba1f1 | |||
| 3a77149087 | |||
| 2a930a1c97 | |||
| c757eeed50 | |||
| 2eaf2eace7 | |||
| 0860492446 | |||
| de0ccf03aa | |||
| 89824b5b9b | |||
| 8c334be65d | |||
| 93db17b384 | |||
| 866688b92f | |||
| 219e4f7eff | |||
| 19dac5146c | |||
| e3228837f4 | |||
| dc99d1aa52 | |||
| 1bf755f6c4 | |||
| 51dbfd41b6 | |||
| 6df3dd9981 | |||
| 527336bfc9 | |||
| 12080ceedb | |||
| 5c29ca9407 | |||
| 4a0efe8879 | |||
| fb08424cef | |||
| 0754ad0792 | |||
| f92707dac8 | |||
| a051dbacba | |||
| f872eac1f9 | |||
| f0aba1eddd | |||
| 41b056b7e3 | |||
| 1cbee6b018 | |||
| c2ed5a24a3 | |||
| 615092f4f7 | |||
| 048aa9bc1e | |||
| 0b764b79de | |||
| 2267517fd8 | |||
| 100d744c25 | |||
| 2abff304ed | |||
| 9959f95b94 | |||
| 5dbb46ef8f | |||
| 57bd7b5d06 | |||
| 864cea6135 | |||
| ff5f90121b | |||
| 1b40829135 | |||
| 03f45212ef | |||
| c78074a969 | |||
| 5f11219226 | |||
| 766554bf63 | |||
| 19c7b60a65 | |||
| 9c3badc55c | |||
| d45c67a317 | |||
| fceedd02cd | |||
| 1ae8a22afe | |||
| 5ee6065017 | |||
| f392c896f9 | |||
| dc35ddc3e4 | |||
| 1404c2ddc5 | |||
| 1630d6194a | |||
| a22ca24906 | |||
| 24b1142738 | |||
| 908eb5583d | |||
| a904f63ec8 | |||
| b0ed512e97 | |||
| b08eb009bb | |||
| 5497931abd | |||
| 2892a7eddc | |||
| 567fd12706 | |||
| 0dc5c3ca55 | |||
| 2a9e882ac6 | |||
| aedefe6b5e | |||
| dc7e46260b | |||
| ba4253e13e | |||
| ac07899dc4 | |||
| cb06599dca | |||
| da59c713f9 | |||
| a75f7baf1b | |||
| 63eb55e24c | |||
| 526e16163b | |||
| 41545fcef0 | |||
| 417e846cbd | |||
| 8b4ba65e1c | |||
| 4a8e633083 | |||
| df9fe4c2fe | |||
| 3e8ee66ab6 | |||
| ebfd91eeab | |||
| 5a42826d44 | |||
| caa1f07b66 | |||
| 19059fde96 | |||
| 9f06c238d3 | |||
| 5a3d4788da | |||
| 53fb63802b | |||
| 9436cc5397 | |||
| f658f120e6 | |||
| 4c28eaf393 | |||
| 791649c7d5 | |||
| ba21f861f4 | |||
| 1e6158be77 | |||
| 62d763199f | |||
| 5a91323a2e | |||
| 27e50a7624 | |||
| 705e0faab6 | |||
| ce8b4fb420 | |||
| 34ff62d317 | |||
| 1a91e16ac9 | |||
| b1706e9589 | |||
| 7cf6c31da3 | |||
| e56f043483 | |||
| 03ad011f64 | |||
| 9c1de1657d | |||
| 999a21e2d9 | |||
| 28797a83cc | |||
| 72c0115b19 | |||
| 7591eb8cda | |||
| ef4528a441 | |||
| e9ddeae33f | |||
| 950148b3f7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,6 +35,8 @@ build/
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
/.logs/
|
||||
/logs/
|
||||
/agent-workspaces/
|
||||
/.idea/
|
||||
.logs
|
||||
.idea
|
||||
@@ -45,4 +47,4 @@ luceneKnowledge
|
||||
**/*.lic
|
||||
|
||||
# v1
|
||||
/easyflow-ui-react
|
||||
/easyflow-ui-react
|
||||
|
||||
99
Dockerfile
99
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# 后端构建脚本
|
||||
FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
|
||||
FROM 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,12 +9,15 @@ 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.npmmirror.com
|
||||
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN useradd --system --create-home easyflow && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
@@ -29,26 +32,106 @@ 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}" && \
|
||||
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/* && \
|
||||
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 /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/easyflow-entrypoint.sh
|
||||
RUN chmod 755 /usr/local/bin/soffice /usr/local/bin/easyflow-entrypoint.sh
|
||||
|
||||
VOLUME ["/app/logs", "/app/data"]
|
||||
EXPOSE 8111
|
||||
|
||||
15
README.md
15
README.md
@@ -58,6 +58,14 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
管理端开发环境与生产环境统一使用 `/flow/` 基路径和 Hash 路由,默认访问地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5090/flow/#/
|
||||
```
|
||||
|
||||
直接访问 `http://127.0.0.1:5090` 或 `/flow` 时,开发服务器会自动补齐 `/flow/`。
|
||||
|
||||
用户中心:
|
||||
|
||||
```bash
|
||||
@@ -68,6 +76,13 @@ pnpm dev
|
||||
|
||||
默认测试账号:`admin / Easy@2026`
|
||||
|
||||
管理端发布前可执行以下命令,验证环境契约、生产镜像和 Nginx 路由:
|
||||
|
||||
```bash
|
||||
cd easyflow-ui-admin
|
||||
pnpm verify:deployment
|
||||
```
|
||||
|
||||
## 后端 Jar 包构建与部署
|
||||
|
||||
### 构建 Jar
|
||||
|
||||
@@ -72,6 +72,8 @@ 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"
|
||||
@@ -88,7 +90,7 @@ services:
|
||||
MINIO_ROOT_USER: easyflowadmin
|
||||
MINIO_ROOT_PASSWORD: easyflowadmin123
|
||||
MINIO_ENDPOINT: http://minio:9000
|
||||
MINIO_BUCKETS: easyflow,milvus
|
||||
MINIO_BUCKETS: easyflow,milvus,easyflow-agent-media
|
||||
MINIO_PUBLIC_BUCKETS: easyflow
|
||||
MINIO_ALIAS: local
|
||||
volumes:
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
- easyflow-net
|
||||
volumes:
|
||||
- ./attachment:/www/easyflow/attachment
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
21
docker-soffice-wrapper.sh
Normal file
21
docker-soffice-wrapper.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/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 \
|
||||
"$@"
|
||||
@@ -24,6 +24,10 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
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;
|
||||
@@ -19,30 +30,44 @@ 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.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 tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.log.annotation.LogReporterDisabled;
|
||||
|
||||
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 管理端控制器。
|
||||
*/
|
||||
@@ -55,17 +80,25 @@ 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 控制器。
|
||||
@@ -76,6 +109,16 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
super(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 列表关键字搜索字段。
|
||||
*
|
||||
* @return Agent 名称和描述属性
|
||||
*/
|
||||
@Override
|
||||
protected String[] getKeywordSearchProperties() {
|
||||
return new String[]{"name", "description"};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 详情。
|
||||
*
|
||||
@@ -83,10 +126,11 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
* @return Agent 详情
|
||||
*/
|
||||
@GetMapping("/getDetail")
|
||||
public Result<Agent> getDetail(BigInteger id) {
|
||||
public Result<AgentDetailView> getDetail(BigInteger id) {
|
||||
Agent agent = service.getDetail(id);
|
||||
agentApprovalStateService.fillAgentApprovalState(agent);
|
||||
return Result.ok(agent);
|
||||
aiResourceCreatorNameSupport.fillAgentCreatorNames(List.of(agent));
|
||||
return Result.ok(AgentDetailView.from(agent));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,7 +142,8 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
@Override
|
||||
@PostMapping("save")
|
||||
public Result<?> save(@JsonBody Agent agent) {
|
||||
return Result.ok(service.saveDraft(agent));
|
||||
Agent saved = service.saveDraft(agent);
|
||||
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +155,45 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
@Override
|
||||
@PostMapping("update")
|
||||
public Result<?> update(@JsonBody Agent agent) {
|
||||
return Result.ok(service.updateDraft(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())));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,20 +209,67 @@ 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);
|
||||
if (!applyCategoryPermission(queryWrapper)) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
|
||||
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 纯文本聊天。
|
||||
*
|
||||
@@ -147,6 +277,7 @@ 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);
|
||||
}
|
||||
@@ -158,10 +289,327 @@ 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 草稿试运行会话。
|
||||
*
|
||||
@@ -169,6 +617,7 @@ 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();
|
||||
@@ -182,6 +631,9 @@ 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);
|
||||
@@ -197,6 +649,9 @@ 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) {
|
||||
@@ -232,6 +687,26 @@ 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交发布审批。
|
||||
*
|
||||
@@ -269,16 +744,13 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
}
|
||||
|
||||
@Override
|
||||
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");
|
||||
}
|
||||
}
|
||||
agentToolBindingService.remove(QueryWrapper.create().in("agent_id", ids));
|
||||
agentKnowledgeBindingService.remove(QueryWrapper.create().in("agent_id", ids));
|
||||
return super.onRemoveBefore(ids);
|
||||
public Result<?> remove(Serializable id) {
|
||||
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> removeBatch(Collection<Serializable> ids) {
|
||||
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,38 +762,55 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
*/
|
||||
@Override
|
||||
protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) {
|
||||
if (!applyCategoryPermission(queryWrapper)) {
|
||||
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
|
||||
}
|
||||
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
|
||||
applyPublishedOnlyFilter(queryWrapper);
|
||||
Page<Agent> result = super.queryPage(page, queryWrapper);
|
||||
Page<Agent> result = service.page(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;
|
||||
}
|
||||
|
||||
private boolean applyCategoryPermission(QueryWrapper queryWrapper) {
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
|
||||
if (!access.isRestricted()) {
|
||||
return true;
|
||||
/**
|
||||
* 清理列表无需返回的配置和发布快照,避免敏感运行配置进入浏览器。
|
||||
*
|
||||
* @param agent Agent 列表项
|
||||
*/
|
||||
private void sanitizeListItem(Agent agent) {
|
||||
if (agent == null) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为仅发布查询追加发布状态条件。
|
||||
*
|
||||
* @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) {
|
||||
@@ -343,6 +832,14 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
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;
|
||||
@@ -21,17 +26,42 @@ 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) {
|
||||
public AgentSessionController(AgentSessionService agentSessionService,
|
||||
AgentOptionQueryService agentOptionQueryService) {
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +160,17 @@ public class AgentSessionController {
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录信息失效时抛出
|
||||
*/
|
||||
private LoginAccount currentAccount() {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.admin.controller.ai.support.BotResourceCreatorNameSupport;
|
||||
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 AiResourceApprovalStateService aiResourceApprovalStateService;
|
||||
private BotApprovalStateService botApprovalStateService;
|
||||
@Resource
|
||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||
private BotResourceCreatorNameSupport botResourceCreatorNameSupport;
|
||||
@Resource
|
||||
private ChatWorkspaceService chatWorkspaceService;
|
||||
|
||||
@@ -97,6 +97,16 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
this.botMessageService = botMessageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取智能体列表关键字搜索字段。
|
||||
*
|
||||
* @return 标题和描述属性
|
||||
*/
|
||||
@Override
|
||||
protected String[] getKeywordSearchProperties() {
|
||||
return new String[]{"title", "description"};
|
||||
}
|
||||
|
||||
@Resource
|
||||
private BotPluginService botPluginService;
|
||||
|
||||
@@ -240,7 +250,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
bot = botService.toPublishedView(rawBot);
|
||||
}
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(bot);
|
||||
botApprovalStateService.fillApprovalState(bot);
|
||||
}
|
||||
return Result.ok(bot);
|
||||
}
|
||||
@@ -275,7 +285,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
|
||||
if (data.getModelId() == null) {
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(data);
|
||||
botApprovalStateService.fillApprovalState(data);
|
||||
}
|
||||
return Result.ok(data);
|
||||
}
|
||||
@@ -286,7 +296,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
if (llm == null) {
|
||||
data.setModelId(null);
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(data);
|
||||
botApprovalStateService.fillApprovalState(data);
|
||||
}
|
||||
return Result.ok(data);
|
||||
}
|
||||
@@ -302,21 +312,43 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
}
|
||||
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(data);
|
||||
botApprovalStateService.fillApprovalState(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) {
|
||||
public Result<BigInteger> submitPublishApproval(
|
||||
@JsonBody("id") BigInteger id,
|
||||
@JsonBody("applicationReason") String applicationReason
|
||||
) {
|
||||
return buildApprovalActionResult(
|
||||
botPublishAppService.submitPublishApproval(id),
|
||||
botPublishAppService.submitPublishApproval(id, applicationReason),
|
||||
"已提交发布审批",
|
||||
"已直接发布"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预检聊天助手发布是否命中审批流。
|
||||
*
|
||||
* @param id 助手 ID
|
||||
* @return 是否需要审批
|
||||
*/
|
||||
@GetMapping("/publishApprovalRequirement")
|
||||
@SaCheckPermission("/api/v1/bot/save")
|
||||
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
|
||||
return Result.ok(botPublishAppService.isPublishApprovalRequired(id));
|
||||
}
|
||||
|
||||
@PostMapping("/submitOfflineApproval")
|
||||
@SaCheckPermission("/api/v1/bot/save")
|
||||
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
||||
@@ -347,7 +379,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
if (isPublishedOnlyRequest()) {
|
||||
bots = bots.stream().map(botService::toPublishedView).toList();
|
||||
}
|
||||
aiResourceApprovalStateService.fillBotApprovalState(bots);
|
||||
botApprovalStateService.fillApprovalState(bots);
|
||||
return Result.ok(bots);
|
||||
}
|
||||
|
||||
@@ -359,8 +391,8 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
if (isPublishedOnlyRequest()) {
|
||||
result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList());
|
||||
}
|
||||
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords());
|
||||
aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords());
|
||||
botApprovalStateService.fillApprovalState(result.getRecords());
|
||||
botResourceCreatorNameSupport.fillCreatorNames(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
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;
|
||||
@@ -16,46 +19,133 @@ 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;
|
||||
|
||||
public ChatHistoryController(ChatHistoryManageService chatHistoryManageService) {
|
||||
/**
|
||||
* 创建聊天历史控制器。
|
||||
*
|
||||
* @param chatHistoryManageService 聊天历史管理服务
|
||||
* @param categoryPermissionService 账号权限服务
|
||||
* @param agentOptionQueryService Agent 安全选项服务
|
||||
*/
|
||||
public ChatHistoryController(ChatHistoryManageService chatHistoryManageService,
|
||||
CategoryPermissionService categoryPermissionService,
|
||||
AgentOptionQueryService agentOptionQueryService) {
|
||||
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) {
|
||||
return Result.ok(chatHistoryManageService.queryAdminSessions(query));
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(chatHistoryManageService.queryAdminSessions(
|
||||
account.getId(),
|
||||
categoryPermissionService.isSuperAdmin(account),
|
||||
query
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前账号可见的 Agent 会话详情。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 会话详情
|
||||
*/
|
||||
@GetMapping("/sessions/{sessionId}")
|
||||
public Result<ChatSessionSummary> getSession(@PathVariable BigInteger sessionId) {
|
||||
return Result.ok(chatHistoryManageService.getAdminSession(sessionId));
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(chatHistoryManageService.getAdminSession(
|
||||
account.getId(),
|
||||
categoryPermissionService.isSuperAdmin(account),
|
||||
sessionId
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询当前账号可见会话的消息。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param query 消息分页条件
|
||||
* @return 消息分页结果
|
||||
*/
|
||||
@GetMapping("/sessions/{sessionId}/messages")
|
||||
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
|
||||
return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query));
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(chatHistoryManageService.queryAdminMessages(
|
||||
account.getId(),
|
||||
categoryPermissionService.isSuperAdmin(account),
|
||||
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) {
|
||||
return Result.ok(chatHistoryManageService.listAdminRoundVariants(sessionId, roundId));
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(chatHistoryManageService.listAdminRoundVariants(
|
||||
account.getId(),
|
||||
categoryPermissionService.isSuperAdmin(account),
|
||||
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(sessionId, roundId, variantIndex, account.getId()));
|
||||
return Result.ok(chatHistoryManageService.selectAdminRoundVariant(
|
||||
account.getId(),
|
||||
categoryPermissionService.isSuperAdmin(account),
|
||||
sessionId,
|
||||
roundId,
|
||||
variantIndex
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ 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;
|
||||
@@ -27,7 +28,6 @@ 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 BotDocumentCollectionService botDocumentCollectionService;
|
||||
private AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
@@ -86,6 +86,16 @@ 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);
|
||||
@@ -169,11 +179,11 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
|
||||
}
|
||||
|
||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
||||
queryWrapper.in(BotDocumentCollection::getDocumentCollectionId, ids);
|
||||
queryWrapper.in(AgentKnowledgeBinding::getKnowledgeId, ids);
|
||||
|
||||
boolean exists = botDocumentCollectionService.exists(queryWrapper);
|
||||
boolean exists = agentKnowledgeBindingService.exists(queryWrapper);
|
||||
if (exists){
|
||||
throw new BusinessException("此知识库还关联着bot,请先取消关联!");
|
||||
throw new BusinessException("此知识库仍被智能体使用,请先取消绑定后再删除");
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -203,18 +213,34 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
|
||||
* 提交发布审批。
|
||||
*
|
||||
* @param id 知识库 ID
|
||||
* @param applicationReason 审批说明
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitPublishApproval")
|
||||
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
|
||||
public Result<BigInteger> submitPublishApproval(
|
||||
@JsonBody("id") BigInteger id,
|
||||
@JsonBody("applicationReason") String applicationReason
|
||||
) {
|
||||
return buildApprovalActionResult(
|
||||
knowledgePublishAppService.submitPublishApproval(id),
|
||||
knowledgePublishAppService.submitPublishApproval(id, applicationReason),
|
||||
"已提交发布审批",
|
||||
"已直接发布"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预检知识库发布是否命中审批流。
|
||||
*
|
||||
* @param id 知识库 ID
|
||||
* @return 是否需要审批
|
||||
*/
|
||||
@GetMapping("/publishApprovalRequirement")
|
||||
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
|
||||
return Result.ok(knowledgePublishAppService.isPublishApprovalRequired(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交下线审批。
|
||||
*
|
||||
|
||||
@@ -11,8 +11,11 @@ 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;
|
||||
@@ -83,6 +86,9 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
|
||||
@Autowired
|
||||
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
|
||||
|
||||
@Autowired
|
||||
private DocumentImportBatchAppService documentImportBatchAppService;
|
||||
|
||||
@Value("${easyflow.storage.local.root:}")
|
||||
private String fileUploadPath;
|
||||
|
||||
@@ -93,6 +99,16 @@ 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")
|
||||
@@ -142,13 +158,19 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
|
||||
|
||||
@GetMapping("documentList")
|
||||
@SaCheckPermission("/api/v1/documentCollection/query")
|
||||
public Result<Page<Document>> documentList(@RequestParam(name="title", required = false) String fileName, @RequestParam(name="pageSize") int pageSize, @RequestParam(name = "pageNumber") int pageNumber) {
|
||||
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) {
|
||||
String kbSlug = RequestUtil.getParamAsString("id");
|
||||
if (StringUtil.noText(kbSlug)) {
|
||||
throw new BusinessException("知识库id不能为空");
|
||||
}
|
||||
DocumentCollection knowledge = getDocumentCollection(kbSlug, ResourceAction.READ, "无权限访问知识库");
|
||||
Page<Document> documentList = documentService.getDocumentList(knowledge.getId().toString(), pageSize, pageNumber,fileName);
|
||||
String effectiveKeyword = StringUtil.hasText(keyword) ? keyword : legacyTitle;
|
||||
Page<Document> documentList = documentService.getDocumentList(
|
||||
knowledge.getId().toString(), pageSize, pageNumber, effectiveKeyword);
|
||||
return Result.ok(documentList);
|
||||
}
|
||||
|
||||
@@ -312,6 +334,142 @@ 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
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -47,7 +48,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 = 5L * 1024L * 1024L;
|
||||
private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024L * 1024L;
|
||||
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
@@ -114,9 +115,14 @@ public class FaqItemController extends BaseCurdController<FaqItemService, FaqIte
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(FaqItem::getCollectionId, collectionId);
|
||||
|
||||
String question = request.getParameter("question");
|
||||
if (question != null && !question.trim().isEmpty()) {
|
||||
queryWrapper.like(FaqItem::getQuestion, question.trim());
|
||||
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 categoryIdText = request.getParameter("categoryId");
|
||||
@@ -215,7 +221,7 @@ public class FaqItemController extends BaseCurdController<FaqItemService, FaqIte
|
||||
throw new BusinessException("图片不能为空");
|
||||
}
|
||||
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new BusinessException("图片大小不能超过5MB");
|
||||
throw new BusinessException("图片大小不能超过20MB");
|
||||
}
|
||||
if (!isAllowedImageType(file)) {
|
||||
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");
|
||||
|
||||
@@ -9,16 +9,19 @@ 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.BotMcpService;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -33,8 +36,18 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
|
||||
super(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 MCP 列表关键字搜索字段。
|
||||
*
|
||||
* @return 标题和描述属性
|
||||
*/
|
||||
@Override
|
||||
protected String[] getKeywordSearchProperties() {
|
||||
return new String[]{"title", "description"};
|
||||
}
|
||||
|
||||
@Resource
|
||||
private BotMcpService botMcpService;
|
||||
private AgentResourceReferenceService agentResourceReferenceService;
|
||||
@Override
|
||||
public Result<?> save(Mcp entity) {
|
||||
return service.saveMcp(entity);
|
||||
@@ -45,11 +58,29 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
|
||||
return service.updateMcp(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除未被 Agent 绑定的 MCP。
|
||||
*
|
||||
* @param id MCP ID
|
||||
* @return 删除结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,21 @@ 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;
|
||||
@@ -26,7 +32,6 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -39,6 +44,11 @@ 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);
|
||||
}
|
||||
@@ -46,22 +56,30 @@ 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) {
|
||||
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);
|
||||
return Result.ok(service.listSelectableModels(entity, asTree, sortKey, sortType));
|
||||
}
|
||||
|
||||
@GetMapping("getList")
|
||||
@@ -92,9 +110,39 @@ 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();
|
||||
}
|
||||
@@ -145,8 +193,10 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
||||
queryWrapper.eq(Model::getProviderId, providerId);
|
||||
queryWrapper.eq(Model::getModelType, modelType);
|
||||
if (StringUtils.hasLength(selectText)) {
|
||||
queryWrapper.and(ModelTableDef.MODEL.TITLE.like(selectText).or(ModelTableDef.MODEL.MODEL_NAME.like(selectText)));
|
||||
String keyword = normalizeSearchKeyword(selectText);
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
queryWrapper.and(buildLiteralContainsCondition(
|
||||
keyword, ModelTableDef.MODEL.TITLE, ModelTableDef.MODEL.MODEL_NAME));
|
||||
}
|
||||
List<Model> totalList = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
|
||||
Map<String, List<Model>> groupList = totalList.stream().collect(Collectors.groupingBy(Model::getGroupName));
|
||||
@@ -167,11 +217,39 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
||||
}
|
||||
|
||||
@PostMapping("removeLlmByIds")
|
||||
@Transactional
|
||||
@SaCheckPermission("/api/v1/model/remove")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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);
|
||||
service.remove(queryWrapper);
|
||||
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()));
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取带租户信息的当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录状态无效时抛出
|
||||
*/
|
||||
private LoginAccount requireAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -28,12 +40,33 @@ import java.io.Serializable;
|
||||
@UsePermission(moduleName = "/api/v1/model")
|
||||
public class ModelProviderController extends BaseCurdController<ModelProviderService, ModelProvider> {
|
||||
private final ModelService modelService;
|
||||
private final RemoteModelDiscoveryService remoteModelDiscoveryService;
|
||||
private final RemoteModelImportService remoteModelImportService;
|
||||
|
||||
public ModelProviderController(ModelProviderService service, ModelService modelService) {
|
||||
/**
|
||||
* 创建模型服务商控制器。
|
||||
*
|
||||
* @param service 模型服务商服务
|
||||
* @param modelService 模型服务
|
||||
* @param remoteModelDiscoveryService 远端模型发现服务
|
||||
* @param remoteModelImportService 远端模型一键添加服务
|
||||
*/
|
||||
public ModelProviderController(ModelProviderService service,
|
||||
ModelService modelService,
|
||||
RemoteModelDiscoveryService remoteModelDiscoveryService,
|
||||
RemoteModelImportService remoteModelImportService) {
|
||||
super(service);
|
||||
this.modelService = modelService;
|
||||
this.remoteModelDiscoveryService = remoteModelDiscoveryService;
|
||||
this.remoteModelImportService = remoteModelImportService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除没有子模型的服务商。
|
||||
*
|
||||
* @param id 服务商 ID
|
||||
* @return 删除结果
|
||||
*/
|
||||
@Override
|
||||
@PostMapping("remove")
|
||||
@Transactional
|
||||
@@ -45,4 +78,35 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -12,6 +13,7 @@ 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;
|
||||
@@ -47,8 +49,24 @@ 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
|
||||
@@ -89,10 +107,15 @@ 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));
|
||||
}
|
||||
|
||||
@@ -105,9 +128,24 @@ 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 分类 ID,0 表示全部分类
|
||||
* @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) {
|
||||
public Result<Page<Plugin>> pageByCategory(HttpServletRequest request, String sortKey, String sortType,
|
||||
Long pageNumber, Long pageSize, int category,
|
||||
String keyword, String name) {
|
||||
if (pageNumber == null || pageNumber < 1) {
|
||||
pageNumber = 1L;
|
||||
}
|
||||
@@ -120,7 +158,10 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
return Result.ok(queryPage(new Page<>(pageNumber, pageSize), queryWrapper));
|
||||
} else {
|
||||
Result<Page<Plugin>> result = pluginService.pageByCategory(pageNumber, pageSize, category);
|
||||
String effectiveKeyword = normalizeSearchKeyword(
|
||||
keyword == null || keyword.isBlank() ? name : keyword);
|
||||
Result<Page<Plugin>> result = pluginService.pageByCategory(
|
||||
pageNumber, pageSize, category, effectiveKeyword);
|
||||
if (result != null && result.getData() != null) {
|
||||
aiResourceCreatorNameSupport.fillPluginCreatorNames(result.getData().getRecords());
|
||||
}
|
||||
@@ -135,7 +176,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.like("title", keyword.trim());
|
||||
queryWrapper.and(buildLiteralContainsCondition(keyword, new QueryColumn("title")));
|
||||
}
|
||||
queryWrapper.orderBy("modified desc");
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
|
||||
@@ -19,19 +19,20 @@ 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.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.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.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;
|
||||
@@ -42,8 +43,10 @@ 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;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -55,18 +58,35 @@ import java.util.Map;
|
||||
@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 BotPluginService botPluginService;
|
||||
private AgentResourceReferenceService agentResourceReferenceService;
|
||||
@Resource
|
||||
private PluginService pluginService;
|
||||
@Resource
|
||||
private PluginVisibilityService pluginVisibilityService;
|
||||
@Resource
|
||||
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
@@ -91,25 +111,12 @@ 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) {
|
||||
@@ -275,26 +282,64 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件工具前锁定资源并校验 Agent 绑定。
|
||||
*
|
||||
* @param ids 插件工具 ID 集合
|
||||
* @return 校验失败结果;允许删除时返回 {@code null}
|
||||
*/
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
|
||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
||||
queryWrapper.in(BotPlugin::getPluginItemId, ids);
|
||||
|
||||
boolean exists = botPluginService.exists(queryWrapper);
|
||||
if (exists){
|
||||
return Result.fail(1, "此工具还关联着bot,请先取消关联!");
|
||||
}
|
||||
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, "工作流插件工具由系统自动维护,不支持删除");
|
||||
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("插件工具不存在或已被删除");
|
||||
}
|
||||
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
if (loginAccount == null || loginAccount.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录信息无效");
|
||||
}
|
||||
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())) {
|
||||
return Result.fail(1, "工作流插件工具由系统自动维护,不支持删除");
|
||||
}
|
||||
}
|
||||
|
||||
agentResourceReferenceService.assertPluginItemsUnused(List.copyOf(uniquePluginItemIds));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,16 @@ 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();
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -47,6 +48,7 @@ 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;
|
||||
@@ -75,7 +77,7 @@ import java.util.Set;
|
||||
@RequestMapping("/api/v1/share/knowledge")
|
||||
public class ShareKnowledgeController {
|
||||
|
||||
private static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024L * 1024L;
|
||||
private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024L * 1024L;
|
||||
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
@@ -668,9 +670,15 @@ 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(question)) {
|
||||
queryWrapper.like(FaqItem::getQuestion, question.trim());
|
||||
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)));
|
||||
}
|
||||
String categoryId = request.getParameter("categoryId");
|
||||
if (StringUtils.hasText(categoryId)) {
|
||||
@@ -785,7 +793,7 @@ public class ShareKnowledgeController {
|
||||
throw new BusinessException("图片不能为空");
|
||||
}
|
||||
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new BusinessException("图片大小不能超过5MB");
|
||||
throw new BusinessException("图片大小不能超过20MB");
|
||||
}
|
||||
if (!isAllowedImageType(file)) {
|
||||
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");
|
||||
|
||||
@@ -1,88 +1,45 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
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.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.admin.service.ai.WorkflowDesignerOptionService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 工作流节点兼容接口。
|
||||
*/
|
||||
@RequestMapping("/api/v1/workflowNode")
|
||||
@RestController
|
||||
public class WorkFlowNodeController {
|
||||
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
@Resource
|
||||
private ChainParser chainParser;
|
||||
@Resource
|
||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||
private final 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 workflowDesignerOptionService 工作流设计器选项服务
|
||||
*/
|
||||
public WorkFlowNodeController(WorkflowDesignerOptionService workflowDesignerOptionService) {
|
||||
this.workflowDesignerOptionService = workflowDesignerOptionService;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 查询子流程输入输出定义。
|
||||
*
|
||||
* @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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
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.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 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
|
||||
) {
|
||||
WorkflowExecResult record = assertExecutionOwnership(executeId);
|
||||
if (record.getStatus() != null
|
||||
&& (record.getStatus() == ChainStatus.SUCCEEDED.getValue()
|
||||
|| record.getStatus() == ChainStatus.FAILED.getValue()
|
||||
|| record.getStatus() == ChainStatus.CANCELLED.getValue())) {
|
||||
throw new BusinessException("当前工作流执行已结束");
|
||||
}
|
||||
chainExecutor.resumeAsync(
|
||||
executeId,
|
||||
confirmParams == null
|
||||
? new LinkedHashMap<>()
|
||||
: new LinkedHashMap<>(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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,23 @@ 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;
|
||||
@@ -29,7 +37,6 @@ 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;
|
||||
@@ -39,6 +46,7 @@ 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;
|
||||
@@ -53,6 +61,7 @@ import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -71,7 +80,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
@Resource
|
||||
private SysApiKeyService apiKeyService;
|
||||
@Resource
|
||||
private BotWorkflowService botWorkflowService;
|
||||
private AgentToolBindingService agentToolBindingService;
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
@Resource
|
||||
@@ -94,12 +103,130 @@ 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点单独运行
|
||||
*/
|
||||
@@ -121,6 +248,9 @@ 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<>();
|
||||
}
|
||||
@@ -154,6 +284,7 @@ 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());
|
||||
@@ -193,7 +324,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
)
|
||||
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
||||
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
||||
throw new BusinessException(
|
||||
409,
|
||||
40901,
|
||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||
}
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@@ -245,6 +381,7 @@ 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, "节点配置错误,请检查! ");
|
||||
@@ -256,18 +393,48 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
* 提交发布审批。
|
||||
*
|
||||
* @param id 工作流 ID
|
||||
* @param applicationReason 审批说明
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitPublishApproval")
|
||||
@SaCheckPermission("/api/v1/workflow/save")
|
||||
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
|
||||
@RequireResourceAccess(
|
||||
resource = CategoryResourceType.WORKFLOW,
|
||||
action = ResourceAction.MANAGE,
|
||||
lookup = ResourceLookup.WORKFLOW_ID,
|
||||
idExpr = "#id",
|
||||
denyMessage = "无权限发布工作流"
|
||||
)
|
||||
public Result<BigInteger> submitPublishApproval(
|
||||
@JsonBody("id") BigInteger id,
|
||||
@JsonBody("applicationReason") String applicationReason
|
||||
) {
|
||||
return buildApprovalActionResult(
|
||||
workflowPublishAppService.submitPublishApproval(id),
|
||||
workflowPublishAppService.submitPublishApproval(id, applicationReason),
|
||||
"已提交发布审批",
|
||||
"已直接发布"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预检工作流发布是否命中审批流。
|
||||
*
|
||||
* @param id 工作流 ID
|
||||
* @return 是否需要审批
|
||||
*/
|
||||
@GetMapping("/publishApprovalRequirement")
|
||||
@SaCheckPermission("/api/v1/workflow/save")
|
||||
@RequireResourceAccess(
|
||||
resource = CategoryResourceType.WORKFLOW,
|
||||
action = ResourceAction.MANAGE,
|
||||
lookup = ResourceLookup.WORKFLOW_ID,
|
||||
idExpr = "#id",
|
||||
denyMessage = "无权限发布工作流"
|
||||
)
|
||||
public Result<Boolean> publishApprovalRequirement(@RequestParam BigInteger id) {
|
||||
return Result.ok(workflowPublishAppService.isPublishApprovalRequired(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交下线审批。
|
||||
*
|
||||
@@ -276,6 +443,13 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
*/
|
||||
@PostMapping("/submitOfflineApproval")
|
||||
@SaCheckPermission("/api/v1/workflow/save")
|
||||
@RequireResourceAccess(
|
||||
resource = CategoryResourceType.WORKFLOW,
|
||||
action = ResourceAction.MANAGE,
|
||||
lookup = ResourceLookup.WORKFLOW_ID,
|
||||
idExpr = "#id",
|
||||
denyMessage = "无权限下线工作流"
|
||||
)
|
||||
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(
|
||||
workflowPublishAppService.submitOfflineApproval(id),
|
||||
@@ -311,6 +485,13 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
*/
|
||||
@PostMapping("/submitDeleteApproval")
|
||||
@SaCheckPermission("/api/v1/workflow/remove")
|
||||
@RequireResourceAccess(
|
||||
resource = CategoryResourceType.WORKFLOW,
|
||||
action = ResourceAction.MANAGE,
|
||||
lookup = ResourceLookup.WORKFLOW_ID,
|
||||
idExpr = "#id",
|
||||
denyMessage = "无权限删除工作流"
|
||||
)
|
||||
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(
|
||||
workflowPublishAppService.submitDeleteApproval(id),
|
||||
@@ -378,20 +559,76 @@ 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();
|
||||
@@ -455,11 +692,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
Workflow workflow = requireWorkflow(String.valueOf(id));
|
||||
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.MANAGE, "无权限管理工作流");
|
||||
}
|
||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
||||
queryWrapper.in("workflow_id", ids);
|
||||
boolean exists = botWorkflowService.exists(queryWrapper);
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(AgentToolBinding::getToolType, AgentToolType.WORKFLOW.name())
|
||||
.in(AgentToolBinding::getTargetId, ids);
|
||||
boolean exists = agentToolBindingService.exists(queryWrapper);
|
||||
if (exists) {
|
||||
return Result.fail(1, "此工作流还关联有bot,请先取消关联后再删除!");
|
||||
return Result.fail(1, "此工作流仍被智能体使用,请先取消绑定后再删除");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -485,6 +723,15 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建工作流内容修订冲突异常。
|
||||
*
|
||||
* @return HTTP 409 业务异常
|
||||
*/
|
||||
private BusinessException workflowRevisionConflict() {
|
||||
return new BusinessException(409, 409, "工作流已被其他人更新,请刷新后重新编辑");
|
||||
}
|
||||
|
||||
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -36,9 +37,20 @@ 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);
|
||||
@@ -61,4 +73,4 @@ public class WorkflowExecResultController extends BaseCurdController<WorkflowExe
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ 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) {
|
||||
@@ -60,4 +70,4 @@ public class WorkflowExecStepController extends BaseCurdController<WorkflowExecS
|
||||
}
|
||||
return Result.ok(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowShareService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.ai.vo.WorkflowShareCreateResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.util.RequestUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流分享管理接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/workflowShare")
|
||||
public class WorkflowShareController {
|
||||
|
||||
@Resource
|
||||
private WorkflowShareService workflowShareService;
|
||||
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
|
||||
@Resource
|
||||
private KnowledgeShareAuditService knowledgeShareAuditService;
|
||||
|
||||
/**
|
||||
* 创建或刷新已发布工作流的对话分享链接。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @param workflowId 工作流 ID
|
||||
* @return 分享创建结果
|
||||
*/
|
||||
@PostMapping("/url/create")
|
||||
@SaCheckPermission("/api/v1/workflow/save")
|
||||
public Result<WorkflowShareCreateResult> createUrlShare(
|
||||
HttpServletRequest request,
|
||||
@JsonBody("workflowId") BigInteger workflowId
|
||||
) {
|
||||
Workflow workflow = workflowService.getById(workflowId);
|
||||
if (workflow == null) {
|
||||
throw new BusinessException("工作流不存在");
|
||||
}
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.WORKFLOW,
|
||||
workflow,
|
||||
ResourceAction.MANAGE,
|
||||
"无权限分享工作流"
|
||||
);
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
WorkflowShareCreateResult result = workflowShareService.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")
|
||||
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
WorkflowShare share = workflowShareService.resolveChatShare(
|
||||
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER),
|
||||
loginAccount.getTenantId()
|
||||
);
|
||||
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据管理端来源构建工作流分享基础 URL。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @return 工作流独立分享页 URL
|
||||
*/
|
||||
private String buildShareBaseUrl(HttpServletRequest request) {
|
||||
String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request));
|
||||
if (refererBaseUrl != null) {
|
||||
return refererBaseUrl + "/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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
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;
|
||||
@@ -40,15 +42,6 @@ public class AiResourceCreatorNameSupport {
|
||||
fillCreatorNames(workflows, Workflow::getCreatedBy, Workflow::setCreatedByName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充聊天助手创建人名称。
|
||||
*
|
||||
* @param bots 聊天助手集合
|
||||
*/
|
||||
public void fillBotCreatorNames(Collection<Bot> bots) {
|
||||
fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充知识库创建人名称。
|
||||
*
|
||||
@@ -76,6 +69,46 @@ 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 + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的创建人名称填充逻辑。
|
||||
*
|
||||
@@ -84,7 +117,7 @@ public class AiResourceCreatorNameSupport {
|
||||
* @param createdByNameSetter 创建人名称回填函数
|
||||
* @param <T> 资源类型
|
||||
*/
|
||||
private <T> void fillCreatorNames(
|
||||
<T> void fillCreatorNames(
|
||||
Collection<T> resources,
|
||||
Function<T, Number> createdByGetter,
|
||||
BiConsumer<T, String> createdByNameSetter
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,22 @@ package tech.easyflow.admin.controller.auth;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
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.auth.entity.LoginDTO;
|
||||
import tech.easyflow.auth.entity.LoginVO;
|
||||
import tech.easyflow.auth.entity.*;
|
||||
import tech.easyflow.auth.service.AuthCredentialKeyService;
|
||||
import tech.easyflow.auth.service.AuthService;
|
||||
import tech.easyflow.common.captcha.tainai.CaptchaVerificationService;
|
||||
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.log.annotation.LogRecord;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
@@ -21,9 +28,23 @@ public class AuthController {
|
||||
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
@Resource
|
||||
private AuthCredentialKeyService credentialKeyService;
|
||||
@Resource
|
||||
private CaptchaVerificationService captchaVerificationService;
|
||||
|
||||
@GetMapping("credential-key")
|
||||
public Result<CredentialKeyVO> credentialKey() {
|
||||
return Result.ok(credentialKeyService.getCurrentCredentialKey());
|
||||
}
|
||||
|
||||
@PostMapping("login")
|
||||
public Result<LoginVO> login(@JsonBody LoginDTO loginDTO) {
|
||||
public Result<LoginVO> login(@JsonBody EncryptedCredentialDTO encryptedCredential) {
|
||||
JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential);
|
||||
if (!captchaVerificationService.verify(payload.getString("validToken"))) {
|
||||
return captchaVerificationService.failureResult();
|
||||
}
|
||||
LoginDTO loginDTO = toLoginDTO(payload);
|
||||
LoginVO res = authService.login(loginDTO);
|
||||
return Result.ok(res);
|
||||
}
|
||||
@@ -45,4 +66,23 @@ public class AuthController {
|
||||
List<String> permissionList = StpUtil.getPermissionList();
|
||||
return Result.ok(permissionList);
|
||||
}
|
||||
|
||||
@PostMapping("credential-key/rotate")
|
||||
@LogRecord("轮换认证传输密钥")
|
||||
public Result<Void> rotateCredentialKey(@JsonBody CredentialKeyRotateDTO rotateDTO) {
|
||||
StpUtil.checkLogin();
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
if (loginAccount == null || !Constants.SUPER_ADMIN_ID.equals(loginAccount.getId())) {
|
||||
throw new BusinessException("仅超级管理员可轮换认证密钥");
|
||||
}
|
||||
credentialKeyService.rotate(rotateDTO != null && Boolean.TRUE.equals(rotateDTO.getEmergency()));
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private LoginDTO toLoginDTO(JSONObject payload) {
|
||||
LoginDTO loginDTO = new LoginDTO();
|
||||
loginDTO.setAccount(payload.getString("account"));
|
||||
loginDTO.setPassword(payload.getString("password"));
|
||||
return loginDTO;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -16,18 +17,30 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 提供已注册静态字典的查询接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dict/")
|
||||
public class DictController {
|
||||
|
||||
@Resource
|
||||
DictManager dictManager;
|
||||
private 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) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
throw new BusinessException("字典不存在或不支持公共查询");
|
||||
}
|
||||
Map<String, String[]> parameterMap = request.getParameterMap();
|
||||
Dict dict = loader.load(keyword, parameterMap);
|
||||
|
||||
@@ -10,6 +10,8 @@ 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;
|
||||
|
||||
@@ -28,9 +30,29 @@ import java.util.List;
|
||||
public class DashboardController {
|
||||
|
||||
private final DashboardService dashboardService;
|
||||
private final AgentOptionQueryService agentOptionQueryService;
|
||||
|
||||
public DashboardController(DashboardService dashboardService) {
|
||||
/**
|
||||
* 创建管理端工作台控制器。
|
||||
*
|
||||
* @param dashboardService 工作台统计服务
|
||||
* @param agentOptionQueryService Agent 安全选项服务
|
||||
*/
|
||||
public DashboardController(DashboardService dashboardService,
|
||||
AgentOptionQueryService agentOptionQueryService) {
|
||||
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")
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -32,11 +33,21 @@ 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(MultipartFile file) throws Exception {
|
||||
public Result<DatacenterImportJob> importWorkbook(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "sourceName", required = false) String sourceName) throws Exception {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(excelImportService.importWorkbook(file, account));
|
||||
return Result.ok(excelImportService.importWorkbook(file, sourceName, account));
|
||||
}
|
||||
|
||||
@PostMapping("/split")
|
||||
|
||||
@@ -2,17 +2,33 @@ package tech.easyflow.admin.controller.job;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.quartz.CronExpression;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
||||
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.constant.enums.EnumJobType;
|
||||
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.job.entity.SysJob;
|
||||
import tech.easyflow.job.job.JobConstant;
|
||||
import tech.easyflow.job.service.SysJobService;
|
||||
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
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 java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
@@ -20,6 +36,8 @@ import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 系统任务表 控制层。
|
||||
@@ -30,25 +48,71 @@ import java.util.List;
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/sysJob")
|
||||
public class SysJobController extends BaseCurdController<SysJobService, SysJob> {
|
||||
public SysJobController(SysJobService service) {
|
||||
|
||||
/** 工作流服务。 */
|
||||
private final WorkflowService workflowService;
|
||||
|
||||
/** 工作流使用权限校验服务。 */
|
||||
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
|
||||
|
||||
/** 资源访问控制服务。 */
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
|
||||
/** 工作流运行参数解析器。 */
|
||||
private final WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||
|
||||
/**
|
||||
* 创建定时任务控制器。
|
||||
*
|
||||
* @param service 定时任务服务
|
||||
* @param workflowService 工作流服务
|
||||
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
|
||||
* @param resourceAccessService 资源访问控制服务
|
||||
* @param workflowRunningParameterResolver 工作流运行参数解析器
|
||||
*/
|
||||
public SysJobController(SysJobService service,
|
||||
WorkflowService workflowService,
|
||||
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
WorkflowRunningParameterResolver workflowRunningParameterResolver) {
|
||||
super(service);
|
||||
this.workflowService = workflowService;
|
||||
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.workflowRunningParameterResolver = workflowRunningParameterResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取定时任务关键字搜索字段。
|
||||
*
|
||||
* @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("/getNextTimes")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{
|
||||
CronExpression ex = new CronExpression(cronExpression);
|
||||
List<String> times = new ArrayList<>();
|
||||
@@ -61,18 +125,203 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
return Result.ok(times);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前账号可用于定时任务的工作流。
|
||||
*
|
||||
* @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::getStatus, EnumDataStatus.AVAILABLE.getCode())
|
||||
.orderBy(Workflow::getModified, false))
|
||||
.stream()
|
||||
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
|
||||
.filter(workflow -> resourceAccessService.canAccess(
|
||||
account,
|
||||
CategoryResourceType.WORKFLOW,
|
||||
workflow,
|
||||
ResourceAction.USE))
|
||||
.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);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onSaveOrUpdateBefore(SysJob entity, boolean isSave) {
|
||||
if (entity == null) {
|
||||
throw new BusinessException("定时任务不能为空");
|
||||
}
|
||||
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
|
||||
SysJob effectiveEntity = entity;
|
||||
if (isSave) {
|
||||
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);
|
||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工作流类型任务引用的工作流可被当前用户运行。
|
||||
*
|
||||
* @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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并部分更新请求与原记录,生成用于权限和参数校验的有效任务状态。
|
||||
*
|
||||
* @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());
|
||||
return effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验定时任务已填写工作流的全部必填运行参数。
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onRemoveBefore(Collection<Serializable> ids) {
|
||||
service.deleteJob(ids);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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) {
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
/**
|
||||
* Git 仓库 Skill 扫描请求。
|
||||
*
|
||||
* @param repositoryUrl HTTPS Git 仓库地址,可省略 .git 后缀
|
||||
*/
|
||||
public record SkillGitRepositoryScanRequest(String repositoryUrl) {
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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) {
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ 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;
|
||||
@@ -36,6 +38,20 @@ 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询审批流程。
|
||||
@@ -102,6 +118,9 @@ 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));
|
||||
}
|
||||
@@ -116,6 +135,9 @@ 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();
|
||||
|
||||
@@ -3,15 +3,24 @@ package tech.easyflow.admin.controller.system;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
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;
|
||||
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;
|
||||
@@ -21,18 +30,33 @@ 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 java.net.URLEncoder;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 用户表 控制层。
|
||||
@@ -43,22 +67,123 @@ import java.util.List;
|
||||
@RestController("sysAccountController")
|
||||
@RequestMapping("/api/v1/sysAccount")
|
||||
public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> {
|
||||
public SysAccountController(SysAccountService service) {
|
||||
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) {
|
||||
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) {
|
||||
return service.getMapper().paginateWithRelations(page, 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);
|
||||
});
|
||||
}
|
||||
|
||||
@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();
|
||||
@@ -67,7 +192,11 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
||||
if (count > 0) {
|
||||
return Result.fail(1, "用户名已存在");
|
||||
}
|
||||
String password = entity.getPassword();
|
||||
Result<?> roleValidation = validateCreateRoles(entity);
|
||||
if (roleValidation != null) {
|
||||
return roleValidation;
|
||||
}
|
||||
String password = decryptInitialPassword(entity.getPasswordCredential());
|
||||
if (!StringUtil.hasText(password)) {
|
||||
return Result.fail(1, "密码不能为空");
|
||||
}
|
||||
@@ -98,6 +227,34 @@ 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);
|
||||
@@ -118,11 +275,24 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
||||
return super.onRemoveBefore(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前账号资料、角色标识与默认首页。
|
||||
*
|
||||
* @return 当前账号资料视图
|
||||
*/
|
||||
@GetMapping("/myProfile")
|
||||
public Result<SysAccount> myProfile() {
|
||||
public Result<SysAccountProfileVo> myProfile() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
SysAccount sysAccount = service.getById(account.getId());
|
||||
return Result.ok(sysAccount);
|
||||
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));
|
||||
}
|
||||
|
||||
@PostMapping("/updateProfile")
|
||||
@@ -143,42 +313,59 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
||||
/**
|
||||
* 修改密码,用于修改用户自己的密码
|
||||
*
|
||||
* @param password 用户的旧密码
|
||||
* @param newPassword 新密码
|
||||
* @param confirmPassword 确认密码
|
||||
* @param encryptedCredential 加密后的当前密码、新密码与确认密码
|
||||
* @return 密码修改结果
|
||||
*/
|
||||
@PostMapping("/updatePassword")
|
||||
public Result<Void> updatePassword(@JsonBody(value = "password", required = true) String password,
|
||||
@JsonBody(value = "newPassword", required = true) String newPassword,
|
||||
@JsonBody(value = "confirmPassword", required = true) String confirmPassword) {
|
||||
public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) {
|
||||
JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential);
|
||||
String password = payload.getString("password");
|
||||
String newPassword = payload.getString("newPassword");
|
||||
String confirmPassword = payload.getString("confirmPassword");
|
||||
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
|
||||
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);
|
||||
authService.updateOwnPassword(
|
||||
loginAccountId,
|
||||
password,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
StpUtil.getLoginDevice()
|
||||
);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private String decryptInitialPassword(Map<String, Object> passwordCredential) {
|
||||
if (passwordCredential == null || passwordCredential.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
JSONObject payload = credentialKeyService.decryptPayload(toEncryptedCredential(passwordCredential));
|
||||
return payload.getString("password");
|
||||
}
|
||||
|
||||
private EncryptedCredentialDTO toEncryptedCredential(Map<String, Object> passwordCredential) {
|
||||
EncryptedCredentialDTO encryptedCredential = new EncryptedCredentialDTO();
|
||||
encryptedCredential.setKeyId(asString(passwordCredential.get("keyId")));
|
||||
encryptedCredential.setEncryptedKey(asString(passwordCredential.get("encryptedKey")));
|
||||
encryptedCredential.setIv(asString(passwordCredential.get("iv")));
|
||||
encryptedCredential.setCiphertext(asString(passwordCredential.get("ciphertext")));
|
||||
encryptedCredential.setNonce(asString(passwordCredential.get("nonce")));
|
||||
return encryptedCredential;
|
||||
}
|
||||
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定账号密码重置为系统默认强密码。
|
||||
*
|
||||
* @param id 账号 ID
|
||||
* @return 本次重置后使用的明文密码
|
||||
*/
|
||||
@PostMapping("/resetPassword")
|
||||
@SaCheckPermission("/api/v1/sysAccount/save")
|
||||
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
|
||||
service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
|
||||
return Result.ok();
|
||||
public Result<String> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
|
||||
String password = service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
|
||||
return Result.ok(password);
|
||||
}
|
||||
|
||||
@PostMapping("/removeBatchWithResult")
|
||||
@@ -219,8 +406,12 @@ 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);
|
||||
@@ -228,4 +419,33 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,21 @@ 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;
|
||||
@@ -29,6 +32,7 @@ import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -39,10 +43,31 @@ import java.util.List;
|
||||
@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
|
||||
@@ -52,13 +77,19 @@ 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() {
|
||||
public Result<PkVo> save(@JsonBody(value = "name", required = false) String name) {
|
||||
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);
|
||||
@@ -83,12 +114,61 @@ 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 (entity.getKnowledgeShareEnabled() != null) {
|
||||
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) {
|
||||
// 兼容旧客户端:开启旧总开关只授予读取和导入,维护权限保持关闭。
|
||||
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
|
||||
}
|
||||
if (entity.getWorkflowApiEnabled() != null) {
|
||||
@@ -130,11 +210,18 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
||||
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class);
|
||||
entity.setPermissionIds(resourceIds);
|
||||
|
||||
QueryWrapper knowledgeWrapper = QueryWrapper.create()
|
||||
.select(SysApiKeyResourceMapping::getId)
|
||||
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
|
||||
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
|
||||
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0);
|
||||
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 workflowWrapper = QueryWrapper.create()
|
||||
.select(SysApiKeyResourceMapping::getId)
|
||||
@@ -142,4 +229,96 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,16 @@ public class SysApiKeyResourceController extends BaseCurdController<SysApiKeyRes
|
||||
super(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口授权资源关键字搜索字段。
|
||||
*
|
||||
* @return 请求接口和标题属性
|
||||
*/
|
||||
@Override
|
||||
protected String[] getKeywordSearchProperties() {
|
||||
return new String[]{"requestInterface", "title"};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询普通 API Key 接口授权资源。
|
||||
*
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 部门表 控制层。
|
||||
@@ -33,30 +45,154 @@ import java.util.List;
|
||||
@RequestMapping("/api/v1/sysDept")
|
||||
public class SysDeptController extends BaseCurdController<SysDeptService, SysDept> {
|
||||
|
||||
@Resource
|
||||
private SysAccountService sysAccountService;
|
||||
private final SysAccountService sysAccountService;
|
||||
private final ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper;
|
||||
private final ApprovalFlowScopeMapper approvalFlowScopeMapper;
|
||||
|
||||
public SysDeptController(SysDeptService service) {
|
||||
/**
|
||||
* 创建部门管理控制器。
|
||||
*
|
||||
* @param service 部门服务
|
||||
* @param sysAccountService 用户服务
|
||||
* @param approvalFlowStepAssigneeMapper 审批步骤对象 Mapper
|
||||
* @param approvalFlowScopeMapper 审批范围 Mapper
|
||||
*/
|
||||
public SysDeptController(SysDeptService service,
|
||||
SysAccountService sysAccountService,
|
||||
ApprovalFlowStepAssigneeMapper approvalFlowStepAssigneeMapper,
|
||||
ApprovalFlowScopeMapper approvalFlowScopeMapper) {
|
||||
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> sysMenus = service.list(queryWrapper);
|
||||
return Result.ok(Tree.tryToTree(sysMenus, "id", "parentId"));
|
||||
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());
|
||||
}
|
||||
|
||||
// 搜索结果保留所有重名命中项,并补齐各自祖先节点以维持可定位的树结构。
|
||||
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());
|
||||
@@ -65,7 +201,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());
|
||||
@@ -73,20 +209,87 @@ public class SysDeptController extends BaseCurdController<SysDeptService, SysDep
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected Result onRemoveBefore(Collection<Serializable> ids) {
|
||||
List<SysDept> records = service.listByIds(ids);
|
||||
for (SysDept dept : records) {
|
||||
if (Constants.ROOT_DEPT.equals(dept.getDeptCode())) {
|
||||
return Result.fail(1, "无法删除根部门");
|
||||
}
|
||||
if (records.size() != ids.size()) {
|
||||
return Result.fail(1, "部分部门不存在或已删除,请刷新后重试");
|
||||
}
|
||||
QueryWrapper w = QueryWrapper.create();
|
||||
w.in(SysAccount::getDeptId, ids);
|
||||
long count = sysAccountService.count(w);
|
||||
if (containsRootDept(records)) {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 操作日志表 控制层。
|
||||
@@ -21,14 +36,102 @@ import java.util.Collections;
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class SysPositionController extends BaseCurdController<SysPositionService
|
||||
/**
|
||||
* 分页查询岗位列表
|
||||
* <p>
|
||||
* 支持按岗位名称模糊查询,按状态、编码精确查询。
|
||||
* 支持按岗位名称、岗位编码统一模糊查询,状态保持精确查询。
|
||||
* </p>
|
||||
*
|
||||
* @param request 请求对象
|
||||
@@ -67,17 +67,22 @@ 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(positionName)) {
|
||||
queryWrapper.where(SYS_POSITION.POSITION_NAME.like(positionName));
|
||||
}
|
||||
// 岗位编码 - 精确查询
|
||||
if (StringUtil.hasText(positionCode)) {
|
||||
queryWrapper.where(SYS_POSITION.POSITION_CODE.eq(positionCode));
|
||||
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));
|
||||
}
|
||||
if (StringUtil.hasText(positionCode)) {
|
||||
queryWrapper.where(SYS_POSITION.POSITION_CODE.eq(positionCode));
|
||||
}
|
||||
}
|
||||
// 状态 - 精确查询
|
||||
if (StringUtil.hasText(status)) {
|
||||
@@ -129,4 +134,4 @@ public class SysPositionController extends BaseCurdController<SysPositionService
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -25,6 +26,8 @@ public class SysRoleCategoryScopeController {
|
||||
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
@Resource
|
||||
private SystemFormOptionService systemFormOptionService;
|
||||
|
||||
@GetMapping("/detail")
|
||||
@SaCheckPermission("/api/v1/sysRole/query")
|
||||
@@ -41,6 +44,7 @@ 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();
|
||||
|
||||
@@ -9,6 +9,8 @@ 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;
|
||||
@@ -38,11 +40,34 @@ 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
|
||||
@@ -85,6 +110,7 @@ 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());
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ 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) {
|
||||
@@ -33,4 +43,4 @@ public class SysUserFeedbackController extends BaseCurdController<SysUserFeedbac
|
||||
}
|
||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package tech.easyflow.admin.model.ai;
|
||||
|
||||
/**
|
||||
* 模型统一网关页面所需的安全配置。
|
||||
*
|
||||
* @param publishBaseUrl 模型发布基础地址
|
||||
*/
|
||||
public record ModelGatewayConfigView(String publishBaseUrl) {
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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 知识库描述
|
||||
*/
|
||||
public record KnowledgeOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
String title,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件安全选项。
|
||||
*
|
||||
* @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 sourceId 数据源 ID
|
||||
* @param catalogId 目录 ID
|
||||
* @param tableName 数据表名称
|
||||
* @param tableDesc 数据表描述
|
||||
*/
|
||||
public record DatasetOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
@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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@ public class DashboardDistributionItemVo {
|
||||
|
||||
private Long activeUserTotal;
|
||||
|
||||
private Long botTotal;
|
||||
|
||||
private Long workflowTotal;
|
||||
|
||||
private Long knowledgeBaseTotal;
|
||||
@@ -73,14 +71,6 @@ public class DashboardDistributionItemVo {
|
||||
this.activeUserTotal = activeUserTotal;
|
||||
}
|
||||
|
||||
public Long getBotTotal() {
|
||||
return botTotal;
|
||||
}
|
||||
|
||||
public void setBotTotal(Long botTotal) {
|
||||
this.botTotal = botTotal;
|
||||
}
|
||||
|
||||
public Long getWorkflowTotal() {
|
||||
return workflowTotal;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ public class DashboardSummaryVo {
|
||||
|
||||
private Long activeUserTotal;
|
||||
|
||||
private Long botTotal;
|
||||
/** 智能体总数。 */
|
||||
private Long agentTotal;
|
||||
|
||||
private Long workflowTotal;
|
||||
|
||||
@@ -39,12 +40,22 @@ public class DashboardSummaryVo {
|
||||
this.activeUserTotal = activeUserTotal;
|
||||
}
|
||||
|
||||
public Long getBotTotal() {
|
||||
return botTotal;
|
||||
/**
|
||||
* 获取智能体总数。
|
||||
*
|
||||
* @return 智能体总数
|
||||
*/
|
||||
public Long getAgentTotal() {
|
||||
return agentTotal;
|
||||
}
|
||||
|
||||
public void setBotTotal(Long botTotal) {
|
||||
this.botTotal = botTotal;
|
||||
/**
|
||||
* 设置智能体总数。
|
||||
*
|
||||
* @param agentTotal 智能体总数
|
||||
*/
|
||||
public void setAgentTotal(Long agentTotal) {
|
||||
this.agentTotal = agentTotal;
|
||||
}
|
||||
|
||||
public Long getWorkflowTotal() {
|
||||
|
||||
@@ -6,6 +6,9 @@ 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;
|
||||
@@ -22,6 +25,7 @@ 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.*;
|
||||
|
||||
@@ -39,7 +43,11 @@ 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 管理端会话服务。
|
||||
@@ -50,6 +58,8 @@ public class AgentSessionService {
|
||||
* @param documentCollectionService 知识库服务
|
||||
* @param resourceAccessService 资源访问服务
|
||||
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
|
||||
* @param agentMediaService Agent 媒体服务
|
||||
* @param agentComposerDraftService Agent 输入草稿服务
|
||||
* @param chatJsonSupport 聊天 JSON 工具
|
||||
*/
|
||||
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
|
||||
@@ -58,6 +68,8 @@ public class AgentSessionService {
|
||||
DocumentCollectionService documentCollectionService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
|
||||
AgentMediaService agentMediaService,
|
||||
AgentComposerDraftService agentComposerDraftService,
|
||||
ChatJsonSupport chatJsonSupport) {
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.chatSessionCommandService = chatSessionCommandService;
|
||||
@@ -65,6 +77,8 @@ public class AgentSessionService {
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
|
||||
this.agentMediaService = agentMediaService;
|
||||
this.agentComposerDraftService = agentComposerDraftService;
|
||||
this.chatJsonSupport = chatJsonSupport;
|
||||
}
|
||||
|
||||
@@ -186,21 +200,75 @@ public class AgentSessionService {
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
requireUserAgentSession(account, 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);
|
||||
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())
|
||||
|| !ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
||||
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())) {
|
||||
throw new BusinessException("Agent 会话不存在");
|
||||
}
|
||||
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
||||
throw new BusinessException("无权访问该 Agent 会话");
|
||||
}
|
||||
return summary;
|
||||
if (!Objects.equals(summary.getTenantId(), account.getTenantId())) {
|
||||
throw new BusinessException("无权访问该 Agent 会话");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
|
||||
|
||||
@@ -166,8 +166,14 @@ public class ChatWorkspaceService {
|
||||
roundIds.add(record.getRoundId());
|
||||
}
|
||||
}
|
||||
List<ChatMessageRecord> allVariants = new ArrayList<>();
|
||||
for (BigInteger roundId : roundIds) {
|
||||
variantsByRound.put(roundId.toString(), chatRoundOperateService.listVariants(sessionId, roundId));
|
||||
List<ChatMessageRecord> variants = chatRoundOperateService.listVariantsUnprojected(sessionId, roundId);
|
||||
variantsByRound.put(roundId.toString(), variants);
|
||||
allVariants.addAll(variants);
|
||||
}
|
||||
if (!allVariants.isEmpty()) {
|
||||
chatRoundOperateService.projectVariants(sessionId, allVariants);
|
||||
}
|
||||
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
|
||||
view.setRecords(records);
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
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.Edge;
|
||||
import com.easyagents.flow.core.chain.Event;
|
||||
import com.easyagents.flow.core.chain.Node;
|
||||
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 java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 将工作流运行事件转换为对话页可消费的 SSE 事件流。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowChatEventStream {
|
||||
|
||||
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<>();
|
||||
|
||||
/**
|
||||
* 创建工作流对话事件流服务。
|
||||
*
|
||||
* @param chainExecutor 工作流执行器
|
||||
*/
|
||||
public WorkflowChatEventStream(ChainExecutor chainExecutor) {
|
||||
this.chainExecutor = chainExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册工作流全局事件监听器。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void registerListeners() {
|
||||
chainExecutor.addEventListener(this::onEvent);
|
||||
chainExecutor.addErrorListener(this::onChainError);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作流并返回其 SSE 连接。
|
||||
*
|
||||
* @param definitionId 工作流定义 ID
|
||||
* @param variables 运行变量
|
||||
* @return SSE 连接
|
||||
*/
|
||||
public SseEmitter start(String definitionId, Map<String, Object> variables) {
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||
StreamSession session = new StreamSession(emitter);
|
||||
emitter.onTimeout(() -> disconnect(session, "运行连接超时"));
|
||||
emitter.onError(error -> disconnect(session, "运行连接已断开"));
|
||||
emitter.onCompletion(() -> removeSession(session));
|
||||
|
||||
try {
|
||||
chainExecutor.executeAsync(
|
||||
definitionId,
|
||||
variables,
|
||||
executeId -> {
|
||||
session.attach(executeId);
|
||||
sessions.put(executeId, session);
|
||||
session.send("execution_started", Map.of(
|
||||
"executeId", executeId
|
||||
));
|
||||
}
|
||||
);
|
||||
} catch (RuntimeException | Error error) {
|
||||
session.fail(error);
|
||||
throw error;
|
||||
}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将工作流事件转发到对应执行流。
|
||||
*
|
||||
* @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)) {
|
||||
session.send("execution_error", Map.of(
|
||||
"message", safeErrorMessage(error)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找顶级执行对应的事件流会话。
|
||||
*
|
||||
* @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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 SSE 连接异常,并取消尚未结束的工作流。
|
||||
*
|
||||
* @param session 流会话
|
||||
* @param message 取消原因
|
||||
*/
|
||||
private void disconnect(StreamSession session, String message) {
|
||||
if (session == null || session.terminal.get()) {
|
||||
return;
|
||||
}
|
||||
String executeId = session.executeId;
|
||||
removeSession(session);
|
||||
if (executeId != null) {
|
||||
chainExecutor.cancel(executeId, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除流会话。
|
||||
*
|
||||
* @param session 流会话
|
||||
*/
|
||||
private void removeSession(StreamSession session) {
|
||||
if (session != null && session.executeId != null) {
|
||||
sessions.remove(session.executeId, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取适合返回给用户的异常信息。
|
||||
*
|
||||
* @param error 异常
|
||||
* @return 非空异常信息
|
||||
*/
|
||||
private String safeErrorMessage(Throwable error) {
|
||||
if (error == null || error.getMessage() == null
|
||||
|| error.getMessage().isBlank()) {
|
||||
return "工作流执行失败";
|
||||
}
|
||||
return error.getMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉顶级工作流结果中的内部状态控制字段。
|
||||
*
|
||||
* @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 volatile String executeId;
|
||||
|
||||
/**
|
||||
* 创建流会话。
|
||||
*
|
||||
* @param emitter SSE 发送器
|
||||
*/
|
||||
private StreamSession(SseEmitter emitter) {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定执行实例。
|
||||
*
|
||||
* @param executeId 执行实例 ID
|
||||
*/
|
||||
private void attach(String executeId) {
|
||||
this.executeId = executeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理节点开始事件。
|
||||
*
|
||||
* @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.getError() != null) {
|
||||
data.put("error", safeErrorMessage(event.getError()));
|
||||
}
|
||||
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());
|
||||
if (status == ChainStatus.SUCCEEDED) {
|
||||
data.put(
|
||||
"output",
|
||||
visibleFinalOutput(chain.getState().getExecuteResult())
|
||||
);
|
||||
}
|
||||
send(eventType, data);
|
||||
removeSession(this);
|
||||
emitter.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 SSE 事件。
|
||||
*
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据
|
||||
*/
|
||||
private void send(String type, Map<String, ?> data) {
|
||||
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
|
||||
);
|
||||
disconnect(this, "运行连接已断开");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在启动失败时关闭 SSE 会话。
|
||||
*
|
||||
* @param error 启动异常
|
||||
*/
|
||||
private void fail(Throwable error) {
|
||||
if (terminal.compareAndSet(false, true)) {
|
||||
send("execution_failed", Map.of(
|
||||
"message", safeErrorMessage(error)
|
||||
));
|
||||
removeSession(this);
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* 创建工作流设计器选项服务。
|
||||
*
|
||||
* @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 数据集查询服务
|
||||
*/
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设计器初始化所需的模型和知识库。
|
||||
*
|
||||
* @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<>();
|
||||
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 = data.getString("type");
|
||||
if (nodeType == null || nodeType.isBlank()) {
|
||||
nodeType = node.getString("type");
|
||||
}
|
||||
if ("llmNode".equals(nodeType)) {
|
||||
addReferenceId(modelIds, readReferenceId(data, "llmId", "模型"));
|
||||
} else if ("knowledgeNode".equals(nodeType)) {
|
||||
addReferenceId(knowledgeIds, readReferenceId(data, "knowledgeId", "知识库"));
|
||||
} 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询当前账号可用于工作流的插件。
|
||||
*
|
||||
* @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) {
|
||||
return documentCollectionService.list(QueryWrapper.create()
|
||||
.eq(DocumentCollection::getTenantId, account.getTenantId())
|
||||
.orderBy(DocumentCollection::getModified, false))
|
||||
.stream()
|
||||
.filter(item -> resourceAccessService.canAccess(
|
||||
account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
|
||||
.map(item -> new WorkflowDesignerOptionsView.KnowledgeOption(
|
||||
item.getId(), item.getTitle(), item.getDescription()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
@@ -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.ai.entity.Bot;
|
||||
import tech.easyflow.ai.service.BotService;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
|
||||
@@ -33,6 +33,7 @@ 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;
|
||||
@@ -81,7 +82,7 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
private ChatDashboardQueryService chatDashboardQueryService;
|
||||
|
||||
@Resource
|
||||
private BotService botService;
|
||||
private AgentService agentService;
|
||||
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
@@ -156,7 +157,7 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||
summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context));
|
||||
summary.setActiveUserTotal(countActiveUsers(context));
|
||||
summary.setBotTotal(countScopedTable("tb_bot", "b", false, context));
|
||||
summary.setAgentTotal(countScopedTable("tb_agent", "a", false, context));
|
||||
summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context));
|
||||
summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context));
|
||||
summary.setChatMessageTotal(0L);
|
||||
@@ -863,18 +864,18 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
if (assistantId == null) {
|
||||
return null;
|
||||
}
|
||||
Bot bot = botService.getById(assistantId);
|
||||
if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) {
|
||||
throw new BusinessException("聊天助手不存在或未启用");
|
||||
Agent agent = agentService.getById(assistantId);
|
||||
if (agent == null) {
|
||||
throw new BusinessException("智能体不存在或不可见");
|
||||
}
|
||||
boolean visible = categoryPermissionService.canAccessCategory(
|
||||
loginAccount,
|
||||
"BOT",
|
||||
bot.getCreatedBy(),
|
||||
bot.getCategoryId()
|
||||
CategoryResourceType.AGENT.getCode(),
|
||||
agent.getCreatedBy(),
|
||||
agent.getCategoryId()
|
||||
);
|
||||
if (!visible) {
|
||||
throw new BusinessException("聊天助手不存在或未启用");
|
||||
throw new BusinessException("智能体不存在或不可见");
|
||||
}
|
||||
return assistantId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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())));
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ 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.AiResourceApprovalStateService;
|
||||
import tech.easyflow.ai.service.BotApprovalStateService;
|
||||
import tech.easyflow.ai.service.BotDocumentCollectionService;
|
||||
import tech.easyflow.ai.service.BotMessageService;
|
||||
import tech.easyflow.ai.service.BotService;
|
||||
@@ -38,7 +39,7 @@ public class BotControllerTest {
|
||||
private BotDocumentCollectionService botDocumentCollectionService;
|
||||
private BotMessageService botMessageService;
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
private AiResourceApprovalStateService aiResourceApprovalStateService;
|
||||
private BotApprovalStateService botApprovalStateService;
|
||||
private SysAccountService sysAccountService;
|
||||
|
||||
/**
|
||||
@@ -52,7 +53,7 @@ public class BotControllerTest {
|
||||
botDocumentCollectionService = mock(BotDocumentCollectionService.class);
|
||||
botMessageService = mock(BotMessageService.class);
|
||||
categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
aiResourceApprovalStateService = mock(AiResourceApprovalStateService.class);
|
||||
botApprovalStateService = mock(BotApprovalStateService.class);
|
||||
sysAccountService = mock(SysAccountService.class);
|
||||
}
|
||||
|
||||
@@ -69,10 +70,12 @@ public class BotControllerTest {
|
||||
botMessageService
|
||||
);
|
||||
AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport();
|
||||
BotResourceCreatorNameSupport botCreatorNameSupport =
|
||||
new BotResourceCreatorNameSupport(creatorNameSupport);
|
||||
setField(creatorNameSupport, "sysAccountService", sysAccountService);
|
||||
setField(controller, "categoryPermissionService", categoryPermissionService);
|
||||
setField(controller, "aiResourceApprovalStateService", aiResourceApprovalStateService);
|
||||
setField(controller, "aiResourceCreatorNameSupport", creatorNameSupport);
|
||||
setField(controller, "botApprovalStateService", botApprovalStateService);
|
||||
setField(controller, "botResourceCreatorNameSupport", botCreatorNameSupport);
|
||||
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.valueOf(101));
|
||||
@@ -84,7 +87,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(aiResourceApprovalStateService).fillBotApprovalState(page.getRecords());
|
||||
doNothing().when(botApprovalStateService).fillApprovalState(page.getRecords());
|
||||
|
||||
Page<Bot> result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create());
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.PluginService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
|
||||
import java.math.BigInteger;
|
||||
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.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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, "无权限删除该插件工具");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插件工具。
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link WorkflowShareController} 分享地址构建测试。
|
||||
*/
|
||||
public class WorkflowShareControllerTest {
|
||||
|
||||
/**
|
||||
* 验证分享地址保留前端部署基路径。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveFrontendBasePathFromReferer() throws Exception {
|
||||
HttpServletRequest request = request(Map.of(
|
||||
"referer",
|
||||
"https://example.test/easyflow/ai/workflow?page=1"
|
||||
));
|
||||
|
||||
Assert.assertEquals(
|
||||
buildShareBaseUrl(request),
|
||||
"https://example.test/easyflow/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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package tech.easyflow.admin.controller.job;
|
||||
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
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.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
||||
import tech.easyflow.common.constant.enums.EnumJobType;
|
||||
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.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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 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
|
||||
);
|
||||
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
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* {@link SkillCategoryController} 查询参数安全契约测试。
|
||||
*/
|
||||
public class SkillCategoryControllerContractTest {
|
||||
|
||||
/**
|
||||
* 分类排序只接受固定字段和方向,恶意片段应回退到默认排序。
|
||||
*/
|
||||
@Test
|
||||
public void categorySortUsesStrictAllowlist() {
|
||||
SkillCategoryController controller = new SkillCategoryController(mock(SkillCategoryService.class));
|
||||
|
||||
Assert.assertEquals(controller.resolveOrderBy("categoryName", "desc"),
|
||||
"category_name desc, id asc");
|
||||
Assert.assertEquals(controller.resolveOrderBy("sort_no desc; drop table tb_skill", null),
|
||||
"sort_no asc, id asc");
|
||||
Assert.assertEquals(controller.resolveOrderBy("id", "unexpected"), "id asc");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类控制器不得继承未加租户范围的通用 list、page 和 detail 入口。
|
||||
*/
|
||||
@Test
|
||||
public void categoryControllerDoesNotExposeInheritedCrudQueries() {
|
||||
Assert.expectThrows(NoSuchMethodException.class,
|
||||
() -> SkillCategoryController.class.getMethod("detail", String.class));
|
||||
Assert.assertFalse(java.util.Arrays.stream(SkillCategoryController.class.getMethods())
|
||||
.anyMatch(method -> "list".equals(method.getName()) || "page".equals(method.getName())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
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.SkillToolBindingUpdateRequest;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBodyParser;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.file.SkillFileService;
|
||||
import tech.easyflow.skill.gitimport.SkillGitImportService;
|
||||
import tech.easyflow.skill.imports.SkillExportService;
|
||||
import tech.easyflow.skill.imports.SkillImportConfirmRequest;
|
||||
import tech.easyflow.skill.imports.SkillImportService;
|
||||
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.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
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 SkillController} 标准包管理 API 契约测试。
|
||||
*/
|
||||
public class SkillControllerContractTest {
|
||||
|
||||
/**
|
||||
* Tool 绑定请求能够把嵌套列表转换为白名单 JavaBean。
|
||||
*
|
||||
* @throws Exception 反序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void jsonBodyParserDeserializesNestedToolBindings() throws Exception {
|
||||
JSONObject json = JSON.parseObject("""
|
||||
{
|
||||
"skillId": 101,
|
||||
"bindings": [{
|
||||
"toolType": "WORKFLOW",
|
||||
"targetId": 202,
|
||||
"hitlEnabled": true,
|
||||
"sortNo": 0,
|
||||
"resourceSnapshot": {"forged": true}
|
||||
}]
|
||||
}
|
||||
""");
|
||||
|
||||
SkillToolBindingUpdateRequest request = (SkillToolBindingUpdateRequest) JsonBodyParser.parseJsonBody(
|
||||
json, SkillToolBindingUpdateRequest.class, SkillToolBindingUpdateRequest.class, "");
|
||||
|
||||
Assert.assertEquals(request.getSkillId(), BigInteger.valueOf(101));
|
||||
Assert.assertEquals(request.getBindings().get(0).getTargetId(), BigInteger.valueOf(202));
|
||||
Assert.assertEquals(request.getBindings().get(0).getToolType(), "WORKFLOW");
|
||||
Assert.assertTrue(request.getBindings().get(0).getHitlEnabled());
|
||||
Assert.assertEquals(
|
||||
Arrays.stream(SkillToolBindingUpdateRequest.Binding.class.getDeclaredFields())
|
||||
.filter(field -> !Modifier.isStatic(field.getModifiers()))
|
||||
.map(field -> field.getName()).toList(),
|
||||
List.of("toolType", "targetId", "hitlEnabled", "sortNo", "mcpToolManifestHash"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 草稿白名单 DTO 只接受标准包治理字段。
|
||||
*
|
||||
* @throws Exception 反序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void jsonBodyParserDeserializesStandardDraftRecord() throws Exception {
|
||||
JSONObject json = JSON.parseObject("""
|
||||
{
|
||||
"id": 101,
|
||||
"categoryId": 9,
|
||||
"displayName": "演示 Skill",
|
||||
"skillContent": "---\nname: demo-skill\ndescription: Demo\n---\n# Demo\n",
|
||||
"visibilityScope": "PRIVATE"
|
||||
}
|
||||
""");
|
||||
|
||||
SkillDraftRequest request = (SkillDraftRequest) JsonBodyParser.parseJsonBody(
|
||||
json, SkillDraftRequest.class, SkillDraftRequest.class, "");
|
||||
|
||||
Assert.assertEquals(request.id(), BigInteger.valueOf(101));
|
||||
Assert.assertEquals(request.categoryId(), BigInteger.valueOf(9));
|
||||
Assert.assertEquals(request.displayName(), "演示 Skill");
|
||||
Assert.assertEquals(request.visibilityScope(), "PRIVATE");
|
||||
Assert.assertFalse(Arrays.stream(SkillDraftRequest.class.getRecordComponents())
|
||||
.anyMatch(component -> "enabled".equals(component.getName())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存入口使用白名单 DTO 并返回 Skill 视图。
|
||||
*
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
@Test
|
||||
public void saveEndpointUsesDraftRequestAndSkillView() throws Exception {
|
||||
Method method = SkillController.class.getMethod("save", SkillDraftRequest.class);
|
||||
JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class);
|
||||
ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType();
|
||||
|
||||
Assert.assertNotNull(jsonBody);
|
||||
Assert.assertEquals(returnType.getRawType(), Result.class);
|
||||
Assert.assertEquals(returnType.getActualTypeArguments()[0], SkillView.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制只依赖 Skill 新建权限,不再暴露能力绑定权限。
|
||||
*
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
@Test
|
||||
public void copyEndpointUsesOnlySavePermission() throws Exception {
|
||||
SaCheckPermission permission = SkillController.class
|
||||
.getMethod("copy", SkillCopyRequest.class).getAnnotation(SaCheckPermission.class);
|
||||
|
||||
Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/save"});
|
||||
Assert.assertFalse(Arrays.stream(SkillController.class.getDeclaredMethods())
|
||||
.map(method -> method.getAnnotation(SaCheckPermission.class))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.flatMap(item -> Arrays.stream(item.value()))
|
||||
.anyMatch("/api/v1/skill/capability"::equals));
|
||||
}
|
||||
|
||||
/**
|
||||
* Git 仓库扫描与候选准备沿用 Skill 导入权限,并使用白名单请求 DTO。
|
||||
*
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
@Test
|
||||
public void gitRepositoryEndpointsReuseImportPermission() throws Exception {
|
||||
Method scan = SkillController.class.getMethod(
|
||||
"scanGitRepository", SkillGitRepositoryScanRequest.class);
|
||||
Method prepare = SkillController.class.getMethod(
|
||||
"prepareGitRepositoryImport", SkillGitRepositoryPrepareRequest.class);
|
||||
|
||||
Assert.assertEquals(scan.getAnnotation(SaCheckPermission.class).value(),
|
||||
new String[]{"/api/v1/skill/import"});
|
||||
Assert.assertEquals(prepare.getAnnotation(SaCheckPermission.class).value(),
|
||||
new String[]{"/api/v1/skill/import"});
|
||||
Assert.assertNotNull(scan.getParameters()[0].getAnnotation(JsonBody.class));
|
||||
Assert.assertNotNull(prepare.getParameters()[0].getAnnotation(JsonBody.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布入口把必填发布说明原样交给应用服务。
|
||||
*/
|
||||
@Test
|
||||
public void publishEndpointForwardsRequiredReason() {
|
||||
BigInteger id = BigInteger.valueOf(101);
|
||||
SkillPublishAppService publishService = mock(SkillPublishAppService.class);
|
||||
when(publishService.submitPublishApproval(id, "补充审核规则"))
|
||||
.thenReturn(ApprovalActionResult.direct());
|
||||
SkillController controller = controller(mock(SkillImportService.class), publishService);
|
||||
|
||||
controller.submitPublishApproval(id, "补充审核规则");
|
||||
|
||||
verify(publishService).submitPublishApproval(id, "补充审核规则");
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量确认按 token 隔离业务失败,成功项仍保留结果。
|
||||
*/
|
||||
@Test
|
||||
public void batchConfirmKeepsIndependentResults() {
|
||||
SkillImportService importService = mock(SkillImportService.class);
|
||||
SkillImportConfirmRequest first = request("a".repeat(32));
|
||||
SkillImportConfirmRequest second = request("b".repeat(32));
|
||||
when(importService.confirm(first)).thenThrow(new BusinessException("名称不可用"));
|
||||
Skill imported = new Skill();
|
||||
imported.setId(BigInteger.ONE);
|
||||
imported.setName("demo-skill");
|
||||
imported.setPublishStatus(PublishStatus.DRAFT.getCode());
|
||||
when(importService.confirm(second)).thenReturn(List.of(imported));
|
||||
|
||||
List<SkillImportBatchResultView> results = controller(importService, mock(SkillPublishAppService.class))
|
||||
.importConfirmBatch(List.of(first, second)).getData();
|
||||
|
||||
Assert.assertFalse(results.get(0).success());
|
||||
Assert.assertEquals(results.get(0).message(), "名称不可用");
|
||||
Assert.assertTrue(results.get(1).success());
|
||||
Assert.assertEquals(results.get(1).skills().get(0).name(), "demo-skill");
|
||||
}
|
||||
|
||||
private SkillImportConfirmRequest request(String token) {
|
||||
SkillImportConfirmRequest request = new SkillImportConfirmRequest();
|
||||
request.setImportToken(token);
|
||||
request.setVisibilityScope("PRIVATE");
|
||||
return request;
|
||||
}
|
||||
|
||||
private SkillController controller(SkillImportService importService, SkillPublishAppService publishService) {
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
when(accessService.canAccess(any(), any(), any())).thenReturn(true);
|
||||
return new SkillController(mock(SkillService.class), mock(SkillApprovalStateService.class),
|
||||
publishService, importService, mock(SkillGitImportService.class),
|
||||
mock(SkillExportService.class), mock(SkillFileService.class),
|
||||
mock(SkillToolBindingService.class), mock(SkillToolOptionQueryService.class),
|
||||
accessService, mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class),
|
||||
mock(AiResourceCreatorNameSupport.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Skill 列表轻量投影的权限字段回归测试。
|
||||
*/
|
||||
public class SkillControllerProjectionTenantTest {
|
||||
|
||||
/**
|
||||
* 验证列表投影包含内部 tenant_id,以便资源权限派生时不会将合法记录误判为不可读。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void descriptorProjectionShouldIncludeTenantId() throws Exception {
|
||||
SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS);
|
||||
Method method = SkillController.class.getDeclaredMethod("descriptorQuery");
|
||||
method.setAccessible(true);
|
||||
|
||||
QueryWrapper query = (QueryWrapper) method.invoke(controller);
|
||||
|
||||
Assert.assertTrue(query.toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"),
|
||||
"Skill descriptor projection 缺少 tenant_id: " + query.toSQL());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证列表投影不会加载正文或发布快照等重字段。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void descriptorProjectionShouldExcludeHeavyContent() throws Exception {
|
||||
SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS);
|
||||
Method method = SkillController.class.getDeclaredMethod("descriptorQuery");
|
||||
method.setAccessible(true);
|
||||
|
||||
String sql = ((QueryWrapper) method.invoke(controller)).toSQL().toLowerCase(Locale.ROOT);
|
||||
|
||||
Assert.assertFalse(sql.contains("skill_content"), "列表投影不应加载 SKILL.md 正文: " + sql);
|
||||
Assert.assertFalse(sql.contains("published_snapshot_json"), "列表投影不应加载发布快照: " + sql);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package tech.easyflow.admin.controller.system;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.auth.service.AuthCredentialKeyService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.admin.controller.system.vo.SysAccountProfileVo;
|
||||
import tech.easyflow.admin.service.system.SystemFormOptionService;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.entity.SysRole;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.system.service.SysRoleService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
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;
|
||||
import static org.testng.Assert.assertEquals;
|
||||
import static org.testng.Assert.assertFalse;
|
||||
import static org.testng.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link SysAccountController} 创建账号测试。
|
||||
*/
|
||||
public class SysAccountControllerTest {
|
||||
|
||||
/**
|
||||
* 验证统一关键字覆盖账号字段和关联角色名称,并保留租户关联条件。
|
||||
*/
|
||||
@Test
|
||||
public void buildQueryWrapperShouldSearchAccountFieldsAndRoleName() {
|
||||
SysAccountController controller = new SysAccountController(
|
||||
mock(SysAccountService.class),
|
||||
mock(AuthCredentialKeyService.class),
|
||||
mock(SysRoleService.class),
|
||||
mock(SystemFormOptionService.class)
|
||||
);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getParameter("keyword")).thenReturn(" search-user ");
|
||||
|
||||
QueryWrapper queryWrapper = controller.buildQueryWrapper(request);
|
||||
|
||||
String sql = queryWrapper.toSQL().toLowerCase(Locale.ROOT);
|
||||
assertTrue(sql.contains("login_name"), sql);
|
||||
assertTrue(sql.contains("nickname"), sql);
|
||||
assertTrue(sql.contains("mobile"), sql);
|
||||
assertTrue(sql.contains("email"), sql);
|
||||
assertTrue(sql.contains("exists"), sql);
|
||||
assertTrue(sql.contains("tb_sys_account_role"), sql);
|
||||
assertTrue(sql.contains("tb_sys_role"), sql);
|
||||
assertTrue(sql.contains("role_name"), sql);
|
||||
assertTrue(sql.contains("tenant_id"), sql);
|
||||
assertTrue(sql.contains("search-user"), sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空关键字不会产生额外筛选条件。
|
||||
*/
|
||||
@Test
|
||||
public void buildQueryWrapperShouldIgnoreBlankKeyword() {
|
||||
SysAccountController controller = new SysAccountController(
|
||||
mock(SysAccountService.class),
|
||||
mock(AuthCredentialKeyService.class),
|
||||
mock(SysRoleService.class),
|
||||
mock(SystemFormOptionService.class)
|
||||
);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getParameter("keyword")).thenReturn(" ");
|
||||
when(request.getParameterMap()).thenReturn(Collections.emptyMap());
|
||||
|
||||
QueryWrapper queryWrapper = controller.buildQueryWrapper(request);
|
||||
|
||||
assertFalse(queryWrapper.hasCondition());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 LIKE 通配符和反斜杠按普通字符参与搜索。
|
||||
*/
|
||||
@Test
|
||||
public void buildQueryWrapperShouldEscapeLikePatternCharacters() {
|
||||
SysAccountController controller = new SysAccountController(
|
||||
mock(SysAccountService.class),
|
||||
mock(AuthCredentialKeyService.class),
|
||||
mock(SysRoleService.class),
|
||||
mock(SystemFormOptionService.class)
|
||||
);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getParameter("keyword")).thenReturn("a_b%c\\d");
|
||||
|
||||
String sql = controller.buildQueryWrapper(request).toSQL();
|
||||
|
||||
assertTrue(sql.contains("a\\\\_b\\\\%c\\\\\\\\d"), sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证创建用户时保留表单选择的部门,不被操作者部门覆盖。
|
||||
*/
|
||||
@Test
|
||||
public void saveShouldKeepSubmittedDepartment() {
|
||||
BigInteger selectedDeptId = BigInteger.valueOf(200);
|
||||
BigInteger operatorDeptId = BigInteger.valueOf(100);
|
||||
BigInteger tenantId = BigInteger.valueOf(300);
|
||||
BigInteger operatorId = BigInteger.valueOf(400);
|
||||
SysAccountService accountService = mock(SysAccountService.class);
|
||||
AuthCredentialKeyService credentialKeyService = mock(AuthCredentialKeyService.class);
|
||||
SysRoleService roleService = mock(SysRoleService.class);
|
||||
SysAccountController controller = new SysAccountController(
|
||||
accountService,
|
||||
credentialKeyService,
|
||||
roleService,
|
||||
mock(SystemFormOptionService.class)
|
||||
);
|
||||
SysAccount entity = createAccount(selectedDeptId);
|
||||
LoginAccount loginAccount = createLoginAccount(operatorId, tenantId, operatorDeptId);
|
||||
AtomicReference<BigInteger> savedDeptId = new AtomicReference<>();
|
||||
AtomicReference<BigInteger> savedTenantId = new AtomicReference<>();
|
||||
AtomicReference<BigInteger> savedCreatedBy = new AtomicReference<>();
|
||||
AtomicReference<BigInteger> savedModifiedBy = new AtomicReference<>();
|
||||
|
||||
when(accountService.count(any(QueryWrapper.class))).thenReturn(0L);
|
||||
when(roleService.listByIds(anyCollection())).thenReturn(List.of(enabledRole(BigInteger.ONE, "user")));
|
||||
when(accountService.save(any(SysAccount.class))).thenAnswer(invocation -> {
|
||||
SysAccount savedAccount = invocation.getArgument(0, SysAccount.class);
|
||||
savedDeptId.set(savedAccount.getDeptId());
|
||||
savedTenantId.set(savedAccount.getTenantId());
|
||||
savedCreatedBy.set(savedAccount.getCreatedBy());
|
||||
savedModifiedBy.set(savedAccount.getModifiedBy());
|
||||
savedAccount.setId(BigInteger.valueOf(500));
|
||||
return true;
|
||||
});
|
||||
when(credentialKeyService.decryptPayload(any()))
|
||||
.thenReturn(JSONObject.parseObject("{\"password\":\"Valid123!\"}"));
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
|
||||
|
||||
Result<?> result = controller.save(entity);
|
||||
|
||||
assertEquals(result.getErrorCode(), 0);
|
||||
}
|
||||
|
||||
verify(accountService).save(any(SysAccount.class));
|
||||
assertEquals(savedDeptId.get(), selectedDeptId);
|
||||
assertEquals(savedTenantId.get(), tenantId);
|
||||
assertEquals(savedCreatedBy.get(), operatorId);
|
||||
assertEquals(savedModifiedBy.get(), operatorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证创建用户时拒绝空角色。
|
||||
*/
|
||||
@Test
|
||||
public void saveShouldRejectEmptyRoles() {
|
||||
SysAccountService accountService = mock(SysAccountService.class);
|
||||
AuthCredentialKeyService credentialKeyService = mock(AuthCredentialKeyService.class);
|
||||
SysRoleService roleService = mock(SysRoleService.class);
|
||||
SysAccountController controller = new SysAccountController(
|
||||
accountService,
|
||||
credentialKeyService,
|
||||
roleService,
|
||||
mock(SystemFormOptionService.class)
|
||||
);
|
||||
SysAccount entity = createAccount(BigInteger.valueOf(200));
|
||||
entity.setRoleIds(List.of());
|
||||
LoginAccount loginAccount = createLoginAccount(
|
||||
BigInteger.valueOf(400),
|
||||
BigInteger.valueOf(300),
|
||||
BigInteger.valueOf(100)
|
||||
);
|
||||
when(accountService.count(any(QueryWrapper.class))).thenReturn(0L);
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
|
||||
|
||||
Result<?> result = controller.save(entity);
|
||||
|
||||
assertEquals(result.getErrorCode(), 1);
|
||||
assertEquals(result.getMessage(), "角色不能为空");
|
||||
}
|
||||
|
||||
verify(accountService, never()).save(any(SysAccount.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证超级管理员默认进入工作台并返回角色标识。
|
||||
*/
|
||||
@Test
|
||||
public void myProfileShouldReturnSuperAdminHomePath() {
|
||||
BigInteger accountId = BigInteger.ONE;
|
||||
SysAccountService accountService = mock(SysAccountService.class);
|
||||
AuthCredentialKeyService credentialKeyService = mock(AuthCredentialKeyService.class);
|
||||
SysRoleService roleService = mock(SysRoleService.class);
|
||||
SysAccountController controller = new SysAccountController(
|
||||
accountService,
|
||||
credentialKeyService,
|
||||
roleService,
|
||||
mock(SystemFormOptionService.class)
|
||||
);
|
||||
SysAccount account = new SysAccount();
|
||||
account.setId(accountId);
|
||||
when(accountService.getById(accountId)).thenReturn(account);
|
||||
when(roleService.getRolesByAccountId(accountId))
|
||||
.thenReturn(List.of(enabledRole(BigInteger.ONE, "super_admin")));
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount)
|
||||
.thenReturn(createLoginAccount(accountId, BigInteger.ZERO, BigInteger.ZERO));
|
||||
|
||||
Result<SysAccountProfileVo> result = controller.myProfile();
|
||||
|
||||
assertEquals(result.getData().getHomePath(), "/dashboard/workspace");
|
||||
assertEquals(result.getData().getRoles(), List.of("super_admin"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通账号默认进入智能体聊天页。
|
||||
*/
|
||||
@Test
|
||||
public void myProfileShouldReturnAgentChatHomePathForRegularUser() {
|
||||
BigInteger accountId = BigInteger.valueOf(20);
|
||||
SysAccountService accountService = mock(SysAccountService.class);
|
||||
AuthCredentialKeyService credentialKeyService = mock(AuthCredentialKeyService.class);
|
||||
SysRoleService roleService = mock(SysRoleService.class);
|
||||
SysAccountController controller = new SysAccountController(
|
||||
accountService,
|
||||
credentialKeyService,
|
||||
roleService,
|
||||
mock(SystemFormOptionService.class)
|
||||
);
|
||||
SysAccount account = new SysAccount();
|
||||
account.setId(accountId);
|
||||
when(accountService.getById(accountId)).thenReturn(account);
|
||||
when(roleService.getRolesByAccountId(accountId))
|
||||
.thenReturn(List.of(enabledRole(BigInteger.TWO, "operator")));
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount)
|
||||
.thenReturn(createLoginAccount(accountId, BigInteger.ZERO, BigInteger.ZERO));
|
||||
|
||||
Result<SysAccountProfileVo> result = controller.myProfile();
|
||||
|
||||
assertEquals(result.getData().getHomePath(), "/ai/agent-chat");
|
||||
assertEquals(result.getData().getRoles(), List.of("operator"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造待创建账号。
|
||||
*
|
||||
* @param deptId 表单选择的部门 ID
|
||||
* @return 待创建账号
|
||||
*/
|
||||
private SysAccount createAccount(BigInteger deptId) {
|
||||
SysAccount account = new SysAccount();
|
||||
account.setDeptId(deptId);
|
||||
account.setLoginName("department_test_user");
|
||||
account.setNickname("部门测试用户");
|
||||
account.setPasswordCredential(Map.of("keyId", "test-key"));
|
||||
account.setRoleIds(List.of(BigInteger.ONE));
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造启用角色。
|
||||
*
|
||||
* @param id 角色 ID
|
||||
* @param roleKey 角色标识
|
||||
* @return 启用角色
|
||||
*/
|
||||
private SysRole enabledRole(BigInteger id, String roleKey) {
|
||||
SysRole role = new SysRole();
|
||||
role.setId(id);
|
||||
role.setRoleKey(roleKey);
|
||||
role.setStatus(1);
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造当前登录账号。
|
||||
*
|
||||
* @param id 操作人 ID
|
||||
* @param tenantId 租户 ID
|
||||
* @param deptId 操作人部门 ID
|
||||
* @return 当前登录账号
|
||||
*/
|
||||
private LoginAccount createLoginAccount(BigInteger id, BigInteger tenantId, BigInteger deptId) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(id);
|
||||
account.setTenantId(tenantId);
|
||||
account.setDeptId(deptId);
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package tech.easyflow.admin.controller.system;
|
||||
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||
import tech.easyflow.ai.service.WorkflowApiPermissionService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
import tech.easyflow.system.service.SysApiKeyResourceMappingService;
|
||||
import tech.easyflow.system.service.SysApiKeyService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.testng.Assert.assertEquals;
|
||||
import static org.testng.Assert.assertNotEquals;
|
||||
|
||||
/**
|
||||
* {@link SysApiKeyController} 更新访问令牌测试。
|
||||
*/
|
||||
public class SysApiKeyControllerTest {
|
||||
|
||||
/**
|
||||
* 验证只更新权限时不会执行缺少主表更新字段的 SQL。
|
||||
*/
|
||||
@Test
|
||||
public void updateShouldSkipMainTableForPermissionOnlyRequest() {
|
||||
BigInteger apiKeyId = BigInteger.valueOf(100);
|
||||
SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
|
||||
KnowledgeSharePermissionService knowledgePermissionService =
|
||||
mock(KnowledgeSharePermissionService.class);
|
||||
SysApiKeyController controller = controller(apiKeyService, knowledgePermissionService);
|
||||
SysApiKey existing = new SysApiKey();
|
||||
existing.setId(apiKeyId);
|
||||
when(apiKeyService.getById(apiKeyId)).thenReturn(existing);
|
||||
|
||||
SysApiKey request = new SysApiKey();
|
||||
request.setId(apiKeyId);
|
||||
request.setKnowledgeReadEnabled(true);
|
||||
request.setKnowledgeImportEnabled(false);
|
||||
request.setKnowledgeMaintenanceEnabled(false);
|
||||
|
||||
Result<?> result = controller.update(request);
|
||||
|
||||
assertEquals(result.getErrorCode(), 0);
|
||||
verify(apiKeyService, never()).updateById(request);
|
||||
verify(knowledgePermissionService).replaceApiPermissions(apiKeyId, true, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证基础字段与权限同时更新时,两类数据都被保存。
|
||||
*/
|
||||
@Test
|
||||
public void updateShouldPersistMainTableAndPermissionsTogether() {
|
||||
BigInteger apiKeyId = BigInteger.valueOf(101);
|
||||
SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
|
||||
KnowledgeSharePermissionService knowledgePermissionService =
|
||||
mock(KnowledgeSharePermissionService.class);
|
||||
SysApiKeyController controller = controller(apiKeyService, knowledgePermissionService);
|
||||
SysApiKey existing = new SysApiKey();
|
||||
existing.setId(apiKeyId);
|
||||
when(apiKeyService.getById(apiKeyId)).thenReturn(existing);
|
||||
|
||||
SysApiKey request = new SysApiKey();
|
||||
request.setId(apiKeyId);
|
||||
request.setStatus(1);
|
||||
request.setKnowledgeReadEnabled(true);
|
||||
request.setKnowledgeImportEnabled(true);
|
||||
request.setKnowledgeMaintenanceEnabled(true);
|
||||
|
||||
Result<?> result = controller.update(request);
|
||||
|
||||
assertEquals(result.getErrorCode(), 0);
|
||||
verify(apiKeyService).updateById(request);
|
||||
verify(knowledgePermissionService).replaceApiPermissions(apiKeyId, true, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证名称可以单独更新,并在持久化前移除首尾空格。
|
||||
*/
|
||||
@Test
|
||||
public void updateShouldPersistTrimmedNameOnly() {
|
||||
BigInteger apiKeyId = BigInteger.valueOf(103);
|
||||
SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
|
||||
KnowledgeSharePermissionService knowledgePermissionService =
|
||||
mock(KnowledgeSharePermissionService.class);
|
||||
SysApiKeyController controller = controller(apiKeyService, knowledgePermissionService);
|
||||
SysApiKey existing = new SysApiKey();
|
||||
existing.setId(apiKeyId);
|
||||
when(apiKeyService.getById(apiKeyId)).thenReturn(existing);
|
||||
|
||||
SysApiKey request = new SysApiKey();
|
||||
request.setId(apiKeyId);
|
||||
request.setName(" 生产环境调用 ");
|
||||
|
||||
Result<?> result = controller.update(request);
|
||||
|
||||
assertEquals(result.getErrorCode(), 0);
|
||||
assertEquals(request.getName(), "生产环境调用");
|
||||
verify(apiKeyService).updateById(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空白名称会被拒绝,避免覆盖为无意义内容。
|
||||
*/
|
||||
@Test
|
||||
public void updateShouldRejectBlankName() {
|
||||
SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
|
||||
KnowledgeSharePermissionService knowledgePermissionService =
|
||||
mock(KnowledgeSharePermissionService.class);
|
||||
SysApiKeyController controller = controller(apiKeyService, knowledgePermissionService);
|
||||
SysApiKey request = new SysApiKey();
|
||||
request.setId(BigInteger.valueOf(104));
|
||||
request.setName(" ");
|
||||
|
||||
Result<?> result = controller.update(request);
|
||||
|
||||
assertNotEquals(result.getErrorCode(), 0);
|
||||
verifyNoInteractions(apiKeyService, knowledgePermissionService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新版知识库权限缺少字段时拒绝更新,避免遗漏字段被隐式关闭。
|
||||
*/
|
||||
@Test
|
||||
public void updateShouldRejectPartialKnowledgePermissions() {
|
||||
SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
|
||||
KnowledgeSharePermissionService knowledgePermissionService =
|
||||
mock(KnowledgeSharePermissionService.class);
|
||||
SysApiKeyController controller = controller(apiKeyService, knowledgePermissionService);
|
||||
SysApiKey request = new SysApiKey();
|
||||
request.setId(BigInteger.valueOf(102));
|
||||
request.setKnowledgeReadEnabled(true);
|
||||
|
||||
Result<?> result = controller.update(request);
|
||||
|
||||
assertNotEquals(result.getErrorCode(), 0);
|
||||
verifyNoInteractions(apiKeyService, knowledgePermissionService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建注入模拟依赖的控制器。
|
||||
*
|
||||
* @param apiKeyService 访问令牌服务
|
||||
* @param knowledgePermissionService 知识库权限服务
|
||||
* @return 测试控制器
|
||||
*/
|
||||
private SysApiKeyController controller(
|
||||
SysApiKeyService apiKeyService,
|
||||
KnowledgeSharePermissionService knowledgePermissionService
|
||||
) {
|
||||
SysApiKeyController controller = new SysApiKeyController(apiKeyService);
|
||||
setField(controller, "sysApiKeyResourceMappingService", mock(SysApiKeyResourceMappingService.class));
|
||||
setField(controller, "knowledgeSharePermissionService", knowledgePermissionService);
|
||||
setField(controller, "workflowApiPermissionService", mock(WorkflowApiPermissionService.class));
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过反射注入控制器字段。
|
||||
*
|
||||
* @param target 目标对象
|
||||
* @param fieldName 字段名
|
||||
* @param value 字段值
|
||||
*/
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
Class<?> current = target.getClass();
|
||||
while (current != null) {
|
||||
try {
|
||||
Field field = current.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
current = current.getSuperclass();
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new IllegalStateException("设置测试字段失败: " + fieldName, e);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("未找到字段: " + fieldName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package tech.easyflow.admin.controller.system;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.testng.annotations.Test;
|
||||
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.TreeNode;
|
||||
import tech.easyflow.system.entity.SysDept;
|
||||
import tech.easyflow.system.mapper.SysDeptMapper;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.system.service.SysDeptService;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.testng.Assert.assertEquals;
|
||||
import static org.testng.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link SysDeptController} 部门状态保存测试。
|
||||
*/
|
||||
public class SysDeptControllerTest {
|
||||
|
||||
/**
|
||||
* 验证批量状态修改使用单次更新并写入审计字段。
|
||||
*/
|
||||
@Test
|
||||
public void changeStatusBatchShouldUpdateAllDepartmentsOnce() {
|
||||
SysDeptService service = mock(SysDeptService.class);
|
||||
SysDeptMapper mapper = mock(SysDeptMapper.class);
|
||||
SysDeptController controller = createController(service);
|
||||
List<BigInteger> ids = List.of(BigInteger.ONE, BigInteger.TWO);
|
||||
when(service.listByIds(anyCollection()))
|
||||
.thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1"), buildDept(BigInteger.TWO, "dept_2")));
|
||||
when(service.getMapper()).thenReturn(mapper);
|
||||
when(mapper.updateByQuery(any(SysDept.class), any(QueryWrapper.class))).thenReturn(2);
|
||||
|
||||
LoginAccount loginAccount = mock(LoginAccount.class);
|
||||
when(loginAccount.getId()).thenReturn(BigInteger.TEN);
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
|
||||
|
||||
Result<Integer> result = controller.changeStatusBatch(ids, EnumDataStatus.UNAVAILABLE.getCode());
|
||||
|
||||
assertEquals(result.getErrorCode(), 0);
|
||||
assertEquals(result.getData(), Integer.valueOf(2));
|
||||
}
|
||||
|
||||
ArgumentCaptor<SysDept> updateCaptor = ArgumentCaptor.forClass(SysDept.class);
|
||||
verify(mapper).updateByQuery(updateCaptor.capture(), any(QueryWrapper.class));
|
||||
assertEquals(updateCaptor.getValue().getStatus(), EnumDataStatus.UNAVAILABLE.getCode());
|
||||
assertEquals(updateCaptor.getValue().getModifiedBy(), BigInteger.TEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证根部门不能通过批量接口禁用。
|
||||
*/
|
||||
@Test
|
||||
public void changeStatusBatchShouldRejectRootDepartmentDisable() {
|
||||
SysDeptService service = mock(SysDeptService.class);
|
||||
SysDeptMapper mapper = mock(SysDeptMapper.class);
|
||||
SysDeptController controller = createController(service);
|
||||
SysDept root = buildDept(BigInteger.ONE, Constants.ROOT_DEPT);
|
||||
when(service.listByIds(anyCollection())).thenReturn(List.of(root));
|
||||
when(service.getMapper()).thenReturn(mapper);
|
||||
|
||||
Result<Integer> result = controller.changeStatusBatch(
|
||||
List.of(BigInteger.ONE),
|
||||
EnumDataStatus.UNAVAILABLE.getCode());
|
||||
|
||||
assertEquals(result.getErrorCode(), 1);
|
||||
assertEquals(result.getMessage(), "根部门不能禁用");
|
||||
verify(mapper, never()).updateByQuery(any(SysDept.class), any(QueryWrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证批量状态修改拒绝非法状态。
|
||||
*/
|
||||
@Test
|
||||
public void changeStatusBatchShouldRejectInvalidStatus() {
|
||||
SysDeptService service = mock(SysDeptService.class);
|
||||
SysDeptController controller = createController(service);
|
||||
|
||||
Result<Integer> result = controller.changeStatusBatch(List.of(BigInteger.ONE), 2);
|
||||
|
||||
assertEquals(result.getErrorCode(), 1);
|
||||
assertEquals(result.getMessage(), "部门状态不合法");
|
||||
verify(service, never()).listByIds(anyCollection());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证存在未选中下级部门时禁止批量删除。
|
||||
*/
|
||||
@Test
|
||||
public void removeBatchShouldRejectUnselectedChildDepartment() {
|
||||
SysDeptService service = mock(SysDeptService.class);
|
||||
SysDeptController controller = createController(service);
|
||||
Collection<Serializable> ids = List.of(BigInteger.ONE);
|
||||
when(service.listByIds(ids)).thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
|
||||
when(service.count(any(QueryWrapper.class))).thenReturn(1L);
|
||||
|
||||
Result<?> result = controller.onRemoveBefore(ids);
|
||||
|
||||
assertEquals(result.getErrorCode(), 1);
|
||||
assertEquals(result.getMessage(), "所选部门包含未选中的下级部门,不能删除");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证审批步骤引用的部门不能禁用。
|
||||
*/
|
||||
@Test
|
||||
public void changeStatusBatchShouldRejectApprovalStepReference() {
|
||||
SysDeptService service = mock(SysDeptService.class);
|
||||
ApprovalFlowStepAssigneeMapper assigneeMapper = mock(ApprovalFlowStepAssigneeMapper.class);
|
||||
SysDeptController controller = new SysDeptController(
|
||||
service,
|
||||
mock(SysAccountService.class),
|
||||
assigneeMapper,
|
||||
mock(ApprovalFlowScopeMapper.class));
|
||||
when(service.listByIds(anyCollection()))
|
||||
.thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
|
||||
when(assigneeMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
|
||||
|
||||
Result<Integer> result = controller.changeStatusBatch(
|
||||
List.of(BigInteger.ONE),
|
||||
EnumDataStatus.UNAVAILABLE.getCode());
|
||||
|
||||
assertEquals(result.getErrorCode(), 1);
|
||||
assertEquals(result.getMessage(), "所选部门已被审批流程使用,不能禁用");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证审批范围引用的部门不能删除。
|
||||
*/
|
||||
@Test
|
||||
public void removeBatchShouldRejectApprovalScopeReference() {
|
||||
SysDeptService service = mock(SysDeptService.class);
|
||||
ApprovalFlowScopeMapper scopeMapper = mock(ApprovalFlowScopeMapper.class);
|
||||
SysDeptController controller = new SysDeptController(
|
||||
service,
|
||||
mock(SysAccountService.class),
|
||||
mock(ApprovalFlowStepAssigneeMapper.class),
|
||||
scopeMapper);
|
||||
Collection<Serializable> ids = List.of(BigInteger.ONE);
|
||||
when(service.listByIds(ids)).thenReturn(List.of(buildDept(BigInteger.ONE, "dept_1")));
|
||||
when(service.count(any(QueryWrapper.class))).thenReturn(0L);
|
||||
when(scopeMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
|
||||
|
||||
Result<?> result = controller.onRemoveBefore(ids);
|
||||
|
||||
assertEquals(result.getErrorCode(), 1);
|
||||
assertEquals(result.getMessage(), "所选部门已被审批流程使用,请先调整审批配置");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新增部门未传状态时默认启用。
|
||||
*/
|
||||
@Test
|
||||
public void saveShouldDefaultMissingStatusToAvailable() {
|
||||
SysDept entity = buildDept(null);
|
||||
|
||||
invokeSaveBefore(entity);
|
||||
|
||||
assertEquals(entity.getStatus(), EnumDataStatus.AVAILABLE.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新增部门显式传入未启用状态时保持原值。
|
||||
*/
|
||||
@Test
|
||||
public void saveShouldPreserveExplicitUnavailableStatus() {
|
||||
SysDept entity = buildDept(EnumDataStatus.UNAVAILABLE.getCode());
|
||||
|
||||
invokeSaveBefore(entity);
|
||||
|
||||
assertEquals(entity.getStatus(), EnumDataStatus.UNAVAILABLE.getCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证名称或编码模糊搜索会保留所有重名部门及其祖先路径。
|
||||
*/
|
||||
@Test
|
||||
public void listShouldKeepAllDuplicateNameMatchesAndAncestors() {
|
||||
SysDeptService service = mock(SysDeptService.class);
|
||||
SysDeptController controller = createController(service);
|
||||
SysDept firstMatch = buildTreeDept(BigInteger.valueOf(3), BigInteger.valueOf(2),
|
||||
"1,2", "交易银行部", "A-01");
|
||||
SysDept secondMatch = buildTreeDept(BigInteger.valueOf(5), BigInteger.valueOf(4),
|
||||
"1,4", "交易银行部", "B-01");
|
||||
List<SysDept> allDepartments = List.of(
|
||||
buildTreeDept(BigInteger.ONE, BigInteger.ZERO, "0", "总行", "ROOT"),
|
||||
buildTreeDept(BigInteger.valueOf(2), BigInteger.ONE, "1", "分行一", "A"),
|
||||
firstMatch,
|
||||
buildTreeDept(BigInteger.valueOf(4), BigInteger.ONE, "1", "分行二", "B"),
|
||||
secondMatch);
|
||||
when(service.list(any(QueryWrapper.class)))
|
||||
.thenReturn(List.of(firstMatch, secondMatch), allDepartments);
|
||||
|
||||
SysDept query = new SysDept();
|
||||
query.setKeyword(" 银行 ");
|
||||
Result<List<SysDept>> result = controller.list(query, true, null, null);
|
||||
|
||||
List<SysDept> flattened = new ArrayList<>();
|
||||
flattenDepartments(result.getData(), flattened);
|
||||
assertEquals(flattened.stream()
|
||||
.filter(department -> "交易银行部".equals(department.getDeptName()))
|
||||
.count(), 2L);
|
||||
assertEquals(flattened.size(), 5);
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(service, times(2)).list(queryCaptor.capture());
|
||||
String ancestorQuerySql = queryCaptor.getAllValues().get(1).toSQL().toUpperCase(Locale.ROOT);
|
||||
assertTrue(ancestorQuerySql.contains("ID") && ancestorQuerySql.contains(" IN "),
|
||||
"祖先节点查询应使用可见部门 ID 集合限制范围: " + ancestorQuerySql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造待保存的部门。
|
||||
*
|
||||
* @param status 部门状态
|
||||
* @return 部门实体
|
||||
*/
|
||||
private SysDept buildDept(Integer status) {
|
||||
SysDept entity = new SysDept();
|
||||
entity.setParentId(BigInteger.ZERO);
|
||||
entity.setStatus(status);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造指定主键和编码的部门。
|
||||
*
|
||||
* @param id 部门主键
|
||||
* @param deptCode 部门编码
|
||||
* @return 部门实体
|
||||
*/
|
||||
private SysDept buildDept(BigInteger id, String deptCode) {
|
||||
SysDept entity = new SysDept();
|
||||
entity.setId(id);
|
||||
entity.setDeptCode(deptCode);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造部门树节点。
|
||||
*
|
||||
* @param id 部门主键
|
||||
* @param parentId 父部门主键
|
||||
* @param ancestors 祖先路径
|
||||
* @param deptName 部门名称
|
||||
* @param deptCode 部门编码
|
||||
* @return 部门树节点
|
||||
*/
|
||||
private SysDept buildTreeDept(BigInteger id, BigInteger parentId, String ancestors,
|
||||
String deptName, String deptCode) {
|
||||
SysDept entity = buildDept(id, deptCode);
|
||||
entity.setParentId(parentId);
|
||||
entity.setAncestors(ancestors);
|
||||
entity.setDeptName(deptName);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将部门树展开为列表。
|
||||
*
|
||||
* @param departments 当前层部门
|
||||
* @param result 展开结果
|
||||
*/
|
||||
private void flattenDepartments(List<? extends TreeNode> departments, List<SysDept> result) {
|
||||
for (TreeNode node : departments) {
|
||||
SysDept department = (SysDept) node;
|
||||
result.add(department);
|
||||
if (department.getChildren() != null) {
|
||||
flattenDepartments(department.getChildren(), result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用新增前置处理。
|
||||
*
|
||||
* @param entity 部门实体
|
||||
*/
|
||||
private void invokeSaveBefore(SysDept entity) {
|
||||
LoginAccount loginAccount = mock(LoginAccount.class);
|
||||
SysDeptController controller = createController(mock(SysDeptService.class));
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
|
||||
controller.onSaveOrUpdateBefore(entity, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有隔离依赖的部门控制器。
|
||||
*
|
||||
* @param service 部门服务
|
||||
* @return 部门控制器
|
||||
*/
|
||||
private SysDeptController createController(SysDeptService service) {
|
||||
return new SysDeptController(
|
||||
service,
|
||||
mock(SysAccountService.class),
|
||||
mock(ApprovalFlowStepAssigneeMapper.class),
|
||||
mock(ApprovalFlowScopeMapper.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tech.easyflow.admin.controller.system;
|
||||
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.relation.RelationManager;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.testng.annotations.AfterMethod;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysLog;
|
||||
import tech.easyflow.system.mapper.SysLogMapper;
|
||||
import tech.easyflow.system.service.SysLogService;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.testng.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SysLogController} 查询边界测试。
|
||||
*/
|
||||
public class SysLogControllerTest {
|
||||
|
||||
/**
|
||||
* 清理 MyBatis-Flex 关系查询线程配置。
|
||||
*/
|
||||
@AfterMethod
|
||||
public void tearDown() {
|
||||
RelationManager.clearQueryRelations();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证日志分页最多返回一百条。
|
||||
*/
|
||||
@Test
|
||||
public void queryPageShouldClampPageSize() {
|
||||
SysLogService service = mock(SysLogService.class);
|
||||
SysLogMapper mapper = mock(SysLogMapper.class);
|
||||
when(service.getMapper()).thenReturn(mapper);
|
||||
when(mapper.paginateWithRelations(any(Page.class), any(QueryWrapper.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
SysLogController controller = new SysLogController(service);
|
||||
Page<SysLog> page = new Page<>(1, 500);
|
||||
|
||||
Page<SysLog> result = controller.queryPage(page, QueryWrapper.create());
|
||||
|
||||
assertEquals(100L, result.getPageSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证日志默认按操作时间稳定倒序排列。
|
||||
*/
|
||||
@Test
|
||||
public void defaultOrderShouldUseCreatedAndId() {
|
||||
SysLogController controller = new SysLogController(mock(SysLogService.class));
|
||||
|
||||
assertEquals(controller.getDefaultOrderBy(), "created desc, id desc");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证错误的时间格式会返回明确的业务参数错误。
|
||||
*/
|
||||
@Test(expectedExceptions = BusinessException.class)
|
||||
public void buildQueryWrapperShouldRejectInvalidTime() {
|
||||
SysLogController controller = new SysLogController(mock(SysLogService.class));
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getParameterMap()).thenReturn(Collections.emptyMap());
|
||||
when(request.getParameter("createdStart")).thenReturn("2026/07/30");
|
||||
|
||||
controller.buildQueryWrapper(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package tech.easyflow.admin.service.agent;
|
||||
|
||||
import org.mockito.Mockito;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.BeforeMethod;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link AgentSessionService} 会话删除测试。
|
||||
*/
|
||||
public class AgentSessionServiceTest {
|
||||
|
||||
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
|
||||
private static final BigInteger TENANT_ID = BigInteger.valueOf(3);
|
||||
private static final BigInteger SESSION_ID = BigInteger.valueOf(101);
|
||||
|
||||
private ChatSessionQueryService chatSessionQueryService;
|
||||
private ChatSessionCommandService chatSessionCommandService;
|
||||
private AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
|
||||
private AgentMediaService agentMediaService;
|
||||
private AgentComposerDraftService agentComposerDraftService;
|
||||
private AgentSessionService service;
|
||||
private LoginAccount account;
|
||||
|
||||
/**
|
||||
* 初始化测试依赖。
|
||||
*/
|
||||
@BeforeMethod
|
||||
public void setUp() {
|
||||
chatSessionQueryService = mock(ChatSessionQueryService.class);
|
||||
chatSessionCommandService = mock(ChatSessionCommandService.class);
|
||||
agentRuntimeStateCleanupService = mock(AgentRuntimeStateCleanupService.class);
|
||||
agentMediaService = mock(AgentMediaService.class);
|
||||
agentComposerDraftService = mock(AgentComposerDraftService.class);
|
||||
service = new AgentSessionService(
|
||||
chatSessionQueryService,
|
||||
chatSessionCommandService,
|
||||
mock(AgentService.class),
|
||||
mock(DocumentCollectionService.class),
|
||||
mock(ResourceAccessService.class),
|
||||
agentRuntimeStateCleanupService,
|
||||
agentMediaService,
|
||||
agentComposerDraftService,
|
||||
mock(ChatJsonSupport.class)
|
||||
);
|
||||
account = new LoginAccount();
|
||||
account.setId(ACCOUNT_ID);
|
||||
account.setTenantId(TENANT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正常删除会清理运行态、写入删除命令并删除媒体目录。
|
||||
*/
|
||||
@Test
|
||||
public void shouldDeleteActiveOwnedAgentSession() {
|
||||
when(chatSessionQueryService.getSessionSummary(SESSION_ID))
|
||||
.thenReturn(buildSession(ACCOUNT_ID, 0, "AGENT"));
|
||||
|
||||
service.deleteCurrentUserSession(account, SESSION_ID);
|
||||
|
||||
verify(agentRuntimeStateCleanupService).clearChatSession(SESSION_ID, ACCOUNT_ID);
|
||||
verify(chatSessionCommandService).deleteSession(SESSION_ID, ACCOUNT_ID, ACCOUNT_ID);
|
||||
verify(agentComposerDraftService).delete(AgentMediaService.MODE_FORMAL, "9", "101", account);
|
||||
verify(agentMediaService).deleteFormalSession(SESSION_ID.toString(), account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证会话已删除时重复请求仍会重试媒体目录清理并成功返回。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRetryMediaCleanupForDeletedSession() {
|
||||
when(chatSessionQueryService.getSessionSummary(SESSION_ID))
|
||||
.thenReturn(buildSession(ACCOUNT_ID, 1, "AGENT"));
|
||||
|
||||
service.deleteCurrentUserSession(account, SESSION_ID);
|
||||
|
||||
verify(agentRuntimeStateCleanupService, never()).clearChatSession(Mockito.any(), Mockito.any());
|
||||
verify(chatSessionCommandService, never()).deleteSession(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentComposerDraftService).delete(AgentMediaService.MODE_FORMAL, "9", "101", account);
|
||||
verify(agentMediaService).deleteFormalSession(SESSION_ID.toString(), account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证查询不到会话时删除保持幂等,并按当前用户目录重试媒体清理。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRetryMediaCleanupWhenSessionIsMissing() {
|
||||
when(chatSessionQueryService.getSessionSummary(SESSION_ID)).thenReturn(null);
|
||||
|
||||
service.deleteCurrentUserSession(account, SESSION_ID);
|
||||
|
||||
verify(agentRuntimeStateCleanupService, never()).clearChatSession(Mockito.any(), Mockito.any());
|
||||
verify(chatSessionCommandService, never()).deleteSession(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentComposerDraftService, never()).delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentMediaService).deleteFormalSession(SESSION_ID.toString(), account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证活动会话属于其他用户时仍拒绝删除,且不执行任何清理。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectActiveSessionOwnedByAnotherUser() {
|
||||
when(chatSessionQueryService.getSessionSummary(SESSION_ID))
|
||||
.thenReturn(buildSession(BigInteger.valueOf(8), 0, "AGENT"));
|
||||
|
||||
BusinessException exception = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> service.deleteCurrentUserSession(account, SESSION_ID)
|
||||
);
|
||||
|
||||
Assert.assertEquals(exception.getMessage(), "无权访问该 Agent 会话");
|
||||
verify(agentRuntimeStateCleanupService, never()).clearChatSession(Mockito.any(), Mockito.any());
|
||||
verify(chatSessionCommandService, never()).deleteSession(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentComposerDraftService, never()).delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentMediaService, never()).deleteFormalSession(Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造会话摘要。
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @param isDeleted 删除标记
|
||||
* @param assistantCode 助手类型
|
||||
* @return 会话摘要
|
||||
*/
|
||||
private ChatSessionSummary buildSession(BigInteger userId, Integer isDeleted, String assistantCode) {
|
||||
ChatSessionSummary summary = new ChatSessionSummary();
|
||||
summary.setId(SESSION_ID);
|
||||
summary.setTenantId(TENANT_ID);
|
||||
summary.setUserId(userId);
|
||||
summary.setIsDeleted(isDeleted);
|
||||
summary.setAssistantCode(assistantCode);
|
||||
summary.setAssistantId(BigInteger.valueOf(9));
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainConsts;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
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.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* {@link WorkflowChatEventStream} 最终输出协议测试。
|
||||
*/
|
||||
public class WorkflowChatEventStreamTest {
|
||||
|
||||
/**
|
||||
* 验证会话事件流不再订阅节点显式输出。
|
||||
*/
|
||||
@Test
|
||||
public void shouldOnlyRegisterLifecycleAndErrorListeners() {
|
||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||
WorkflowChatEventStream eventStream =
|
||||
new WorkflowChatEventStream(chainExecutor);
|
||||
|
||||
eventStream.registerListeners();
|
||||
|
||||
verify(chainExecutor).addEventListener(any());
|
||||
verify(chainExecutor).addErrorListener(any());
|
||||
verify(chainExecutor, never()).addOutputListener(any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证顶级工作流最终输出会保留业务结构并移除内部控制字段。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepStructuredFinalOutputAndRemoveInternalFields() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("summary", "执行完成");
|
||||
result.put("items", List.of(
|
||||
Map.of("name", "A", "score", 90),
|
||||
Map.of("name", "B", "score", 85)
|
||||
));
|
||||
result.put(ChainConsts.CHAIN_STATE_STATUS_KEY, "SUCCEEDED");
|
||||
result.put(ChainConsts.CHAIN_STATE_MESSAGE_KEY, "internal");
|
||||
result.put(ChainConsts.NODE_STATE_STATUS_KEY, "SUCCESS");
|
||||
result.put(ChainConsts.SCHEDULE_NEXT_NODE_DISABLED_KEY, true);
|
||||
|
||||
Map<String, Object> visible =
|
||||
WorkflowChatEventStream.visibleFinalOutput(result);
|
||||
|
||||
Assert.assertEquals(visible.get("summary"), "执行完成");
|
||||
Assert.assertEquals(visible.get("items"), result.get("items"));
|
||||
Assert.assertEquals(visible.size(), 2);
|
||||
Assert.assertEquals(result.size(), 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空执行结果被规范化为空对象。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNormalizeMissingFinalOutputToEmptyMap() {
|
||||
Assert.assertTrue(
|
||||
WorkflowChatEventStream.visibleFinalOutput(null).isEmpty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
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.execution.service.DatacenterDatasetQueryService;
|
||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
|
||||
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link WorkflowDesignerOptionService} 工作流引用权限测试。
|
||||
*/
|
||||
public class WorkflowDesignerOptionServiceTest {
|
||||
|
||||
/**
|
||||
* 验证客户端提交候选列表之外的模型 ID 时服务端拒绝保存。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectModelOutsideSelectableOptions() {
|
||||
ModelService modelService = mock(ModelService.class);
|
||||
DocumentCollectionService knowledgeService = mock(DocumentCollectionService.class);
|
||||
when(modelService.listByIds(any())).thenReturn(List.of());
|
||||
when(knowledgeService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
WorkflowDesignerOptionService service = createService(
|
||||
modelService, knowledgeService, mock(DatacenterSourceService.class));
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
|
||||
|
||||
BusinessException exception = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> service.assertContentReferences(
|
||||
"{\"nodes\":[{\"type\":\"llmNode\",\"data\":{\"llmId\":\"99\"}}]}")
|
||||
);
|
||||
|
||||
Assert.assertTrue(exception.getMessage().contains("模型"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证工作流数据节点不能引用其他租户的数据源。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectCrossTenantDataSource() {
|
||||
ModelService modelService = mock(ModelService.class);
|
||||
DocumentCollectionService knowledgeService = mock(DocumentCollectionService.class);
|
||||
DatacenterSourceService sourceService = mock(DatacenterSourceService.class);
|
||||
when(modelService.listSelectableModels(any(Model.class), eq(false), eq("id"), eq("desc")))
|
||||
.thenReturn(List.of());
|
||||
when(knowledgeService.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||
DatacenterSource source = new DatacenterSource();
|
||||
source.setId(BigInteger.valueOf(9));
|
||||
source.setTenantId(BigInteger.valueOf(200));
|
||||
when(sourceService.getById(BigInteger.valueOf(9))).thenReturn(source);
|
||||
WorkflowDesignerOptionService service = createService(
|
||||
modelService, knowledgeService, sourceService);
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
|
||||
|
||||
BusinessException exception = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> service.assertContentReferences("""
|
||||
{"nodes":[{"type":"search-dataset-node","data":{
|
||||
"datasetRef":{"sourceId":"9"}
|
||||
}}]}
|
||||
""")
|
||||
);
|
||||
|
||||
Assert.assertTrue(exception.getMessage().contains("数据源"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证子流程节点配置拒绝读取其他租户的工作流。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectCrossTenantChildWorkflow() {
|
||||
WorkflowService workflowService = mock(WorkflowService.class);
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.valueOf(19));
|
||||
workflow.setTenantId(BigInteger.valueOf(200));
|
||||
when(workflowService.getById(BigInteger.valueOf(19))).thenReturn(workflow);
|
||||
WorkflowDesignerOptionService service = createService(
|
||||
mock(ModelService.class),
|
||||
mock(DocumentCollectionService.class),
|
||||
mock(DatacenterSourceService.class),
|
||||
workflowService
|
||||
);
|
||||
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
|
||||
|
||||
BusinessException exception = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> service.getChildWorkflowNodeData(
|
||||
BigInteger.valueOf(10),
|
||||
BigInteger.valueOf(19))
|
||||
);
|
||||
|
||||
Assert.assertTrue(exception.getMessage().contains("子流程"));
|
||||
}
|
||||
}
|
||||
|
||||
private WorkflowDesignerOptionService createService(
|
||||
ModelService modelService,
|
||||
DocumentCollectionService knowledgeService,
|
||||
DatacenterSourceService sourceService) {
|
||||
return createService(modelService, knowledgeService, sourceService, mock(WorkflowService.class));
|
||||
}
|
||||
|
||||
private WorkflowDesignerOptionService createService(
|
||||
ModelService modelService,
|
||||
DocumentCollectionService knowledgeService,
|
||||
DatacenterSourceService sourceService,
|
||||
WorkflowService workflowService) {
|
||||
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
|
||||
return new WorkflowDesignerOptionService(
|
||||
modelService,
|
||||
knowledgeService,
|
||||
mock(PluginService.class),
|
||||
mock(PluginItemService.class),
|
||||
mock(PluginVisibilityService.class),
|
||||
workflowService,
|
||||
new WorkflowUsageAuthorizationService(workflowService, resourceAccessService),
|
||||
mock(WorkflowPluginSnapshotResolver.class),
|
||||
mock(ChainParser.class),
|
||||
mock(WorkflowDatacenterContentService.class),
|
||||
resourceAccessService,
|
||||
sourceService,
|
||||
mock(DatacenterDatasetRegistryService.class),
|
||||
mock(DatacenterDatasetQueryService.class)
|
||||
);
|
||||
}
|
||||
|
||||
private LoginAccount loginAccount() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(BigInteger.ONE);
|
||||
account.setTenantId(BigInteger.valueOf(100));
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.service.BotService;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||
@@ -22,6 +22,7 @@ import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.system.service.SysAccountRoleService;
|
||||
@@ -339,29 +340,34 @@ public class DashboardServiceImplTest {
|
||||
public void shouldQueryUserRanksWithAssistantFilter() throws Exception {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||
BotService botService = mock(BotService.class);
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
SysAccountService sysAccountService = mock(SysAccountService.class);
|
||||
SysAccountRoleService sysAccountRoleService = mock(SysAccountRoleService.class);
|
||||
SysRoleService sysRoleService = mock(SysRoleService.class);
|
||||
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.TEN);
|
||||
bot.setStatus(1);
|
||||
bot.setCreatedBy(BigInteger.ONE);
|
||||
bot.setCategoryId(BigInteger.valueOf(8));
|
||||
Agent agent = new Agent();
|
||||
agent.setId(BigInteger.TEN);
|
||||
agent.setStatus(1);
|
||||
agent.setCreatedBy(BigInteger.ONE);
|
||||
agent.setCategoryId(BigInteger.valueOf(8));
|
||||
|
||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5)))
|
||||
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 2L, 4L, 1L)));
|
||||
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
|
||||
when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8))))
|
||||
when(agentService.getById(BigInteger.TEN)).thenReturn(agent);
|
||||
when(categoryPermissionService.canAccessCategory(
|
||||
any(LoginAccount.class),
|
||||
eq(CategoryResourceType.AGENT.getCode()),
|
||||
eq(BigInteger.ONE),
|
||||
eq(BigInteger.valueOf(8))
|
||||
))
|
||||
.thenReturn(true);
|
||||
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(2L, "demo-user", "演示用户")));
|
||||
when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
|
||||
|
||||
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||
setField(service, "botService", botService);
|
||||
setField(service, "agentService", agentService);
|
||||
setField(service, "categoryPermissionService", categoryPermissionService);
|
||||
setField(service, "sysAccountService", sysAccountService);
|
||||
setField(service, "sysAccountRoleService", sysAccountRoleService);
|
||||
@@ -382,26 +388,59 @@ public class DashboardServiceImplTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未启用智能体会被拒绝。
|
||||
* 验证停用智能体仍可用于筛选历史统计。
|
||||
*/
|
||||
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用")
|
||||
public void shouldRejectDisabledAssistantFilter() {
|
||||
@Test
|
||||
public void shouldAllowDisabledAgentFilterForHistoricalStatistics() {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
BotService botService = mock(BotService.class);
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.TEN);
|
||||
bot.setStatus(0);
|
||||
Agent agent = new Agent();
|
||||
agent.setId(BigInteger.TEN);
|
||||
agent.setStatus(0);
|
||||
agent.setCreatedBy(BigInteger.ONE);
|
||||
agent.setCategoryId(BigInteger.valueOf(8));
|
||||
|
||||
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
|
||||
when(agentService.getById(BigInteger.TEN)).thenReturn(agent);
|
||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||
when(categoryPermissionService.canAccessCategory(
|
||||
any(LoginAccount.class),
|
||||
eq(CategoryResourceType.AGENT.getCode()),
|
||||
eq(BigInteger.ONE),
|
||||
eq(BigInteger.valueOf(8))
|
||||
)).thenReturn(true);
|
||||
|
||||
setFieldSilently(service, "botService", botService);
|
||||
setFieldSilently(service, "agentService", agentService);
|
||||
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||
setFieldSilently(service, "categoryPermissionService", mock(CategoryPermissionService.class));
|
||||
setFieldSilently(service, "categoryPermissionService", categoryPermissionService);
|
||||
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
|
||||
|
||||
DashboardUserRankQuery query = new DashboardUserRankQuery();
|
||||
query.setRange("7d");
|
||||
query.setAssistantId(BigInteger.TEN);
|
||||
List<DashboardUserRankItemVo> userRanks = service.getUserRanks(new LoginAccount(), query);
|
||||
|
||||
Assert.assertTrue(userRanks.isEmpty());
|
||||
verify(chatDashboardQueryService).queryActiveUserRanks(
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
eq(BigInteger.TEN),
|
||||
eq(5)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证不存在的智能体筛选会被拒绝。
|
||||
*/
|
||||
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "智能体不存在或不可见")
|
||||
public void shouldRejectMissingAgentFilter() {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
setFieldSilently(service, "agentService", agentService);
|
||||
|
||||
DashboardUserRankQuery query = new DashboardUserRankQuery();
|
||||
query.setRange("7d");
|
||||
query.setAssistantId(BigInteger.TEN);
|
||||
@@ -411,25 +450,30 @@ public class DashboardServiceImplTest {
|
||||
/**
|
||||
* 验证当前作用域不可见的智能体会被拒绝。
|
||||
*/
|
||||
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用")
|
||||
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "智能体不存在或不可见")
|
||||
public void shouldRejectInvisibleAssistantFilter() {
|
||||
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||
BotService botService = mock(BotService.class);
|
||||
AgentService agentService = mock(AgentService.class);
|
||||
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
|
||||
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.TEN);
|
||||
bot.setStatus(1);
|
||||
bot.setCreatedBy(BigInteger.ONE);
|
||||
bot.setCategoryId(BigInteger.valueOf(8));
|
||||
Agent agent = new Agent();
|
||||
agent.setId(BigInteger.TEN);
|
||||
agent.setStatus(1);
|
||||
agent.setCreatedBy(BigInteger.ONE);
|
||||
agent.setCategoryId(BigInteger.valueOf(8));
|
||||
|
||||
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
|
||||
when(agentService.getById(BigInteger.TEN)).thenReturn(agent);
|
||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||
when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8))))
|
||||
when(categoryPermissionService.canAccessCategory(
|
||||
any(LoginAccount.class),
|
||||
eq(CategoryResourceType.AGENT.getCode()),
|
||||
eq(BigInteger.ONE),
|
||||
eq(BigInteger.valueOf(8))
|
||||
))
|
||||
.thenReturn(false);
|
||||
|
||||
setFieldSilently(service, "botService", botService);
|
||||
setFieldSilently(service, "agentService", agentService);
|
||||
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||
setFieldSilently(service, "categoryPermissionService", categoryPermissionService);
|
||||
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<artifactId>easyflow-api-public</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-agent</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-ai</artifactId>
|
||||
@@ -33,5 +37,11 @@
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package tech.easyflow.publicapi.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.util.StringUtils;
|
||||
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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.agent.runtime.AgentChatRequest;
|
||||
import tech.easyflow.agent.runtime.AgentRunService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
import tech.easyflow.system.service.SysApiKeyService;
|
||||
|
||||
/**
|
||||
* Agent 公共调用接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/public-api/agent")
|
||||
public class PublicAgentController {
|
||||
|
||||
private final AgentRunService agentRunService;
|
||||
private final SysApiKeyService sysApiKeyService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 公共接口控制器。
|
||||
*
|
||||
* @param agentRunService Agent 运行服务
|
||||
* @param sysApiKeyService API Key 服务
|
||||
*/
|
||||
public PublicAgentController(AgentRunService agentRunService,
|
||||
SysApiKeyService sysApiKeyService) {
|
||||
this.agentRunService = agentRunService;
|
||||
this.sysApiKeyService = sysApiKeyService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 API Key 调用已发布 Agent。
|
||||
*
|
||||
* @param chatRequest Agent 聊天请求
|
||||
* @param request HTTP 请求
|
||||
* @return SSE Emitter
|
||||
*/
|
||||
@PostMapping("/chat")
|
||||
public SseEmitter chat(@RequestBody AgentChatRequest chatRequest,
|
||||
HttpServletRequest request) {
|
||||
String apiKey = request.getHeader(SysApiKey.KEY_Apikey);
|
||||
if (!StringUtils.hasText(apiKey)) {
|
||||
throw new BusinessException(401, 401, "Apikey不能为空!");
|
||||
}
|
||||
sysApiKeyService.checkApikeyPermission(apiKey, request.getRequestURI());
|
||||
SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey);
|
||||
return agentRunService.chatPublic(chatRequest, buildApiAccount(sysApiKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 API Key 转换为独立的聊天调用身份。
|
||||
*
|
||||
* @param sysApiKey API Key 记录
|
||||
* @return 调用身份
|
||||
*/
|
||||
private LoginAccount buildApiAccount(SysApiKey sysApiKey) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(sysApiKey.getId());
|
||||
account.setTenantId(sysApiKey.getTenantId() == null
|
||||
? java.math.BigInteger.ZERO
|
||||
: sysApiKey.getTenantId());
|
||||
account.setDeptId(sysApiKey.getDeptId() == null
|
||||
? java.math.BigInteger.ZERO
|
||||
: sysApiKey.getDeptId());
|
||||
account.setLoginName("apikey:" + sysApiKey.getId());
|
||||
account.setNickname("API 调用方");
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package tech.easyflow.publicapi.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.Part;
|
||||
import org.springframework.http.InvalidMediaTypeException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.documentimport.ImportCallerContext;
|
||||
import tech.easyflow.ai.documentimport.ImportCallerType;
|
||||
import tech.easyflow.ai.documentimport.PublicDocumentImportDtos;
|
||||
import tech.easyflow.ai.documentimport.task.KnowledgeImportBatchFacade;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.publicapi.interceptor.PublicApiInterceptor;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
import tech.easyflow.system.service.SysApiKeyService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 知识库文档 Public API 批量异步导入接口。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-08-02
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(
|
||||
value = "/public-api/knowledge-share/document/import/batch",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE
|
||||
)
|
||||
public class PublicKnowledgeDocumentImportController {
|
||||
|
||||
private static final long MAX_METADATA_BYTES = 1024L * 1024L;
|
||||
|
||||
private final SysApiKeyService sysApiKeyService;
|
||||
private final KnowledgeSharePermissionService permissionService;
|
||||
private final KnowledgeShareAuditService auditService;
|
||||
private final DocumentCollectionService documentCollectionService;
|
||||
private final KnowledgeImportBatchFacade importFacade;
|
||||
|
||||
/**
|
||||
* 创建 Public API 批量导入控制器。
|
||||
*
|
||||
* @param sysApiKeyService 访问令牌服务
|
||||
* @param permissionService 知识库权限服务
|
||||
* @param auditService 审计服务
|
||||
* @param documentCollectionService 知识库服务
|
||||
* @param importFacade 批量导入门面
|
||||
*/
|
||||
public PublicKnowledgeDocumentImportController(
|
||||
SysApiKeyService sysApiKeyService,
|
||||
KnowledgeSharePermissionService permissionService,
|
||||
KnowledgeShareAuditService auditService,
|
||||
DocumentCollectionService documentCollectionService,
|
||||
KnowledgeImportBatchFacade importFacade) {
|
||||
this.sysApiKeyService = sysApiKeyService;
|
||||
this.permissionService = permissionService;
|
||||
this.auditService = auditService;
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.importFacade = importFacade;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收多文件并创建异步导入任务。
|
||||
*
|
||||
* @param apiKey 访问令牌
|
||||
* @param metadataPart JSON 元数据 Part
|
||||
* @param files 多个文件 Part
|
||||
* @param servletRequest Servlet 请求
|
||||
* @return HTTP 202 异步任务响应
|
||||
*/
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<Result<PublicDocumentImportDtos.SubmitResponse>> submit(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestPart("metadata") Part metadataPart,
|
||||
@RequestPart("files") List<MultipartFile> files,
|
||||
HttpServletRequest servletRequest) {
|
||||
PublicDocumentImportDtos.BatchMetadata metadata =
|
||||
parseMetadata(metadataPart);
|
||||
SysApiKey token = resolveAuthenticatedApiKey(servletRequest, apiKey);
|
||||
assertImportPermission(
|
||||
token,
|
||||
servletRequest.getRequestURI(),
|
||||
metadata.getKnowledgeId()
|
||||
);
|
||||
requireDocumentKnowledge(metadata.getKnowledgeId());
|
||||
ImportCallerContext caller =
|
||||
new ImportCallerContext(ImportCallerType.PUBLIC_API, token.getId());
|
||||
PublicDocumentImportDtos.SubmitResponse response =
|
||||
importFacade.submit(caller, metadata, files);
|
||||
audit(
|
||||
token,
|
||||
"API批量导入文档",
|
||||
servletRequest.getRequestURI(),
|
||||
Map.of(
|
||||
"knowledgeId", metadata.getKnowledgeId(),
|
||||
"taskId", response.getTaskId(),
|
||||
"totalCount", response.getTotalCount()
|
||||
)
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.ACCEPTED).body(Result.ok(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询异步导入任务状态。
|
||||
*
|
||||
* @param apiKey 访问令牌
|
||||
* @param taskId 批次任务 ID
|
||||
* @param itemStatus 可选文件状态
|
||||
* @param pageNumber 页码
|
||||
* @param pageSize 每页数量
|
||||
* @param servletRequest Servlet 请求
|
||||
* @return 任务状态
|
||||
*/
|
||||
@GetMapping("/status")
|
||||
public Result<PublicDocumentImportDtos.StatusResponse> status(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger taskId,
|
||||
@RequestParam(required = false) String itemStatus,
|
||||
@RequestParam(defaultValue = "1") long pageNumber,
|
||||
@RequestParam(defaultValue = "20") long pageSize,
|
||||
HttpServletRequest servletRequest) {
|
||||
SysApiKey token = resolveAuthenticatedApiKey(servletRequest, apiKey);
|
||||
ImportCallerContext caller =
|
||||
new ImportCallerContext(ImportCallerType.PUBLIC_API, token.getId());
|
||||
BigInteger knowledgeId =
|
||||
importFacade.getOwnedKnowledgeId(caller, taskId);
|
||||
assertImportPermission(token, servletRequest.getRequestURI(), knowledgeId);
|
||||
PublicDocumentImportDtos.StatusResponse response =
|
||||
importFacade.getStatus(
|
||||
caller,
|
||||
taskId,
|
||||
itemStatus,
|
||||
pageNumber,
|
||||
pageSize
|
||||
);
|
||||
return Result.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对异常任务执行断点重试。
|
||||
*
|
||||
* @param apiKey 访问令牌
|
||||
* @param request 重试请求
|
||||
* @param servletRequest Servlet 请求
|
||||
* @return 重试结果
|
||||
*/
|
||||
@PostMapping(value = "/retry", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Result<PublicDocumentImportDtos.RetryResponse> retry(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@JsonBody PublicDocumentImportDtos.RetryRequest request,
|
||||
HttpServletRequest servletRequest) {
|
||||
SysApiKey token = resolveAuthenticatedApiKey(servletRequest, apiKey);
|
||||
ImportCallerContext caller =
|
||||
new ImportCallerContext(ImportCallerType.PUBLIC_API, token.getId());
|
||||
BigInteger taskId = request == null ? null : request.getTaskId();
|
||||
if (taskId == null) {
|
||||
throw new BusinessException("taskId 不能为空");
|
||||
}
|
||||
BigInteger knowledgeId =
|
||||
importFacade.getOwnedKnowledgeId(caller, taskId);
|
||||
assertImportPermission(token, servletRequest.getRequestURI(), knowledgeId);
|
||||
PublicDocumentImportDtos.RetryResponse response =
|
||||
importFacade.retry(caller, request);
|
||||
audit(
|
||||
token,
|
||||
"API重试批量导入任务",
|
||||
servletRequest.getRequestURI(),
|
||||
Map.of(
|
||||
"knowledgeId", knowledgeId,
|
||||
"taskId", response.getTaskId(),
|
||||
"retriedCount", response.getRetriedCount()
|
||||
)
|
||||
);
|
||||
return Result.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并校验 metadata JSON Part。
|
||||
*
|
||||
* @param metadataPart metadata Part
|
||||
* @return 批量元数据
|
||||
*/
|
||||
private PublicDocumentImportDtos.BatchMetadata parseMetadata(
|
||||
Part metadataPart) {
|
||||
if (metadataPart == null || metadataPart.getSize() <= 0) {
|
||||
throw new BusinessException("metadata 不能为空");
|
||||
}
|
||||
if (metadataPart.getSize() > MAX_METADATA_BYTES) {
|
||||
throw new BusinessException(413, 41304, "metadata 不能超过1MiB");
|
||||
}
|
||||
String contentType = metadataPart.getContentType();
|
||||
try {
|
||||
if (contentType == null
|
||||
|| !MediaType.APPLICATION_JSON.includes(
|
||||
MediaType.parseMediaType(contentType))) {
|
||||
throw new BusinessException(415, 41503,
|
||||
"metadata Part 必须使用 application/json");
|
||||
}
|
||||
} catch (InvalidMediaTypeException error) {
|
||||
throw new BusinessException(415, 41503,
|
||||
"metadata Part Content-Type 无效", error);
|
||||
}
|
||||
try {
|
||||
String json = new String(
|
||||
metadataPart.getInputStream().readAllBytes(),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
PublicDocumentImportDtos.BatchMetadata metadata =
|
||||
JSON.parseObject(
|
||||
json,
|
||||
PublicDocumentImportDtos.BatchMetadata.class
|
||||
);
|
||||
if (metadata == null) {
|
||||
throw new BusinessException("metadata 不能为空");
|
||||
}
|
||||
return metadata;
|
||||
} catch (JSONException error) {
|
||||
throw new BusinessException(400, 40021,
|
||||
"metadata JSON 格式无效", error);
|
||||
} catch (java.io.IOException error) {
|
||||
throw new BusinessException(500, 50023,
|
||||
"读取 metadata 失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言访问令牌具有知识导入权限。
|
||||
*
|
||||
* @param token 访问令牌
|
||||
* @param requestUri 请求 URI
|
||||
* @param knowledgeId 知识库 ID
|
||||
*/
|
||||
private void assertImportPermission(SysApiKey token,
|
||||
String requestUri,
|
||||
BigInteger knowledgeId) {
|
||||
permissionService.assertApiShare(
|
||||
token.getId(),
|
||||
requestUri,
|
||||
knowledgeId,
|
||||
KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用拦截器已经完成认证的访问令牌。
|
||||
*
|
||||
* @param request Servlet 请求
|
||||
* @param apiKey 访问令牌明文
|
||||
* @return 已认证访问令牌
|
||||
*/
|
||||
private SysApiKey resolveAuthenticatedApiKey(
|
||||
HttpServletRequest request,
|
||||
String apiKey) {
|
||||
Object authenticated =
|
||||
request.getAttribute(
|
||||
PublicApiInterceptor.AUTHENTICATED_API_KEY_ATTRIBUTE
|
||||
);
|
||||
if (authenticated instanceof SysApiKey token) {
|
||||
return token;
|
||||
}
|
||||
// 兼容控制器单测和绕过 MVC 拦截器的内部直接调用。
|
||||
return sysApiKeyService.getSysApiKey(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言知识库存在且为文档类型。
|
||||
*
|
||||
* @param knowledgeId 知识库 ID
|
||||
*/
|
||||
private void requireDocumentKnowledge(BigInteger knowledgeId) {
|
||||
DocumentCollection knowledge =
|
||||
knowledgeId == null ? null : documentCollectionService.getById(knowledgeId);
|
||||
if (knowledge == null) {
|
||||
throw new BusinessException(404, 404, "知识库不存在");
|
||||
}
|
||||
if (!knowledge.isDocumentCollection()) {
|
||||
throw new BusinessException("当前知识库类型不支持文档导入");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录不含令牌明文的 Public API 审计。
|
||||
*
|
||||
* @param token 访问令牌
|
||||
* @param actionName 操作名称
|
||||
* @param actionUrl 操作 URI
|
||||
* @param detail 业务详情
|
||||
*/
|
||||
private void audit(SysApiKey token,
|
||||
String actionName,
|
||||
String actionUrl,
|
||||
Map<String, Object> detail) {
|
||||
Map<String, Object> payload = new HashMap<>(detail);
|
||||
payload.put("apiKeyId", token.getId());
|
||||
payload.put("channel", "API");
|
||||
auditService.log(
|
||||
null,
|
||||
actionName,
|
||||
"KNOWLEDGE_API_SHARE_WRITE",
|
||||
actionUrl,
|
||||
payload
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,12 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
||||
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
|
||||
import tech.easyflow.ai.entity.Document;
|
||||
import tech.easyflow.ai.entity.DocumentChunk;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.FaqItem;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
|
||||
import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||
import tech.easyflow.ai.service.DocumentChunkService;
|
||||
@@ -42,6 +41,8 @@ import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.publicapi.dto.PublicKnowledgeDetailResponse;
|
||||
import tech.easyflow.publicapi.dto.PublicKnowledgeSearchResultItem;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
import tech.easyflow.system.service.SysApiKeyService;
|
||||
|
||||
@@ -89,28 +90,43 @@ public class PublicKnowledgeShareController {
|
||||
* 获取知识库详情。
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
public Result<DocumentCollection> detail(
|
||||
public Result<PublicKnowledgeDetailResponse> detail(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam(defaultValue = "1") int pageNumber,
|
||||
@RequestParam(defaultValue = "50") int pageSize,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
|
||||
validateDocumentPage(pageNumber, pageSize);
|
||||
DocumentCollection knowledge = documentCollectionService.getDetail(knowledgeId.toString());
|
||||
if (knowledge == null) {
|
||||
throw new BusinessException("知识库不存在");
|
||||
}
|
||||
Page<Document> documents = knowledge.isDocumentCollection()
|
||||
? documentService.getDocumentList(
|
||||
knowledgeId.toString(),
|
||||
pageSize,
|
||||
pageNumber,
|
||||
null
|
||||
)
|
||||
: new Page<>(Collections.emptyList(), pageNumber, pageSize, 0L);
|
||||
audit(apiKey, "API读取知识库详情", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
|
||||
return Result.ok(documentCollectionService.getDetail(knowledgeId.toString()));
|
||||
return Result.ok(new PublicKnowledgeDetailResponse(knowledge, documents));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索知识库。
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
public Result<List<KnowledgeSearchResultItem>> search(
|
||||
public Result<List<PublicKnowledgeSearchResultItem>> search(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam String keyword,
|
||||
@RequestParam(required = false) String retrievalMode,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.SEARCH.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
|
||||
KnowledgeRetrievalRequest retrievalRequest = new KnowledgeRetrievalRequest();
|
||||
retrievalRequest.setKnowledgeId(knowledgeId);
|
||||
retrievalRequest.setQuery(keyword);
|
||||
@@ -128,14 +144,19 @@ public class PublicKnowledgeShareController {
|
||||
public Result<Page<Document>> documentPage(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam(required = false) String title,
|
||||
@RequestParam(required = false) BigInteger documentId,
|
||||
@RequestParam(defaultValue = "10") int pageSize,
|
||||
@RequestParam(defaultValue = "1") int pageNumber,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
return Result.ok(documentService.getDocumentList(knowledgeId.toString(), pageSize, pageNumber, title));
|
||||
return Result.ok(documentService.getDocumentListById(
|
||||
knowledgeId.toString(),
|
||||
pageSize,
|
||||
pageNumber,
|
||||
documentId
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +170,7 @@ public class PublicKnowledgeShareController {
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
Document document = requireDocument(documentId, knowledgeId);
|
||||
response.setContentType("application/octet-stream");
|
||||
@@ -169,11 +190,11 @@ public class PublicKnowledgeShareController {
|
||||
@PostMapping("/document/remove")
|
||||
public Result<?> removeDocument(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@JsonBody("id") String id,
|
||||
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
|
||||
@JsonBody(value = "id", required = true) String id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
requireDocument(new BigInteger(id), knowledgeId);
|
||||
audit(apiKey, "API删除文档", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "documentId", id));
|
||||
@@ -189,7 +210,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody DocumentImportDtos.AnalyzeRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API分析文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.analyzeImport(request);
|
||||
@@ -204,7 +225,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody DocumentImportDtos.PreviewRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API预览文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.previewImport(request);
|
||||
@@ -219,7 +240,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody DocumentImportDtos.CommitRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API提交文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.commitImport(request);
|
||||
@@ -231,7 +252,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody DocumentImportDtos.TaskCreateRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API创建文档导入任务", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.createImportTask(request);
|
||||
@@ -244,7 +265,7 @@ public class PublicKnowledgeShareController {
|
||||
@RequestParam BigInteger taskId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
|
||||
if (result.getData() == null || result.getData().getKnowledgeId() == null
|
||||
@@ -260,7 +281,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody DocumentImportDtos.PreviewRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API预览文档分块", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.previewImportTask(request);
|
||||
@@ -272,7 +293,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody DocumentImportDtos.TaskStartIndexRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API启动文档向量化", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.startIndexTask(request);
|
||||
@@ -284,7 +305,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody DocumentImportDtos.TaskRetryRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API重试文档解析", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.retryParseTask(request);
|
||||
@@ -296,7 +317,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody DocumentImportDtos.TaskRetryRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API重试文档向量化", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.retryIndexTask(request);
|
||||
@@ -314,7 +335,7 @@ public class PublicKnowledgeShareController {
|
||||
@RequestParam(defaultValue = "10") long pageSize,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
requireDocument(documentId, knowledgeId);
|
||||
QueryWrapper wrapper = QueryWrapper.create()
|
||||
@@ -329,11 +350,11 @@ public class PublicKnowledgeShareController {
|
||||
@PostMapping("/documentChunk/update")
|
||||
public Result<?> updateDocumentChunk(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
|
||||
@JsonBody DocumentChunk documentChunk,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_UPDATE.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
DocumentChunk current = requireDocumentChunk(documentChunk.getId(), knowledgeId);
|
||||
boolean success = documentChunkService.updateById(documentChunk);
|
||||
@@ -369,11 +390,11 @@ public class PublicKnowledgeShareController {
|
||||
@PostMapping("/documentChunk/remove")
|
||||
public Result<?> removeDocumentChunk(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@JsonBody("id") BigInteger chunkId,
|
||||
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
|
||||
@JsonBody(value = "id", required = true) BigInteger chunkId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
requireDocumentChunk(chunkId, knowledgeId);
|
||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||
@@ -410,7 +431,7 @@ public class PublicKnowledgeShareController {
|
||||
@RequestParam(defaultValue = "10") long pageSize,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
faqCategoryService.ensureDefaultCategory(knowledgeId);
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
@@ -447,7 +468,7 @@ public class PublicKnowledgeShareController {
|
||||
@RequestParam String id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
FaqItem faqItem = requireFaq(new BigInteger(id), knowledgeId);
|
||||
return Result.ok(faqItem);
|
||||
@@ -462,7 +483,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody FaqItem entity,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), entity.getCollectionId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), entity.getCollectionId(), KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
||||
requireFaqKnowledge(entity.getCollectionId());
|
||||
audit(apiKey, "API新增FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", entity.getCollectionId()));
|
||||
return Result.ok(faqItemService.saveFaqItem(entity));
|
||||
@@ -478,7 +499,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody FaqItem entity,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_UPDATE.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
requireFaq(entity.getId(), knowledgeId);
|
||||
audit(apiKey, "API更新FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "faqId", entity.getId()));
|
||||
@@ -495,7 +516,7 @@ public class PublicKnowledgeShareController {
|
||||
@JsonBody("id") BigInteger id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
requireFaq(id, knowledgeId);
|
||||
audit(apiKey, "API删除FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "faqId", id));
|
||||
@@ -512,7 +533,7 @@ public class PublicKnowledgeShareController {
|
||||
BigInteger collectionId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), collectionId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), collectionId, KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireFaqKnowledge(collectionId);
|
||||
audit(apiKey, "API导入FAQ Excel", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", collectionId));
|
||||
return Result.ok(faqItemService.importFromExcel(collectionId, file));
|
||||
@@ -528,7 +549,7 @@ public class PublicKnowledgeShareController {
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(
|
||||
@@ -550,7 +571,7 @@ public class PublicKnowledgeShareController {
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
String fileName = "faq_export_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".xlsx";
|
||||
response.setContentType("application/octet-stream");
|
||||
@@ -610,6 +631,22 @@ public class PublicKnowledgeShareController {
|
||||
return knowledge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验详情接口中的文档分页参数。
|
||||
*
|
||||
* @param pageNumber 页码
|
||||
* @param pageSize 每页条数
|
||||
* @throws BusinessException 页码小于 1 或每页条数不在 1 到 100 之间时抛出
|
||||
*/
|
||||
private void validateDocumentPage(int pageNumber, int pageSize) {
|
||||
if (pageNumber < 1) {
|
||||
throw new BusinessException("pageNumber 必须大于等于 1");
|
||||
}
|
||||
if (pageSize < 1 || pageSize > 100) {
|
||||
throw new BusinessException("pageSize 必须在 1 到 100 之间");
|
||||
}
|
||||
}
|
||||
|
||||
private Document requireDocument(BigInteger documentId, BigInteger knowledgeId) {
|
||||
Document document = documentService.getById(documentId);
|
||||
if (document == null || document.getCollectionId() == null || document.getCollectionId().compareTo(knowledgeId) != 0) {
|
||||
@@ -642,15 +679,38 @@ public class PublicKnowledgeShareController {
|
||||
knowledgeShareAuditService.log(null, actionName, actionType, actionUrl, payload);
|
||||
}
|
||||
|
||||
private List<KnowledgeSearchResultItem> toKnowledgeSearchResult(List<com.easyagents.core.document.Document> documents) {
|
||||
List<KnowledgeSearchResultItem> result = new java.util.ArrayList<>();
|
||||
private List<PublicKnowledgeSearchResultItem> toKnowledgeSearchResult(
|
||||
List<com.easyagents.core.document.Document> documents
|
||||
) {
|
||||
List<PublicKnowledgeSearchResultItem> result = new java.util.ArrayList<>();
|
||||
for (com.easyagents.core.document.Document document : documents) {
|
||||
KnowledgeSearchResultItem item = new KnowledgeSearchResultItem();
|
||||
PublicKnowledgeSearchResultItem item =
|
||||
new PublicKnowledgeSearchResultItem();
|
||||
item.setContent(document.getContent());
|
||||
String resultType =
|
||||
asString(document.getMetadata("resultType"));
|
||||
item.setResultType(resultType);
|
||||
Object renderMarkdown = document.getMetadata("renderMarkdown");
|
||||
item.setRenderMarkdown(renderMarkdown == null ? null : String.valueOf(renderMarkdown));
|
||||
Object sourceFileName = document.getMetadata("sourceFileName");
|
||||
item.setSourceFileName(sourceFileName == null ? null : String.valueOf(sourceFileName));
|
||||
String documentName =
|
||||
sourceFileName == null ? null : String.valueOf(sourceFileName);
|
||||
item.setSourceFileName(documentName);
|
||||
if (DocumentCollection.TYPE_FAQ.equalsIgnoreCase(resultType)) {
|
||||
item.setFaqId(asBigInteger(document.getMetadata("faqId")));
|
||||
item.setQuestion(asString(document.getMetadata("question")));
|
||||
item.setAnswerText(
|
||||
asString(document.getMetadata("answerText"))
|
||||
);
|
||||
item.setCategoryId(
|
||||
asBigInteger(document.getMetadata("categoryId"))
|
||||
);
|
||||
} else {
|
||||
item.setDocumentName(documentName);
|
||||
item.setDocumentId(
|
||||
asBigInteger(document.getMetadata("documentId"))
|
||||
);
|
||||
}
|
||||
item.setScore(document.getScore());
|
||||
Object hitSource = document.getMetadata("hitSource");
|
||||
item.setHitSource(hitSource == null ? null : String.valueOf(hitSource));
|
||||
@@ -661,6 +721,36 @@ public class PublicKnowledgeShareController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将检索元数据转换为字符串。
|
||||
*
|
||||
* @param value 元数据值
|
||||
* @return 字符串值;原值为空时返回 {@code null}
|
||||
*/
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将检索元数据转换为大整数 ID。
|
||||
*
|
||||
* @param value 元数据值
|
||||
* @return 大整数 ID;原值为空或格式无效时返回 {@code null}
|
||||
*/
|
||||
private BigInteger asBigInteger(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof BigInteger) {
|
||||
return (BigInteger) value;
|
||||
}
|
||||
try {
|
||||
return new BigInteger(String.valueOf(value));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Double asDouble(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
||||
@@ -5,8 +5,12 @@ import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import tech.easyflow.approval.annotation.RequirePublishedAccess;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
@@ -15,6 +19,8 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
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.easyagentsflow.upload.WorkflowApiPreparedUpload;
|
||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
@@ -26,19 +32,30 @@ 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.error.RequestIdContext;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowInfo;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowRunMetadata;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowRunResult;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
|
||||
import tech.easyflow.publicapi.service.WorkflowApiMultipartParameterMapper;
|
||||
import tech.easyflow.publicapi.service.PublicWorkflowTopologyService;
|
||||
import tech.easyflow.publicapi.service.PublicWorkflowStatusSanitizer;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 工作流
|
||||
*/
|
||||
@RequestMapping("/public-api/workflow")
|
||||
@RequestMapping(value = "/public-api/workflow", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
@RestController
|
||||
public class PublicWorkflowController {
|
||||
|
||||
@@ -56,23 +73,32 @@ public class PublicWorkflowController {
|
||||
private WorkflowApiPermissionService workflowApiPermissionService;
|
||||
@Resource
|
||||
private WorkflowExecResultService workflowExecResultService;
|
||||
@Resource
|
||||
private WorkflowApiUploadLifecycleService workflowApiUploadLifecycleService;
|
||||
@Resource
|
||||
private PublicWorkflowTopologyService publicWorkflowTopologyService;
|
||||
@Resource
|
||||
private PublicWorkflowStatusSanitizer publicWorkflowStatusSanitizer;
|
||||
@Resource
|
||||
private WorkflowApiMultipartParameterMapper
|
||||
workflowApiMultipartParameterMapper;
|
||||
|
||||
/**
|
||||
* 通过id或别名获取工作流详情
|
||||
*
|
||||
* @param key id或者别名
|
||||
* @return 工作流详情
|
||||
* @return 工作流安全基础信息
|
||||
*/
|
||||
@GetMapping(value = "/getByIdOrAlias")
|
||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布")
|
||||
public Result<Workflow> getByIdOrAlias(
|
||||
public Result<PublicWorkflowInfo> getByIdOrAlias(
|
||||
@RequestParam
|
||||
@NotBlank(message = "key不能为空") String key,
|
||||
HttpServletRequest request) {
|
||||
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
Workflow workflow = workflowService.getPublishedDetail(key);
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
return Result.ok(workflow);
|
||||
return Result.ok(PublicWorkflowInfo.from(workflow));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,38 +127,117 @@ public class PublicWorkflowController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行工作流 - v2
|
||||
* 使用 JSON 参数异步运行已发布工作流。
|
||||
*
|
||||
* @param metadata 工作流 ID 与运行变量
|
||||
* @param request Servlet 请求
|
||||
* @return 保留执行 ID 字符串并附带工作流拓扑的响应
|
||||
*/
|
||||
@PostMapping("/runAsync")
|
||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
|
||||
public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id,
|
||||
@JsonBody("variables") Map<String, Object> variables,
|
||||
HttpServletRequest request) {
|
||||
@PostMapping(
|
||||
value = "/runAsync",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@RequirePublishedAccess(
|
||||
resourceType = "WORKFLOW",
|
||||
idExpr = "#metadata.id",
|
||||
denyMessage = "工作流尚未发布")
|
||||
public PublicWorkflowRunResult runAsync(
|
||||
@Valid @RequestBody PublicWorkflowRunMetadata metadata,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
if (variables == null) {
|
||||
variables = new HashMap<>();
|
||||
Workflow workflow = loadExecutableWorkflow(metadata.getId());
|
||||
PublicWorkflowTopology topology =
|
||||
publicWorkflowTopologyService.resolve(workflow);
|
||||
Map<String, Object> normalized =
|
||||
workflowRunningParameterResolver.normalizeRuntimeVariables(
|
||||
workflow.getContent(),
|
||||
metadata.getVariables());
|
||||
return executePublishedWorkflow(
|
||||
workflow,
|
||||
normalized,
|
||||
apiKey,
|
||||
topology,
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过单个 multipart 请求上传文件并异步运行工作流。
|
||||
*
|
||||
* <p>{@code metadata} Part 使用 JSON;工作流文件 Part 使用
|
||||
* {@code files.<开始节点文件参数名>},同名 Part 可重复上传多个文件。</p>
|
||||
*
|
||||
* @param metadata 工作流 ID 与普通运行变量
|
||||
* @param multipartRequest multipart 请求
|
||||
* @param request Servlet 请求
|
||||
* @return 保留执行 ID 字符串并附带工作流拓扑的响应
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/runAsync",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RequirePublishedAccess(
|
||||
resourceType = "WORKFLOW",
|
||||
idExpr = "#metadata.id",
|
||||
denyMessage = "工作流尚未发布")
|
||||
public PublicWorkflowRunResult runAsyncMultipart(
|
||||
@Valid
|
||||
@RequestPart("metadata")
|
||||
PublicWorkflowRunMetadata metadata,
|
||||
MultipartHttpServletRequest multipartRequest,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey =
|
||||
workflowApiPermissionService.assertWorkflowApi(
|
||||
request.getHeader("ApiKey"),
|
||||
request.getRequestURI());
|
||||
Workflow workflow = loadExecutableWorkflow(metadata.getId());
|
||||
PublicWorkflowTopology topology =
|
||||
publicWorkflowTopologyService.resolve(workflow);
|
||||
Map<String, List<MultipartFile>> fileParts =
|
||||
workflowApiMultipartParameterMapper.map(
|
||||
multipartRequest.getMultiFileMap());
|
||||
WorkflowApiPreparedUpload preparedUpload =
|
||||
workflowApiUploadLifecycleService.prepare(
|
||||
RequestIdContext.get(request),
|
||||
workflow.getContent(),
|
||||
metadata.getVariables(),
|
||||
fileParts);
|
||||
try {
|
||||
return executePublishedWorkflow(
|
||||
workflow,
|
||||
preparedUpload.getVariables(),
|
||||
apiKey,
|
||||
topology,
|
||||
executeId ->
|
||||
workflowApiUploadLifecycleService.bindExecution(
|
||||
preparedUpload.getUploadId(),
|
||||
executeId));
|
||||
} catch (RuntimeException | Error error) {
|
||||
try {
|
||||
workflowApiUploadLifecycleService.abort(
|
||||
preparedUpload.getUploadId());
|
||||
} catch (RuntimeException cleanupError) {
|
||||
error.addSuppressed(cleanupError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
Workflow workflow = workflowService.getPublishedById(id);
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
variables.put(Constants.LOGIN_USER_KEY, buildApiKeyLoginAccount(apiKey));
|
||||
variables.put(WorkFlowUtil.CREATED_KEY_MEMORY_KEY, WorkFlowUtil.API_KEY);
|
||||
String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
|
||||
return Result.ok(executeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作流运行状态 - v2
|
||||
*/
|
||||
@PostMapping("/getChainStatus")
|
||||
public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId,
|
||||
@JsonBody("nodes") List<NodeInfo> nodes,
|
||||
HttpServletRequest request) {
|
||||
public Result<PublicWorkflowChainStatus> getChainStatus(
|
||||
@JsonBody(value = "executeId") String executeId,
|
||||
@JsonBody("nodes") List<NodeInfo> nodes,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||
ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes);
|
||||
return Result.ok(res);
|
||||
if (res == null) {
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40402,
|
||||
"执行记录不存在、已过期或不可访问");
|
||||
}
|
||||
return Result.ok(publicWorkflowStatusSanitizer.sanitize(res));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,7 +250,14 @@ public class PublicWorkflowController {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||
assertWorkflowExecutionResumable(execResult);
|
||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
||||
if (!chainExecutor.resumeAsyncIfSuspended(
|
||||
executeId,
|
||||
confirmParams)) {
|
||||
throw new BusinessException(
|
||||
409,
|
||||
40901,
|
||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||
}
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@@ -159,7 +271,10 @@ public class PublicWorkflowController {
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||
if (res == null) {
|
||||
return Result.fail(2, "节点配置错误,请检查! ");
|
||||
throw new BusinessException(
|
||||
500,
|
||||
50001,
|
||||
"工作流运行参数配置不可用");
|
||||
}
|
||||
return Result.ok(res);
|
||||
}
|
||||
@@ -180,6 +295,57 @@ public class PublicWorkflowController {
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并校验可执行的已发布工作流。
|
||||
*
|
||||
* @param id 工作流 ID
|
||||
* @return 已发布工作流视图
|
||||
*/
|
||||
private Workflow loadExecutableWorkflow(BigInteger id) {
|
||||
Workflow workflow = workflowService.getPublishedById(id);
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
workflowCheckService.checkOrThrow(
|
||||
workflow.getContent(),
|
||||
WorkflowCheckStage.PRE_EXECUTE,
|
||||
workflow.getId());
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用统一身份和发布快照启动工作流。
|
||||
*
|
||||
* @param workflow 已发布工作流
|
||||
* @param variables 已归一化运行变量
|
||||
* @param apiKey API Key 实体
|
||||
* @param topology 对外公开拓扑
|
||||
* @param beforeStart 首个节点启动前回调
|
||||
* @return 执行响应
|
||||
*/
|
||||
private PublicWorkflowRunResult executePublishedWorkflow(
|
||||
Workflow workflow,
|
||||
Map<String, Object> variables,
|
||||
SysApiKey apiKey,
|
||||
PublicWorkflowTopology topology,
|
||||
Consumer<String> beforeStart) {
|
||||
Map<String, Object> executionVariables =
|
||||
new LinkedHashMap<>();
|
||||
if (variables != null) {
|
||||
executionVariables.putAll(variables);
|
||||
}
|
||||
executionVariables.put(
|
||||
Constants.LOGIN_USER_KEY,
|
||||
buildApiKeyLoginAccount(apiKey));
|
||||
executionVariables.put(
|
||||
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
|
||||
WorkFlowUtil.API_KEY);
|
||||
String executeId = chainExecutor.executeAsync(
|
||||
PublishedWorkflowDefinitionIds.published(
|
||||
workflow.getId().toString()),
|
||||
executionVariables,
|
||||
beforeStart);
|
||||
return PublicWorkflowRunResult.success(executeId, topology);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工作流 Public API 只能访问严格已发布且存在发布快照的工作流。
|
||||
*
|
||||
@@ -188,7 +354,10 @@ public class PublicWorkflowController {
|
||||
private void assertStrictPublishedWorkflow(Workflow workflow) {
|
||||
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
|
||||
throw new BusinessException("工作流尚未发布");
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40401,
|
||||
"工作流不存在或当前不可公开调用");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,17 +370,26 @@ public class PublicWorkflowController {
|
||||
*/
|
||||
private WorkflowExecResult assertApiKeyExecutionOwnership(SysApiKey apiKey, String executeId) {
|
||||
if (executeId == null || executeId.isBlank()) {
|
||||
throw new BusinessException("执行ID不能为空");
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40017,
|
||||
"executeId 不能为空");
|
||||
}
|
||||
WorkflowExecResult execResult = workflowExecResultService.getByExecKey(executeId);
|
||||
if (execResult == null) {
|
||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40402,
|
||||
"工作流执行记录不存在、已过期或不可访问");
|
||||
}
|
||||
if (!WorkFlowUtil.API_KEY.equals(execResult.getCreatedKey())
|
||||
|| apiKey == null
|
||||
|| apiKey.getId() == null
|
||||
|| !String.valueOf(apiKey.getId()).equals(execResult.getCreatedBy())) {
|
||||
throw new BusinessException("无权限访问当前工作流执行记录");
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40402,
|
||||
"工作流执行记录不存在、已过期或不可访问");
|
||||
}
|
||||
return execResult;
|
||||
}
|
||||
@@ -223,12 +401,18 @@ public class PublicWorkflowController {
|
||||
*/
|
||||
private void assertWorkflowExecutionResumable(WorkflowExecResult execResult) {
|
||||
if (execResult == null || execResult.getWorkflowId() == null) {
|
||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40402,
|
||||
"工作流执行记录不存在、已过期或不可访问");
|
||||
}
|
||||
Workflow workflow = workflowService.getById(execResult.getWorkflowId());
|
||||
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
|
||||
throw new BusinessException("工作流已下线或不可恢复执行");
|
||||
throw new BusinessException(
|
||||
409,
|
||||
40901,
|
||||
"工作流已下线或当前执行状态不可恢复");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package tech.easyflow.publicapi.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Public API 可安全返回的机器可读错误详情。
|
||||
*/
|
||||
public class PublicApiErrorDetail implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String requestId;
|
||||
private final String location;
|
||||
private final String field;
|
||||
private final String actual;
|
||||
private final List<String> expected;
|
||||
private final boolean retryable;
|
||||
|
||||
/**
|
||||
* 创建公共错误详情。
|
||||
*
|
||||
* @param requestId 请求关联标识
|
||||
* @param location 错误位置
|
||||
* @param field 错误字段
|
||||
* @param actual 经脱敏和限长的实际值
|
||||
* @param expected 合法值或格式
|
||||
* @param retryable 是否适合直接重试
|
||||
*/
|
||||
public PublicApiErrorDetail(
|
||||
String requestId,
|
||||
String location,
|
||||
String field,
|
||||
String actual,
|
||||
List<String> expected,
|
||||
boolean retryable) {
|
||||
this.requestId = requestId;
|
||||
this.location = location;
|
||||
this.field = field;
|
||||
this.actual = actual;
|
||||
this.expected = expected == null
|
||||
? List.of()
|
||||
: List.copyOf(expected);
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求关联标识。
|
||||
*
|
||||
* @return 请求关联标识
|
||||
*/
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误位置。
|
||||
*
|
||||
* @return 错误位置
|
||||
*/
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误字段。
|
||||
*
|
||||
* @return 错误字段
|
||||
*/
|
||||
public String getField() {
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全实际值。
|
||||
*
|
||||
* @return 实际值
|
||||
*/
|
||||
public String getActual() {
|
||||
return actual;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取期望值。
|
||||
*
|
||||
* @return 不可变期望值列表
|
||||
*/
|
||||
public List<String> getExpected() {
|
||||
return expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否适合直接重试。
|
||||
*
|
||||
* @return 是否可重试
|
||||
*/
|
||||
public boolean isRetryable() {
|
||||
return retryable;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user