17 Commits

Author SHA1 Message Date
ageerle
8e3b286190 fix: restore admin-managed model keys and refresh cached clients 2026-09-21 11:08:05 +08:00
ageerle
91ca91571b test: cover external plan step visibility 2026-09-21 11:07:37 +08:00
ageerle
ce9228c5bb fix: preserve application Jackson settings for tenant requests 2026-09-21 11:07:36 +08:00
ageerle
6f19c86fe6 Merge pull request #337 from eroXianYin/main
知识库检索节点补齐严格模式、默认回复及节点注册
2026-09-21 11:02:32 +08:00
SUNLIZHAO\孙立钊
422d935b26 知识库检索节点补齐严格模式、默认回复及节点注册
- KnowledgeRetrievalNodeConfig 新增 is_strict / default_response 字段
- KnowledgeRetrievalNode 检索无结果时,严格模式开启返回默认回复,关闭返回空
- 检索模式优先级修正:节点明确选择覆盖知识库的混合检索配置
- 新增幂等注册 SQL(update 脚本 + 全量初始化),新老环境均可使用
2026-09-11 19:57:21 +08:00
ageerle
cca3090577 fix: support Atlas multimodal generation and media preview 2026-09-11 10:49:29 +08:00
ageerle
4766d2e628 docs: document URL management and local validation 2026-09-08 23:56:52 +08:00
ageerle
c9ca55b45b fix: preserve manual plan transitions during external verification 2026-09-08 23:48:59 +08:00
ageerle
bcff4fd9e3 feat: enhance coding harness, model security and system features 2026-09-08 23:33:29 +08:00
ageerle
d4ed2b8672 Merge pull request #326 from ansn366/docs/improve-windows-docker-setup
docs: improve Windows Docker deployment instructions
2026-09-05 22:27:32 +08:00
ageerle
ee838aa488 Merge pull request #328 from l-vM2k/fix-mainExecutor
fix: 修复 mainExecutor 线程池在高核CPU服务器上会空指针导致应用无法启动
2026-09-05 22:27:25 +08:00
ageerle
b27fa844e6 fix: handle missing Qdrant collections during deletion
Treat gRPC NOT_FOUND as an idempotent delete for collections, documents and fragments so first uploads can initialize their collection. Preserve other failures and add 19 regression cases.

Refs: https://gitee.com/ageerle/ruoyi-ai/issues/IKCTSX
2026-09-05 22:19:15 +08:00
Jiali
7f728c7a92 fix: 修复 mainExecutor 线程池在高核CPU服务器上会空指针导致应用无法启动
原配置从配置文件读取 corePoolSize 和 maxPoolSize,
当配置缺失时传入 null 导致 BeanCreationException。
现改为基于 CPU 核心数动态计算,并设置固定上限 100。
2026-08-26 19:10:39 +08:00
Ansn
15ffb4ec2e docs: improve Windows Docker deployment instructions 2026-08-21 11:54:57 +08:00
ageerle
f92e510665 chore: enable demo mode 2026-08-16 20:58:48 +08:00
ageerle@163.com
997548dd4b fix: expose builtin tools to LangChain4j agents 2026-08-15 23:14:08 +08:00
ageerle
25ab33c79c feat: add coding harness runtime and CMS module 2026-08-15 23:04:34 +08:00
435 changed files with 39127 additions and 1110 deletions

View File

