Compare commits
204 Commits
1a6ea64e80
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| e938ecda80 | |||
| 431fe8e707 | |||
| 0968e3bfa5 | |||
| 65c85180c2 | |||
| 0f126ad489 | |||
| 1e59063c37 | |||
| 788c8e5459 | |||
| c3673ece46 | |||
| 36acf37976 | |||
| 1dd0ac167c | |||
| 6498f1049a | |||
| 1f37b0a8ae | |||
| c00369f6b9 | |||
| 3e34b65bcb | |||
| 263f5f4b8b | |||
| 6daf805cd0 | |||
| 4386b2a1a6 | |||
| f28e3919ac | |||
| 0cedf85729 | |||
| 155af9989c | |||
| 8c174e5c02 | |||
| 17ef189862 | |||
| e6b2e2798f | |||
| 4823b0741f | |||
| 1c68e3582c | |||
| 619b60600d | |||
| 240b84063a | |||
| ec6e03587a | |||
| 1ccdafdb47 | |||
| e38821e48a | |||
| 3e79e99925 | |||
| 98b34bd4bb | |||
| c27e97bcc2 | |||
| 9068d42f4d | |||
| 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 | |||
| 1a91e16ac9 | |||
| b1706e9589 | |||
| 7cf6c31da3 | |||
| e56f043483 | |||
| 03ad011f64 | |||
| 9c1de1657d | |||
| 999a21e2d9 | |||
| 28797a83cc | |||
| 72c0115b19 | |||
| 7591eb8cda | |||
| ef4528a441 | |||
| e9ddeae33f | |||
| 950148b3f7 | |||
| cb379e071c | |||
| 8b80770960 | |||
| c316eff5be | |||
| 1ea863cb2c | |||
| 0f4d10c43c | |||
| cc3bb9cff0 | |||
| e39f7521e2 | |||
| 1c205c3720 | |||
| 11e595b088 | |||
| 72df00f25b | |||
| 6c3d98eaac | |||
| b7f3ae2854 | |||
| 2907acac95 | |||
| 0947009ee6 | |||
| a186066641 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ build/
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
/.logs/
|
||||
/logs/
|
||||
/agent-workspaces/
|
||||
/.idea/
|
||||
.logs
|
||||
.idea
|
||||
|
||||
120
Dockerfile
120
Dockerfile
@@ -1,4 +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
|
||||
@@ -8,18 +9,129 @@ ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar
|
||||
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
|
||||
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
||||
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
|
||||
ENV NPM_CONFIG_REGISTRY=https://registry.npmjs.org
|
||||
ENV PIP_INDEX_URL=https://pypi.org/simple
|
||||
ENV PYTHONPATH=/opt/easyflow/python-packages
|
||||
ENV NODE_PATH=/app/node_modules
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN useradd --system --create-home easyflow && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 inotify-tools tini && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg && \
|
||||
mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/nodesource.gpg.key && \
|
||||
gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg /tmp/nodesource.gpg.key && \
|
||||
chmod 644 /etc/apt/keyrings/nodesource.gpg && \
|
||||
printf "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main\n" > /etc/apt/sources.list.d/nodesource.list && \
|
||||
rm -f /tmp/nodesource.gpg.key && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
coreutils \
|
||||
diffutils \
|
||||
file \
|
||||
findutils \
|
||||
fontconfig \
|
||||
fonts-liberation2 \
|
||||
fonts-noto-cjk \
|
||||
gawk \
|
||||
grep \
|
||||
gzip \
|
||||
inotify-tools \
|
||||
jq \
|
||||
libdigest-sha-perl \
|
||||
libreoffice-calc \
|
||||
libreoffice-impress \
|
||||
libreoffice-writer \
|
||||
nodejs \
|
||||
pandoc \
|
||||
poppler-utils \
|
||||
procps \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
qpdf \
|
||||
ripgrep \
|
||||
sed \
|
||||
tar \
|
||||
tree \
|
||||
unzip \
|
||||
util-linux \
|
||||
zip \
|
||||
tini && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /etc/pip "${PYTHONPATH}" /opt/easyflow/node-runtime && \
|
||||
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
||||
ln -sf /usr/bin/pip3 /usr/local/bin/pip && \
|
||||
npm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
||||
printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \
|
||||
npm install -g pnpm@10.17.1 && \
|
||||
pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
||||
printf "[global]\nindex-url = %s\n" "${PIP_INDEX_URL}" > /etc/pip.conf
|
||||
|
||||
RUN python3 -m pip install --no-cache-dir --target "${PYTHONPATH}" \
|
||||
python-docx==1.2.0 \
|
||||
python-pptx==1.0.2 \
|
||||
openpyxl==3.1.5 \
|
||||
xlsxwriter==3.2.9 \
|
||||
lxml==6.1.1 \
|
||||
defusedxml==0.7.1 \
|
||||
pillow==12.3.0 \
|
||||
pypdf==6.16.1 \
|
||||
pdfplumber==0.11.10 \
|
||||
pdf2image==1.17.0 \
|
||||
reportlab==5.0.0 \
|
||||
numpy==2.5.2 \
|
||||
pandas==3.0.5 \
|
||||
matplotlib==3.11.1 \
|
||||
seaborn==0.13.2 \
|
||||
pyyaml==6.0.3 \
|
||||
jsonschema==4.26.0 \
|
||||
jinja2==3.1.6 \
|
||||
beautifulsoup4==4.15.0 \
|
||||
pydantic==2.13.4 \
|
||||
python-dateutil==2.9.0.post0 \
|
||||
tabulate==0.10.0 \
|
||||
markdown==3.10.3 \
|
||||
charset-normalizer==3.5.1 \
|
||||
tenacity==9.1.4 && \
|
||||
PYTHONPATH="${PYTHONPATH}" python3 -c "import bs4, defusedxml, docx, jsonschema, lxml, matplotlib, numpy, openpyxl, pandas, pdfplumber, PIL, pptx, pydantic, pypdf, reportlab, seaborn, yaml"
|
||||
|
||||
RUN npm install --prefix /opt/easyflow/node-runtime --omit=dev --no-audit --no-fund --save-exact \
|
||||
docx@9.7.1 \
|
||||
pptxgenjs@4.0.1 \
|
||||
sharp@0.35.3 \
|
||||
pdf-lib@1.17.1 \
|
||||
pdfjs-dist@6.2.108 \
|
||||
zod@4.4.3 \
|
||||
ajv@8.20.0 \
|
||||
yaml@2.9.0 \
|
||||
csv-parse@7.0.2 \
|
||||
csv-stringify@6.8.3 \
|
||||
fast-xml-parser@5.10.1 \
|
||||
marked@18.0.9 \
|
||||
sanitize-html@2.17.7 \
|
||||
cheerio@1.2.0 \
|
||||
dayjs@1.11.21 \
|
||||
handlebars@4.7.9 \
|
||||
jszip@3.10.1 && \
|
||||
ln -s /opt/easyflow/node-runtime/node_modules /app/node_modules && \
|
||||
node -e "for (const name of ['docx','pptxgenjs','sharp','pdf-lib','pdfjs-dist/package.json','zod','ajv','yaml','csv-parse','csv-stringify','fast-xml-parser','marked','sanitize-html','cheerio','dayjs','handlebars','jszip']) require.resolve(name)" && \
|
||||
npm cache clean --force
|
||||
|
||||
RUN fc-cache -f && \
|
||||
mkdir -p /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
|
||||
|
||||
61
config/proguard/common-keep.pro
Normal file
61
config/proguard/common-keep.pro
Normal file
@@ -0,0 +1,61 @@
|
||||
-dontshrink
|
||||
-dontoptimize
|
||||
-dontpreverify
|
||||
-ignorewarnings
|
||||
-dontnote
|
||||
|
||||
-libraryjars <java.home>/jmods/java.base.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.compiler.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.datatransfer.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.desktop.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.instrument.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.logging.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.management.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.naming.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.net.http.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.prefs.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.rmi.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.scripting.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.security.jgss.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.security.sasl.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.sql.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.transaction.xa.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.xml.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.xml.crypto.jmod(!**.jar;!module-info.class)
|
||||
|
||||
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations,RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations,AnnotationDefault,Signature,InnerClasses,EnclosingMethod,Record,SourceFile,LineNumberTable,MethodParameters
|
||||
|
||||
-keep @org.springframework.stereotype.Controller class * { *; }
|
||||
-keep @org.springframework.web.bind.annotation.RestController class * { *; }
|
||||
-keep @org.springframework.context.annotation.Configuration class * { *; }
|
||||
-keep @org.springframework.boot.context.properties.ConfigurationProperties class * { *; }
|
||||
-keep @org.springframework.boot.autoconfigure.SpringBootApplication class * { *; }
|
||||
|
||||
-keep class **.*Controller { *; }
|
||||
-keep class **.*Mapper { *; }
|
||||
-keep class **.mapper.** { *; }
|
||||
-keep class **.entity.** { *; }
|
||||
-keep class **.dto.** { *; }
|
||||
-keep class **.vo.** { *; }
|
||||
-keep class **.model.** { *; }
|
||||
-keep class **.config.** { *; }
|
||||
-keep class **.enums.** { *; }
|
||||
-keep class **.annotation.** { *; }
|
||||
-keep class **.*Exception { *; }
|
||||
-keep class **.*ErrorCode { *; }
|
||||
-keep class **.*Properties { *; }
|
||||
-keep class **.*Config { *; }
|
||||
-keep class **.*Configuration { *; }
|
||||
-keep interface tech.easyflow.** { *; }
|
||||
-keep enum tech.easyflow.** { *; }
|
||||
|
||||
-keepclassmembers class * {
|
||||
@jakarta.annotation.Resource <fields>;
|
||||
@org.springframework.beans.factory.annotation.Autowired <fields>;
|
||||
@org.springframework.beans.factory.annotation.Value <fields>;
|
||||
@org.springframework.context.annotation.Bean <methods>;
|
||||
}
|
||||
|
||||
-keepclassmembers class * {
|
||||
public <init>(...);
|
||||
}
|
||||
28
config/proguard/easyflow-module-ai.pro
Normal file
28
config/proguard/easyflow-module-ai.pro
Normal file
@@ -0,0 +1,28 @@
|
||||
-include ../../config/proguard/common-keep.pro
|
||||
|
||||
-keep class tech.easyflow.ai.chattime.** { *; }
|
||||
-keep class tech.easyflow.ai.constants.** { *; }
|
||||
-keep class tech.easyflow.ai.document.** { *; }
|
||||
-keep class tech.easyflow.ai.documentimport.** { *; }
|
||||
-keep class tech.easyflow.ai.easyagents.** { *; }
|
||||
-keep class tech.easyflow.ai.exception.** { *; }
|
||||
-keep class tech.easyflow.ai.mcp.** { *; }
|
||||
-keep class tech.easyflow.ai.node.** { *; }
|
||||
-keep class tech.easyflow.ai.permission.** { *; }
|
||||
-keep class tech.easyflow.ai.plugin.** { *; }
|
||||
-keep class tech.easyflow.ai.publish.** { *; }
|
||||
-keep class tech.easyflow.ai.rag.** { *; }
|
||||
-keep class tech.easyflow.ai.service.** { *; }
|
||||
-keep class tech.easyflow.ai.support.** { *; }
|
||||
-keep class tech.easyflow.ai.utils.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.service.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.model.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.protocol.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.exception.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.mapper.OpenAiProtocolMapper { *; }
|
||||
-keep class tech.easyflow.ai.invoke.provider.ModelProviderGateway { *; }
|
||||
-keep class tech.easyflow.ai.invoke.provider.UnifiedChatChunkObserver { *; }
|
||||
-keep class tech.easyflow.ai.easyagentsflow.config.** { *; }
|
||||
-keep class tech.easyflow.ai.easyagentsflow.entity.** { *; }
|
||||
-keep class tech.easyflow.ai.easyagentsflow.service.** { *; }
|
||||
-keep class tech.easyflow.ai.easyagentsflow.support.** { *; }
|
||||
5
config/proguard/easyflow-module-autoconfig.pro
Normal file
5
config/proguard/easyflow-module-autoconfig.pro
Normal file
@@ -0,0 +1,5 @@
|
||||
-include ../../config/proguard/common-keep.pro
|
||||
|
||||
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseBootstrapValidator { *; }
|
||||
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseProperties { *; }
|
||||
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseVerificationResult { *; }
|
||||
10
config/proguard/easyflow-module-datacenter.pro
Normal file
10
config/proguard/easyflow-module-datacenter.pro
Normal file
@@ -0,0 +1,10 @@
|
||||
-include ../../config/proguard/common-keep.pro
|
||||
|
||||
-keep class tech.easyflow.datacenter.connector.DatacenterConnector { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.QueryExecutor { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.WriteExecutor { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.MetadataExplorer { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.SourceHealthChecker { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.SqlDialect { *; }
|
||||
-keep class tech.easyflow.datacenter.execution.model.** { *; }
|
||||
-keep class tech.easyflow.datacenter.meta.enums.** { *; }
|
||||
@@ -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 \
|
||||
"$@"
|
||||
@@ -20,6 +20,14 @@
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-ai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<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>
|
||||
@@ -32,6 +40,10 @@
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-job</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-dataspace</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-captcha</artifactId>
|
||||
|
||||
@@ -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("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tech.easyflow.admin.controller.agent;
|
||||
|
||||
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 tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentCategory;
|
||||
import tech.easyflow.agent.mapper.AgentMapper;
|
||||
import tech.easyflow.agent.service.AgentCategoryService;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 分类管理控制器。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agentCategory")
|
||||
@UsePermission(moduleName = "/api/v1/agent")
|
||||
public class AgentCategoryController extends BaseCurdController<AgentCategoryService, AgentCategory> {
|
||||
|
||||
@Resource
|
||||
private AgentMapper agentMapper;
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 分类管理控制器。
|
||||
*
|
||||
* @param service Agent 分类服务
|
||||
*/
|
||||
public AgentCategoryController(AgentCategoryService service) {
|
||||
super(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户可见的 Agent 分类。
|
||||
*
|
||||
* @param entity 查询条件
|
||||
* @param asTree 是否转树
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方式
|
||||
* @return 可见分类列表
|
||||
*/
|
||||
@GetMapping("visibleList")
|
||||
public Result<List<AgentCategory>> visibleList(AgentCategory entity, Boolean asTree, String sortKey, String sortType) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
|
||||
if (access.isRestricted()) {
|
||||
if (access.getCategoryIds().isEmpty()) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
queryWrapper.in("id", access.getCategoryIds());
|
||||
}
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
return Result.ok(service.list(queryWrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分类前校验是否仍被 Agent 使用。
|
||||
*
|
||||
* @param ids 分类 ID 集合
|
||||
* @return 校验结果
|
||||
*/
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
for (Serializable id : ids) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create().eq(Agent::getCategoryId, id);
|
||||
List<Agent> agents = agentMapper.selectListByQuery(queryWrapper);
|
||||
if (agents != null && !agents.isEmpty()) {
|
||||
throw new BusinessException("请先删除该分类下的所有 Agent");
|
||||
}
|
||||
}
|
||||
return super.onRemoveBefore(ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
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;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
import tech.easyflow.agent.publish.AgentPublishAppService;
|
||||
import tech.easyflow.agent.runtime.AgentChatRequest;
|
||||
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
||||
import tech.easyflow.agent.runtime.AgentRunService;
|
||||
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
|
||||
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatusView;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
|
||||
import tech.easyflow.agent.runtime.document.AgentDocumentResource;
|
||||
import tech.easyflow.agent.runtime.document.AgentDocumentService;
|
||||
import tech.easyflow.agent.runtime.document.AgentDocumentUploadView;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
|
||||
import com.easyagents.agent.runtime.media.AgentMediaResource;
|
||||
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
|
||||
import tech.easyflow.agent.service.AgentApprovalStateService;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.agent.service.AgentOptionQueryService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.agent.service.AgentSkillBindingService;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.agent.vo.AgentOptionView;
|
||||
import tech.easyflow.agent.vo.AgentResourceOptionsView;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.log.annotation.LogReporterDisabled;
|
||||
|
||||
import 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;
|
||||
|
||||
/**
|
||||
* Agent 管理端控制器。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agent")
|
||||
public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
|
||||
@Resource
|
||||
private AgentToolBindingService agentToolBindingService;
|
||||
@Resource
|
||||
private AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||
@Resource
|
||||
private AgentSkillBindingService agentSkillBindingService;
|
||||
@Resource
|
||||
private AgentRunService agentRunService;
|
||||
@Resource
|
||||
private AgentPublishAppService agentPublishAppService;
|
||||
@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 控制器。
|
||||
*
|
||||
* @param service Agent 服务
|
||||
*/
|
||||
public AgentController(AgentService service) {
|
||||
super(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 列表关键字搜索字段。
|
||||
*
|
||||
* @return Agent 名称和描述属性
|
||||
*/
|
||||
@Override
|
||||
protected String[] getKeywordSearchProperties() {
|
||||
return new String[]{"name", "description"};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 详情。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return Agent 详情
|
||||
*/
|
||||
@GetMapping("/getDetail")
|
||||
public Result<AgentDetailView> getDetail(BigInteger id) {
|
||||
Agent agent = service.getDetail(id);
|
||||
agentApprovalStateService.fillAgentApprovalState(agent);
|
||||
aiResourceCreatorNameSupport.fillAgentCreatorNames(List.of(agent));
|
||||
return Result.ok(AgentDetailView.from(agent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Agent 草稿。
|
||||
*
|
||||
* @param agent Agent 草稿
|
||||
* @return Agent 详情
|
||||
*/
|
||||
@Override
|
||||
@PostMapping("save")
|
||||
public Result<?> save(@JsonBody Agent agent) {
|
||||
Agent saved = service.saveDraft(agent);
|
||||
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Agent 草稿。
|
||||
*
|
||||
* @param agent Agent 草稿
|
||||
* @return Agent 详情
|
||||
*/
|
||||
@Override
|
||||
@PostMapping("update")
|
||||
public Result<?> update(@JsonBody Agent agent) {
|
||||
Agent saved = service.updateDraft(agent);
|
||||
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子保存 Agent 草稿及本次发生变化的绑定组。
|
||||
*
|
||||
* @param request 设计器保存请求
|
||||
* @return 保存后的 Agent 与本次替换的绑定
|
||||
*/
|
||||
@PostMapping("/draft/save")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<AgentDetailView> saveDraft(@JsonBody(required = true, skipConvertError = false)
|
||||
AgentDraftSaveRequest request) {
|
||||
if (request == null || request.getAgent() == null) {
|
||||
throw new BusinessException("Agent 草稿不能为空");
|
||||
}
|
||||
Agent saved = service.saveDraftGraph(
|
||||
request.getAgent(),
|
||||
request.getToolBindings(),
|
||||
request.isReplaceToolBindings(),
|
||||
request.getKnowledgeBindings(),
|
||||
request.isReplaceKnowledgeBindings(),
|
||||
request.toSkillBindings(),
|
||||
request.isReplaceSkillBindings());
|
||||
return Result.ok(AgentDetailView.from(saved));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Agent 可见范围。
|
||||
*
|
||||
* @param agent 包含 Agent ID 和可见范围的请求数据
|
||||
* @return 更新后的 Agent
|
||||
*/
|
||||
@PostMapping("visibilityScope/update")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<AgentDetailView> updateVisibilityScope(@JsonBody Agent agent) {
|
||||
return Result.ok(AgentDetailView.from(
|
||||
service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 列表。
|
||||
*
|
||||
* @param entity 查询条件
|
||||
* @param asTree 是否转树
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方式
|
||||
* @return Agent 列表
|
||||
*/
|
||||
@Override
|
||||
public Result<List<Agent>> list(Agent entity, Boolean asTree, String sortKey, String sortType) {
|
||||
HttpServletRequest request = currentRequest();
|
||||
QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request);
|
||||
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
|
||||
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 纯文本聊天。
|
||||
*
|
||||
* @param request 聊天请求
|
||||
* @return SSE Emitter
|
||||
*/
|
||||
@PostMapping("chat")
|
||||
@SaCheckPermission("/api/v1/agent/session/query")
|
||||
public SseEmitter chat(@JsonBody AgentChatRequest request) {
|
||||
return agentRunService.chat(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 Agent 草稿态纯文本试用。
|
||||
*
|
||||
* @param request 草稿试用请求
|
||||
* @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 草稿试运行会话。
|
||||
*
|
||||
* @param sessionId 草稿试运行会话 ID
|
||||
* @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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批准工具执行。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @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);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝工具执行。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param reason 拒绝原因
|
||||
* @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) {
|
||||
agentRunService.reject(requestId, resumeToken, reason);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Agent 工具绑定。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param bindings 工具绑定
|
||||
* @return 保存后的启用绑定
|
||||
*/
|
||||
@PostMapping("/toolBinding/update")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<List<AgentToolBinding>> updateToolBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId,
|
||||
@JsonBody("bindings") List<AgentToolBinding> bindings) {
|
||||
return Result.ok(agentToolBindingService.replaceBindings(agentId, bindings));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Agent 知识库绑定。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param bindings 知识库绑定
|
||||
* @return 保存后的启用绑定
|
||||
*/
|
||||
@PostMapping("/knowledgeBinding/update")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<List<AgentKnowledgeBinding>> updateKnowledgeBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId,
|
||||
@JsonBody("bindings") List<AgentKnowledgeBinding> bindings) {
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交发布审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitPublishApproval")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(agentPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交下线审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitOfflineApproval")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(agentPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交删除审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitDeleteApproval")
|
||||
@SaCheckPermission("/api/v1/agent/remove")
|
||||
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(agentPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> remove(Serializable id) {
|
||||
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> removeBatch(Collection<Serializable> ids) {
|
||||
throw new BusinessException("Agent 仅支持通过生命周期审批删除");
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 分页。
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param queryWrapper 查询条件
|
||||
* @return Agent 分页
|
||||
*/
|
||||
@Override
|
||||
protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) {
|
||||
agentVisibilityQueryHelper.applyReadableAccess(queryWrapper);
|
||||
applyPublishedOnlyFilter(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理列表无需返回的配置和发布快照,避免敏感运行配置进入浏览器。
|
||||
*
|
||||
* @param agent Agent 列表项
|
||||
*/
|
||||
private void sanitizeListItem(Agent agent) {
|
||||
if (agent == null) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
return "true".equalsIgnoreCase(request.getParameter("publishedOnly"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 HTTP 请求。
|
||||
*
|
||||
* @return 当前请求,不在 Web 请求上下文中时返回 null
|
||||
*/
|
||||
private HttpServletRequest currentRequest() {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return null;
|
||||
}
|
||||
return attributes.getRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将审批执行结果转换为统一响应。
|
||||
*
|
||||
* @param actionResult 审批动作结果
|
||||
* @param approvalMessage 进入审批时的提示
|
||||
* @param directMessage 直接执行时的提示
|
||||
* @return 审批实例响应
|
||||
*/
|
||||
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
|
||||
String approvalMessage,
|
||||
String directMessage) {
|
||||
return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 管理端会话控制器。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agent/session")
|
||||
@SaCheckPermission("/api/v1/agent/session/query")
|
||||
public class AgentSessionController {
|
||||
|
||||
private final AgentSessionService agentSessionService;
|
||||
private final AgentOptionQueryService agentOptionQueryService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 管理端会话控制器。
|
||||
*
|
||||
* @param agentSessionService Agent 会话服务
|
||||
* @param agentOptionQueryService Agent 安全选项服务
|
||||
*/
|
||||
public AgentSessionController(AgentSessionService agentSessionService,
|
||||
AgentOptionQueryService agentOptionQueryService) {
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Agent 会话 ID。
|
||||
*
|
||||
* @return 会话 ID 字符串
|
||||
*/
|
||||
@GetMapping("/generateId")
|
||||
public Result<String> generateId() {
|
||||
long nextId = new SnowFlakeIDKeyGenerator().nextId();
|
||||
return Result.ok(String.valueOf(nextId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 会话分页。
|
||||
*
|
||||
* @param agentId Agent ID,可为空
|
||||
* @param query 分页参数
|
||||
* @return 会话分页
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public Result<ChatWorkspaceSessionPage> list(BigInteger agentId, ChatPageQuery query) {
|
||||
return Result.ok(agentSessionService.queryCurrentUserSessions(currentAccount(), agentId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 会话详情。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 会话详情
|
||||
*/
|
||||
@GetMapping("/{sessionId}")
|
||||
public Result<ChatWorkspaceSessionDetailView> detail(@PathVariable BigInteger sessionId) {
|
||||
return Result.ok(agentSessionService.getCurrentUserSession(currentAccount(), sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 会话消息。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param query 分页参数
|
||||
* @return 消息分页
|
||||
*/
|
||||
@GetMapping("/{sessionId}/messages")
|
||||
public Result<ChatHistoryPage> messages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
|
||||
return Result.ok(agentSessionService.queryCurrentUserMessages(currentAccount(), sessionId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 完整会话。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 完整会话
|
||||
*/
|
||||
@GetMapping("/{sessionId}/conversation")
|
||||
public Result<ChatWorkspaceConversationView> conversation(@PathVariable BigInteger sessionId) {
|
||||
return Result.ok(agentSessionService.getCurrentUserConversation(currentAccount(), sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名 Agent 会话。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param title 新标题
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/{sessionId}/rename")
|
||||
public Result<Void> rename(@PathVariable BigInteger sessionId,
|
||||
@JsonBody(value = "title", required = true) String title) {
|
||||
agentSessionService.renameCurrentUserSession(currentAccount(), sessionId, title);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Agent 会话临时知识库。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param knowledgeIds 临时知识库 ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/{sessionId}/extraKnowledges")
|
||||
public Result<ChatWorkspaceSessionDetailView> saveExtraKnowledges(@PathVariable BigInteger sessionId,
|
||||
@JsonBody(value = "knowledgeIds") List<BigInteger> knowledgeIds) {
|
||||
return Result.ok(agentSessionService.saveCurrentUserExtraKnowledges(currentAccount(), sessionId, knowledgeIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Agent 会话。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/{sessionId}/delete")
|
||||
public Result<Void> delete(@PathVariable BigInteger sessionId) {
|
||||
agentSessionService.deleteCurrentUserSession(currentAccount(), sessionId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录账号。
|
||||
*
|
||||
* @return 当前登录账号
|
||||
* @throws BusinessException 登录信息失效时抛出
|
||||
*/
|
||||
private LoginAccount currentAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import com.easyagents.core.model.embedding.EmbeddingModel;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
|
||||
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
|
||||
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
|
||||
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
|
||||
import tech.easyflow.ai.entity.DocumentChunk;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.service.DocumentChunkService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.controller.BaseController;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import com.easyagents.core.document.Document;
|
||||
import com.easyagents.core.store.DocumentStore;
|
||||
import com.easyagents.core.store.StoreOptions;
|
||||
import com.easyagents.core.store.StoreResult;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -27,12 +23,8 @@ import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.enums.ResourceLookup;
|
||||
import tech.easyflow.system.permission.resource.RequireResourceAccess;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -43,19 +35,12 @@ import java.util.Map;
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/documentChunk")
|
||||
@UsePermission(moduleName = "/api/v1/documentCollection")
|
||||
public class DocumentChunkController extends BaseCurdController<DocumentChunkService, DocumentChunk> {
|
||||
public class DocumentChunkController extends BaseController {
|
||||
|
||||
@Resource
|
||||
DocumentCollectionService documentCollectionService;
|
||||
|
||||
@Resource
|
||||
ModelService modelService;
|
||||
|
||||
@Resource
|
||||
DocumentChunkService documentChunkService;
|
||||
private final DocumentChunkService documentChunkService;
|
||||
|
||||
public DocumentChunkController(DocumentChunkService service) {
|
||||
super(service);
|
||||
this.documentChunkService = service;
|
||||
}
|
||||
|
||||
@GetMapping("page")
|
||||
@@ -67,9 +52,30 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
||||
idExpr = "#request.getParameter('documentId')",
|
||||
denyMessage = "无权限访问知识库"
|
||||
)
|
||||
@Override
|
||||
public Result<Page<DocumentChunk>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
||||
return super.page(request, sortKey, sortType, pageNumber, pageSize);
|
||||
public Result<Page<DocumentChunk>> page(
|
||||
HttpServletRequest request,
|
||||
Long pageNumber,
|
||||
Long pageSize
|
||||
) {
|
||||
String documentIdValue = request.getParameter("documentId");
|
||||
if (documentIdValue == null || documentIdValue.isBlank()) {
|
||||
return Result.<Page<DocumentChunk>>fail("documentId不能为空", null);
|
||||
}
|
||||
BigInteger documentId;
|
||||
try {
|
||||
documentId = new BigInteger(documentIdValue);
|
||||
} catch (NumberFormatException e) {
|
||||
return Result.<Page<DocumentChunk>>fail("documentId格式不正确", null);
|
||||
}
|
||||
long normalizedPageNumber = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
|
||||
long normalizedPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.eq(DocumentChunk::getDocumentId, documentId)
|
||||
.orderBy("sorting asc");
|
||||
return Result.ok(documentChunkService.page(
|
||||
new Page<>(normalizedPageNumber, normalizedPageSize),
|
||||
query
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("update")
|
||||
@@ -78,39 +84,23 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
||||
resource = CategoryResourceType.KNOWLEDGE,
|
||||
action = ResourceAction.MANAGE,
|
||||
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
|
||||
idExpr = "#documentChunk.id",
|
||||
idExpr = "#request.id",
|
||||
denyMessage = "无权限管理知识库"
|
||||
)
|
||||
public Result<?> update(@JsonBody DocumentChunk documentChunk) {
|
||||
boolean success = service.updateById(documentChunk);
|
||||
if (success){
|
||||
DocumentChunk record = documentChunkService.getById(documentChunk.getId());
|
||||
DocumentCollection knowledge = documentCollectionService.getById(record.getDocumentCollectionId());
|
||||
if (knowledge == null) {
|
||||
return Result.fail(1, "知识库不存在");
|
||||
public Result<?> update(
|
||||
@JsonBody(required = true, skipConvertError = false)
|
||||
DocumentChunkContentUpdateRequest request
|
||||
) {
|
||||
DocumentChunk current = documentChunkService.getById(request.getId());
|
||||
if (current == null) {
|
||||
return Result.fail(1, "记录不存在");
|
||||
}
|
||||
DocumentStore documentStore = knowledge.toDocumentStore();
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
// 设置向量模型
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
Document document = Document.of(documentChunk.getContent());
|
||||
document.setId(documentChunk.getId());
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
metadata.put("keywords", documentChunk.getMetadataKeyWords());
|
||||
metadata.put("questions", documentChunk.getMetadataQuestions());
|
||||
document.setMetadataMap(metadata);
|
||||
StoreResult result = documentStore.update(document, options); // 更新已有记录
|
||||
return Result.ok(result);
|
||||
}
|
||||
return Result.ok(false);
|
||||
DocumentChunk updated = documentChunkService.updateContent(
|
||||
current.getDocumentCollectionId(),
|
||||
current.getId(),
|
||||
request.getContent()
|
||||
);
|
||||
return Result.ok(updated);
|
||||
}
|
||||
|
||||
@PostMapping("removeChunk")
|
||||
@@ -122,32 +112,58 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
||||
idExpr = "#chunkId",
|
||||
denyMessage = "无权限管理知识库"
|
||||
)
|
||||
public Result<?> remove(@JsonBody(value = "id", required = true) BigInteger chunkId) {
|
||||
public Result<?> removeChunk(@JsonBody(value = "id", required = true) BigInteger chunkId) {
|
||||
DocumentChunk docChunk = documentChunkService.getById(chunkId);
|
||||
if (docChunk == null) {
|
||||
return Result.fail(1, "记录不存在");
|
||||
}
|
||||
DocumentCollection knowledge = documentCollectionService.getById(docChunk.getDocumentCollectionId());
|
||||
if (knowledge == null) {
|
||||
return Result.fail(2, "知识库不存在");
|
||||
return Result.ok(documentChunkService.deleteChunk(
|
||||
docChunk.getDocumentCollectionId(),
|
||||
chunkId
|
||||
));
|
||||
}
|
||||
DocumentStore documentStore = knowledge.toDocumentStore();
|
||||
if (documentStore == null) {
|
||||
return Result.fail(3, "知识库没有配置向量库");
|
||||
}
|
||||
// 设置向量模型
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(4, "知识库没有配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
List<BigInteger> deleteList = new ArrayList<>();
|
||||
deleteList.add(chunkId);
|
||||
documentStore.delete(deleteList, options);
|
||||
documentChunkService.removeChunk(knowledge, chunkId);
|
||||
|
||||
return super.remove(chunkId);
|
||||
@PostMapping("syncStatus")
|
||||
@SaCheckPermission("/api/v1/documentCollection/query")
|
||||
@RequireResourceAccess(
|
||||
resource = CategoryResourceType.KNOWLEDGE,
|
||||
action = ResourceAction.READ,
|
||||
lookup = ResourceLookup.DOCUMENT_ID,
|
||||
idExpr = "#request.documentId",
|
||||
denyMessage = "无权限访问知识库"
|
||||
)
|
||||
public Result<List<DocumentChunkSyncStatus>> syncStatus(
|
||||
@JsonBody(required = true, skipConvertError = false)
|
||||
DocumentChunkSyncStatusRequest request
|
||||
) {
|
||||
return Result.ok(documentChunkService.listIndexSyncStatus(
|
||||
null,
|
||||
request.getDocumentId(),
|
||||
request.getIds()
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("retrySync")
|
||||
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||
@RequireResourceAccess(
|
||||
resource = CategoryResourceType.KNOWLEDGE,
|
||||
action = ResourceAction.MANAGE,
|
||||
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
|
||||
idExpr = "#request.id",
|
||||
denyMessage = "无权限管理知识库"
|
||||
)
|
||||
public Result<?> retrySync(
|
||||
@JsonBody(required = true, skipConvertError = false)
|
||||
DocumentChunkSyncRetryRequest request
|
||||
) {
|
||||
DocumentChunk current = documentChunkService.getById(request.getId());
|
||||
if (current == null || request.getIndexSyncVersion() == null) {
|
||||
return Result.fail(1, "记录不存在或同步版本缺失");
|
||||
}
|
||||
return Result.ok(documentChunkService.retryIndexSync(
|
||||
current.getDocumentCollectionId(),
|
||||
current.getId(),
|
||||
request.getIndexSyncVersion()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -102,12 +118,10 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
|
||||
List<Serializable> ids = Collections.singletonList(id);
|
||||
Result<?> result = onRemoveBefore(ids);
|
||||
if (result != null) return result;
|
||||
boolean isSuccess = documentService.removeDoc(id);
|
||||
if (!isSuccess){
|
||||
return Result.ok(false);
|
||||
}
|
||||
boolean success = service.removeById(id);
|
||||
boolean success = documentService.removeDoc(id);
|
||||
if (success) {
|
||||
onRemoveAfter(ids);
|
||||
}
|
||||
return Result.ok(success);
|
||||
}
|
||||
|
||||
@@ -142,13 +156,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 +332,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 图片");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -8,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;
|
||||
|
||||
/**
|
||||
* 控制层。
|
||||
@@ -32,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);
|
||||
@@ -44,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();
|
||||
}
|
||||
|
||||
@@ -64,6 +96,11 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
|
||||
return Result.ok(service.getMcpTools(id));
|
||||
}
|
||||
|
||||
@PostMapping("/check")
|
||||
public Result<McpEnvironmentCheckResult> check(@JsonBody("configJson") String configJson) {
|
||||
return Result.ok(service.checkMcp(configJson));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("pageTools")
|
||||
public Result<Page<Mcp>> pageTools(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,20 +18,24 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.BotPlugin;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.enums.PluginType;
|
||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||
import tech.easyflow.ai.service.BotPluginService;
|
||||
import tech.easyflow.ai.service.PluginService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.AgentResourceReferenceService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
@@ -42,8 +46,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,27 +61,48 @@ 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;
|
||||
@Resource
|
||||
private WorkflowExecResultService workflowExecResultService;
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
@Resource
|
||||
private TinyFlowService tinyFlowService;
|
||||
@Resource
|
||||
private WorkflowCheckService workflowCheckService;
|
||||
@Resource
|
||||
private WorkflowResumeService workflowResumeService;
|
||||
|
||||
@PostMapping("/tool/save")
|
||||
@SaCheckPermission("/api/v1/plugin/save")
|
||||
@@ -91,25 +118,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) {
|
||||
@@ -208,6 +222,7 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
||||
@SaCheckPermission("/api/v1/plugin/query")
|
||||
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
|
||||
@JsonBody("nodes") List<NodeInfo> nodes) {
|
||||
assertPluginTestExecutionOwnership(executeId);
|
||||
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
|
||||
}
|
||||
|
||||
@@ -222,10 +237,33 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
||||
@SaCheckPermission("/api/v1/plugin/query")
|
||||
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
||||
assertPluginTestExecutionOwnership(executeId);
|
||||
workflowResumeService.resume(executeId, confirmParams);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验插件试运行实例由当前登录用户发起。
|
||||
*
|
||||
* @param executeId 执行实例 ID
|
||||
*/
|
||||
private void assertPluginTestExecutionOwnership(String executeId) {
|
||||
if (StrUtil.isBlank(executeId)) {
|
||||
throw new BusinessException("执行ID不能为空");
|
||||
}
|
||||
WorkflowExecResult record = workflowExecResultService.getByExecKey(executeId);
|
||||
if (record == null) {
|
||||
throw new BusinessException(404, 404, "工作流执行记录不存在或已过期");
|
||||
}
|
||||
LoginAccount currentAccount = SaTokenUtil.getLoginAccount();
|
||||
if (currentAccount == null
|
||||
|| currentAccount.getId() == null
|
||||
|| record.getCreatedBy() == null
|
||||
|| !currentAccount.getId().toString().equals(record.getCreatedBy())) {
|
||||
throw new BusinessException(403, 403, "无权限访问当前插件试运行实例");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleArray(JSONArray array) {
|
||||
for (Object o : array) {
|
||||
JSONObject obj = (JSONObject) o;
|
||||
@@ -275,26 +313,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,请先取消关联!");
|
||||
Set<BigInteger> uniquePluginItemIds = new LinkedHashSet<>();
|
||||
try {
|
||||
for (Serializable id : ids) {
|
||||
if (id == null) {
|
||||
throw new NumberFormatException("null");
|
||||
}
|
||||
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())) {
|
||||
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();
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import com.easyagents.core.model.embedding.EmbeddingModel;
|
||||
import com.easyagents.core.store.DocumentStore;
|
||||
import com.easyagents.core.store.StoreOptions;
|
||||
import com.easyagents.core.store.StoreResult;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryColumn;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -21,6 +18,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
||||
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
|
||||
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
|
||||
import tech.easyflow.ai.dto.DocumentChunkDeleteResult;
|
||||
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
|
||||
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
|
||||
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
|
||||
import tech.easyflow.ai.dto.KnowledgeShareLimitedConfigRequest;
|
||||
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
|
||||
import tech.easyflow.ai.entity.Document;
|
||||
@@ -46,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;
|
||||
@@ -59,7 +62,6 @@ import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
@@ -74,7 +76,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",
|
||||
@@ -502,39 +504,27 @@ public class ShareKnowledgeController {
|
||||
@PostMapping("/documentChunk/update")
|
||||
public Result<?> updateDocumentChunk(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody DocumentChunk documentChunk
|
||||
@JsonBody(required = true, skipConvertError = false)
|
||||
DocumentChunkContentUpdateRequest request
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
||||
);
|
||||
DocumentChunk current = documentChunkService.getById(documentChunk.getId());
|
||||
DocumentChunk current = documentChunkService.getById(request.getId());
|
||||
if (current == null || current.getDocumentCollectionId() == null
|
||||
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
boolean success = documentChunkService.updateById(documentChunk);
|
||||
if (success) {
|
||||
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
|
||||
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
|
||||
doc.setId(documentChunk.getId());
|
||||
StoreResult result = documentStore.update(doc, options);
|
||||
DocumentChunk updated = documentChunkService.updateContent(
|
||||
context.getKnowledge().getId(),
|
||||
current.getId(),
|
||||
request.getContent()
|
||||
);
|
||||
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
|
||||
return Result.ok(result);
|
||||
}
|
||||
return Result.ok(false);
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
|
||||
return Result.ok(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,21 +545,50 @@ public class ShareKnowledgeController {
|
||||
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
return Result.fail(1, "记录不存在");
|
||||
}
|
||||
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
}
|
||||
documentStore.setEmbeddingModel(model.toEmbeddingModel());
|
||||
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
|
||||
documentStore.delete(Collections.singletonList(chunkId), options);
|
||||
documentChunkService.removeById(chunkId);
|
||||
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
|
||||
context.getKnowledge().getId(),
|
||||
chunkId
|
||||
);
|
||||
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
|
||||
return Result.ok(true);
|
||||
return Result.ok(removed);
|
||||
}
|
||||
|
||||
@PostMapping("/documentChunk/syncStatus")
|
||||
public Result<List<DocumentChunkSyncStatus>> documentChunkSyncStatus(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody DocumentChunkSyncStatusRequest request
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey, null, KnowledgeShareActionScope.VIEW.name()
|
||||
);
|
||||
Document document = documentService.getById(request.getDocumentId());
|
||||
if (document == null || document.getCollectionId() == null
|
||||
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
throw new BusinessException("文档不存在");
|
||||
}
|
||||
return Result.ok(documentChunkService.listIndexSyncStatus(
|
||||
context.getKnowledge().getId(), request.getDocumentId(), request.getIds()
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/documentChunk/retrySync")
|
||||
public Result<DocumentChunk> retryDocumentChunkSync(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody DocumentChunkSyncRetryRequest request
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey, null, KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
||||
);
|
||||
if (request.getIndexSyncVersion() == null) {
|
||||
throw new BusinessException("同步版本不能为空");
|
||||
}
|
||||
DocumentChunk chunk = documentChunkService.retryIndexSync(
|
||||
context.getKnowledge().getId(), request.getId(), request.getIndexSyncVersion()
|
||||
);
|
||||
audit(context, "重试分享文档 Chunk 索引同步", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
|
||||
return Result.ok(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,9 +678,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)) {
|
||||
@@ -776,7 +801,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;
|
||||
|
||||
/**
|
||||
* 创建工作流节点兼容控制器。
|
||||
*
|
||||
* @param workflowDesignerOptionService 工作流设计器选项服务
|
||||
*/
|
||||
public WorkFlowNodeController(WorkflowDesignerOptionService workflowDesignerOptionService) {
|
||||
this.workflowDesignerOptionService = workflowDesignerOptionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询子流程输入输出定义。
|
||||
*
|
||||
* @param currentId 当前工作流 ID
|
||||
* @param workflowId 子流程 ID
|
||||
* @return 子流程节点配置
|
||||
* @deprecated 请使用 {@code /api/v1/workflow/designer/childWorkflow}
|
||||
*/
|
||||
@Deprecated
|
||||
@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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@SaCheckPermission("/api/v1/workflow/query")
|
||||
public Result<JSONObject> getChainParams(BigInteger currentId, BigInteger workflowId) {
|
||||
return Result.ok(workflowDesignerOptionService.getChildWorkflowNodeData(currentId, workflowId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowShareService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流管理端与分享端的对话运行接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/workflowChat")
|
||||
public class WorkflowChatController {
|
||||
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
@Resource
|
||||
private WorkflowShareService workflowShareService;
|
||||
@Resource
|
||||
private WorkflowCheckService workflowCheckService;
|
||||
@Resource
|
||||
private WorkflowRunningParameterResolver parameterResolver;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private WorkflowChatEventStream eventStream;
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
@Resource
|
||||
private WorkflowResumeService workflowResumeService;
|
||||
@Resource
|
||||
private WorkflowExecResultService execResultService;
|
||||
@Resource
|
||||
private WorkflowExecStepService execStepService;
|
||||
|
||||
/**
|
||||
* 获取工作流的对话运行描述和输入表单。
|
||||
*
|
||||
* @param workflowId 工作流 ID
|
||||
* @param request HTTP 请求
|
||||
* @return 对话运行描述
|
||||
*/
|
||||
@GetMapping("/descriptor")
|
||||
public Result<Map<String, Object>> descriptor(
|
||||
BigInteger workflowId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
Workflow workflow = loadRunnableWorkflow(workflowId, request);
|
||||
workflowCheckService.checkOrThrow(
|
||||
workflow.getContent(),
|
||||
WorkflowCheckStage.PRE_EXECUTE,
|
||||
workflow.getId()
|
||||
);
|
||||
Map<String, Object> descriptor =
|
||||
parameterResolver.buildRunningParametersView(workflow);
|
||||
if (descriptor == null) {
|
||||
throw new BusinessException("工作流输入配置无法解析");
|
||||
}
|
||||
descriptor.put("workflowId", workflow.getId());
|
||||
descriptor.put("publishStatus", workflow.getPublishStatus());
|
||||
descriptor.put("shareable", isStrictlyPublished(workflow));
|
||||
return Result.ok(descriptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作流,并流式返回全部可见输出。
|
||||
*
|
||||
* @param workflowId 工作流 ID
|
||||
* @param variables 工作流运行变量
|
||||
* @param request HTTP 请求
|
||||
* @return 工作流 SSE 事件流
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/run",
|
||||
produces = MediaType.TEXT_EVENT_STREAM_VALUE
|
||||
)
|
||||
public SseEmitter run(
|
||||
@JsonBody(value = "workflowId", required = true)
|
||||
BigInteger workflowId,
|
||||
@JsonBody("variables") Map<String, Object> variables,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
Workflow workflow = loadRunnableWorkflow(workflowId, request);
|
||||
workflowCheckService.checkOrThrow(
|
||||
workflow.getContent(),
|
||||
WorkflowCheckStage.PRE_EXECUTE,
|
||||
workflow.getId()
|
||||
);
|
||||
Map<String, Object> normalizedVariables =
|
||||
parameterResolver.normalizeRuntimeVariables(
|
||||
workflow.getContent(),
|
||||
variables
|
||||
);
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
normalizedVariables.put(Constants.LOGIN_USER_KEY, account);
|
||||
normalizedVariables.put(
|
||||
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
|
||||
hasChatShareKey(request)
|
||||
? WorkFlowUtil.WORKFLOW_CHAT_SHARE
|
||||
: WorkFlowUtil.WORKFLOW_CHAT
|
||||
);
|
||||
return eventStream.start(
|
||||
isStrictlyPublished(workflow)
|
||||
? PublishedWorkflowDefinitionIds.published(workflowId.toString())
|
||||
: workflowId.toString(),
|
||||
normalizedVariables
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前用户发起的工作流执行。
|
||||
*
|
||||
* @param executeId 执行实例 ID
|
||||
* @return 是否完成取消状态转换
|
||||
*/
|
||||
@PostMapping("/cancel")
|
||||
public Result<Boolean> cancel(
|
||||
@JsonBody(value = "executeId", required = true)
|
||||
String executeId
|
||||
) {
|
||||
assertExecutionOwnership(executeId);
|
||||
return Result.ok(chainExecutor.cancel(executeId, "用户已中止运行"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复当前用户发起并等待确认的工作流执行。
|
||||
*
|
||||
* @param executeId 执行实例 ID
|
||||
* @param confirmParams 确认参数
|
||||
* @return 空结果
|
||||
*/
|
||||
@PostMapping("/resume")
|
||||
public Result<Void> resume(
|
||||
@JsonBody(value = "executeId", required = true)
|
||||
String executeId,
|
||||
@JsonBody("confirmParams")
|
||||
Map<String, Object> confirmParams
|
||||
) {
|
||||
assertExecutionOwnership(executeId);
|
||||
workflowResumeService.resume(executeId, confirmParams);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户工作流执行的运行详情。
|
||||
*
|
||||
* @param executeId 执行实例 ID
|
||||
* @return 执行记录和有序节点步骤
|
||||
*/
|
||||
@GetMapping("/execution")
|
||||
public Result<Map<String, Object>> detail(String executeId) {
|
||||
WorkflowExecResult record = assertExecutionOwnership(executeId);
|
||||
List<WorkflowExecStep> steps = execStepService.list(
|
||||
QueryWrapper.create()
|
||||
.eq(WorkflowExecStep::getRecordId, record.getId())
|
||||
.orderBy(WorkflowExecStep::getStartTime, true)
|
||||
);
|
||||
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
|
||||
for (WorkflowExecStep step : steps) {
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("id", step.getId());
|
||||
view.put("attemptKey", step.getExecKey());
|
||||
view.put("nodeId", step.getNodeId());
|
||||
view.put("nodeName", step.getNodeName());
|
||||
view.put("input", step.getInput());
|
||||
view.put("output", step.getOutput());
|
||||
view.put("status", step.getStatus());
|
||||
view.put("errorInfo", step.getErrorInfo());
|
||||
view.put("startTime", step.getStartTime());
|
||||
view.put("endTime", step.getEndTime());
|
||||
view.put("execTime", step.getExecTime());
|
||||
stepViews.add(view);
|
||||
}
|
||||
|
||||
Map<String, Object> recordView = new LinkedHashMap<>();
|
||||
recordView.put("executeId", record.getExecKey());
|
||||
recordView.put("workflowId", record.getWorkflowId());
|
||||
recordView.put("title", record.getTitle());
|
||||
recordView.put("status", record.getStatus());
|
||||
recordView.put("input", record.getInput());
|
||||
recordView.put("output", record.getOutput());
|
||||
recordView.put("errorInfo", record.getErrorInfo());
|
||||
recordView.put("startTime", record.getStartTime());
|
||||
recordView.put("endTime", record.getEndTime());
|
||||
recordView.put("execTime", record.getExecTime());
|
||||
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("record", recordView);
|
||||
detail.put("steps", stepViews);
|
||||
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;
|
||||
@@ -23,13 +31,13 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.publish.WorkflowPublishAppService;
|
||||
import tech.easyflow.ai.service.AiResourceApprovalStateService;
|
||||
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.ai.service.BotWorkflowService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
@@ -39,6 +47,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 +62,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 +81,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
@Resource
|
||||
private SysApiKeyService apiKeyService;
|
||||
@Resource
|
||||
private BotWorkflowService botWorkflowService;
|
||||
private AgentToolBindingService agentToolBindingService;
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
@Resource
|
||||
@@ -85,6 +95,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
@Resource
|
||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||
@Resource
|
||||
private WorkflowResumeService workflowResumeService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
||||
@@ -94,12 +106,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 +251,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 +287,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 +327,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
)
|
||||
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
||||
workflowResumeService.resume(executeId, confirmParams);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@@ -245,6 +379,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 +391,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 +441,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 +483,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 +557,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 +690,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 +721,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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.admin.service.ai.WorkflowPublicChatService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.vo.UploadResVo;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流对话匿名分享接口。
|
||||
*/
|
||||
@SaIgnore
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/workflowChat/public")
|
||||
public class WorkflowPublicChatController {
|
||||
|
||||
private final WorkflowPublicChatService publicChatService;
|
||||
|
||||
public WorkflowPublicChatController(
|
||||
WorkflowPublicChatService publicChatService
|
||||
) {
|
||||
this.publicChatService = publicChatService;
|
||||
}
|
||||
|
||||
@GetMapping("/descriptor")
|
||||
public Result<Map<String, Object>> descriptor(HttpServletRequest request) {
|
||||
return Result.ok(publicChatService.descriptor(
|
||||
shareKey(request),
|
||||
visitorId(request)
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter run(
|
||||
@JsonBody("variables") Map<String, Object> variables,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return publicChatService.run(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
variables
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/execution")
|
||||
public Result<Map<String, Object>> execution(
|
||||
@RequestParam String executeId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return Result.ok(publicChatService.detail(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
executeId
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/cancel")
|
||||
public Result<Boolean> cancel(
|
||||
@JsonBody(value = "executeId", required = true) String executeId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return Result.ok(publicChatService.cancel(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
executeId
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/resume")
|
||||
public Result<Void> resume(
|
||||
@JsonBody(value = "executeId", required = true) String executeId,
|
||||
@JsonBody("confirmParams") Map<String, Object> confirmParams,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
publicChatService.resume(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
executeId,
|
||||
confirmParams
|
||||
);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/upload", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Result<UploadResVo> upload(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("parameterName") String parameterName,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return Result.ok(publicChatService.upload(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
parameterName,
|
||||
file
|
||||
));
|
||||
}
|
||||
|
||||
private String shareKey(HttpServletRequest request) {
|
||||
return request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER);
|
||||
}
|
||||
|
||||
private String visitorId(HttpServletRequest request) {
|
||||
return request.getHeader(WorkflowSharePolicy.CHAT_VISITOR_HEADER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowShareService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.ai.vo.WorkflowShareCreateResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.util.RequestUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流分享管理接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/workflowShare")
|
||||
public class WorkflowShareController {
|
||||
|
||||
@Resource
|
||||
private WorkflowShareService workflowShareService;
|
||||
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
|
||||
@Resource
|
||||
private KnowledgeShareAuditService knowledgeShareAuditService;
|
||||
|
||||
/**
|
||||
* 创建或刷新已发布工作流的对话分享链接。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @param workflowId 工作流 ID
|
||||
* @return 分享创建结果
|
||||
*/
|
||||
@PostMapping("/url/create")
|
||||
@SaCheckPermission("/api/v1/workflow/save")
|
||||
public Result<WorkflowShareCreateResult> createUrlShare(
|
||||
HttpServletRequest request,
|
||||
@JsonBody("workflowId") BigInteger workflowId
|
||||
) {
|
||||
Workflow workflow = workflowService.getById(workflowId);
|
||||
if (workflow == null) {
|
||||
throw new BusinessException("工作流不存在");
|
||||
}
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.WORKFLOW,
|
||||
workflow,
|
||||
ResourceAction.MANAGE,
|
||||
"无权限分享工作流"
|
||||
);
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
WorkflowShareCreateResult result = workflowShareService.createChatShare(
|
||||
workflowId,
|
||||
loginAccount.getTenantId(),
|
||||
loginAccount.getDeptId(),
|
||||
loginAccount.getId(),
|
||||
buildShareBaseUrl(request)
|
||||
);
|
||||
knowledgeShareAuditService.log(
|
||||
loginAccount.getId(),
|
||||
"创建工作流对话分享",
|
||||
"WORKFLOW_CHAT_SHARE_CREATE",
|
||||
request.getRequestURI(),
|
||||
Map.of("workflowId", workflowId, "shareId", result.getId())
|
||||
);
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前对话分享指向的工作流。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @return 工作流标识
|
||||
*/
|
||||
@GetMapping("/resolve")
|
||||
@SaIgnore
|
||||
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
|
||||
WorkflowShare share = workflowShareService.resolvePublicChatShare(
|
||||
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
|
||||
);
|
||||
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据管理端来源构建工作流分享基础 URL。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @return 工作流独立分享页 URL
|
||||
*/
|
||||
private String buildShareBaseUrl(HttpServletRequest request) {
|
||||
String refererBaseUrl = extractFrontendBaseUrl(RequestUtil.getReferer(request));
|
||||
if (refererBaseUrl != null) {
|
||||
return refererBaseUrl + "/share/workflow";
|
||||
}
|
||||
|
||||
String forwardedOrigin = buildForwardedOrigin(request);
|
||||
if (forwardedOrigin != null) {
|
||||
return forwardedOrigin
|
||||
+ normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix")))
|
||||
+ "/share/workflow";
|
||||
}
|
||||
|
||||
String origin = normalizeOrigin(request.getHeader("Origin"));
|
||||
if (origin != null) {
|
||||
return origin + normalizeBasePath(request.getContextPath()) + "/share/workflow";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(request.getScheme()).append("://").append(request.getServerName());
|
||||
if (request.getServerPort() != 80 && request.getServerPort() != 443) {
|
||||
builder.append(':').append(request.getServerPort());
|
||||
}
|
||||
return builder.append(normalizeBasePath(request.getContextPath()))
|
||||
.append("/share/workflow")
|
||||
.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从来源地址提取前端 origin 与部署基路径。
|
||||
*
|
||||
* @param sourceUrl 来源地址
|
||||
* @return origin 与部署基路径,无法解析时返回 {@code null}
|
||||
*/
|
||||
private String extractFrontendBaseUrl(String sourceUrl) {
|
||||
if (sourceUrl == null || sourceUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(sourceUrl.trim());
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
return null;
|
||||
}
|
||||
String origin = extractOrigin(sourceUrl);
|
||||
return origin == null ? null : origin + inferFrontendBasePath(uri.getPath());
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从前端页面路径推断部署基路径。
|
||||
*
|
||||
* @param path 页面路径
|
||||
* @return 规范化后的部署基路径
|
||||
*/
|
||||
private String inferFrontendBasePath(String path) {
|
||||
if (path == null || path.isBlank() || "/".equals(path)) {
|
||||
return "";
|
||||
}
|
||||
for (String marker : new String[]{"/ai/", "/auth/", "/share/"}) {
|
||||
int markerIndex = path.indexOf(marker);
|
||||
if (markerIndex > 0) {
|
||||
return normalizeBasePath(path.substring(0, markerIndex));
|
||||
}
|
||||
if (markerIndex == 0) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化部署基路径。
|
||||
*
|
||||
* @param basePath 原始基路径
|
||||
* @return 无尾斜杠的基路径
|
||||
*/
|
||||
private String normalizeBasePath(String basePath) {
|
||||
if (basePath == null || basePath.isBlank() || "/".equals(basePath.trim())) {
|
||||
return "";
|
||||
}
|
||||
String normalized = basePath.trim();
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
while (normalized.endsWith("/") && normalized.length() > 1) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按反向代理头构建外部访问 origin。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @return 外部 origin,缺少代理头时返回 {@code null}
|
||||
*/
|
||||
private String buildForwardedOrigin(HttpServletRequest request) {
|
||||
String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto"));
|
||||
String host = firstHeaderValue(request.getHeader("X-Forwarded-Host"));
|
||||
if (proto == null || host == null) {
|
||||
return null;
|
||||
}
|
||||
return normalizeOrigin(proto + "://" + host);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 提取 origin。
|
||||
*
|
||||
* @param url 完整 URL
|
||||
* @return origin,无法解析时返回 {@code null}
|
||||
*/
|
||||
private String extractOrigin(String url) {
|
||||
if (url == null || url.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(url.trim());
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(uri.getScheme()).append("://").append(uri.getHost());
|
||||
if (uri.getPort() != -1 && uri.getPort() != 80 && uri.getPort() != 443) {
|
||||
builder.append(':').append(uri.getPort());
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 origin。
|
||||
*
|
||||
* @param origin 原始 origin
|
||||
* @return 规范化结果
|
||||
*/
|
||||
private String normalizeOrigin(String origin) {
|
||||
return extractOrigin(origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取代理头的首个有效值。
|
||||
*
|
||||
* @param value 原始请求头
|
||||
* @return 首个有效值
|
||||
*/
|
||||
private String firstHeaderValue(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
int commaIndex = value.indexOf(',');
|
||||
String normalized = commaIndex >= 0 ? value.substring(0, commaIndex) : value;
|
||||
normalized = normalized.trim();
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package tech.easyflow.admin.controller.ai.support;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
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;
|
||||
@@ -39,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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充知识库创建人名称。
|
||||
*
|
||||
@@ -66,6 +60,55 @@ public class AiResourceCreatorNameSupport {
|
||||
fillCreatorNames(plugins, Plugin::getCreatedBy, Plugin::setCreatedByName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充 Agent 创建人名称。
|
||||
*
|
||||
* @param agents Agent 集合
|
||||
*/
|
||||
public void fillAgentCreatorNames(Collection<Agent> agents) {
|
||||
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 + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的创建人名称填充逻辑。
|
||||
*
|
||||
@@ -74,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")
|
||||
|
||||
@@ -24,6 +24,11 @@ import java.util.List;
|
||||
@RequestMapping("/api/v1/datacenterDataset")
|
||||
public class DatacenterDatasetController {
|
||||
|
||||
/** 对外 Schema 接口的默认字段页码。 */
|
||||
private static final long DEFAULT_FIELD_PAGE_NUMBER = 1L;
|
||||
/** 对外 Schema 接口的默认字段页大小。 */
|
||||
private static final long DEFAULT_FIELD_PAGE_SIZE = 200L;
|
||||
|
||||
@Resource
|
||||
private DatacenterDatasetQueryService queryService;
|
||||
@Resource
|
||||
@@ -32,13 +37,18 @@ public class DatacenterDatasetController {
|
||||
@PostMapping("/queryPage")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<Page<Row>> queryPage(@RequestBody DatacenterQueryRequest request) {
|
||||
return Result.ok(queryService.queryPage(request));
|
||||
return Result.ok(queryService.queryPage(
|
||||
request, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
@GetMapping("/schema")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<DatacenterSchemaResponse> schema(DatasetRef datasetRef) {
|
||||
return Result.ok(queryService.getSchema(datasetRef));
|
||||
public Result<DatacenterSchemaResponse> schema(
|
||||
DatasetRef datasetRef,
|
||||
@RequestParam(defaultValue = "1") Long fieldPageNumber,
|
||||
@RequestParam(defaultValue = "200") Long fieldPageSize) {
|
||||
return Result.ok(queryService.getSchema(
|
||||
datasetRef, fieldPageNumber, fieldPageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/managedTables")
|
||||
@@ -63,6 +73,13 @@ public class DatacenterDatasetController {
|
||||
request == null ? List.of() : request.getFields(),
|
||||
account
|
||||
);
|
||||
return Result.ok(queryService.getSchema(registryService.resolveDatasetRef(table.getId())));
|
||||
return Result.ok(queryService.getSchema(
|
||||
registryService.resolveDatasetRef(table.getId()),
|
||||
request == null || request.getFieldPageNumber() == null
|
||||
? DEFAULT_FIELD_PAGE_NUMBER
|
||||
: request.getFieldPageNumber(),
|
||||
request == null || request.getFieldPageSize() == null
|
||||
? DEFAULT_FIELD_PAGE_SIZE
|
||||
: request.getFieldPageSize()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package tech.easyflow.admin.controller.datacenter;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleRequest;
|
||||
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleResult;
|
||||
import tech.easyflow.datacenter.execution.model.DatacenterSqlCancelRequest;
|
||||
import tech.easyflow.datacenter.federation.DatacenterFederationQueryService;
|
||||
import tech.easyflow.datacenter.federation.DatacenterFederationQueryCancellationService;
|
||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
|
||||
|
||||
/**
|
||||
* 数据中枢管理端只读 SQL 控制台。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/datacenterQuery")
|
||||
public class DatacenterQueryController {
|
||||
|
||||
private final DatacenterDatasetRegistryService registryService;
|
||||
private final DatacenterFederationQueryService queryService;
|
||||
private final DatacenterFederationQueryCancellationService cancellationService;
|
||||
|
||||
/**
|
||||
* 创建查询 Controller。
|
||||
*
|
||||
* @param registryService 数据集注册服务
|
||||
* @param queryService Federation 查询服务
|
||||
* @param cancellationService 跨节点查询取消服务
|
||||
*/
|
||||
public DatacenterQueryController(
|
||||
DatacenterDatasetRegistryService registryService,
|
||||
DatacenterFederationQueryService queryService,
|
||||
DatacenterFederationQueryCancellationService cancellationService) {
|
||||
this.registryService = registryService;
|
||||
this.queryService = queryService;
|
||||
this.cancellationService = cancellationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一条受 Calcite 与业务 Policy 校验的只读 SQL。
|
||||
*
|
||||
* @param request 查询请求
|
||||
* @return 有界查询结果
|
||||
*/
|
||||
@PostMapping("/execute")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<DatacenterSqlConsoleResult> execute(
|
||||
@RequestBody DatacenterSqlConsoleRequest request) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
DatacenterSource source = registryService.getSourceRequired(
|
||||
request == null ? null : request.sourceId());
|
||||
return Result.ok(queryService.execute(
|
||||
source,
|
||||
request == null ? null : request.sql(),
|
||||
java.util.List.of(),
|
||||
request == null ? null : request.maxRows(),
|
||||
account,
|
||||
"MANUAL",
|
||||
account == null || account.getId() == null
|
||||
? null : account.getId().toString(),
|
||||
request == null ? null : request.queryId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前租户在任一节点执行的 SQL 查询。
|
||||
*
|
||||
* @param request 取消请求
|
||||
* @return 是否在本地或集群中接受取消提示
|
||||
*/
|
||||
@PostMapping("/cancel")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<Boolean> cancel(@RequestBody DatacenterSqlCancelRequest request) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(cancellationService.cancel(
|
||||
request == null ? null : request.queryId(), account));
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,16 @@ import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||
import tech.easyflow.datacenter.execution.model.DatacenterConnectionTestResult;
|
||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterBatchRegisterRequest;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterMetadataPage;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterRemoveSourceRequest;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterSourceActivateRequest;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateMetadataRequest;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateCatalogRequest;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterSourceDraftRequest;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterSourceReconfigureRequest;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterSourceView;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta;
|
||||
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
|
||||
|
||||
@@ -19,6 +25,9 @@ import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 数据源绑定、生命周期与元数据浏览接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/datacenterSource")
|
||||
public class DatacenterSourceController {
|
||||
@@ -26,23 +35,100 @@ public class DatacenterSourceController {
|
||||
@Resource
|
||||
private DatacenterSourceService sourceService;
|
||||
|
||||
@PostMapping("/testConnection")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<DatacenterConnectionTestResult> testConnection(@RequestBody DatacenterSource source) {
|
||||
@PostMapping("/draft")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterSourceView> saveDraft(@RequestBody DatacenterSourceDraftRequest request) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(sourceService.testConnection(source, account));
|
||||
return Result.ok(sourceService.saveDraft(request, account));
|
||||
}
|
||||
|
||||
@PostMapping("/save")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterSource> save(@RequestBody DatacenterSource source) {
|
||||
@PostMapping("/{sourceId}/probe")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<DatacenterConnectionTestResult> probe(@PathVariable BigInteger sourceId) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(sourceService.saveSource(source, account));
|
||||
return Result.ok(sourceService.probe(sourceId, account));
|
||||
}
|
||||
|
||||
@PostMapping("/activate")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterSourceView> activate(@RequestBody DatacenterSourceActivateRequest request) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(sourceService.activate(request, account));
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测活动数据源的未发布候选配置。
|
||||
*
|
||||
* @param request 候选连接配置
|
||||
* @return 连接探测结果
|
||||
*/
|
||||
@PostMapping("/candidate/probe")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterConnectionTestResult> probeCandidate(
|
||||
@RequestBody DatacenterSourceDraftRequest request) {
|
||||
return Result.ok(sourceService.probeCandidate(
|
||||
request, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览活动数据源候选配置可访问的命名空间。
|
||||
*
|
||||
* @param request 候选连接配置
|
||||
* @return 命名空间列表
|
||||
*/
|
||||
@PostMapping("/candidate/catalogs")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<List<DatacenterCatalogMeta>> candidateCatalogs(
|
||||
@RequestBody DatacenterSourceDraftRequest request) {
|
||||
return Result.ok(sourceService.listCandidateCatalogs(
|
||||
request, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页浏览候选配置可访问的命名空间。
|
||||
*
|
||||
* @param request 候选配置和分页条件
|
||||
* @return 有界命名空间列表
|
||||
*/
|
||||
@PostMapping("/candidate/catalogs/page")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> candidateCatalogsPage(
|
||||
@RequestBody DatacenterSourceCandidateCatalogRequest request) {
|
||||
return Result.ok(sourceService.listCandidateCatalogsPage(
|
||||
request, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页浏览活动数据源候选配置可访问的表。
|
||||
*
|
||||
* @param request 候选配置与分页条件
|
||||
* @return 有界表列表
|
||||
*/
|
||||
@PostMapping("/candidate/tables")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterMetadataPage<DatacenterTable>> candidateTables(
|
||||
@RequestBody DatacenterSourceCandidateMetadataRequest request) {
|
||||
return Result.ok(sourceService.listCandidateTables(
|
||||
request, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 原地发布活动数据源的新连接配置和纳管范围。
|
||||
*
|
||||
* @param request 重配置请求
|
||||
* @return 发布后的数据源视图
|
||||
*/
|
||||
@PostMapping("/reconfigure")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterSourceView> reconfigure(
|
||||
@RequestBody DatacenterSourceReconfigureRequest request) {
|
||||
return Result.ok(sourceService.reconfigure(
|
||||
request, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<Page<DatacenterSource>> page(Long pageNumber, Long pageSize) {
|
||||
public Result<Page<DatacenterSourceView>> page(Long pageNumber, Long pageSize) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(sourceService.pageSources(pageNumber, pageSize, account));
|
||||
}
|
||||
@@ -54,19 +140,53 @@ public class DatacenterSourceController {
|
||||
return Result.ok(sourceService.listCatalogs(sourceId, account));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页浏览当前数据源的命名空间。
|
||||
*
|
||||
* @param sourceId 数据源 ID
|
||||
* @param keyword 名称搜索词
|
||||
* @param pageNumber 页码
|
||||
* @param pageSize 每页大小
|
||||
* @return 有界命名空间列表
|
||||
*/
|
||||
@GetMapping("/catalogs/page")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> catalogsPage(
|
||||
BigInteger sourceId,
|
||||
String keyword,
|
||||
Long pageNumber,
|
||||
Long pageSize) {
|
||||
return Result.ok(sourceService.listCatalogsPage(
|
||||
sourceId, keyword, pageNumber, pageSize,
|
||||
SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
@GetMapping("/tables")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<List<DatacenterTable>> tables(BigInteger sourceId, String catalogName) {
|
||||
public Result<DatacenterMetadataPage<DatacenterTable>> tables(
|
||||
BigInteger sourceId,
|
||||
String catalogName,
|
||||
String keyword,
|
||||
Long pageNumber,
|
||||
Long pageSize) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(sourceService.listTables(sourceId, catalogName, account));
|
||||
return Result.ok(sourceService.listTables(
|
||||
sourceId, catalogName, keyword, pageNumber, pageSize, account));
|
||||
}
|
||||
|
||||
@GetMapping("/tableDetail")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||
public Result<DatacenterTableDetailMeta> tableDetail(BigInteger sourceId, String catalogName, String tableName,
|
||||
@RequestParam(defaultValue = "false") boolean register) {
|
||||
public Result<DatacenterTableDetailMeta> tableDetail(
|
||||
BigInteger sourceId,
|
||||
String catalogName,
|
||||
String tableName,
|
||||
@RequestParam(defaultValue = "false") boolean register,
|
||||
Long fieldPageNumber,
|
||||
Long fieldPageSize) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(sourceService.getTableDetail(sourceId, catalogName, tableName, register, account));
|
||||
return Result.ok(sourceService.getTableDetail(
|
||||
sourceId, catalogName, tableName, register,
|
||||
fieldPageNumber, fieldPageSize, account));
|
||||
}
|
||||
|
||||
@PostMapping("/registerBatch")
|
||||
@@ -83,4 +203,44 @@ public class DatacenterSourceController {
|
||||
sourceService.removeSource(request == null ? null : request.getSourceId(), account);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用活动数据源。
|
||||
*
|
||||
* @param sourceId 数据源 ID
|
||||
* @return 停用后的数据源视图
|
||||
*/
|
||||
@PostMapping("/{sourceId}/disable")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterSourceView> disable(@PathVariable BigInteger sourceId) {
|
||||
return Result.ok(sourceService.disable(
|
||||
sourceId, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新启用已停用数据源。
|
||||
*
|
||||
* @param sourceId 数据源 ID
|
||||
* @return 启用后的数据源视图
|
||||
*/
|
||||
@PostMapping("/{sourceId}/enable")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterSourceView> enable(@PathVariable BigInteger sourceId) {
|
||||
return Result.ok(sourceService.enable(
|
||||
sourceId, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新已纳管对象的 JDBC 元数据观测状态。
|
||||
*
|
||||
* @param sourceId 数据源 ID
|
||||
* @return 刷新后的数据源视图
|
||||
*/
|
||||
@PostMapping("/{sourceId}/metadata/refresh")
|
||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||
public Result<DatacenterSourceView> refreshMetadata(
|
||||
@PathVariable BigInteger sourceId) {
|
||||
return Result.ok(sourceService.refreshMetadata(
|
||||
sourceId, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package tech.easyflow.admin.controller.dataspace;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.dataspace.model.ConnectionDefinition;
|
||||
import tech.easyflow.dataspace.model.ConnectionView;
|
||||
import tech.easyflow.dataspace.model.ObjectView;
|
||||
import tech.easyflow.dataspace.provider.DataspaceProbe;
|
||||
import tech.easyflow.dataspace.service.DataspaceConnectionService;
|
||||
|
||||
/**
|
||||
* 数据空间物理连接管理端 API。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dataspaceConnection")
|
||||
public class DataspaceConnectionController {
|
||||
|
||||
private final DataspaceConnectionService connectionService;
|
||||
|
||||
/**
|
||||
* 创建连接控制器。
|
||||
*
|
||||
* @param connectionService 连接服务
|
||||
*/
|
||||
public DataspaceConnectionController(DataspaceConnectionService connectionService) {
|
||||
this.connectionService = connectionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前租户连接列表。
|
||||
*
|
||||
* @param keyword 搜索关键词
|
||||
* @return 连接列表
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||
public Result<List<ConnectionView>> list(String keyword) {
|
||||
return Result.ok(connectionService.list(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接详情。
|
||||
*
|
||||
* @param id 连接 ID
|
||||
* @return 连接详情
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||
public Result<ConnectionView> detail(BigInteger id) {
|
||||
return Result.ok(connectionService.detail(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试候选或已保存连接。
|
||||
*
|
||||
* @param definition 连接定义
|
||||
* @return 测试结果
|
||||
*/
|
||||
@PostMapping("/test")
|
||||
@SaCheckPermission("/api/v1/dataspaceConnection/test")
|
||||
public Result<DataspaceProbe> test(
|
||||
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
|
||||
return Result.ok(connectionService.test(definition));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新连接。
|
||||
*
|
||||
* @param definition 连接定义
|
||||
* @return 保存后的连接
|
||||
*/
|
||||
@PostMapping("/save")
|
||||
@SaCheckPermission("/api/v1/dataspaceConnection/save")
|
||||
public Result<ConnectionView> save(
|
||||
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
|
||||
return Result.ok(connectionService.save(definition));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用或禁用连接。
|
||||
*
|
||||
* @param request 状态变更请求
|
||||
* @return 变更后的连接
|
||||
*/
|
||||
@PostMapping("/status")
|
||||
@SaCheckPermission("/api/v1/dataspaceConnection/save")
|
||||
public Result<ConnectionView> status(
|
||||
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
|
||||
if (request == null || request.enabled() == null) {
|
||||
throw new BusinessException("连接状态不能为空");
|
||||
}
|
||||
return Result.ok(connectionService.setEnabled(request.id(), request.enabled()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前连接的对象树数据。
|
||||
*
|
||||
* @param connectionId 连接 ID
|
||||
* @param keyword Schema 或表名关键词
|
||||
* @return 对象列表
|
||||
*/
|
||||
@GetMapping("/objects")
|
||||
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||
public Result<List<ObjectView>> objects(BigInteger connectionId, String keyword) {
|
||||
return Result.ok(connectionService.objects(connectionId, keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新连接元数据。
|
||||
*
|
||||
* @param request 刷新请求
|
||||
* @return 新 revision 对象列表
|
||||
*/
|
||||
@PostMapping("/refreshMetadata")
|
||||
@SaCheckPermission("/api/v1/dataspaceConnection/metadata")
|
||||
public Result<List<ObjectView>> refreshMetadata(
|
||||
@JsonBody(required = true, skipConvertError = false) RefreshRequest request) {
|
||||
return Result.ok(connectionService.refreshMetadata(
|
||||
request.connectionId(), request.expectedRevision()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除未被引用的连接。
|
||||
*
|
||||
* @param id 连接 ID
|
||||
* @return 成功结果
|
||||
*/
|
||||
@PostMapping("/remove")
|
||||
@SaCheckPermission("/api/v1/dataspaceConnection/remove")
|
||||
public Result<Void> remove(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||
connectionService.remove(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据刷新请求。
|
||||
*
|
||||
* @param connectionId 连接 ID
|
||||
* @param expectedRevision 期望 revision
|
||||
*/
|
||||
public record RefreshRequest(BigInteger connectionId, long expectedRevision) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态变更请求。
|
||||
*
|
||||
* @param id 连接 ID
|
||||
* @param enabled 是否启用
|
||||
*/
|
||||
public record StatusRequest(BigInteger id, Boolean enabled) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package tech.easyflow.admin.controller.dataspace;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.dataspace.model.DataspaceDefinition;
|
||||
import tech.easyflow.dataspace.model.DataspaceSummary;
|
||||
import tech.easyflow.dataspace.model.DataspaceView;
|
||||
import tech.easyflow.dataspace.service.DataspaceService;
|
||||
|
||||
/**
|
||||
* 虚拟数据空间管理端 API。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dataspace")
|
||||
public class DataspaceController {
|
||||
|
||||
private final DataspaceService dataspaceService;
|
||||
|
||||
/**
|
||||
* 创建数据空间控制器。
|
||||
*
|
||||
* @param dataspaceService 数据空间服务
|
||||
*/
|
||||
public DataspaceController(DataspaceService dataspaceService) {
|
||||
this.dataspaceService = dataspaceService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询数据空间列表。
|
||||
*
|
||||
* @param keyword 搜索关键词
|
||||
* @return 数据空间摘要
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
@SaCheckPermission("/api/v1/dataspace/query")
|
||||
public Result<List<DataspaceSummary>> list(String keyword) {
|
||||
return Result.ok(dataspaceService.list(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据空间当前 revision 详情。
|
||||
*
|
||||
* @param id 数据空间 ID
|
||||
* @return 数据空间详情
|
||||
*/
|
||||
@GetMapping("/detail")
|
||||
@SaCheckPermission("/api/v1/dataspace/detail")
|
||||
public Result<DataspaceView> detail(BigInteger id) {
|
||||
return Result.ok(dataspaceService.detail(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存数据空间并生成新 revision。
|
||||
*
|
||||
* @param definition 数据空间定义
|
||||
* @return 保存后的详情
|
||||
*/
|
||||
@PostMapping("/save")
|
||||
@SaCheckPermission("/api/v1/dataspace/save")
|
||||
public Result<DataspaceView> save(
|
||||
@RequestBody DataspaceDefinition definition) {
|
||||
return Result.ok(dataspaceService.save(definition));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用或禁用数据空间。
|
||||
*
|
||||
* @param request 状态变更请求
|
||||
* @return 成功结果
|
||||
*/
|
||||
@PostMapping("/status")
|
||||
@SaCheckPermission("/api/v1/dataspace/save")
|
||||
public Result<Void> status(
|
||||
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
|
||||
if (request == null || request.enabled() == null) {
|
||||
throw new BusinessException("数据空间状态不能为空");
|
||||
}
|
||||
dataspaceService.setEnabled(request.id(), request.enabled());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑删除数据空间。
|
||||
*
|
||||
* @param id 数据空间 ID
|
||||
* @return 成功结果
|
||||
*/
|
||||
@PostMapping("/remove")
|
||||
@SaCheckPermission("/api/v1/dataspace/remove")
|
||||
public Result<Void> remove(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||
dataspaceService.remove(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据空间状态变更请求。
|
||||
*
|
||||
* @param id 数据空间 ID
|
||||
* @param enabled 是否启用
|
||||
*/
|
||||
public record StatusRequest(BigInteger id, Boolean enabled) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package tech.easyflow.admin.controller.dataspace;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.dataspace.model.DataspaceExplainResult;
|
||||
import tech.easyflow.dataspace.model.DataspaceQueryRequest;
|
||||
import tech.easyflow.dataspace.model.DataspaceQueryResult;
|
||||
import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest;
|
||||
import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult;
|
||||
import tech.easyflow.dataspace.service.DataspaceQueryService;
|
||||
|
||||
/**
|
||||
* 数据空间 SQL 工作台 Query、Explain、Complete 与 Cancel API。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dataspaceSql")
|
||||
public class DataspaceSqlController {
|
||||
|
||||
private final DataspaceQueryService queryService;
|
||||
|
||||
/**
|
||||
* 创建 SQL 控制器。
|
||||
*
|
||||
* @param queryService 查询服务
|
||||
*/
|
||||
public DataspaceSqlController(DataspaceQueryService queryService) {
|
||||
this.queryService = queryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行只读 SQL。
|
||||
*
|
||||
* @param request 查询请求
|
||||
* @return 查询结果与指标
|
||||
*/
|
||||
@PostMapping("/query")
|
||||
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||
public Result<DataspaceQueryResult> query(
|
||||
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
|
||||
return Result.ok(queryService.query(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式执行非 ANALYZE Explain。
|
||||
*
|
||||
* @param request Explain 请求
|
||||
* @return Explain 与索引信息
|
||||
*/
|
||||
@PostMapping("/explain")
|
||||
@SaCheckPermission("/api/v1/dataspaceSql/explain")
|
||||
public Result<DataspaceExplainResult> explain(
|
||||
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
|
||||
return Result.ok(queryService.explain(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前数据空间内的 Calcite SQL 补全候选。
|
||||
*
|
||||
* @param request 补全请求
|
||||
* @return 补全替换区间与候选
|
||||
*/
|
||||
@PostMapping("/complete")
|
||||
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||
public Result<DataspaceSqlCompletionResult> complete(
|
||||
@JsonBody(required = true, skipConvertError = false)
|
||||
DataspaceSqlCompletionRequest request) {
|
||||
return Result.ok(queryService.complete(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试取消当前节点查询。
|
||||
*
|
||||
* @param queryId 查询 ID
|
||||
* @return 是否找到并发起取消
|
||||
*/
|
||||
@PostMapping("/cancel")
|
||||
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||
public Result<Boolean> cancel(
|
||||
@JsonBody(value = "queryId", required = true, skipConvertError = false) String queryId) {
|
||||
return Result.ok(queryService.cancel(queryId));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,49 @@
|
||||
package tech.easyflow.admin.controller.job;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import org.quartz.CronExpression;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
||||
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
|
||||
import tech.easyflow.common.constant.enums.EnumJobStatus;
|
||||
import tech.easyflow.common.constant.enums.EnumJobType;
|
||||
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.job.entity.SysJob;
|
||||
import tech.easyflow.job.job.JobConstant;
|
||||
import tech.easyflow.job.service.SysJobService;
|
||||
|
||||
import tech.easyflow.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;
|
||||
import java.util.ArrayList;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 系统任务表 控制层。
|
||||
@@ -30,52 +54,356 @@ 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;
|
||||
|
||||
/** 与调度计算一致的 Cron 预览格式化器。 */
|
||||
private final DateTimeFormatter jobTimeFormatter;
|
||||
|
||||
/**
|
||||
* 创建定时任务控制器。
|
||||
*
|
||||
* @param service 定时任务服务
|
||||
* @param workflowService 工作流服务
|
||||
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
|
||||
* @param resourceAccessService 资源访问控制服务
|
||||
* @param workflowRunningParameterResolver 工作流运行参数解析器
|
||||
* @param jobTimezone 定时任务业务时区
|
||||
*/
|
||||
public SysJobController(SysJobService service,
|
||||
WorkflowService workflowService,
|
||||
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
WorkflowRunningParameterResolver workflowRunningParameterResolver,
|
||||
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
|
||||
super(service);
|
||||
this.workflowService = workflowService;
|
||||
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.workflowRunningParameterResolver = workflowRunningParameterResolver;
|
||||
this.jobTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.of(jobTimezone));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取定时任务关键字搜索字段。
|
||||
*
|
||||
* @return 任务名称和备注属性
|
||||
*/
|
||||
@Override
|
||||
protected String[] getKeywordSearchProperties() {
|
||||
return new String[]{"jobName", "remark"};
|
||||
}
|
||||
|
||||
@GetMapping("/start")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
@LogRecord("启动定时任务")
|
||||
public Result<Void> start(BigInteger id) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
SysJob job = requireExistingJob(id);
|
||||
validateWorkflowReference(job, account);
|
||||
service.startJob(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/stop")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
@LogRecord("停止定时任务")
|
||||
public Result<Void> stop(BigInteger id) {
|
||||
service.stopJob(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/getNextTimes")
|
||||
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{
|
||||
CronExpression ex = new CronExpression(cronExpression);
|
||||
List<String> times = new ArrayList<>();
|
||||
Date date = new Date();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
Date next = ex.getNextValidTimeAfter(date);
|
||||
times.add(DateUtil.formatDateTime(next));
|
||||
date = next;
|
||||
@GetMapping("/trigger")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
@LogRecord("立即执行定时任务")
|
||||
public Result<String> trigger(BigInteger id) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
SysJob job = requireExistingJob(id);
|
||||
validateWorkflowReference(job, account);
|
||||
return Result.ok(service.triggerNow(id));
|
||||
}
|
||||
return Result.ok(times);
|
||||
|
||||
@GetMapping("/getNextTimes")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
public Result<List<String>> getNextTimes(String cronExpression) {
|
||||
return Result.ok(service.nextFireTimes(cronExpression, 5).stream()
|
||||
.map(Date::toInstant)
|
||||
.map(jobTimeFormatter::format)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping("remove")
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
|
||||
service.deleteJob(List.of(id));
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping("removeBatch")
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public Result<?> removeBatch(
|
||||
@JsonBody(value = "ids", required = true) Collection<Serializable> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Result.fail("id不能为空");
|
||||
}
|
||||
service.deleteJob(ids);
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前账号可用于定时任务的工作流。
|
||||
*
|
||||
* @return 工作流安全选项
|
||||
*/
|
||||
@GetMapping("/workflowOptions")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
public Result<List<SysJobWorkflowOptionView>> workflowOptions() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
List<SysJobWorkflowOptionView> options = workflowService.list(QueryWrapper.create()
|
||||
.eq(Workflow::getTenantId, account.getTenantId())
|
||||
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
|
||||
.orderBy(Workflow::getModified, false))
|
||||
.stream()
|
||||
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
|
||||
.filter(workflow -> workflow.getPublishedSnapshotJson() != null
|
||||
&& !workflow.getPublishedSnapshotJson().isEmpty())
|
||||
.filter(workflow -> resourceAccessService.canAccess(
|
||||
account,
|
||||
CategoryResourceType.WORKFLOW,
|
||||
workflow,
|
||||
ResourceAction.USE))
|
||||
.map(workflowService::toPublishedView)
|
||||
.filter(Objects::nonNull)
|
||||
.map(workflow -> new SysJobWorkflowOptionView(
|
||||
workflow.getId(),
|
||||
workflow.getTitle(),
|
||||
workflow.getDescription()))
|
||||
.toList();
|
||||
return Result.ok(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询定时任务所选工作流的运行参数。
|
||||
*
|
||||
* @param id 工作流 ID
|
||||
* @return 工作流运行参数
|
||||
* @throws BusinessException 工作流不存在或无运行权限时抛出
|
||||
*/
|
||||
@GetMapping("/workflowRunningParameters")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
public Result<Map<String, Object>> workflowRunningParameters(BigInteger id) {
|
||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||
id,
|
||||
SaTokenUtil.getLoginAccount(),
|
||||
"工作流不存在、未发布或无权运行");
|
||||
Map<String, Object> result = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||
if (result == null) {
|
||||
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
|
||||
}
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onSaveOrUpdateBefore(SysJob entity, boolean isSave) {
|
||||
if (entity == null) {
|
||||
throw new BusinessException("定时任务不能为空");
|
||||
}
|
||||
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
|
||||
SysJob effectiveEntity = entity;
|
||||
if (isSave) {
|
||||
// 新任务固定从 STOP 和第 0 代开始,禁止请求绕过启动协议。
|
||||
entity.setStatus(EnumJobStatus.STOP.getCode());
|
||||
entity.setScheduleGeneration(0L);
|
||||
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
|
||||
} else {
|
||||
SysJob existing = requireExistingJob(entity.getId());
|
||||
preserveServerControlledFields(entity, existing);
|
||||
effectiveEntity = mergeForValidation(entity, existing);
|
||||
entity.setModified(new Date());
|
||||
entity.setModifiedBy(loginUser.getId());
|
||||
}
|
||||
validateWorkflowReference(effectiveEntity, loginUser);
|
||||
validateCronExpression(effectiveEntity.getCronExpression());
|
||||
validateMisfirePolicy(effectiveEntity.getMisfirePolicy());
|
||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onRemoveBefore(Collection<Serializable> ids) {
|
||||
service.deleteJob(ids);
|
||||
return super.onRemoveBefore(ids);
|
||||
protected void onSaveOrUpdateAfter(SysJob entity, boolean isSave) {
|
||||
service.syncJob(entity.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping("update")
|
||||
public Result<?> update(@JsonBody SysJob entity) {
|
||||
Result<?> result = onSaveOrUpdateBefore(entity, false);
|
||||
if (result != null) return result;
|
||||
service.updateJobDefinition(entity);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工作流类型任务引用的工作流可被当前用户运行。
|
||||
*
|
||||
* @param entity 待保存的定时任务
|
||||
* @param account 当前账号
|
||||
* @throws BusinessException 工作流不存在、参数非法或无运行权限时抛出
|
||||
*/
|
||||
private void validateWorkflowReference(SysJob entity, LoginAccount account) {
|
||||
if (entity == null
|
||||
|| !Integer.valueOf(EnumJobType.TINY_FLOW.getCode()).equals(entity.getJobType())) {
|
||||
return;
|
||||
}
|
||||
BigInteger workflowId = SysJobWorkflowReferenceSupport.requireWorkflowId(entity);
|
||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||
workflowId,
|
||||
account,
|
||||
"工作流不存在、未发布或无权运行");
|
||||
validateRequiredWorkflowParams(entity, workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前租户内存在的定时任务。
|
||||
*
|
||||
* @param id 定时任务 ID
|
||||
* @return 已存在的定时任务
|
||||
* @throws BusinessException ID 缺失或任务不存在时抛出
|
||||
*/
|
||||
private SysJob requireExistingJob(BigInteger id) {
|
||||
if (id == null) {
|
||||
throw new BusinessException("定时任务ID不能为空");
|
||||
}
|
||||
SysJob existing = service.getById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException("定时任务不存在");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留更新请求不能修改的服务端控制字段。
|
||||
*
|
||||
* @param entity 更新请求
|
||||
* @param existing 数据库中的定时任务
|
||||
*/
|
||||
private void preserveServerControlledFields(SysJob entity, SysJob existing) {
|
||||
entity.setTenantId(existing.getTenantId());
|
||||
entity.setDeptId(existing.getDeptId());
|
||||
entity.setCreated(existing.getCreated());
|
||||
entity.setCreatedBy(existing.getCreatedBy());
|
||||
entity.setStatus(existing.getStatus());
|
||||
entity.setScheduleGeneration(existing.getScheduleGeneration());
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并部分更新请求与原记录,生成用于权限和参数校验的有效任务状态。
|
||||
*
|
||||
* @param entity 更新请求
|
||||
* @param existing 数据库中的定时任务
|
||||
* @return 合并后的校验对象
|
||||
*/
|
||||
private SysJob mergeForValidation(SysJob entity, SysJob existing) {
|
||||
SysJob effective = new SysJob();
|
||||
effective.setJobType(entity.getJobType() == null
|
||||
? existing.getJobType()
|
||||
: entity.getJobType());
|
||||
effective.setJobParams(entity.getJobParams() == null
|
||||
? existing.getJobParams()
|
||||
: entity.getJobParams());
|
||||
effective.setCronExpression(entity.getCronExpression() == null
|
||||
? existing.getCronExpression()
|
||||
: entity.getCronExpression());
|
||||
effective.setMisfirePolicy(entity.getMisfirePolicy() == null
|
||||
? existing.getMisfirePolicy()
|
||||
: entity.getMisfirePolicy());
|
||||
return effective;
|
||||
}
|
||||
|
||||
private void validateMisfirePolicy(Integer misfirePolicy) {
|
||||
if (!Integer.valueOf(EnumMisfirePolicy.FIRE_ONCE_NOW.getCode()).equals(misfirePolicy)
|
||||
&& !Integer.valueOf(EnumMisfirePolicy.SKIP.getCode()).equals(misfirePolicy)) {
|
||||
throw new BusinessException("错过策略只支持恢复后补执行一次或跳过本次");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCronExpression(String cronExpression) {
|
||||
try {
|
||||
service.nextFireTimes(cronExpression, 1);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BusinessException(400, 1, "Cron 表达式无效", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验定时任务已填写工作流的全部必填运行参数。
|
||||
*
|
||||
* @param entity 待保存定时任务
|
||||
* @param workflow 关联工作流
|
||||
* @throws BusinessException 工作流参数配置无效或必填值缺失时抛出
|
||||
*/
|
||||
private void validateRequiredWorkflowParams(SysJob entity, Workflow workflow) {
|
||||
List<Parameter> parameters =
|
||||
workflowRunningParameterResolver.resolveStartParameters(workflow.getContent());
|
||||
if (parameters == null) {
|
||||
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
|
||||
}
|
||||
Map<String, Object> jobParams = entity.getJobParams();
|
||||
Object rawWorkflowParams = jobParams == null
|
||||
? null
|
||||
: jobParams.get(JobConstant.WORKFLOW_PARAMS_KEY);
|
||||
Map<?, ?> workflowParams = rawWorkflowParams instanceof Map<?, ?> map
|
||||
? map
|
||||
: Map.of();
|
||||
for (Parameter parameter : parameters) {
|
||||
if (parameter == null || !parameter.isRequired()) {
|
||||
continue;
|
||||
}
|
||||
String name = parameter.getName();
|
||||
if (!StringUtils.hasText(name)) {
|
||||
throw new BusinessException("工作流存在无效必填参数配置,请检查工作流后重试");
|
||||
}
|
||||
if (!hasRequiredValue(workflowParams.get(name))) {
|
||||
String label = StringUtils.hasText(parameter.getFormLabel())
|
||||
? parameter.getFormLabel()
|
||||
: name;
|
||||
throw new BusinessException("工作流必填参数“" + label + "”不能为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断必填参数值是否有效。
|
||||
*
|
||||
* @param value 参数值
|
||||
* @return 非空时为 true
|
||||
*/
|
||||
private boolean hasRequiredValue(Object value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
if (value instanceof CharSequence sequence) {
|
||||
return StringUtils.hasText(sequence);
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
return !collection.isEmpty();
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return !map.isEmpty();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
package tech.easyflow.admin.controller.job;
|
||||
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.job.entity.SysJobLog;
|
||||
import tech.easyflow.job.service.SysJobLogService;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统任务日志 控制层。
|
||||
*
|
||||
@@ -20,16 +34,112 @@ import tech.easyflow.job.service.SysJobLogService;
|
||||
@RequestMapping("/api/v1/sysJobLog")
|
||||
@UsePermission(moduleName = "/api/v1/sysJob")
|
||||
public class SysJobLogController extends BaseCurdController<SysJobLogService, SysJobLog> {
|
||||
public SysJobLogController(SysJobLogService service) {
|
||||
private static final long DEFAULT_PAGE_SIZE = 10L;
|
||||
private static final long MAX_PAGE_SIZE = 100L;
|
||||
private static final DateTimeFormatter QUERY_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ZoneId jobZoneId;
|
||||
|
||||
public SysJobLogController(
|
||||
SysJobLogService service,
|
||||
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
|
||||
super(service);
|
||||
this.jobZoneId = ZoneId.of(jobTimezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造日志筛选条件,并追加计划触发时间和实际触发时间范围。
|
||||
*/
|
||||
@Override
|
||||
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
|
||||
QueryWrapper queryWrapper = super.buildQueryWrapper(request);
|
||||
Date scheduledStart = parseQueryTime(
|
||||
request.getParameter("scheduledStart"), "计划触发开始时间");
|
||||
Date scheduledEnd = parseQueryTime(
|
||||
request.getParameter("scheduledEnd"), "计划触发结束时间");
|
||||
Date actualStart = parseQueryTime(
|
||||
request.getParameter("actualStart"), "实际触发开始时间");
|
||||
Date actualEnd = parseQueryTime(
|
||||
request.getParameter("actualEnd"), "实际触发结束时间");
|
||||
|
||||
validateTimeRange(scheduledStart, scheduledEnd, "计划触发时间");
|
||||
validateTimeRange(actualStart, actualEnd, "实际触发时间");
|
||||
if (scheduledStart != null) {
|
||||
queryWrapper.ge(SysJobLog::getScheduledFireTime, scheduledStart);
|
||||
}
|
||||
if (scheduledEnd != null) {
|
||||
queryWrapper.le(SysJobLog::getScheduledFireTime, scheduledEnd);
|
||||
}
|
||||
if (actualStart != null) {
|
||||
queryWrapper.ge(SysJobLog::getActualFireTime, actualStart);
|
||||
}
|
||||
if (actualEnd != null) {
|
||||
queryWrapper.le(SysJobLog::getActualFireTime, actualEnd);
|
||||
}
|
||||
return queryWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动刷新只读取当前第一页,不执行分页总数统计。
|
||||
*/
|
||||
@GetMapping("refresh")
|
||||
public Result<List<SysJobLog>> refresh(HttpServletRequest request, Long pageSize) {
|
||||
QueryWrapper queryWrapper = buildQueryWrapper(request);
|
||||
queryWrapper.orderBy(buildOrderBy(null, null, getDefaultOrderBy()));
|
||||
queryWrapper.limit(resolvePageSize(pageSize));
|
||||
return Result.ok(service.list(queryWrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新计划触发记录优先,并用主键保证毫秒时间相同时顺序稳定。
|
||||
*/
|
||||
@Override
|
||||
protected String getDefaultOrderBy() {
|
||||
return "scheduled_fire_time desc, id desc";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Page<SysJobLog> queryPage(
|
||||
Page<SysJobLog> page, QueryWrapper queryWrapper) {
|
||||
page.setPageSize(resolvePageSize(page.getPageSize()));
|
||||
return super.queryPage(page, queryWrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onSaveOrUpdateBefore(SysJobLog entity, boolean isSave) {
|
||||
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
|
||||
if (isSave) {
|
||||
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
|
||||
throw new IllegalStateException("定时任务执行记录由系统维护,禁止外部写入");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result onRemoveBefore(Collection<Serializable> ids) {
|
||||
service.requireTerminal(ids);
|
||||
return super.onRemoveBefore(ids);
|
||||
}
|
||||
|
||||
private long resolvePageSize(Long pageSize) {
|
||||
if (pageSize == null || pageSize < 1) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
private Date parseQueryTime(String value, String fieldName) {
|
||||
if (!StringUtil.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER);
|
||||
return Date.from(dateTime.atZone(jobZoneId).toInstant());
|
||||
} catch (DateTimeParseException exception) {
|
||||
throw new BusinessException(
|
||||
400, 400, fieldName + "格式不正确", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateTimeRange(Date start, Date end, String fieldName) {
|
||||
if (start != null && end != null && start.after(end)) {
|
||||
throw new BusinessException(400, 400, fieldName + "范围不正确");
|
||||
}
|
||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||
}
|
||||
}
|
||||
@@ -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())) {
|
||||
if (records.size() != ids.size()) {
|
||||
return Result.fail(1, "部分部门不存在或已删除,请刷新后重试");
|
||||
}
|
||||
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, "所选部门包含未选中的下级部门,不能删除");
|
||||
}
|
||||
QueryWrapper w = QueryWrapper.create();
|
||||
w.in(SysAccount::getDeptId, ids);
|
||||
long count = sysAccountService.count(w);
|
||||
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,18 +67,23 @@ public class SysPositionController extends BaseCurdController<SysPositionService
|
||||
.from(SYS_POSITION);
|
||||
|
||||
// 获取查询参数
|
||||
String keyword = normalizeSearchKeyword(request.getParameter("keyword"));
|
||||
String positionName = request.getParameter("positionName");
|
||||
String positionCode = request.getParameter("positionCode");
|
||||
String status = request.getParameter("status");
|
||||
|
||||
// 岗位名称 - 模糊查询
|
||||
if (StringUtil.hasText(keyword)) {
|
||||
queryWrapper.and(buildLiteralContainsCondition(
|
||||
keyword, SYS_POSITION.POSITION_NAME, SYS_POSITION.POSITION_CODE));
|
||||
} else {
|
||||
// 兼容仍按旧参数调用的客户端。
|
||||
if (StringUtil.hasText(positionName)) {
|
||||
queryWrapper.where(SYS_POSITION.POSITION_NAME.like(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)) {
|
||||
queryWrapper.where(SYS_POSITION.STATUS.eq(status));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,180 @@
|
||||
package tech.easyflow.admin.model.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流设计器所需的安全选项视图。
|
||||
*
|
||||
* @param models 模型选项
|
||||
* @param knowledges 知识库选项
|
||||
* @param codeEngines 代码执行引擎选项
|
||||
*/
|
||||
public record WorkflowDesignerOptionsView(
|
||||
List<ModelOption> models,
|
||||
List<KnowledgeOption> knowledges,
|
||||
List<Map<String, Object>> codeEngines
|
||||
) {
|
||||
|
||||
/**
|
||||
* 模型安全选项。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param title 模型标题
|
||||
* @param description 模型描述
|
||||
* @param modelProvider 供应商安全摘要
|
||||
*/
|
||||
public record ModelOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
String title,
|
||||
String description,
|
||||
ProviderOption modelProvider
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型供应商安全摘要。
|
||||
*
|
||||
* @param providerName 供应商名称
|
||||
* @param providerType 供应商类型
|
||||
* @param icon 供应商图标
|
||||
*/
|
||||
public record ProviderOption(String providerName, String providerType, String icon) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库安全选项。
|
||||
*
|
||||
* @param id 知识库 ID
|
||||
* @param title 知识库标题
|
||||
* @param description 知识库描述
|
||||
* @param vectorEmbedModelId Embedding 模型 ID
|
||||
* @param dimensionOfVectorModel 向量维度
|
||||
* @param vectorStoreEnabled 是否可用于向量检索
|
||||
*/
|
||||
public record KnowledgeOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
String title,
|
||||
String description,
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger vectorEmbedModelId,
|
||||
Integer dimensionOfVectorModel,
|
||||
Boolean vectorStoreEnabled
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件安全选项。
|
||||
*
|
||||
* @param id 插件 ID
|
||||
* @param name 插件名称
|
||||
* @param description 插件描述
|
||||
* @param icon 插件图标
|
||||
* @param tools 可用工具
|
||||
*/
|
||||
public record PluginOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
String name,
|
||||
String description,
|
||||
String icon,
|
||||
List<PluginToolOption> tools
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件工具安全选项。
|
||||
*
|
||||
* @param id 工具 ID
|
||||
* @param name 工具名称
|
||||
* @param description 工具描述
|
||||
*/
|
||||
public record PluginToolOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
String name,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源安全选项。
|
||||
*
|
||||
* @param id 数据源 ID
|
||||
* @param sourceName 数据源名称
|
||||
* @param sourceType 数据源类型
|
||||
*/
|
||||
public record DataSourceOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
String sourceName,
|
||||
String sourceType
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据目录安全选项。
|
||||
*
|
||||
* @param id 目录 ID
|
||||
* @param sourceId 数据源 ID
|
||||
* @param catalogName 目录名称
|
||||
* @param catalogDesc 目录描述
|
||||
*/
|
||||
public record CatalogOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
|
||||
String catalogName,
|
||||
String catalogDesc
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 已接入数据集安全选项。
|
||||
*
|
||||
* @param id 数据集 ID
|
||||
* @param tenantId 租户 ID
|
||||
* @param sourceId 数据源 ID
|
||||
* @param catalogId 目录 ID
|
||||
* @param tableName 数据表名称
|
||||
* @param tableDesc 数据表描述
|
||||
*/
|
||||
public record DatasetOption(
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger tenantId,
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
|
||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger catalogId,
|
||||
String tableName,
|
||||
String tableDesc
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据集字段安全视图。
|
||||
*
|
||||
* @param fieldName 字段名称
|
||||
* @param fieldDesc 字段描述
|
||||
* @param jdbcType JDBC 类型
|
||||
* @param fieldType 业务字段类型
|
||||
*/
|
||||
public record DatasetFieldOption(
|
||||
String fieldName,
|
||||
String fieldDesc,
|
||||
String jdbcType,
|
||||
Integer fieldType
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据集结构安全视图。
|
||||
*
|
||||
* @param tableName 数据表名称
|
||||
* @param tableDesc 数据表描述
|
||||
* @param fields 字段列表
|
||||
*/
|
||||
public record DatasetSchemaOption(
|
||||
String tableName,
|
||||
String tableDesc,
|
||||
List<DatasetFieldOption> fields
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
package tech.easyflow.admin.service.agent;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
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;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.*;
|
||||
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.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.*;
|
||||
|
||||
/**
|
||||
* Agent 管理端会话服务。
|
||||
*/
|
||||
@Service
|
||||
public class AgentSessionService {
|
||||
|
||||
private static final String ASSISTANT_CODE = "AGENT";
|
||||
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatSessionCommandService chatSessionCommandService;
|
||||
private final AgentService agentService;
|
||||
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 管理端会话服务。
|
||||
*
|
||||
* @param chatSessionQueryService 聊天会话查询服务
|
||||
* @param chatSessionCommandService 聊天会话命令服务
|
||||
* @param agentService Agent 服务
|
||||
* @param documentCollectionService 知识库服务
|
||||
* @param resourceAccessService 资源访问服务
|
||||
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
|
||||
* @param agentMediaService Agent 媒体服务
|
||||
* @param agentComposerDraftService Agent 输入草稿服务
|
||||
* @param chatJsonSupport 聊天 JSON 工具
|
||||
*/
|
||||
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
|
||||
ChatSessionCommandService chatSessionCommandService,
|
||||
AgentService agentService,
|
||||
DocumentCollectionService documentCollectionService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
|
||||
AgentMediaService agentMediaService,
|
||||
AgentComposerDraftService agentComposerDraftService,
|
||||
ChatJsonSupport chatJsonSupport) {
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.chatSessionCommandService = chatSessionCommandService;
|
||||
this.agentService = agentService;
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
|
||||
this.agentMediaService = agentMediaService;
|
||||
this.agentComposerDraftService = agentComposerDraftService;
|
||||
this.chatJsonSupport = chatJsonSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的 Agent 会话分页。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param agentId Agent ID
|
||||
* @param query 分页参数
|
||||
* @return Agent 会话分页
|
||||
*/
|
||||
public ChatWorkspaceSessionPage queryCurrentUserSessions(LoginAccount account, BigInteger agentId, ChatPageQuery query) {
|
||||
ChatSessionPage page = chatSessionQueryService.pageSessions(account.getId(), agentId, ASSISTANT_CODE, query);
|
||||
Map<BigInteger, AgentAvailability> availabilityMap = resolveAgentAvailability(page.getRecords());
|
||||
ChatWorkspaceSessionPage result = new ChatWorkspaceSessionPage();
|
||||
result.setTotal(page.getTotal());
|
||||
result.setPageNumber(page.getPageNumber());
|
||||
result.setPageSize(page.getPageSize());
|
||||
List<ChatWorkspaceSessionView> records = new ArrayList<>();
|
||||
for (ChatSessionSummary summary : page.getRecords()) {
|
||||
records.add(toSessionView(summary, availabilityMap.get(summary.getAssistantId())));
|
||||
}
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的 Agent 会话详情。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @return Agent 会话详情
|
||||
*/
|
||||
public ChatWorkspaceSessionDetailView getCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
|
||||
AgentAvailability availability = resolveAgentAvailability(List.of(summary)).get(summary.getAssistantId());
|
||||
ChatWorkspaceSessionDetailView detail = new ChatWorkspaceSessionDetailView();
|
||||
fillSessionView(detail, summary, availability);
|
||||
Agent displayAgent = availability == null ? null : availability.displayAgent();
|
||||
detail.setAssistant(toAssistantView(displayAgent, summary));
|
||||
detail.setBoundKnowledges(resolveBoundKnowledges(displayAgent));
|
||||
ExtraKnowledgeResolution extraKnowledgeResolution = resolveExtraKnowledges(summary);
|
||||
detail.setExtraKnowledges(extraKnowledgeResolution.validKnowledges());
|
||||
detail.setRemovedExtraKnowledgeNames(extraKnowledgeResolution.removedNames());
|
||||
if (extraKnowledgeResolution.shouldSync()) {
|
||||
syncSessionExtraKnowledges(summary, extraKnowledgeResolution.validKnowledgeIds(), account.getId());
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的 Agent 会话消息。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @param query 分页参数
|
||||
* @return 消息分页
|
||||
*/
|
||||
public ChatHistoryPage queryCurrentUserMessages(LoginAccount account, BigInteger sessionId, ChatPageQuery query) {
|
||||
requireUserAgentSession(account, sessionId);
|
||||
return chatSessionQueryService.pageMainlineMessages(sessionId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的 Agent 完整会话。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @return 完整会话
|
||||
*/
|
||||
public ChatWorkspaceConversationView getCurrentUserConversation(LoginAccount account, BigInteger sessionId) {
|
||||
requireUserAgentSession(account, sessionId);
|
||||
List<ChatMessageRecord> records = chatSessionQueryService.listMainlineMessages(sessionId);
|
||||
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
|
||||
view.setRecords(records);
|
||||
view.setTotal(records.size());
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名当前用户的 Agent 会话。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @param title 新标题
|
||||
*/
|
||||
public void renameCurrentUserSession(LoginAccount account, BigInteger sessionId, String title) {
|
||||
if (!StringUtils.hasText(title)) {
|
||||
throw new BusinessException("标题不能为空");
|
||||
}
|
||||
requireUserAgentSession(account, sessionId);
|
||||
chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前用户 Agent 会话的临时知识库。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @param knowledgeIds 临时知识库 ID
|
||||
* @return 更新后的会话详情
|
||||
*/
|
||||
public ChatWorkspaceSessionDetailView saveCurrentUserExtraKnowledges(LoginAccount account,
|
||||
BigInteger sessionId,
|
||||
List<BigInteger> knowledgeIds) {
|
||||
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
|
||||
ExtraKnowledgeResolution resolution = resolveVisibleKnowledgeViews(normalizeExtraKnowledgeIds(knowledgeIds));
|
||||
if (!resolution.removedNames().isEmpty()) {
|
||||
throw new BusinessException("所选知识库已失效或无权限使用");
|
||||
}
|
||||
syncSessionExtraKnowledges(summary, resolution.validKnowledgeIds(), account.getId());
|
||||
return getCurrentUserSession(account, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户的 Agent 会话。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||
// 上一次删除可能已写入删除标记但媒体清理失败,重试时继续清理当前用户目录。
|
||||
deleteComposerDraft(summary, account, sessionId);
|
||||
agentMediaService.deleteFormalSession(sessionId.toString(), account);
|
||||
deleteFormalDocuments(sessionId, account);
|
||||
return;
|
||||
}
|
||||
requireUserAgentSession(account, summary);
|
||||
agentRuntimeStateCleanupService.clearChatSession(sessionId, account.getId());
|
||||
chatSessionCommandService.deleteSession(sessionId, account.getId(), account.getId());
|
||||
deleteComposerDraft(summary, account, sessionId);
|
||||
agentMediaService.deleteFormalSession(sessionId.toString(), account);
|
||||
deleteFormalDocuments(sessionId, account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等清理正式会话绑定的文档对象与快照。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param account 当前账号
|
||||
*/
|
||||
private void deleteFormalDocuments(BigInteger sessionId, LoginAccount account) {
|
||||
if (agentDocumentService != null) {
|
||||
agentDocumentService.deleteFormalSession(sessionId.toString(), account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话对应的未发送草稿和临时图片。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
private void deleteComposerDraft(ChatSessionSummary summary, LoginAccount account, BigInteger sessionId) {
|
||||
if (summary == null || summary.getAssistantId() == null) {
|
||||
return;
|
||||
}
|
||||
agentComposerDraftService.delete(AgentMediaService.MODE_FORMAL,
|
||||
summary.getAssistantId().toString(), sessionId.toString(), account);
|
||||
}
|
||||
|
||||
private ChatSessionSummary requireUserAgentSession(LoginAccount account, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||
throw new BusinessException("Agent 会话不存在");
|
||||
}
|
||||
requireUserAgentSession(account, summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验会话属于当前用户且类型为 Agent。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param summary 会话摘要
|
||||
* @throws BusinessException 会话类型不匹配或不属于当前用户时抛出
|
||||
*/
|
||||
private void requireUserAgentSession(LoginAccount account, ChatSessionSummary summary) {
|
||||
if (!ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
||||
throw new BusinessException("Agent 会话不存在");
|
||||
}
|
||||
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
||||
throw new BusinessException("无权访问该 Agent 会话");
|
||||
}
|
||||
if (!Objects.equals(summary.getTenantId(), account.getTenantId())) {
|
||||
throw new BusinessException("无权访问该 Agent 会话");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
|
||||
Map<BigInteger, AgentAvailability> result = new LinkedHashMap<>();
|
||||
if (sessions == null || sessions.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Set<BigInteger> agentIds = new LinkedHashSet<>();
|
||||
for (ChatSessionSummary session : sessions) {
|
||||
if (session != null && session.getAssistantId() != null) {
|
||||
agentIds.add(session.getAssistantId());
|
||||
}
|
||||
}
|
||||
if (agentIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Agent> agents = agentService.list(QueryWrapper.create().in("id", agentIds));
|
||||
Map<BigInteger, Agent> agentMap = new LinkedHashMap<>();
|
||||
for (Agent agent : agents) {
|
||||
agentMap.put(agent.getId(), agent);
|
||||
}
|
||||
for (BigInteger agentId : agentIds) {
|
||||
Agent currentAgent = agentMap.get(agentId);
|
||||
if (currentAgent == null) {
|
||||
result.put(agentId, new AgentAvailability(false, ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED, null));
|
||||
continue;
|
||||
}
|
||||
if (!resourceAccessService.canAccess(CategoryResourceType.AGENT, currentAgent, ResourceAction.USE)) {
|
||||
result.put(agentId, new AgentAvailability(false, ChatWorkspaceReadOnlyReason.NO_PERMISSION, null));
|
||||
continue;
|
||||
}
|
||||
boolean online = Integer.valueOf(1).equals(currentAgent.getStatus())
|
||||
&& PublishStatus.from(currentAgent.getPublishStatus()) == PublishStatus.PUBLISHED;
|
||||
result.put(agentId, new AgentAvailability(
|
||||
online,
|
||||
online ? null : ChatWorkspaceReadOnlyReason.ASSISTANT_OFFLINE,
|
||||
toDisplayAgent(currentAgent)
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Agent toDisplayAgent(Agent currentAgent) {
|
||||
if (currentAgent.getPublishedSnapshotJson() != null && !currentAgent.getPublishedSnapshotJson().isEmpty()) {
|
||||
return agentService.fromSnapshot(currentAgent.getPublishedSnapshotJson());
|
||||
}
|
||||
return currentAgent;
|
||||
}
|
||||
|
||||
private ChatWorkspaceSessionView toSessionView(ChatSessionSummary summary, AgentAvailability availability) {
|
||||
ChatWorkspaceSessionView view = new ChatWorkspaceSessionView();
|
||||
fillSessionView(view, summary, availability);
|
||||
return view;
|
||||
}
|
||||
|
||||
private void fillSessionView(ChatWorkspaceSessionView view, ChatSessionSummary summary, AgentAvailability availability) {
|
||||
view.setSessionId(summary.getId());
|
||||
view.setAssistantId(summary.getAssistantId());
|
||||
view.setAssistantCode(summary.getAssistantCode());
|
||||
view.setAssistantName(summary.getAssistantName());
|
||||
view.setTitle(summary.getTitle());
|
||||
view.setLastMessagePreview(summary.getLastMessagePreview());
|
||||
view.setMessageCount(summary.getMessageCount());
|
||||
view.setAccessAt(summary.getAccessAt());
|
||||
view.setLastMessageAt(summary.getLastMessageAt());
|
||||
view.setContinuable(availability != null && availability.continuable());
|
||||
view.setReadOnlyReason(availability == null ? ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED : availability.reason());
|
||||
}
|
||||
|
||||
private ChatWorkspaceAssistantView toAssistantView(Agent agent, ChatSessionSummary summary) {
|
||||
ChatWorkspaceAssistantView view = new ChatWorkspaceAssistantView();
|
||||
if (agent != null) {
|
||||
view.setId(agent.getId());
|
||||
view.setAlias(agent.getId() == null ? null : agent.getId().toString());
|
||||
view.setTitle(agent.getName());
|
||||
view.setDescription(agent.getDescription());
|
||||
view.setIcon(agent.getAvatar());
|
||||
return view;
|
||||
}
|
||||
view.setId(summary == null ? null : summary.getAssistantId());
|
||||
view.setAlias(summary == null ? null : summary.getAssistantCode());
|
||||
view.setTitle(summary == null ? null : summary.getAssistantName());
|
||||
return view;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ChatWorkspaceKnowledgeView> resolveBoundKnowledges(Agent displayAgent) {
|
||||
if (displayAgent == null || displayAgent.getKnowledgeBindings() == null || displayAgent.getKnowledgeBindings().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<BigInteger> knowledgeIds = displayAgent.getKnowledgeBindings().stream()
|
||||
.map(binding -> binding.getKnowledgeId())
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (knowledgeIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<DocumentCollection> collections = documentCollectionService.listByIds(knowledgeIds);
|
||||
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
|
||||
for (DocumentCollection collection : collections) {
|
||||
collectionMap.put(collection.getId(), collection);
|
||||
}
|
||||
List<ChatWorkspaceKnowledgeView> views = new ArrayList<>();
|
||||
for (BigInteger knowledgeId : knowledgeIds) {
|
||||
DocumentCollection collection = collectionMap.get(knowledgeId);
|
||||
if (collection == null || PublishStatus.from(collection.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
continue;
|
||||
}
|
||||
views.add(toKnowledgeView(documentCollectionService.toPublishedView(collection)));
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
private ChatWorkspaceKnowledgeView toKnowledgeView(DocumentCollection collection) {
|
||||
ChatWorkspaceKnowledgeView view = new ChatWorkspaceKnowledgeView();
|
||||
view.setId(collection.getId());
|
||||
view.setAlias(collection.getAlias());
|
||||
view.setTitle(collection.getTitle());
|
||||
view.setDescription(collection.getDescription());
|
||||
view.setIcon(collection.getIcon());
|
||||
return view;
|
||||
}
|
||||
|
||||
private ExtraKnowledgeResolution resolveExtraKnowledges(ChatSessionSummary summary) {
|
||||
ChatSessionExtPayload payload = chatJsonSupport.fromJson(summary.getExtJson(), ChatSessionExtPayload.class);
|
||||
List<BigInteger> extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds();
|
||||
return resolveVisibleKnowledgeViews(extraKnowledgeIds);
|
||||
}
|
||||
|
||||
private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List<BigInteger> knowledgeIds) {
|
||||
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
|
||||
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
|
||||
}
|
||||
List<BigInteger> normalizedIds = normalizeExtraKnowledgeIds(knowledgeIds);
|
||||
if (normalizedIds.isEmpty()) {
|
||||
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
|
||||
}
|
||||
List<DocumentCollection> collections = documentCollectionService.listByIds(normalizedIds);
|
||||
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
|
||||
for (DocumentCollection collection : collections) {
|
||||
collectionMap.put(collection.getId(), collection);
|
||||
}
|
||||
List<ChatWorkspaceKnowledgeView> validKnowledges = new ArrayList<>();
|
||||
List<BigInteger> validKnowledgeIds = new ArrayList<>();
|
||||
List<String> removedNames = new ArrayList<>();
|
||||
boolean changed = false;
|
||||
for (BigInteger knowledgeId : normalizedIds) {
|
||||
DocumentCollection current = collectionMap.get(knowledgeId);
|
||||
if (current == null) {
|
||||
removedNames.add("知识库#" + knowledgeId);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (PublishStatus.from(current.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
removedNames.add(current.getTitle());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (!resourceAccessService.canAccess(CategoryResourceType.KNOWLEDGE, current, ResourceAction.USE)) {
|
||||
removedNames.add(current.getTitle());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
validKnowledges.add(toKnowledgeView(documentCollectionService.toPublishedView(current)));
|
||||
validKnowledgeIds.add(current.getId());
|
||||
}
|
||||
if (!Objects.equals(normalizedIds, validKnowledgeIds)) {
|
||||
changed = true;
|
||||
}
|
||||
return new ExtraKnowledgeResolution(validKnowledges, validKnowledgeIds, removedNames, changed);
|
||||
}
|
||||
|
||||
private List<BigInteger> normalizeExtraKnowledgeIds(List<BigInteger> knowledgeIds) {
|
||||
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<BigInteger> normalizedIds = new ArrayList<>();
|
||||
for (BigInteger knowledgeId : knowledgeIds) {
|
||||
if (knowledgeId != null && !normalizedIds.contains(knowledgeId)) {
|
||||
normalizedIds.add(knowledgeId);
|
||||
}
|
||||
}
|
||||
if (normalizedIds.size() > 3) {
|
||||
throw new BusinessException("临时知识库最多选择 3 个");
|
||||
}
|
||||
return normalizedIds;
|
||||
}
|
||||
|
||||
private void syncSessionExtraKnowledges(ChatSessionSummary summary, List<BigInteger> validKnowledgeIds, BigInteger operatorId) {
|
||||
ChatSessionExtPayload payload = new ChatSessionExtPayload();
|
||||
payload.setExtraKnowledgeIds(validKnowledgeIds);
|
||||
ChatSessionUpsertCommand command = new ChatSessionUpsertCommand();
|
||||
command.setSessionId(summary.getId());
|
||||
command.setTenantId(summary.getTenantId());
|
||||
command.setDeptId(summary.getDeptId());
|
||||
command.setUserId(summary.getUserId());
|
||||
command.setUserAccount(summary.getUserAccount());
|
||||
command.setAssistantId(summary.getAssistantId());
|
||||
command.setAssistantCode(summary.getAssistantCode());
|
||||
command.setAssistantName(summary.getAssistantName());
|
||||
command.setTitle(summary.getTitle());
|
||||
command.setExtJson(chatJsonSupport.toJson(payload));
|
||||
command.setOperatorId(operatorId);
|
||||
chatSessionCommandService.createOrTouchSession(command);
|
||||
}
|
||||
|
||||
private record AgentAvailability(boolean continuable,
|
||||
ChatWorkspaceReadOnlyReason reason,
|
||||
Agent displayAgent) {
|
||||
}
|
||||
|
||||
private record ExtraKnowledgeResolution(List<ChatWorkspaceKnowledgeView> validKnowledges,
|
||||
List<BigInteger> validKnowledgeIds,
|
||||
List<String> removedNames,
|
||||
boolean shouldSync) {
|
||||
}
|
||||
}
|
||||
@@ -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,648 @@
|
||||
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 javax.annotation.PreDestroy;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 将工作流运行事件转换为对话页可消费的 SSE 事件流。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowChatEventStream {
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(WorkflowChatEventStream.class);
|
||||
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
|
||||
|
||||
private final ChainExecutor chainExecutor;
|
||||
private final Map<String, StreamSession> sessions =
|
||||
new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService detachedSessionCleaner =
|
||||
Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
Thread thread = new Thread(
|
||||
task,
|
||||
"workflow-chat-detached-session-cleaner"
|
||||
);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建工作流对话事件流服务。
|
||||
*
|
||||
* @param chainExecutor 工作流执行器
|
||||
*/
|
||||
public WorkflowChatEventStream(ChainExecutor chainExecutor) {
|
||||
this.chainExecutor = chainExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册工作流全局事件监听器。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void registerListeners() {
|
||||
chainExecutor.addEventListener(this::onEvent);
|
||||
chainExecutor.addErrorListener(this::onChainError);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭断开会话清理线程并释放残留外部资源。
|
||||
*/
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
sessions.values().forEach(this::removeSession);
|
||||
detachedSessionCleaner.shutdownNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作流并返回其 SSE 连接。
|
||||
*
|
||||
* @param definitionId 工作流定义 ID
|
||||
* @param variables 运行变量
|
||||
* @return SSE 连接
|
||||
*/
|
||||
public SseEmitter start(String definitionId, Map<String, Object> variables) {
|
||||
return start(definitionId, variables, () -> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作流并在流会话结束时执行清理回调。
|
||||
*
|
||||
* @param definitionId 工作流定义 ID
|
||||
* @param variables 运行变量
|
||||
* @param cleanup 终态、启动失败或连接断开后的幂等清理任务
|
||||
* @return SSE 连接
|
||||
*/
|
||||
public SseEmitter start(
|
||||
String definitionId,
|
||||
Map<String, Object> variables,
|
||||
Runnable cleanup
|
||||
) {
|
||||
return start(definitionId, variables, cleanup, Duration.ZERO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作流并将浏览器连接与 Runtime 生命周期分离。
|
||||
*
|
||||
* <p>浏览器断开后不取消工作流;在保留期内继续监听真实终态并执行清理,
|
||||
* 超过保留期时由租约兜底释放资源。</p>
|
||||
*
|
||||
* @param definitionId 工作流定义 ID
|
||||
* @param variables 运行变量
|
||||
* @param cleanup 终态、启动失败或保留期结束后的幂等清理任务
|
||||
* @param detachedRetention 浏览器断开后的监听保留时长
|
||||
* @return SSE 连接
|
||||
*/
|
||||
public SseEmitter start(
|
||||
String definitionId,
|
||||
Map<String, Object> variables,
|
||||
Runnable cleanup,
|
||||
Duration detachedRetention
|
||||
) {
|
||||
SseEmitter emitter = createEmitter();
|
||||
StreamSession session = new StreamSession(
|
||||
emitter,
|
||||
cleanup,
|
||||
detachedRetention
|
||||
);
|
||||
emitter.onTimeout(() -> detach(session));
|
||||
emitter.onError(error -> detach(session));
|
||||
emitter.onCompletion(() -> detach(session));
|
||||
|
||||
try {
|
||||
chainExecutor.executeAsync(
|
||||
definitionId,
|
||||
variables,
|
||||
executeId -> {
|
||||
session.attach(executeId);
|
||||
if (session.cleaned.get()) {
|
||||
return;
|
||||
}
|
||||
sessions.put(executeId, session);
|
||||
session.send("execution_started", Map.of(
|
||||
"executeId", executeId
|
||||
));
|
||||
}
|
||||
);
|
||||
} catch (RuntimeException | Error error) {
|
||||
session.fail(error);
|
||||
throw error;
|
||||
}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 SSE 发送器,便于验证连接生命周期。
|
||||
*/
|
||||
SseEmitter createEmitter() {
|
||||
return new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将工作流事件转发到对应执行流。
|
||||
*
|
||||
* @param event 工作流事件
|
||||
* @param chain 当前工作流
|
||||
*/
|
||||
private void onEvent(Event event, Chain chain) {
|
||||
StreamSession session = findSession(chain);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
if (event instanceof NodeStartEvent nodeStartEvent) {
|
||||
session.onNodeStarted(chain, nodeStartEvent);
|
||||
return;
|
||||
}
|
||||
if (event instanceof NodeEndEvent nodeEndEvent) {
|
||||
session.onNodeFinished(chain, nodeEndEvent);
|
||||
return;
|
||||
}
|
||||
if (event instanceof EdgeTriggerEvent edgeTriggerEvent) {
|
||||
session.onEdgeTriggered(chain, edgeTriggerEvent);
|
||||
return;
|
||||
}
|
||||
if (event instanceof EdgeConditionCheckFailedEvent failedEvent) {
|
||||
session.onEdgeConditionFailed(chain, failedEvent);
|
||||
return;
|
||||
}
|
||||
if (event instanceof ChainStatusChangeEvent statusEvent
|
||||
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
||||
session.onStatusChanged(chain, statusEvent.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将链级异常发送到客户端。
|
||||
*
|
||||
* @param error 链级异常
|
||||
* @param chain 当前工作流
|
||||
*/
|
||||
private void onChainError(Throwable error, Chain chain) {
|
||||
StreamSession session = findSession(chain);
|
||||
if (session != null
|
||||
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分离已经断开的浏览器传输,不影响工作流 Runtime。
|
||||
*
|
||||
* @param session 流会话
|
||||
*/
|
||||
private void detach(StreamSession session) {
|
||||
if (session == null || session.terminal.get()) {
|
||||
return;
|
||||
}
|
||||
session.detachTransport();
|
||||
if (session.detachedRetention.isZero()
|
||||
|| session.detachedRetention.isNegative()) {
|
||||
removeSession(session);
|
||||
return;
|
||||
}
|
||||
session.scheduleDetachedCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除流会话。
|
||||
*
|
||||
* @param session 流会话
|
||||
*/
|
||||
private void removeSession(StreamSession session) {
|
||||
if (session != null && session.executeId != null) {
|
||||
sessions.remove(session.executeId, session);
|
||||
}
|
||||
if (session != null) {
|
||||
session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取适合返回给用户的异常信息。
|
||||
*
|
||||
* @param 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 final AtomicBoolean cleaned = new AtomicBoolean(false);
|
||||
private final AtomicBoolean connected = new AtomicBoolean(true);
|
||||
private final Runnable cleanup;
|
||||
private final Duration detachedRetention;
|
||||
private volatile ScheduledFuture<?> detachedCleanup;
|
||||
private volatile String executeId;
|
||||
|
||||
/**
|
||||
* 创建流会话。
|
||||
*
|
||||
* @param emitter SSE 发送器
|
||||
*/
|
||||
private StreamSession(
|
||||
SseEmitter emitter,
|
||||
Runnable cleanup,
|
||||
Duration detachedRetention
|
||||
) {
|
||||
this.emitter = emitter;
|
||||
this.cleanup = cleanup == null ? () -> {
|
||||
} : cleanup;
|
||||
this.detachedRetention = detachedRetention == null
|
||||
? Duration.ZERO
|
||||
: detachedRetention;
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等释放当前流持有的外部资源。
|
||||
*/
|
||||
private void cleanup() {
|
||||
if (!cleaned.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
cancelDetachedCleanup();
|
||||
try {
|
||||
cleanup.run();
|
||||
} catch (RuntimeException error) {
|
||||
log.warn(
|
||||
"workflow chat stream cleanup failed, executeId={}",
|
||||
executeId,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定执行实例。
|
||||
*
|
||||
* @param executeId 执行实例 ID
|
||||
*/
|
||||
private void attach(String executeId) {
|
||||
this.executeId = executeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记浏览器传输已经断开,后续事件只推进 Runtime 清理。
|
||||
*/
|
||||
private void detachTransport() {
|
||||
connected.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器断开后按活动租约安排会话兜底清理。
|
||||
*/
|
||||
private synchronized void scheduleDetachedCleanup() {
|
||||
if (detachedCleanup != null || cleaned.get()) {
|
||||
return;
|
||||
}
|
||||
detachedCleanup = detachedSessionCleaner.schedule(
|
||||
() -> removeSession(this),
|
||||
Math.max(1L, detachedRetention.toMillis()),
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消尚未触发的断开会话兜底任务。
|
||||
*/
|
||||
private synchronized void cancelDetachedCleanup() {
|
||||
if (detachedCleanup == null) {
|
||||
return;
|
||||
}
|
||||
detachedCleanup.cancel(false);
|
||||
detachedCleanup = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理节点开始事件。
|
||||
*
|
||||
* @param chain 当前工作流
|
||||
* @param event 节点开始事件
|
||||
*/
|
||||
private void onNodeStarted(Chain chain, NodeStartEvent event) {
|
||||
Node node = event.getNode();
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("attemptKey", event.getExecutionAttemptKey());
|
||||
data.put("nodeClass", node.getClass().getSimpleName());
|
||||
data.put("chainInstanceId", chain.getStateInstanceId());
|
||||
data.put("startedAt", System.currentTimeMillis());
|
||||
data.put("input", resolveNodeInput(chain, node));
|
||||
send("node_started", nodePayload(node, data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理节点完成事件并更新运行详情。
|
||||
*
|
||||
* @param chain 当前工作流
|
||||
* @param event 节点完成事件
|
||||
*/
|
||||
private void onNodeFinished(Chain chain, NodeEndEvent event) {
|
||||
Node node = event.getNode();
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("attemptKey", event.getExecutionAttemptKey());
|
||||
data.put("status", event.getStatus() == null
|
||||
? null
|
||||
: event.getStatus().name());
|
||||
data.put("chainInstanceId", chain.getStateInstanceId());
|
||||
data.put("finishedAt", System.currentTimeMillis());
|
||||
data.put("output", event.getResult() == null
|
||||
? Map.of()
|
||||
: event.getResult());
|
||||
if (event.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);
|
||||
if (connected.compareAndSet(true, false)) {
|
||||
emitter.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 SSE 事件。
|
||||
*
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据
|
||||
*/
|
||||
private void send(String type, Map<String, ?> data) {
|
||||
if (!connected.get()) {
|
||||
return;
|
||||
}
|
||||
long nextSequence = sequence.incrementAndGet();
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("eventId", executeId + ":" + nextSequence);
|
||||
payload.put("sequence", nextSequence);
|
||||
payload.put("executeId", executeId);
|
||||
payload.put("type", type);
|
||||
payload.put("data", data);
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.id(String.valueOf(nextSequence))
|
||||
.name("workflow")
|
||||
// 显式发送 JSON 文本,避免全局 CBOR 转换器将 SSE data 编码为二进制。
|
||||
.data(JSON.toJSONString(payload)));
|
||||
} catch (IOException | IllegalStateException error) {
|
||||
log.debug(
|
||||
"workflow chat stream disconnected, executeId={}",
|
||||
executeId,
|
||||
error
|
||||
);
|
||||
detach(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在启动失败时关闭 SSE 会话。
|
||||
*
|
||||
* @param error 启动异常
|
||||
*/
|
||||
private void fail(Throwable error) {
|
||||
if (terminal.compareAndSet(false, true)) {
|
||||
send("execution_failed", Map.of(
|
||||
"message", safeErrorMessage(error)
|
||||
));
|
||||
removeSession(this);
|
||||
if (connected.compareAndSet(true, false)) {
|
||||
emitter.completeWithError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建带节点信息的事件数据。
|
||||
*
|
||||
* @param node 当前节点
|
||||
* @param values 业务数据
|
||||
* @return 事件数据
|
||||
*/
|
||||
private Map<String, Object> nodePayload(
|
||||
Node node,
|
||||
Map<String, ?> values
|
||||
) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("nodeId", node == null ? null : node.getId());
|
||||
payload.put("nodeName", node == null ? null : node.getName());
|
||||
if (values != null) {
|
||||
payload.putAll(values);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.Node;
|
||||
import com.easyagents.flow.core.node.ConfirmNode;
|
||||
import com.easyagents.flow.core.node.EndNode;
|
||||
import com.easyagents.flow.core.node.StartNode;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||
import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.entity.ModelProvider;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.enums.PluginType;
|
||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.PluginService;
|
||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||
import tech.easyflow.datacenter.entity.DatacenterTableField;
|
||||
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
|
||||
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||
import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
|
||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
|
||||
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
|
||||
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 查询工作流设计器所需的场景化安全选项。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowDesignerOptionService {
|
||||
|
||||
private final ModelService modelService;
|
||||
private final DocumentCollectionService documentCollectionService;
|
||||
private final PluginService pluginService;
|
||||
private final PluginItemService pluginItemService;
|
||||
private final PluginVisibilityService pluginVisibilityService;
|
||||
private final WorkflowService workflowService;
|
||||
private final WorkflowUsageAuthorizationService workflowUsageAuthorizationService;
|
||||
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
|
||||
private final ChainParser chainParser;
|
||||
private final WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final DatacenterSourceService datacenterSourceService;
|
||||
private final DatacenterDatasetRegistryService datacenterDatasetRegistryService;
|
||||
private final DatacenterDatasetQueryService datacenterDatasetQueryService;
|
||||
private final WorkflowKnowledgeContractService workflowKnowledgeContractService;
|
||||
|
||||
/**
|
||||
* 创建工作流设计器选项服务。
|
||||
*
|
||||
* @param modelService 模型服务
|
||||
* @param documentCollectionService 知识库服务
|
||||
* @param pluginService 插件服务
|
||||
* @param pluginItemService 插件工具服务
|
||||
* @param pluginVisibilityService 插件可见性服务
|
||||
* @param workflowService 工作流服务
|
||||
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
|
||||
* @param workflowPluginSnapshotResolver 工作流插件快照解析器
|
||||
* @param chainParser 工作流解析器
|
||||
* @param workflowDatacenterContentService 工作流数据中心内容服务
|
||||
* @param resourceAccessService 资源访问服务
|
||||
* @param datacenterSourceService 数据源服务
|
||||
* @param datacenterDatasetRegistryService 数据集注册服务
|
||||
* @param datacenterDatasetQueryService 数据集查询服务
|
||||
* @param workflowKnowledgeContractService 工作流知识库契约服务
|
||||
*/
|
||||
public WorkflowDesignerOptionService(
|
||||
ModelService modelService,
|
||||
DocumentCollectionService documentCollectionService,
|
||||
PluginService pluginService,
|
||||
PluginItemService pluginItemService,
|
||||
PluginVisibilityService pluginVisibilityService,
|
||||
WorkflowService workflowService,
|
||||
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
|
||||
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
|
||||
ChainParser chainParser,
|
||||
WorkflowDatacenterContentService workflowDatacenterContentService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
DatacenterSourceService datacenterSourceService,
|
||||
DatacenterDatasetRegistryService datacenterDatasetRegistryService,
|
||||
DatacenterDatasetQueryService datacenterDatasetQueryService,
|
||||
WorkflowKnowledgeContractService workflowKnowledgeContractService) {
|
||||
this.modelService = modelService;
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.pluginService = pluginService;
|
||||
this.pluginItemService = pluginItemService;
|
||||
this.pluginVisibilityService = pluginVisibilityService;
|
||||
this.workflowService = workflowService;
|
||||
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
|
||||
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
|
||||
this.chainParser = chainParser;
|
||||
this.workflowDatacenterContentService = workflowDatacenterContentService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.datacenterSourceService = datacenterSourceService;
|
||||
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
|
||||
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
|
||||
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设计器初始化所需的模型和知识库。
|
||||
*
|
||||
* @param codeEngines 代码执行引擎选项
|
||||
* @return 设计器初始化选项
|
||||
*/
|
||||
public WorkflowDesignerOptionsView listOptions(List<Map<String, Object>> codeEngines) {
|
||||
LoginAccount account = requireAccount();
|
||||
return new WorkflowDesignerOptionsView(
|
||||
listModelOptions(account),
|
||||
listKnowledgeOptions(account),
|
||||
codeEngines == null ? List.of() : codeEngines
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工作流内容引用的场景资源是否仍允许当前账号使用。
|
||||
*
|
||||
* @param content 工作流内容
|
||||
* @throws BusinessException 内容引用越权、跨租户或资源失效时抛出
|
||||
*/
|
||||
public void assertContentReferences(String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
return;
|
||||
}
|
||||
JSONObject root;
|
||||
try {
|
||||
root = JSON.parseObject(content);
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException("工作流内容不是合法JSON");
|
||||
}
|
||||
JSONArray nodes = root.getJSONArray("nodes");
|
||||
if (nodes == null || nodes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
LoginAccount account = requireAccount();
|
||||
Set<BigInteger> modelIds = new HashSet<>();
|
||||
Set<BigInteger> knowledgeIds = new HashSet<>();
|
||||
List<List<BigInteger>> knowledgeGroups = new ArrayList<>();
|
||||
Set<BigInteger> checkedPluginItemIds = new HashSet<>();
|
||||
Set<BigInteger> checkedWorkflowIds = new HashSet<>();
|
||||
Set<BigInteger> checkedSourceIds = new HashSet<>();
|
||||
Set<String> checkedTableIds = new HashSet<>();
|
||||
|
||||
for (int index = 0; index < nodes.size(); index++) {
|
||||
JSONObject node = nodes.getJSONObject(index);
|
||||
if (node == null) {
|
||||
continue;
|
||||
}
|
||||
JSONObject data = node.getJSONObject("data");
|
||||
if (data == null) {
|
||||
continue;
|
||||
}
|
||||
String nodeType = node.getString("type");
|
||||
String dataType = data.getString("type");
|
||||
if (nodeType != null && !nodeType.isBlank()
|
||||
&& dataType != null && !dataType.isBlank()
|
||||
&& !Objects.equals(nodeType, dataType)) {
|
||||
throw new BusinessException("工作流节点类型与节点数据类型不一致");
|
||||
}
|
||||
if (nodeType == null || nodeType.isBlank()) {
|
||||
nodeType = dataType;
|
||||
}
|
||||
if ("llmNode".equals(nodeType)) {
|
||||
addReferenceId(modelIds, readReferenceId(data, "llmId", "模型"));
|
||||
} else if ("knowledgeNode".equals(nodeType)) {
|
||||
List<BigInteger> nodeKnowledgeIds = readKnowledgeReferenceIds(data);
|
||||
knowledgeIds.addAll(nodeKnowledgeIds);
|
||||
knowledgeGroups.add(nodeKnowledgeIds);
|
||||
} else if ("plugin-node".equals(nodeType)) {
|
||||
BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具");
|
||||
if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) {
|
||||
getPluginTinyFlowData(pluginItemId);
|
||||
}
|
||||
} else if ("workflow-node".equals(nodeType)) {
|
||||
assertWorkflowReference(readReferenceId(data, "workflowId", "子流程"),
|
||||
account, checkedWorkflowIds);
|
||||
}
|
||||
assertDatasetReference(data, account, checkedSourceIds, checkedTableIds);
|
||||
}
|
||||
assertModelReferences(modelIds, account);
|
||||
assertKnowledgeReferences(knowledgeIds, account);
|
||||
workflowKnowledgeContractService.assertMultiKnowledgeContracts(
|
||||
knowledgeGroups, account.getTenantId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询当前账号可用于工作流的插件。
|
||||
*
|
||||
* @param pageNumber 页码
|
||||
* @param pageSize 每页数量
|
||||
* @param keyword 插件名称或描述关键字
|
||||
* @return 插件安全选项分页
|
||||
*/
|
||||
public Page<WorkflowDesignerOptionsView.PluginOption> pagePlugins(
|
||||
Long pageNumber, Long pageSize, String keyword) {
|
||||
LoginAccount account = requireAccount();
|
||||
QueryWrapper wrapper = QueryWrapper.create()
|
||||
.eq(Plugin::getTenantId, account.getTenantId().longValue())
|
||||
.orderBy(Plugin::getCreated, false);
|
||||
List<Plugin> plugins = pluginService.getMapper().selectListWithRelationsByQuery(wrapper);
|
||||
String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
List<Plugin> availablePlugins = pluginService.preparePluginsForCurrentUser(plugins, false, true).stream()
|
||||
.filter(plugin -> matchesKeyword(normalizedKeyword, plugin.getName(), plugin.getDescription()))
|
||||
.toList();
|
||||
List<WorkflowDesignerOptionsView.PluginOption> options = availablePlugins.stream()
|
||||
.map(this::toPluginOption)
|
||||
.toList();
|
||||
long actualPageNumber = pageNumber == null || pageNumber < 1 ? 1L : pageNumber;
|
||||
long actualPageSize = pageSize == null || pageSize < 1 ? 10L : Math.min(pageSize, 100L);
|
||||
int fromIndex = Math.toIntExact(Math.min(options.size(), (actualPageNumber - 1) * actualPageSize));
|
||||
int toIndex = Math.toIntExact(Math.min(options.size(), fromIndex + actualPageSize));
|
||||
return new Page<>(
|
||||
options.subList(fromIndex, toIndex),
|
||||
actualPageNumber,
|
||||
actualPageSize,
|
||||
options.size()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断任一候选文本是否包含关键字。
|
||||
*
|
||||
* @param keyword 已归一化的小写关键字
|
||||
* @param values 候选文本
|
||||
* @return 空关键字或任一文本命中时返回 {@code true}
|
||||
*/
|
||||
private boolean matchesKeyword(String keyword, String... values) {
|
||||
if (keyword == null || keyword.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && value.toLowerCase(java.util.Locale.ROOT).contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询一个插件工具的工作流节点安全配置。
|
||||
*
|
||||
* @param pluginItemId 插件工具 ID
|
||||
* @return 节点配置
|
||||
* @throws BusinessException 插件工具不存在或不可用时抛出
|
||||
*/
|
||||
public JSONObject getPluginTinyFlowData(BigInteger pluginItemId) {
|
||||
if (pluginItemId == null) {
|
||||
throw new BusinessException("插件工具不能为空");
|
||||
}
|
||||
PluginItem record = pluginItemService.getById(pluginItemId);
|
||||
if (record == null || !Integer.valueOf(1).equals(record.getStatus())) {
|
||||
throw new BusinessException("插件工具不存在或已禁用");
|
||||
}
|
||||
Plugin plugin = pluginService.getById(record.getPluginId());
|
||||
if (plugin == null) {
|
||||
throw new BusinessException("插件不存在");
|
||||
}
|
||||
LoginAccount account = requireAccount();
|
||||
if (!Objects.equals(plugin.getTenantId(), account.getTenantId().longValue())) {
|
||||
throw new BusinessException("无权限访问插件");
|
||||
}
|
||||
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件");
|
||||
Plugin preparedPlugin = pluginService.preparePluginForCurrentUser(plugin);
|
||||
if (Boolean.FALSE.equals(preparedPlugin.getAvailable())) {
|
||||
throw new BusinessException(preparedPlugin.getReasonMessage());
|
||||
}
|
||||
|
||||
JSONObject nodeData = new JSONObject();
|
||||
nodeData.put("pluginId", record.getId().toString());
|
||||
nodeData.put("pluginName", record.getName());
|
||||
nodeData.put("pluginType", preparedPlugin.getType());
|
||||
nodeData.put("workflowId", preparedPlugin.getWorkflowId());
|
||||
nodeData.put("workflowTitle", preparedPlugin.getWorkflowTitle());
|
||||
nodeData.put("available", preparedPlugin.getAvailable());
|
||||
nodeData.put("reasonCode", preparedPlugin.getReasonCode());
|
||||
nodeData.put("reasonMessage", preparedPlugin.getReasonMessage());
|
||||
nodeData.put("parameters", parseSchemaArray(record.getInputData()));
|
||||
nodeData.put("outputDefs", parseSchemaArray(record.getOutputData()));
|
||||
nodeData.put("schemaHash", resolveSchemaHash(record, preparedPlugin));
|
||||
return nodeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询子流程节点所需的输入输出定义。
|
||||
*
|
||||
* @param currentWorkflowId 当前工作流 ID
|
||||
* @param childWorkflowId 子流程 ID
|
||||
* @return 子流程节点配置
|
||||
* @throws BusinessException 子流程不存在、不可用或无权使用时抛出
|
||||
*/
|
||||
public JSONObject getChildWorkflowNodeData(
|
||||
BigInteger currentWorkflowId,
|
||||
BigInteger childWorkflowId) {
|
||||
if (childWorkflowId == null) {
|
||||
throw new BusinessException("子流程不能为空");
|
||||
}
|
||||
if (Objects.equals(childWorkflowId, currentWorkflowId)) {
|
||||
throw new BusinessException("工作流不能作为自身子节点");
|
||||
}
|
||||
LoginAccount account = requireAccount();
|
||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||
childWorkflowId,
|
||||
account,
|
||||
"子流程不存在、未发布或无权使用");
|
||||
assertContentReferences(workflow.getContent());
|
||||
|
||||
ChainDefinition definition = chainParser.parse(
|
||||
workflowDatacenterContentService.prepareContent(workflow.getContent()));
|
||||
JSONArray inputs = new JSONArray();
|
||||
JSONArray outputs = new JSONArray();
|
||||
for (Node node : definition.getNodes()) {
|
||||
if (node instanceof StartNode) {
|
||||
inputs = JSON.parseArray(JSON.toJSONString(node.getParameters()));
|
||||
decorateChildWorkflowSchemaArray(inputs);
|
||||
}
|
||||
if (node instanceof EndNode endNode) {
|
||||
outputs = JSON.parseArray(JSON.toJSONString(endNode.getOutputDefs()));
|
||||
decorateChildWorkflowSchemaArray(outputs);
|
||||
}
|
||||
if (node instanceof ConfirmNode) {
|
||||
throw new BusinessException("工作流存在【确认节点】,暂不支持作为子节点");
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject nodeData = new JSONObject();
|
||||
nodeData.put("workflowId", workflow.getId());
|
||||
nodeData.put("workflowName", workflow.getTitle());
|
||||
nodeData.put("parameters", inputs);
|
||||
nodeData.put("outputDefs", outputs);
|
||||
return nodeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前租户可用于工作流的数据源安全选项。
|
||||
*
|
||||
* @return 数据源安全选项
|
||||
*/
|
||||
public List<WorkflowDesignerOptionsView.DataSourceOption> listDataSources() {
|
||||
LoginAccount account = requireAccount();
|
||||
datacenterDatasetRegistryService.ensureBuiltinSource(DatacenterSourceType.PROJECT_MYSQL, account);
|
||||
return datacenterSourceService.list(QueryWrapper.create()
|
||||
.eq(DatacenterSource::getTenantId, account.getTenantId())
|
||||
.orderBy(DatacenterSource::getModified, false))
|
||||
.stream()
|
||||
.map(source -> new WorkflowDesignerOptionsView.DataSourceOption(
|
||||
source.getId(), source.getSourceName(), source.getSourceType()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询一个数据源的目录安全选项。
|
||||
*
|
||||
* @param sourceId 数据源 ID
|
||||
* @return 目录安全选项
|
||||
*/
|
||||
public List<WorkflowDesignerOptionsView.CatalogOption> listCatalogs(BigInteger sourceId) {
|
||||
LoginAccount account = requireAccount();
|
||||
requireTenantSource(sourceId, account);
|
||||
return datacenterSourceService.listCatalogs(sourceId, account).stream()
|
||||
.map(this::toCatalogOption)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询一个数据源目录下已接入的数据集。
|
||||
*
|
||||
* @param sourceId 数据源 ID
|
||||
* @param catalogId 目录 ID
|
||||
* @return 数据集安全选项
|
||||
*/
|
||||
public List<WorkflowDesignerOptionsView.DatasetOption> listManagedDatasets(
|
||||
BigInteger sourceId,
|
||||
BigInteger catalogId) {
|
||||
LoginAccount account = requireAccount();
|
||||
requireTenantSource(sourceId, account);
|
||||
return datacenterDatasetRegistryService.listManagedTables(sourceId, catalogId).stream()
|
||||
.filter(table -> Objects.equals(table.getTenantId(), account.getTenantId()))
|
||||
.filter(table -> Objects.equals(table.getSourceId(), sourceId))
|
||||
.filter(table -> catalogId == null || Objects.equals(table.getCatalogId(), catalogId))
|
||||
.map(this::toDatasetOption)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询工作流数据节点所需的数据集结构。
|
||||
*
|
||||
* @param datasetRef 数据集引用
|
||||
* @return 数据集结构安全视图
|
||||
*/
|
||||
public WorkflowDesignerOptionsView.DatasetSchemaOption getDatasetSchema(DatasetRef datasetRef) {
|
||||
if (datasetRef == null || datasetRef.getSourceId() == null || datasetRef.getTableId() == null) {
|
||||
throw new BusinessException("数据集引用不完整");
|
||||
}
|
||||
LoginAccount account = requireAccount();
|
||||
requireTenantSource(datasetRef.getSourceId(), account);
|
||||
DatacenterTable table = datacenterDatasetRegistryService.getTableWithFields(datasetRef.getTableId());
|
||||
if (table == null
|
||||
|| !Objects.equals(table.getTenantId(), account.getTenantId())
|
||||
|| !Objects.equals(table.getSourceId(), datasetRef.getSourceId())) {
|
||||
throw new BusinessException("数据集不存在或无权访问");
|
||||
}
|
||||
DatacenterSchemaResponse schema = datacenterDatasetQueryService.getSchema(datasetRef);
|
||||
List<DatacenterTableField> fields = schema == null || schema.getFields() == null
|
||||
? Collections.emptyList()
|
||||
: schema.getFields();
|
||||
return new WorkflowDesignerOptionsView.DatasetSchemaOption(
|
||||
table.getTableName(),
|
||||
table.getTableDesc(),
|
||||
fields.stream()
|
||||
.map(field -> new WorkflowDesignerOptionsView.DatasetFieldOption(
|
||||
field.getFieldName(),
|
||||
field.getFieldDesc(),
|
||||
field.getJdbcType(),
|
||||
field.getFieldType()
|
||||
))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
private List<WorkflowDesignerOptionsView.ModelOption> listModelOptions(LoginAccount account) {
|
||||
Model query = new Model();
|
||||
query.setTenantId(account.getTenantId());
|
||||
query.setModelType(Model.MODEL_TYPES[0]);
|
||||
return modelService.listSelectableModels(query, false, "id", "desc").stream()
|
||||
.filter(model -> Objects.equals(model.getTenantId(), account.getTenantId()))
|
||||
.map(model -> {
|
||||
ModelProvider provider = model.getModelProvider();
|
||||
WorkflowDesignerOptionsView.ProviderOption providerOption = provider == null
|
||||
? null
|
||||
: new WorkflowDesignerOptionsView.ProviderOption(
|
||||
provider.getProviderName(),
|
||||
provider.getProviderType(),
|
||||
provider.getIcon()
|
||||
);
|
||||
return new WorkflowDesignerOptionsView.ModelOption(
|
||||
model.getId(),
|
||||
model.getTitle(),
|
||||
model.getDescription(),
|
||||
providerOption
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
|
||||
List<DocumentCollection> collections = documentCollectionService.list(QueryWrapper.create()
|
||||
.eq(DocumentCollection::getTenantId, account.getTenantId())
|
||||
.orderBy(DocumentCollection::getModified, false));
|
||||
Set<BigInteger> vectorReadyIds = workflowKnowledgeContractService
|
||||
.findVectorReadyKnowledgeIds(collections, account.getTenantId());
|
||||
return collections.stream()
|
||||
.filter(item -> resourceAccessService.canAccess(
|
||||
account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
|
||||
.map(item -> new WorkflowDesignerOptionsView.KnowledgeOption(
|
||||
item.getId(),
|
||||
item.getTitle(),
|
||||
item.getDescription(),
|
||||
item.getVectorEmbedModelId(),
|
||||
item.getDimensionOfVectorModel(),
|
||||
vectorReadyIds.contains(item.getId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<BigInteger> readKnowledgeReferenceIds(JSONObject data) {
|
||||
if (data.containsKey("knowledgeIds")) {
|
||||
Object rawIds = data.get("knowledgeIds");
|
||||
if (!(rawIds instanceof JSONArray ids) || ids.isEmpty()) {
|
||||
throw new BusinessException("知识库节点至少需要选择一个知识库");
|
||||
}
|
||||
List<BigInteger> result = new ArrayList<>();
|
||||
for (Object id : ids) {
|
||||
BigInteger parsed = parseReferenceId(id, "知识库");
|
||||
if (result.contains(parsed)) {
|
||||
throw new BusinessException("知识库节点不能重复选择同一知识库");
|
||||
}
|
||||
result.add(parsed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
BigInteger legacyId = readReferenceId(data, "knowledgeId", "知识库");
|
||||
return legacyId == null ? List.of() : List.of(legacyId);
|
||||
}
|
||||
|
||||
private BigInteger parseReferenceId(Object value, String resourceName) {
|
||||
if (value == null || String.valueOf(value).isBlank()) {
|
||||
throw new BusinessException(resourceName + "ID不能为空");
|
||||
}
|
||||
try {
|
||||
return new BigInteger(String.valueOf(value));
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new BusinessException(resourceName + "ID无效");
|
||||
}
|
||||
}
|
||||
|
||||
private void addReferenceId(Set<BigInteger> resourceIds, BigInteger resourceId) {
|
||||
if (resourceId != null) {
|
||||
resourceIds.add(resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertModelReferences(Set<BigInteger> modelIds, LoginAccount account) {
|
||||
if (modelIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Model> models = modelService.listByIds(modelIds);
|
||||
boolean valid = models.size() == modelIds.size()
|
||||
&& models.stream().allMatch(model ->
|
||||
modelIds.contains(model.getId())
|
||||
&& Objects.equals(model.getTenantId(), account.getTenantId())
|
||||
&& Model.MODEL_TYPES[0].equals(model.getModelType()));
|
||||
if (!valid) {
|
||||
throw new BusinessException("模型不存在、已失效或无权使用");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertKnowledgeReferences(Set<BigInteger> knowledgeIds, LoginAccount account) {
|
||||
if (knowledgeIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<DocumentCollection> knowledges = documentCollectionService.listByIds(knowledgeIds);
|
||||
boolean valid = knowledges.size() == knowledgeIds.size()
|
||||
&& knowledges.stream().allMatch(knowledge ->
|
||||
knowledgeIds.contains(knowledge.getId())
|
||||
&& Objects.equals(knowledge.getTenantId(), account.getTenantId())
|
||||
&& resourceAccessService.canAccess(
|
||||
account, CategoryResourceType.KNOWLEDGE, knowledge, ResourceAction.USE));
|
||||
if (!valid) {
|
||||
throw new BusinessException("知识库不存在、已失效或无权使用");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertWorkflowReference(
|
||||
BigInteger workflowId,
|
||||
LoginAccount account,
|
||||
Set<BigInteger> checkedWorkflowIds) {
|
||||
if (workflowId == null || !checkedWorkflowIds.add(workflowId)) {
|
||||
return;
|
||||
}
|
||||
workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||
workflowId,
|
||||
account,
|
||||
"子流程不存在、未发布或无权使用");
|
||||
}
|
||||
|
||||
private void assertDatasetReference(
|
||||
JSONObject data,
|
||||
LoginAccount account,
|
||||
Set<BigInteger> checkedSourceIds,
|
||||
Set<String> checkedTableIds) {
|
||||
JSONObject datasetRef = data.getJSONObject("datasetRef");
|
||||
if (datasetRef == null) {
|
||||
return;
|
||||
}
|
||||
BigInteger sourceId = readReferenceId(datasetRef, "sourceId", "数据源");
|
||||
if (sourceId != null && checkedSourceIds.add(sourceId)) {
|
||||
requireTenantSource(sourceId, account);
|
||||
}
|
||||
BigInteger tableId = readReferenceId(datasetRef, "tableId", "数据集");
|
||||
if (tableId == null) {
|
||||
return;
|
||||
}
|
||||
if (sourceId == null) {
|
||||
throw new BusinessException("数据集缺少数据源引用");
|
||||
}
|
||||
String checkedKey = sourceId + ":" + tableId;
|
||||
if (!checkedTableIds.add(checkedKey)) {
|
||||
return;
|
||||
}
|
||||
DatacenterTable table = datacenterDatasetRegistryService.getTableWithFields(tableId);
|
||||
if (table == null
|
||||
|| !Objects.equals(table.getTenantId(), account.getTenantId())
|
||||
|| !Objects.equals(table.getSourceId(), sourceId)) {
|
||||
throw new BusinessException("数据集不存在或无权使用");
|
||||
}
|
||||
}
|
||||
|
||||
private BigInteger readReferenceId(JSONObject data, String key, String resourceName) {
|
||||
Object value = data.get(key);
|
||||
if (value == null || String.valueOf(value).isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigInteger(String.valueOf(value));
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new BusinessException(resourceName + "引用格式无效");
|
||||
}
|
||||
}
|
||||
|
||||
private WorkflowDesignerOptionsView.PluginOption toPluginOption(Plugin plugin) {
|
||||
List<WorkflowDesignerOptionsView.PluginToolOption> tools =
|
||||
plugin.getTools() == null ? List.of() : plugin.getTools().stream()
|
||||
.filter(tool -> Integer.valueOf(1).equals(tool.getStatus()))
|
||||
.map(tool -> new WorkflowDesignerOptionsView.PluginToolOption(
|
||||
tool.getId(), tool.getName(), tool.getDescription()))
|
||||
.toList();
|
||||
return new WorkflowDesignerOptionsView.PluginOption(
|
||||
plugin.getId(),
|
||||
plugin.getName(),
|
||||
plugin.getDescription(),
|
||||
plugin.getIcon(),
|
||||
tools
|
||||
);
|
||||
}
|
||||
|
||||
private JSONArray parseSchemaArray(String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
return new JSONArray();
|
||||
}
|
||||
JSONArray array = JSON.parseArray(content);
|
||||
decorateSchemaArray(array);
|
||||
return array;
|
||||
}
|
||||
|
||||
private void decorateSchemaArray(JSONArray array) {
|
||||
for (Object item : array) {
|
||||
if (!(item instanceof JSONObject value)) {
|
||||
continue;
|
||||
}
|
||||
value.put("id", cn.hutool.core.util.IdUtil.simpleUUID());
|
||||
value.put("nameDisabled", true);
|
||||
value.put("dataTypeDisabled", true);
|
||||
value.put("deleteDisabled", true);
|
||||
value.put("addChildDisabled", true);
|
||||
JSONArray children = value.getJSONArray("children");
|
||||
if (children != null) {
|
||||
decorateSchemaArray(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为子流程参数补充设计器只读元数据。
|
||||
*
|
||||
* @param array 子流程参数定义
|
||||
*/
|
||||
private void decorateChildWorkflowSchemaArray(JSONArray array) {
|
||||
for (Object item : array) {
|
||||
if (!(item instanceof JSONObject value)) {
|
||||
continue;
|
||||
}
|
||||
value.put("id", cn.hutool.core.util.IdUtil.simpleUUID());
|
||||
value.put("nameDisabled", true);
|
||||
value.put("dataTypeDisabled", true);
|
||||
value.put("deleteDisabled", true);
|
||||
value.put("addChildDisabled", true);
|
||||
value.put("refType", "ref");
|
||||
JSONArray children = value.getJSONArray("children");
|
||||
if (children != null) {
|
||||
decorateChildWorkflowSchemaArray(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveSchemaHash(PluginItem record, Plugin plugin) {
|
||||
if (record.getSchemaHash() != null && !record.getSchemaHash().isBlank()) {
|
||||
return record.getSchemaHash();
|
||||
}
|
||||
if (!PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
|
||||
return null;
|
||||
}
|
||||
Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
|
||||
return workflow == null ? null : workflowPluginSnapshotResolver.resolveSchemaHash(workflow);
|
||||
}
|
||||
|
||||
private WorkflowDesignerOptionsView.CatalogOption toCatalogOption(DatacenterCatalogMeta catalog) {
|
||||
return new WorkflowDesignerOptionsView.CatalogOption(
|
||||
catalog.getId(),
|
||||
catalog.getSourceId(),
|
||||
catalog.getCatalogName(),
|
||||
catalog.getCatalogDesc()
|
||||
);
|
||||
}
|
||||
|
||||
private WorkflowDesignerOptionsView.DatasetOption toDatasetOption(DatacenterTable table) {
|
||||
return new WorkflowDesignerOptionsView.DatasetOption(
|
||||
table.getId(),
|
||||
table.getTenantId(),
|
||||
table.getSourceId(),
|
||||
table.getCatalogId(),
|
||||
table.getTableName(),
|
||||
table.getTableDesc()
|
||||
);
|
||||
}
|
||||
|
||||
private DatacenterSource requireTenantSource(BigInteger sourceId, LoginAccount account) {
|
||||
if (sourceId == null) {
|
||||
throw new BusinessException("数据源不能为空");
|
||||
}
|
||||
DatacenterSource source = datacenterSourceService.getById(sourceId);
|
||||
if (source == null || !Objects.equals(source.getTenantId(), account.getTenantId())) {
|
||||
throw new BusinessException("数据源不存在或无权访问");
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
private LoginAccount requireAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作流匿名分享的限流与活动执行互斥保护。
|
||||
*/
|
||||
@Component
|
||||
public class WorkflowPublicChatAccessGuard {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(
|
||||
WorkflowPublicChatAccessGuard.class);
|
||||
private static final String KEY_PREFIX = "easyflow:workflow-public-share:";
|
||||
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
|
||||
|
||||
static {
|
||||
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
|
||||
RATE_LIMIT_SCRIPT.setScriptText(
|
||||
"local visitor = redis.call('incr', KEYS[1]); "
|
||||
+ "if visitor == 1 then redis.call('pexpire', KEYS[1], ARGV[3]); end; "
|
||||
+ "local share = redis.call('incr', KEYS[2]); "
|
||||
+ "if share == 1 then redis.call('pexpire', KEYS[2], ARGV[3]); end; "
|
||||
+ "if visitor > tonumber(ARGV[1]) or share > tonumber(ARGV[2]) "
|
||||
+ "then return 0 else return 1 end"
|
||||
);
|
||||
RATE_LIMIT_SCRIPT.setResultType(Long.class);
|
||||
}
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final RedisLockExecutor redisLockExecutor;
|
||||
private final WorkflowPublicShareProperties properties;
|
||||
|
||||
public WorkflowPublicChatAccessGuard(
|
||||
StringRedisTemplate redisTemplate,
|
||||
RedisLockExecutor redisLockExecutor,
|
||||
WorkflowPublicShareProperties properties
|
||||
) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.redisLockExecutor = redisLockExecutor;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查匿名运行固定窗口限流。
|
||||
*/
|
||||
public void checkRun(BigInteger shareId, String visitorDigest) {
|
||||
checkRate(
|
||||
shareId,
|
||||
visitorDigest,
|
||||
"run",
|
||||
properties.getRunVisitorLimit(),
|
||||
properties.getRunShareLimit()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查匿名上传固定窗口限流。
|
||||
*/
|
||||
public void checkUpload(BigInteger shareId, String visitorDigest) {
|
||||
checkRate(
|
||||
shareId,
|
||||
visitorDigest,
|
||||
"upload",
|
||||
properties.getUploadVisitorLimit(),
|
||||
properties.getUploadShareLimit()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同一分享访客的活动执行锁。
|
||||
*
|
||||
* @return 由 SSE 生命周期显式释放的锁句柄
|
||||
*/
|
||||
public RedisLockExecutor.LockHandle acquireActivity(
|
||||
BigInteger shareId,
|
||||
String visitorDigest
|
||||
) {
|
||||
try {
|
||||
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
|
||||
KEY_PREFIX + "{" + shareId + "}:active:" + visitorDigest,
|
||||
Duration.ZERO,
|
||||
properties.getActiveLease()
|
||||
);
|
||||
if (handle == null) {
|
||||
throw new BusinessException(
|
||||
409,
|
||||
40931,
|
||||
"当前分享访客已有工作流正在运行"
|
||||
);
|
||||
}
|
||||
return handle;
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("匿名工作流活动锁暂不可用,shareId={}", shareId, exception);
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匿名活动执行锁的租约,用作浏览器断开后的监听保留上限。
|
||||
*/
|
||||
public Duration activityLease() {
|
||||
return properties.getActiveLease();
|
||||
}
|
||||
|
||||
private void checkRate(
|
||||
BigInteger shareId,
|
||||
String visitorDigest,
|
||||
String action,
|
||||
int visitorLimit,
|
||||
int shareLimit
|
||||
) {
|
||||
String slot = "{" + shareId + "}";
|
||||
List<String> keys = List.of(
|
||||
KEY_PREFIX + slot + ":rate:" + action + ":visitor:" + visitorDigest,
|
||||
KEY_PREFIX + slot + ":rate:" + action + ":share"
|
||||
);
|
||||
try {
|
||||
Long allowed = redisTemplate.execute(
|
||||
RATE_LIMIT_SCRIPT,
|
||||
keys,
|
||||
String.valueOf(visitorLimit),
|
||||
String.valueOf(shareLimit),
|
||||
String.valueOf(properties.getRateWindow().toMillis())
|
||||
);
|
||||
if (allowed == null) {
|
||||
throw unavailable(new IllegalStateException(
|
||||
"Redis 未返回匿名工作流限流结果"));
|
||||
}
|
||||
if (!Long.valueOf(1L).equals(allowed)) {
|
||||
throw new BusinessException(
|
||||
429,
|
||||
42931,
|
||||
"匿名工作流请求过于频繁,请稍后重试"
|
||||
);
|
||||
}
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("匿名工作流限流暂不可用,shareId={}, action={}",
|
||||
shareId, action, exception);
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private BusinessException unavailable(RuntimeException cause) {
|
||||
return new BusinessException(
|
||||
503,
|
||||
50331,
|
||||
"匿名工作流保护服务暂不可用,请稍后重试",
|
||||
cause
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
|
||||
/**
|
||||
* 完成匿名分享边界校验后的运行上下文。
|
||||
*/
|
||||
public record WorkflowPublicChatContext(
|
||||
WorkflowShare share,
|
||||
Workflow workflow,
|
||||
LoginAccount creator,
|
||||
String shareKey,
|
||||
String visitorDigest
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.mybatisflex.core.tenant.TenantManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowShareService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 解析并校验工作流匿名分享上下文。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowPublicChatContextResolver {
|
||||
|
||||
private static final Pattern VISITOR_PATTERN = Pattern.compile("[a-f0-9]{32}");
|
||||
|
||||
private final WorkflowShareService shareService;
|
||||
private final WorkflowService workflowService;
|
||||
private final SysAccountService accountService;
|
||||
|
||||
public WorkflowPublicChatContextResolver(
|
||||
WorkflowShareService shareService,
|
||||
WorkflowService workflowService,
|
||||
SysAccountService accountService
|
||||
) {
|
||||
this.shareService = shareService;
|
||||
this.workflowService = workflowService;
|
||||
this.accountService = accountService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析新运行、恢复与上传所需的当前有效上下文。
|
||||
*/
|
||||
public WorkflowPublicChatContext resolveActive(
|
||||
String shareKey,
|
||||
String visitorId
|
||||
) {
|
||||
String normalizedVisitor = requireVisitor(visitorId);
|
||||
WorkflowShare share = shareService.resolvePublicChatShare(shareKey);
|
||||
Workflow workflow = TenantManager.withoutTenantCondition(
|
||||
() -> workflowService.getPublishedById(share.getWorkflowId()));
|
||||
if (!isStrictlyPublished(workflow)
|
||||
|| !Objects.equals(share.getTenantId(), workflow.getTenantId())) {
|
||||
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
|
||||
}
|
||||
SysAccount account = TenantManager.withoutTenantCondition(
|
||||
() -> accountService.getById(share.getCreatedBy()));
|
||||
if (account == null
|
||||
|| !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())
|
||||
|| !Objects.equals(share.getTenantId(), account.getTenantId())) {
|
||||
throw new BusinessException(
|
||||
403,
|
||||
40331,
|
||||
"工作流分享创建者账号当前不可用"
|
||||
);
|
||||
}
|
||||
LoginAccount creator = account.toLoginAccount();
|
||||
return new WorkflowPublicChatContext(
|
||||
share,
|
||||
workflow,
|
||||
creator,
|
||||
shareKey,
|
||||
WorkflowSharePolicy.hashChatVisitor(
|
||||
shareKey,
|
||||
normalizedVisitor
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析已发起执行的详情与取消所需历史上下文。
|
||||
*/
|
||||
public WorkflowPublicChatContext resolveHistorical(
|
||||
String shareKey,
|
||||
String visitorId
|
||||
) {
|
||||
String normalizedVisitor = requireVisitor(visitorId);
|
||||
WorkflowShare share = shareService.resolveHistoricalChatShare(shareKey);
|
||||
return new WorkflowPublicChatContext(
|
||||
share,
|
||||
null,
|
||||
null,
|
||||
shareKey,
|
||||
WorkflowSharePolicy.hashChatVisitor(
|
||||
shareKey,
|
||||
normalizedVisitor
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private String requireVisitor(String visitorId) {
|
||||
String normalized = visitorId == null ? "" : visitorId.trim();
|
||||
if (!VISITOR_PATTERN.matcher(normalized).matches()) {
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40031,
|
||||
"工作流分享访客标识无效"
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private boolean isStrictlyPublished(Workflow workflow) {
|
||||
return workflow != null
|
||||
&& PublishStatus.PUBLISHED.getCode().equals(
|
||||
workflow.getPublishStatus())
|
||||
&& workflow.getPublishedSnapshotJson() != null
|
||||
&& !workflow.getPublishedSnapshotJson().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.tenant.TenantManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.vo.UploadResVo;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 工作流匿名分享对话应用服务。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowPublicChatService {
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(WorkflowPublicChatService.class);
|
||||
|
||||
private final WorkflowPublicChatContextResolver contextResolver;
|
||||
private final WorkflowCheckService workflowCheckService;
|
||||
private final WorkflowRunningParameterResolver parameterResolver;
|
||||
private final WorkflowPublicChatUploadService uploadService;
|
||||
private final WorkflowPublicChatAccessGuard accessGuard;
|
||||
private final WorkflowChatEventStream eventStream;
|
||||
private final ChainExecutor chainExecutor;
|
||||
private final WorkflowResumeService workflowResumeService;
|
||||
private final WorkflowExecResultService execResultService;
|
||||
private final WorkflowExecStepService execStepService;
|
||||
|
||||
public WorkflowPublicChatService(
|
||||
WorkflowPublicChatContextResolver contextResolver,
|
||||
WorkflowCheckService workflowCheckService,
|
||||
WorkflowRunningParameterResolver parameterResolver,
|
||||
WorkflowPublicChatUploadService uploadService,
|
||||
WorkflowPublicChatAccessGuard accessGuard,
|
||||
WorkflowChatEventStream eventStream,
|
||||
ChainExecutor chainExecutor,
|
||||
WorkflowResumeService workflowResumeService,
|
||||
WorkflowExecResultService execResultService,
|
||||
WorkflowExecStepService execStepService
|
||||
) {
|
||||
this.contextResolver = contextResolver;
|
||||
this.workflowCheckService = workflowCheckService;
|
||||
this.parameterResolver = parameterResolver;
|
||||
this.uploadService = uploadService;
|
||||
this.accessGuard = accessGuard;
|
||||
this.eventStream = eventStream;
|
||||
this.chainExecutor = chainExecutor;
|
||||
this.workflowResumeService = workflowResumeService;
|
||||
this.execResultService = execResultService;
|
||||
this.execStepService = execStepService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匿名分享的发布工作流描述。
|
||||
*/
|
||||
public Map<String, Object> descriptor(String shareKey, String visitorId) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||
shareKey, visitorId);
|
||||
checkWorkflow(context);
|
||||
Map<String, Object> descriptor = parameterResolver
|
||||
.buildRunningParametersView(context.workflow());
|
||||
if (descriptor == null) {
|
||||
throw new BusinessException("工作流输入配置无法解析");
|
||||
}
|
||||
descriptor.put("workflowId", context.workflow().getId());
|
||||
descriptor.put("publishStatus", context.workflow().getPublishStatus());
|
||||
descriptor.put("shareable", false);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动匿名分享工作流并返回 SSE。
|
||||
*/
|
||||
public SseEmitter run(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
Map<String, Object> variables
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||
shareKey, visitorId);
|
||||
accessGuard.checkRun(
|
||||
context.share().getId(),
|
||||
context.visitorDigest()
|
||||
);
|
||||
checkWorkflow(context);
|
||||
Map<String, Object> normalized = parameterResolver
|
||||
.normalizeRuntimeVariables(
|
||||
context.workflow().getContent(),
|
||||
variables
|
||||
);
|
||||
uploadService.assertOwnedUploads(context, normalized);
|
||||
normalized.put(Constants.LOGIN_USER_KEY, context.creator());
|
||||
normalized.put(
|
||||
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
|
||||
WorkFlowUtil.publicChatShareCreatedKey(
|
||||
context.share().getId())
|
||||
);
|
||||
normalized.put(
|
||||
WorkFlowUtil.CREATED_BY_MEMORY_KEY,
|
||||
context.visitorDigest()
|
||||
);
|
||||
|
||||
RedisLockExecutor.LockHandle activity = accessGuard.acquireActivity(
|
||||
context.share().getId(),
|
||||
context.visitorDigest()
|
||||
);
|
||||
try {
|
||||
return eventStream.start(
|
||||
PublishedWorkflowDefinitionIds.published(
|
||||
context.workflow().getId().toString()),
|
||||
normalized,
|
||||
activity::release,
|
||||
accessGuard.activityLease()
|
||||
);
|
||||
} catch (RuntimeException | Error error) {
|
||||
activity.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前匿名访客发起的执行详情。
|
||||
*/
|
||||
public Map<String, Object> detail(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
String executeId
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
|
||||
shareKey, visitorId);
|
||||
WorkflowExecResult record = assertExecutionOwnership(
|
||||
context, executeId);
|
||||
List<WorkflowExecStep> steps = TenantManager.withoutTenantCondition(
|
||||
() -> execStepService.list(
|
||||
QueryWrapper.create()
|
||||
.eq(WorkflowExecStep::getRecordId, record.getId())
|
||||
.orderBy(WorkflowExecStep::getStartTime, true)
|
||||
));
|
||||
return buildExecutionDetail(record, steps, runtimeView(executeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前匿名访客发起的执行。
|
||||
*/
|
||||
public boolean cancel(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
String executeId
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
|
||||
shareKey, visitorId);
|
||||
assertExecutionOwnership(context, executeId);
|
||||
return chainExecutor.cancel(executeId, "匿名访客已中止运行");
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复当前有效分享访客等待确认的执行。
|
||||
*/
|
||||
public void resume(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
String executeId,
|
||||
Map<String, Object> confirmParams
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||
shareKey, visitorId);
|
||||
assertExecutionOwnership(context, executeId);
|
||||
workflowResumeService.resume(executeId, confirmParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传当前发布快照声明的匿名输入文件。
|
||||
*/
|
||||
public UploadResVo upload(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
String parameterName,
|
||||
MultipartFile file
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||
shareKey, visitorId);
|
||||
return uploadService.upload(context, parameterName, file);
|
||||
}
|
||||
|
||||
private void checkWorkflow(WorkflowPublicChatContext context) {
|
||||
TenantManager.withoutTenantCondition(() -> {
|
||||
workflowCheckService.checkOrThrow(
|
||||
context.workflow().getContent(),
|
||||
WorkflowCheckStage.PRE_EXECUTE,
|
||||
context.workflow().getId()
|
||||
);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private WorkflowExecResult assertExecutionOwnership(
|
||||
WorkflowPublicChatContext context,
|
||||
String executeId
|
||||
) {
|
||||
if (executeId == null || executeId.isBlank()) {
|
||||
throw new BusinessException("执行ID不能为空");
|
||||
}
|
||||
WorkflowExecResult record = TenantManager.withoutTenantCondition(
|
||||
() -> execResultService.getByExecKey(executeId));
|
||||
if (record == null) {
|
||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||
}
|
||||
String expectedSource = WorkFlowUtil.publicChatShareCreatedKey(
|
||||
context.share().getId());
|
||||
if (!Objects.equals(expectedSource, record.getCreatedKey())
|
||||
|| !Objects.equals(
|
||||
context.visitorDigest(),
|
||||
record.getCreatedBy())
|
||||
|| !Objects.equals(
|
||||
context.share().getWorkflowId(),
|
||||
record.getWorkflowId())) {
|
||||
throw new BusinessException(
|
||||
403,
|
||||
40333,
|
||||
"无权限访问当前工作流执行记录"
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildExecutionDetail(
|
||||
WorkflowExecResult record,
|
||||
List<WorkflowExecStep> steps,
|
||||
Map<String, Object> runtime
|
||||
) {
|
||||
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
|
||||
for (WorkflowExecStep step : steps) {
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("id", step.getId());
|
||||
view.put("attemptKey", step.getExecKey());
|
||||
view.put("nodeId", step.getNodeId());
|
||||
view.put("nodeName", step.getNodeName());
|
||||
view.put("input", step.getInput());
|
||||
view.put("output", step.getOutput());
|
||||
view.put("status", step.getStatus());
|
||||
view.put("errorInfo", step.getErrorInfo());
|
||||
view.put("startTime", step.getStartTime());
|
||||
view.put("endTime", step.getEndTime());
|
||||
view.put("execTime", step.getExecTime());
|
||||
stepViews.add(view);
|
||||
}
|
||||
|
||||
Map<String, Object> recordView = new LinkedHashMap<>();
|
||||
recordView.put("executeId", record.getExecKey());
|
||||
recordView.put("workflowId", record.getWorkflowId());
|
||||
recordView.put("title", record.getTitle());
|
||||
recordView.put("status", record.getStatus());
|
||||
recordView.put("input", record.getInput());
|
||||
recordView.put("output", record.getOutput());
|
||||
recordView.put("errorInfo", record.getErrorInfo());
|
||||
recordView.put("startTime", record.getStartTime());
|
||||
recordView.put("endTime", record.getEndTime());
|
||||
recordView.put("execTime", record.getExecTime());
|
||||
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("record", recordView);
|
||||
detail.put("steps", stepViews);
|
||||
detail.put("runtime", runtime);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建刷新恢复所需的最小 Runtime 视图。
|
||||
*/
|
||||
private Map<String, Object> runtimeView(String executeId) {
|
||||
try {
|
||||
ChainState state = chainExecutor.getChainStateRepository()
|
||||
.load(executeId);
|
||||
if (state == null || state.getStatus() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("status", state.getStatus().name());
|
||||
view.put("statusValue", state.getStatus().getValue());
|
||||
view.put("message", state.getMessage());
|
||||
if (state.getStatus() == ChainStatus.SUSPEND) {
|
||||
view.put("parameters", state.getSuspendForParameters());
|
||||
}
|
||||
if (state.getStatus() == ChainStatus.SUCCEEDED) {
|
||||
view.put(
|
||||
"output",
|
||||
WorkflowChatEventStream.visibleFinalOutput(
|
||||
state.getExecuteResult())
|
||||
);
|
||||
}
|
||||
return view;
|
||||
} catch (RuntimeException error) {
|
||||
log.warn(
|
||||
"failed to load public workflow runtime state, executeId={}",
|
||||
executeId,
|
||||
error
|
||||
);
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.vo.UploadResVo;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 工作流匿名分享的隔离上传与运行引用校验。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowPublicChatUploadService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(
|
||||
WorkflowPublicChatUploadService.class);
|
||||
private static final long FILE_MAX_SIZE = 100L * 1024L * 1024L;
|
||||
private static final long IMAGE_MAX_SIZE = 10L * 1024L * 1024L;
|
||||
private static final Set<String> IMAGE_MIME_TYPES = Set.of(
|
||||
"image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp");
|
||||
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
|
||||
"bmp", "gif", "jpeg", "jpg", "png", "webp");
|
||||
private static final String GRANT_PREFIX = "easyflow:workflow-public-share:upload:";
|
||||
|
||||
private final WorkflowRunningParameterResolver parameterResolver;
|
||||
private final WorkflowPublicChatAccessGuard accessGuard;
|
||||
private final WorkflowPublicShareProperties properties;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final FileStorageService storageService;
|
||||
|
||||
public WorkflowPublicChatUploadService(
|
||||
WorkflowRunningParameterResolver parameterResolver,
|
||||
WorkflowPublicChatAccessGuard accessGuard,
|
||||
WorkflowPublicShareProperties properties,
|
||||
StringRedisTemplate redisTemplate,
|
||||
@Qualifier("default") FileStorageService storageService
|
||||
) {
|
||||
this.parameterResolver = parameterResolver;
|
||||
this.accessGuard = accessGuard;
|
||||
this.properties = properties;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传发布快照声明的文件或图片参数。
|
||||
*/
|
||||
public UploadResVo upload(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
MultipartFile file
|
||||
) {
|
||||
String normalizedName = requireParameterName(parameterName);
|
||||
String contentType = resolveUploadContentType(context, normalizedName);
|
||||
validateFile(file, contentType);
|
||||
accessGuard.checkUpload(
|
||||
context.share().getId(),
|
||||
context.visitorDigest()
|
||||
);
|
||||
|
||||
String path = storageService.save(
|
||||
file,
|
||||
"workflow-chat-share/" + context.share().getId()
|
||||
+ "/" + context.visitorDigest()
|
||||
);
|
||||
if (!StringUtils.hasText(path)) {
|
||||
throw new BusinessException(503, 50332, "匿名文件上传失败,请稍后重试");
|
||||
}
|
||||
try {
|
||||
redisTemplate.opsForValue().set(
|
||||
grantKey(context, normalizedName, path),
|
||||
contentType,
|
||||
grantTtl(context).toMillis(),
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
storageService.delete(path);
|
||||
} catch (RuntimeException cleanupError) {
|
||||
log.warn("匿名上传授权写入失败后清理文件失败,path={}",
|
||||
path, cleanupError);
|
||||
}
|
||||
throw new BusinessException(
|
||||
503,
|
||||
50332,
|
||||
"匿名上传保护服务暂不可用,请稍后重试",
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
UploadResVo response = new UploadResVo();
|
||||
response.setPath(path);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验公开运行引用的上传文件均属于当前分享访客和参数。
|
||||
*/
|
||||
public void assertOwnedUploads(
|
||||
WorkflowPublicChatContext context,
|
||||
Map<String, Object> variables
|
||||
) {
|
||||
Map<String, String> uploadFields = resolveUploadFields(context);
|
||||
for (Map.Entry<String, String> entry : uploadFields.entrySet()) {
|
||||
Object value = variables.get(entry.getKey());
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if ("image".equals(entry.getValue())) {
|
||||
assertOwnedImage(context, entry.getKey(), value);
|
||||
} else {
|
||||
assertOwnedFiles(context, entry.getKey(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertOwnedImage(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
Object value
|
||||
) {
|
||||
if (!(value instanceof Map<?, ?> image)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
String sourceType = trim(image.get("sourceType"));
|
||||
if ("url".equals(sourceType)) {
|
||||
String url = trim(image.get("url"));
|
||||
if (isHttpUrl(url)) {
|
||||
return;
|
||||
}
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
if (!"upload".equals(sourceType)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
assertGrant(
|
||||
context,
|
||||
parameterName,
|
||||
trim(image.get("filePath")),
|
||||
"image"
|
||||
);
|
||||
}
|
||||
|
||||
private void assertOwnedFiles(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
Object value
|
||||
) {
|
||||
if (!(value instanceof Collection<?> files)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
for (Object item : files) {
|
||||
if (!(item instanceof Map<?, ?> file)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
assertGrant(
|
||||
context,
|
||||
parameterName,
|
||||
trim(file.get("filePath")),
|
||||
"file"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertGrant(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
String path,
|
||||
String expectedContentType
|
||||
) {
|
||||
if (!StringUtils.hasText(path)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
try {
|
||||
String grantedContentType = redisTemplate.opsForValue().get(
|
||||
grantKey(context, parameterName, path));
|
||||
if (!expectedContentType.equals(grantedContentType)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BusinessException(
|
||||
503,
|
||||
50332,
|
||||
"匿名上传保护服务暂不可用,请稍后重试",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveUploadContentType(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName
|
||||
) {
|
||||
String contentType = resolveUploadFields(context).get(parameterName);
|
||||
if (contentType == null) {
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40032,
|
||||
"当前发布工作流未声明该上传参数"
|
||||
);
|
||||
}
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, String> resolveUploadFields(
|
||||
WorkflowPublicChatContext context
|
||||
) {
|
||||
Map<String, Object> descriptor = parameterResolver
|
||||
.buildRunningParametersView(context.workflow());
|
||||
if (descriptor == null) {
|
||||
throw new BusinessException("工作流输入配置无法解析");
|
||||
}
|
||||
Map<String, String> fields = new java.util.LinkedHashMap<>();
|
||||
Object rawSchema = descriptor.get("startFormSchema");
|
||||
if (!(rawSchema instanceof Collection<?> schema)) {
|
||||
return fields;
|
||||
}
|
||||
for (Object item : schema) {
|
||||
if (!(item instanceof Map<?, ?> field)) {
|
||||
continue;
|
||||
}
|
||||
String name = trim(field.get("key"));
|
||||
String contentType = trim(field.get("contentType"));
|
||||
if (StringUtils.hasText(name)
|
||||
&& ("file".equals(contentType)
|
||||
|| "image".equals(contentType))) {
|
||||
fields.put(name, contentType);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private void validateFile(MultipartFile file, String contentType) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("上传文件不能为空");
|
||||
}
|
||||
long maxSize = "image".equals(contentType)
|
||||
? IMAGE_MAX_SIZE
|
||||
: FILE_MAX_SIZE;
|
||||
if (file.getSize() > maxSize) {
|
||||
throw new BusinessException(
|
||||
"image".equals(contentType)
|
||||
? "单张图片不能超过 10 MiB"
|
||||
: "单个文件不能超过 100 MiB"
|
||||
);
|
||||
}
|
||||
if (!"image".equals(contentType)) {
|
||||
return;
|
||||
}
|
||||
String mimeType = trim(file.getContentType()).toLowerCase(Locale.ROOT);
|
||||
String filename = trim(file.getOriginalFilename());
|
||||
int dot = filename.lastIndexOf('.');
|
||||
String extension = dot < 0
|
||||
? ""
|
||||
: filename.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
if (!IMAGE_MIME_TYPES.contains(mimeType)
|
||||
&& !IMAGE_EXTENSIONS.contains(extension)) {
|
||||
throw new BusinessException("仅支持 PNG、JPEG、WebP、GIF、BMP 图片");
|
||||
}
|
||||
}
|
||||
|
||||
private Duration grantTtl(WorkflowPublicChatContext context) {
|
||||
long expiresIn = context.share().getExpiresAt().getTime()
|
||||
- System.currentTimeMillis();
|
||||
long ttl = Math.min(
|
||||
properties.getUploadGrantTtl().toMillis(),
|
||||
expiresIn
|
||||
);
|
||||
return Duration.ofMillis(Math.max(1L, ttl));
|
||||
}
|
||||
|
||||
private String grantKey(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
String path
|
||||
) {
|
||||
return GRANT_PREFIX + "{" + context.share().getId() + "}:"
|
||||
+ context.visitorDigest() + ":"
|
||||
+ WorkflowSharePolicy.hashShareKey(parameterName) + ":"
|
||||
+ WorkflowSharePolicy.hashShareKey(path);
|
||||
}
|
||||
|
||||
private String requireParameterName(String value) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (!StringUtils.hasText(normalized)) {
|
||||
throw new BusinessException("上传参数名不能为空");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String trim(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private boolean isHttpUrl(String value) {
|
||||
String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT);
|
||||
return normalized.startsWith("http://")
|
||||
|| normalized.startsWith("https://");
|
||||
}
|
||||
|
||||
private BusinessException invalidUploadReference(String parameterName) {
|
||||
return new BusinessException(
|
||||
403,
|
||||
40332,
|
||||
"上传参数 " + parameterName + " 不属于当前分享访客"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 工作流匿名分享运行保护参数。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "easyflow.workflow.public-share")
|
||||
public class WorkflowPublicShareProperties {
|
||||
|
||||
private Duration rateWindow = Duration.ofMinutes(1);
|
||||
private int runVisitorLimit = 5;
|
||||
private int runShareLimit = 60;
|
||||
private int uploadVisitorLimit = 10;
|
||||
private int uploadShareLimit = 60;
|
||||
private Duration activeLease = Duration.ofMinutes(35);
|
||||
private Duration uploadGrantTtl = Duration.ofDays(7);
|
||||
|
||||
public Duration getRateWindow() {
|
||||
return rateWindow;
|
||||
}
|
||||
|
||||
public void setRateWindow(Duration rateWindow) {
|
||||
this.rateWindow = requirePositive(rateWindow, "rateWindow");
|
||||
}
|
||||
|
||||
public int getRunVisitorLimit() {
|
||||
return runVisitorLimit;
|
||||
}
|
||||
|
||||
public void setRunVisitorLimit(int runVisitorLimit) {
|
||||
this.runVisitorLimit = requirePositive(runVisitorLimit, "runVisitorLimit");
|
||||
}
|
||||
|
||||
public int getRunShareLimit() {
|
||||
return runShareLimit;
|
||||
}
|
||||
|
||||
public void setRunShareLimit(int runShareLimit) {
|
||||
this.runShareLimit = requirePositive(runShareLimit, "runShareLimit");
|
||||
}
|
||||
|
||||
public int getUploadVisitorLimit() {
|
||||
return uploadVisitorLimit;
|
||||
}
|
||||
|
||||
public void setUploadVisitorLimit(int uploadVisitorLimit) {
|
||||
this.uploadVisitorLimit = requirePositive(uploadVisitorLimit, "uploadVisitorLimit");
|
||||
}
|
||||
|
||||
public int getUploadShareLimit() {
|
||||
return uploadShareLimit;
|
||||
}
|
||||
|
||||
public void setUploadShareLimit(int uploadShareLimit) {
|
||||
this.uploadShareLimit = requirePositive(uploadShareLimit, "uploadShareLimit");
|
||||
}
|
||||
|
||||
public Duration getActiveLease() {
|
||||
return activeLease;
|
||||
}
|
||||
|
||||
public void setActiveLease(Duration activeLease) {
|
||||
this.activeLease = requirePositive(activeLease, "activeLease");
|
||||
}
|
||||
|
||||
public Duration getUploadGrantTtl() {
|
||||
return uploadGrantTtl;
|
||||
}
|
||||
|
||||
public void setUploadGrantTtl(Duration uploadGrantTtl) {
|
||||
this.uploadGrantTtl = requirePositive(uploadGrantTtl, "uploadGrantTtl");
|
||||
}
|
||||
|
||||
private static int requirePositive(int value, String name) {
|
||||
if (value <= 0) {
|
||||
throw new IllegalArgumentException(name + " 必须大于 0");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Duration requirePositive(Duration value, String name) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw new IllegalArgumentException(name + " 必须大于 0");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -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,120 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
|
||||
import tech.easyflow.ai.entity.DocumentChunk;
|
||||
import tech.easyflow.ai.service.DocumentChunkService;
|
||||
import tech.easyflow.common.web.controller.BaseController;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.system.permission.resource.RequireResourceAccess;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 文档分块维护接口契约测试。
|
||||
*/
|
||||
public class DocumentChunkControllerContractTest {
|
||||
|
||||
@Test
|
||||
public void updateEndpointsShouldOnlyAcceptChunkIdAndContent() throws Exception {
|
||||
Method adminUpdate = DocumentChunkController.class.getDeclaredMethod(
|
||||
"update",
|
||||
DocumentChunkContentUpdateRequest.class
|
||||
);
|
||||
Method shareUpdate = ShareKnowledgeController.class.getDeclaredMethod(
|
||||
"updateDocumentChunk",
|
||||
String.class,
|
||||
DocumentChunkContentUpdateRequest.class
|
||||
);
|
||||
|
||||
Assert.assertNotNull(adminUpdate);
|
||||
Assert.assertNotNull(shareUpdate);
|
||||
assertStrictJsonBody(adminUpdate.getParameters()[0]);
|
||||
assertStrictJsonBody(shareUpdate.getParameters()[1]);
|
||||
Assert.assertEquals(
|
||||
adminUpdate.getAnnotation(RequireResourceAccess.class).idExpr(),
|
||||
"#request.id"
|
||||
);
|
||||
Set<String> fields = Arrays.stream(
|
||||
DocumentChunkContentUpdateRequest.class.getDeclaredFields()
|
||||
)
|
||||
.map(Field::getName)
|
||||
.collect(Collectors.toSet());
|
||||
Assert.assertEquals(Set.of("id", "content"), fields);
|
||||
Assert.assertEquals(
|
||||
DocumentChunkController.class.getMethod(
|
||||
"update",
|
||||
DocumentChunkContentUpdateRequest.class
|
||||
).getDeclaringClass(),
|
||||
DocumentChunkController.class
|
||||
);
|
||||
Assert.assertEquals(
|
||||
Arrays.stream(DocumentChunkController.class.getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals("update"))
|
||||
.filter(method -> !method.isBridge() && !method.isSynthetic())
|
||||
.count(),
|
||||
1L
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void controllerShouldNotExposeGenericWriteEndpoints() {
|
||||
Assert.assertEquals(DocumentChunkController.class.getSuperclass(), BaseController.class);
|
||||
Set<String> postMappings = Arrays.stream(DocumentChunkController.class.getMethods())
|
||||
.map(method -> method.getAnnotation(PostMapping.class))
|
||||
.filter(annotation -> annotation != null)
|
||||
.flatMap(annotation -> Arrays.stream(annotation.value()))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Assert.assertEquals(
|
||||
postMappings,
|
||||
Set.of("update", "removeChunk", "syncStatus", "retrySync")
|
||||
);
|
||||
Assert.assertFalse(postMappings.contains("save"));
|
||||
Assert.assertFalse(postMappings.contains("remove"));
|
||||
Assert.assertFalse(postMappings.contains("removeBatch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void adminUpdateAndDeleteShouldUseUnifiedMaintenanceService() {
|
||||
DocumentChunkService service = mock(DocumentChunkService.class);
|
||||
DocumentChunkController controller = new DocumentChunkController(service);
|
||||
|
||||
DocumentChunk current = new DocumentChunk();
|
||||
current.setId(BigInteger.ONE);
|
||||
current.setDocumentCollectionId(BigInteger.TWO);
|
||||
when(service.getById(BigInteger.ONE)).thenReturn(current);
|
||||
|
||||
DocumentChunkContentUpdateRequest request = new DocumentChunkContentUpdateRequest();
|
||||
request.setId(BigInteger.ONE);
|
||||
request.setContent("updated");
|
||||
controller.update(request);
|
||||
controller.removeChunk(BigInteger.ONE);
|
||||
|
||||
verify(service).updateContent(BigInteger.TWO, BigInteger.ONE, "updated");
|
||||
verify(service).deleteChunk(BigInteger.TWO, BigInteger.ONE);
|
||||
verify(service, never()).updateById(any(DocumentChunk.class));
|
||||
verify(service, never()).removeById(any());
|
||||
}
|
||||
|
||||
private static void assertStrictJsonBody(Parameter parameter) {
|
||||
JsonBody jsonBody = parameter.getAnnotation(JsonBody.class);
|
||||
Assert.assertNotNull(jsonBody);
|
||||
Assert.assertTrue(jsonBody.required());
|
||||
Assert.assertFalse(jsonBody.skipConvertError());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user