mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-11 21:40:41 +00:00
feat: enhance coding harness, model security and system features
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,7 +105,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 配置
|
||||
|
||||
@@ -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 配置
|
||||
|
||||
45
docs/script/sql/README.md
Normal file
45
docs/script/sql/README.md
Normal 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 文件头。
|
||||
@@ -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', 'env:DEEPSEEK_API_KEY', 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', 'env:DEEPSEEK_API_KEY', 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', 'env:DEEPSEEK_API_KEY', 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);
|
||||
@@ -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
|
||||
-- ----------------------------
|
||||
@@ -3669,7 +3697,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 段已省略;菜单插入加了判重守卫。
|
||||
-- ============================================================
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
-- ----------------------------
|
||||
-- 内容管理表 cms_content
|
||||
-- 2026-08-15 创建
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `cms_content` (
|
||||
`content_id` bigint NOT NULL COMMENT '内容ID',
|
||||
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '000000' COMMENT '租户编号',
|
||||
`title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '内容标题',
|
||||
`summary` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '摘要',
|
||||
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '正文内容',
|
||||
`cover_image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '封面图片',
|
||||
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态(0草稿 1已发布 2下架)',
|
||||
`publish_time` datetime NULL DEFAULT NULL COMMENT '发布时间',
|
||||
`sort_order` int NULL DEFAULT 0 COMMENT '排序顺序',
|
||||
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
|
||||
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL 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 (`content_id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '内容管理表' ROW_FORMAT = DYNAMIC;
|
||||
@@ -1,62 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 内容管理(CMS)菜单权限脚本
|
||||
-- 2026-08-15
|
||||
-- 菜单 ID 使用 2099010100000000040–2099010100000000045 段(已验证空区间),
|
||||
-- 与现有 snowflake id(2xxxxxxxxxxxxxxxxx)及 2099010100000000xxx 既有段不冲突。
|
||||
-- 幂等:每行通过 WHERE NOT EXISTS 同时检查固定 menu_id 与对应的 path/component/perms 语义,
|
||||
-- 避免同语义菜单已用其他 ID 存在时重复插入。
|
||||
-- ============================================================
|
||||
|
||||
-- 目录:内容管理 (parent_id=0, path='cms', menu_type='M')
|
||||
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`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000040, '内容管理', 0, 6, 'cms', '', '', 1, 0, 'M', '0', '0', '', 'fluent:content-24-regular', 103, 1, '2026-08-15 00:00:00', NULL, NULL, '内容管理目录'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000040
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `path` = 'cms' AND `menu_type` = 'M' AND `parent_id` = 0
|
||||
);
|
||||
|
||||
-- 页面:内容列表 (parent_id=2099010100000000040, component='cms/content/index', 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`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000041, '内容列表', 2099010100000000040, 1, 'content', 'cms/content/index', NULL, 1, 0, 'C', '0', '0', 'cms:content:list', 'fluent:document-text-24-regular', 103, 1, '2026-08-15 00:00:00', NULL, NULL, '内容列表菜单'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000041
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `component` = 'cms/content/index' AND `menu_type` = 'C'
|
||||
);
|
||||
|
||||
-- 按钮:内容查询 (perms='cms:content:query')
|
||||
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`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000042, '内容查询', 2099010100000000041, 1, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:query', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000042
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:query' AND `menu_type` = 'F'
|
||||
);
|
||||
|
||||
-- 按钮:内容新增 (perms='cms:content:add')
|
||||
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`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000043, '内容新增', 2099010100000000041, 2, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:add', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000043
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:add' AND `menu_type` = 'F'
|
||||
);
|
||||
|
||||
-- 按钮:内容修改 (perms='cms:content:edit')
|
||||
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`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000044, '内容修改', 2099010100000000041, 3, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:edit', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000044
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:edit' AND `menu_type` = 'F'
|
||||
);
|
||||
|
||||
-- 按钮:内容删除 (perms='cms:content:remove')
|
||||
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`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000045, '内容删除', 2099010100000000041, 4, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:remove', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000045
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:remove' AND `menu_type` = 'F'
|
||||
);
|
||||
41
docs/script/sql/update/2026-08-30-mcp-market-tool-tenant.sql
Normal file
41
docs/script/sql/update/2026-08-30-mcp-market-tool-tenant.sql
Normal 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`);
|
||||
76
docs/script/sql/update/2026-09-01-sys-url.sql
Normal file
76
docs/script/sql/update/2026-09-01-sys-url.sql
Normal 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');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# 严格模式 匹配不到数据源则报错
|
||||
|
||||
@@ -47,7 +47,7 @@ spring:
|
||||
# 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content
|
||||
dynamic:
|
||||
# 性能分析插件(有性能损耗 不建议生产环境使用)
|
||||
p6spy: true
|
||||
p6spy: false
|
||||
# 设置默认的数据源或者数据源组,默认值即为 master
|
||||
primary: master
|
||||
# 严格模式 匹配不到数据源则报错
|
||||
|
||||
@@ -20,16 +20,34 @@ server:
|
||||
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
|
||||
worker: 256
|
||||
|
||||
# Harness 命令仍受工作区租约、执行计划和工具审批约束;开启后才能验证并运行生成的网站。
|
||||
# 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 次保持同比例,避免工具预算先于回合耗尽。
|
||||
max-iterations: 200
|
||||
max-tool-calls: 600
|
||||
# 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: true
|
||||
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:
|
||||
@@ -54,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
|
||||
|
||||
# 用户配置
|
||||
@@ -244,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:
|
||||
@@ -290,7 +320,7 @@ websocket:
|
||||
--- # 演示模式配置
|
||||
demo:
|
||||
# 是否开启演示模式(开启后所有写操作将被拦截)
|
||||
enabled: true
|
||||
enabled: ${DEMO_ENABLED:false}
|
||||
# 提示消息
|
||||
message: "演示模式,不允许操作"
|
||||
# 排除的路径(这些路径不受演示模式限制)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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 jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatModel;
|
||||
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
|
||||
import org.ruoyi.common.chat.security.ChatModelSecretReference;
|
||||
import org.ruoyi.common.core.validate.AddGroup;
|
||||
import org.ruoyi.common.core.validate.EditGroup;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
@@ -63,11 +68,21 @@ public class ChatModelBo extends BaseEntity {
|
||||
/**
|
||||
* 请求地址
|
||||
*/
|
||||
@NotBlank(message = "请求地址不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
@Pattern(regexp = ChatModelCredentialPolicy.HTTPS_API_HOST_REGEXP,
|
||||
message = "请求地址必须是无凭据、查询参数和片段的 HTTPS 地址",
|
||||
groups = { AddGroup.class, EditGroup.class })
|
||||
private String apiHost;
|
||||
|
||||
/**
|
||||
* 密钥
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
@Pattern(regexp = ChatModelSecretReference.ENV_REFERENCE_REGEXP,
|
||||
message = "密钥必须使用受信任的环境变量引用",
|
||||
groups = { AddGroup.class, EditGroup.class })
|
||||
@ToString.Exclude
|
||||
@EqualsAndHashCode.Exclude
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package org.ruoyi.common.chat.domain.bo.chat;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import org.ruoyi.common.chat.security.ChatModelSecretReference;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
@@ -24,7 +28,12 @@ public class ModelBatchKeyBo extends BaseEntity {
|
||||
/**
|
||||
* 密钥
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
@NotBlank(message = "密钥不能为空")
|
||||
@Pattern(regexp = ChatModelSecretReference.ENV_REFERENCE_REGEXP,
|
||||
message = "密钥必须使用受信任的环境变量引用")
|
||||
@ToString.Exclude
|
||||
@EqualsAndHashCode.Exclude
|
||||
private String apiKey;
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 对话模型详情
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ 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 org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
@@ -18,7 +22,7 @@ import java.io.Serializable;
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = ChatModel.class)
|
||||
@AutoMapper(target = ChatModel.class, convertGenerate = false)
|
||||
public class ChatModelVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
@@ -74,11 +78,29 @@ public class ChatModelVo implements Serializable {
|
||||
private String apiHost;
|
||||
|
||||
/**
|
||||
* 密钥
|
||||
* 密钥仅用于服务端模型调用和写入配置。任何读取接口、日志字符串或 Excel 导出
|
||||
* 都不得把它带出服务端边界。
|
||||
*/
|
||||
@ExcelProperty(value = "密钥")
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
@ToString.Exclude
|
||||
@EqualsAndHashCode.Exclude
|
||||
private String apiKey;
|
||||
|
||||
/** Returns the stored configuration reference without touching the process environment. */
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the reference only after binding it to both the consuming provider and this row's
|
||||
* effective provider, model and endpoint. Non-allowlisted, misrouted, or legacy rows fail
|
||||
* closed before the environment is read.
|
||||
*/
|
||||
public String resolveApiKeyForConfiguredEndpoint(String consumingProviderCode) {
|
||||
return ChatModelCredentialPolicy.resolveApiKeyForUse(
|
||||
consumingProviderCode, providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Security policy binding model credential references to trusted provider endpoints. */
|
||||
public final class ChatModelCredentialPolicy {
|
||||
|
||||
public static final String HTTPS_API_HOST_REGEXP =
|
||||
"^https://(?![^/?#]*@)[^\\s/?#]+(?::[0-9]{1,5})?(?:/[^\\s?#]*)?$";
|
||||
|
||||
public static final String DEEPSEEK_PROVIDER = "deepseek";
|
||||
public static final String DEEPSEEK_API_HOST = "https://api.deepseek.com";
|
||||
public static final String PPIO_PROVIDER = "ppio";
|
||||
public static final String PPIO_API_HOST = "https://api.ppio.com/openai/v1";
|
||||
|
||||
private static final Set<String> PPIO_API_HOSTS = Set.of(
|
||||
PPIO_API_HOST, PPIO_API_HOST + "/",
|
||||
"https://api.ppio.com/openai", "https://api.ppio.com/openai/"
|
||||
);
|
||||
|
||||
private static final Set<String> DEEPSEEK_MODELS = Set.of(
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash-vision-exp"
|
||||
);
|
||||
|
||||
private ChatModelCredentialPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a provider credential only after the effective provider/model/endpoint/reference
|
||||
* tuple passes the same allowlist used at the persistence boundary.
|
||||
*/
|
||||
public static String resolveApiKeyForUse(String consumingProviderCode,
|
||||
String providerCode,
|
||||
String modelName,
|
||||
String apiHost,
|
||||
String apiKey) {
|
||||
return resolveApiKeyForUse(consumingProviderCode, providerCode, modelName, apiHost,
|
||||
apiKey, System::getenv);
|
||||
}
|
||||
|
||||
static String resolveApiKeyForUse(String consumingProviderCode,
|
||||
String providerCode,
|
||||
String modelName,
|
||||
String apiHost,
|
||||
String apiKey,
|
||||
Function<String, String> environment) {
|
||||
if (!Objects.equals(consumingProviderCode, providerCode)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Credential consumer does not match the configured provider");
|
||||
}
|
||||
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
|
||||
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey, environment);
|
||||
} else {
|
||||
requireTrustedConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
return ChatModelSecretReference.resolveAllowlistedReference(apiKey,
|
||||
Objects.requireNonNull(environment, "environment"));
|
||||
}
|
||||
|
||||
/** Validates the effective row which will remain after an insert or partial update. */
|
||||
public static void requirePersistableConfiguration(String providerCode,
|
||||
String modelName,
|
||||
String apiHost,
|
||||
String apiKey) {
|
||||
requireSecureApiHost(apiHost);
|
||||
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
|
||||
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
return;
|
||||
}
|
||||
if (PPIO_PROVIDER.equals(providerCode)) {
|
||||
requirePpioConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
return;
|
||||
}
|
||||
if (apiKey != null) {
|
||||
ChatModelSecretReference.requirePersistableReference(apiKey);
|
||||
if (!isDeepSeekConfiguration(providerCode, modelName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Model API key reference is not allowed for this provider");
|
||||
}
|
||||
}
|
||||
if (isDeepSeekConfiguration(providerCode, modelName)) {
|
||||
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-validates the configuration immediately before a DeepSeek provider client is built. */
|
||||
public static void requireDeepSeekConfiguration(String providerCode,
|
||||
String modelName,
|
||||
String apiHost,
|
||||
String apiKey) {
|
||||
if (!DEEPSEEK_PROVIDER.equals(providerCode)) {
|
||||
throw new IllegalArgumentException("DeepSeek model provider is not trusted");
|
||||
}
|
||||
if (!DEEPSEEK_MODELS.contains(modelName)) {
|
||||
throw new IllegalArgumentException("DeepSeek model is not allowlisted");
|
||||
}
|
||||
requireSecureApiHost(apiHost);
|
||||
if (!DEEPSEEK_API_HOST.equals(apiHost)) {
|
||||
throw new IllegalArgumentException("DeepSeek API host is not allowlisted");
|
||||
}
|
||||
if (apiKey == null) {
|
||||
throw new IllegalArgumentException("DeepSeek API key reference is required");
|
||||
}
|
||||
requireProviderReference(providerCode, apiKey);
|
||||
}
|
||||
|
||||
/** Binds each provider to its own environment variable, including batch credential updates. */
|
||||
public static void requireProviderReference(String providerCode, String apiKey) {
|
||||
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
|
||||
CustomApiCredentialPolicy.requireReference(providerCode, apiKey);
|
||||
return;
|
||||
}
|
||||
String expectedReference;
|
||||
if (DEEPSEEK_PROVIDER.equals(providerCode)) {
|
||||
expectedReference = "env:DEEPSEEK_API_KEY";
|
||||
} else if (PPIO_PROVIDER.equals(providerCode)) {
|
||||
expectedReference = "env:PPIO_API_KEY";
|
||||
} else {
|
||||
throw new IllegalArgumentException("Model credential provider is not allowlisted");
|
||||
}
|
||||
ChatModelSecretReference.requirePersistableReference(apiKey);
|
||||
if (!expectedReference.equals(apiKey)) {
|
||||
throw new IllegalArgumentException("Model API key reference does not match the provider");
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates credential use without allowing another adapter to consume a provider's key. */
|
||||
public static void requireTrustedConfiguration(String providerCode, String modelName,
|
||||
String apiHost, String apiKey) {
|
||||
if (CustomApiCredentialPolicy.isCustomProvider(providerCode)) {
|
||||
CustomApiCredentialPolicy.requireConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
} else if (PPIO_PROVIDER.equals(providerCode)) {
|
||||
requirePpioConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
} else {
|
||||
requireDeepSeekConfiguration(providerCode, modelName, apiHost, apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates PPIO and returns the base URL expected by the OpenAI Chat Completions client. */
|
||||
public static String requirePpioConfiguration(String providerCode, String modelName,
|
||||
String apiHost, String apiKey) {
|
||||
if (!PPIO_PROVIDER.equals(providerCode)) {
|
||||
throw new IllegalArgumentException("PPIO model provider is not trusted");
|
||||
}
|
||||
// PPIO hosts models from multiple vendors; use the full model ID from its catalog.
|
||||
if (modelName == null || !modelName.matches("[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}")) {
|
||||
throw new IllegalArgumentException("PPIO model ID is invalid");
|
||||
}
|
||||
requireSecureApiHost(apiHost);
|
||||
if (!PPIO_API_HOSTS.contains(apiHost)) {
|
||||
throw new IllegalArgumentException("PPIO API host is not allowlisted");
|
||||
}
|
||||
requireProviderReference(providerCode, apiKey);
|
||||
return PPIO_API_HOST;
|
||||
}
|
||||
|
||||
public static String requireSecureApiHost(String apiHost) {
|
||||
if (apiHost == null || apiHost.isBlank()) {
|
||||
throw new IllegalArgumentException("Model API host must be an absolute HTTPS URI");
|
||||
}
|
||||
final URI uri;
|
||||
try {
|
||||
uri = new URI(apiHost);
|
||||
} catch (URISyntaxException invalid) {
|
||||
throw new IllegalArgumentException("Model API host must be an absolute HTTPS URI");
|
||||
}
|
||||
if (!uri.isAbsolute() || !"https".equals(uri.getScheme()) || uri.getHost() == null
|
||||
|| uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null
|
||||
|| uri.getPort() > 65535) {
|
||||
throw new IllegalArgumentException(
|
||||
"Model API host must be HTTPS without userinfo, query, or fragment");
|
||||
}
|
||||
return apiHost;
|
||||
}
|
||||
|
||||
public static boolean isDeepSeekConfiguration(String providerCode, String modelName) {
|
||||
return DEEPSEEK_PROVIDER.equals(providerCode) || DEEPSEEK_MODELS.contains(modelName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Resolves an explicit {@code env:NAME} model credential without exposing it through DTO JSON. */
|
||||
public final class ChatModelSecretReference {
|
||||
|
||||
public static final String ENV_REFERENCE_REGEXP =
|
||||
"^env:(?:(?:DEEPSEEK|PPIO)_API_KEY|" + CustomApiCredentialPolicy.REFERENCE_PATTERN + ")$";
|
||||
|
||||
private static final String ENV_PREFIX = "env:";
|
||||
private static final Pattern ENV_REFERENCE = Pattern.compile(ENV_REFERENCE_REGEXP);
|
||||
|
||||
private ChatModelSecretReference() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-private final resolution step. Callers must first bind provider, model and endpoint
|
||||
* through {@link ChatModelCredentialPolicy}; exposing a context-free resolver would recreate a
|
||||
* credential-confusion channel.
|
||||
*/
|
||||
static String resolveAllowlistedReference(String configuredValue,
|
||||
Function<String, String> environment) {
|
||||
String reference = requirePersistableReference(configuredValue);
|
||||
if (reference == null) {
|
||||
return null;
|
||||
}
|
||||
String variable = reference.substring(ENV_PREFIX.length());
|
||||
String resolved = Objects.requireNonNull(environment, "environment").apply(variable);
|
||||
if (resolved == null || resolved.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"Required model API key environment variable is not configured: " + variable);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the only credential representation that may cross a persistence boundary.
|
||||
* A {@code null} value means that an update did not request a credential change.
|
||||
*/
|
||||
public static String requirePersistableReference(String configuredValue) {
|
||||
if (configuredValue == null) {
|
||||
return null;
|
||||
}
|
||||
if (!ENV_REFERENCE.matcher(configuredValue).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid model API key reference; reference is not allowlisted");
|
||||
}
|
||||
return configuredValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow compatibility hook for an in-place legacy migration. It is deliberately package
|
||||
* private and requires an audit callback which never receives the credential value. Normal
|
||||
* provider resolution and all persistence paths must use the strict methods above.
|
||||
*/
|
||||
static String resolveForLegacyMigration(String configuredValue,
|
||||
Function<String, String> environment,
|
||||
Runnable legacyPlaintextAudit) {
|
||||
if (configuredValue == null || configuredValue.startsWith(ENV_PREFIX)) {
|
||||
return resolveAllowlistedReference(configuredValue, environment);
|
||||
}
|
||||
if (configuredValue.isBlank()) {
|
||||
throw new IllegalArgumentException("Legacy model API key cannot be blank");
|
||||
}
|
||||
Objects.requireNonNull(legacyPlaintextAudit, "legacyPlaintextAudit").run();
|
||||
return configuredValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.ruoyi.common.chat.security;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Binds configurable OpenAI/Anthropic endpoints to dedicated deployment credentials. */
|
||||
public final class CustomApiCredentialPolicy {
|
||||
|
||||
public static final String OPENAI_PROVIDER = "custom_api";
|
||||
public static final String ANTHROPIC_PROVIDER = "custom_anthropic";
|
||||
public static final String REFERENCE_PATTERN =
|
||||
"CUSTOM_(?:OPENAI|ANTHROPIC)(?:_[A-Z][A-Z0-9_]{0,31})?_API_KEY";
|
||||
|
||||
private CustomApiCredentialPolicy() {
|
||||
}
|
||||
|
||||
public static boolean isCustomProvider(String providerCode) {
|
||||
return OPENAI_PROVIDER.equals(providerCode) || ANTHROPIC_PROVIDER.equals(providerCode);
|
||||
}
|
||||
|
||||
public static void requireReference(String providerCode, String reference) {
|
||||
String protocol;
|
||||
if (OPENAI_PROVIDER.equals(providerCode)) {
|
||||
protocol = "OPENAI";
|
||||
} else if (ANTHROPIC_PROVIDER.equals(providerCode)) {
|
||||
protocol = "ANTHROPIC";
|
||||
} else {
|
||||
throw new IllegalArgumentException("不支持的自定义厂商协议");
|
||||
}
|
||||
String pattern = "env:CUSTOM_" + protocol + "(?:_[A-Z][A-Z0-9_]{0,31})?_API_KEY";
|
||||
if (reference == null || !Pattern.matches(pattern, reference)) {
|
||||
throw new IllegalArgumentException("自定义厂商密钥必须使用与协议匹配的环境变量引用");
|
||||
}
|
||||
}
|
||||
|
||||
public static String requireConfiguration(String providerCode, String modelName,
|
||||
String apiHost, String reference) {
|
||||
return requireConfiguration(providerCode, modelName, apiHost, reference, System::getenv);
|
||||
}
|
||||
|
||||
static String requireConfiguration(String providerCode, String modelName, String apiHost,
|
||||
String reference, Function<String, String> environment) {
|
||||
requireReference(providerCode, reference);
|
||||
if (modelName == null || modelName.isBlank()) {
|
||||
throw new IllegalArgumentException("自定义厂商的模型名称不能为空");
|
||||
}
|
||||
String baseUrl = normalizeBaseUrl(providerCode, apiHost);
|
||||
String keyVariable = reference.substring("env:".length());
|
||||
String hostVariable = keyVariable.substring(0, keyVariable.length() - "_API_KEY".length()) + "_BASE_URL";
|
||||
String configuredHost = environment.apply(hostVariable);
|
||||
if (configuredHost == null || configuredHost.isBlank()) {
|
||||
throw new IllegalArgumentException("请先在后端配置自定义厂商地址环境变量:" + hostVariable);
|
||||
}
|
||||
if (!baseUrl.equals(normalizeBaseUrl(providerCode, configuredHost))) {
|
||||
throw new IllegalArgumentException("模型请求地址与该密钥绑定的自定义厂商地址不一致");
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/** 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("不支持的自定义厂商协议");
|
||||
}
|
||||
ChatModelCredentialPolicy.requireSecureApiHost(apiHost);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ public interface IChatModelService {
|
||||
*/
|
||||
List<ChatModelVo> queryList(ChatModelBo bo);
|
||||
|
||||
/** 查询当前租户中厂商已启用的模型,供用户选择或业务调用。 */
|
||||
List<ChatModelVo> queryAvailableList(ChatModelBo bo);
|
||||
|
||||
/**
|
||||
* 新增模型管理
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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|]", "_");
|
||||
}
|
||||
}
|
||||
@@ -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]");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-crypto</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -5,16 +5,14 @@ import cn.hutool.http.HttpStatus;
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.core.exception.ServiceException;
|
||||
import org.ruoyi.common.core.exception.SseException;
|
||||
import org.ruoyi.common.core.exception.base.BaseException;
|
||||
import org.ruoyi.common.core.utils.StreamUtils;
|
||||
import org.ruoyi.common.json.utils.JsonUtils;
|
||||
import org.springframework.context.support.DefaultMessageSourceResolvable;
|
||||
import org.ruoyi.common.web.utils.SafeRequestLogUtils;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
@@ -38,15 +36,18 @@ import java.io.IOException;
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final String SAFE_SERVICE_ERROR_MESSAGE = "请求处理失败";
|
||||
private static final String SAFE_VALIDATION_ERROR_MESSAGE = "请求参数校验失败";
|
||||
|
||||
/**
|
||||
* 请求方式不支持
|
||||
*/
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public R<Void> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e,
|
||||
HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
String requestURI = safeUri(request);
|
||||
log.error("请求地址'{}',不支持'{}'请求", requestURI, e.getMethod());
|
||||
return R.fail(HttpStatus.HTTP_BAD_METHOD, e.getMessage());
|
||||
return R.fail(HttpStatus.HTTP_BAD_METHOD, "请求方式不支持");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,9 +55,11 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(ServiceException.class)
|
||||
public R<Void> handleServiceException(ServiceException e, HttpServletRequest request) {
|
||||
log.error(e.getMessage());
|
||||
log.error("request_failure category=SERVICE_EXCEPTION errorType={}", e.getClass().getName());
|
||||
Integer code = e.getCode();
|
||||
return ObjectUtil.isNotNull(code) ? R.fail(code, e.getMessage()) : R.fail(e.getMessage());
|
||||
return ObjectUtil.isNotNull(code)
|
||||
? R.fail(code, SAFE_SERVICE_ERROR_MESSAGE)
|
||||
: R.fail(SAFE_SERVICE_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,8 +68,8 @@ public class GlobalExceptionHandler {
|
||||
@ResponseStatus(org.springframework.http.HttpStatus.UNAUTHORIZED)
|
||||
@ExceptionHandler(SseException.class)
|
||||
public String handleNotLoginException(SseException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.debug("请求地址'{}',认证失败'{}',无法访问系统资源", requestURI, e.getMessage());
|
||||
String requestURI = safeUri(request);
|
||||
log.debug("请求地址'{}',认证失败,无法访问系统资源", requestURI);
|
||||
return JsonUtils.toJsonString(R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源"));
|
||||
}
|
||||
|
||||
@@ -75,9 +78,9 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(ServletException.class)
|
||||
public R<Void> handleServletException(ServletException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}',发生未知异常.", requestURI, e);
|
||||
return R.fail(e.getMessage());
|
||||
String requestURI = safeUri(request);
|
||||
log.error("请求地址'{}',发生 Servlet 异常: {}", requestURI, e.getClass().getSimpleName());
|
||||
return internalError();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,8 +88,8 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(BaseException.class)
|
||||
public R<Void> handleBaseException(BaseException e, HttpServletRequest request) {
|
||||
log.error(e.getMessage());
|
||||
return R.fail(e.getMessage());
|
||||
log.error("request_failure category=BASE_EXCEPTION errorType={}", e.getClass().getName());
|
||||
return R.fail(SAFE_SERVICE_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,9 +97,9 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(MissingPathVariableException.class)
|
||||
public R<Void> handleMissingPathVariableException(MissingPathVariableException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
String requestURI = safeUri(request);
|
||||
log.error("请求路径中缺少必需的路径变量'{}',发生系统异常.", requestURI);
|
||||
return R.fail(String.format("请求路径中缺少必需的路径变量[%s]", e.getVariableName()));
|
||||
return R.fail("请求路径参数缺失");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,9 +107,9 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public R<Void> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
String requestURI = safeUri(request);
|
||||
log.error("请求参数类型不匹配'{}',发生系统异常.", requestURI);
|
||||
return R.fail(String.format("请求参数类型不匹配,参数[%s]要求类型为:'%s',但输入值为:'%s'", e.getName(), e.getRequiredType().getName(), e.getValue()));
|
||||
return R.fail("请求参数类型不匹配");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,9 +117,9 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(NoHandlerFoundException.class)
|
||||
public R<Void> handleNoHandlerFoundException(NoHandlerFoundException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
String requestURI = safeUri(request);
|
||||
log.error("请求地址'{}'不存在.", requestURI);
|
||||
return R.fail(HttpStatus.HTTP_NOT_FOUND, e.getMessage());
|
||||
return R.fail(HttpStatus.HTTP_NOT_FOUND, "请求地址不存在");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +129,7 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(IOException.class)
|
||||
public R<Void> handleIoException(IOException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
String requestURI = safeUri(request);
|
||||
if (requestURI.contains("sse")) {
|
||||
// sse 经常性连接中断 例如关闭浏览器 直接屏蔽
|
||||
return null;
|
||||
@@ -134,10 +137,12 @@ public class GlobalExceptionHandler {
|
||||
// 排除文件下载/导出相关的 IOException,让异常正常传播以便上层处理
|
||||
if (requestURI.contains("/export") || requestURI.contains("/download")) {
|
||||
// 重新抛出,让调用方处理
|
||||
throw new RuntimeException("文件导出/下载IO异常: " + e.getMessage(), e);
|
||||
log.error("请求地址'{}',文件导出/下载连接中断: {}", requestURI,
|
||||
e.getClass().getSimpleName());
|
||||
return internalError();
|
||||
}
|
||||
log.error("请求地址'{}',连接中断", requestURI, e);
|
||||
return R.fail(e.getMessage());
|
||||
log.error("请求地址'{}',连接中断: {}", requestURI, e.getClass().getSimpleName());
|
||||
return internalError();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,16 +157,16 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public R<Void> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
String requestURI = safeUri(request);
|
||||
// 对于文件导出相关异常,不进行封装处理,让原始异常信息传播
|
||||
Throwable cause = e.getCause();
|
||||
if (requestURI.contains("/export") || requestURI.contains("/download")) {
|
||||
log.error("请求地址'{}',文件导出/下载异常.", requestURI, e);
|
||||
// 对于文件导出,直接返回异常信息,不进行额外封装
|
||||
return R.fail(cause != null ? cause.getMessage() : e.getMessage());
|
||||
log.error("请求地址'{}',文件导出/下载异常: {}", requestURI,
|
||||
cause == null ? e.getClass().getSimpleName() : cause.getClass().getSimpleName());
|
||||
return internalError();
|
||||
}
|
||||
log.error("请求地址'{}',发生未知异常.", requestURI, e);
|
||||
return R.fail(e.getMessage());
|
||||
log.error("请求地址'{}',发生未知异常: {}", requestURI, e.getClass().getSimpleName());
|
||||
return internalError();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,9 +174,9 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public R<Void> handleException(Exception e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}',发生系统异常.", requestURI, e);
|
||||
return R.fail(e.getMessage());
|
||||
String requestURI = safeUri(request);
|
||||
log.error("请求地址'{}',发生系统异常: {}", requestURI, e.getClass().getSimpleName());
|
||||
return internalError();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,9 +184,8 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(BindException.class)
|
||||
public R<Void> handleBindException(BindException e) {
|
||||
log.error(e.getMessage());
|
||||
String message = StreamUtils.join(e.getAllErrors(), DefaultMessageSourceResolvable::getDefaultMessage, ", ");
|
||||
return R.fail(message);
|
||||
log.error("请求绑定失败: {}", e.getClass().getSimpleName());
|
||||
return R.fail(SAFE_VALIDATION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,9 +193,8 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public R<Void> constraintViolationException(ConstraintViolationException e) {
|
||||
log.error(e.getMessage());
|
||||
String message = StreamUtils.join(e.getConstraintViolations(), ConstraintViolation::getMessage, ", ");
|
||||
return R.fail(message);
|
||||
log.error("约束校验失败: {}", e.getClass().getSimpleName());
|
||||
return R.fail(SAFE_VALIDATION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,9 +202,8 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public R<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
|
||||
log.error(e.getMessage());
|
||||
String message = StreamUtils.join(e.getBindingResult().getAllErrors(), DefaultMessageSourceResolvable::getDefaultMessage, ", ");
|
||||
return R.fail(message);
|
||||
log.error("请求参数校验失败: {}", e.getClass().getSimpleName());
|
||||
return R.fail(SAFE_VALIDATION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,9 +212,9 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(JsonParseException.class)
|
||||
public R<Void> handleJsonParseException(JsonParseException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}' 发生 JSON 解析异常: {}", requestURI, e.getMessage());
|
||||
return R.fail(HttpStatus.HTTP_BAD_REQUEST, "请求数据格式错误(JSON 解析失败):" + e.getMessage());
|
||||
String requestURI = safeUri(request);
|
||||
log.error("请求地址'{}'发生 JSON 解析异常: {}", requestURI, e.getClass().getSimpleName());
|
||||
return R.fail(HttpStatus.HTTP_BAD_REQUEST, "请求数据格式错误(JSON 解析失败)");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,8 +222,16 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public R<Void> handleHttpMessageNotReadableException(HttpMessageNotReadableException e, HttpServletRequest request) {
|
||||
log.error("请求地址'{}', 参数解析失败: {}", request.getRequestURI(), e.getMessage());
|
||||
return R.fail(HttpStatus.HTTP_BAD_REQUEST, "请求参数格式错误:" + e.getMostSpecificCause().getMessage());
|
||||
log.error("请求地址'{}',参数解析失败: {}", safeUri(request), e.getClass().getSimpleName());
|
||||
return R.fail(HttpStatus.HTTP_BAD_REQUEST, "请求参数格式错误");
|
||||
}
|
||||
|
||||
private String safeUri(HttpServletRequest request) {
|
||||
return SafeRequestLogUtils.sanitizeUri(request.getRequestURI());
|
||||
}
|
||||
|
||||
private R<Void> internalError() {
|
||||
return R.fail("系统异常,请联系管理员");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
package org.ruoyi.common.web.interceptor;
|
||||
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.time.StopWatch;
|
||||
import org.ruoyi.common.core.utils.StringUtils;
|
||||
import org.ruoyi.common.json.utils.JsonUtils;
|
||||
import org.ruoyi.common.web.filter.RepeatedlyRequestWrapper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.ruoyi.common.web.utils.SafeRequestLogUtils;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* web的调用时间统计拦截器
|
||||
* Web request timing interceptor. Request payloads, form fields and query strings are
|
||||
* deliberately outside this logging boundary.
|
||||
*
|
||||
* @author Lion Li
|
||||
* @since 3.3.0
|
||||
@@ -26,64 +18,44 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
public class PlusWebInvokeTimeInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final static ThreadLocal<StopWatch> KEY_CACHE = new ThreadLocal<>();
|
||||
private static final ThreadLocal<StopWatch> KEY_CACHE = new ThreadLocal<>();
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
String url = request.getMethod() + " " + request.getRequestURI();
|
||||
|
||||
// 打印请求参数
|
||||
if (isJsonRequest(request)) {
|
||||
String jsonParam = "";
|
||||
if (request instanceof RepeatedlyRequestWrapper) {
|
||||
BufferedReader reader = request.getReader();
|
||||
jsonParam = IoUtil.read(reader);
|
||||
}
|
||||
log.info("[PLUS]开始请求 => URL[{}],参数类型[json],参数:[{}]", url, jsonParam);
|
||||
} else {
|
||||
Map<String, String[]> parameterMap = request.getParameterMap();
|
||||
if (MapUtil.isNotEmpty(parameterMap)) {
|
||||
String parameters = JsonUtils.toJsonString(parameterMap);
|
||||
log.info("[PLUS]开始请求 => URL[{}],参数类型[param],参数:[{}]", url, parameters);
|
||||
} else {
|
||||
log.info("[PLUS]开始请求 => URL[{}],无参数", url);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
KEY_CACHE.set(stopWatch);
|
||||
stopWatch.start();
|
||||
|
||||
log.info("[PLUS] request_started method={} path={}", request.getMethod(), safePath(request));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
|
||||
|
||||
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
|
||||
ModelAndView modelAndView) {
|
||||
// Completion metadata is emitted once from afterCompletion.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
|
||||
Exception ex) {
|
||||
StopWatch stopWatch = KEY_CACHE.get();
|
||||
if (ObjectUtil.isNotNull(stopWatch)) {
|
||||
stopWatch.stop();
|
||||
log.info("[PLUS]结束请求 => URL[{}],耗时:[{}]毫秒", request.getMethod() + " " + request.getRequestURI(), stopWatch.getDuration().toMillis());
|
||||
try {
|
||||
if (stopWatch == null) {
|
||||
return;
|
||||
}
|
||||
if (stopWatch.isStarted() && !stopWatch.isStopped()) {
|
||||
stopWatch.stop();
|
||||
}
|
||||
log.info("[PLUS] request_completed method={} path={} status={} elapsedMs={} outcome={} errorType={}",
|
||||
request.getMethod(), safePath(request), response.getStatus(),
|
||||
stopWatch.getDuration().toMillis(), ex == null ? "SUCCESS" : "FAILED",
|
||||
ex == null ? "none" : ex.getClass().getName());
|
||||
} finally {
|
||||
KEY_CACHE.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断本次请求的数据类型是否为json
|
||||
*
|
||||
* @param request request
|
||||
* @return boolean
|
||||
*/
|
||||
private boolean isJsonRequest(HttpServletRequest request) {
|
||||
String contentType = request.getContentType();
|
||||
if (contentType != null) {
|
||||
return StringUtils.startsWithIgnoreCase(contentType, MediaType.APPLICATION_JSON_VALUE);
|
||||
}
|
||||
return false;
|
||||
private static String safePath(HttpServletRequest request) {
|
||||
return SafeRequestLogUtils.sanitizeUri(request.getRequestURI());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package org.ruoyi.common.web.utils;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.ruoyi.common.core.utils.StringUtils;
|
||||
|
||||
import java.lang.reflect.Array;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Fail-closed boundary for values that may be written to HTTP logs.
|
||||
*/
|
||||
public final class SafeRequestLogUtils {
|
||||
|
||||
public static final String REDACTED = "[REDACTED]";
|
||||
|
||||
private static final int MAX_URI_LENGTH = 512;
|
||||
private static final int MAX_TEXT_LENGTH = 4096;
|
||||
private static final int MAX_DEPTH = 16;
|
||||
private static final int MAX_COLLECTION_ITEMS = 100;
|
||||
private static final String TRUNCATED = "[TRUNCATED]";
|
||||
private static final String CYCLE = "[CYCLE]";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final Set<String> SECRET_KEYS = Set.of(
|
||||
"token", "tokenid", "accesstoken", "refreshtoken", "authorization",
|
||||
"apikey", "clientsecret", "password", "passwd", "pwd", "secret",
|
||||
"secretkey", "secretaccesskey", "sessiontoken", "jwtsecretkey",
|
||||
"signingkey", "signature", "privatekey", "credential", "credentials"
|
||||
);
|
||||
private static final Pattern ONLINE_TOKEN_PATH = Pattern.compile(
|
||||
"(?i)(/monitor/online/(?:myself/)?)([^/?#;\\s]+)");
|
||||
private static final Pattern MYSELF_TOKEN_PATH = Pattern.compile(
|
||||
"(?i)(/myself/)([^/?#;\\s]+)");
|
||||
private static final Pattern GENERIC_SECRET_PATH = Pattern.compile(
|
||||
"(?i)(/(?:token(?:[-_]?id)?|access[-_]?token|refresh[-_]?token|api[-_]?key)/)"
|
||||
+ "([^/?#;\\s]+)");
|
||||
private static final String SECRET_FIELD =
|
||||
"(?:[a-z0-9_-]*(?:token|api[_-]?key|secret|password|passwd|pwd|credential|"
|
||||
+ "authorization|signing[_-]?key|signature)[a-z0-9_-]*)";
|
||||
private static final Pattern JSON_SECRET = Pattern.compile(
|
||||
"(?i)(\\\"" + SECRET_FIELD + "\\\"\\s*:\\s*)"
|
||||
+ "(?:\\\"(?:\\\\.|[^\\\"])*\\\"|'(?:\\\\.|[^'])*'|"
|
||||
+ "\\[[^\\]]*\\]|[^,}\\r\\n]*(?=[,}\\r\\n]|$))");
|
||||
private static final Pattern KEY_VALUE_SECRET = Pattern.compile(
|
||||
"(?i)(\\b" + SECRET_FIELD + "\\b\\s*(?:=|:)\\s*)"
|
||||
+ "(?:\\\"(?:\\\\.|[^\\\"])*\\\"|'(?:\\\\.|[^'])*'|"
|
||||
+ "[^&,;}\\r\\n]*?(?=\\s+[a-z0-9_-]+\\s*(?:=|:)|[&,;}\\r\\n]|$))");
|
||||
private static final Pattern AUTH_SCHEME = Pattern.compile(
|
||||
"(?i)\\b(?:Bearer|Basic)\\s+[^\\s\\\"'<>]+");
|
||||
private static final Pattern JWT = Pattern.compile(
|
||||
"\\beyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}(?:\\.[A-Za-z0-9_-]{5,})?\\b");
|
||||
private static final Pattern OPENAI_STYLE_KEY = Pattern.compile(
|
||||
"\\bsk[-_][A-Za-z0-9][A-Za-z0-9._~+/-]{7,}\\b");
|
||||
private static final Pattern URI_PASSWORD = Pattern.compile(
|
||||
"(?i)((?:jdbc:)?[a-z][a-z0-9+.-]*://[^\\s/@:]+:)([^\\s/@]+)(@)");
|
||||
|
||||
private SafeRequestLogUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only the path portion and removes query/fragment data entirely.
|
||||
*/
|
||||
public static String sanitizeUri(String uri) {
|
||||
if (StringUtils.isBlank(uri)) {
|
||||
return uri;
|
||||
}
|
||||
int suffixIndex = firstNonNegative(uri.indexOf('?'), uri.indexOf('#'));
|
||||
String safe = suffixIndex >= 0 ? uri.substring(0, suffixIndex) : uri;
|
||||
safe = ONLINE_TOKEN_PATH.matcher(safe).replaceAll("$1" + REDACTED);
|
||||
safe = MYSELF_TOKEN_PATH.matcher(safe).replaceAll("$1" + REDACTED);
|
||||
safe = GENERIC_SECRET_PATH.matcher(safe).replaceAll("$1" + REDACTED);
|
||||
safe = sanitizeUnstructuredText(safe);
|
||||
return abbreviate(safe, MAX_URI_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes structured JSON recursively and falls back to bounded text patterns.
|
||||
*/
|
||||
public static String sanitizeText(String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (looksLikeJson(trimmed)) {
|
||||
try {
|
||||
Object parsed = OBJECT_MAPPER.readValue(trimmed, Object.class);
|
||||
return abbreviate(writeSanitized(parsed), MAX_TEXT_LENGTH);
|
||||
} catch (JsonProcessingException ignored) {
|
||||
// Invalid JSON is still protected by the unstructured boundary below.
|
||||
}
|
||||
}
|
||||
return abbreviate(sanitizeUnstructuredText(value), MAX_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitizes maps, iterables, arrays, POJOs and JSON-compatible values.
|
||||
*/
|
||||
public static String sanitizeObject(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return abbreviate(writeSanitized(value), MAX_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
private static String writeSanitized(Object value) {
|
||||
Object safe = sanitizeValue(value, null, 0, new IdentityHashMap<>());
|
||||
if (safe instanceof String text) {
|
||||
return text;
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(safe);
|
||||
} catch (JsonProcessingException ignored) {
|
||||
return "[UNSERIALIZABLE:" + safeType(value) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
private static Object sanitizeValue(Object value, String fieldName, int depth,
|
||||
IdentityHashMap<Object, Boolean> seen) {
|
||||
if (isSecretKey(fieldName)) {
|
||||
return REDACTED;
|
||||
}
|
||||
if (value == null || value instanceof Number || value instanceof Boolean
|
||||
|| value instanceof Character || value instanceof Enum<?>) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof CharSequence text) {
|
||||
return sanitizeUnstructuredText(text.toString());
|
||||
}
|
||||
if (value instanceof Throwable failure) {
|
||||
return "[THROWABLE:" + failure.getClass().getName() + "]";
|
||||
}
|
||||
if (value instanceof Date || value instanceof TemporalAccessor) {
|
||||
return value.toString();
|
||||
}
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return TRUNCATED;
|
||||
}
|
||||
if (seen.put(value, Boolean.TRUE) != null) {
|
||||
return CYCLE;
|
||||
}
|
||||
try {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<String, Object> safe = new LinkedHashMap<>();
|
||||
int count = 0;
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (count++ >= MAX_COLLECTION_ITEMS) {
|
||||
safe.put(TRUNCATED, TRUNCATED);
|
||||
break;
|
||||
}
|
||||
String key = safeMapKey(entry.getKey());
|
||||
safe.put(key, sanitizeValue(entry.getValue(), key, depth + 1, seen));
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
if (value instanceof Iterable<?> iterable) {
|
||||
List<Object> safe = new ArrayList<>();
|
||||
int count = 0;
|
||||
for (Object item : iterable) {
|
||||
if (count++ >= MAX_COLLECTION_ITEMS) {
|
||||
safe.add(TRUNCATED);
|
||||
break;
|
||||
}
|
||||
safe.add(sanitizeValue(item, null, depth + 1, seen));
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
if (value.getClass().isArray()) {
|
||||
List<Object> safe = new ArrayList<>();
|
||||
int length = Math.min(Array.getLength(value), MAX_COLLECTION_ITEMS);
|
||||
for (int index = 0; index < length; index++) {
|
||||
safe.add(sanitizeValue(Array.get(value, index), null, depth + 1, seen));
|
||||
}
|
||||
if (Array.getLength(value) > length) {
|
||||
safe.add(TRUNCATED);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
try {
|
||||
Object converted = OBJECT_MAPPER.convertValue(value, Object.class);
|
||||
if (converted == value || converted == null) {
|
||||
return "[OBJECT:" + safeType(value) + "]";
|
||||
}
|
||||
return sanitizeValue(converted, fieldName, depth + 1, seen);
|
||||
} catch (IllegalArgumentException | StackOverflowError ignored) {
|
||||
return "[OBJECT:" + safeType(value) + "]";
|
||||
}
|
||||
} finally {
|
||||
seen.remove(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sanitizeUnstructuredText(String value) {
|
||||
String safe = AUTH_SCHEME.matcher(value).replaceAll("[AUTH " + REDACTED + "]");
|
||||
safe = JSON_SECRET.matcher(safe).replaceAll("$1\"" + REDACTED + "\"");
|
||||
safe = KEY_VALUE_SECRET.matcher(safe).replaceAll("$1" + REDACTED);
|
||||
safe = JWT.matcher(safe).replaceAll(REDACTED);
|
||||
safe = OPENAI_STYLE_KEY.matcher(safe).replaceAll(REDACTED);
|
||||
safe = URI_PASSWORD.matcher(safe).replaceAll("$1" + REDACTED + "$3");
|
||||
return safe;
|
||||
}
|
||||
|
||||
private static boolean isSecretKey(String fieldName) {
|
||||
if (fieldName == null) {
|
||||
return false;
|
||||
}
|
||||
String normalized = fieldName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
|
||||
return SECRET_KEYS.contains(normalized)
|
||||
|| normalized.contains("token")
|
||||
|| normalized.contains("authorization")
|
||||
|| normalized.contains("apikey")
|
||||
|| normalized.contains("secret")
|
||||
|| normalized.contains("signingkey")
|
||||
|| normalized.contains("signature")
|
||||
|| normalized.contains("password")
|
||||
|| normalized.contains("passwd")
|
||||
|| normalized.contains("credential");
|
||||
}
|
||||
|
||||
private static String safeMapKey(Object key) {
|
||||
if (key == null) {
|
||||
return "null";
|
||||
}
|
||||
if (key instanceof CharSequence || key instanceof Number || key instanceof Enum<?>) {
|
||||
return sanitizeUnstructuredText(String.valueOf(key));
|
||||
}
|
||||
return "[KEY:" + safeType(key) + "]";
|
||||
}
|
||||
|
||||
private static boolean looksLikeJson(String value) {
|
||||
return (value.startsWith("{") && value.endsWith("}"))
|
||||
|| (value.startsWith("[") && value.endsWith("]"));
|
||||
}
|
||||
|
||||
private static int firstNonNegative(int left, int right) {
|
||||
if (left < 0) {
|
||||
return right;
|
||||
}
|
||||
if (right < 0) {
|
||||
return left;
|
||||
}
|
||||
return Math.min(left, right);
|
||||
}
|
||||
|
||||
private static String safeType(Object value) {
|
||||
return value == null ? "null" : value.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
private static String abbreviate(String value, int maximumLength) {
|
||||
if (value == null || value.length() <= maximumLength) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, maximumLength) + "...(truncated)";
|
||||
}
|
||||
}
|
||||
@@ -42,5 +42,10 @@
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -89,7 +89,8 @@ public class PlusWebSocketHandler extends AbstractWebSocketHandler {
|
||||
*/
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||
log.error("[transport error] sessionId: {} , exception:{}", session.getId(), exception.getMessage());
|
||||
log.error("websocket_transport_failed sessionId={} exceptionType={}", session.getId(),
|
||||
exception.getClass().getName());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.web.socket.WebSocketHandler;
|
||||
import org.springframework.web.socket.server.HandshakeInterceptor;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.ruoyi.common.websocket.constant.WebSocketConstants.LOGIN_USER_KEY;
|
||||
|
||||
@@ -24,6 +25,10 @@ import static org.ruoyi.common.websocket.constant.WebSocketConstants.LOGIN_USER_
|
||||
@Slf4j
|
||||
public class PlusWebSocketInterceptor implements HandshakeInterceptor {
|
||||
|
||||
private static final Pattern CREDENTIAL_PATH_SEGMENT = Pattern.compile(
|
||||
"(?i)(/(?:token(?:[-_]?id)?|access[-_]?token|refresh[-_]?token|api[-_]?key|"
|
||||
+ "monitor/online(?:/myself)?|myself)/)([^/?#;\\s]+)");
|
||||
|
||||
/**
|
||||
* WebSocket握手之前执行的前置处理方法
|
||||
*
|
||||
@@ -54,7 +59,8 @@ public class PlusWebSocketInterceptor implements HandshakeInterceptor {
|
||||
attributes.put(LOGIN_USER_KEY, loginUser);
|
||||
return true;
|
||||
} catch (NotLoginException e) {
|
||||
log.error("WebSocket 认证失败'{}',无法访问系统资源", e.getMessage());
|
||||
log.error("websocket_handshake_failed category=NOT_LOGIN path={} exceptionType={}",
|
||||
safePath(request), e.getClass().getName());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -72,4 +78,10 @@ public class PlusWebSocketInterceptor implements HandshakeInterceptor {
|
||||
// 在这个方法中可以执行一些握手成功后的后续处理逻辑,比如记录日志或者其他操作
|
||||
}
|
||||
|
||||
private static String safePath(ServerHttpRequest request) {
|
||||
String path = request.getURI().getPath();
|
||||
return path == null ? null
|
||||
: CREDENTIAL_PATH_SEGMENT.matcher(path).replaceAll("$1[REDACTED]");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,7 +26,11 @@ public class WebSocketTopicListener implements ApplicationRunner, Ordered {
|
||||
public void run(ApplicationArguments args) throws Exception {
|
||||
// 订阅WebSocket消息
|
||||
WebSocketUtils.subscribeMessage((message) -> {
|
||||
log.info("WebSocket主题订阅收到消息session keys={} message={}", message.getSessionKeys(), message.getMessage());
|
||||
boolean broadcast = CollUtil.isEmpty(message.getSessionKeys());
|
||||
int recipientCount = broadcast ? WebSocketSessionHolder.getSessionsAll().size()
|
||||
: message.getSessionKeys().size();
|
||||
log.info("websocket_topic_received broadcast={} recipientCount={} payloadLength={}",
|
||||
broadcast, recipientCount, message.getMessage() == null ? 0 : message.getMessage().length());
|
||||
// 如果key不为空就按照key发消息 如果为空就群发
|
||||
if (CollUtil.isNotEmpty(message.getSessionKeys())) {
|
||||
message.getSessionKeys().forEach(key -> {
|
||||
@@ -40,7 +44,7 @@ public class WebSocketTopicListener implements ApplicationRunner, Ordered {
|
||||
});
|
||||
}
|
||||
});
|
||||
log.info("初始化WebSocket主题订阅监听器成功");
|
||||
log.info("websocket_topic_listener_initialized status=SUCCESS");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user