@@ -75,9 +75,12 @@ This project provides two Docker deployment methods:
### Method 1: One-click Start All Services (Recommended)
Use `docker-compose-all.yaml` to start all services at once (including backend, admin panel, user frontend, and dependencies):
#### Requirements
- Docker Engine(Linux/macOS)或 Docker Desktop(Windows)
- Docker Compose V2
#### Linux / macOS
```bash
# Requirements: Docker Engine and Docker Compose V2
# Clone the v3.1.0 release
git clone --depth 1 --branch v3.1.0 https://github.com/ageerle/ruoyi-ai.git
@@ -103,6 +106,37 @@ docker compose --env-file docs/docker/ruoyi-ai/.env \
# Backend API: http://SERVER_IP:26039
```
#### Windows PowerShell
If you are using Windows with Docker Desktop, you can use PowerShell to execute the following commands:
```PowerShell
# Clone the v3.1.0 release
git clone --depth 1 --branch v3.1.0 https://github.com/ageerle/ruoyi-ai.git
cd ruoyi-ai
# Create the environment configuration file
Copy-Item docs\docker\ruoyi-ai\.env.example docs\docker\ruoyi-ai\.env
# Pin the image version
(Get-Content docs\docker\ruoyi-ai\.env) `
-replace '^RUIYI_VERSION=.*', 'RUIYI_VERSION=v3.1.0' |
Set-Content docs\docker\ruoyi-ai\.env
# Check the environment configuration
Get-Content docs\docker\ruoyi-ai\.env
# Pull pre-built images from GHCR and start all services
docker compose --env-file docs\docker\ruoyi-ai\.env `
-f docs\docker\ruoyi-ai\docker-compose-all.yaml pull
docker compose --env-file docs\docker\ruoyi-ai\.env `
-f docs\docker\ruoyi-ai\docker-compose-all.yaml up -d
# Check service status
docker compose --env-file docs\docker\ruoyi-ai\.env `
-f docs\docker\ruoyi-ai\docker-compose-all.yaml ps
```
The default Compose file also publishes MySQL (`23306`), Redis (`26379`),
Weaviate (`28080`), and MinIO (`29000`/`29090`). For production deployments,
change the default MySQL and MinIO passwords and expose only the application
@@ -175,6 +209,9 @@ Want to learn more about installation, deployment, configuration, and secondary
**👉 [Complete Documentation](https://doc.ruoyiai.chat/)**
- [Model and API Key configuration in ruoyi-admin (Chinese)](docs/model-configuration.md)
- [Dify Cloud setup for local development (Chinese)](docs/dify-cloud.md)
## 🤝 Contributing
We warmly welcome community contributions! Whether you are a seasoned developer or just getting started, you can contribute to the project 💪

View File

@@ -76,10 +76,13 @@
### 方式一:一键启动所有服务(推荐)
使用 `docker-compose-all.yaml` 可以一键启动所有服务(包括后端、管理端、用户端及依赖服务):
#### 环境要求
- Docker Engine(Linux/macOS)或 Docker Desktop(Windows)
- Docker Compose V2
#### Linux / macOS
```bash
# 环境要求:Docker Engine 和 Docker Compose V2
# 克隆 v3.1.0 版本
git clone --depth 1 --branch v3.1.0 https://github.com/ageerle/ruoyi-ai.git
cd ruoyi-ai
@@ -104,6 +107,35 @@ docker compose --env-file docs/docker/ruoyi-ai/.env \
# 后端API: http://SERVER_IP:26039
```
#### Windows PowerShell
如果您使用 Windows + Docker Desktop,可以使用 PowerShell 执行以下命令:
```PowerShell
# 克隆 v3.1.0 版本
git clone --depth 1 --branch v3.1.0 https://github.com/ageerle/ruoyi-ai.git
cd ruoyi-ai
# 创建环境配置文件
Copy-Item docs\docker\ruoyi-ai\.env.example docs\docker\ruoyi-ai\.env
# 固定镜像版本
(Get-Content docs\docker\ruoyi-ai\.env) `
-replace '^RUIYI_VERSION=.*', 'RUIYI_VERSION=v3.1.0' |
Set-Content docs\docker\ruoyi-ai\.env
# 查看环境配置
Get-Content docs\docker\ruoyi-ai\.env
# 从 GHCR 拉取预构建镜像并启动全部服务
docker compose --env-file docs\docker\ruoyi-ai\.env `
-f docs\docker\ruoyi-ai\docker-compose-all.yaml pull
docker compose --env-file docs\docker\ruoyi-ai\.env `
-f docs\docker\ruoyi-ai\docker-compose-all.yaml up -d
# 查看服务状态
docker compose --env-file docs\docker\ruoyi-ai\.env `
-f docs\docker\ruoyi-ai\docker-compose-all.yaml ps
```
默认 Compose 还会发布 MySQL(`23306`)、Redis(`26379`)、Weaviate(`28080`)和
MinIO(`29000`/`29090`)端口。生产环境请修改 MySQL 和 MinIO 默认密码,并通过防火墙或反向代理只开放应用端口。
@@ -173,6 +205,9 @@ docker-compose up -d --build
**👉 [完整使用文档](https://doc.ruoyiai.chat/)**
- [在 ruoyi-admin 中配置模型和 API Key](docs/model-configuration.md)
- [本地开发接入 Dify 官方云服务](docs/dify-cloud.md)
## 🤝 参与贡献
我们热烈欢迎社区贡献!无论您是资深开发者还是初学者,都可以为项目贡献力量 💪

50
docs/dify-cloud.md Normal file
View File

@@ -0,0 +1,50 @@
# Dify 官方云服务配置
本指南适用于本地运行 RuoYi-AI Java 后端、连接 Dify 官方云服务的场景。
## 准备 Dify 应用
在 Dify 中创建并发布聊天应用,先在 Dify 自己的调试页面确认可以正常回答。首次接入可以使用 Chatbot(聊天助手)。在该应用的 API 访问页面创建应用 API Key。
应用 Key 对应具体的 Dify 应用;知识库 Key 不能用于聊天。Dify 官方云服务的 API 基础地址为 `https://api.dify.ai/v1`。参见 [Dify API 入门](https://docs.dify.ai/en/api-reference/guides/get-started) 和 [聊天应用 API](https://docs.dify.ai/en/api-reference/guides/chat)。
## 在后台配置 Key
API Key 统一在 **ruoyi-admin → 对话管理 → 模型管理** 中直接填写并保存,无需配置后端环境变量。
## 模型管理填写示例
先确认厂商管理中的 `Dify` 已启用,再在模型管理中修改 Dify 模型:
| 字段 | 填写内容 |
| --- | --- |
| 模型分类 | 聊天(`chat`) |
| 模型供应商 | Dify(编码 `dify`) |
| 模型名称 | `dify-chat` |
| 模型描述 | Dify 聊天助手 |
| 请求地址 | `https://api.dify.ai/v1` |
| 密钥 | 从 Dify 应用复制的真实 API Key,如 `app-...` |
| 是否显示 | 是 |
直接填写真实应用 Key,不需要 `env:` 或 `Bearer ` 前缀。编辑时 Key 不回显;留空保留原 Key,填写新值会替换原值。保存后新的调用读取最新配置,无需重启后端。
请求地址填写到 `/v1`,不要追加 `/chat-messages`,也不要填写 Dify 应用的网页分享链接。模型名称是本系统的选择标识,可以取一个不重复的应用名称;实际调用哪个 Dify 应用由应用 Key 决定。
已有数据库直接在模型管理中按上述内容保存即可,无需重新导入全量 SQL。保存后在聊天页面选择该模型,发送一条简单消息验证。
## 适用范围
- 官方云服务使用 `https://api.dify.ai/v1`;自建 Dify 填写后端可以访问的服务 API 地址。
- 不同模型可以填写不同的应用 Key,分别连接对应的 Dify 应用。
- 当前适配器使用聊天消息接口,普通 Workflow 应用不能通过此模型配置调用。
- 当前请求的自定义 `inputs` 为空。接入的聊天应用应能在不提供额外必填输入变量的情况下运行。
- 当前聊天适配器没有实现 Dify 的 Human Input 表单交互和文件上传流程;首次验证请使用简单文本聊天应用。
## 常见情况
| 现象 | 检查方法 |
| --- | --- |
| 后台无法保存 | 检查厂商是否启用,以及模型分类、名称等必填项是否齐全。 |
| 编辑时密钥框为空 | 密钥不会回显;留空保存会保留原 Key。 |
| Dify 返回鉴权失败 | 在模型管理中填写当前应用的有效 API Key,检查复制时是否带入空格或 `Bearer ` 前缀。 |
| Dify 返回应用类型或输入参数错误 | 检查是否使用聊天应用,以及应用是否要求额外输入变量;先在 Dify 中验证应用可以回答。 |

View File

@@ -6,7 +6,7 @@ FROM mysql:8.0
ENV TZ=Asia/Shanghai
# MySQL 官方入口会按文件名顺序执行初始化目录中的 SQL。
# ruoyi-ai.sql 会导入 MYSQL_DATABASE,snail_job_mysql.sql 会自行创建并切换数据库。
# 主库统一为 ruoyi-ai,与初始化 SQL 和 Compose 中的数据库配置保持一致。
COPY docs/script/sql/ruoyi-ai.sql /docker-entrypoint-initdb.d/01-ruoyi-ai.sql
COPY docs/script/sql/snail_job_mysql.sql /docker-entrypoint-initdb.d/02-snail-job.sql

View File

@@ -23,7 +23,7 @@ services:
- "23306:3306"
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ruoyi-ai-agent
MYSQL_DATABASE: ruoyi-ai
TZ: Asia/Shanghai
volumes:
- mysql-data:/var/lib/mysql
@@ -105,13 +105,19 @@ services:
# MySQL 配置
SPRING_DATASOURCE_DYNAMIC_PRIMARY: master
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_DRIVERCLASSNAME: com.mysql.cj.jdbc.Driver
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_URL: jdbc:mysql://mysql:3306/ruoyi-ai-agent?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_URL: jdbc:mysql://mysql:3306/ruoyi-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_USERNAME: root
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: root
# Redis 配置
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_DATA_REDIS_DATABASE: 0
# Allow large presentation files for knowledge-base ingestion.
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: 600MB
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: 620MB
VECTOR_STORE_TYPE: weaviate
VECTOR_STORE_WEAVIATE_PROTOCOL: http
VECTOR_STORE_WEAVIATE_HOST: weaviate:8080
# 日志配置
LOGGING_LEVEL_ORG_RUOYI: info
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK: warn

View File

@@ -18,7 +18,7 @@ services:
- "23306:3306"
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ruoyi-ai-agent
MYSQL_DATABASE: ruoyi-ai
TZ: Asia/Shanghai
volumes:
- ../../script/sql/ruoyi-ai.sql:/docker-entrypoint-initdb.d/01-ruoyi-ai.sql:ro
@@ -110,7 +110,7 @@ services:
# MySQL 配置
SPRING_DATASOURCE_DYNAMIC_PRIMARY: master
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_DRIVERCLASSNAME: com.mysql.cj.jdbc.Driver
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_URL: jdbc:mysql://mysql:3306/ruoyi-ai-agent?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_URL: jdbc:mysql://mysql:3306/ruoyi-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_USERNAME: root
SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: root
# Redis 配置

View File

@@ -0,0 +1,33 @@
# 模型与 API Key 配置
模型的 API Key 统一在 **ruoyi-admin → 对话管理 → 模型管理** 中配置。
1. 在“厂商管理”中选择并启用对应厂商,填写服务 API 地址。
2. 在“模型管理”中新建或编辑模型,选择厂商、分类,填写服务商提供的模型 ID。
3. 在“密钥”框直接粘贴真实 API Key,保存模型。
4. 在 `ruoyi-web` 中选择模型使用;向量和重排模型则在对应知识库配置中选择。
需要 API Key 的厂商适配器使用该模型保存的 Key,不要求固定的 Key 前缀或环境变量名称。自定义 OpenAI、Anthropic 服务的地址与 Key 也在模型管理中配置,无需设置配对环境变量。无需鉴权的本地模型可以不填 Key。
## 修改与批量更新
- 编辑模型时 Key 不回显;留空保存会保留原 Key,输入新值会替换原 Key。
- 按厂商批量更新接口会将提交的 Key 保存到该厂商的模型中。
- 保存后新的模型调用会读取最新配置,无需重启后端。正在进行的请求继续使用开始时的配置。
- 模型列表、聊天用户端和导出文件不返回 Key。
## 已有模型
如果模型中原来保存的是 `env:ATLAS_API_KEY` 等文本,请在后台将其替换为真实 Key。密钥字段按填写内容直接使用,不解析 `env:` 引用。无需重新导入数据库。
## 示例
| 场景 | 厂商编码 | 密钥框填写内容 |
| --- | --- | --- |
| 千问聊天、向量或重排 | `qianwen` | 阿里云百炼的 API Key |
| DeepSeek | `deepseek` | DeepSeek 控制台创建的 API Key |
| Atlas 多媒体 | `atlas` | Atlas 控制台创建的 API Key |
| Dify 聊天应用 | `dify` | 对应应用的 `app-...` Key |
| 自定义 OpenAI / Anthropic | `custom_api` / `custom_anthropic` | 对应服务的 API Key |
填写的地址、模型 ID 和 Key 应对应同一个服务账号。模型是否可用、支持哪些能力以及额度限制,以服务商实际响应为准。

45
docs/script/sql/README.md Normal file
View File

@@ -0,0 +1,45 @@
# SQL 脚本说明
## 全新安装
执行 [ruoyi-ai.sql](ruoyi-ai.sql) 即可创建 `ruoyi-ai` 主库和 `snail_job` 调度库,并导入初始化数据。
```sh
mysql -uroot -p < ruoyi-ai.sql
```
主 SQL 已合并下表中的全部更新,无需再次执行 `update` 目录中的脚本。[snail_job_mysql.sql](snail_job_mysql.sql) 仅用于单独初始化调度库,执行主 SQL 后无需再执行它。
全量初始化包含 `DROP TABLE` 和调度库的 `DROP DATABASE`,已有数据库升级应使用对应的增量脚本。
## 已有数据库升级
连接目标主库后,按文件名升序执行尚未应用的更新脚本;不要根据文件重命名重复执行已应用的更新。
- `2026-06-15-trace.sql` 原名为 `update-0615-trace.sql`,内容未变;其中菜单插入没有判重,不应重复执行。
- `2026-08-30-mcp-market-tool-tenant.sql` 是一次性迁移,执行前检查脚本内的预检要求;部分执行失败后应按实际表结构续跑。
- 其余脚本包含重复执行保护,具体适用条件见各脚本注释。
## 命名约定
更新脚本统一采用 `YYYY-MM-DD-功能描述.sql`,功能描述使用小写英文和连字符,例如 `2026-09-01-sys-url.sql`。保留已有更新日期,同日脚本按文件名升序执行,并确保依赖顺序一致。
全量入口保留 `ruoyi-ai.sql`,独立调度库入口保留 `snail_job_mysql.sql`。
## 主 SQL 合并清单
截至 2026-09-01,`update` 目录的 9 个脚本均已体现在主 SQL 的最终表结构和初始化数据中。结构变更直接并入建表语句,重复配置按最终值保留一份;旧库回填及兼容逻辑保留在增量脚本中。
| 更新脚本 | 主 SQL 中对应内容 |
| --- | --- |
| [2026-06-15-trace.sql](update/2026-06-15-trace.sql) | `trace_run`、`trace_node` 表、租户字段和索引,以及链路追踪菜单 |
| [2026-07-20-knowledge-fragment-fid.sql](update/2026-07-20-knowledge-fragment-fid.sql) | 附件 `file_hash`、片段 `fid`、可空的 `doc_id` 和知识库租户索引 |
| [2026-07-21-chat-provider-icon-length.sql](update/2026-07-21-chat-provider-icon-length.sql) | `chat_provider.provider_icon` 扩展为 `varchar(1000)` |
| [2026-07-21-sys-config-node-template.sql](update/2026-07-21-sys-config-node-template.sql) | 7 个工作流节点消息模板配置 |
| [2026-07-24-register-default-role-and-missing-menus.sql](update/2026-07-24-register-default-role-and-missing-menus.sql) | 默认注册角色、角色菜单关联、默认角色配置,以及会话、附件和片段权限 |
| [2026-07-29-sys-config-node-template-all.sql](update/2026-07-29-sys-config-node-template-all.sql) | 工作流全部 8 个模板,前 7 个配置与上一批模板合并去重 |
| [2026-07-29-zhipu-web-search-node.sql](update/2026-07-29-zhipu-web-search-node.sql) | 智谱网络搜索节点及其消息模板;内部组件名保留 `Google` 以兼容已有流程 |
| [2026-08-30-mcp-market-tool-tenant.sql](update/2026-08-30-mcp-market-tool-tenant.sql) | `mcp_market_tool` 租户、审计字段和 `idx_tenant_market` 索引 |
| [2026-09-01-sys-url.sql](update/2026-09-01-sys-url.sql) | `sys_url` 表、5 个菜单权限和 2 条公开链接 |
后续增加更新脚本时,同步修改主 SQL 中对应的表结构或初始化数据,并更新本清单及主 SQL 文件头。

View File

@@ -1,17 +1,5 @@
/*
RuoYi AI 全量初始化 SQL(合并版)
包含内容:
1. ruoyi-ai 主库:全部表结构 + 初始化数据(含 fid 迁移、provider_icon、
工作流节点消息模板、智谱 Web Search 节点等历史增量,均已内置)
2. 链路追踪表 trace_run / trace_node + 监控菜单
3. 注册默认角色 + 会话管理/知识附件/知识片段菜单权限
4. snail_job 任务调度库(独立数据库)
使用方法(全新安装一次执行即可):
mysql -uroot -p < ruoyi-ai.sql
生成日期:2026-08-04
RuoYi-AI 全量初始化 SQL
*/
-- ============================================================
@@ -127,7 +115,9 @@ CREATE TABLE `chat_model` (
-- ----------------------------
-- Records of chat_model
-- ----------------------------
INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'deepseek-v4-flash', 'deepseek', 'deepseek-v4-flash', NULL, 'Y', 'https://api.deepseek.com', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0);
INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'deepseek-v4-flash', 'deepseek', 'DeepSeek V4 Flash', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2025-12-15 23:16:54', 1, '2026-08-30 00:00:00', '简单快速编程任务', 0);
INSERT INTO `chat_model` VALUES (2090000000000000001, 'chat', 'deepseek-v4-pro', 'deepseek', 'DeepSeek V4 Pro', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '复杂编程、规划与审查', 0);
INSERT INTO `chat_model` VALUES (2090000000000000002, 'chat', 'deepseek-v4-flash-vision-exp', 'deepseek', 'DeepSeek V4 Flash Vision (experimental)', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '带图编程任务的视觉证据提取', 0);
INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'embedding-3', 'zhipu', 'embedding-3', 2048, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', '向量模型', 0);
INSERT INTO `chat_model` VALUES (2045071617578237953, 'rerank', 'rerank', 'zhipu', 'rerank', NULL, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '重排序模型', 0);
INSERT INTO `chat_model` VALUES (2000585866022060003, 'chat', 'deepseek-ai/deepseek-v4-flash', 'atlas', 'deepseek-v4-flash', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0);
@@ -136,7 +126,7 @@ INSERT INTO `chat_model` VALUES (2060622000000000001, 'image', 'openai/gpt-image
INSERT INTO `chat_model` VALUES (2060622000000000002, 'image', 'openai/gpt-image-2/edit', 'atlas', 'GPT-IMAGE-2 图生图编辑', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 图生图/编辑,支持基于参考图修改风格、纹理等', 0);
INSERT INTO `chat_model` VALUES (2060622000000000003, 'video', 'bytedance/seedance-2.0/image-to-video', 'atlas', 'Seedance 2.0 图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 17:24:07', 1, '2026-06-22 17:24:07', 'Atlas Cloud 视频模型 - Seedance 2.0 图生视频,接收参考图+提示词生成视频', 0);
INSERT INTO `chat_model` VALUES (2060622000000000004, 'video', 'bytedance/seedance-2.0/reference-to-video', 'atlas', 'Seedance 2.0 多参考图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 20:24:44', 1, '2026-06-22 20:24:44', 'Atlas Cloud 视频模型 - Seedance 2.0 多参考图生视频,接收多张参考图+带@imageN标记的提示词生成视频', 0);
INSERT INTO `chat_model` VALUES (2070700000000000002, 'chat', 'dify-chat', 'dify', 'Dify Chat App', NULL, 'Y', 'https://api.dify.ai/v1', '替换为你的DIFY_APP_API_KEY', 103, 1, '2026-07-14 11:03:44', 1, '2026-07-14 11:03:44', 'Dify 聊天应用;api_key 为 Dify App API Key,model_name 可按应用名修改', 0);
INSERT INTO `chat_model` VALUES (2070700000000000002, 'chat', 'dify-chat', 'dify', 'Dify Chat App', NULL, 'Y', 'https://api.dify.ai/v1', '', 103, 1, '2026-07-14 11:03:44', 1, '2026-07-14 11:03:44', 'Dify 聊天应用;真实 App API Key 在后台模型管理中填写,model_name 可按应用名修改', 0);
INSERT INTO `chat_model` VALUES (2070700000000000004, 'chat', '替换为你的COZE_BOT_ID', 'coze', 'Coze Bot', NULL, 'Y', 'https://api.coze.cn', '替换为你的COZE_PAT', 103, 1, '2026-07-14 11:03:38', 1, '2026-07-14 11:03:38', 'Coze 聊天 Bot;model_name 为 Coze Bot ID,api_key 为 PAT 或 OAuth access token', 0);
INSERT INTO `chat_model` VALUES (2070700000000000010, 'audio', 'bytedance/seed-audio-1.0', 'atlas', 'Seed Audio 1.0 语音生成(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-07-16 18:00:00', 1, '2026-07-16 18:00:00', 'Atlas Cloud 语音模型 - 支持多角色对白配音,references 指定 speaker 音色,text 中用 @audioN 引用', 0);
@@ -1284,9 +1274,15 @@ CREATE TABLE `mcp_market_tool` (
`tool_metadata` json NULL COMMENT '工具元数据(JSON格式)',
`is_loaded` tinyint(1) NULL DEFAULT 0 COMMENT '是否已加载到本地',
`local_tool_id` bigint NULL DEFAULT NULL COMMENT '关联的本地工具ID',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '000000' COMMENT '租户编号',
`create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门',
`create_by` bigint NULL DEFAULT NULL COMMENT '创建者',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_by` bigint NULL DEFAULT NULL COMMENT '更新者',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_market_id`(`market_id` ASC) USING BTREE,
INDEX `idx_tenant_market`(`tenant_id` ASC, `market_id` ASC) USING BTREE,
INDEX `idx_tool_name`(`tool_name` ASC) USING BTREE,
INDEX `idx_is_loaded`(`is_loaded` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'MCP市场工具关联表' ROW_FORMAT = DYNAMIC;
@@ -2715,6 +2711,11 @@ INSERT INTO `sys_menu` VALUES (2014, 'MCP市场删除', 2010, 4, '#', '', '', 1,
INSERT INTO `sys_menu` VALUES (2015, 'MCP市场刷新', 2010, 5, '#', '', '', 1, 0, 'F', '0', '0', 'mcp:market:refresh', '#', 103, 1, '2026-02-24 20:02:47', NULL, NULL, '');
INSERT INTO `sys_menu` VALUES (2016, 'MCP工具加载', 2010, 6, '#', '', '', 1, 0, 'F', '0', '0', 'mcp:market:load', '#', 103, 1, '2026-02-24 20:02:47', NULL, NULL, '');
INSERT INTO `sys_menu` VALUES (2017, 'MCP市场导出', 2010, 7, '#', '', '', 1, 0, 'F', '0', '0', 'mcp:market:export', '#', 103, 1, '2026-02-24 20:02:47', NULL, NULL, '');
INSERT INTO `sys_menu` VALUES (2020, 'URL管理', 1, 12, 'url', 'system/url/index', '', 1, 0, 'C', '0', '0', 'system:url:list', 'mdi:link-variant', 103, 1, '2026-09-01 00:00:00', NULL, NULL, 'URL管理菜单');
INSERT INTO `sys_menu` VALUES (2021, 'URL查询', 2020, 1, '#', '', '', 1, 0, 'F', '0', '0', 'system:url:query', '#', 103, 1, '2026-09-01 00:00:00', NULL, NULL, '');
INSERT INTO `sys_menu` VALUES (2022, 'URL新增', 2020, 2, '#', '', '', 1, 0, 'F', '0', '0', 'system:url:add', '#', 103, 1, '2026-09-01 00:00:00', NULL, NULL, '');
INSERT INTO `sys_menu` VALUES (2023, 'URL修改', 2020, 3, '#', '', '', 1, 0, 'F', '0', '0', 'system:url:edit', '#', 103, 1, '2026-09-01 00:00:00', NULL, NULL, '');
INSERT INTO `sys_menu` VALUES (2024, 'URL删除', 2020, 4, '#', '', '', 1, 0, 'F', '0', '0', 'system:url:remove', '#', 103, 1, '2026-09-01 00:00:00', NULL, NULL, '');
INSERT INTO `sys_menu` VALUES (3000, '智能体管理', 0, 1, 'agent', '', '', 1, 0, 'M', '0', '0', '', 'mdi:robot', 103, 1, '2026-07-07 21:37:37', NULL, NULL, '智能体管理目录');
INSERT INTO `sys_menu` VALUES (3001, '智能体列表', 3000, 1, 'agent', 'agent/agent/index', '', 1, 0, 'C', '0', '0', 'agent:agent:list', 'mdi:robot-outline', 103, 1, '2026-07-07 21:37:37', NULL, NULL, '智能体列表菜单');
INSERT INTO `sys_menu` VALUES (3002, '智能体查询', 3001, 1, '#', '', '', 1, 0, 'F', '0', '0', 'agent:agent:query', '#', 103, 1, '2026-07-07 21:37:37', NULL, NULL, '');
@@ -2799,6 +2800,33 @@ CREATE TABLE `sys_notice` (
INSERT INTO `sys_notice` VALUES (1, '000000', '温馨提醒:2018-07-01 新版本发布啦', '2', 0xE696B0E78988E69CACE58685E5AEB9, '0', 103, 1, '2026-02-03 05:14:52', NULL, NULL, '管理员');
INSERT INTO `sys_notice` VALUES (2, '000000', '维护通知:2018-07-01 系统凌晨维护', '1', 0xE7BBB4E68AA4E58685E5AEB9, '0', 103, 1, '2026-02-03 05:14:52', NULL, NULL, '管理员');
-- ----------------------------
-- Table structure for sys_url
-- ----------------------------
DROP TABLE IF EXISTS `sys_url`;
CREATE TABLE `sys_url` (
`url_id` bigint NOT NULL AUTO_INCREMENT COMMENT '链接ID',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '000000' COMMENT '租户编号',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '链接名称',
`url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'HTTP(S) 地址',
`description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '链接说明',
`sort_order` int NULL DEFAULT 0 COMMENT '显示顺序',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态(0正常 1停用)',
`create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门',
`create_by` bigint NULL DEFAULT NULL COMMENT '创建者',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_by` bigint NULL DEFAULT NULL COMMENT '更新者',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`url_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'URL 管理表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of sys_url
-- ----------------------------
INSERT INTO `sys_url` VALUES (1, '000000', '若依官网', 'https://ruoyi.vip', 'RuoYi 官方网站,提供文档、源码与社区支持', 1, '0', 103, 1, '2026-09-01 00:00:00', NULL, NULL, '初始公开链接');
INSERT INTO `sys_url` VALUES (2, '000000', 'RuoYi-AI 开源仓库', 'https://github.com/ageerle/ruoyi-ai', 'RuoYi-AI 项目 GitHub 仓库', 2, '0', 103, 1, '2026-09-01 00:00:00', NULL, NULL, '初始公开链接');
-- ----------------------------
-- Table structure for sys_oper_log
-- ----------------------------
@@ -3448,6 +3476,7 @@ INSERT INTO `t_workflow_component` VALUES (18, '5cd6ac69bbb411f0bb7840c2ba9a7fbc
INSERT INTO `t_workflow_component` VALUES (19, '5cd6c8eabbb411f0bb7840c2ba9a7fbc', 'Answer', '生成回答', '调用大语言模型回答问题', 0, 1, '2025-11-07 16:32:49', '2025-11-07 16:32:49', 0, '000000');
INSERT INTO `t_workflow_component` VALUES (26, 'bb00fc2f52c74fec82ee3f99725b56bb', 'Switcher', '条件分支', '根据条件执行不同分支', 0, 1, '2025-12-26 16:30:46', '2025-12-26 16:30:46', 0, '000000');
INSERT INTO `t_workflow_component` VALUES (37, 'a7f8c2d44e5b4c83a9d6f103c2b47e18', 'Google', '网络搜索', '调用智谱 Web Search 检索互联网信息', 40, 1, '2026-07-29 20:30:00', '2026-07-29 20:30:00', 0, '000000');
INSERT INTO `t_workflow_component` VALUES (38, 'b8e3c1d55f6a4d92b0e7f214d3c58a29', 'KnowledgeRetrieval', '知识库检索', '从知识库中检索相关内容,支持向量检索和混合检索', 30, 1, '2026-09-11 10:00:00', '2026-09-11 10:00:00', 0, '000000');
-- ----------------------------
-- Table structure for t_workflow_edge
@@ -3669,7 +3698,7 @@ SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- 第二部分:链路追踪(trace_run / trace_node + 监控菜单)
-- 来源:update/update-0615-trace.sql
-- 来源:update/2026-06-15-trace.sql
-- 说明:全新库中 CREATE TABLE 已含 tenant_id 及索引,
-- 原脚本中的旧表兼容 ALTER 段已省略;菜单插入加了判重守卫。
-- ============================================================

View File

@@ -0,0 +1,41 @@
-- ----------------------------
-- MCP 市场工具租户与审计列补齐
-- 2026-08-30
-- ----------------------------
-- 本脚本只迁移结构和归属信息,不删除无法关联到市场的孤儿记录。
-- 孤儿记录的 tenant_id 保持 NULL,因此租户归属查询会 fail closed。
-- 这是一次性迁移脚本:两个 ALTER TABLE 都会隐式提交。若执行中断或仅完成部分 DDL,
-- 必须先核对实际表结构后逐句续跑,禁止直接重跑整个文件。
-- 本机受控执行入口为 scripts/apply-live-migrations.ps1;先使用 -PreflightOnly,
-- 再以同一个显式 DPAPI 备份路径执行正式迁移。生产环境仍需按自身发布流程审计后执行。
-- 首次 DDL 前预检;上线前应确认结果为 0,避免新增列后留下无法可信回填的归属。
SELECT COUNT(*) AS `preflight_invalid_market_owner_count`
FROM `mcp_market_tool` AS `tool`
LEFT JOIN `mcp_market_info` AS `market` ON `market`.`id` = `tool`.`market_id`
WHERE `market`.`id` IS NULL
OR `market`.`tenant_id` IS NULL;
ALTER TABLE `mcp_market_tool`
ADD COLUMN `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '租户编号' AFTER `local_tool_id`,
ADD COLUMN `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门' AFTER `tenant_id`,
ADD COLUMN `create_by` bigint NULL DEFAULT NULL COMMENT '创建者' AFTER `create_dept`,
ADD COLUMN `update_by` bigint NULL DEFAULT NULL COMMENT '更新者' AFTER `create_time`,
ADD COLUMN `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间' AFTER `update_by`;
UPDATE `mcp_market_tool` AS `tool`
INNER JOIN `mcp_market_info` AS `market` ON `market`.`id` = `tool`.`market_id`
SET `tool`.`tenant_id` = `market`.`tenant_id`;
ALTER TABLE `mcp_market_tool`
MODIFY COLUMN `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '000000' COMMENT '租户编号',
ADD INDEX `idx_tenant_market` (`tenant_id`, `market_id`);
-- 上线前应确认结果为 0;统计覆盖父市场缺失、子表租户为空、父子租户为空或不一致。
SELECT COUNT(*) AS `invalid_market_tool_tenant_count`
FROM `mcp_market_tool` AS `tool`
LEFT JOIN `mcp_market_info` AS `market` ON `market`.`id` = `tool`.`market_id`
WHERE `market`.`id` IS NULL
OR `tool`.`tenant_id` IS NULL
OR `market`.`tenant_id` IS NULL
OR NOT (BINARY `tool`.`tenant_id` <=> BINARY `market`.`tenant_id`);

View File

@@ -0,0 +1,76 @@
-- ----------------------------------------------------------------------------
-- URL 管理模块增量脚本(可重复执行)
-- 1) 创建 sys_url 表
-- 2) 新增「系统管理 -> URL管理」菜单及 4 个按钮权限(按 perms 去重,幂等)
-- 3) 插入两条初始公开网站链接(按 name + url 去重,幂等)
-- 适用于已存在数据库的升级;全新安装请直接使用 ruoyi-ai.sql
-- ----------------------------------------------------------------------------
-- ----------------------------
-- 1. URL 管理表 sys_url
-- ----------------------------
CREATE TABLE IF NOT EXISTS `sys_url` (
`url_id` bigint NOT NULL AUTO_INCREMENT COMMENT '链接ID',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '000000' COMMENT '租户编号',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '链接名称',
`url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'HTTP(S) 地址',
`description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '链接说明',
`sort_order` int NULL DEFAULT 0 COMMENT '显示顺序',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态(0正常 1停用)',
`create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门',
`create_by` bigint NULL DEFAULT NULL COMMENT '创建者',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_by` bigint NULL DEFAULT NULL COMMENT '更新者',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`url_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'URL 管理表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- 2. 菜单 & 按钮权限(幂等:perms 不存在时才插入)
-- 父菜单 1 = 系统管理目录
-- ----------------------------
INSERT INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`)
SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), 'URL管理', 1, 12, 'url', 'system/url/index', '', 1, 0, 'C', '0', '0', 'system:url:list', 'mdi:link-variant', 103, 1, NOW(), 'URL管理菜单'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `perms` = 'system:url:list' AND `menu_type` = 'C');
INSERT INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`)
SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), 'URL查询', (SELECT `menu_id` FROM `sys_menu` WHERE `perms` = 'system:url:list' AND `menu_type` = 'C' LIMIT 1), 1, '#', '', '', 1, 0, 'F', '0', '0', 'system:url:query', '#', 103, 1, NOW(), ''
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `perms` = 'system:url:query' AND `menu_type` = 'F');
INSERT INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`)
SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), 'URL新增', (SELECT `menu_id` FROM `sys_menu` WHERE `perms` = 'system:url:list' AND `menu_type` = 'C' LIMIT 1), 2, '#', '', '', 1, 0, 'F', '0', '0', 'system:url:add', '#', 103, 1, NOW(), ''
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `perms` = 'system:url:add' AND `menu_type` = 'F');
INSERT INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`)
SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), 'URL修改', (SELECT `menu_id` FROM `sys_menu` WHERE `perms` = 'system:url:list' AND `menu_type` = 'C' LIMIT 1), 3, '#', '', '', 1, 0, 'F', '0', '0', 'system:url:edit', '#', 103, 1, NOW(), ''
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `perms` = 'system:url:edit' AND `menu_type` = 'F');
INSERT INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`)
SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), 'URL删除', (SELECT `menu_id` FROM `sys_menu` WHERE `perms` = 'system:url:list' AND `menu_type` = 'C' LIMIT 1), 4, '#', '', '', 1, 0, 'F', '0', '0', 'system:url:remove', '#', 103, 1, NOW(), ''
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `perms` = 'system:url:remove' AND `menu_type` = 'F');
-- ----------------------------
-- 3. 初始公开网站链接(幂等:name + url 均不存在时才插入)
-- ----------------------------
INSERT INTO `sys_url`
(`tenant_id`, `name`, `url`, `description`, `sort_order`, `status`, `create_dept`, `create_by`, `create_time`, `remark`)
SELECT '000000', '若依官网', 'https://ruoyi.vip', 'RuoYi 官方网站,提供文档、源码与社区支持', 1, '0', 103, 1, NOW(), '初始公开链接'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_url` WHERE `name` = '若依官网' AND `url` = 'https://ruoyi.vip');
INSERT INTO `sys_url`
(`tenant_id`, `name`, `url`, `description`, `sort_order`, `status`, `create_dept`, `create_by`, `create_time`, `remark`)
SELECT '000000', 'RuoYi-AI 开源仓库', 'https://github.com/ageerle/ruoyi-ai', 'RuoYi-AI 项目 GitHub 仓库', 2, '0', 103, 1, NOW(), '初始公开链接'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_url` WHERE `name` = 'RuoYi-AI 开源仓库' AND `url` = 'https://github.com/ageerle/ruoyi-ai');

View File

@@ -0,0 +1,24 @@
-- 知识库检索工作流节点注册
-- 为新安装和已有环境提供幂等的节点注册
INSERT INTO `t_workflow_component`
(`uuid`, `name`, `title`, `remark`, `display_order`, `is_enable`,
`create_time`, `update_time`, `is_deleted`, `tenant_id`)
SELECT
'b8e3c1d55f6a4d92b0e7f214d3c58a29',
'KnowledgeRetrieval',
'知识库检索',
'从知识库中检索相关内容,支持向量检索和混合检索',
30,
1,
NOW(),
NOW(),
0,
'000000'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM `t_workflow_component`
WHERE `name` = 'KnowledgeRetrieval'
AND `tenant_id` = '000000'
);

47
docs/tenant-management.md Normal file
View File

@@ -0,0 +1,47 @@
# 新增租户接口
开启 `tenant.enable: true` 后,超级管理员可通过 `POST /system/tenant` 创建租户。请求需要登录凭证和 `system:tenant:add` 权限;使用反向代理时,请按部署配置添加接口前缀。
## 请求参数
请求使用 `Content-Type: application/json`。
| 参数 | 说明 |
| --- | --- |
| `companyName` | 企业名称,必填且不能与已有租户重复。 |
| `contactUserName` | 联系人,必填。 |
| `contactPhone` | 联系电话,必填。 |
| `username` | 租户初始管理员用户名,必填。 |
| `password` | 租户初始管理员密码,必填。 |
| `packageId` | 已存在的租户套餐 ID,必填。建议使用字符串传输,避免浏览器对大整数的精度损失。 |
| `expireTime` | 过期时间,使用 `yyyy-MM-dd HH:mm:ss`,例如 `2027-09-11 00:00:00`;不传或传 `null` 表示不限制有效期。未带时区的时间按后端运行时区解释。 |
| `accountCount` | 可创建的用户数量,`-1` 表示不限制。 |
## 请求示例
请将套餐 ID 替换为当前系统中的有效套餐,并设置实际使用的管理员账号和密码。
```json
{
"companyName": "示例企业",
"contactUserName": "联系人",
"contactPhone": "13800000000",
"username": "tenant_admin",
"password": "ChangeMe_123456!",
"packageId": "2018611998196109314",
"expireTime": "2027-09-11 00:00:00",
"accountCount": -1
}
```
## 日期配置
后端默认日期格式配置位于 `ruoyi-admin/src/main/resources/application.yml`:
```yaml
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
```
扩展应用的 `ObjectMapper` 时,使用 Spring 注入的 `Jackson2ObjectMapperBuilder`,以保留全局日期格式、自定义 Jackson 模块和构建器配置。

35
docs/url-management.md Normal file
View File

@@ -0,0 +1,35 @@
# URL 管理
URL 管理用于维护 Copilot 新建编程任务页的常用链接。管理端入口为“系统管理 → URL管理”(`/system/url`),支持新增、编辑、批量删除、名称和状态筛选及分页。
## 安装与权限
已有数据库执行 [增量脚本](script/sql/update/2026-09-01-sys-url.sql),可重复执行。新安装使用包含 URL 表、菜单及初始链接的全量 `docs/script/sql/ruoyi-ai.sql`。脚本创建 `sys_url` 表和菜单;普通管理角色需分配对应菜单及按钮权限。
| 接口 | 权限 | 用途 |
| --- | --- | --- |
| `GET /system/url/list` | `system:url:list` | 名称、状态筛选和分页 |
| `GET /system/url/{urlId}` | `system:url:query` | 编辑回填 |
| `POST /system/url` | `system:url:add` | 新增 |
| `PUT /system/url` | `system:url:edit` | 编辑 |
| `DELETE /system/url/{urlIds}` | `system:url:remove` | 删除,多个 ID 以逗号分隔 |
| `GET /system/url/shortcuts` | `coding:harness:use` 或 `system:url:list` | 当前租户的已启用链接 |
链接地址仅允许带有效主机名的 HTTP(S) 地址。状态 `0` 表示启用,`1` 表示停用;列表和快捷入口按 `sortOrder`、`urlId` 升序排列。数据查询和修改遵循当前租户隔离。
## 使用
在管理端填写名称、地址、说明、顺序和状态后保存。Copilot 点击“新建编程任务”会刷新快捷入口;停用的链接不显示。卡片显示名称、说明和外链标识,并以 `noopener,noreferrer` 在新标签页打开。加载中、空列表和请求失败均有提示,失败时可重试。
后端实现位于 `ruoyi-modules/ruoyi-system` 的 `SysUrlController`、`SysUrlServiceImpl`;管理端位于 `ruoyi-admin/apps/web-antd/src/views/system/url`;Copilot 使用 `UrlShortcutCards.vue` 和 `useUrlShortcuts.ts`。
## 2026-09-08 最终验收
- 后端打包、管理端构建和 Copilot 构建通过;Copilot 构建包含类型检查。
- URL 接口 14/14,覆盖 CRUD、分页、排序、输入校验、权限和租户隔离。
- Copilot 单元测试 18/18,卡片状态与点击检查 7/7,EXTERNAL 计划工具回归 2/2。
- 增量 SQL 连续执行两次通过,全量 SQL 的 URL 模块在临时隔离库通过。
- 浏览器完成新增、编辑、筛选、停用隐藏、重新启用刷新和卡片说明检查;临时数据已清理。
- 管理端全项目仍有 103 项既有类型诊断,URL 模块为 0 项。
本轮还修复了 EXTERNAL 模式隐藏 `plan_step` 的框架问题,使模型能在证据校验后完成计划。原生运行和计划均已到达 `COMPLETED`。本轮为本地验收;未包含远程部署。新标签页打开参数通过组件测试确认,本轮未新增真实弹窗或手机尺寸实测结论。

View File

@@ -4,9 +4,6 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
/**
* 启动程序
*
@@ -16,66 +13,10 @@ import java.net.ServerSocket;
public class RuoYiAIApplication {
public static void main(String[] args) {
killPortProcess(6039);
SpringApplication application = new SpringApplication(RuoYiAIApplication.class);
application.setApplicationStartup(new BufferingApplicationStartup(2048));
application.run(args);
System.out.println("(♥◠‿◠)ノ゙ RuoYi-AI启动成功 ლ(´ڡ`ლ)");
}
/**
* 检查并终止占用指定端口的进程
*
* @param port 端口号
*/
private static void killPortProcess(int port) {
try {
if (!isPortInUse(port)) {
return;
}
System.out.println("端口 " + port + " 已被占用,正在查找并终止进程...");
ProcessBuilder pb = new ProcessBuilder("netstat", "-ano");
Process process = pb.start();
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
if (line.contains(":" + port + " ") && line.contains("LISTENING")) {
String[] parts = line.trim().split("\\s+");
String pid = parts[parts.length - 1];
System.out.println("找到占用端口 " + port + " 的进程 PID: " + pid + ",正在终止...");
ProcessBuilder killPb = new ProcessBuilder("taskkill", "/F", "/PID", pid);
Process killProcess = killPb.start();
int exitCode = killProcess.waitFor();
if (exitCode == 0) {
System.out.println("进程 " + pid + " 已成功终止");
} else {
System.out.println("终止进程 " + pid + " 失败,exitCode: " + exitCode);
}
break;
}
}
// 等待一小段时间确保端口释放
Thread.sleep(500);
} catch (Exception e) {
System.out.println("检查/终止端口进程时发生异常: " + e.getMessage());
}
}
/**
* 检查端口是否被占用
*/
private static boolean isPortInUse(int port) {
try (ServerSocket socket = new ServerSocket()) {
socket.bind(new InetSocketAddress(port));
return false;
} catch (Exception e) {
return true;
}
}
}

View File

@@ -17,6 +17,26 @@ mcp:
enabled: false
url: http://localhost:8085/sse
--- # Local Harness workspace boundary. Override the environment variable to narrow it further.
coding:
harness:
workspace:
shared-root: ${CODING_HARNESS_WORKSPACE_SHARED_ROOT:D:/Project}
journal:
# Keep local projection disabled unless an operator explicitly chooses a destination.
enabled: ${CODING_HARNESS_JOURNAL_ENABLED:false}
output-directory: ${CODING_HARNESS_JOURNAL_OUTPUT_DIRECTORY:./data/coding-harness-journal}
# 本地实战也必须显式提供 Docker CLI 绝对路径与不可变镜像;默认保持关闭。
tools:
execute-process:
enabled: ${CODING_HARNESS_EXECUTE_PROCESS_ENABLED:false}
sandbox:
docker-executable: ${CODING_HARNESS_DOCKER_EXECUTABLE:}
docker-executable-sha256: ${CODING_HARNESS_DOCKER_EXECUTABLE_SHA256:}
docker-config-directory: ${CODING_HARNESS_DOCKER_CONFIG_DIRECTORY:}
docker-host: ${CODING_HARNESS_DOCKER_HOST:}
image: ${CODING_HARNESS_SANDBOX_IMAGE:}
--- # 上传文件地址
sys:
upload:
@@ -46,7 +66,7 @@ spring:
# 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content
dynamic:
# 性能分析插件(有性能损耗 不建议生产环境使用)
p6spy: true
p6spy: false
# 设置默认的数据源或者数据源组,默认值即为 master
primary: master
# 严格模式 匹配不到数据源则报错

View File

@@ -47,7 +47,7 @@ spring:
# 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content
dynamic:
# 性能分析插件(有性能损耗 不建议生产环境使用)
p6spy: true
p6spy: false
# 设置默认的数据源或者数据源组,默认值即为 master
primary: master
# 严格模式 匹配不到数据源则报错

View File

@@ -20,6 +20,35 @@ server:
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
worker: 256
# execute_process 只能经由固定镜像的 Docker OS sandbox;缺任一配置均 fail closed。
coding:
harness:
journal:
# Operator HTML projection is opt-in; production paths must be chosen by deployment config.
enabled: ${CODING_HARNESS_JOURNAL_ENABLED:false}
output-directory: ${CODING_HARNESS_JOURNAL_OUTPUT_DIRECTORY:./data/coding-harness-journal}
budget:
# 模型回合上限;工具额度按每回合最多 3 次保持同比例,避免工具预算先于回合耗尽。
# No task iteration or wall-time cutoff; context compaction supports continued work.
# Optional cumulative spending ceilings (0 = disabled).
max-tool-calls: 0
max-input-tokens: 0
max-output-tokens: 0
tools:
execute-process:
enabled: ${CODING_HARNESS_EXECUTE_PROCESS_ENABLED:false}
sandbox:
# 必须是宿主 Docker CLI 的绝对路径;不会搜索或继承宿主 PATH/HOME。
docker-executable: ${CODING_HARNESS_DOCKER_EXECUTABLE:}
# 对 CLI 文件内容做每次控制调用前复核,不能只信文件名或绝对路径。
docker-executable-sha256: ${CODING_HARNESS_DOCKER_EXECUTABLE_SHA256:}
# 必须是绝对、无链接且始终为空的专用目录,阻断宿主 config.json/proxy/credential helper。
docker-config-directory: ${CODING_HARNESS_DOCKER_CONFIG_DIRECTORY:}
# 只接受平台本地 Docker Engine(Windows named pipe / Unix socket),拒绝远端 context。
docker-host: ${CODING_HARNESS_DOCKER_HOST:}
# 仅接受 evaluator 批准的本地 sha256:<64 hex> image ID,永不 pull/build。
image: ${CODING_HARNESS_SANDBOX_IMAGE:}
--- # 小程序对话 WebSocket 兜底默认模型(前端未传 model 且无智能体绑定时使用)
chat:
default-model: deepseek-v4-flash
@@ -43,6 +72,15 @@ logging:
org.springframework: warn
org.mybatis.spring.mapper: error
org.apache.fury: warn
# MCP traffic/stdout may contain tool arguments, results or credentials.
MCP: "OFF"
dev.langchain4j.mcp: "OFF"
dev.langchain4j.mcp.McpToolProvider: "OFF"
dev.langchain4j.mcp.client.logging.DefaultMcpLogMessageHandler: "OFF"
dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport: "OFF"
dev.langchain4j.mcp.client.transport.stdio.ProcessStderrHandler: "OFF"
dev.langchain4j.mcp.transport.stdio.JsonRpcIoHandler: "ERROR"
dev.langchain4j.mcp.client.transport.http.StreamableHttpMcpTransport: "OFF"
config: classpath:logback-plus.xml
# 用户配置
@@ -129,7 +167,6 @@ security:
- /*/api-docs/**
- /warm-flow-ui/config
- /workflow/run
- /coding/**
# 多租户配置
tenant:
# 是否开启
@@ -234,6 +271,9 @@ xss:
# 排除链接
excludeUrls:
- /system/notice
# Coding prompts contain source text (for example TypeScript <T>), rendered as escaped Markdown.
- /coding/harness/sessions/*/runs
- /coding/harness/sessions/*/runs/*/inputs
--- # 链路追踪配置
trace:
@@ -280,7 +320,7 @@ websocket:
--- # 演示模式配置
demo:
# 是否开启演示模式(开启后所有写操作将被拦截)
enabled: false
enabled: ${DEMO_ENABLED:false}
# 提示消息
message: "演示模式,不允许操作"
# 排除的路径(这些路径不受演示模式限制)

View File

@@ -0,0 +1,143 @@
package org.ruoyi.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ruoyi.common.json.config.JacksonConfig;
import org.ruoyi.system.domain.bo.SysTenantBo;
import org.ruoyi.workflow.config.BeanConfig;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.mock.http.MockHttpInputMessage;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class TenantRequestJacksonTest {
private static final String TENANT_REQUEST = """
{
"contactPhone": "13700000000",
"expireTime": "2027-09-11 00:00:00",
"accountCount": -1,
"companyName": "第一集",
"contactUserName": "刘备",
"username": "user",
"password": "123456",
"packageId": "2018611998196109314"
}
""";
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JacksonConfig.class, JacksonAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class))
.withUserConfiguration(BeanConfig.class);
@Test
void readsTenantRequestWithApplicationDateFormat() {
contextRunner.withPropertyValues("spring.jackson.date-format=yyyy-MM-dd HH:mm:ss").run(context -> {
MappingJackson2HttpMessageConverter converter = context.getBean(MappingJackson2HttpMessageConverter.class);
assertSame(context.getBean(ObjectMapper.class), converter.getObjectMapper());
SysTenantBo tenant = readTenant(converter, TENANT_REQUEST);
assertEquals(expirationDate(), tenant.getExpireTime());
assertEquals(2018611998196109314L, tenant.getPackageId());
assertEquals(-1L, tenant.getAccountCount());
assertEquals("第一集", tenant.getCompanyName());
assertEquals("user", tenant.getUsername());
});
}
@Test
void keepsRegisteredDateDeserializerWithoutDateFormatProperty() {
contextRunner.run(context -> {
SysTenantBo tenant = readTenant(context.getBean(MappingJackson2HttpMessageConverter.class), TENANT_REQUEST);
assertEquals(expirationDate(), tenant.getExpireTime());
});
}
@Test
void honorsConfiguredDateSerializationFormat() {
contextRunner.withPropertyValues("spring.jackson.date-format=yyyy/MM/dd HH:mm:ss").run(context -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
String json = mapper.writeValueAsString(new DateValue(expirationDate()));
assertEquals("{\"expireTime\":\"2027/09/11 00:00:00\"}", json);
});
}
@Test
void preservesWorkflowJsonRoundTripAndNullOmission() {
contextRunner.run(context -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
WorkflowValue value = new WorkflowValue(2018611998196109314L,
LocalDateTime.of(2027, 9, 11, 0, 0), Optional.of("workflow"), null);
String json = mapper.writeValueAsString(value);
JsonNode tree = mapper.readTree(json);
assertTrue(tree.get("id").isTextual());
assertEquals("2018611998196109314", tree.get("id").asText());
assertEquals("2027-09-11 00:00:00", tree.get("createdAt").asText());
assertEquals("workflow", tree.get("name").asText());
assertFalse(tree.has("remark"));
assertEquals(value, mapper.readValue(json, WorkflowValue.class));
});
}
@Test
void acceptsNullExpirationDate() {
contextRunner.run(context -> {
String request = TENANT_REQUEST.replace("\"2027-09-11 00:00:00\"", "null");
SysTenantBo tenant = readTenant(context.getBean(MappingJackson2HttpMessageConverter.class), request);
assertNull(tenant.getExpireTime());
});
}
@Test
void rejectsInvalidExpirationDate() {
contextRunner.run(context -> {
String request = TENANT_REQUEST.replace("2027-09-11 00:00:00", "invalid-date");
MappingJackson2HttpMessageConverter converter = context.getBean(MappingJackson2HttpMessageConverter.class);
assertThrows(HttpMessageNotReadableException.class, () -> readTenant(converter, request));
});
}
private static SysTenantBo readTenant(MappingJackson2HttpMessageConverter converter, String json) throws Exception {
MockHttpInputMessage input = new MockHttpInputMessage(json.getBytes(StandardCharsets.UTF_8));
input.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return (SysTenantBo) converter.read(SysTenantBo.class, input);
}
private static Date expirationDate() {
return Date.from(LocalDateTime.of(2027, 9, 11, 0, 0).atZone(ZoneId.systemDefault()).toInstant());
}
private record DateValue(Date expireTime) {
}
private record WorkflowValue(Long id, LocalDateTime createdAt, Optional<String> name, String remark) {
}
}

View File

@@ -68,6 +68,12 @@
<artifactId>swagger-annotations</artifactId>
<version>${swagger-annotations.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,10 +1,12 @@
package org.ruoyi.common.chat.domain.bo.chat;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.chat.entity.chat.ChatModel;
import org.ruoyi.common.core.validate.AddGroup;
import org.ruoyi.common.core.validate.EditGroup;
@@ -68,6 +70,9 @@ public class ChatModelBo extends BaseEntity {
/**
* 密钥
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;
/**

View File

@@ -0,0 +1,11 @@
package org.ruoyi.common.chat.domain.bo.chat;
import lombok.Data;
/** Minimal, non-secret query contract for user-facing model selection. */
@Data
public class ChatModelSelectQuery {
/** Optional model category; defaults to chat at the controller boundary. */
private String category;
}

View File

@@ -1,8 +1,10 @@
package org.ruoyi.common.chat.domain.bo.chat;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
/**
@@ -24,7 +26,10 @@ public class ModelBatchKeyBo extends BaseEntity {
/**
* 密钥
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@NotBlank(message = "密钥不能为空")
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;
}

View File

@@ -75,6 +75,19 @@ public class ChatRequest {
*/
private Boolean enableThinking = false;
/**
* 是否回放上下文中已经存在的模型思考内容。
* 仅供需要在非思考动作轮中延续 DeepSeek 工具协议的内部调用使用。
*/
private Boolean replayThinking = false;
/**
* Doubao-Seed-Evolving 思考等级(reasoning_effort)。
* 取值 none/minimal/low/medium/high/xhigh/max;null 表示不发送 Doubao 专属参数。
* 仅在 custom_api 供应商承载 Doubao 模型时使用,其他模型忽略此字段。
*/
private String reasoningEffort;
/**
* 对话模型详情
*/

View File

@@ -0,0 +1,40 @@
package org.ruoyi.common.chat.domain.vo.chat;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* Public model option exposed to clients that can select a chat model.
*
* <p>This deliberately contains only the fields required to identify and display a model.
* Operational configuration, including endpoints and credentials, remains server-side.</p>
*/
@Data
public class ChatModelSelectVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** Model configuration identifier. */
private Long id;
/** Configured model name. */
private String modelName;
/** User-facing model description; clients can fall back to modelName when blank. */
private String modelDescribe;
/** Provider identifier used for display. */
private String providerCode;
public static ChatModelSelectVo from(ChatModelVo source) {
ChatModelSelectVo target = new ChatModelSelectVo();
target.setId(source.getId());
target.setModelName(source.getModelName());
target.setModelDescribe(source.getModelDescribe());
target.setProviderCode(source.getProviderCode());
return target;
}
}

View File

@@ -3,8 +3,11 @@ package org.ruoyi.common.chat.domain.vo.chat;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.chat.entity.chat.ChatModel;
import java.io.Serial;
@@ -18,7 +21,7 @@ import java.io.Serializable;
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = ChatModel.class)
@AutoMapper(target = ChatModel.class, convertGenerate = false)
public class ChatModelVo implements Serializable {
@Serial
@@ -74,9 +77,12 @@ public class ChatModelVo implements Serializable {
private String apiHost;
/**
* 密钥
* 密钥仅用于服务端模型调用和写入配置。任何读取接口、日志字符串或 Excel 导出
* 都不得把它带出服务端边界。
*/
@ExcelProperty(value = "密钥")
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;
/**

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.ruoyi.common.tenant.core.TenantEntity;
import java.io.Serial;
@@ -67,6 +68,8 @@ public class ChatModel extends TenantEntity {
/**
* 密钥
*/
@ToString.Exclude
@EqualsAndHashCode.Exclude
private String apiKey;
/**

View File

@@ -0,0 +1,44 @@
package org.ruoyi.common.chat.security;
import java.net.URI;
/** Normalizes the OpenAI/Anthropic endpoint configured in model management. */
public final class CustomApiCredentialPolicy {
public static final String OPENAI_PROVIDER = "custom_api";
public static final String ANTHROPIC_PROVIDER = "custom_anthropic";
private CustomApiCredentialPolicy() {
}
public static boolean isCustomProvider(String providerCode) {
return OPENAI_PROVIDER.equals(providerCode) || ANTHROPIC_PROVIDER.equals(providerCode);
}
/** Accepts a base URL or the selected protocol's complete chat endpoint. */
public static String normalizeBaseUrl(String providerCode, String apiHost) {
if (!isCustomProvider(providerCode)) {
throw new IllegalArgumentException("不支持的自定义厂商协议");
}
if (apiHost == null || apiHost.isBlank()) {
throw new IllegalArgumentException("请填写自定义厂商的请求地址");
}
URI uri = URI.create(apiHost);
if (uri.getHost() == null || !("http".equalsIgnoreCase(uri.getScheme())
|| "https".equalsIgnoreCase(uri.getScheme()))) {
throw new IllegalArgumentException("请求地址必须是有效的 HTTP 或 HTTPS 地址");
}
String normalized = apiHost.replaceAll("/+$", "");
String suffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/messages" : "/chat/completions";
String otherSuffix = ANTHROPIC_PROVIDER.equals(providerCode) ? "/chat/completions" : "/messages";
if (normalized.endsWith(otherSuffix)) {
throw new IllegalArgumentException("请求地址与所选自定义厂商协议不匹配");
}
if (normalized.endsWith(suffix)) {
normalized = normalized.substring(0, normalized.length() - suffix.length());
} else if (URI.create(normalized).getPath().isEmpty()) {
normalized += "/v1";
}
return normalized;
}
}

View File

@@ -49,6 +49,9 @@ public interface IChatModelService {
*/
List<ChatModelVo> queryList(ChatModelBo bo);
/** 查询当前租户中厂商已启用的模型,供用户选择或业务调用。 */
List<ChatModelVo> queryAvailableList(ChatModelBo bo);
/**
* 新增模型管理
*

View File

@@ -0,0 +1,40 @@
package org.ruoyi.common.chat.security;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.domain.bo.chat.ModelBatchKeyBo;
import org.ruoyi.common.core.validate.AddGroup;
import static org.junit.jupiter.api.Assertions.*;
@Tag("dev")
class DifyApiKeyConfigurationTest {
@Test
void acceptsAnAppKeyForCloudAndSelfHostedDify() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String host : new String[]{"https://api.dify.ai/v1", "http://localhost:8080/v1"}) {
var model = new ChatModelBo();
model.setCategory("chat");
model.setProviderCode("dify");
model.setModelName("dify-chat");
model.setApiHost(host);
model.setApiKey("app-test-key");
assertTrue(factory.getValidator().validate(model, AddGroup.class).isEmpty());
}
}
}
@Test
void batchUpdatesStillRequireAProviderAndANonblankKey() {
var batch = new ModelBatchKeyBo();
batch.setProviderCode("dify");
batch.setApiKey("");
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertFalse(factory.getValidator().validate(batch).isEmpty());
batch.setApiKey("app-test-key");
assertTrue(factory.getValidator().validate(batch).isEmpty());
}
}
}

View File

@@ -0,0 +1,73 @@
package org.ruoyi.common.chat.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.domain.bo.chat.ModelBatchKeyBo;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.core.validate.AddGroup;
import org.ruoyi.common.core.validate.EditGroup;
import static org.junit.jupiter.api.Assertions.*;
@Tag("dev")
class ModelApiKeyValidationTest {
@ParameterizedTest
@ValueSource(strings = {"qianwen", "zhipu", "openai", "deepseek", "ppio", "atlas",
"minimax", "dify", "coze", "xiaomi", "custom_api", "custom_anthropic"})
void acceptsAdminConfiguredKeysForEveryProvider(String provider) {
var model = new ChatModelBo();
model.setId(1L);
model.setCategory("chat");
model.setProviderCode(provider);
model.setModelName("configured-model");
model.setApiHost("http://localhost:11434/v1");
model.setApiKey("test-key-" + provider);
var batch = new ModelBatchKeyBo();
batch.setProviderCode(provider);
batch.setApiKey(model.getApiKey());
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
assertTrue(validator.validate(model, AddGroup.class, EditGroup.class).isEmpty());
assertTrue(validator.validate(batch).isEmpty());
}
}
@Test
void acceptsKeylessLocalModelsAndDoesNotRequireHiddenHostField() {
var model = new ChatModelBo();
model.setCategory("chat");
model.setProviderCode("ollama");
model.setModelName("local-model");
try (var factory = Validation.buildDefaultValidatorFactory()) {
assertTrue(factory.getValidator().validate(model, AddGroup.class).isEmpty());
}
}
@Test
void keyRemainsWritableButIsNotSerializedOrIncludedInToString() throws Exception {
var mapper = new ObjectMapper();
var model = mapper.readValue("{\"providerCode\":\"qianwen\",\"apiKey\":\"test-secret\"}", ChatModelBo.class);
assertEquals("test-secret", model.getApiKey());
var view = new ChatModelVo();
view.setApiKey(model.getApiKey());
assertFalse(mapper.writeValueAsString(model).contains("test-secret"));
assertFalse(mapper.writeValueAsString(view).contains("apiKey"));
assertFalse(model.toString().contains("test-secret"));
assertFalse(view.toString().contains("test-secret"));
}
@Test
void customEndpointsNeedNoDeploymentVariablesAndSupportLocalServices() {
assertEquals("http://localhost:11434/v1", CustomApiCredentialPolicy.normalizeBaseUrl(
"custom_api", "http://localhost:11434/v1/chat/completions"));
assertEquals("https://example.com/v1", CustomApiCredentialPolicy.normalizeBaseUrl(
"custom_anthropic", "https://example.com/v1/messages"));
assertThrows(IllegalArgumentException.class, () -> CustomApiCredentialPolicy.normalizeBaseUrl(
"custom_anthropic", "https://example.com/v1/chat/completions"));
}
}

View File

@@ -8,6 +8,7 @@ import org.ruoyi.common.core.utils.SpringUtils;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.task.VirtualThreadTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@@ -39,6 +40,7 @@ public class ThreadPoolConfig {
/**
* 执行周期性或定时任务
*/
@Primary
@Bean(name = "scheduledExecutorService")
protected ScheduledExecutorService scheduledExecutorService() {
// daemon 必须为 true

View File

@@ -27,6 +27,17 @@
<artifactId>ruoyi-common-json</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,6 +1,5 @@
package org.ruoyi.common.log.aspect;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjectUtil;
@@ -22,6 +21,7 @@ import org.ruoyi.common.log.annotation.Log;
import org.ruoyi.common.log.enums.BusinessStatus;
import org.ruoyi.common.log.event.OperLogEvent;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.common.web.utils.SafeRequestLogUtils;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.http.HttpMethod;
import org.springframework.validation.BindingResult;
@@ -42,7 +42,11 @@ public class LogAspect {
/**
* 排除敏感属性字段
*/
public static final String[] EXCLUDE_PROPERTIES = { "password", "oldPassword", "newPassword", "confirmPassword" };
public static final String[] EXCLUDE_PROPERTIES = {
"password", "oldPassword", "newPassword", "confirmPassword", "apiKey",
"clientSecret", "secret", "secretKey", "token", "tokenId", "accessToken",
"refreshToken", "authorization", "credential", "privateKey"
};
/**
@@ -91,14 +95,15 @@ public class LogAspect {
// 请求的地址
String ip = ServletUtils.getClientIP();
operLog.setOperIp(ip);
operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255));
operLog.setOperUrl(StringUtils.substring(SafeRequestLogUtils.sanitizeUri(
ServletUtils.getRequest().getRequestURI()), 0, 255));
LoginUser loginUser = LoginHelper.getLoginUser();
operLog.setOperName(loginUser.getUsername());
operLog.setDeptName(loginUser.getDeptName());
if (e != null) {
operLog.setStatus(BusinessStatus.FAIL.ordinal());
operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 3800));
operLog.setErrorMsg("Request failed: " + e.getClass().getSimpleName());
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
@@ -116,7 +121,7 @@ public class LogAspect {
SpringUtils.context().publishEvent(operLog);
} catch (Exception exp) {
// 记录本地异常日志
log.error("异常信息:{}", exp.getMessage());
log.error("Operation audit failed: {}", exp.getClass().getSimpleName());
} finally {
KEY_CACHE.remove();
}
@@ -143,7 +148,9 @@ public class LogAspect {
}
// 是否需要保存response,参数和值
if (log.isSaveResponseData() && ObjectUtil.isNotNull(jsonResult)) {
operLog.setJsonResult(StringUtils.substring(JsonUtils.toJsonString(jsonResult), 0, 3800));
String responseJson = SensitiveLogSanitizer.sanitizeJson(
JsonUtils.toJsonString(jsonResult), log.excludeParamNames());
operLog.setJsonResult(StringUtils.substring(responseJson, 0, 3800));
}
}
@@ -160,9 +167,9 @@ public class LogAspect {
String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames);
operLog.setOperParam(StringUtils.substring(params, 0, 3800));
} else {
MapUtil.removeAny(paramsMap, EXCLUDE_PROPERTIES);
MapUtil.removeAny(paramsMap, excludeParamNames);
operLog.setOperParam(StringUtils.substring(JsonUtils.toJsonString(paramsMap), 0, 3800));
String params = SensitiveLogSanitizer.sanitizeJson(
JsonUtils.toJsonString(paramsMap), ArrayUtil.addAll(excludeParamNames, EXCLUDE_PROPERTIES));
operLog.setOperParam(StringUtils.substring(params, 0, 3800));
}
}
@@ -177,26 +184,7 @@ public class LogAspect {
String[] exclude = ArrayUtil.addAll(excludeParamNames, EXCLUDE_PROPERTIES);
for (Object o : paramsArray) {
if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {
String str = "";
if (o instanceof List<?> list) {
List<Dict> list1 = new ArrayList<>();
for (Object obj : list) {
String str1 = JsonUtils.toJsonString(obj);
Dict dict = JsonUtils.parseMap(str1);
if (MapUtil.isNotEmpty(dict)) {
MapUtil.removeAny(dict, exclude);
list1.add(dict);
}
}
str = JsonUtils.toJsonString(list1);
} else {
str = JsonUtils.toJsonString(o);
Dict dict = JsonUtils.parseMap(str);
if (MapUtil.isNotEmpty(dict)) {
MapUtil.removeAny(dict, exclude);
str = JsonUtils.toJsonString(dict);
}
}
String str = SensitiveLogSanitizer.sanitizeJson(JsonUtils.toJsonString(o), exclude);
params.add(str);
}
}

View File

@@ -0,0 +1,95 @@
package org.ruoyi.common.log.aspect;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import org.ruoyi.common.web.utils.SafeRequestLogUtils;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** Recursively removes credential fields from operation-audit JSON. */
final class SensitiveLogSanitizer {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Set<String> SENSITIVE_FIELDS = Set.of(
"password", "oldpassword", "newpassword", "confirmpassword", "passwd", "pwd",
"apikey", "clientsecret", "secret", "secretkey", "accesskey", "privatekey",
"token", "tokenid", "accesstoken", "refreshtoken", "authorization", "credential",
"credentials", "dbpassword"
);
private SensitiveLogSanitizer() {
}
static String sanitizeJson(String json, String... additionalSensitiveFields) {
if (json == null || json.isBlank()) {
return json;
}
try {
JsonNode root = MAPPER.readTree(json);
JsonNode safeRoot = redact(root, normalizedFields(additionalSensitiveFields));
return MAPPER.writeValueAsString(safeRoot);
} catch (Exception ignored) {
// Never fall back to the raw value: it may be exactly the malformed secret payload.
return SafeRequestLogUtils.sanitizeText(json);
}
}
private static Set<String> normalizedFields(String[] additionalSensitiveFields) {
Set<String> fields = new HashSet<>(SENSITIVE_FIELDS);
if (additionalSensitiveFields != null) {
Arrays.stream(additionalSensitiveFields)
.filter(value -> value != null && !value.isBlank())
.map(SensitiveLogSanitizer::normalize)
.forEach(fields::add);
}
return fields;
}
private static JsonNode redact(JsonNode node, Set<String> sensitiveFields) {
if (node == null) {
return null;
}
if (node instanceof ObjectNode object) {
Iterator<Map.Entry<String, JsonNode>> fields = object.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
if (sensitiveFields.contains(normalize(field.getKey()))) {
fields.remove();
} else {
JsonNode value = field.getValue();
if (value != null && value.isTextual()) {
object.set(field.getKey(), TextNode.valueOf(
SafeRequestLogUtils.sanitizeText(value.textValue())));
} else {
object.set(field.getKey(), redact(value, sensitiveFields));
}
}
}
} else if (node instanceof ArrayNode array) {
for (int index = 0; index < array.size(); index++) {
JsonNode value = array.get(index);
if (value != null && value.isTextual()) {
array.set(index, TextNode.valueOf(
SafeRequestLogUtils.sanitizeText(value.textValue())));
} else {
array.set(index, redact(value, sensitiveFields));
}
}
} else if (node.isTextual()) {
return TextNode.valueOf(SafeRequestLogUtils.sanitizeText(node.textValue()));
}
return node;
}
private static String normalize(String fieldName) {
return fieldName.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
}
}

View File

@@ -47,6 +47,12 @@
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,56 @@
package org.ruoyi.common.mybatis.config;
import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
import java.util.regex.Pattern;
/**
* P6Spy formatter that deliberately ignores the bound SQL argument.
*
* <p>P6Spy supplies both the JDBC prepared statement and a reconstructed statement with
* parameter values. The latter can contain passwords, API keys and tokens, so this formatter
* records only a bounded, literal-free structural form of the prepared statement.</p>
*/
public final class SafeP6SpyMessageFormatter implements MessageFormattingStrategy {
private static final int MAX_SQL_LENGTH = 4096;
private static final Pattern SQL_STRING_LITERAL = Pattern.compile("'(?:''|\\\\.|[^'\\\\])*'");
private static final Pattern SQL_DOLLAR_QUOTED_LITERAL = Pattern.compile(
"\\$[A-Za-z0-9_]*\\$.*?\\$[A-Za-z0-9_]*\\$", Pattern.DOTALL);
private static final Pattern SECRET_SHAPED_TOKEN = Pattern.compile(
"(?i)\\b(?:sk[-_][A-Za-z0-9._~+/-]{8,}|AKIA[A-Z0-9]{12,})\\b");
private static final Pattern SQL_LINE_COMMENT = Pattern.compile("--[^\\r\\n]*");
private static final Pattern SQL_BLOCK_COMMENT = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL);
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
@Override
public String formatMessage(int connectionId, String now, long elapsed, String category,
String prepared, String sql, String url) {
String statement = structuralPreparedSql(prepared);
return "%s | %d ms | %s | connection %d | %s".formatted(
safeLabel(now), elapsed, safeLabel(category), connectionId, statement);
}
static String structuralPreparedSql(String prepared) {
if (prepared == null || prepared.isBlank()) {
return "[prepared-sql-unavailable]";
}
String safe = SQL_BLOCK_COMMENT.matcher(prepared).replaceAll(" ");
safe = SQL_LINE_COMMENT.matcher(safe).replaceAll(" ");
safe = SQL_DOLLAR_QUOTED_LITERAL.matcher(safe).replaceAll("?");
safe = SQL_STRING_LITERAL.matcher(safe).replaceAll("'?'");
safe = SECRET_SHAPED_TOKEN.matcher(safe).replaceAll("[REDACTED]");
safe = WHITESPACE.matcher(safe).replaceAll(" ").trim();
if (safe.length() > MAX_SQL_LENGTH) {
return safe.substring(0, MAX_SQL_LENGTH) + "...(truncated)";
}
return safe;
}
private static String safeLabel(String value) {
if (value == null) {
return "";
}
return value.replaceAll("[\\r\\n|]", "_");
}
}

View File

@@ -11,6 +11,8 @@ import org.springframework.dao.DuplicateKeyException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.regex.Pattern;
/**
* Mybatis异常处理器
*
@@ -20,13 +22,17 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class MybatisExceptionHandler {
private static final Pattern CREDENTIAL_PATH_SEGMENT = Pattern.compile(
"(?i)(/(?:token(?:[-_]?id)?|access[-_]?token|refresh[-_]?token|api[-_]?key|"
+ "monitor/online(?:/myself)?|myself)/)([^/?#;\\s]+)");
/**
* 主键或UNIQUE索引,数据重复异常
*/
@ExceptionHandler(DuplicateKeyException.class)
public R<Void> handleDuplicateKeyException(DuplicateKeyException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}',数据库中已存在记录'{}'", requestURI, e.getMessage());
log.error("database_request_failed category=DUPLICATE_KEY method={} path={} exceptionType={}",
request.getMethod(), safePath(request), e.getClass().getName());
return R.fail(HttpStatus.HTTP_CONFLICT, "数据库中已存在该记录,请联系管理员确认");
}
@@ -35,18 +41,20 @@ public class MybatisExceptionHandler {
*/
@ExceptionHandler(MyBatisSystemException.class)
public R<Void> handleCannotFindDataSourceException(MyBatisSystemException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
Throwable root = getRootCause(e);
if (root instanceof NotLoginException) {
log.error("请求地址'{}',认证失败'{}',无法访问系统资源", requestURI, root.getMessage());
log.error("database_request_failed category=AUTHENTICATION method={} path={} exceptionType={}",
request.getMethod(), safePath(request), root.getClass().getName());
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源");
}
if (root instanceof CannotFindDataSourceException) {
log.error("请求地址'{}', 未找到数据源", requestURI);
log.error("database_request_failed category=DATA_SOURCE_NOT_FOUND method={} path={} exceptionType={}",
request.getMethod(), safePath(request), root.getClass().getName());
return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "未找到数据源,请联系管理员确认");
}
log.error("请求地址'{}', Mybatis系统异常", requestURI, e);
return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, e.getMessage());
log.error("database_request_failed category=MYBATIS_SYSTEM method={} path={} exceptionType={} rootType={}",
request.getMethod(), safePath(request), e.getClass().getName(), root.getClass().getName());
return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "数据库访问异常,请联系管理员确认");
}
/**
@@ -86,4 +94,16 @@ public class MybatisExceptionHandler {
return null;
}
private static String safePath(HttpServletRequest request) {
String path = request.getRequestURI();
if (path == null) {
return null;
}
int queryIndex = path.indexOf('?');
if (queryIndex >= 0) {
path = path.substring(0, queryIndex);
}
return CREDENTIAL_PATH_SEGMENT.matcher(path).replaceAll("$1[REDACTED]");
}
}

View File

@@ -1,11 +1,9 @@
# p6spy 性能分析插件配置文件
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory
# 自定义日志打印
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
#日志输出到控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 使用日志系统记录 sql
#appender=com.p6spy.engine.spy.appender.Slf4JLogger
logMessageFormat=org.ruoyi.common.mybatis.config.SafeP6SpyMessageFormatter
# 使用日志系统记录脱敏后的 prepared SQL 结构;formatter 永不消费 bound SQL 参数。
appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 取消JDBC URL前缀
useprefix=true
# 配置记录 Log 例外,可去掉的结果集有error,info,batch,debug,statement,commit,rollback,result,resultset.

View File

@@ -10,6 +10,8 @@ import org.ruoyi.common.core.domain.R;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.regex.Pattern;
/**
* SaToken异常处理器
*
@@ -19,13 +21,17 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class SaTokenExceptionHandler {
private static final Pattern CREDENTIAL_PATH_SEGMENT = Pattern.compile(
"(?i)(/(?:token(?:[-_]?id)?|access[-_]?token|refresh[-_]?token|api[-_]?key|"
+ "monitor/online(?:/myself)?|myself)/)([^/?#;\\s]+)");
/**
* 权限码异常
*/
@ExceptionHandler(NotPermissionException.class)
public R<Void> handleNotPermissionException(NotPermissionException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}',权限码校验失败'{}'", requestURI, e.getMessage());
log.error("authorization_failed category=PERMISSION method={} path={} exceptionType={}",
request.getMethod(), safePath(request), e.getClass().getName());
return R.fail(HttpStatus.HTTP_FORBIDDEN, "没有访问权限,请联系管理员授权");
}
@@ -34,8 +40,8 @@ public class SaTokenExceptionHandler {
*/
@ExceptionHandler(NotRoleException.class)
public R<Void> handleNotRoleException(NotRoleException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}',角色权限校验失败'{}'", requestURI, e.getMessage());
log.error("authorization_failed category=ROLE method={} path={} exceptionType={}",
request.getMethod(), safePath(request), e.getClass().getName());
return R.fail(HttpStatus.HTTP_FORBIDDEN, "没有访问权限,请联系管理员授权");
}
@@ -44,9 +50,21 @@ public class SaTokenExceptionHandler {
*/
@ExceptionHandler(NotLoginException.class)
public R<Void> handleNotLoginException(NotLoginException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}',认证失败'{}',无法访问系统资源", requestURI, e.getMessage());
log.error("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}",
request.getMethod(), safePath(request), e.getClass().getName());
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源");
}
private static String safePath(HttpServletRequest request) {
String path = request.getRequestURI();
if (path == null) {
return null;
}
int queryIndex = path.indexOf('?');
if (queryIndex >= 0) {
path = path.substring(0, queryIndex);
}
return CREDENTIAL_PATH_SEGMENT.matcher(path).replaceAll("$1[REDACTED]");
}
}

View File

@@ -9,6 +9,7 @@ import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import cn.dev33.satoken.util.SaTokenConsts;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -50,9 +51,11 @@ public class SecurityConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
// 注册路由拦截器,自定义验证规则
registry.addInterceptor(new SaInterceptor(handler -> {
HttpServletRequest request = ServletUtils.getRequest();
// 异步线程中 SaToken 上下文不存在,跳过检查
// 这避免了 SSE 流式响应完成后 emitter.complete() 触发的问题
if (SaTokenContextForThreadLocalStaff.getModelBoxOrNull() == null) {
if (request.getDispatcherType() != DispatcherType.REQUEST
|| SaTokenContextForThreadLocalStaff.getModelBoxOrNull() == null) {
return;
}
AllUrlHandler allUrlHandler = SpringUtils.getBean(AllUrlHandler.class);
@@ -62,9 +65,6 @@ public class SecurityConfig implements WebMvcConfigurer {
.match(allUrlHandler.getUrls())
// 对未排除的路径进行检查
.check(() -> {
HttpServletRequest request = ServletUtils.getRequest();
HttpServletResponse response = ServletUtils.getResponse();
response.setContentType(SaTokenConsts.CONTENT_TYPE_APPLICATION_JSON);
// 检查是否登录 是否有token
StpUtil.checkLogin();

View File

@@ -32,5 +32,10 @@
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-json</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -97,7 +97,8 @@ public class SseEmitterManager {
try {
emitter.send(SseEmitter.event().comment("disconnected"));
} catch (Exception exception) {
log.error(exception.getMessage());
log.error("sse_connection operation=DISCONNECT routeType=SESSION status=FAILED errorType={}",
errorType(exception));
}
emitter.complete();
}
@@ -115,16 +116,18 @@ public class SseEmitterManager {
}
SseEmitter emitter = SESSION_EMITTERS.get(sessionId);
if (emitter == null) {
log.warn("【SSE发送失败】sessionId: {} 没有活跃的SSE连接", sessionId);
log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}",
SESSION_EMITTERS.size());
return;
}
try {
log.debug("【SSE发送】sessionId: {}, event: {}", sessionId, eventDto.getEvent());
log.debug("sse_delivery routeType=SESSION status=SENDING eventType={} payloadChars={}",
safeEventType(eventDto), eventPayloadLength(eventDto));
emitter.send(SseEmitter.event()
.name(eventDto.getEvent())
.data(JSONUtil.toJsonStr(eventDto)));
} catch (Exception e) {
log.error("【SSE发送失败】sessionId: {}, error: {}", sessionId, e.getMessage());
log.error("sse_delivery routeType=SESSION status=FAILED errorType={}", errorType(e));
removeSessionEmitter(sessionId, emitter);
}
}
@@ -212,7 +215,8 @@ public class SseEmitterManager {
sseEmitter.send(SseEmitter.event().comment("disconnected"));
//sseEmitter.complete();
} catch (Exception exception) {
log.error(exception.getMessage());
log.error("sse_connection operation=DISCONNECT routeType=USER status=FAILED errorType={}",
errorType(exception));
}
emitters.remove(token);
} else {
@@ -321,14 +325,15 @@ public class SseEmitterManager {
public void sendEvent(Long userId, SseEventDto eventDto) {
Map<String, SseEmitter> emitters = USER_TOKEN_EMITTERS.get(userId);
if (MapUtil.isNotEmpty(emitters)) {
log.debug("【SSE发送】userId: {}, emitter数量: {}, event: {}", userId, emitters.size(), eventDto.getEvent());
log.debug("sse_delivery routeType=USER status=SENDING connectionCount={} eventType={} payloadChars={}",
emitters.size(), safeEventType(eventDto), eventPayloadLength(eventDto));
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
try {
entry.getValue().send(SseEmitter.event()
.name(eventDto.getEvent())
.data(JSONUtil.toJsonStr(eventDto)));
} catch (Exception e) {
log.error("【SSE发送失败】userId: {}, token: {}, error: {}", userId, entry.getKey(), e.getMessage());
log.error("{}", sendFailureSummary(e));
SseEmitter remove = emitters.remove(entry.getKey());
if (remove != null) {
remove.complete();
@@ -336,11 +341,17 @@ public class SseEmitterManager {
}
}
} else {
log.warn("【SSE发送失败】userId: {} 没有活跃的SSE连接, 当前连接用户: {}", userId, USER_TOKEN_EMITTERS.keySet());
log.warn("sse_delivery routeType=USER status=SKIPPED reason=NO_ACTIVE_CONNECTION activeUserCount={}",
USER_TOKEN_EMITTERS.size());
USER_TOKEN_EMITTERS.remove(userId);
}
}
/** This boundary accepts neither routing identifiers nor raw exception text. */
static String sendFailureSummary(Exception failure) {
return "sse_delivery routeType=USER status=FAILED errorType=" + errorType(failure);
}
/**
* 本机全用户会话发送消息
@@ -365,8 +376,7 @@ public class SseEmitterManager {
broadcastMessage.setSessionId(sseMessageDto.getSessionId());
broadcastMessage.setEventDto(sseMessageDto.getEventDto());
RedisUtils.publish(SSE_TOPIC, broadcastMessage, consumer -> {
log.info("SSE发送主题订阅消息topic:{} session:{} session keys:{} message:{}",
SSE_TOPIC, sseMessageDto.getSessionId(), sseMessageDto.getUserIds(), sseMessageDto.getMessage());
log.info("{}", publishSummary(sseMessageDto));
});
}
@@ -379,7 +389,56 @@ public class SseEmitterManager {
SseMessageDto broadcastMessage = new SseMessageDto();
broadcastMessage.setMessage(message);
RedisUtils.publish(SSE_TOPIC, broadcastMessage, consumer -> {
log.info("SSE发送主题订阅消息topic:{} message:{}", SSE_TOPIC, message);
log.info("sse_publish routeType=BROADCAST recipientCount=-1 payloadChars={} eventType=none",
textLength(message));
});
}
static String publishSummary(SseMessageDto message) {
if (message == null) {
return "sse_publish routeType=UNKNOWN recipientCount=0 payloadChars=0 eventType=none";
}
String routeType;
int recipientCount;
if (message.getSessionId() != null && !message.getSessionId().isBlank()) {
routeType = "SESSION";
recipientCount = 1;
} else if (message.getUserIds() != null && !message.getUserIds().isEmpty()) {
routeType = "USER";
recipientCount = message.getUserIds().size();
} else {
routeType = "BROADCAST";
recipientCount = -1;
}
return "sse_publish routeType=" + routeType
+ " recipientCount=" + recipientCount
+ " payloadChars=" + (textLength(message.getMessage()) + eventPayloadLength(message.getEventDto()))
+ " eventType=" + safeEventType(message.getEventDto());
}
static String safeEventType(SseEventDto event) {
if (event == null || event.getEvent() == null) {
return "none";
}
return switch (event.getEvent()) {
case "content", "reasoning", "done", "error", "mcp_tool", "message" -> event.getEvent();
default -> "custom";
};
}
private static int eventPayloadLength(SseEventDto event) {
if (event == null) {
return 0;
}
return textLength(event.getContent()) + textLength(event.getReasoningContent())
+ textLength(event.getError());
}
private static int textLength(String value) {
return value == null ? 0 : value.length();
}
private static String errorType(Throwable failure) {
return failure == null ? "unknown" : failure.getClass().getName();
}
}

View File

@@ -30,8 +30,7 @@ public class SseTopicListener implements ApplicationRunner, Ordered {
@Override
public void run(ApplicationArguments args) throws Exception {
sseEmitterManager.subscribeMessage((message) -> {
log.info("SSE主题订阅收到消息session:{} session keys={} message={}",
message.getSessionId(), message.getUserIds(), message.getMessage());
log.info("{}", deliverySummary(message));
// 优先按会话路由(对话流式响应)
if (StrUtil.isNotBlank(message.getSessionId())) {
if (message.getEventDto() != null) {
@@ -59,4 +58,44 @@ public class SseTopicListener implements ApplicationRunner, Ordered {
public int getOrder() {
return -1;
}
static String deliverySummary(SseMessageDto message) {
if (message == null) {
return "sse_subscription status=RECEIVED routeType=UNKNOWN recipientCount=0 payloadChars=0 eventType=none";
}
String routeType;
int recipientCount;
if (StrUtil.isNotBlank(message.getSessionId())) {
routeType = "SESSION";
recipientCount = 1;
} else if (CollUtil.isNotEmpty(message.getUserIds())) {
routeType = "USER";
recipientCount = message.getUserIds().size();
} else {
routeType = "BROADCAST";
recipientCount = -1;
}
int payloadChars = textLength(message.getMessage());
String eventType = "none";
if (message.getEventDto() != null) {
payloadChars += textLength(message.getEventDto().getContent())
+ textLength(message.getEventDto().getReasoningContent())
+ textLength(message.getEventDto().getError());
String rawEventType = message.getEventDto().getEvent();
if (rawEventType != null) {
eventType = switch (rawEventType) {
case "content", "reasoning", "done", "error", "mcp_tool", "message" -> rawEventType;
default -> "custom";
};
}
}
return "sse_subscription status=RECEIVED routeType=" + routeType
+ " recipientCount=" + recipientCount
+ " payloadChars=" + payloadChars
+ " eventType=" + eventType;
}
private static int textLength(String value) {
return value == null ? 0 : value.length();
}
}

View File

@@ -18,6 +18,7 @@ import org.ruoyi.common.sse.dto.SseMessageDto;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SseMessageUtils {
private static final String SAFE_ERROR_MESSAGE = "请求处理失败,请稍后重试";
private final static Boolean SSE_ENABLE = SpringUtils.getProperty("sse.enabled", Boolean.class, true);
private static SseEmitterManager MANAGER;
@@ -196,7 +197,7 @@ public class SseMessageUtils {
* @param error 错误信息
*/
public static void sendError(Long userId, String error) {
sendEvent(userId, SseEventDto.error(error));
sendEvent(userId, SseEventDto.error(SAFE_ERROR_MESSAGE));
}
/**
@@ -206,7 +207,7 @@ public class SseMessageUtils {
* @param error 错误信息
*/
public static void sendError(String sessionId, String error) {
sendEvent(sessionId, SseEventDto.error(error));
sendEvent(sessionId, SseEventDto.error(SAFE_ERROR_MESSAGE));
}
/**

View File

@@ -65,7 +65,12 @@ public class DefaultTraceStreamSpan implements TraceStreamSpan {
traceRecordService.finishNode(traceId, nodeId, status, errorMessage, outputPayload,
new Date(), System.currentTimeMillis() - startMillis);
} catch (Exception e) {
log.warn("结束 trace stream span 失败,traceId={}, nodeId={}", traceId, nodeId, e);
log.warn("trace_persistence operation=FINISH_STREAM_NODE status=FAILED traceId={} nodeId={} errorType={}",
traceId, nodeId, errorType(e));
}
}
private static String errorType(Throwable error) {
return error == null ? "unknown" : error.getClass().getName();
}
}

View File

@@ -37,9 +37,14 @@ public final class TracePayloadUtils {
if (throwable == null) {
return null;
}
int maxLength = properties == null ? 1000 : properties.getPayload().getMaxErrorLength();
String message = throwable.getClass().getSimpleName() + ": " + (throwable.getMessage() == null ? "" : throwable.getMessage());
return truncate(message, maxLength);
int maxLength = properties == null || properties.getPayload() == null
? 1000 : properties.getPayload().getMaxErrorLength();
// Trace payloads are durable, operator-visible records. Provider exception messages can
// carry prompts, credentials, SQL, file-system paths, or response bodies, so this boundary
// must never read or retain Throwable#getMessage(). The exception type is sufficient for
// classification and remains useful without persisting untrusted content.
String summary = "TRACE_FAILURE errorType=" + throwable.getClass().getName();
return truncate(summary, maxLength);
}
public static String toJson(Object value) {

View File

@@ -1,6 +1,10 @@
package org.ruoyi.common.trace.core;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -9,11 +13,14 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.ruoyi.common.trace.config.TraceProperties;
import org.ruoyi.common.trace.constant.TraceConstants;
import org.ruoyi.common.trace.service.TraceRecordService;
import org.slf4j.LoggerFactory;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
@@ -23,6 +30,7 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class DefaultTraceStreamSpanTest {
@Mock
@@ -45,17 +53,18 @@ class DefaultTraceStreamSpanTest {
}
@Test
void finishErrorShouldRecordTruncatedError() {
void finishErrorShouldRecordOnlyBoundedExceptionType() {
TraceProperties properties = new TraceProperties();
properties.getPayload().setMaxErrorLength(8);
properties.getPayload().setMaxErrorLength(200);
DefaultTraceStreamSpan span = new DefaultTraceStreamSpan(traceRecordService, properties, "trace-1", "node-1", System.currentTimeMillis());
span.finishError(new IllegalStateException("abcdef"));
span.finishError(new IllegalStateException("provider-credential-path-canary"));
ArgumentCaptor<String> errorCaptor = ArgumentCaptor.forClass(String.class);
verify(traceRecordService).finishNode(eq("trace-1"), eq("node-1"), eq(TraceConstants.STATUS_ERROR),
errorCaptor.capture(), isNull(), any(Date.class), anyLong());
assertEquals("IllegalS", errorCaptor.getValue());
assertEquals("TRACE_FAILURE errorType=java.lang.IllegalStateException", errorCaptor.getValue());
assertFalse(errorCaptor.getValue().contains("provider-credential-path-canary"));
}
@Test
@@ -72,12 +81,38 @@ class DefaultTraceStreamSpanTest {
@Test
void writeFailureShouldNotEscape() {
doThrow(new IllegalStateException("db down")).when(traceRecordService)
Logger logger = (Logger) LoggerFactory.getLogger(DefaultTraceStreamSpan.class);
ListAppender<ILoggingEvent> appender = attach(logger);
doThrow(new IllegalStateException("trace-storage-credential-canary")).when(traceRecordService)
.finishNode(eq("trace-1"), eq("node-1"), eq(TraceConstants.STATUS_SUCCESS), isNull(), isNull(), any(Date.class), anyLong());
DefaultTraceStreamSpan span = new DefaultTraceStreamSpan(traceRecordService, new TraceProperties(), "trace-1", "node-1", System.currentTimeMillis());
span.finishSuccess();
try {
span.finishSuccess();
assertNull(TraceContext.currentNodeId());
String logs = formattedLogs(appender);
assertNull(TraceContext.currentNodeId());
assertFalse(logs.contains("trace-storage-credential-canary"));
assertTrue(logs.contains("java.lang.IllegalStateException"));
} finally {
detach(logger, appender);
}
}
private static ListAppender<ILoggingEvent> attach(Logger logger) {
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
return appender;
}
private static void detach(Logger logger, ListAppender<ILoggingEvent> appender) {
logger.detachAppender(appender);
appender.stop();
}
private static String formattedLogs(ListAppender<ILoggingEvent> appender) {
return appender.list.stream().map(ILoggingEvent::getFormattedMessage)
.reduce("", (left, right) -> left + "\n" + right);
}
}

View File

@@ -1,13 +1,18 @@
package org.ruoyi.common.trace.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import org.ruoyi.common.trace.config.TraceProperties;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class TracePayloadUtilsTest {
@Test
@@ -25,4 +30,25 @@ class TracePayloadUtilsTest {
String json = TracePayloadUtils.toJson(Map.of("count", 2));
assertTrue(json.contains("\"count\":2"));
}
@Test
void errorShouldPersistOnlyTypeWithoutReadingThrowableMessage() {
AtomicBoolean messageRead = new AtomicBoolean();
Throwable failure = new IllegalStateException() {
@Override
public String getMessage() {
messageRead.set(true);
return "provider-credential-path-canary";
}
};
String summary = TracePayloadUtils.error(failure, new TraceProperties());
TraceProperties missingPayloadPolicy = new TraceProperties();
missingPayloadPolicy.setPayload(null);
assertFalse(messageRead.get());
assertEquals("TRACE_FAILURE errorType=" + failure.getClass().getName(), summary);
assertEquals(summary, TracePayloadUtils.error(failure, missingPayloadPolicy));
assertFalse(summary.contains("provider-credential-path-canary"));
}
}

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