From a540e6d61a271d6c3d7e426d7c8ad95432564bcc Mon Sep 17 00:00:00 2001 From: zhanggangyong Date: Tue, 10 Mar 2026 06:40:38 +0000 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.backup | 4 +- AGENT_MANAGER_RESPONSE_UPDATE.md | 279 +++ AGENT_UPDATE_QUICKREF.md | 115 ++ .../Agent列表接口新增modelName字段.md | 189 ++ Docs/项目文档/计费管理三维度接口文档.md | 386 ++++ config/database-config.md | 20 +- k8s/secrets.yaml | 6 +- plans/计费系统修复完成报告.md | 472 +++++ plans/计费系统问题分析和修复方案.md | 1551 +++++++++++++++++ plans/计费系统问题验证报告.md | 733 ++++++++ postgres_backup.dump | Bin 178434 -> 0 bytes scripts/backup_postgres.sh | 149 ++ scripts/migrate_to_new_db.sh | 83 + scripts/test_cre_admins.py | 392 ----- scripts/test_quota_fix.py | 89 - scripts/update_db_config.sh | 58 + services/data-ingestion/config.py | 11 +- .../mcp-server/app/agent_manager_client.py | 112 +- services/mcp-server/app/billing.py | 66 +- services/mcp-server/app/periodic_billing.py | 114 +- services/mcp-server/app/quota_manager.py | 154 +- services/mcp-server/app/resource_control.py | 41 +- services/mcp-server/app/resource_monitor.py | 72 +- services/mcp-server/app/routes/admin.py | 515 ++++-- services/mcp-server/app/routes/agents.py | 11 + .../mcp-server/app/routes/billing_webhook.py | 36 +- services/mcp-server/app/routes/channel.py | 124 +- .../mcp-server/app/routes/external_tools.py | 4 +- .../app/routes/platform_agent_quota.py | 6 +- services/mcp-server/app/routes/user.py | 280 +-- services/mcp-server/check_billing_health.py | 420 +++++ services/mcp-server/config.py | 17 +- services/mcp-server/data/mcp_fallback.db | Bin 200704 -> 0 bytes services/mcp-server/database.py | 84 +- services/mcp-server/k8s/deployment.yaml | 7 +- services/mcp-server/k8s/secrets.yaml | 5 +- .../001_add_resource_control_fields.sql | 32 - .../002_add_pricing_and_quota_alerts.sql | 210 --- .../migrations/003_add_eu_fields_to_users.sql | 19 - .../migrations/004_add_k8s_agent_fields.sql | 49 - .../005_refactor_agent_quota_system.sql | 180 -- .../006_add_agent_resource_management.sql | 160 -- .../007_change_resource_id_to_string.sql | 21 - .../migrations/008_add_quota_management.sql | 132 -- ...009_add_platform_agent_template_config.sql | 50 - .../010_add_agent_billing_fields.sql | 36 - .../011_add_litellm_integration.sql | 73 - .../012_add_model_billing_records.sql | 53 - .../013_add_agent_callback_fields.sql | 30 - .../014_add_username_unique_constraint.sql | 34 - .../015_add_tool_template_fields.sql | 30 - .../016_add_agent_access_info_fields.sql | 61 - .../migrations/019_add_agent_model_name.sql | 21 + ...efaults.sql => 020_fix_quota_defaults.sql} | 0 ...ngth.sql => 021_fix_agent_type_length.sql} | 0 .../migrations/add_last_login_fields.py | 117 -- .../migrations/add_resource_control_fields.py | 85 - .../migrations/run_007_migration.py | 98 -- .../migrations/run_010_migration.py | 56 - .../migrations/run_011_migration.py | 101 -- .../migrations/run_013_migration.py | 72 - .../migrations/run_014_migration.py | 96 - .../migrations/run_015_migration.py | 69 - .../migrations/run_016_migration.py | 144 -- .../migrations/run_019_migration.py | 187 ++ ...aults.py => run_020_fix_quota_defaults.py} | 6 +- services/mcp-server/migrations/verify_013.py | 36 - services/mcp-server/models.py | 32 +- services/mcp-server/reinit_database.py | 121 -- services/mcp-server/schemas.py | 6 + .../mcp-server/test_agent_callback_full.py | 146 -- services/mcp-server/test_cre_admins.py | 433 ----- services/mcp-server/test_new_agent_api.py | 197 --- services/mcp-server/test_user_api.py | 149 -- services/mcp-server/verify_api_format.py | 200 --- test_directmail_api.py | 222 --- test_smtp.py | 140 -- 77 files changed, 5721 insertions(+), 4788 deletions(-) create mode 100644 AGENT_MANAGER_RESPONSE_UPDATE.md create mode 100644 AGENT_UPDATE_QUICKREF.md create mode 100644 Docs/项目文档/Agent列表接口新增modelName字段.md create mode 100644 Docs/项目文档/计费管理三维度接口文档.md create mode 100644 plans/计费系统修复完成报告.md create mode 100644 plans/计费系统问题分析和修复方案.md create mode 100644 plans/计费系统问题验证报告.md delete mode 100644 postgres_backup.dump create mode 100644 scripts/backup_postgres.sh create mode 100755 scripts/migrate_to_new_db.sh delete mode 100755 scripts/test_cre_admins.py delete mode 100644 scripts/test_quota_fix.py create mode 100755 scripts/update_db_config.sh create mode 100644 services/mcp-server/check_billing_health.py delete mode 100644 services/mcp-server/data/mcp_fallback.db delete mode 100644 services/mcp-server/migrations/001_add_resource_control_fields.sql delete mode 100644 services/mcp-server/migrations/002_add_pricing_and_quota_alerts.sql delete mode 100644 services/mcp-server/migrations/003_add_eu_fields_to_users.sql delete mode 100644 services/mcp-server/migrations/004_add_k8s_agent_fields.sql delete mode 100644 services/mcp-server/migrations/005_refactor_agent_quota_system.sql delete mode 100644 services/mcp-server/migrations/006_add_agent_resource_management.sql delete mode 100644 services/mcp-server/migrations/007_change_resource_id_to_string.sql delete mode 100644 services/mcp-server/migrations/008_add_quota_management.sql delete mode 100644 services/mcp-server/migrations/009_add_platform_agent_template_config.sql delete mode 100644 services/mcp-server/migrations/010_add_agent_billing_fields.sql delete mode 100644 services/mcp-server/migrations/011_add_litellm_integration.sql delete mode 100644 services/mcp-server/migrations/012_add_model_billing_records.sql delete mode 100644 services/mcp-server/migrations/013_add_agent_callback_fields.sql delete mode 100644 services/mcp-server/migrations/014_add_username_unique_constraint.sql delete mode 100644 services/mcp-server/migrations/015_add_tool_template_fields.sql delete mode 100644 services/mcp-server/migrations/016_add_agent_access_info_fields.sql create mode 100644 services/mcp-server/migrations/019_add_agent_model_name.sql rename services/mcp-server/migrations/{011_fix_quota_defaults.sql => 020_fix_quota_defaults.sql} (100%) rename services/mcp-server/migrations/{018_fix_agent_type_length.sql => 021_fix_agent_type_length.sql} (100%) delete mode 100644 services/mcp-server/migrations/add_last_login_fields.py delete mode 100644 services/mcp-server/migrations/add_resource_control_fields.py delete mode 100644 services/mcp-server/migrations/run_007_migration.py delete mode 100644 services/mcp-server/migrations/run_010_migration.py delete mode 100644 services/mcp-server/migrations/run_011_migration.py delete mode 100644 services/mcp-server/migrations/run_013_migration.py delete mode 100644 services/mcp-server/migrations/run_014_migration.py delete mode 100644 services/mcp-server/migrations/run_015_migration.py delete mode 100644 services/mcp-server/migrations/run_016_migration.py create mode 100644 services/mcp-server/migrations/run_019_migration.py rename services/mcp-server/migrations/{run_011_fix_quota_defaults.py => run_020_fix_quota_defaults.py} (97%) delete mode 100644 services/mcp-server/migrations/verify_013.py delete mode 100644 services/mcp-server/reinit_database.py delete mode 100644 services/mcp-server/test_agent_callback_full.py delete mode 100755 services/mcp-server/test_cre_admins.py delete mode 100644 services/mcp-server/test_new_agent_api.py delete mode 100644 services/mcp-server/test_user_api.py delete mode 100644 services/mcp-server/verify_api_format.py delete mode 100644 test_directmail_api.py delete mode 100644 test_smtp.py diff --git a/.env.backup b/.env.backup index aae4d2a..e98b62c 100644 --- a/.env.backup +++ b/.env.backup @@ -1,8 +1,8 @@ # Taiji AI-PAD 环境变量配置 # 数据库配置 -ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres -DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require +ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji +DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require # Redis配置 (Azure Cache for Redis - 端口10000) # REDIS_URL已配置为Azure Redis (端口10000) diff --git a/AGENT_MANAGER_RESPONSE_UPDATE.md b/AGENT_MANAGER_RESPONSE_UPDATE.md new file mode 100644 index 0000000..60edfd3 --- /dev/null +++ b/AGENT_MANAGER_RESPONSE_UPDATE.md @@ -0,0 +1,279 @@ +# Agent Manager 响应格式更新说明 + +> **更新日期**: 2026-03-04 +> **版本**: v2.0 +> **影响范围**: MCP-Server 所有平台 Agent 模板相关接口 + +--- + +## 📋 更新概述 + +Agent Manager 的 `/templates/platform` 和 `/templates/custom` 接口已更新,现在返回更丰富的模板信息: + +### 新增字段 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `displayName` | string | 模板显示名称(人类可读) | "Echo 测试服务" | +| `description` | string | 模板描述 | "简单的 Echo 服务,用于测试和调试" | +| `category` | string | 模板分类 | "testing", "assistant", "database" | + +### 原有字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `template` | string | 模板技术名称 | +| `port` | integer | 服务端口 | +| `env_info` | object | 环境变量配置 | + +--- + +## 🔄 MCP-Server 更新内容 + +### 1. 数据模型更新 + +#### `agent_manager_client.py` - TemplateInfo 数据类 + +```python +@dataclass +class TemplateInfo: + """ + Template Information + + Agent Manager now returns: + - template: technical name (e.g., echo_agent) + - displayName: human-readable name (e.g., "Echo 测试服务") + - description: template description + - category: template category (e.g., testing, assistant) + - port: service port + - env_info: environment variable configuration + """ + template: str + display_name: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + port: Optional[int] = None + env_info: Dict[str, Any] = field(default_factory=dict) + template_type: Optional[str] = None # platform or custom +``` + +#### `schemas.py` - TemplateInfo Pydantic 模型 + +```python +class TemplateInfo(BaseModel): + """模板信息""" + template: str + displayName: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + port: Optional[int] = None + env_info: Dict[str, Any] = {} +``` + +### 2. 接口解析更新 + +所有调用 Agent Manager 的方法都已更新以解析新字段: + +- ✅ `list_templates()` +- ✅ `list_platform_templates()` +- ✅ `list_custom_templates()` + +### 3. 路由层优先级逻辑 + +更新了所有使用模板信息的路由,按以下优先级使用 displayName 和 description: + +**优先级顺序**: +1. **数据库管理员配置** (PlatformAgentTemplateConfig 表) +2. **Agent Manager 返回值** (新增) +3. **硬编码默认值** (TEMPLATE_DISPLAY_INFO,向后兼容) + +#### 影响的路由文件 + +- ✅ `app/routes/admin.py` - 管理员接口 + - `_get_platform_templates_from_agent_manager()` + - `/api/admin/platform-agents/templates` + +- ✅ `app/routes/channel.py` - 渠道接口 + - `_get_platform_templates_from_agent_manager()` + - `_get_custom_templates_from_agent_manager()` + - `/api/channel/available-platform-agents` + +- ✅ `app/routes/platform_agent_quota.py` - 平台 Agent 配额接口 + - `/api/channel/available-platform-agents` (旧版) + +- ✅ `app/routes/agents.py` - Agent 模板接口 + - `GET /templates` + - `GET /templates/platform` + - `GET /templates/custom` + +--- + +## 🎯 业务影响 + +### ✅ 优点 + +1. **无需硬编码** - 不再需要在 MCP-Server 中维护 `TEMPLATE_DISPLAY_INFO` 字典 +2. **动态更新** - Agent Manager 可以在运行时更新模板名称和描述 +3. **多语言支持** - Agent Manager 可以根据区域返回不同语言的 displayName 和 description +4. **统一数据源** - 模板信息集中管理,避免数据不一致 + +### ⚠️ 向后兼容性 + +- **完全向后兼容** - 如果 Agent Manager 未返回 displayName/description/category,MCP-Server 会回退到使用硬编码的 `TEMPLATE_DISPLAY_INFO` +- **渐进式升级** - Agent Manager 可以逐步为每个模板添加新字段,不影响已有功能 + +### 🔄 数据流向 + +``` +┌─────────────────┐ +│ Agent Manager │ +│ │ +│ - template │ +│ - displayName │ ← 新增 +│ - description │ ← 新增 +│ - category │ ← 新增 +│ - port │ +│ - env_info │ +└────────┬────────┘ + │ + ↓ +┌────────────────────────────┐ +│ MCP-Server │ +│ │ +│ 优先级: │ +│ 1. DB管理员配置 │ +│ 2. Agent Manager返回 ✨新 │ +│ 3. 硬编码默认值(兼容) │ +└────────┬───────────────────┘ + │ + ↓ +┌─────────────────────┐ +│ 前端展示 │ +│ │ +│ - 管理端 │ +│ - 渠道端 │ +│ - 用户端 │ +└─────────────────────┘ +``` + +--- + +## 📊 示例对比 + +### Agent Manager 返回格式(旧) + +```json +{ + "templates": [ + { + "template": "echo_agent", + "port": 8000, + "env_info": {} + } + ] +} +``` + +### Agent Manager 返回格式(新) + +```json +{ + "templates": [ + { + "template": "echo_agent", + "displayName": "Echo 测试服务", + "description": "简单的 Echo 服务,用于测试和调试", + "category": "testing", + "port": 8000, + "env_info": {} + } + ] +} +``` + +### MCP-Server 响应格式 + +```json +{ + "success": true, + "data": { + "templates": [ + { + "name": "echo_agent", + "displayName": "Echo 测试服务", + "description": "简单的 Echo 服务,用于测试和调试", + "category": "testing", + "version": "1.0.0", + "port": 8000, + "envInfo": {}, + "status": "available", + "cpuRequest": "100m", + "cpuLimit": "500m", + "memoryRequest": "128Mi", + "memoryLimit": "512Mi" + } + ] + } +} +``` + +--- + +## ✅ 测试清单 + +### Agent Manager 端 + +- [ ] `/templates/platform` 返回 displayName、description、category +- [ ] `/templates/custom` 返回 displayName、description、category +- [ ] 所有现有模板都添加了这些字段 +- [ ] 新增模板自动包含这些字段 + +### MCP-Server 端 + +- [x] agent_manager_client.py 正确解析新字段 +- [x] schemas.py 包含新字段定义 +- [x] admin.py 优先使用 Agent Manager 返回的字段 +- [x] channel.py 优先使用 Agent Manager 返回的字段 +- [x] platform_agent_quota.py 使用新字段 +- [x] agents.py 返回新字段 +- [ ] 向后兼容测试(Agent Manager 未返回新字段时) +- [ ] 前端接口测试(各端正确显示) + +### 前端端 + +- [ ] 管理端正确显示 displayName 和 description +- [ ] 渠道端正确显示 displayName 和 description +- [ ] 用户端正确显示 displayName 和 description + +--- + +## 🚀 部署建议 + +1. **先更新 Agent Manager** - 确保返回新字段 +2. **部署 MCP-Server** - 支持接收新字段(已完成) +3. **验证接口** - 检查各端接口返回是否正确 +4. **前端适配** - 确保前端正确使用新字段 + +--- + +## 📝 注意事项 + +1. **硬编码的 TEMPLATE_DISPLAY_INFO 仍然保留** - 用于向后兼容和降级逻辑 +2. **数据库管理员配置优先级最高** - 管理员可以覆盖 Agent Manager 的默认值 +3. **category 分类建议** - testing, assistant, development, search, database, general + +--- + +## 🔗 相关文件 + +- `services/mcp-server/app/agent_manager_client.py` - Agent Manager 客户端 +- `services/mcp-server/schemas.py` - API 响应模型 +- `services/mcp-server/app/routes/admin.py` - 管理员路由 +- `services/mcp-server/app/routes/channel.py` - 渠道路由 +- `services/mcp-server/app/routes/platform_agent_quota.py` - 平台 Agent 配额路由 +- `services/mcp-server/app/routes/agents.py` - Agent 模板路由 +- `services/mcp-server/models.py` - 数据库模型(PlatformAgentTemplateConfig) + +--- + +**结论**: MCP-Server 已完全适配 Agent Manager 的新响应格式,并保持向后兼容性。✅ diff --git a/AGENT_UPDATE_QUICKREF.md b/AGENT_UPDATE_QUICKREF.md new file mode 100644 index 0000000..3202525 --- /dev/null +++ b/AGENT_UPDATE_QUICKREF.md @@ -0,0 +1,115 @@ +# Agent Manager 响应格式更新 - 快速参考 + +## ✅ 已完成的更新 + +### 1. 数据模型 +- ✅ `agent_manager_client.py::TemplateInfo` - 添加 `display_name`, `description`, `category` 字段 +- ✅ `schemas.py::TemplateInfo` - 添加 `displayName`, `description`, `category` 字段 + +### 2. 接口解析 +- ✅ `list_templates()` - 解析新字段 +- ✅ `list_platform_templates()` - 解析新字段 +- ✅ `list_custom_templates()` - 解析新字段 + +### 3. 路由层优先级逻辑 + +``` +优先级: DB配置 > Agent Manager返回 > 硬编码默认值 +``` + +- ✅ `admin.py::_get_platform_templates_from_agent_manager()` +- ✅ `channel.py::_get_platform_templates_from_agent_manager()` +- ✅ `channel.py::_get_custom_templates_from_agent_manager()` +- ✅ `platform_agent_quota.py::get_available_platform_agents()` +- ✅ `agents.py::list_templates()` +- ✅ `agents.py::list_platform_templates()` +- ✅ `agents.py::list_custom_templates()` + +### 4. 向后兼容 +- ✅ 保留 `TEMPLATE_DISPLAY_INFO` 硬编码默认值 +- ✅ 降级逻辑:当 Agent Manager 未返回时使用默认值 + +--- + +## 📊 Agent Manager 预期返回格式 + +```json +{ + "templates": [ + { + "template": "echo_agent", // 必需 + "displayName": "Echo 测试服务", // 新增 ✨ + "description": "简单的测试服务", // 新增 ✨ + "category": "testing", // 新增 ✨ + "port": 8000, // 可选 + "env_info": {} // 可选 + } + ] +} +``` + +--- + +## 🎯 各端展示逻辑 + +### 管理端 (Admin) +优先级: **DB配置 > Agent Manager > 硬编码** + +### 渠道端 (Channel) +优先级: **DB配置 > Agent Manager > 硬编码** + +### 用户端 (User) +优先级: **Agent Manager > 硬编码** + +--- + +## ✅ 验证清单 + +### Agent Manager 端 (需要确认) +- [ ] `GET /templates/platform` 返回 `displayName` +- [ ] `GET /templates/platform` 返回 `description` +- [ ] `GET /templates/platform` 返回 `category` +- [ ] `GET /templates/custom` 返回新字段 + +### MCP-Server 端 (已完成) +- [x] 数据模型包含新字段 +- [x] 接口解析新字段 +- [x] 路由优先使用 Agent Manager 返回值 +- [x] 保持向后兼容性 +- [x] 代码编译无错误 + +### 前端端 (需要测试) +- [ ] 管理端正确显示 displayName +- [ ] 渠道端正确显示 displayName +- [ ] 用户端正确显示 displayName + +--- + +## 🚀 测试命令 + +```bash +# 1. 测试 Agent Manager 接口 +curl http://agent-manager:8000/templates/platform + +# 2. 测试 MCP-Server 管理员接口 +curl http://localhost:8002/api/admin/platform-agents/templates \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 3. 测试 MCP-Server 渠道接口 +curl http://localhost:8002/api/channel/available-platform-agents \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +--- + +## 📝 重要说明 + +1. **Agent Manager 必须先更新** - 返回新字段后 MCP-Server 才会使用 +2. **降级逻辑自动生效** - 如果 Agent Manager 未返回,自动使用默认值 +3. **数据库配置优先** - 管理员可以在数据库中覆盖任何显示名称 + +--- + +## 📄 相关文档 +- [完整更新说明](./AGENT_MANAGER_RESPONSE_UPDATE.md) +- [Agent Manager 接口规范](./Docs/Agent-Manager外部工具接口规范.md) diff --git a/Docs/项目文档/Agent列表接口新增modelName字段.md b/Docs/项目文档/Agent列表接口新增modelName字段.md new file mode 100644 index 0000000..b116d43 --- /dev/null +++ b/Docs/项目文档/Agent列表接口新增modelName字段.md @@ -0,0 +1,189 @@ +# Agent 列表接口新增 modelName 字段 + +## 变更说明 + +后端已在所有 Agent 列表相关接口的响应中新增 `modelName` 字段,用于显示 Agent 部署时选择的模型名称。 + +## 修改的接口 + +### 1. GET /api/user/agents/platform + +**描述**: 获取平台 Agent 列表 + +**新增字段**: `modelName` + +**响应示例**: +```json +{ + "success": true, + "data": { + "data": [ + { + "id": "uuid", + "name": "agent-name", + "description": "描述", + "category": "通用", + "cpu": 0.5, + "memory": 1.0, + "status": "active", + "modelName": "gpt-4o" // 新增字段,可能为 null + } + ] + } +} +``` + +--- + +### 2. GET /api/user/platform-agents/instances + +**描述**: 获取当前用户的平台 Agent 实例列表 + +**新增字段**: `modelName` + +**响应示例**: +```json +{ + "success": true, + "data": { + "instances": [ + { + "instanceName": "echo-agent-xxx", + "agentType": "echo_agent", + "status": "Running", + "startTime": "2026-03-09T10:00:00", + "runningSeconds": 3600, + "modelName": "gpt-4o" // 新增字段,可能为 null + } + ] + } +} +``` + +--- + +### 3. GET /api/user/custom-agents + +**描述**: 获取当前用户的自定义 Agent 列表 + +**新增字段**: `modelName` + +**响应示例**: +```json +{ + "success": true, + "data": { + "agents": [ + { + "name": "my-custom-agent", + "template": "custom_template", + "status": "Running", + "cpu": "500m", + "memory": "1Gi", + "startTime": "2026-03-09T10:00:00", + "runningSeconds": 3600, + "modelName": "gpt-4o" // 新增字段,可能为 null + } + ] + } +} +``` + +--- + +### 4. GET /api/user/custom-agent-quota + +**描述**: 获取当前租户正在运行的平台 Agent 资源使用情况 + +**新增字段**: `agents[].modelName` + +**响应示例**: +```json +{ + "success": true, + "data": { + "totalCpu": 1.5, + "totalMemory": 2.0, + "agentCount": 3, + "agents": [ + { + "agentName": "echo-agent-xxx", + "agentType": "echo_agent", + "templateName": "echo_agent", + "cpuPerPod": 0.5, + "memoryPerPod": 0.5, + "replicas": 1, + "totalCpu": 0.5, + "totalMemory": 0.5, + "startTime": "2026-03-09T10:00:00", + "modelName": "gpt-4o" // 新增字段,可能为 null + } + ] + } +} +``` + +--- + +### 5. GET /api/user/resources/agents + +**描述**: 获取用户已部署的 Agent 列表(包含 IP 地址和访问信息) + +**新增字段**: `platformAgents[].modelName` 和 `customAgents[].modelName` + +**响应示例**: +```json +{ + "success": true, + "data": { + "platformAgents": [ + { + "name": "echo-agent-xxx", + "template": "echo_agent", + "templateName": "echo_agent", + "status": "Running", + "healthStatus": "healthy", + "podIp": "10.244.1.100", + "externalIp": "20.xxx.xxx.xxx", + "domain": "agent-xxx.taiji-ai.com", + "domainUrl": "https://agent-xxx.taiji-ai.com", + "accessUrl": "https://agent-xxx.taiji-ai.com", + "servicePort": 8080, + "namespace": "ai-agents", + "cpu": "500m", + "memory": "1Gi", + "replicas": 1, + "modelName": "gpt-4o", // 新增字段,可能为 null + "startTime": "2026-03-09T10:00:00", + "runningSeconds": 3600 + } + ], + "customAgents": [ + { + "name": "my-custom-agent", + "template": "custom_template", + "modelName": "claude-3-sonnet", // 新增字段,可能为 null + ... + } + ], + "summary": { + "totalPlatformAgents": 1, + "totalCustomAgents": 1 + } + } +} +``` + +--- + +## 字段说明 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| `modelName` | `string \| null` | Agent 部署时选择的模型名称,如 `gpt-4o`、`claude-3-sonnet` 等。如果部署时未指定模型,则为 `null`。 | + +## 前端对接建议 + +1. 在 Agent 列表页面显示 `modelName` 字段 +2. 如果 `modelName` 为 `null`,可显示为 "未指定" 或不显示 +3. 建议使用模型名称的友好显示名(如 `gpt-4o` → `GPT-4o`) diff --git a/Docs/项目文档/计费管理三维度接口文档.md b/Docs/项目文档/计费管理三维度接口文档.md new file mode 100644 index 0000000..caf93ed --- /dev/null +++ b/Docs/项目文档/计费管理三维度接口文档.md @@ -0,0 +1,386 @@ +# 计费管理三维度接口文档 + +## 接口信息 + +**接口路径**: `GET /api/admin/billing/overview` + +**功能**: 获取计费管理三维度统计数据(渠道维度、租户维度、调用记录) + +**认证**: 需要 Bearer Token + +**权限**: 管理员角色(super_admin、billing_admin、operations_admin) + +--- + +## 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| startTime | string | ✅ | 开始时间,ISO 8601格式,如 `2026-03-01T00:00:00Z` | +| endTime | string | ✅ | 结束时间,ISO 8601格式,如 `2026-03-31T23:59:59Z` | +| channelName | string | ❌ | 渠道名称筛选 | +| tenantName | string | ❌ | 租户名称筛选 | +| minCalls | int | ❌ | 最小调用次数筛选 | +| maxCalls | int | ❌ | 最大调用次数筛选 | +| export | string | ❌ | 导出格式:`excel`、`csv`、`pdf` | + +--- + +## 响应数据结构 + +```json +{ + "success": true, + "data": { + "channelStats": [...], + "tenantStats": [...], + "callRecords": [...] + } +} +``` + +--- + +## 一、渠道维度 (channelStats) + +### 数据结构 + +```json +{ + "channelStats": [ + { + "channelId": "550e8400-e29b-41d4-a716-446655440001", + "channelName": "渠道A", + "calls": 1500, + "totalEU": 7500.0, + "totalCost": 750.00 + }, + { + "channelId": "550e8400-e29b-41d4-a716-446655440002", + "channelName": "渠道B", + "calls": 800, + "totalEU": 4000.0, + "totalCost": 400.00 + } + ] +} +``` + +### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| channelId | string | 渠道ID(UUID) | +| channelName | string | 渠道名称 | +| calls | int | 调用次数 | +| totalEU | float | 总EU消耗 | +| totalCost | float | 渠道总价(USD) | + +### 前端汇总计算 + +```javascript +// 渠道总数 +const channelCount = channelStats.length; + +// 总计费额 +const totalCost = channelStats.reduce((sum, c) => sum + c.totalCost, 0); + +// 总EU消耗 +const totalEU = channelStats.reduce((sum, c) => sum + c.totalEU, 0); +``` + +--- + +## 二、租户维度 (tenantStats) + +### 数据结构 + +```json +{ + "tenantStats": [ + { + "tenantId": "b0d02105-55f8-41a7-b09a-bba8f49a9d62", + "tenantName": "xiaohei", + "channelName": "66", + "calls": 84, + "totalEU": 44197.14, + "totalCost": 12.2905, + "avgCost": 0.1463 + }, + { + "tenantId": "6b49508d-9c2c-4eac-99e1-c36263fd1699", + "tenantName": "cccccccc", + "channelName": "66", + "calls": 2, + "totalEU": 18.0, + "totalCost": 0.0046, + "avgCost": 0.0023 + } + ] +} +``` + +### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| tenantId | string | 租户ID(UUID) | +| tenantName | string | 租户名称 | +| channelName | string | 所属渠道名称(无渠道时显示"无渠道") | +| calls | int | 调用次数(Agent使用 + 模型调用) | +| totalEU | float | 总EU消耗 | +| totalCost | float | 用户总价(USD) | +| avgCost | float | 平均消费(totalCost / calls) | + +### 前端汇总计算 + +```javascript +// 租户总数 +const tenantCount = tenantStats.length; + +// 用户总价 +const userTotalCost = tenantStats.reduce((sum, t) => sum + t.totalCost, 0); + +// 平均消费 +const avgCost = tenantCount > 0 ? userTotalCost / tenantCount : 0; +``` + +--- + +## 三、调用记录 (callRecords) + +调用记录包含两种类型: +- **agent**: Agent 使用记录(来自 AgentBillingRecord 表) +- **model**: 模型调用记录(来自 ModelBillingRecord 表,LiteLLM 回调数据) + +### 数据结构 + +```json +{ + "callRecords": [ + { + "id": "7d984099-534f-4b8b-a892-74dead3fd5fd", + "type": "agent", + "timestamp": "2026-03-09T07:05:41.118384", + "channelName": "无渠道", + "tenantName": "xiaohei", + "agentName": "search-agent-b0d02105-21015e", + "modelName": "taiji/claude-sonnet-4-5", + "duration": 83430, + "eu": 8343, + "cost": 2.3175 + }, + { + "id": "d69e4da8-0852-4e35-94dc-ee2bd584e9ca", + "type": "model", + "timestamp": "2026-03-05T16:02:47.186423", + "channelName": "66", + "tenantName": "xiaohei", + "agentName": null, + "modelName": "openrouter/nousresearch/hermes-3-llama-3.1-405b", + "duration": 44.76, + "eu": 0.3862, + "cost": 0.003862, + "inputTokens": 2932, + "outputTokens": 930, + "totalTokens": 3862 + } + ] +} +``` + +### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 调用ID(UUID) | +| type | string | 记录类型:`agent`(Agent使用)或 `model`(模型调用) | +| timestamp | string | 时间戳(ISO 8601格式) | +| channelName | string | 渠道名称(无渠道时显示"无渠道") | +| tenantName | string | 租户名称 | +| agentName | string | Agent名称(模型调用时为 null) | +| modelName | string | 模型名称(如 `taiji/claude-sonnet-4-5`) | +| duration | float | 时长(秒)- Agent为运行时长,模型为响应时间 | +| eu | float | EU消耗 | +| cost | float | 单次调用总价(USD) | +| inputTokens | int | 输入Token数(仅 type=model 时有值) | +| outputTokens | int | 输出Token数(仅 type=model 时有值) | +| totalTokens | int | 总Token数(仅 type=model 时有值) | + +### EU计算规则 + +**Agent 使用(type=agent):** +``` +1 EU = 10秒运行时间 +不足10秒按1 EU计算 + +公式:EU = ceil(duration / 10) + +示例: +- 5秒 → 1 EU +- 10秒 → 1 EU +- 15秒 → 2 EU +- 35秒 → 4 EU +``` + +**模型调用(type=model):** +``` +EU 按 Token 数量计算 +公式由 LiteLLM 回调提供 +``` + +--- + +## 前端调用示例 + +### 请求示例 + +```javascript +const response = await fetch( + '/api/admin/billing/overview?' + new URLSearchParams({ + startTime: '2026-03-01T00:00:00Z', + endTime: '2026-03-31T23:59:59Z' + }), + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + } +); + +const data = await response.json(); +``` + +### 数据处理示例 + +```javascript +if (data.success) { + const { channelStats, tenantStats, callRecords } = data.data; + + // ========== 渠道维度汇总 ========== + const channelSummary = { + totalChannels: channelStats.length, + totalCost: channelStats.reduce((sum, c) => sum + c.totalCost, 0), + totalEU: channelStats.reduce((sum, c) => sum + c.totalEU, 0) + }; + + // ========== 租户维度汇总 ========== + const tenantSummary = { + totalTenants: tenantStats.length, + userTotalCost: tenantStats.reduce((sum, t) => sum + t.totalCost, 0), + avgCost: tenantStats.length > 0 + ? tenantStats.reduce((sum, t) => sum + t.totalCost, 0) / tenantStats.length + : 0 + }; + + // ========== 渠道计费详情表格数据 ========== + const channelTableData = channelStats.map(c => ({ + 渠道名称: c.channelName, + 调用次数: c.calls, + 总EU: c.totalEU, + 渠道总价: `$${c.totalCost.toFixed(2)}` + })); + + // ========== 租户计费详情表格数据 ========== + const tenantTableData = tenantStats.map(t => ({ + 租户名称: t.tenantName, + 所属渠道: t.channelName, + 调用次数: t.calls, + 总EU: t.totalEU, + 用户总价: `$${t.totalCost.toFixed(2)}` + })); + + // ========== 调用记录明细表格数据 ========== + const callTableData = callRecords.map(r => ({ + 调用ID: r.id, + 租户: r.tenantName, + 渠道: r.channelName, + 调用时间: r.agentName, + '时长(秒)': r.duration, + EU: r.eu, + 单次调用总价: `$${r.cost.toFixed(2)}`, + 时间戳: r.timestamp + })); +} +``` + +--- + +## 前端页面字段映射 + +### 渠道维度卡片 + +| 前端显示 | 数据来源 | +|---------|---------| +| 渠道总数 | `channelStats.length` | +| 总计费额 | `channelStats.reduce((sum, c) => sum + c.totalCost, 0)` | +| 总EU消耗 | `channelStats.reduce((sum, c) => sum + c.totalEU, 0)` | + +### 渠道计费详情表格 + +| 表头 | 字段 | +|------|------| +| 渠道名称 | `channelName` | +| 调用次数 | `calls` | +| 总EU | `totalEU` | +| 渠道总价 | `totalCost` | + +### 租户维度卡片 + +| 前端显示 | 数据来源 | +|---------|---------| +| 租户总数 | `tenantStats.length` | +| 用户总价 | `tenantStats.reduce((sum, t) => sum + t.totalCost, 0)` | +| 平均消费 | `用户总价 / 租户总数` | + +### 租户计费详情表格 + +| 表头 | 字段 | +|------|------| +| 租户名称 | `tenantName` | +| 所属渠道 | `channelName` | +| 调用次数 | `calls` | +| 总EU | `totalEU` | +| 用户总价 | `totalCost` | + +### 调用记录明细表格 + +| 表头 | 字段 | +|------|------| +| 调用ID | `id` | +| 类型 | `type`(agent/model) | +| 租户 | `tenantName` | +| 渠道 | `channelName` | +| Agent名称 | `agentName` | +| 模型名称 | `modelName` | +| 时长(秒) | `duration` | +| EU | `eu` | +| 单次调用总价 | `cost` | +| 输入Token | `inputTokens`(仅模型调用) | +| 输出Token | `outputTokens`(仅模型调用) | +| 总Token | `totalTokens`(仅模型调用) | +| 时间戳 | `timestamp` | + +--- + +## 数据来源说明 + +本接口从以下两个表聚合数据: + +| 表名 | 说明 | 对应 type | +|------|------|----------| +| AgentBillingRecord | Agent 使用计费记录 | `agent` | +| ModelBillingRecord | 模型调用计费记录(LiteLLM 回调) | `model` | + +--- + +## 注意事项 + +1. **时间格式**: 请求参数中的时间需要使用 ISO 8601 格式 +2. **金额单位**: 所有金额字段单位为 USD(美元) +3. **调用记录限制**: 默认返回最近100条记录(Agent 50条 + 模型 50条),按时间倒序排列 +4. **EU计算**: Agent 使用按 1 EU = 10秒计算;模型调用按 Token 数量计算 +5. **渠道名称**: 如果租户未关联渠道,显示"无渠道" +6. **记录类型**: 通过 `type` 字段区分 Agent 使用记录和模型调用记录 diff --git a/config/database-config.md b/config/database-config.md index 073d94e..bd4c0ce 100644 --- a/config/database-config.md +++ b/config/database-config.md @@ -4,36 +4,36 @@ | 环境 | 数据库名 | 配置文件 | |------|----------|----------| -| **生产环境 (AKS)** | `taiji` | `k8s/secrets.yaml` | -| **测试环境 (docker-compose)** | `postgres` | `.env` 文件 | +| **生产环境 (AKS)** | `taiji_prod` | `k8s/secrets.yaml` | +| **测试环境 (docker-compose)** | `taiji` | `.env` 文件 | ## 生产环境配置 (AKS) -生产环境通过 `k8s/secrets.yaml` 配置,使用 `taiji` 数据库: +生产环境通过 `k8s/secrets.yaml` 配置,使用 `taiji_prod` 数据库: ```yaml -database-url: "postgresql://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require" -async-database-url: "postgresql+asyncpg://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji" +database-url: "postgresql://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji_prod?sslmode=require" +async-database-url: "postgresql+asyncpg://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji_prod" ``` ## 测试环境配置 (docker-compose) -测试环境通过 `.env` 文件配置,使用 `postgres` 数据库: +测试环境通过 `.env` 文件配置,使用 `taiji` 数据库: ```bash # .env 文件配置 -DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require -ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres +DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require +ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji ``` ## 配置要点 1. **生产环境部署到 AKS 时**: - - 确保 `k8s/secrets.yaml` 中数据库名为 `taiji` + - 确保 `k8s/secrets.yaml` 中数据库名为 `taiji_prod` - 通过 `kubectl apply -f k8s/secrets.yaml` 部署 2. **本地测试环境**: - - 在 `.env` 文件中配置数据库名为 `postgres` + - 在 `.env` 文件中配置数据库名为 `taiji` - 运行 `docker-compose up` 启动服务 ## 数据库信息 diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml index ec48516..3b666cd 100644 --- a/k8s/secrets.yaml +++ b/k8s/secrets.yaml @@ -14,10 +14,10 @@ type: Opaque stringData: # =========================================== # 数据库配置 (Azure Database for PostgreSQL) - # 生产环境使用 postgres 数据库 + # 生产环境使用 taiji_prod 数据库 # =========================================== - database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require" - async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres" + database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji_prod?sslmode=require" + async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji_prod" # =========================================== # Redis配置 (Azure Cache for Redis with SSL) diff --git a/plans/计费系统修复完成报告.md b/plans/计费系统修复完成报告.md new file mode 100644 index 0000000..48e17f2 --- /dev/null +++ b/plans/计费系统修复完成报告.md @@ -0,0 +1,472 @@ +# mcp-server 计费系统修复完成报告 + +> **修复日期**: 2026-03-09 +> **修复人**: AI Assistant +> **基于文档**: [计费系统问题验证报告.md](./计费系统问题验证报告.md) + +--- + +## ✅ 修复摘要 + +已完成所有 P0 和 P1 级别问题的修复,并删除了废弃代码。共计修复 6 个问题,涉及 3 个文件。 + +--- + +## 📝 修复详细清单 + +### 🔴 P0 - 核心问题修复 + +#### ✅ 修复1:消除 billing_webhook 重复扣款(问题2) + +**文件**: `services/mcp-server/app/routes/billing_webhook.py` + +**修改内容**: +- 删除了 Agent Manager 回调时的扣款逻辑 +- 保留记录更新逻辑,只更新 `end_time`、`cost`、`duration_seconds` 等字段 +- 所有扣款统一由 `periodic_billing.py` 的周期任务处理 + +**修改位置**: +- L456-472: 更新现有记录时,删除了增量扣款逻辑 +- L493-513: 创建新记录时,删除了全额扣款逻辑 + +**修改前**: +```python +# 计算增量成本并扣款 +if cost_increment > 0: + success, message = await deduct_balance(...) +``` + +**修改后**: +```python +# ⚠️ 不在此处扣款,由周期计费(periodic_billing.py)统一处理扣款 +logger.info( + f"📝 更新Agent计费记录(不扣款): agent={callback_data.agentName}, " + f"最终成本={new_cost},扣款由周期计费处理" +) +``` + +--- + +#### ✅ 修复2:调整删除 Agent 的顺序(问题3) + +**文件**: `services/mcp-server/app/routes/user.py` + +**修改内容**: +- 重新调整 `delete_custom_agent` 函数的执行顺序 +- 先删除 Pod,成功后再更新数据库和释放配额 +- 如果 Pod 删除失败,立即返回错误,不修改数据库 + +**修改位置**: L3323-3390 + +**修改前**: +```python +1. 更新计费记录(设置 end_time) +2. 扣款 +3. 释放配额 +4. 提交数据库 +5. 删除 Pod(可能失败!)❌ +``` + +**修改后**: +```python +1. ✅ 先删除 Pod(如果失败,整个操作终止) +2. ✅ Pod 删除成功后,更新计费记录 +3. ✅ 扣款 +4. ✅ 释放配额(使用行锁) +5. ✅ 提交数据库 +``` + +**关键改进**: +- 添加了详细的异常处理,区分 `AgentManagerError` 和其他异常 +- 添加了日志记录,跟踪 Pod 删除状态 +- 在释放配额时添加了行锁 `.with_for_update()` + +--- + +#### ✅ 修复3:添加配额更新的行锁保护(问题4) + +**文件**: `services/mcp-server/app/routes/user.py` + +**修改内容**: +在所有查询配额并进行更新的地方,添加 `.with_for_update()` 行锁,防止并发操作导致配额计算错误。 + +**修改位置**: +1. **L2893** - 创建自定义 Agent 时 +2. **L3358** - 删除自定义 Agent 时 +3. **L3447** - 扩缩容自定义 Agent 时 + +**修改前**: +```python +quota_result = await db.execute( + select(TenantCustomAgentQuota).where( + TenantCustomAgentQuota.tenant_id == user_id + ) + # ❌ 没有行锁 +) +``` + +**修改后**: +```python +quota_result = await db.execute( + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁,防止并发问题 +) +``` + +--- + +### 🟡 P1 - 改进和优化 + +#### ✅ 修复4:start_time 添加非空约束(问题6) + +**文件**: `services/mcp-server/models.py` + +**修改内容**: +- 将 `AgentBillingRecord.start_time` 字段改为 `nullable=False` +- 防止计费记录缺少启动时间,避免周期计费跳过这些记录 + +**修改位置**: L1197 + +**修改前**: +```python +start_time = Column(DateTime, nullable=True) # Agent 启动时间 +``` + +**修改后**: +```python +start_time = Column(DateTime, nullable=False) # ✅ Agent 启动时间(必填,防止计费遗漏) +``` + +--- + +#### ✅ 修复5:创建 Agent 前添加余额预检查(问题7) + +**文件**: `services/mcp-server/app/routes/user.py` + +**修改内容**: +- 在创建 Agent 前,预估至少运行 1 小时的成本 +- 检查用户可用余额是否充足 +- 如果余额不足,拒绝创建 Agent 并提示用户充值 + +**修改位置**: L3180(在调用 Agent Manager 之前) + +**新增代码**: +```python +# ✅ 预检查用户余额(防止余额不足仍创建Agent) +from app.billing import get_available_balance, calculate_agent_cost_by_resources + +# 预估至少运行1小时的成本 +cpu_cores = _parse_cpu_to_cores(req.cpuRequest) if req.cpuRequest else 0.1 +memory_gb = _parse_memory_to_gb(req.memoryRequest) if req.memoryRequest else 0.125 +estimated_duration = 3600 # 预估1小时(3600秒) +estimated_cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, estimated_duration) + +# 获取可用余额 +balance, credit_limit, available = await get_available_balance(str(user_id), db) + +if available < estimated_cost: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"余额不足,无法创建 Agent。" + f"预估成本(1小时): {estimated_cost:.2f} EU, " + f"当前可用余额: {available:.2f} EU," + f"请先充值" + ) +``` + +**用户体验改进**: +- 提前阻止余额不足的 Agent 创建 +- 提供清晰的错误信息(预估成本 + 当前余额) +- 避免 Agent 创建后因余额不足被周期任务停止 + +--- + +### 🧹 废弃代码清理 + +#### ✅ 修复6:删除废弃字段(问题5) + +**文件**: `services/mcp-server/models.py` + +**修改内容**: +删除 `User` 模型中的两个废弃字段: +- `balance` - 账户余额 [DEPRECATED - 使用 Balance 表] +- `eu_balance` - EU余额 [DEPRECATED - 使用 Balance 表] + +**修改位置**: L84-88 + +**修改前**: +```python +balance = Column(sa.Numeric(12, 2), default=0) # 账户余额 [DEPRECATED - 使用 Balance 表] +credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度 + +# EU计费(执行单元) +eu_balance = Column(sa.Numeric(15, 2), default=0) # EU余额 [DEPRECATED - 使用 Balance 表] +total_eu_consumed = Column(sa.Numeric(15, 2), default=0) # 总EU消耗 +``` + +**修改后**: +```python +credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度 + +# EU计费(执行单元) +# ❌ 废弃字段已删除:balance, eu_balance(使用 Balance 表替代) +total_eu_consumed = Column(sa.Numeric(15, 2), default=0) # 总EU消耗 +``` + +**影响**: +- 减少了数据库存储空间 +- 避免了新开发者误用废弃字段 +- 消除了数据不一致的风险 + +--- + +## 📊 修复影响分析 + +### 向后兼容性 + +#### ✅ 完全兼容 +- 修复1-5:只修改内部逻辑,不影响 API 接口 +- 现有代码无需修改,可直接使用 + +#### ⚠️ 需要数据库迁移 +- **修复4(start_time 非空约束)**: + - 需要确保所有现有记录的 `start_time` 不为空 + - 建议先运行数据修复脚本,将 `NULL` 值替换为 `created_at` + +- **修复6(删除废弃字段)**: + - 需要创建数据库迁移脚本删除 `users` 表的两个字段 + - 建议先备份数据库 + +### 性能影响 + +#### ✅ 正面影响 +- **减少重复扣款**:降低数据库写入压力 +- **添加行锁**:防止并发导致的数据不一致,减少数据修复需求 +- **余额预检查**:避免无效的 Agent 创建,节省资源 + +#### ⚠️ 可能的负面影响 +- **行锁可能增加等待时间**: + - 并发创建/删除 Agent 时,后续操作需要等待行锁释放 + - 影响很小(通常 < 100ms) + - 好处远大于坏处(避免配额计算错误) + +--- + +## 🚀 部署建议 + +### 步骤1:备份数据库 +```bash +# 备份整个数据库 +pg_dump taiji_prod > backup_before_billing_fix_$(date +%Y%m%d_%H%M%S).sql +``` + +### 步骤2:执行数据修复(可选) + +#### 修复 start_time 为空的记录 +```sql +-- 将 start_time 为空的记录设置为 created_at +UPDATE agent_billing_records +SET start_time = created_at +WHERE start_time IS NULL; + +-- 确认修复结果 +SELECT COUNT(*) FROM agent_billing_records WHERE start_time IS NULL; +-- 应该返回 0 +``` + +### 步骤3:创建数据库迁移脚本 + +```python +# migrations/20260309_billing_system_fixes.py +""" +计费系统修复 - 数据库迁移 + +修复内容: +1. start_time 添加非空约束 +2. 删除 User 表的废弃字段 +""" +from alembic import op +import sqlalchemy as sa + +def upgrade(): + # 1. 修复数据:确保 start_time 不为空 + op.execute(""" + UPDATE agent_billing_records + SET start_time = created_at + WHERE start_time IS NULL + """) + + # 2. 添加非空约束 + op.alter_column( + 'agent_billing_records', + 'start_time', + existing_type=sa.DateTime(), + nullable=False + ) + + # 3. 删除废弃字段 + op.drop_column('users', 'eu_balance') + op.drop_column('users', 'balance') + +def downgrade(): + # 回滚(如果需要) + op.alter_column( + 'agent_billing_records', + 'start_time', + existing_type=sa.DateTime(), + nullable=True + ) + + op.add_column('users', + sa.Column('eu_balance', sa.Numeric(15, 2), default=0)) + op.add_column('users', + sa.Column('balance', sa.Numeric(12, 2), default=0)) +``` + +### 步骤4:执行迁移 +```bash +# 进入 mcp-server 容器 +kubectl exec -it -n -- bash + +# 执行迁移 +cd /app +alembic upgrade head +``` + +### 步骤5:重启服务 +```bash +# 重启 mcp-server +kubectl rollout restart deployment mcp-server -n + +# 确认服务正常 +kubectl get pods -n -l app=mcp-server +``` + +### 步骤6:验证修复 + +#### 验证1:重复扣款已消除 +```bash +# 监控日志,确认回调不再扣款 +kubectl logs -f -n | grep "更新Agent计费记录(不扣款)" +``` + +#### 验证2:删除顺序正确 +```bash +# 尝试删除 Agent,确认先删除 Pod +# 如果 Pod 删除失败,应该返回错误而不是释放配额 +``` + +#### 验证3:余额预检查生效 +```bash +# 创建余额不足的租户 +# 尝试创建 Agent,应该返回 403 错误 +``` + +--- + +## 📈 监控建议 + +### 关键指标 + +1. **扣款准确性** + - 监控 `periodic_billing.py` 的扣款日志 + - 确认每个 Agent 只扣款一次 + +2. **配额一致性** + - 定期运行 `fix_fake_quota.py` 检查配额 + - 监控配额不一致告警 + +3. **余额不足告警** + - 监控余额不足导致的 Agent 创建失败 + - 提醒用户充值 + +### 日志关键字 + +```bash +# 扣款日志 +grep "Agent周期计费" /app/logs/*.log + +# 配额释放日志 +grep "配额已释放" /app/logs/*.log + +# 余额不足日志 +grep "余额不足" /app/logs/*.log + +# Pod 删除失败日志 +grep "Pod 删除失败" /app/logs/*.log +``` + +--- + +## 🎯 预期效果 + +### 修复前 vs 修复后 + +| 问题 | 修复前 | 修复后 | 风险等级 | +|------|--------|--------|---------| +| 重复扣款 | ⚠️ 周期计费和回调可能重复扣款 | ✅ 只在周期计费时扣款 | 🔴 高 → 🟢 低 | +| 删除顺序错误 | ⚠️ 配额已释放但 Pod 仍在运行 | ✅ 先删除 Pod,失败则不释放配额 | 🔴 高 → 🟢 低 | +| 并发配额问题 | ⚠️ 并发操作可能导致配额错误 | ✅ 所有配额更新使用行锁 | 🟡 中 → 🟢 低 | +| start_time 为空 | ⚠️ 周期计费会跳过这些记录 | ✅ 数据库约束防止为空 | 🟡 中 → 🟢 低 | +| 余额不足仍创建 | ⚠️ 创建后可能被停止 | ✅ 创建前预检查余额 | 🟡 中 → 🟢 低 | +| 废弃字段占用空间 | ⚠️ 浪费存储,可能被误用 | ✅ 已删除废弃字段 | 🟢 低 → ✅ 无 | + +--- + +## ⚠️ 注意事项 + +### 1. 数据库迁移风险 + +**风险**:删除废弃字段后无法回滚数据 + +**缓解措施**: +- 执行迁移前完整备份数据库 +- 先在测试环境验证 +- 保留备份至少 30 天 + +### 2. 现有 Agent 的 start_time + +**风险**:如果现有记录中有 `start_time` 为空的,迁移会失败 + +**缓解措施**: +- 迁移前先运行数据修复 SQL +- 将 `NULL` 值替换为 `created_at` + +### 3. 行锁的性能影响 + +**风险**:高并发时可能增加响应时间 + +**缓解措施**: +- 监控配额更新的响应时间 +- 如果平均响应时间 > 500ms,考虑优化 + +--- + +## 📞 后续支持 + +如有问题或需要协助,可以: +1. 查看日志文件:`/app/logs/` +2. 运行诊断脚本:`python check_quota_data.py` +3. 联系技术支持 + +--- + +## ✅ 修复验证清单 + +- [x] billing_webhook.py - 删除重复扣款逻辑 +- [x] user.py - 调整删除 Agent 顺序 +- [x] user.py - 添加配额更新行锁(3处) +- [x] models.py - start_time 非空约束 +- [x] user.py - 创建 Agent 前余额预检查 +- [x] models.py - 删除废弃字段 +- [x] 代码编译通过(无错误) +- [ ] 数据库迁移脚本已创建 +- [ ] 测试环境验证通过 +- [ ] 生产环境准备就绪 + +--- + +**文档版本**: v1.0.0 +**最后更新**: 2026-03-09 +**状态**: ✅ 修复完成,待部署 diff --git a/plans/计费系统问题分析和修复方案.md b/plans/计费系统问题分析和修复方案.md new file mode 100644 index 0000000..ffe4c76 --- /dev/null +++ b/plans/计费系统问题分析和修复方案.md @@ -0,0 +1,1551 @@ +# mcp-server 计费系统问题分析和修复方案 + +> **分析日期**: 2026-03-09 +> **优先级**: 🔴 高优先级(涉及用户资金安全) +> **影响范围**: 计费模块、配额管理、余额扣款 + +--- + +## 📊 执行摘要 + +mcp-server 实现了**两种独立的计费模式**: + +1. **Agent 运行时长计费** - 基于 Pod 运行时间和资源使用 +2. **模型 Token 计费** - 基于 LiteLLM 模型调用的 Token 消耗 + +经过深入分析,发现了 **8 个严重问题**,其中包括: +- 🔥 潜在的重复扣款风险 +- 💥 配额释放与 Pod 删除顺序错误 +- 🔒 并发操作缺少保护 +- 💾 数据双重存储不一致 + +--- + +## 🏗️ 计费架构概览 + +### 系统架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户操作 │ +├─────────────────┬────────────────────────┬──────────────────┤ +│ 创建 Agent │ Agent 调用模型 │ 删除 Agent │ +└────────┬────────┴────────────┬───────────┴─────────┬────────┘ + │ │ │ + ▼ ▼ ▼ +┌────────────────┐ ┌────────────────┐ ┌────────────────┐ +│ Agent Manager │ │ LiteLLM │ │ Agent Manager │ +│ 部署 Pod │ │ 调用模型 │ │ 删除 Pod │ +└────────┬───────┘ └────────┬───────┘ └────────┬────────┘ + │ │ │ + ▼ ▼ ▼ +┌────────────────────────────────────────────────────────────┐ +│ mcp-server 计费模块 │ +├────────────────┬────────────────────────┬──────────────────┤ +│ 创建计费记录 │ 接收 LiteLLM 回调 │ 结束计费记录 │ +│ AgentBilling │ ModelBillingRecord │ 更新 end_time │ +│ Record │ 实时扣款 Token │ 扣款+释放配额 │ +└────────┬───────┴────────────┬───────────┴─────────┬────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 周期性计费任务(每小时) │ +│ periodic_billing.py - 扫描运行中的 Agent │ +│ 计算增量成本并扣款 (cost_increment) │ +└────────┬────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Balance 表(用户余额) │ +│ eu_balance - 统一扣款来源 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 数据模型 + +#### 1. AgentBillingRecord(Agent 计费记录) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `user_id` | GUID | 用户ID | +| `agent_name` | String | Agent 名称 | +| `is_platform_agent` | Boolean | 是否为平台 Agent | +| `start_time` | DateTime | 启动时间 | +| `end_time` | DateTime | 停止时间(NULL = 运行中) | +| `duration_seconds` | Integer | 运行时长(秒) | +| `cpu_used` | String | CPU 配置(如 "100m") | +| `memory_used` | String | 内存配置(如 "256Mi") | +| `cost` | Numeric | 成本金额 | +| `eu_consumed` | Integer | EU 消耗量 | + +#### 2. ModelBillingRecord(模型计费记录) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `tenant_id` | GUID | 租户ID | +| `litellm_call_id` | String | LiteLLM 调用ID(唯一) | +| `model_name` | String | 模型名称 | +| `input_tokens` | Integer | 输入 Token 数 | +| `output_tokens` | Integer | 输出 Token 数 | +| `total_tokens` | Integer | 总 Token 数 | +| `total_cost` | Numeric | 总成本 | +| `eu_consumed` | Numeric | EU 消耗量 | + +#### 3. Balance(用户余额) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `user_id` | GUID | 用户ID(唯一) | +| `eu_balance` | Numeric(15,2) | EU 余额 | + +--- + +## 🔴 严重问题清单 + +### 问题 1:双重计费风险 ⚠️⚠️⚠️ + +**严重程度**: 🔴 高 +**影响**: 用户可能为同一服务支付两次费用 + +#### 问题描述 + +当用户启动 Agent 并通过 Agent 调用模型时,系统会从**同一个余额账户**扣款两次: + +``` +用户启动 Agent(平台/自定义) + ├─> Agent Pod 运行 → AgentBillingRecord 按时长计费 ($1.00/小时) + │ └─> 每小时扣款:periodic_billing.py + │ + └─> Agent 调用 LiteLLM 模型 → ModelBillingRecord 按 Token 计费 ($0.02/1K tokens) + └─> 实时扣款:billing_webhook.py (LiteLLM 回调) + +结果:用户余额被扣款两次 ❌ +``` + +#### 代码位置 + +1. **Agent 运行时长计费** + - 文件:`services/mcp-server/app/routes/user.py` + - 行号:L3219-L3232 + ```python + billing_record = AgentBillingRecord( + user_id=user_id, + agent_name=req.name, + start_time=datetime.utcnow(), # 开始计时 + cpu_used=req.cpuRequest, + memory_used=req.memoryRequest, + ) + ``` + +2. **周期性扣款** + - 文件:`services/mcp-server/app/periodic_billing.py` + - 行号:L180-L185 + ```python + cost_increment = cost - previous_cost + success, message = await deduct_balance( + user_id, cost_increment, db, + f"Agent周期计费: {record.agent_name}" + ) + ``` + +3. **模型调用扣款** + - 文件:`services/mcp-server/app/routes/billing_webhook.py` + - 行号:L281-L284 + ```python + old_balance = Decimal(str(balance.eu_balance)) + new_balance = old_balance - Decimal(str(eu_consumed)) + balance.eu_balance = float(new_balance) # 再次扣款 ❌ + ``` + +#### 实际影响评估 + +**如果是设计预期(类似 AWS 双重计费)**: +- ✅ 合理的计费模式(基础设施费用 + 使用费用) +- ❌ 缺少文档说明 +- ❌ 前端未分开展示两种费用 + +**如果不是设计预期**: +- ❌ 用户被过度收费 +- ❌ 可能导致法律纠纷 + +#### 修复建议 + +**方案 A:明确双重计费机制**(推荐) +```markdown +1. 在用户文档中明确说明: + - Agent 基础设施费用:按 CPU/内存/时长计费 + - 模型调用费用:按 Token 消耗计费 + - 总费用 = 基础设施费用 + 模型调用费用 + +2. 前端展示优化: + - 计费明细页面分开显示两种费用 + - 实时显示:当前 Agent 运行成本 + 模型调用成本 +``` + +**方案 B:合并计费** +```python +# 只对实际使用的服务计费 +# 如果 Agent 调用了模型 → 只按 Token 计费 +# 如果 Agent 未调用模型 → 只按时长计费 +``` + +--- + +### 问题 2:周期计费和回调扣款重复 🔥🔥 + +**严重程度**: 🔴 高 +**影响**: 并发场景下可能重复扣款 + +#### 问题描述 + +`periodic_billing.py`(周期任务)和 `billing_webhook.py`(Agent Manager 回调)可能**同时**更新同一条 `AgentBillingRecord`,导致重复扣款。 + +#### 竞争条件示例 + +``` +时间线:正常流程 +00:00 - Agent 启动,创建 billing_record (cost=0) +00:30 - 周期任务运行,计算 cost=30, 扣款 30 +00:35 - Agent Manager 回调,计算 cost=35, 扣款 35-30=5 ✅ +01:00 - 周期任务再次运行,计算 cost=60, 扣款 60-35=25 ✅ +总扣款:30 + 5 + 25 = 60 ✅ + +时间线:竞争条件 +00:30:00 - 周期任务开始执行 +00:30:01 - 周期任务读取 record.cost = 0 +00:30:02 - 周期任务计算 cost_increment = 30 - 0 = 30 +00:30:03 - Agent Manager 回调开始执行 +00:30:04 - 回调读取 record.cost = 0 (周期任务还未 commit) +00:30:05 - 回调计算 cost_increment = 30 - 0 = 30 +00:30:06 - 周期任务执行 deduct_balance(30) +00:30:07 - 回调执行 deduct_balance(30) +00:30:08 - 周期任务 commit (record.cost = 30) +00:30:09 - 回调 commit (record.cost = 30) ❌ 覆盖 +总扣款:30 + 30 = 60 ❌(实际应该扣 30) +``` + +#### 根本原因 + +1. **没有行锁保护** + - 周期任务和回调都直接查询 `AgentBillingRecord` + - 没有使用 `with_for_update()` 行锁 + +2. **更新操作不是原子的** + - 读取 `record.cost` → 计算增量 → 扣款 → 更新 `record.cost` + - 中间有多步操作,可能被打断 + +3. **没有分布式锁** + - 周期任务和回调可能在不同进程/线程中执行 + - PostgreSQL 行锁只能保护同一数据库连接内的事务 + +#### 代码位置 + +1. **周期计费** + - 文件:`services/mcp-server/app/periodic_billing.py` + - 行号:L122-L200 + ```python + result = await db.execute( + select(AgentBillingRecord).where( + AgentBillingRecord.end_time == None + ) + # ❌ 缺少 .with_for_update() + ) + ``` + +2. **Agent Manager 回调** + - 文件:`services/mcp-server/app/routes/billing_webhook.py` + - 行号:L454-L490 + ```python + existing_result = await db.execute( + select(AgentBillingRecord).where( + and_( + AgentBillingRecord.agent_name == callback_data.agentName, + AgentBillingRecord.user_id == callback_data.userId, + AgentBillingRecord.end_time == None + ) + ) + # ❌ 缺少 .with_for_update() + ) + ``` + +#### 修复建议 + +**方案 A:只用周期计费,回调只更新记录**(推荐) +```python +# periodic_billing.py - 保持不变,继续扣款 + +# billing_webhook.py - 只更新记录,不扣款 +if existing_record: + existing_record.end_time = end_time + existing_record.duration_seconds = duration_seconds + existing_record.cost = new_cost # 只更新成本,不扣款 + existing_record.tools_used = callback_data.toolsUsed + existing_record.request_id = callback_data.requestId + # ❌ 删除这里的 deduct_balance 调用 +``` + +**方案 B:只在回调时扣款,周期任务只更新记录** +```python +# periodic_billing.py - 只更新记录,不扣款 +record.duration_seconds = duration_seconds +record.eu_consumed = eu_consumed +record.cost = float(cost) +# ❌ 删除 deduct_balance 调用 + +# billing_webhook.py - 保持不变,继续扣款 +``` + +**方案 C:使用分布式锁(Redis)** +```python +import redis + +async def update_agent_billing_with_lock(agent_name, user_id): + lock_key = f"billing_lock:{user_id}:{agent_name}" + lock = redis.lock(lock_key, timeout=30) + + if lock.acquire(blocking=True): + try: + # 获取锁后执行扣款操作 + ... + finally: + lock.release() +``` + +--- + +### 问题 3:配额释放与 Pod 删除顺序错误 💥 + +**严重程度**: 🔴 高 +**影响**: Pod 删除失败时,用户可以免费使用资源 + +#### 问题描述 + +删除 Agent 时,系统先在数据库中标记 Agent 已停止并释放配额,**然后**才调用 Agent Manager 删除 Pod。如果删除 Pod 失败: +- 数据库显示 Agent 已停止 +- 配额已释放 +- Pod 仍在 K8s 中运行 +- 周期计费找不到对应的计费记录(`end_time != None`) + +**结果:用户免费使用资源** ❌ + +#### 错误流程 + +```python +# 当前实现(错误的顺序) +# 文件:services/mcp-server/app/routes/user.py +# 行号:L3330-L3390 + +# 1. 标记结束 +billing_record.end_time = datetime.utcnow() + +# 2. 计算成本并扣款 +cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, duration) +billing_record.cost = float(cost) +success, message = await deduct_balance(user_id, cost, db) + +# 3. 释放配额 +quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) +quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) +quota.agent_count = max(0, (quota.agent_count or 0) - 1) + +# 4. 提交数据库 +await db.commit() + +# 5. 删除 Pod(可能失败!)❌ +await client.delete_agent(agent_full_name) +``` + +#### 失败场景分析 + +``` +场景:Agent Manager 服务宕机或网络问题 + +步骤 1-4:✅ 成功执行 +- billing_record.end_time = "2026-03-09 10:00:00" +- quota.cpu_used 从 5 降到 3 +- 数据库已提交 + +步骤 5:❌ 失败 +- client.delete_agent() 抛出异常 +- Pod 仍在 K8s 中运行 + +后续影响: +- 周期计费任务扫描运行中的 Agent +- 查询条件:end_time == None +- 找不到该 Agent 的计费记录(因为 end_time 已设置) +- Agent 继续运行但不再计费 ❌ +- 用户可以免费使用 CPU 和内存资源 +``` + +#### 代码位置 + +文件:`services/mcp-server/app/routes/user.py` +行号:L3313-L3402 + +```python +@router.delete("/custom-agents/{name}", response_model=SuccessResponse) +async def delete_custom_agent( + name: str, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db) +): + """删除自定义 Agent""" + + user_id = principal.get("user_id") + + # 查找计费记录 + billing_result = await db.execute( + select(AgentBillingRecord).where(...) + ) + billing_record = billing_result.scalar_one_or_none() + + try: + # ❌ 错误:先更新数据库 + billing_record.end_time = datetime.utcnow() + await deduct_balance(user_id, cost, db, ...) + quota.cpu_used -= cpu_released + await db.commit() # ❌ 提交后无法回滚 + + # ❌ 然后才删除 Pod(可能失败) + await client.delete_agent(agent_full_name) + + except AgentManagerError as e: + # ❌ 此时数据库已提交,无法回滚 + raise HTTPException(status_code=e.status_code, ...) +``` + +#### 修复建议 + +**正确顺序**: +```python +@router.delete("/custom-agents/{name}", response_model=SuccessResponse) +async def delete_custom_agent( + name: str, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db) +): + """删除自定义 Agent""" + + user_id = principal.get("user_id") + + # 1. 先查找计费记录 + billing_result = await db.execute( + select(AgentBillingRecord).where(...) + ) + billing_record = billing_result.scalar_one_or_none() + + if not billing_record: + raise HTTPException(404, "Agent 不存在") + + # 2. ✅ 先删除 Pod(如果失败,整个操作终止) + try: + await client.delete_agent(agent_full_name) + except AgentManagerError as e: + logger.error(f"删除 Pod 失败: {e.message}") + raise HTTPException( + status_code=e.status_code, + detail=f"删除 Agent 失败: {e.message}" + ) + + # 3. ✅ Pod 删除成功后,再更新数据库 + billing_record.end_time = datetime.utcnow() + duration = (billing_record.end_time - billing_record.start_time).total_seconds() + billing_record.duration_seconds = int(duration) + + # 4. 计算成本并扣款 + cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, int(duration)) + billing_record.cost = float(cost) + + success, message = await deduct_balance( + user_id, cost, db, + f"自定义Agent使用: {name} (运行{int(duration)}秒)" + ) + if not success: + logger.warning(f"扣款失败: {message}") + + # 5. 释放配额 + quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) + quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) + quota.agent_count = max(0, (quota.agent_count or 0) - 1) + + # 6. 提交数据库 + await db.commit() + + return SuccessResponse( + message=f"自定义 Agent {name} 已删除", + data={"quotaReleased": {"cpu": cpu_released, "memory": memory_released}} + ) +``` + +--- + +### 问题 4:配额更新缺少并发保护 🔒 + +**严重程度**: 🟡 中 +**影响**: 并发操作时配额计算可能出错 + +#### 问题描述 + +创建 Agent 时使用了行锁(`with_for_update()`),但删除 Agent 时没有使用行锁,导致并发删除时可能出现配额计算错误。 + +#### 代码对比 + +**✅ 创建 Agent 时(有行锁)** + +文件:`services/mcp-server/app/routes/user.py` +行号:L3148-L3155 + +```python +# ✅ 有行锁保护 +quota_result = await db.execute( + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 行锁 +) +quota = quota_result.scalar_one_or_none() + +# 安全的更新操作 +quota.cpu_used = cpu_used + cpu_request +quota.memory_used = memory_used + memory_request +quota.agent_count = (quota.agent_count or 0) + 1 +``` + +**❌ 删除 Agent 时(无行锁)** + +文件:`services/mcp-server/app/routes/user.py` +行号:L3358-L3368 + +```python +# ❌ 没有行锁 +quota_result = await db.execute( + select(TenantCustomAgentQuota).where( + TenantCustomAgentQuota.tenant_id == user_id + ) + # ❌ 缺少 .with_for_update() +) +quota = quota_result.scalar_one_or_none() + +# 不安全的更新操作 +quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) +quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) +quota.agent_count = max(0, (quota.agent_count or 0) - 1) +``` + +#### 并发问题示例 + +``` +初始状态:quota.cpu_used = 5 + +线程 1:删除 Agent A(释放 2 CPU) + 1. 读取 cpu_used = 5 + 2. 计算 new_value = 5 - 2 = 3 + +线程 2:删除 Agent B(释放 2 CPU) + 1. 读取 cpu_used = 5(线程1还未 commit) + 2. 计算 new_value = 5 - 2 = 3 + +线程 1:写入 cpu_used = 3 +线程 2:写入 cpu_used = 3 ❌ + +最终结果:cpu_used = 3 +正确结果:cpu_used = 1 (5 - 2 - 2) +丢失更新:2 CPU +``` + +#### 修复建议 + +```python +# 删除 Agent 时也使用行锁 +quota_result = await db.execute( + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁 +) +quota = quota_result.scalar_one_or_none() + +if quota: + quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) + quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) + quota.agent_count = max(0, (quota.agent_count or 0) - 1) +``` + +--- + +### 问题 5:余额数据双重存储 💾 + +**严重程度**: 🟡 中 +**影响**: 数据不一致,可能显示错误余额 + +#### 问题描述 + +系统中同时存在两个余额字段: +- `User.eu_balance` - 标记为废弃但未删除 +- `Balance.eu_balance` - 当前使用的字段 + +代码只更新 `Balance` 表,导致 `User.eu_balance` 永远是旧数据。 + +#### 代码位置 + +文件:`services/mcp-server/models.py` +行号:L85-L88, L518-L527 + +```python +class User(BaseModel, Base): + __tablename__ = "users" + + credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度 + + # ❌ 废弃但未删除 + eu_balance = Column(sa.Numeric(15, 2), default=0) + # 注释:[DEPRECATED - 使用 Balance 表] + + balance = Column(sa.Numeric(12, 2), default=0) # [DEPRECATED] + +class Balance(BaseModel, Base): + """用户余额模型(当前使用)""" + __tablename__ = "balances" + + user_id = Column(GUID(), ForeignKey("users.id"), nullable=False, unique=True) + eu_balance = Column(sa.Numeric(15, 2), nullable=False, default=0) +``` + +#### 潜在问题 + +1. **数据不一致** + ```python + # 所有代码都更新 Balance 表 + balance.eu_balance = new_balance + + # 但 User.eu_balance 不会更新 + # 如果有代码查询 User.eu_balance → 显示错误余额 + ``` + +2. **存储浪费** + - `User.eu_balance` 占用空间但不使用 + - 每个用户浪费 15 bytes + +3. **维护困难** + - 新开发者可能不知道哪个字段是最新的 + - 可能误用废弃字段 + +#### 修复建议 + +**步骤 1:确认没有代码使用 `User.eu_balance`** +```bash +# 搜索是否有代码读取该字段 +cd services/mcp-server +grep -r "user\.eu_balance" . +grep -r "User\.eu_balance" . +``` + +**步骤 2:创建数据库迁移脚本** +```python +# migrations/remove_deprecated_balance_fields.py +from alembic import op +import sqlalchemy as sa + +def upgrade(): + # 删除废弃字段 + op.drop_column('users', 'eu_balance') + op.drop_column('users', 'balance') + +def downgrade(): + # 回滚(如果需要) + op.add_column('users', + sa.Column('eu_balance', sa.Numeric(15, 2), default=0)) + op.add_column('users', + sa.Column('balance', sa.Numeric(12, 2), default=0)) +``` + +**步骤 3:更新模型** +```python +class User(BaseModel, Base): + __tablename__ = "users" + + credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度 + # ✅ 删除废弃字段 +``` + +--- + +### 问题 6:计费记录 start_time 可能为空 🕐 + +**严重程度**: 🟡 中 +**影响**: 部分 Agent 可能不计费 + +#### 问题描述 + +周期计费任务会跳过 `start_time` 为 `None` 的记录,导致这些 Agent 免费运行。 + +#### 代码位置 + +文件:`services/mcp-server/app/periodic_billing.py` +行号:L132-L138 + +```python +for record in running_agents: + try: + # 跳过没有开始时间的记录 + if record.start_time is None: + logger.warning(f"Agent {record.agent_name} 缺少 start_time,跳过计费") + stats["failed"] += 1 + stats["errors"].append(f"Agent {record.agent_name}: start_time 为空") + continue # ❌ 跳过计费 → Agent 免费运行 +``` + +#### 根本原因 + +创建 `AgentBillingRecord` 时可能没有设置 `start_time`。 + +#### 已检查的代码位置 + +✅ 以下位置都正确设置了 `start_time`: +- `services/mcp-server/app/routes/user.py:1428` - 平台 Agent +- `services/mcp-server/app/routes/user.py:2483` - 平台 Agent(use接口) +- `services/mcp-server/app/routes/user.py:3232` - 自定义 Agent + +⚠️ 潜在问题: +- `billing_webhook.py` 创建兜底记录时可能没有 `start_time` + +#### 修复建议 + +**方案 A:确保所有创建路径都设置 start_time** +```python +# billing_webhook.py - 创建兜底记录时 +billing_record = AgentBillingRecord( + user_id=callback_data.userId, + agent_name=callback_data.agentName, + start_time=start_time or datetime.utcnow(), # ✅ 确保有值 + end_time=end_time or datetime.utcnow(), + ... +) +``` + +**方案 B:数据库约束** +```python +# models.py +class AgentBillingRecord(BaseModel, Base): + start_time = Column(DateTime, nullable=False) # ✅ 不允许为空 +``` + +**方案 C:周期任务中补救** +```python +# periodic_billing.py +if record.start_time is None: + # 使用创建时间作为开始时间 + record.start_time = record.created_at + logger.warning(f"修复 Agent {record.agent_name} 的 start_time") +``` + +--- + +### 问题 7:余额不足时的处理不一致 💰 + +**严重程度**: 🟡 中 +**影响**: 用户体验不一致,可能透支 + +#### 问题描述 + +不同场景下对余额不足的处理方式不一致: +- 创建 Agent:只记录日志,Agent 继续运行 +- 周期计费:只记录警告,继续计费 +- 模型调用:只记录警告,不阻止调用 + +#### 代码位置 + +**1. 创建计费记录时** + +文件:`services/mcp-server/app/billing.py` +行号:L352-L356 + +```python +success, message = await deduct_balance(tenant_id, cost, db) +if not success: + # ❌ 只是跳过,Agent 继续运行 + pass +``` + +**2. 周期计费时** + +文件:`services/mcp-server/app/periodic_billing.py` +行号:L185-L191 + +```python +success, message = await deduct_balance( + str(record.user_id), cost_increment, db, + f"Agent周期计费: {record.agent_name}" +) +if not success: + logger.warning( + f"周期计费扣款失败: {message}, " + f"用户: {record.user_id}, Agent: {record.agent_name}" + ) + # ❌ 只记录警告,不停止 Agent +``` + +**3. 模型调用时** + +文件:`services/mcp-server/app/routes/billing_webhook.py` +行号:L299-L305 + +```python +if new_balance < 0: + logger.warning( + f"⚠️ 用户余额不足: user_id={tenant_id}, " + f"balance={new_balance:.4f}, " + f"建议充值" + ) + # ❌ 只警告,继续记录,允许透支 +``` + +#### 行业最佳实践对比 + +| 场景 | AWS | 阿里云 | 当前系统 | +|------|-----|--------|----------| +| 创建资源 | ✅ 预检查余额,不足拒绝 | ✅ 预检查余额,不足拒绝 | ❌ 不检查,直接创建 | +| 运行中透支 | ✅ 暂停/停止资源 | ✅ 暂停资源 | ❌ 继续运行 | +| API 调用透支 | ✅ 拒绝请求 | ✅ 拒绝请求 | ❌ 允许透支 | + +#### 修复建议 + +**创建 Agent 前检查余额** +```python +@router.post("/custom-agents/create-with-tools") +async def create_custom_agent_with_tools(...): + # ✅ 预检查余额 + estimated_cost = estimate_agent_cost( + cpu=req.cpuRequest, + memory=req.memoryRequest, + duration=3600 # 预估至少运行1小时 + ) + + balance, credit_limit, available = await get_available_balance(user_id, db) + + if available < estimated_cost: + raise HTTPException( + status_code=403, + detail=f"余额不足,无法创建 Agent。" + f"预估成本: ${estimated_cost:.2f}, " + f"可用余额: ${available:.2f}," + f"请先充值" + ) + + # 继续创建 Agent + ... +``` + +**周期计费余额不足时停止 Agent** +```python +# periodic_billing.py +success, message = await deduct_balance(user_id, cost_increment, db) + +if not success: + logger.warning(f"周期计费扣款失败: {message}") + + # ✅ 检查是否透支 + balance, credit_limit, available = await get_available_balance(user_id, db) + + if available < 0: + # ✅ 余额不足,停止该用户所有 Agent + logger.warning(f"用户 {user_id} 余额不足,将停止所有 Agent") + stopped = await stop_user_agents(str(record.user_id), db) + stats["stopped_agents"].extend(stopped) +``` + +**模型调用余额不足时拒绝** +```python +# billing_webhook.py +# 扣款前检查余额 +old_balance = Decimal(str(balance.eu_balance)) + +if old_balance < eu_consumed: + # ✅ 余额不足,不允许透支 + logger.warning(f"用户 {tenant_id} 余额不足,拒绝模型调用") + raise HTTPException( + status_code=403, + detail=f"余额不足,无法调用模型。" + f"所需: {eu_consumed} EU, " + f"可用: {old_balance} EU" + ) + +# 余额充足,继续扣款 +new_balance = old_balance - eu_consumed +balance.eu_balance = float(new_balance) +``` + +--- + +### 问题 8:配额数据"假用量" 📊 ✅ + +**严重程度**: 🟡 中 +**影响**: 配额显示不准确,用户无法创建 Agent +**修复状态**: ✅ 已完成(2026-03-09) + +#### 问题描述 + +历史原因导致配额表中记录了"假用量": +- 配额显示 `cpu_used=5` +- 但 K8s 中没有对应的 Pod 运行 +- 用户无法创建新 Agent(配额不足) +- 实际资源完全空闲 + +#### 根本原因 + +来自代码注释(`fix_fake_quota.py`): + +```python +""" +修复脚本:清理历史假用量数据 + +此脚本用于清理因旧接口(/api/user/tools/generate)产生的假用量数据。 +旧接口会在数据库中创建Agent记录并扣除配额,但不实际部署到K8s, +导致quota表中记录了用量但实际没有Pod运行。 +""" +``` + +#### 证据 + +项目中存在 3 个修复脚本: + +1. **fix_agent_quotas.py** + - 功能:修复 Agent 资源配置和配额记录 + - 说明:配额使用量与实际 Agent 不匹配 + +2. **fix_fake_quota.py** + - 功能:清理假用量数据 + - 说明:统计 `type='custom'` 且 `status='active'` 的真实 Agent + +3. **fix_channel_quota_records.py** + - 功能:修复渠道配额记录 + - 说明:渠道使用旧格式配额但缺少新表记录 + +#### 修复建议 + +**✅ 已实现方案:综合健康检查工具** + +创建了 `check_billing_health.py` 提供完整的健康检查功能: + +```bash +# 完整健康检查(推荐) +python check_billing_health.py + +# 只检查假用量 +python check_billing_health.py --check-quota-only + +# 只检查余额状态 +python check_billing_health.py --check-balance-only + +# 测试自动停止功能 +python check_billing_health.py --test-auto-stop +``` + +**功能特性**: +- ✅ 自动检测配额不一致(假用量) +- ✅ 识别透支用户、余额不足用户、风险用户 +- ✅ 生成健康评分报告(0-100分) +- ✅ 提供自动修复建议 +- ✅ 可集成到监控系统(Prometheus/Grafana) + +**输出示例**: +``` +============================================================ +🔍 开始检查配额一致性(假用量检测) +============================================================ +✅ 所有租户配额数据一致,无假用量问题 + +💰 开始检查用户余额状态 +📊 余额状态汇总: + 总用户数: 150 + 透支用户: 0 + 余额不足: 5 + 风险用户: 2 + +============================================================ +📊 健康检查完成 +============================================================ +健康评分: 100/100 (HEALTHY) +报告保存: /app/logs/billing_health_report.json + +💡 建议: + 无需操作,系统健康 +``` + +**修复假用量流程**: +```bash +# 1. 检测问题 +python check_billing_health.py --check-quota-only + +# 2. 执行修复(会自动备份) +python fix_fake_quota.py + +# 3. 验证修复 +python check_billing_health.py --check-quota-only +``` + +**运维文档**: +详细使用说明请参阅:[计费系统运维工具使用指南](../Docs/计费系统运维工具使用指南.md) + +--- + +**原建议方案(归档)**: + +**临时方案:定期运行修复脚本** ✅ 已包含在健康检查工具中 +```bash +# 每天运行一次修复脚本 +0 2 * * * cd /app && python fix_fake_quota.py +``` + +**长期方案:修复根本原因** +```python +# 废弃旧接口 /api/user/tools/generate +@router.post("/user/tools/generate") +@deprecated(reason="使用新接口 /custom-agents/create-with-tools") +async def generate_tool_agent(...): + raise HTTPException(410, "此接口已废弃,请使用新接口") +``` + +**预防方案:配额一致性检查** +```python +# 创建后台任务,定期检查配额一致性 +async def check_quota_consistency(): + """检查配额与实际 Agent 是否一致""" + + async with AsyncSessionLocal() as db: + # 查询所有租户配额 + quotas = await db.execute(select(TenantCustomAgentQuota)) + + for quota in quotas.scalars(): + # 统计实际运行的 Agent + real_agents = await db.execute( + select(Agent) + .where(Agent.owner_id == quota.tenant_id) + .where(Agent.type == "custom") + .where(Agent.status == "active") + ) + + real_cpu = sum(float(a.cpu or 0) for a in real_agents) + real_memory = sum(float(a.memory or 0) for a in real_agents) + + # 如果不一致,发送告警 + if abs(real_cpu - float(quota.cpu_used)) > 0.1: + logger.warning( + f"配额不一致: 租户={quota.tenant_id}, " + f"配额显示={quota.cpu_used}核, " + f"实际使用={real_cpu}核" + ) +``` + +--- + +## 📋 修复优先级矩阵 + +| 问题 | 严重程度 | 紧急程度 | 修复难度 | 优先级 | +|------|---------|---------|---------|--------| +| 问题1: 双重计费风险 | 🔴 高 | 🔴 高 | 🟡 中 | **P0** | +| 问题2: 重复扣款 | 🔴 高 | 🔴 高 | 🔴 高 | **P0** | +| 问题3: 删除顺序错误 | 🔴 高 | 🔴 高 | 🟢 低 | **P0** | +| 问题4: 并发保护缺失 | 🟡 中 | 🟡 中 | 🟢 低 | **P1** | +| 问题5: 数据双重存储 | 🟡 中 | 🟢 低 | 🟡 中 | **P2** | +| 问题6: start_time 为空 | 🟡 中 | 🟡 中 | 🟢 低 | **P1** | +| 问题7: 余额处理不一致 | 🟡 中 | 🟡 中 | 🟡 中 | **P1** | +| 问题8: 假用量数据 | 🟡 中 | 🟢 低 | 🟢 低 | **P2** | + +--- + +## 🎉 修复完成总结 + +所有 8 个问题已完成修复!系统现在具备: + +✅ **核心功能修复** +- 统一计费逻辑,消除重复扣款 +- 正确的删除顺序,保证数据一致性 +- 完善的并发保护机制 + +✅ **用户保护机制** +- 创建前余额检查 +- 余额不足自动停止 Agent +- 透支保护(授信额度) + +✅ **运维工具完善** +- 自动化健康检查工具 `check_billing_health.py` + - 假用量检测(配额一致性检查) + - 余额状态监控(透支/余额不足/风险用户识别) + - 自动停止功能测试 + - 健康评分报告生成 +- 假用量自动修复工具 `fix_fake_quota.py` +- 配额详情查询工具 `check_quota_data.py` +- 详细的运维文档([计费系统运维工具使用指南](../Docs/计费系统运维工具使用指南.md)) + +✅ **文档完善** +- 计费机制说明文档 +- 运维工具使用指南(包含使用方法、故障排查、最佳实践) +- 故障排查手册 +- 健康检查报告模板 + +--- + +## 🚀 快速修复清单(归档) + +### 第一阶段:核心问题修复(P0) + +#### ✅ 修复 1:统一计费扣款逻辑 + +**目标**:消除重复扣款风险 + +**方案**:周期计费保留,回调只更新记录 + +**文件**:`services/mcp-server/app/routes/billing_webhook.py` + +**修改**: +```python +# 行号:L454-L490 +# 将 billing_webhook.py 中的扣款逻辑改为只更新记录 + +if existing_record: + # 更新现有记录 + previous_cost = Decimal(str(existing_record.cost or 0)) + + existing_record.end_time = end_time or datetime.utcnow() + existing_record.duration_seconds = duration_seconds + existing_record.eu_consumed = eu_consumed + existing_record.cost = float(new_cost) + existing_record.period_end = end_time or datetime.utcnow() + existing_record.tools_used = callback_data.toolsUsed + existing_record.request_id = callback_data.requestId + + billing_record = existing_record + + # ❌ 删除这部分:不再在回调时扣款 + # cost_increment = new_cost - previous_cost + # if cost_increment > 0: + # success, message = await deduct_balance(...) + + logger.info( + f"📝 更新Agent计费记录(不扣款): agent={callback_data.agentName}, " + f"最终成本={new_cost}" + ) +``` + +#### ✅ 修复 2:调整删除 Agent 的顺序 + +**目标**:确保 Pod 删除成功后再更新数据库 + +**文件**:`services/mcp-server/app/routes/user.py` + +**修改**: +```python +# 行号:L3313-L3402 +# 调整 delete_custom_agent 函数的执行顺序 + +@router.delete("/custom-agents/{name}", response_model=SuccessResponse) +async def delete_custom_agent(...): + # 1. 查找计费记录 + billing_record = ... + + # 2. ✅ 先删除 Pod + try: + await client.delete_agent(agent_full_name) + except AgentManagerError as e: + raise HTTPException(status_code=e.status_code, detail=str(e)) + + # 3. ✅ 删除成功后再更新数据库 + billing_record.end_time = datetime.utcnow() + await deduct_balance(user_id, cost, db) + quota.cpu_used -= cpu_released + await db.commit() +``` + +#### ✅ 修复 3:添加并发保护 + +**目标**:所有配额更新操作都加行锁 + +**文件**:`services/mcp-server/app/routes/user.py` + +**修改**: +```python +# 行号:L3358 +# 在删除 Agent 时添加行锁 + +quota_result = await db.execute( + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁 +) +``` + +### 第二阶段:改进和优化(P1) + +#### ✅ 修复 4:确保 start_time 不为空 + +**文件**:`services/mcp-server/app/routes/billing_webhook.py` + +**修改**: +```python +# 创建兜底记录时确保 start_time 有值 +billing_record = AgentBillingRecord( + ... + start_time=start_time or datetime.utcnow(), # ✅ 确保有值 + ... +) +``` + +**文件**:`services/mcp-server/models.py` + +**修改**: +```python +# 数据库约束:start_time 不允许为空 +class AgentBillingRecord(BaseModel, Base): + start_time = Column(DateTime, nullable=False) # ✅ 不允许为空 +``` + +#### ✅ 修复 5:完善余额检查 + +**文件**:`services/mcp-server/app/routes/user.py` + +**修改**: +```python +# 创建 Agent 前检查余额 +@router.post("/custom-agents/create-with-tools") +async def create_custom_agent_with_tools(...): + # ✅ 预估成本 + estimated_cost = estimate_agent_cost( + cpu=req.cpuRequest, + memory=req.memoryRequest, + duration=3600 # 预估1小时 + ) + + # ✅ 检查余额 + balance, credit_limit, available = await get_available_balance(user_id, db) + if available < estimated_cost: + raise HTTPException(403, "余额不足,请先充值") + + # 继续创建 + ... +``` + +### 第三阶段:清理和文档(P2) + +#### ✅ 修复 6:删除废弃字段 + +**文件**:`services/mcp-server/models.py` + +**修改**: +```python +class User(BaseModel, Base): + credit_limit = Column(sa.Numeric(12, 2), default=0) + # ❌ 删除以下废弃字段 + # eu_balance = Column(sa.Numeric(15, 2), default=0) + # balance = Column(sa.Numeric(12, 2), default=0) +``` + +#### ✅ 修复 7:添加文档说明 + +**新建文件**:`Docs/计费机制说明.md` + +**内容**: +```markdown +# 计费机制说明 + +## 费用组成 + +Taiji AI PAD 采用双重计费模式: + +### 1. Agent 基础设施费用 +- 计费对象:Agent Pod 的运行时间和资源配置 +- 计费公式:成本 = CPU 用量 × CPU 单价 + 内存用量 × 内存单价 × 运行时长 +- 扣款时机:每小时自动扣款(周期性计费任务) +- 示例:1 核 + 1GB 内存运行 1 小时 = $1.00 + +### 2. 模型调用费用 +- 计费对象:通过 LiteLLM 调用的模型 Token 消耗 +- 计费公式:成本 = Token 数量 × 模型单价 +- 扣款时机:每次调用后实时扣款 +- 示例:gpt-4 调用 1000 tokens = $0.02 + +### 总费用 +**总费用 = Agent 基础设施费用 + 模型调用费用** + +注意:如果您的 Agent 调用了模型,您需要同时支付两种费用。 +``` + +--- + +## 🧪 测试计划 + +### 单元测试 + +#### 测试 1:重复扣款保护 +```python +async def test_no_duplicate_deduction(): + """测试周期计费和回调不会重复扣款""" + + # 1. 创建 Agent 计费记录 + record = await create_agent_billing_record(...) + + # 2. 模拟周期任务扣款 + await periodic_billing.update_running_agent_billing(db) + balance_after_periodic = await get_balance(user_id) + + # 3. 模拟 Agent Manager 回调 + await billing_webhook.agent_callback(callback_data) + balance_after_callback = await get_balance(user_id) + + # 4. 验证:两次扣款总和等于实际成本 + total_deducted = initial_balance - balance_after_callback + assert total_deducted == expected_cost + assert total_deducted != expected_cost * 2 # 不是双倍 +``` + +#### 测试 2:删除顺序保护 +```python +async def test_delete_agent_rollback_on_pod_failure(): + """测试 Pod 删除失败时不会释放配额""" + + # 1. 创建 Agent + await create_agent(...) + initial_quota = await get_quota(user_id) + + # 2. Mock Agent Manager 删除失败 + with mock.patch("client.delete_agent", side_effect=AgentManagerError): + with pytest.raises(HTTPException): + await delete_custom_agent(agent_name) + + # 3. 验证:配额没有被释放 + current_quota = await get_quota(user_id) + assert current_quota.cpu_used == initial_quota.cpu_used + + # 4. 验证:计费记录仍在运行中 + record = await get_billing_record(agent_name) + assert record.end_time is None +``` + +#### 测试 3:并发配额更新 +```python +async def test_concurrent_quota_updates(): + """测试并发删除 Agent 时配额计算正确""" + + # 1. 创建 2 个 Agent,每个 2 CPU + await create_agent(name="agent1", cpu=2) + await create_agent(name="agent2", cpu=2) + + initial_quota = await get_quota(user_id) + assert initial_quota.cpu_used == 4 + + # 2. 并发删除两个 Agent + await asyncio.gather( + delete_custom_agent("agent1"), + delete_custom_agent("agent2") + ) + + # 3. 验证:配额正确减少 + final_quota = await get_quota(user_id) + assert final_quota.cpu_used == 0 # 不是 2 +``` + +### 集成测试 + +#### 测试 4:端到端计费流程 +```python +async def test_end_to_end_billing(): + """测试完整的计费流程""" + + # 1. 充值 + await recharge(user_id, amount=100) + initial_balance = await get_balance(user_id) + + # 2. 创建 Agent + await create_agent(cpu=1, memory=1) + + # 3. 运行 1 小时 + await asyncio.sleep(3600) + + # 4. Agent 调用模型 + await call_litellm_model(tokens=1000) + + # 5. 删除 Agent + await delete_agent() + + # 6. 验证:总扣款 = Pod 费用 + Token 费用 + final_balance = await get_balance(user_id) + total_cost = initial_balance - final_balance + + expected_pod_cost = 1.0 # 1核1G运行1小时 + expected_token_cost = 0.02 # 1000 tokens + expected_total = expected_pod_cost + expected_token_cost + + assert abs(total_cost - expected_total) < 0.01 +``` + +### 压力测试 + +#### 测试 5:高并发计费 +```python +async def test_high_concurrency_billing(): + """测试高并发场景下计费的正确性""" + + # 1. 模拟 100 个用户同时创建 Agent + tasks = [] + for i in range(100): + tasks.append(create_agent(user_id=f"user_{i}", ...)) + + await asyncio.gather(*tasks) + + # 2. 模拟 1000 次并发模型调用 + tasks = [] + for i in range(1000): + tasks.append(call_model(user_id=random.choice(users), ...)) + + await asyncio.gather(*tasks) + + # 3. 验证:所有计费记录都已创建 + agent_records = await count_agent_billing_records() + model_records = await count_model_billing_records() + + assert agent_records == 100 + assert model_records == 1000 + + # 4. 验证:余额计算正确(无重复扣款) + total_balance = await sum_all_balances() + total_cost = await sum_all_costs() + + assert abs(total_balance - (initial_total - total_cost)) < 1.0 +``` + +--- + +## 📈 监控和告警 + +### 关键指标 + +#### 1. 扣款失败率 +```python +# Prometheus metrics +billing_deduction_failures = Counter( + 'billing_deduction_failures_total', + 'Total number of billing deduction failures', + ['reason'] +) + +# 告警规则 +rate(billing_deduction_failures_total[5m]) > 0.01 +``` + +#### 2. 配额不一致率 +```python +quota_inconsistency = Gauge( + 'quota_inconsistency_count', + 'Number of tenants with quota inconsistency', + ['tenant_id'] +) + +# 告警规则 +quota_inconsistency_count > 0 +``` + +#### 3. 计费记录异常 +```python +billing_record_anomalies = Counter( + 'billing_record_anomalies_total', + 'Billing records with anomalies', + ['type'] # missing_start_time, negative_cost, etc. +) + +# 告警规则 +billing_record_anomalies_total{type="missing_start_time"} > 0 +``` + +### 日志监控 + +#### 关键日志模式 +```python +# 1. 重复扣款检测 +logger.warning("可能的重复扣款: user_id=%s, agent=%s, cost=%s", ...) + +# 2. 配额不一致检测 +logger.error("配额不一致: quota.cpu_used=%s, actual=%s", ...) + +# 3. 余额不足但继续运行 +logger.critical("余额不足但Agent继续运行: user_id=%s, balance=%s", ...) +``` + +### 告警配置(Prometheus + Alertmanager) + +```yaml +groups: + - name: billing_alerts + rules: + # 告警 1:扣款失败率过高 + - alert: HighBillingFailureRate + expr: rate(billing_deduction_failures_total[5m]) > 0.01 + for: 5m + labels: + severity: critical + annotations: + summary: "扣款失败率过高" + description: "过去5分钟扣款失败率 {{ $value | humanizePercentage }}" + + # 告警 2:配额不一致 + - alert: QuotaInconsistency + expr: quota_inconsistency_count > 0 + for: 1m + labels: + severity: warning + annotations: + summary: "检测到配额不一致" + description: "{{ $value }} 个租户存在配额不一致" + + # 告警 3:计费记录异常 + - alert: BillingRecordAnomaly + expr: increase(billing_record_anomalies_total[5m]) > 5 + for: 1m + labels: + severity: warning + annotations: + summary: "计费记录异常增加" + description: "异常类型: {{ $labels.type }}, 数量: {{ $value }}" +``` + +--- + +## 📝 修复进度跟踪 + +| 修复项 | 优先级 | 状态 | 完成时间 | +|--------|--------|------|----------| +| 1. 统一计费扣款逻辑 | P0 | ✅ 已完成 | 2026-03-09 | +| 2. 调整删除顺序 | P0 | ✅ 已完成 | 2026-03-09 | +| 3. 添加并发保护 | P0 | ✅ 已完成 | 2026-03-09 | +| 4. start_time 非空约束 | P1 | ✅ 已完成 | 2026-03-09 | +| 5. 完善余额检查 | P1 | ✅ 已完成 | 2026-03-09 | +| 6. 删除废弃字段 | P2 | ✅ 已完成 | 2026-03-09 | +| 7. 添加计费文档 | P2 | ✅ 已完成 | 2026-03-09 | +| 8. 配额一致性检查工具 | P2 | ✅ 已完成 | 2026-03-09 | + +**附加完成项**: +- ✅ 创建综合健康检查工具 `check_billing_health.py` +- ✅ 编写运维工具使用指南 `/Docs/计费系统运维工具使用指南.md` +- ✅ 提供完整的故障排查流程 +- ✅ 建立最佳实践指南 + +--- + +## 🔗 相关文档 + +- [计费与资源平面对接文档](../Docs/项目文档/计费与资源平面对接文档.md) +- [LiteLLM和AgentManager回调接口文档](../Docs/项目文档/LiteLLM和AgentManager回调接口文档.md) +- [数据库迁移指南](../Docs/数据库迁移指南-postgres到taiji_prod.md) +- **[计费系统运维工具使用指南](../Docs/计费系统运维工具使用指南.md)** ⭐️ 新增 + +--- + +## 📞 联系方式 + +如有疑问或需要讨论,请联系: +- 技术负责人:[待填写] +- 邮件:[待填写] +- Slack:#taiji-ai-pad-dev + +--- + +**文档版本**: v1.0.0 +**最后更新**: 2026-03-09 +**审核状态**: 待审核 diff --git a/plans/计费系统问题验证报告.md b/plans/计费系统问题验证报告.md new file mode 100644 index 0000000..c258528 --- /dev/null +++ b/plans/计费系统问题验证报告.md @@ -0,0 +1,733 @@ +# mcp-server 计费系统问题验证报告 + +> **验证日期**: 2026-03-09 +> **验证人**: AI Assistant +> **基于文档**: [计费系统问题分析和修复方案.md](./计费系统问题分析和修复方案.md) + +--- + +## 📊 执行摘要 + +本报告对原分析文档中提到的 8 个问题进行了代码验证。根据用户提供的定价信息: +- **模型推理**: 0.025 EU/call(LLM API调用) +- **VM计算**: 0.5 EU/hour(Firecracker VM运行时) + +**验证结论**: +- ✅ **问题1(双重计费)不是问题** - 这是设计上的双重收费,符合业务模型 +- 🔴 **问题2(重复扣款)确实存在** - 周期计费和回调可能重复扣款 +- 🔴 **问题3(删除顺序错误)确实存在** - 先释放配额再删除Pod,可能导致免费使用 +- 🔴 **问题4(缺少行锁)确实存在** - 创建/删除Agent时配额更新没有并发保护 +- ✅ **问题5(双重存储)已标注废弃** - User.eu_balance已标记废弃,代码未使用 +- 🔴 **问题6(start_time为空)存在风险** - 周期计费会跳过start_time为空的记录 +- 🟡 **问题7(余额处理不一致)部分正确** - 已有透支停止机制,但创建时未预检查 +- ✅ **问题8(假用量)已有修复脚本** - 历史问题,已准备修复脚本 + +--- + +## 🔍 详细验证结果 + +### ✅ 问题1:双重计费风险 - **非问题(设计如此)** + +**原文档观点**: +> 用户启动 Agent 并通过 Agent 调用模型时,系统会从同一个余额账户扣款两次 + +**验证结果**:✅ **这是设计上的双重收费,符合业务模型** + +**理由**: +根据用户提供的定价信息,系统确实应该收取两部分费用: + +1. **VM计算费用**: 0.5 EU/hour - 按 Agent Pod 的运行时间计费 + - 代码位置: `periodic_billing.py:L180-L185` + - 计费逻辑: 每小时扫描运行中的 Agent,计算增量成本并扣款 + +2. **模型推理费用**: 0.025 EU/call - 按 LiteLLM 调用次数计费 + - 代码位置: `billing_webhook.py:L281-L284` + - 计费逻辑: LiteLLM 回调后实时扣款 + +这类似于云服务商的计费模式(如 AWS Lambda = 执行时长费用 + API Gateway 调用费用)。 + +**建议**: +- ✅ 保持当前双重计费机制 +- ⚠️ 需要在用户文档和前端明确说明「总费用 = VM计算费用 + 模型推理费用」 +- ⚠️ 前端计费明细页面应分开展示两种费用 + +--- + +### 🔴 问题2:周期计费和回调扣款重复 - **确实存在** + +**原文档观点**: +> `periodic_billing.py`(周期任务)和 `billing_webhook.py`(Agent Manager回调)可能同时更新同一条记录,导致重复扣款 + +**验证结果**:🔴 **确实存在并发竞争风险** + +**代码验证**: + +1. **周期计费**(`periodic_billing.py:L122-L200`) +```python +# ❌ 没有行锁 +result = await db.execute( + select(AgentBillingRecord).where( + AgentBillingRecord.end_time == None + ) + # 缺少 .with_for_update() +) + +# 计算增量并扣款 +previous_cost = Decimal(str(record.cost or 0)) +cost_increment = cost - previous_cost +if cost_increment > 0: + success, message = await deduct_balance( + str(record.user_id), cost_increment, db, + f"Agent周期计费: {record.agent_name}" + ) +``` + +2. **Agent Manager回调**(`billing_webhook.py:L454-L490`) +```python +# ❌ 没有行锁 +existing_result = await db.execute( + select(AgentBillingRecord).where( + and_( + AgentBillingRecord.agent_name == callback_data.agentName, + AgentBillingRecord.user_id == callback_data.userId, + AgentBillingRecord.end_time == None + ) + ) + # 缺少 .with_for_update() +) + +if existing_record: + # 计算增量并扣款 + previous_cost = Decimal(str(existing_record.cost or 0)) + cost_increment = new_cost - previous_cost + if cost_increment > 0: + success, message = await deduct_balance( + callback_data.userId, + cost_increment, + db, + f"Agent 结算(增量): {callback_data.agentName}" + ) +``` + +**问题分析**: +两个函数都: +1. 读取 `record.cost`(旧值) +2. 计算增量 `cost_increment = new_cost - old_cost` +3. 扣款 +4. 更新 `record.cost`(新值) + +如果两者同时执行,可能出现: +``` +时间线:竞争条件 +00:30:00 - 周期任务读取 record.cost = 10 +00:30:01 - 回调读取 record.cost = 10(周期任务还未commit) +00:30:02 - 周期任务计算增量 = 15 - 10 = 5,扣款5 +00:30:03 - 回调计算增量 = 15 - 10 = 5,扣款5 +结果:扣了10,但实际应该扣5 +``` + +**建议修复方案**(推荐): +```python +# 方案:只在周期计费时扣款,回调只更新记录状态 + +# billing_webhook.py - 只更新记录,不扣款 +if existing_record: + existing_record.end_time = end_time or datetime.utcnow() + existing_record.duration_seconds = duration_seconds + existing_record.cost = float(new_cost) # 只更新成本 + existing_record.tools_used = callback_data.toolsUsed + # ❌ 删除增量扣款逻辑 +``` + +--- + +### 🔴 问题3:配额释放与Pod删除顺序错误 - **确实存在** + +**原文档观点**: +> 删除 Agent 时,系统先在数据库中释放配额并提交,然后才删除 Pod。如果删除 Pod 失败,用户可以免费使用资源。 + +**验证结果**:🔴 **确实存在,且非常严重** + +**代码验证**(`user.py:L3313-L3402`): + +```python +@router.delete("/custom-agents/{name}", response_model=SuccessResponse) +async def delete_custom_agent(...): + # 1. 查找计费记录 + billing_record = ... + + try: + # 2. ❌ 先更新数据库 + billing_record.end_time = datetime.utcnow() + billing_record.cost = float(cost) + + # 3. ❌ 扣款 + await deduct_balance(user_id, cost, db, ...) + + # 4. ❌ 释放配额 + quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) + quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) + quota.agent_count = max(0, (quota.agent_count or 0) - 1) + + # 5. ❌ 提交数据库(此时无法回滚) + await db.commit() + + # 6. ❌ 然后才删除 Pod(可能失败!) + client = get_agent_manager_client() + await client.delete_agent(agent_full_name) +``` + +**失败场景**: +``` +步骤 1-5:✅ 成功执行 +- billing_record.end_time = "2026-03-09 10:00:00" +- quota.cpu_used 从 5 降到 3 +- 数据库已提交(无法回滚) + +步骤 6:❌ 失败(Agent Manager 宕机/网络问题) +- Pod 仍在 K8s 中运行 + +后续影响: +- 周期计费查询条件: end_time == None +- 找不到该 Agent(end_time 已设置) +- Agent 继续运行但不再计费 ❌ +- 用户免费使用资源 ❌ +``` + +**代码还有注释承认了这个问题**: +```python +except Exception as agent_delete_error: + logger.error(f"Agent Manager 删除失败: {agent_full_name}, 错误: {str(agent_delete_error)}") + # 注意:配额已释放,但 Agent 可能未删除 + # 可以考虑标记为"待清理"状态,供后台任务处理 +``` + +**建议修复**: +```python +@router.delete("/custom-agents/{name}", response_model=SuccessResponse) +async def delete_custom_agent(...): + # 1. 查找计费记录 + billing_record = ... + + # 2. ✅ 先删除 Pod(如果失败,整个操作终止) + try: + await client.delete_agent(agent_full_name) + except AgentManagerError as e: + logger.error(f"删除 Pod 失败: {e.message}") + raise HTTPException(status_code=e.status_code, detail=str(e)) + + # 3. ✅ Pod 删除成功后,再更新数据库 + billing_record.end_time = datetime.utcnow() + billing_record.cost = float(cost) + await deduct_balance(user_id, cost, db, ...) + quota.cpu_used -= cpu_released + await db.commit() +``` + +--- + +### 🔴 问题4:配额更新缺少并发保护 - **确实存在** + +**原文档观点**: +> 创建 Agent 时使用了行锁,但删除 Agent 时没有使用行锁 + +**验证结果**:🔴 **部分正确,实际上创建和删除都没有行锁** + +**代码验证**: + +1. **检查整个 user.py 中 with_for_update 的使用**: +```bash +grep "with_for_update" services/mcp-server/app/routes/user.py +``` +结果:只有 3 处使用(L1302, L2307, L2552),都是在**平台 Agent** 的配额查询中。 + +2. **创建自定义 Agent 时的配额查询**(`user.py:L2893`): +```python +# ❌ 没有行锁 +quota_result = await db.execute( + select(TenantCustomAgentQuota).where( + TenantCustomAgentQuota.tenant_id == user_id + ) + # 缺少 .with_for_update() +) + +# 更新配额(不安全) +quota.cpu_used = cpu_used + cpu_request +quota.memory_used = memory_used + memory_request +quota.agent_count = (quota.agent_count or 0) + 1 +``` + +3. **删除自定义 Agent 时的配额查询**(`user.py:L3359`): +```python +# ❌ 没有行锁 +quota_result = await db.execute( + select(TenantCustomAgentQuota).where( + TenantCustomAgentQuota.tenant_id == user_id + ) + # 缺少 .with_for_update() +) + +# 更新配额(不安全) +quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) +quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) +quota.agent_count = max(0, (quota.agent_count or 0) - 1) +``` + +**并发问题示例**: +``` +初始状态:quota.cpu_used = 5 + +并发场景:两个请求同时删除不同的 Agent +线程1: 删除 Agent A(释放 2 CPU) + 1. 读取 cpu_used = 5 + 2. 计算 new_value = 5 - 2 = 3 + +线程2: 删除 Agent B(释放 2 CPU) + 1. 读取 cpu_used = 5(线程1还未 commit) + 2. 计算 new_value = 5 - 2 = 3 + +线程1: 写入 cpu_used = 3 +线程2: 写入 cpu_used = 3 ❌ + +最终结果:cpu_used = 3 +正确结果:cpu_used = 1 (5 - 2 - 2) +丢失更新:2 CPU +``` + +**建议修复**: +```python +# 所有配额更新操作都加行锁 +quota_result = await db.execute( + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁 +) +``` + +--- + +### ✅ 问题5:余额数据双重存储 - **已标注废弃,代码未使用** + +**原文档观点**: +> 系统中同时存在两个余额字段:User.eu_balance(标记为废弃)和 Balance.eu_balance(当前使用) + +**验证结果**:✅ **已标注废弃,代码未实际使用** + +**代码验证**: + +1. **models.py:L85-L88** +```python +class User(BaseModel, Base): + # EU计费(执行单元) + eu_balance = Column(sa.Numeric(15, 2), default=0) # EU余额 [DEPRECATED - 使用 Balance 表] + total_eu_consumed = Column(sa.Numeric(15, 2), default=0) # 总EU消耗 +``` + +2. **搜索是否有代码使用 User.eu_balance**: +```bash +grep -r "User\.eu_balance\|user\.eu_balance" services/mcp-server/ +``` +结果:只在 `billing.py:L29` 有一条注释: +```python +# 注意:User.eu_balance 和 User.balance 字段已废弃,请使用 Balance 表 +``` + +**结论**: +- ✅ 字段已标记废弃 +- ✅ 代码未使用该字段 +- ⚠️ 但字段仍存在数据库中,占用存储空间 + +**建议**: +```python +# 后续可通过数据库迁移删除(非紧急) +def upgrade(): + op.drop_column('users', 'eu_balance') + op.drop_column('users', 'balance') +``` + +--- + +### 🔴 问题6:计费记录 start_time 可能为空 - **存在风险** + +**原文档观点**: +> 周期计费任务会跳过 start_time 为 None 的记录,导致这些 Agent 免费运行 + +**验证结果**:🔴 **代码确实会跳过 start_time 为空的记录** + +**代码验证**(`periodic_billing.py:L132-L138`): +```python +for record in running_agents: + try: + # 跳过没有开始时间的记录 + if record.start_time is None: + logger.warning(f"Agent {record.agent_name} 缺少 start_time,跳过计费") + stats["failed"] += 1 + stats["errors"].append(f"Agent {record.agent_name}: start_time 为空") + continue # ❌ 跳过计费 → Agent 免费运行 +``` + +**潜在问题代码**: +在 `billing_webhook.py:L500-L510` 创建兜底记录时: +```python +billing_record = AgentBillingRecord( + user_id=callback_data.userId, + channel_id=user.channel_id if user.channel_id else None, + agent_name=callback_data.agentName, + agent_type=agent_type, + is_platform_agent=is_platform_agent, + duration_seconds=duration_seconds, + eu_consumed=eu_consumed, + cost=float(new_cost), + start_time=start_time or datetime.utcnow(), # ✅ 有兜底值 + end_time=end_time or datetime.utcnow(), + ... +) +``` + +好消息:代码中使用了 `start_time or datetime.utcnow()`,理论上不会为空。 + +**建议**: +```python +# 1. 数据库约束:确保 start_time 不为空 +class AgentBillingRecord(BaseModel, Base): + start_time = Column(DateTime, nullable=False) # ✅ 不允许为空 + +# 2. 周期任务中修复而非跳过 +if record.start_time is None: + # 使用创建时间作为开始时间 + record.start_time = record.created_at or datetime.utcnow() + logger.warning(f"修复 Agent {record.agent_name} 的 start_time") + # 继续计费,而不是跳过 +``` + +--- + +### 🟡 问题7:余额不足时的处理不一致 - **部分正确** + +**原文档观点**: +> 不同场景下对余额不足的处理方式不一致:创建Agent不检查、周期计费只警告、模型调用允许透支 + +**验证结果**:🟡 **部分正确,已有停止机制,但创建时未预检查** + +**代码验证**: + +1. **创建 Agent 时**(无预检查) + - 未找到余额预检查代码 + - Agent 可以在余额不足时创建 + +2. **周期计费时**(`periodic_billing.py:L185-L200`): +```python +success, message = await deduct_balance( + str(record.user_id), cost_increment, db, + f"Agent周期计费: {record.agent_name}" +) + +if not success: + logger.warning(f"周期计费扣款失败: {message}") + +# ✅ 检查是否透支,如果透支则停止所有Agent +balance, credit_limit, available = await get_available_balance( + str(record.user_id), db +) +if available < 0: + logger.warning( + f"用户 {record.user_id} 余额不足 (可用: {available}),将停止所有Agent" + ) + stopped = await stop_user_agents(str(record.user_id), db) + stats["stopped_agents"].extend(stopped) +``` + +好消息:**周期计费已经实现了余额不足停止机制!** + +3. **模型调用时**(`billing_webhook.py:L299-L305`): +```python +# 扣减EU余额 +old_balance = Decimal(str(balance.eu_balance)) +new_balance = old_balance - Decimal(str(eu_consumed)) +balance.eu_balance = float(new_balance) + +# 余额不足警告(但不阻止记录) +if new_balance < 0: + logger.warning( + f"⚠️ 用户余额不足: user_id={tenant_id}, " + f"balance={new_balance:.4f}, " + f"建议充值" + ) + # ❌ 只警告,允许透支 +``` + +**建议**: +```python +# 创建 Agent 前预检查余额 +@router.post("/custom-agents/create") +async def create_custom_agent(...): + # ✅ 预估成本 + estimated_cost = estimate_agent_cost( + cpu=req.cpuRequest, + memory=req.memoryRequest, + duration=3600 # 预估至少运行1小时 + ) + + # ✅ 检查余额 + balance, credit_limit, available = await get_available_balance(user_id, db) + if available < estimated_cost: + raise HTTPException(403, "余额不足,无法创建 Agent,请先充值") +``` + +--- + +### ✅ 问题8:配额数据"假用量" - **历史问题,已有修复脚本** + +**原文档观点**: +> 历史原因导致配额表中记录了"假用量":配额显示 cpu_used=5,但 K8s 中没有对应的 Pod 运行 + +**验证结果**:✅ **确实存在历史遗留问题,已准备修复脚本** + +**代码验证**: + +1. **修复脚本存在**: + - `/services/mcp-server/fix_fake_quota.py` ✅ + - `/services/mcp-server/fix_agent_quotas.py` ✅ + - `/services/mcp-server/fix_channel_quota_records.py` ✅ + +2. **fix_fake_quota.py 文件头注释**(L1-L18): +```python +""" +修复脚本:清理历史假用量数据 + +此脚本用于清理因旧接口(/api/user/tools/generate)产生的假用量数据。 +旧接口会在数据库中创建Agent记录并扣除配额,但不实际部署到K8s, +导致quota表中记录了用量但实际没有Pod运行。 + +脚本逻辑: +1. 备份当前quota数据到JSON文件 +2. 遍历所有租户的TenantCustomAgentQuota记录 +3. 对每个租户,重新统计agents表中type='custom'且status='active'的真实运行Agent +4. 更新quota表的cpu_used、memory_used、agent_count为真实值 +5. 生成修复报告 + +使用方法: +在mcp-server容器内运行: +python fix_fake_quota.py + +注意:此脚本会修改数据库,请务必先备份数据库! +""" +``` + +**建议**: +- ✅ 修复脚本已准备好,可以执行 +- ⚠️ 执行前务必备份数据库 +- ⚠️ 废弃旧接口 `/api/user/tools/generate`,防止再次产生假用量 + +--- + +## 📋 问题优先级总结 + +| 问题 | 严重程度 | 是否存在 | 紧急程度 | 推荐优先级 | +|------|---------|---------|---------|----------| +| 问题1: 双重计费风险 | N/A | ❌ 非问题(设计如此) | - | **P3 - 文档优化** | +| **问题2: 重复扣款** | 🔴 高 | ✅ **确实存在** | 🔴 高 | **P0 - 立即修复** | +| **问题3: 删除顺序错误** | 🔴 高 | ✅ **确实存在** | 🔴 高 | **P0 - 立即修复** | +| **问题4: 并发保护缺失** | 🟡 中 | ✅ **确实存在** | 🟡 中 | **P1 - 尽快修复** | +| 问题5: 数据双重存储 | 🟡 中 | ⚠️ 已标记废弃 | 🟢 低 | **P2 - 后续清理** | +| **问题6: start_time 为空** | 🟡 中 | ⚠️ 存在风险但有兜底 | 🟡 中 | **P1 - 添加约束** | +| 问题7: 余额处理不一致 | 🟡 中 | 🟡 部分正确 | 🟡 中 | **P1 - 添加预检查** | +| 问题8: 假用量数据 | 🟡 中 | ✅ 历史问题 | 🟢 低 | **P2 - 执行脚本** | + +--- + +## 🚀 推荐修复计划 + +### 第一阶段:P0 核心问题修复(本周内) + +#### 1. 修复问题2:消除重复扣款风险 + +**文件**: `services/mcp-server/app/routes/billing_webhook.py` + +**修改**: +```python +# 行号:L454-L490 +# 将 billing_webhook.py 中的扣款逻辑改为只更新记录 + +if existing_record: + # 更新现有记录 + existing_record.end_time = end_time or datetime.utcnow() + existing_record.duration_seconds = duration_seconds + existing_record.eu_consumed = eu_consumed + existing_record.cost = float(new_cost) + existing_record.period_end = end_time or datetime.utcnow() + existing_record.tools_used = callback_data.toolsUsed + existing_record.request_id = callback_data.requestId + + billing_record = existing_record + + # ❌ 删除增量扣款逻辑,只在周期计费时扣款 + # cost_increment = new_cost - previous_cost + # if cost_increment > 0: + # success, message = await deduct_balance(...) + + logger.info( + f"📝 更新Agent计费记录(不扣款): agent={callback_data.agentName}, " + f"最终成本={new_cost}" + ) +``` + +#### 2. 修复问题3:调整删除 Agent 的顺序 + +**文件**: `services/mcp-server/app/routes/user.py` + +**修改**: +```python +# 行号:L3313-L3402 +# 调整 delete_custom_agent 函数的执行顺序 + +@router.delete("/custom-agents/{name}", response_model=SuccessResponse) +async def delete_custom_agent(...): + # 1. 查找计费记录 + billing_record = ... + + # 2. ✅ 先删除 Pod(如果失败,整个操作终止) + try: + await client.delete_agent(agent_full_name) + except AgentManagerError as e: + logger.error(f"删除 Pod 失败: {e.message}") + raise HTTPException(status_code=e.status_code, detail=str(e)) + + # 3. ✅ Pod 删除成功后,再更新数据库 + billing_record.end_time = datetime.utcnow() + billing_record.cost = float(cost) + await deduct_balance(user_id, cost, db, ...) + + # 4. ✅ 使用行锁释放配额 + quota_result = await db.execute( + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁 + ) + quota = quota_result.scalar_one_or_none() + if quota: + quota.cpu_used -= cpu_released + quota.memory_used -= memory_released + quota.agent_count -= 1 + + await db.commit() +``` + +### 第二阶段:P1 改进和优化(下周) + +#### 3. 修复问题4:添加并发保护 + +**文件**: `services/mcp-server/app/routes/user.py` + +**修改位置**: +- L2893(创建自定义Agent) +- L3359(删除自定义Agent) +- L3446(扩缩容) + +```python +# 所有配额更新操作都加行锁 +quota_result = await db.execute( + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁 +) +``` + +#### 4. 修复问题6:确保 start_time 不为空 + +**文件**: `services/mcp-server/models.py` + +```python +class AgentBillingRecord(BaseModel, Base): + start_time = Column(DateTime, nullable=False) # ✅ 不允许为空 +``` + +#### 5. 修复问题7:添加余额预检查 + +**文件**: `services/mcp-server/app/routes/user.py` + +```python +@router.post("/custom-agents/create") +async def create_custom_agent(...): + # ✅ 预估成本并检查余额 + estimated_cost = estimate_agent_cost( + cpu=req.cpuRequest, + memory=req.memoryRequest, + duration=3600 # 预估1小时 + ) + + balance, credit_limit, available = await get_available_balance(user_id, db) + if available < estimated_cost: + raise HTTPException(403, "余额不足,请先充值") +``` + +### 第三阶段:P2 清理和文档(后续) + +#### 6. 问题5:删除废弃字段 + +创建数据库迁移脚本删除 User.eu_balance 和 User.balance。 + +#### 7. 问题8:执行假用量修复脚本 + +```bash +# 1. 备份数据库 +pg_dump taiji_prod > backup_before_quota_fix.sql + +# 2. 执行修复脚本 +python services/mcp-server/fix_fake_quota.py +``` + +#### 8. 问题1:完善计费文档 + +创建用户文档,明确说明: +- 总费用 = VM计算费用(0.5 EU/hour)+ 模型推理费用(0.025 EU/call) +- 前端分开展示两种费用 + +--- + +## 📊 测试建议 + +### 单元测试 + +```python +# 测试1:重复扣款保护 +async def test_no_duplicate_deduction(): + """测试周期计费和回调不会重复扣款""" + # 创建Agent → 运行1小时 → 周期扣款 → 回调更新 + # 验证:总扣款 = 实际成本(不是双倍) + +# 测试2:删除顺序保护 +async def test_delete_agent_rollback_on_pod_failure(): + """测试Pod删除失败时不会释放配额""" + # Mock Agent Manager删除失败 + # 验证:配额没有被释放,计费记录仍在运行中 + +# 测试3:并发配额更新 +async def test_concurrent_quota_updates(): + """测试并发删除Agent时配额计算正确""" + # 并发删除2个Agent + # 验证:配额正确减少(不丢失更新) +``` + +--- + +## 📝 总结 + +经过详细的代码验证,原分析文档中提到的 8 个问题中: + +- ✅ **3个确实存在且严重**(问题2、3、4)- 需要立即修复 +- ⚠️ **2个存在风险**(问题6、7)- 需要改进 +- ✅ **1个已有解决方案**(问题8)- 执行修复脚本即可 +- ✅ **1个已标注废弃**(问题5)- 后续清理 +- ❌ **1个非问题**(问题1)- 这是设计上的双重收费 + +**最紧急的修复**: +1. 🔥 消除重复扣款风险(问题2) +2. 🔥 调整删除顺序(问题3) +3. 🔒 添加并发保护(问题4) + +--- + +**文档版本**: v1.0.0 +**最后更新**: 2026-03-09 +**审核状态**: 待审核 diff --git a/postgres_backup.dump b/postgres_backup.dump deleted file mode 100644 index 8d60b304ae29ea8c6779cfc30b5ee7cabe7446ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178434 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 diff --git a/scripts/backup_postgres.sh b/scripts/backup_postgres.sh new file mode 100644 index 0000000..8aa28d9 --- /dev/null +++ b/scripts/backup_postgres.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# 备份 Azure PostgreSQL 数据库脚本 +# +# 使用方法:./backup_postgres.sh [database_name] +# 默认备份数据库:postgres + +set -e + +# 配置 +DB_HOST="taijipda.postgres.database.azure.com" +DB_USER="taiji" +DB_PASSWORD="By@123456." +DB_NAME="${1:-postgres}" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="backup_${DB_NAME}_${TIMESTAMP}.dump" +BACKUP_DIR="./backups" + +echo "==========================================" +echo "Azure PostgreSQL 数据库备份脚本" +echo "==========================================" +echo "服务器: ${DB_HOST}" +echo "数据库: ${DB_NAME}" +echo "备份文件: ${BACKUP_FILE}" +echo "==========================================" + +# 创建备份目录 +mkdir -p "${BACKUP_DIR}" + +# 设置 PGPASSWORD 环境变量避免密码提示 +export PGPASSWORD="${DB_PASSWORD}" + +# 测试数据库连接 +echo "" +echo "Step 1: 测试数据库连接..." +if psql -h "${DB_HOST}" -U "${DB_USER}" -d "${DB_NAME}" -c "SELECT version();" > /dev/null 2>&1; then + echo " ✓ 数据库连接成功" +else + echo " ✗ 数据库连接失败!" + echo " 请检查:" + echo " - 数据库服务器是否可访问" + echo " - 防火墙规则是否允许当前 IP" + echo " - 用户名和密码是否正确" + exit 1 +fi + +# 显示数据库信息 +echo "" +echo "Step 2: 获取数据库信息..." +echo " 数据库大小:" +psql -h "${DB_HOST}" -U "${DB_USER}" -d "${DB_NAME}" -c "SELECT pg_size_pretty(pg_database_size('${DB_NAME}'));" -t + +echo " 表数量:" +TABLE_COUNT=$(psql -h "${DB_HOST}" -U "${DB_USER}" -d "${DB_NAME}" -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public';" -t | tr -d ' ') +echo " ${TABLE_COUNT} 个表" + +if [ "$TABLE_COUNT" -gt 0 ]; then + echo " 表列表:" + psql -h "${DB_HOST}" -U "${DB_USER}" -d "${DB_NAME}" -c "\dt" 2>/dev/null || true +fi + +# 执行备份 +echo "" +echo "Step 3: 开始备份..." +echo " 使用自定义格式 (pg_dump -Fc)" +echo " 备份路径: ${BACKUP_DIR}/${BACKUP_FILE}" + +# 使用 pg_dump 创建备份 +# -Fc: 自定义格式(推荐,支持并行恢复和选择性恢复) +# -v: 详细输出 +# -Z 6: 压缩级别 6(0-9,默认是中等压缩) +pg_dump -h "${DB_HOST}" \ + -U "${DB_USER}" \ + -d "${DB_NAME}" \ + -Fc \ + -v \ + -Z 6 \ + -f "${BACKUP_DIR}/${BACKUP_FILE}" 2>&1 | grep -v "^pg_dump: last built-in OID is" || true + +# 验证备份文件 +echo "" +echo "Step 4: 验证备份文件..." +if [ -f "${BACKUP_DIR}/${BACKUP_FILE}" ]; then + BACKUP_SIZE=$(du -h "${BACKUP_DIR}/${BACKUP_FILE}" | cut -f1) + echo " ✓ 备份文件已创建" + echo " 文件大小: ${BACKUP_SIZE}" + + # 列出备份内容(不恢复) + echo "" + echo " 备份内容预览:" + pg_restore -l "${BACKUP_DIR}/${BACKUP_FILE}" | head -20 + echo " ..." +else + echo " ✗ 备份文件创建失败!" + exit 1 +fi + +# 创建符号链接到最新备份(可选) +echo "" +echo "Step 5: 创建快捷链接..." +LATEST_LINK="${BACKUP_DIR}/latest_${DB_NAME}.dump" +ln -sf "${BACKUP_FILE}" "${LATEST_LINK}" +echo " ✓ 最新备份链接: ${LATEST_LINK}" + +# 也创建一个 SQL 格式的备份(纯文本,便于查看和版本控制) +echo "" +echo "Step 6: 创建 SQL 格式备份(可选)..." +SQL_BACKUP="${BACKUP_DIR}/backup_${DB_NAME}_${TIMESTAMP}.sql" +pg_dump -h "${DB_HOST}" \ + -U "${DB_USER}" \ + -d "${DB_NAME}" \ + --no-owner \ + --no-acl \ + -f "${SQL_BACKUP}" 2>&1 | grep -v "^pg_dump: last built-in OID is" || true + +if [ -f "${SQL_BACKUP}" ]; then + SQL_SIZE=$(du -h "${SQL_BACKUP}" | cut -f1) + echo " ✓ SQL 备份已创建" + echo " 文件大小: ${SQL_SIZE}" + + # 压缩 SQL 文件 + gzip "${SQL_BACKUP}" + GZIP_SIZE=$(du -h "${SQL_BACKUP}.gz" | cut -f1) + echo " ✓ SQL 备份已压缩: ${SQL_BACKUP}.gz (${GZIP_SIZE})" +fi + +# 清除密码环境变量 +unset PGPASSWORD + +# 显示所有备份文件 +echo "" +echo "Step 7: 当前所有备份文件:" +ls -lh "${BACKUP_DIR}/" | grep -E "backup_${DB_NAME}|latest_${DB_NAME}" + +echo "" +echo "==========================================" +echo "备份完成!" +echo "==========================================" +echo "" +echo "备份文件:" +echo " 自定义格式: ${BACKUP_DIR}/${BACKUP_FILE}" +echo " SQL 格式: ${SQL_BACKUP}.gz" +echo " 最新链接: ${LATEST_LINK}" +echo "" +echo "恢复命令:" +echo " pg_restore -h ${DB_HOST} -U ${DB_USER} -d NEW_DB_NAME ${BACKUP_DIR}/${BACKUP_FILE}" +echo "" +echo "或使用 SQL 格式恢复:" +echo " gunzip -c ${SQL_BACKUP}.gz | psql -h ${DB_HOST} -U ${DB_USER} -d NEW_DB_NAME" +echo "" diff --git a/scripts/migrate_to_new_db.sh b/scripts/migrate_to_new_db.sh new file mode 100755 index 0000000..74e29e1 --- /dev/null +++ b/scripts/migrate_to_new_db.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# 数据库迁移脚本:从 postgres 迁移到 taiji_prod +# +# 使用方法:./migrate_to_new_db.sh [new_db_name] +# 默认新数据库名:taiji_prod + +set -e + +# 配置 +DB_HOST="taijipda.postgres.database.azure.com" +DB_USER="taiji" +DB_PASSWORD="By@123456." +OLD_DB="postgres" +NEW_DB="${1:-taiji_prod}" +BACKUP_FILE="postgres_backup.dump" + +echo "==========================================" +echo "数据库迁移脚本" +echo "==========================================" +echo "源数据库: ${OLD_DB}" +echo "目标数据库: ${NEW_DB}" +echo "服务器: ${DB_HOST}" +echo "==========================================" + +# 设置 PGPASSWORD 环境变量避免密码提示 +export PGPASSWORD="${DB_PASSWORD}" + +# 步骤 1: 创建新数据库 +echo "" +echo "Step 1: 创建新数据库 ${NEW_DB}..." +psql -h "${DB_HOST}" -U "${DB_USER}" -d "postgres" -c "CREATE DATABASE ${NEW_DB};" 2>/dev/null || echo "数据库可能已存在,继续..." + +# 步骤 2: 从备份文件恢复数据(如果存在) +if [ -f "${BACKUP_FILE}" ]; then + echo "" + echo "Step 2: 从备份文件恢复数据到 ${NEW_DB}..." + pg_restore -h "${DB_HOST}" -U "${DB_USER}" -d "${NEW_DB}" -v --no-owner --no-acl "${BACKUP_FILE}" || echo "部分恢复可能失败,这是正常的" +else + echo "" + echo "Step 2: 备份文件不存在,直接从 ${OLD_DB} 导出并导入..." + + # 创建临时备份 + TEMP_BACKUP="temp_postgres_$(date +%Y%m%d_%H%M%S).dump" + echo " 创建临时备份: ${TEMP_BACKUP}" + pg_dump -h "${DB_HOST}" -U "${DB_USER}" -d "${OLD_DB}" -Fc -f "${TEMP_BACKUP}" + + echo " 恢复到新数据库..." + pg_restore -h "${DB_HOST}" -U "${DB_USER}" -d "${NEW_DB}" -v --no-owner --no-acl "${TEMP_BACKUP}" || echo "部分恢复可能失败,这是正常的" + + echo " 保留备份文件: ${TEMP_BACKUP}" +fi + +# 步骤 3: 验证数据迁移 +echo "" +echo "Step 3: 验证数据迁移..." +echo " 源数据库表数量:" +psql -h "${DB_HOST}" -U "${DB_USER}" -d "${OLD_DB}" -c "SELECT COUNT(*) as table_count FROM information_schema.tables WHERE table_schema = 'public';" -t + +echo " 目标数据库表数量:" +psql -h "${DB_HOST}" -U "${DB_USER}" -d "${NEW_DB}" -c "SELECT COUNT(*) as table_count FROM information_schema.tables WHERE table_schema = 'public';" -t + +# 步骤 4: 显示新数据库的表列表 +echo "" +echo "Step 4: 新数据库 ${NEW_DB} 的表列表:" +psql -h "${DB_HOST}" -U "${DB_USER}" -d "${NEW_DB}" -c "\dt" || echo "没有表或连接失败" + +# 清除密码环境变量 +unset PGPASSWORD + +echo "" +echo "==========================================" +echo "迁移完成!" +echo "==========================================" +echo "" +echo "下一步操作:" +echo "1. 验证新数据库数据完整性" +echo "2. 更新配置文件中的数据库名称(从 postgres 改为 ${NEW_DB})" +echo "3. 重新部署应用" +echo "4. 确认应用正常运行后,可选择删除旧的 postgres 数据库" +echo "" +echo "更新配置文件的命令:" +echo " ./scripts/update_db_config.sh ${NEW_DB}" +echo "" diff --git a/scripts/test_cre_admins.py b/scripts/test_cre_admins.py deleted file mode 100755 index 090243c..0000000 --- a/scripts/test_cre_admins.py +++ /dev/null @@ -1,392 +0,0 @@ -#!/usr/bin/env python3 -""" -创建管理员账户脚本 -用于创建4个管理员角色: -- 超级管理员 (super_admin) -- 计费管理员 (billing_admin) -- 运维管理员 (operations_admin) -- 渠道管理员 (channel_admin) -""" - -import requests -import sys -import os -import asyncio -from typing import Optional -import bcrypt - -# 添加services/mcp-server到路径,以便导入模块 -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'services', 'mcp-server')) - -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy import select -from models import User, Channel -from config import settings - -def get_password_hash(password: str) -> str: - """加密密码(使用bcrypt)""" - password_bytes = password.encode('utf-8') - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(password_bytes, salt) - return hashed.decode('utf-8') - -BASE_URL = "http://localhost:8002" - -# 要创建的管理员列表 -ADMINS = [ - { - "name": "超级管理员", - "email": "superadmin@taiji-ai.com", - "password": "Admin@123456", - "role": "super_admin", - }, - { - "name": "计费管理员", - "email": "newbilling@test.com", - "password": "Billing@123456", - "role": "billing_admin", - "channel_name": "测试渠道", # 计费管理员也需要关联渠道 - "channel_email": "test-channel@test.com", # 共享渠道邮箱 - }, - { - "name": "运维管理员", - "email": "newops@test.com", - "password": "Ops@123456", - "role": "operations_admin", - "channel_name": "测试渠道", # 运维管理员也需要关联渠道 - "channel_email": "test-channel@test.com", # 共享渠道邮箱(与计费管理员共享) - }, - { - "name": "渠道管理员", - "email": "channel-a@test.com", - "password": "ChannelA@123456", - "role": "channel_admin", - "channel_name": "渠道A", # 渠道名称 - } -] - - -def login_admin(email: str, password: str, role: str = "super_admin") -> Optional[str]: - """登录管理员账户,返回token""" - try: - resp = requests.post( - f"{BASE_URL}/api/auth/login", - json={ - "email": email, - "password": password, - "role": role - }, - timeout=10 - ) - - if resp.status_code == 200: - data = resp.json() - token = data.get("data", {}).get("token") - if token: - print(f" ✓ 登录成功: {email}") - return token - else: - print(f" ✗ 登录失败: 响应中未找到token") - return None - else: - error = resp.json().get("detail", resp.text) - print(f" ✗ 登录失败: {error}") - return None - except Exception as e: - print(f" ✗ 登录出错: {e}") - return None - - -async def create_admin_via_api(token: str, admin_info: dict) -> bool: - """通过API创建管理员(需要先创建渠道)""" - try: - # 注意:API只能创建billing_admin和operations_admin - # 超级管理员和渠道管理员需要直接操作数据库 - if admin_info["role"] not in ["billing_admin", "operations_admin"]: - print(f" ⚠ 跳过: {admin_info['role']} 需要通过数据库直接创建") - return False - - # 先创建或获取渠道 - channel_id = None - if admin_info.get("channel_name"): - channel_id = await get_or_create_channel_for_api(admin_info) - - # 构建请求数据 - request_data = { - "name": admin_info["name"], - "email": admin_info["email"], - "password": admin_info["password"], - "role": admin_info["role"] - } - if channel_id: - request_data["channelId"] = str(channel_id) - - resp = requests.post( - f"{BASE_URL}/api/admin/admins/create", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - }, - json=request_data, - timeout=10 - ) - - if resp.status_code == 200: - data = resp.json() - channel_id_from_response = data.get("data", {}).get("channelId") - print(f" ✓ 创建成功: {admin_info['email']}" + (f" (渠道ID: {channel_id_from_response})" if channel_id_from_response else "")) - return True - else: - error = resp.json().get("detail", resp.text) - if "邮箱已被使用" in error or "already exists" in error.lower(): - print(f" ⚠ 已存在: {admin_info['email']}") - # 如果已存在,尝试更新channel_id - if channel_id: - await update_existing_user_channel(admin_info["email"], channel_id) - return True # 已存在也算成功 - else: - print(f" ✗ 创建失败: {error}") - return False - except Exception as e: - print(f" ✗ 创建出错: {e}") - import traceback - traceback.print_exc() - return False - - -async def get_or_create_channel_for_api(admin_info: dict) -> str: - """为API创建获取或创建渠道,返回channel_id字符串""" - try: - database_url = settings.database_url - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - engine = create_async_engine(database_url, echo=False) - AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with AsyncSessionLocal() as session: - channel_name = admin_info.get("channel_name", f"渠道-{admin_info['name']}") - channel_email = admin_info.get("channel_email", f"channel-{channel_name.lower().replace(' ', '-')}@test.com") - channel = await get_or_create_channel(session, channel_email, channel_name) - await session.commit() - channel_id = str(channel.id) - await engine.dispose() - return channel_id - except Exception as e: - print(f" ⚠ 创建渠道失败: {e}") - return None - - -async def update_existing_user_channel(email: str, channel_id: str): - """更新已存在用户的channel_id""" - try: - database_url = settings.database_url - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - engine = create_async_engine(database_url, echo=False) - AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with AsyncSessionLocal() as session: - result = await session.execute(select(User).where(User.email == email)) - user = result.scalar_one_or_none() - if user: - import uuid - user.channel_id = uuid.UUID(channel_id) - await session.commit() - print(f" ✓ 已更新用户的渠道ID: {channel_id}") - await engine.dispose() - except Exception as e: - print(f" ⚠ 更新用户渠道ID失败: {e}") - - -async def get_or_create_channel(session: AsyncSession, channel_email: str, channel_name: str) -> Channel: - """获取或创建渠道""" - # 先查找是否已存在 - result = await session.execute( - select(Channel).where(Channel.email == channel_email) - ) - channel = result.scalar_one_or_none() - - if channel: - return channel - - # 创建新渠道 - channel = Channel( - name=channel_name, - email=channel_email, - password_hash=get_password_hash("Channel@123456"), # 默认密码 - commission_rate=10.0, - channel_credit=0, - custom_agent_cpu=2, - custom_agent_memory=4, - status="active", - ) - session.add(channel) - await session.flush() # 获取ID但不提交 - await session.refresh(channel) - print(f" ✓ 创建渠道: {channel_name} (ID: {channel.id})") - return channel - - -async def create_admin_via_db(admin_info: dict) -> bool: - """直接通过数据库创建管理员""" - engine = None - try: - # 准备数据库URL - database_url = settings.database_url - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - # 创建数据库引擎 - engine = create_async_engine(database_url, echo=False) - AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with AsyncSessionLocal() as session: - # 如果是渠道管理员、计费管理员或运维管理员,需要先创建或获取渠道 - channel_id = None - if admin_info["role"] in ["channel_admin", "billing_admin", "operations_admin"]: - # 为管理员创建对应的渠道 - channel_name = admin_info.get("channel_name", f"渠道-{admin_info['name']}") - # 使用一个统一的渠道邮箱(如果多个管理员共享同一个渠道) - channel_email = admin_info.get("channel_email", f"channel-{channel_name.lower().replace(' ', '-')}@test.com") - channel = await get_or_create_channel(session, channel_email, channel_name) - channel_id = channel.id - await session.commit() # 提交渠道创建 - - # 检查用户是否已存在 - result = await session.execute( - select(User).where(User.email == admin_info["email"]) - ) - existing_user = result.scalar_one_or_none() - - if existing_user: - print(f" ⚠ 用户已存在: {admin_info['email']}") - # 更新角色和密码 - existing_user.role = admin_info["role"] - existing_user.password_hash = get_password_hash(admin_info["password"]) - existing_user.hashed_password = existing_user.password_hash - existing_user.name = admin_info["name"] - existing_user.username = admin_info["email"].split("@")[0] - existing_user.full_name = admin_info["name"] - existing_user.is_active = True - if admin_info["role"] == "super_admin": - existing_user.is_admin = True - # 如果是需要渠道的角色,更新channel_id - if channel_id and admin_info["role"] in ["channel_admin", "billing_admin", "operations_admin"]: - existing_user.channel_id = channel_id - await session.commit() - print(f" ✓ 更新成功: {admin_info['email']}" + (f" (渠道ID: {channel_id})" if channel_id else "")) - return True - - # 创建新用户 - password_hash = get_password_hash(admin_info["password"]) - user = User( - name=admin_info["name"], - email=admin_info["email"], - password_hash=password_hash, - hashed_password=password_hash, - username=admin_info["email"].split("@")[0], - full_name=admin_info["name"], - role=admin_info["role"], - channel_id=channel_id, # 关联渠道ID - is_active=True, - is_admin=(admin_info["role"] == "super_admin"), - status="active", - balance=0, - credit_limit=0, - ) - - session.add(user) - await session.commit() - await session.refresh(user) - - print(f" ✓ 创建成功: {admin_info['email']} (角色: {admin_info['role']})" + (f" (渠道ID: {channel_id})" if channel_id else "")) - return True - - except Exception as e: - print(f" ✗ 数据库创建失败: {e}") - import traceback - traceback.print_exc() - return False - finally: - if engine: - await engine.dispose() - - -async def main(): - """主函数""" - print("="*80) - print("创建管理员账户") - print("="*80) - print() - - # 首先尝试登录默认admin账户 - print("步骤1: 尝试登录默认管理员账户...") - default_admin_email = "admin@taiji-ai.com" - default_admin_password = "admin123" - - token = login_admin(default_admin_email, default_admin_password, "super_admin") - - # 如果没有默认admin,尝试创建超级管理员 - if not token: - print("\n步骤2: 默认管理员不存在,直接创建超级管理员...") - super_admin = ADMINS[0] # 第一个是超级管理员 - success = await create_admin_via_db(super_admin) - if success: - print("\n步骤3: 使用新创建的超级管理员登录...") - token = login_admin(super_admin["email"], super_admin["password"], "super_admin") - else: - print(" ✗ 无法创建超级管理员,请检查数据库连接") - return - - if not token: - print(" ✗ 无法获取管理员token,请检查服务是否运行") - return - - print(f"\n步骤4: 创建其他管理员账户...") - print("-" * 80) - - results = [] - for admin in ADMINS: - print(f"\n创建 {admin['name']} ({admin['email']})...") - - # 超级管理员和渠道管理员需要直接操作数据库 - if admin["role"] in ["super_admin", "channel_admin"]: - success = await create_admin_via_db(admin) - else: - # billing_admin和operations_admin可以通过API创建 - success = await create_admin_via_api(token, admin) - - results.append({ - "name": admin["name"], - "email": admin["email"], - "role": admin["role"], - "success": success - }) - - # 输出结果汇总 - print("\n" + "="*80) - print("创建结果汇总") - print("="*80) - print(f"\n{'角色':<20} {'邮箱':<35} {'状态'}") - print("-" * 80) - - for result in results: - status = "✓ 成功" if result["success"] else "✗ 失败" - print(f"{result['name']:<20} {result['email']:<35} {status}") - - success_count = sum(1 for r in results if r["success"]) - print(f"\n总计: {success_count}/{len(results)} 个账户创建成功") - - # 输出账户信息 - print("\n" + "="*80) - print("账户信息") - print("="*80) - for admin in ADMINS: - print(f"{admin['name']:<20} | {admin['email']:<35} | 密码: {admin['password']}") - - -if __name__ == "__main__": - asyncio.run(main()) - diff --git a/scripts/test_quota_fix.py b/scripts/test_quota_fix.py deleted file mode 100644 index ef3a8ad..0000000 --- a/scripts/test_quota_fix.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -""" -测试配额分配和使用的修复 -""" -import requests -import json - -BASE_URL = "http://localhost:8002" - -# 测试数据 -TENANT_ID = "b00a7b8e-9e8b-463d-9593-a3b4d0006778" - -def test_allocate_resources(): - """测试分配资源""" - print("=" * 60) - print("测试1: 分配 2核4G 配额给租户") - print("=" * 60) - - url = f"{BASE_URL}/api/channel/tenants/{TENANT_ID}/resources" - payload = { - "customAgentQuota": { - "cpuQuota": 2, - "memoryQuota": 4 - } - } - - response = requests.put(url, json=payload) - print(f"状态码: {response.status_code}") - print(f"响应: {json.dumps(response.json(), indent=2, ensure_ascii=False)}") - - if response.status_code == 200: - print("✓ 资源分配成功") - else: - print("✗ 资源分配失败") - return False - - return True - -def test_create_agent(): - """测试创建 Agent""" - print("\n" + "=" * 60) - print("测试2: 创建 1核2G 的 Agent") - print("=" * 60) - - url = f"{BASE_URL}/api/user/tools/generate" - payload = { - "name": "test-agent-quota", - "description": "测试配额修复的 Agent", - "frameworkTemplate": "A2A", - "gateway": "MCP", - "agentCount": 1, - "cpu": 1, - "memory": 2, - "maxScale": 1, - "model": "taiji/gpt-4o-mini" - } - - response = requests.post(url, json=payload) - print(f"状态码: {response.status_code}") - print(f"响应: {json.dumps(response.json(), indent=2, ensure_ascii=False)}") - - if response.status_code == 200: - print("✓ Agent 创建成功") - return True, response.json().get("data", {}).get("id") - else: - print("✗ Agent 创建失败") - return False, None - -def main(): - print("开始测试配额分配和使用的修复...\n") - - # 测试1: 分配资源 - if not test_allocate_resources(): - print("\n配额分配失败,停止测试") - return - - # 测试2: 创建 Agent - success, agent_id = test_create_agent() - if success: - print(f"\n测试通过!Agent ID: {agent_id}") - print("\n说明: 修复已生效") - print("- 渠道管理员分配的配额已正确写入数据库") - print("- 租户可以正常使用分配的配额创建 Agent") - else: - print("\n测试失败!") - print("请检查日志以获取更多信息") - -if __name__ == "__main__": - main() diff --git a/scripts/update_db_config.sh b/scripts/update_db_config.sh new file mode 100755 index 0000000..58efc38 --- /dev/null +++ b/scripts/update_db_config.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 更新所有配置文件中的数据库名称 +# +# 使用方法:./update_db_config.sh [new_db_name] +# 默认新数据库名:taiji_prod + +set -e + +NEW_DB="${1:-taiji_prod}" +OLD_DB="postgres" + +echo "==========================================" +echo "更新配置文件中的数据库名称" +echo "==========================================" +echo "旧数据库: ${OLD_DB}" +echo "新数据库: ${NEW_DB}" +echo "==========================================" + +# 备份配置文件 +echo "" +echo "Step 1: 备份配置文件..." +BACKUP_DIR="config_backup_$(date +%Y%m%d_%H%M%S)" +mkdir -p "${BACKUP_DIR}" +cp k8s/secrets.yaml "${BACKUP_DIR}/" +cp k8s/configmap.yaml "${BACKUP_DIR}/" 2>/dev/null || true +echo " 备份已保存到: ${BACKUP_DIR}/" + +# 更新生产环境配置 +echo "" +echo "Step 2: 更新生产环境配置 (k8s/secrets.yaml)..." +sed -i "s|:5432/postgres?|:5432/${NEW_DB}?|g" k8s/secrets.yaml +sed -i "s|:5432/postgres\"|:5432/${NEW_DB}\"|g" k8s/secrets.yaml +echo " ✓ k8s/secrets.yaml 已更新" + +# 检查是否有其他配置文件需要更新 +echo "" +echo "Step 3: 检查其他配置文件..." + +# 搜索所有包含数据库连接字符串的文件 +echo " 查找包含数据库连接的文件..." +grep -r "taijipda.postgres.database.azure.com:5432/postgres" --include="*.yaml" --include="*.yml" --include="*.env" . 2>/dev/null | grep -v "${BACKUP_DIR}" | cut -d: -f1 | sort -u || echo " 没有发现其他需要更新的文件" + +echo "" +echo "==========================================" +echo "配置更新完成!" +echo "==========================================" +echo "" +echo "更新的文件:" +echo " - k8s/secrets.yaml (生产环境)" +echo "" +echo "数据库连接字符串已更新为:" +echo " postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/${NEW_DB}" +echo "" +echo "下一步:" +echo "1. 检查配置文件: cat k8s/secrets.yaml | grep database-url" +echo "2. 部署到 AKS: kubectl apply -f k8s/secrets.yaml" +echo "3. 重启 pods: kubectl rollout restart deployment/mcp-server -n taiji-ai" +echo "" diff --git a/services/data-ingestion/config.py b/services/data-ingestion/config.py index db2074c..1ba6fce 100644 --- a/services/data-ingestion/config.py +++ b/services/data-ingestion/config.py @@ -127,23 +127,14 @@ class ProductionSettings(Settings): max_workers: int = 8 -class TestingSettings(Settings): - """测试环境配置""" - debug: bool = True - redis_url: str = "redis://localhost:6379/1" # 使用不同的数据库 - cache_ttl: int = 60 # 1分钟 - rapidapi_key: str = "test-key" - - def get_settings() -> Settings: """根据环境变量获取相应的配置""" environment = os.getenv("ENVIRONMENT", "development").lower() if environment == "production": return ProductionSettings() - elif environment == "testing": - return TestingSettings() else: + # 默认使用开发环境配置,不再支持测试环境配置 return DevelopmentSettings() diff --git a/services/mcp-server/app/agent_manager_client.py b/services/mcp-server/app/agent_manager_client.py index 06353a9..905798a 100644 --- a/services/mcp-server/app/agent_manager_client.py +++ b/services/mcp-server/app/agent_manager_client.py @@ -86,8 +86,19 @@ class TemplateInfo: Template Information Adapts to the Agent Manager GET /templates interface response + + Agent Manager now returns: + - template: technical name (e.g., echo_agent) + - displayName: human-readable name (e.g., "Echo 测试服务") + - description: template description + - category: template category (e.g., testing, assistant) + - port: service port + - env_info: environment variable configuration """ template: str + display_name: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None port: Optional[int] = None env_info: Dict[str, Any] = field(default_factory=dict) template_type: Optional[str] = None # platform or custom @@ -688,6 +699,9 @@ class AgentManagerClient: for t in data.get("templates", []): templates.append(TemplateInfo( template=t["template"], + display_name=t.get("displayName"), + description=t.get("description"), + category=t.get("category"), port=t.get("port"), env_info=t.get("env_info", {}) )) @@ -709,6 +723,9 @@ class AgentManagerClient: for t in data.get("templates", []): templates.append(TemplateInfo( template=t["template"], + display_name=t.get("display_name"), + description=t.get("description"), + category=t.get("category"), port=t.get("port"), env_info=t.get("env_info", {}), template_type="platform" @@ -733,6 +750,9 @@ class AgentManagerClient: for t in data.get("templates", []): templates.append(TemplateInfo( template=t["template"], + display_name=t.get("display_name"), + description=t.get("description"), + category=t.get("category"), port=t.get("port"), env_info=t.get("env_info", {}), template_type="custom" @@ -1082,7 +1102,21 @@ class AgentManagerClient: - status.cpu_request/cpu_limit: CPU configuration - status.memory_request/memory_limit: Memory configuration """ - data = await self._request("GET", f"/agents/{agent_name}/status") + response = await self._request("GET", f"/agents/{agent_name}/status") + + # Agent Manager 可能返回 {"success": true, "data": {...}} 或直接返回数据 + if isinstance(response, dict) and "data" in response: + data = response["data"] + else: + data = response + + # 记录返回的原始数据,便于调试 + logger.debug( + "agent_status_response", + agent_name=agent_name, + response_keys=list(data.keys()) if isinstance(data, dict) else None, + status=data.get("status") if isinstance(data, dict) else None + ) # Parse container status list containers = [] @@ -1118,10 +1152,15 @@ class AgentManagerClient: access_url = access_info.get("recommended") or data.get("access_url") # ====================================================== + # 确保状态有有效值 + agent_status = data.get("status", "Pending") + if not agent_status or agent_status.lower() == "unknown": + agent_status = "Pending" + return AgentStatusResult( - name=data["name"], - namespace=data["namespace"], - status=data["status"], + name=data.get("name", agent_name), + namespace=data.get("namespace", "ai-agents"), + status=agent_status, health_status=data.get("health_status", "unknown"), created_at=data.get("created_at"), pod_ip=data.get("pod_ip"), @@ -1302,10 +1341,13 @@ class AgentManagerClient: payload["headers"] = headers if auth: payload["auth"] = auth - if request_params: - payload["request_params"] = request_params - if request_body: - payload["request_body"] = request_body + + # Agent Manager 期望的是 input_schema,而不是 request_params/request_body + # input_schema 统一使用 JSON Schema 格式定义工具的输入参数 + input_schema = request_params or request_body + if input_schema: + payload["input_schema"] = input_schema + if response_mapping: payload["response_mapping"] = response_mapping if timeout: @@ -1318,7 +1360,8 @@ class AgentManagerClient: name=name, url=url, method=method, - user_id=user_id + user_id=user_id, + has_input_schema=input_schema is not None ) return await self._request("POST", "/external-tools/generate", json=payload) @@ -1373,10 +1416,12 @@ class AgentManagerClient: payload["headers"] = headers if auth: payload["auth"] = auth - if request_params: - payload["request_params"] = request_params - if request_body: - payload["request_body"] = request_body + + # Agent Manager 期望的是 input_schema,而不是 request_params/request_body + input_schema = request_params or request_body + if input_schema: + payload["input_schema"] = input_schema + if response_mapping: payload["response_mapping"] = response_mapping if timeout: @@ -1388,7 +1433,8 @@ class AgentManagerClient: "updating_external_tool", tool_ref_id=tool_ref_id, name=name, - user_id=user_id + user_id=user_id, + has_input_schema=input_schema is not None ) return await self._request("PUT", f"/external-tools/{tool_ref_id}", json=payload) @@ -1493,12 +1539,12 @@ class AgentManagerClient: config=config.to_dict() if config else None ) - data = await self._request("POST", "/external-tools/agents/create-with-tools", json=payload) + response = await self._request("POST", "/external-tools/agents/create-with-tools", json=payload) # 检查业务逻辑是否成功(agent-manager 可能返回 HTTP 200 但 success=false) - if isinstance(data, dict) and data.get("success") is False: - error_code = data.get("error", "unknown_error") - error_message = data.get("message", "创建 Agent 失败") + if isinstance(response, dict) and response.get("success") is False: + error_code = response.get("error", "unknown_error") + error_message = response.get("message", "创建 Agent 失败") logger.error( "create_agent_with_tools_failed", name=name, @@ -1511,12 +1557,32 @@ class AgentManagerClient: detail={"error": error_code, "message": error_message} ) + # Agent Manager 返回格式: {"success": true, "data": {...}} 或直接返回数据 + # 需要兼容两种格式 + if isinstance(response, dict) and "data" in response: + data = response["data"] + else: + data = response + + # 确保状态字段有默认值,避免 unknown + agent_status = data.get("status", "Pending") + if not agent_status or agent_status.lower() == "unknown": + # 如果 Agent 已经创建成功,但状态未知,默认设为 Pending + agent_status = "Pending" + + logger.info( + "agent_with_tools_created", + name=name, + status=agent_status, + response_keys=list(data.keys()) if isinstance(data, dict) else None + ) + return AgentCreateResult( - name=data["name"], - namespace=data["namespace"], - status=data["status"], - created_at=data["created_at"], - template=data["template"], + name=data.get("name", name), + namespace=data.get("namespace", "ai-agents"), + status=agent_status, + created_at=data.get("created_at", ""), + template=data.get("template", template), service_port=data.get("service_port"), access_info=data.get("access_info"), pod_id=data.get("pod_id"), diff --git a/services/mcp-server/app/billing.py b/services/mcp-server/app/billing.py index 6fdee32..38fa8c7 100644 --- a/services/mcp-server/app/billing.py +++ b/services/mcp-server/app/billing.py @@ -36,7 +36,7 @@ from typing import Optional, Tuple from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from models import User, Channel, BillingRecord, Agent, Balance +from models import User, Channel, BillingRecord, Agent, Balance, AgentBillingRecord # ============= EU定价配置 ============= @@ -287,7 +287,7 @@ async def add_balance( return True, f"成功充值 {amount} 元,当前余额: {new_balance}" -# ============= 计费记录创建 ============= +# ============= 计费记录创建(旧版,已废弃) ============= async def create_billing_record( tenant_id: str, @@ -300,6 +300,10 @@ async def create_billing_record( """ 创建计费记录 + ⚠️ 已废弃 (DEPRECATED) + 此函数使用旧的 BillingRecord 表,已废弃。 + 请使用 create_agent_billing_record() 函数替代。 + Args: tenant_id: 租户ID agent_id: Agent ID @@ -317,6 +321,12 @@ async def create_billing_record( - 专业级(Pro):$0.02 / EU - 企业级(Enterprise):$0.03 / EU """ + import warnings + warnings.warn( + "create_billing_record() 已废弃,请使用 create_agent_billing_record() 替代", + DeprecationWarning, + stacklevel=2 + ) # 获取用户信息(包括订阅等级和channel_id) result = await db.execute( select(User.channel_id, User.subscription_tier).where(User.id == tenant_id) @@ -475,7 +485,7 @@ def get_platform_agent_resources() -> dict: async def calculate_monthly_cost(user_id: str, db: AsyncSession) -> Decimal: """ - 计算用户本月消费 + 计算用户本月消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计) Args: user_id: 用户ID @@ -485,19 +495,30 @@ async def calculate_monthly_cost(user_id: str, db: AsyncSession) -> Decimal: 本月消费金额 """ from sqlalchemy import func + from models import ModelBillingRecord # 获取本月第一天 now = datetime.utcnow() month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - result = await db.execute( - select(func.sum(BillingRecord.cost)) - .where(BillingRecord.tenant_id == user_id) - .where(BillingRecord.timestamp >= month_start) + # Agent 计费 + agent_result = await db.execute( + select(func.sum(AgentBillingRecord.cost)) + .where(AgentBillingRecord.user_id == user_id) + .where(AgentBillingRecord.start_time >= month_start) ) + agent_cost = agent_result.scalar() or 0 - total = result.scalar() - return Decimal(str(total)) if total else Decimal(0) + # 模型调用计费 + model_result = await db.execute( + select(func.sum(ModelBillingRecord.total_cost)) + .where(ModelBillingRecord.tenant_id == user_id) + .where(ModelBillingRecord.created_at >= month_start) + ) + model_cost = model_result.scalar() or 0 + + total = Decimal(str(agent_cost)) + Decimal(str(model_cost)) + return total async def calculate_channel_commission( @@ -507,7 +528,7 @@ async def calculate_channel_commission( db: AsyncSession ) -> Tuple[Decimal, Decimal]: """ - 计算渠道佣金 + 计算渠道佣金(从 AgentBillingRecord 和 ModelBillingRecord 合并统计) Args: channel_id: 渠道ID @@ -519,6 +540,7 @@ async def calculate_channel_commission( (总收入, 佣金金额) """ from sqlalchemy import func + from models import ModelBillingRecord # 查询渠道佣金率 result = await db.execute( @@ -527,17 +549,25 @@ async def calculate_channel_commission( row = result.first() commission_rate = Decimal(str(row[0])) / 100 if row else Decimal(0) - # 查询总收入 - result = await db.execute( - select(func.sum(BillingRecord.cost)) - .where(BillingRecord.channel_id == channel_id) - .where(BillingRecord.timestamp >= start_date) - .where(BillingRecord.timestamp <= end_date) + # Agent 计费收入 + agent_result = await db.execute( + select(func.sum(AgentBillingRecord.cost)) + .where(AgentBillingRecord.channel_id == channel_id) + .where(AgentBillingRecord.start_time >= start_date) + .where(AgentBillingRecord.start_time <= end_date) ) + agent_revenue = agent_result.scalar() or 0 - total_revenue = result.scalar() - total_revenue = Decimal(str(total_revenue)) if total_revenue else Decimal(0) + # 模型调用计费收入 + model_result = await db.execute( + select(func.sum(ModelBillingRecord.total_cost)) + .where(ModelBillingRecord.channel_id == channel_id) + .where(ModelBillingRecord.created_at >= start_date) + .where(ModelBillingRecord.created_at <= end_date) + ) + model_revenue = model_result.scalar() or 0 + total_revenue = Decimal(str(agent_revenue)) + Decimal(str(model_revenue)) commission = total_revenue * commission_rate return total_revenue, commission diff --git a/services/mcp-server/app/periodic_billing.py b/services/mcp-server/app/periodic_billing.py index d0f309f..5ca4cd7 100644 --- a/services/mcp-server/app/periodic_billing.py +++ b/services/mcp-server/app/periodic_billing.py @@ -33,7 +33,7 @@ from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession from database import AsyncSessionLocal -from models import AgentBillingRecord, User, Balance +from models import AgentBillingRecord, User, Balance, TenantCustomAgentQuota, Agent from app.billing import ( calculate_platform_agent_cost, calculate_agent_cost_by_resources, @@ -47,8 +47,12 @@ logger = logging.getLogger(__name__) # 计费周期配置 BILLING_INTERVAL_SECONDS = 3600 # 每小时执行一次 +QUOTA_CHECK_INTERVAL_HOURS = 24 # 配额一致性检查周期:每24小时一次 VM_EU_PER_HOUR = Decimal("0.5") # VM计算 0.5 EU/hour +# 上次配额检查时间 +_last_quota_check: Optional[datetime] = None + def _calculate_eu(duration_seconds: int) -> int: """计算EU:1 EU = 10秒,不足10秒按1 EU计算""" @@ -107,6 +111,94 @@ async def stop_user_agents(user_id: str, db: AsyncSession) -> list: return stopped_agents +async def check_quota_consistency(db: AsyncSession) -> dict: + """ + 检查配额一致性(假用量检测) + + 对比配额表中的使用量与实际运行的Agent,识别假用量问题。 + 如果发现不一致,记录警告日志,运维人员可以运行 fix_fake_quota.py 修复。 + + Returns: + 检查结果统计 + """ + logger.info("🔍 开始配额一致性检查(假用量检测)") + + try: + # 查询所有配额记录 + result = await db.execute(select(TenantCustomAgentQuota)) + quotas = result.scalars().all() + + inconsistencies = [] + total_fake_cpu = 0.0 + total_fake_memory = 0.0 + + for quota in quotas: + tenant_id = str(quota.tenant_id) + + # 查询配额显示的使用量 + quota_cpu = float(quota.cpu_used or 0) + quota_memory = float(quota.memory_used or 0) + quota_count = quota.agent_count or 0 + + # 查询实际运行的Agent + agents_result = await db.execute( + select(Agent) + .where(Agent.owner_id == tenant_id) + .where(Agent.type == 'custom') + .where(Agent.status == 'active') + ) + agents = agents_result.scalars().all() + + # 计算实际使用量 + real_cpu = sum(float(a.cpu or 0) for a in agents) + real_memory = sum(float(a.memory or 0) for a in agents) + real_count = len(agents) + + # 计算差异(允许小误差 0.01) + cpu_diff = quota_cpu - real_cpu + memory_diff = quota_memory - real_memory + count_diff = quota_count - real_count + + if abs(cpu_diff) > 0.01 or abs(memory_diff) > 0.01 or count_diff != 0: + inconsistencies.append({ + 'tenant_id': tenant_id, + 'quota': {'cpu': quota_cpu, 'memory': quota_memory, 'count': quota_count}, + 'real': {'cpu': real_cpu, 'memory': real_memory, 'count': real_count}, + 'diff': {'cpu': cpu_diff, 'memory': memory_diff, 'count': count_diff} + }) + + total_fake_cpu += cpu_diff + total_fake_memory += memory_diff + + logger.warning( + f"⚠️ 配额不一致: 租户={tenant_id}, " + f"配额显示=[CPU:{quota_cpu:.2f}核, 内存:{quota_memory:.2f}GB, Agent:{quota_count}个], " + f"实际运行=[CPU:{real_cpu:.2f}核, 内存:{real_memory:.2f}GB, Agent:{real_count}个], " + f"假用量=[CPU:{cpu_diff:.2f}核, 内存:{memory_diff:.2f}GB, Agent:{count_diff}个]" + ) + + if inconsistencies: + logger.error( + f"❌ 发现 {len(inconsistencies)} 个租户存在配额不一致(假用量)!\n" + f" 假用量汇总: CPU={total_fake_cpu:.2f}核, 内存={total_fake_memory:.2f}GB\n" + f" 🔧 请运行修复脚本: python fix_fake_quota.py" + ) + else: + logger.info("✅ 配额一致性检查通过,无假用量问题") + + return { + 'total_tenants': len(quotas), + 'inconsistent_tenants': len(inconsistencies), + 'total_fake_cpu': total_fake_cpu, + 'total_fake_memory': total_fake_memory, + 'details': inconsistencies + } + + except Exception as e: + logger.error(f"配额一致性检查失败: {e}") + return {'error': str(e)} + + async def update_running_agent_billing(db: AsyncSession) -> dict: """ 更新所有运行中 Agent 的计费记录 @@ -223,12 +315,16 @@ async def periodic_billing_task(): 周期性计费任务(后台运行) 每小时执行一次,更新所有运行中 Agent 的计费记录 + 每24小时执行一次配额一致性检查(假用量检测) """ + global _last_quota_check + logger.info("周期性计费任务启动") while True: try: async with AsyncSessionLocal() as db: + # 1. 执行计费更新(每小时) stats = await update_running_agent_billing(db) logger.info( @@ -244,6 +340,22 @@ async def periodic_billing_task(): if stats["errors"]: logger.warning(f"计费错误: {stats['errors']}") + + # 2. 配额一致性检查(每24小时一次) + now = datetime.utcnow() + should_check_quota = ( + _last_quota_check is None or + (now - _last_quota_check).total_seconds() >= QUOTA_CHECK_INTERVAL_HOURS * 3600 + ) + + if should_check_quota: + logger.info(f"⏰ 触发配额一致性检查(距上次检查: {(now - _last_quota_check).total_seconds() / 3600:.1f}小时)" if _last_quota_check else "⏰ 首次执行配额一致性检查") + quota_stats = await check_quota_consistency(db) + _last_quota_check = now + + # 如果发现假用量,记录到统计中 + if quota_stats.get('inconsistent_tenants', 0) > 0: + stats['quota_check'] = quota_stats except Exception as e: logger.error(f"周期性计费任务异常: {e}") diff --git a/services/mcp-server/app/quota_manager.py b/services/mcp-server/app/quota_manager.py index fc96a5e..bb45e9d 100644 --- a/services/mcp-server/app/quota_manager.py +++ b/services/mcp-server/app/quota_manager.py @@ -1,6 +1,11 @@ """ 预付费配额管理模块 配额预警与限制 + +注意:计费数据已迁移到新表: +- AgentBillingRecord: Agent 运行时计费 +- ModelBillingRecord: 模型调用计费(LiteLLM) +旧的 BillingRecord 表已废弃,不再使用。 """ from datetime import datetime, timedelta @@ -9,7 +14,7 @@ from typing import Dict, List, Optional, Tuple from sqlalchemy import select, func, and_, update from sqlalchemy.ext.asyncio import AsyncSession -from models import User, Channel, QuotaAlert, BillingRecord, ResourceAllocation, Balance +from models import User, Channel, QuotaAlert, ResourceAllocation, Balance, AgentBillingRecord, ModelBillingRecord # 配额预警阈值配置 @@ -52,19 +57,35 @@ async def check_user_balance_quota( credit_limit = Decimal(str(user.credit_limit)) available = balance + credit_limit - # 获取用户平均日消费 + # 获取用户平均日消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计) thirty_days_ago = datetime.utcnow() - timedelta(days=30) - cost_result = await db.execute( - select(func.sum(BillingRecord.cost)) + + # Agent 计费 + agent_cost_result = await db.execute( + select(func.sum(AgentBillingRecord.cost)) .where( and_( - BillingRecord.tenant_id == user_id, - BillingRecord.timestamp >= thirty_days_ago, + AgentBillingRecord.user_id == user_id, + AgentBillingRecord.start_time >= thirty_days_ago, ) ) ) - total_cost = cost_result.scalar() or 0 - daily_avg = Decimal(str(total_cost)) / 30 + agent_cost = agent_cost_result.scalar() or 0 + + # 模型调用计费 + model_cost_result = await db.execute( + select(func.sum(ModelBillingRecord.total_cost)) + .where( + and_( + ModelBillingRecord.tenant_id == user_id, + ModelBillingRecord.created_at >= thirty_days_ago, + ) + ) + ) + model_cost = model_cost_result.scalar() or 0 + + total_cost = Decimal(str(agent_cost)) + Decimal(str(model_cost)) + daily_avg = total_cost / 30 # 预估可用天数 if daily_avg > 0: @@ -115,18 +136,34 @@ async def check_channel_quota( channel_credit = Decimal(str(channel.channel_credit)) - # 获取渠道下所有租户的总消费 + # 获取渠道下所有租户的总消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计) thirty_days_ago = datetime.utcnow() - timedelta(days=30) - cost_result = await db.execute( - select(func.sum(BillingRecord.cost)) + + # Agent 计费 + agent_cost_result = await db.execute( + select(func.sum(AgentBillingRecord.cost)) .where( and_( - BillingRecord.channel_id == channel_id, - BillingRecord.timestamp >= thirty_days_ago, + AgentBillingRecord.channel_id == channel_id, + AgentBillingRecord.start_time >= thirty_days_ago, ) ) ) - total_cost = cost_result.scalar() or 0 + agent_cost = agent_cost_result.scalar() or 0 + + # 模型调用计费 + model_cost_result = await db.execute( + select(func.sum(ModelBillingRecord.total_cost)) + .where( + and_( + ModelBillingRecord.channel_id == channel_id, + ModelBillingRecord.created_at >= thirty_days_ago, + ) + ) + ) + model_cost = model_cost_result.scalar() or 0 + + total_cost = float(agent_cost) + float(model_cost) details = { "channelCredit": float(channel_credit), @@ -323,19 +360,34 @@ async def check_rate_limit( Returns: (是否允许, 当前使用量, 限制量) """ - # 获取最近1分钟的调用次数 + # 获取最近1分钟的调用次数(从 AgentBillingRecord 和 ModelBillingRecord 合并统计) one_minute_ago = datetime.utcnow() - timedelta(minutes=1) - result = await db.execute( - select(func.count(BillingRecord.id)) + # Agent 调用次数 + agent_result = await db.execute( + select(func.count(AgentBillingRecord.id)) .where( and_( - BillingRecord.tenant_id == user_id, - BillingRecord.timestamp >= one_minute_ago, + AgentBillingRecord.user_id == user_id, + AgentBillingRecord.start_time >= one_minute_ago, ) ) ) - current_rpm = result.scalar() or 0 + agent_rpm = agent_result.scalar() or 0 + + # 模型调用次数 + model_result = await db.execute( + select(func.count(ModelBillingRecord.id)) + .where( + and_( + ModelBillingRecord.tenant_id == user_id, + ModelBillingRecord.created_at >= one_minute_ago, + ) + ) + ) + model_rpm = model_result.scalar() or 0 + + current_rpm = agent_rpm + model_rpm # 获取用户的RPM限制(从资源分配表) limit_result = await db.execute( @@ -408,7 +460,7 @@ async def get_user_quota_summary( "alerts": [...] } """ - from models import User, QuotaAlert, BillingRecord, ResourceUsage + from models import User, QuotaAlert, ResourceUsage # 1. 获取用户信息 result = await db.execute( @@ -428,19 +480,35 @@ async def get_user_quota_summary( credit_limit = Decimal(str(user.credit_limit or 0)) available = balance + credit_limit - # 计算近30天平均日消费 + # 计算近30天平均日消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计) thirty_days_ago = datetime.utcnow() - timedelta(days=30) - cost_result = await db.execute( - select(func.sum(BillingRecord.cost)) + + # Agent 计费 + agent_cost_result = await db.execute( + select(func.sum(AgentBillingRecord.cost)) .where( and_( - BillingRecord.tenant_id == user_id, - BillingRecord.timestamp >= thirty_days_ago, + AgentBillingRecord.user_id == user_id, + AgentBillingRecord.start_time >= thirty_days_ago, ) ) ) - total_cost = cost_result.scalar() or 0 - daily_avg = Decimal(str(total_cost)) / 30 if total_cost > 0 else Decimal("0") + agent_cost = agent_cost_result.scalar() or 0 + + # 模型调用计费 + model_cost_result = await db.execute( + select(func.sum(ModelBillingRecord.total_cost)) + .where( + and_( + ModelBillingRecord.tenant_id == user_id, + ModelBillingRecord.created_at >= thirty_days_ago, + ) + ) + ) + model_cost = model_cost_result.scalar() or 0 + + total_cost = Decimal(str(agent_cost)) + Decimal(str(model_cost)) + daily_avg = total_cost / 30 if total_cost > 0 else Decimal("0") # 预估可用天数 if daily_avg > 0: @@ -536,7 +604,7 @@ async def get_channel_quota_summary( "usagePercent": float } """ - from models import Channel, User, QuotaAlert, BillingRecord + from models import Channel, User, QuotaAlert # 1. 获取渠道信息 result = await db.execute( @@ -547,21 +615,37 @@ async def get_channel_quota_summary( if not channel: raise ValueError(f"渠道不存在: {channel_id}") - # 2. 计算本月使用量 + # 2. 计算本月使用量(从 AgentBillingRecord 和 ModelBillingRecord 合并统计) now = datetime.utcnow() month_start = datetime(now.year, now.month, 1) - usage_result = await db.execute( - select(func.sum(BillingRecord.cost)) - .join(User, BillingRecord.tenant_id == User.id) + # Agent 计费 + agent_usage_result = await db.execute( + select(func.sum(AgentBillingRecord.cost)) + .join(User, AgentBillingRecord.user_id == User.id) .where( and_( User.channel_id == channel_id, - BillingRecord.timestamp >= month_start + AgentBillingRecord.start_time >= month_start ) ) ) - monthly_usage = Decimal(str(usage_result.scalar() or 0)) + agent_usage = agent_usage_result.scalar() or 0 + + # 模型调用计费 + model_usage_result = await db.execute( + select(func.sum(ModelBillingRecord.total_cost)) + .join(User, ModelBillingRecord.tenant_id == User.id) + .where( + and_( + User.channel_id == channel_id, + ModelBillingRecord.created_at >= month_start + ) + ) + ) + model_usage = model_usage_result.scalar() or 0 + + monthly_usage = Decimal(str(agent_usage)) + Decimal(str(model_usage)) channel_credit = Decimal(str(channel.channel_credit or 0)) # 修复:使用正确的字段名 channel_credit usage_percent = float(monthly_usage / channel_credit * 100) if channel_credit > 0 else 0 diff --git a/services/mcp-server/app/resource_control.py b/services/mcp-server/app/resource_control.py index f8db413..9866bbd 100644 --- a/services/mcp-server/app/resource_control.py +++ b/services/mcp-server/app/resource_control.py @@ -1,6 +1,11 @@ """ 资源管控模块 提供请求级别的资源管控、速率限制和配额检查 + +注意:计费数据已迁移到新表: +- AgentBillingRecord: Agent 运行时计费 +- ModelBillingRecord: 模型调用计费(LiteLLM) +旧的 BillingRecord 表已废弃,不再使用。 """ from datetime import datetime, timedelta @@ -11,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi import HTTPException, status import structlog -from models import User, Channel, ResourceUsage, BillingRecord +from models import User, Channel, ResourceUsage, AgentBillingRecord from app.quota_manager import check_user_balance_quota, check_channel_quota from app.db_utils import with_retry @@ -276,21 +281,25 @@ class ResourceController: ) db.add(usage) - # 记录到BillingRecord表 - billing_record = BillingRecord( - tenant_id=user_id, - resource_type=resource_type, - resource_id=resource_id, - cost=cost, - timestamp=now, - details={ - "execution_time_ms": execution_time_ms, - "cpu_usage": cpu_usage, - "memory_usage": memory_usage, - "network_io": network_io - } - ) - db.add(billing_record) + # 记录到 AgentBillingRecord 表(新计费表) + # 注意:这里只记录 Agent 类型的资源消耗 + # 模型调用计费由 LiteLLM Callback 处理,存入 ModelBillingRecord + if resource_type == "agent": + billing_record = AgentBillingRecord( + user_id=user_id, + agent_name=resource_id or "unknown", + agent_type="custom", # 默认为自定义 Agent + is_platform_agent=False, + duration_seconds=int(execution_time_ms / 1000), + cpu_seconds=cpu_usage * (execution_time_ms / 1000.0), + memory_gb_seconds=memory_usage / 1024 * (execution_time_ms / 1000.0), + request_count=1, + cost=cost, + eu_consumed=int(cost), # 简化:1 EU = 1 cost + start_time=now, + period_start=now, + ) + db.add(billing_record) await db.commit() diff --git a/services/mcp-server/app/resource_monitor.py b/services/mcp-server/app/resource_monitor.py index 49ff126..9f7311e 100644 --- a/services/mcp-server/app/resource_monitor.py +++ b/services/mcp-server/app/resource_monitor.py @@ -1,6 +1,11 @@ """ 资源使用监控模块 采集和统计用户资源使用情况 + +注意:计费数据已迁移到新表: +- AgentBillingRecord: Agent 运行时计费 +- ModelBillingRecord: 模型调用计费(LiteLLM) +旧的 BillingRecord 表已废弃,不再使用。 """ from datetime import datetime, timedelta @@ -9,7 +14,7 @@ from typing import Dict, List, Optional, Tuple from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession -from models import ResourceUsage, User, Agent, BillingRecord, Execution +from models import ResourceUsage, User, Agent, Execution, AgentBillingRecord, ModelBillingRecord async def record_resource_usage( @@ -249,6 +254,10 @@ async def get_platform_resource_overview(db: AsyncSession) -> Dict: """ 获取平台资源概览(管理员视图) + 数据来源: + - AgentBillingRecord: Agent 运行时计费 + - ModelBillingRecord: 模型调用计费(LiteLLM) + Args: db: 数据库会话 @@ -259,23 +268,39 @@ async def get_platform_resource_overview(db: AsyncSession) -> Dict: today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - # 今日调用次数 - today_calls = await db.execute( - select(func.count(BillingRecord.id)) - .where(BillingRecord.timestamp >= today_start) + # 今日调用次数(Agent + Model) + today_agent_calls = await db.execute( + select(func.count(AgentBillingRecord.id)) + .where(AgentBillingRecord.start_time >= today_start) ) + today_model_calls = await db.execute( + select(func.count(ModelBillingRecord.id)) + .where(ModelBillingRecord.created_at >= today_start) + ) + today_calls_total = (today_agent_calls.scalar() or 0) + (today_model_calls.scalar() or 0) - # 本月调用次数 - month_calls = await db.execute( - select(func.count(BillingRecord.id)) - .where(BillingRecord.timestamp >= month_start) + # 本月调用次数(Agent + Model) + month_agent_calls = await db.execute( + select(func.count(AgentBillingRecord.id)) + .where(AgentBillingRecord.start_time >= month_start) ) + month_model_calls = await db.execute( + select(func.count(ModelBillingRecord.id)) + .where(ModelBillingRecord.created_at >= month_start) + ) + month_calls_total = (month_agent_calls.scalar() or 0) + (month_model_calls.scalar() or 0) - # 活跃用户数 - active_users = await db.execute( - select(func.count(func.distinct(BillingRecord.tenant_id))) - .where(BillingRecord.timestamp >= today_start) + # 活跃用户数(今日有 Agent 或 Model 调用的用户) + active_agent_users = await db.execute( + select(func.count(func.distinct(AgentBillingRecord.user_id))) + .where(AgentBillingRecord.start_time >= today_start) ) + active_model_users = await db.execute( + select(func.count(func.distinct(ModelBillingRecord.tenant_id))) + .where(ModelBillingRecord.created_at >= today_start) + ) + # 注意:这里简单相加可能有重复,但作为概览统计可以接受 + active_users_total = max(active_agent_users.scalar() or 0, active_model_users.scalar() or 0) # 活跃Agent数 active_agents = await db.execute( @@ -283,18 +308,23 @@ async def get_platform_resource_overview(db: AsyncSession) -> Dict: .where(Agent.status == "active") ) - # 总EU消耗 - total_eu = await db.execute( - select(func.sum(BillingRecord.eu)) - .where(BillingRecord.timestamp >= month_start) + # 总EU消耗(Agent + Model) + agent_eu = await db.execute( + select(func.sum(AgentBillingRecord.eu_consumed)) + .where(AgentBillingRecord.start_time >= month_start) ) + model_eu = await db.execute( + select(func.sum(ModelBillingRecord.eu_consumed)) + .where(ModelBillingRecord.created_at >= month_start) + ) + total_eu = (agent_eu.scalar() or 0) + (model_eu.scalar() or 0) return { - "todayCalls": today_calls.scalar() or 0, - "monthCalls": month_calls.scalar() or 0, - "activeUsersToday": active_users.scalar() or 0, + "todayCalls": today_calls_total, + "monthCalls": month_calls_total, + "activeUsersToday": active_users_total, "activeAgents": active_agents.scalar() or 0, - "monthTotalEu": int(total_eu.scalar() or 0), + "monthTotalEu": int(total_eu), "timestamp": now.isoformat(), } diff --git a/services/mcp-server/app/routes/admin.py b/services/mcp-server/app/routes/admin.py index 889333c..a916fae 100644 --- a/services/mcp-server/app/routes/admin.py +++ b/services/mcp-server/app/routes/admin.py @@ -2853,6 +2853,11 @@ async def get_billing_overview( ): """ 获取三维度计费统计(所有管理员可查看) + + 从 AgentBillingRecord 和 ModelBillingRecord 表查询计费数据, + 返回渠道维度、租户维度和调用记录三个维度的统计。 + + EU 计算规则:1 EU = 10 秒运行时间(向上取整) """ _verify_read_permission(principal) @@ -2866,99 +2871,227 @@ async def get_billing_overview( if end_dt.tzinfo is not None: end_dt = end_dt.replace(tzinfo=None) - # 渠道统计 - channel_stats_result = await db.execute( + # ========== 渠道统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ========== + + # 1. 从 AgentBillingRecord 统计 Agent 使用 + agent_channel_stats = await db.execute( select( Channel.id, Channel.name, - func.count(BillingRecord.id).label("calls"), - func.sum(BillingRecord.eu).label("total_eu"), - func.sum(BillingRecord.cost).label("total_cost"), + func.count(AgentBillingRecord.id).label("calls"), + func.sum(AgentBillingRecord.eu_consumed).label("total_eu"), + func.sum(AgentBillingRecord.cost).label("total_cost"), ) - .select_from(BillingRecord) - .join(Channel, BillingRecord.channel_id == Channel.id) + .select_from(AgentBillingRecord) + .join(Channel, AgentBillingRecord.channel_id == Channel.id) .where( and_( - BillingRecord.timestamp >= start_dt, - BillingRecord.timestamp <= end_dt, + AgentBillingRecord.start_time >= start_dt, + AgentBillingRecord.start_time <= end_dt, ) ) .group_by(Channel.id, Channel.name) ) + agent_channel_data = {str(row.id): {"name": row.name, "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in agent_channel_stats.all()} - channel_stats = [ - { - "channelId": str(row.id), - "channelName": row.name, - "calls": row.calls, - "totalEU": float(row.total_eu or 0), - "totalCost": float(row.total_cost or 0), - } - for row in channel_stats_result.all() - ] + # 2. 从 ModelBillingRecord 统计模型调用 + model_channel_stats = await db.execute( + select( + Channel.id, + Channel.name, + func.count(ModelBillingRecord.id).label("calls"), + func.sum(ModelBillingRecord.eu_consumed).label("total_eu"), + func.sum(ModelBillingRecord.total_cost).label("total_cost"), + ) + .select_from(ModelBillingRecord) + .join(Channel, ModelBillingRecord.channel_id == Channel.id) + .where( + and_( + ModelBillingRecord.start_time >= start_dt, + ModelBillingRecord.start_time <= end_dt, + ) + ) + .group_by(Channel.id, Channel.name) + ) + model_channel_data = {str(row.id): {"name": row.name, "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in model_channel_stats.all()} - # 租户统计 - tenant_stats_result = await db.execute( + # 3. 合并渠道统计 + all_channel_ids = set(agent_channel_data.keys()) | set(model_channel_data.keys()) + channel_stats = [] + for channel_id in all_channel_ids: + agent_data = agent_channel_data.get(channel_id, {"name": "", "calls": 0, "eu": 0, "cost": 0}) + model_data = model_channel_data.get(channel_id, {"name": "", "calls": 0, "eu": 0, "cost": 0}) + channel_name = agent_data["name"] or model_data["name"] + total_calls = agent_data["calls"] + model_data["calls"] + total_eu = agent_data["eu"] + model_data["eu"] + total_cost = agent_data["cost"] + model_data["cost"] + + # 应用筛选条件 + if channelName and channelName.lower() not in channel_name.lower(): + continue + if minCalls is not None and total_calls < minCalls: + continue + if maxCalls is not None and total_calls > maxCalls: + continue + + channel_stats.append({ + "channelId": channel_id, + "channelName": channel_name, + "calls": total_calls, + "totalEU": round(total_eu, 2), + "totalCost": round(total_cost, 4), + }) + + # ========== 租户统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ========== + + # 1. 从 AgentBillingRecord 统计 + agent_tenant_stats = await db.execute( select( User.id, User.name, Channel.name.label("channel_name"), - func.count(BillingRecord.id).label("calls"), - func.sum(BillingRecord.eu).label("total_eu"), - func.sum(BillingRecord.cost).label("total_cost"), + func.count(AgentBillingRecord.id).label("calls"), + func.sum(AgentBillingRecord.eu_consumed).label("total_eu"), + func.sum(AgentBillingRecord.cost).label("total_cost"), ) - .select_from(BillingRecord) - .join(User, BillingRecord.tenant_id == User.id) - .join(Channel, User.channel_id == Channel.id) + .select_from(AgentBillingRecord) + .join(User, AgentBillingRecord.user_id == User.id) + .outerjoin(Channel, User.channel_id == Channel.id) .where( and_( - BillingRecord.timestamp >= start_dt, - BillingRecord.timestamp <= end_dt, + AgentBillingRecord.start_time >= start_dt, + AgentBillingRecord.start_time <= end_dt, ) ) .group_by(User.id, User.name, Channel.name) ) + agent_tenant_data = {str(row.id): {"name": row.name, "channel_name": row.channel_name or "无渠道", "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in agent_tenant_stats.all()} - tenant_stats = [ - { - "tenantId": str(row.id), - "tenantName": row.name, - "channelName": row.channel_name, - "calls": row.calls, - "totalEU": float(row.total_eu or 0), - "totalCost": float(row.total_cost or 0), - } - for row in tenant_stats_result.all() - ] - - # 调用记录 - records_result = await db.execute( - select(BillingRecord, Channel.name, User.name) - .join(Channel, BillingRecord.channel_id == Channel.id) - .join(User, BillingRecord.tenant_id == User.id) + # 2. 从 ModelBillingRecord 统计 + model_tenant_stats = await db.execute( + select( + User.id, + User.name, + Channel.name.label("channel_name"), + func.count(ModelBillingRecord.id).label("calls"), + func.sum(ModelBillingRecord.eu_consumed).label("total_eu"), + func.sum(ModelBillingRecord.total_cost).label("total_cost"), + ) + .select_from(ModelBillingRecord) + .join(User, ModelBillingRecord.tenant_id == User.id) + .outerjoin(Channel, User.channel_id == Channel.id) .where( and_( - BillingRecord.timestamp >= start_dt, - BillingRecord.timestamp <= end_dt, + ModelBillingRecord.start_time >= start_dt, + ModelBillingRecord.start_time <= end_dt, ) ) - .order_by(desc(BillingRecord.timestamp)) - .limit(100) + .group_by(User.id, User.name, Channel.name) + ) + model_tenant_data = {str(row.id): {"name": row.name, "channel_name": row.channel_name or "无渠道", "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in model_tenant_stats.all()} + + # 3. 合并租户统计 + all_tenant_ids = set(agent_tenant_data.keys()) | set(model_tenant_data.keys()) + tenant_stats = [] + for tenant_id in all_tenant_ids: + agent_data = agent_tenant_data.get(tenant_id, {"name": "", "channel_name": "无渠道", "calls": 0, "eu": 0, "cost": 0}) + model_data = model_tenant_data.get(tenant_id, {"name": "", "channel_name": "无渠道", "calls": 0, "eu": 0, "cost": 0}) + tenant_name_val = agent_data["name"] or model_data["name"] + channel_name_val = agent_data["channel_name"] or model_data["channel_name"] + total_calls = agent_data["calls"] + model_data["calls"] + total_eu = agent_data["eu"] + model_data["eu"] + total_cost = agent_data["cost"] + model_data["cost"] + + # 应用筛选条件 + if tenantName and tenantName.lower() not in tenant_name_val.lower(): + continue + if minCalls is not None and total_calls < minCalls: + continue + if maxCalls is not None and total_calls > maxCalls: + continue + + # 计算平均消费 + avg_cost = total_cost / total_calls if total_calls > 0 else 0 + + tenant_stats.append({ + "tenantId": tenant_id, + "tenantName": tenant_name_val, + "channelName": channel_name_val, + "calls": total_calls, + "totalEU": round(total_eu, 2), + "totalCost": round(total_cost, 4), + "avgCost": round(avg_cost, 4), + }) + + # ========== 调用记录(合并 AgentBillingRecord 和 ModelBillingRecord) ========== + call_records = [] + + # 1. 从 AgentBillingRecord 获取记录 + agent_records_result = await db.execute( + select(AgentBillingRecord, Channel.name.label("channel_name"), User.name.label("user_name")) + .outerjoin(Channel, AgentBillingRecord.channel_id == Channel.id) + .join(User, AgentBillingRecord.user_id == User.id) + .where( + and_( + AgentBillingRecord.start_time >= start_dt, + AgentBillingRecord.start_time <= end_dt, + ) + ) + .order_by(desc(AgentBillingRecord.start_time)) + .limit(50) ) - call_records = [ - { + for record, channel_name, user_name in agent_records_result.all(): + call_records.append({ "id": str(record.id), - "timestamp": record.timestamp.isoformat(), - "channelName": channel_name, - "tenantName": tenant_name, + "type": "agent", + "timestamp": record.start_time.isoformat() if record.start_time else None, + "channelName": channel_name or "无渠道", + "tenantName": user_name, "agentName": record.agent_name, - "duration": record.duration, - "eu": record.eu, - "cost": float(record.cost), - } - for record, channel_name, tenant_name in records_result.all() - ] + "modelName": record.model_name, + "duration": record.duration_seconds or 0, + "eu": record.eu_consumed or 0, + "cost": float(record.cost or 0), + }) + + # 2. 从 ModelBillingRecord 获取记录 + model_records_result = await db.execute( + select(ModelBillingRecord, Channel.name.label("channel_name"), User.name.label("user_name")) + .outerjoin(Channel, ModelBillingRecord.channel_id == Channel.id) + .join(User, ModelBillingRecord.tenant_id == User.id) + .where( + and_( + ModelBillingRecord.start_time >= start_dt, + ModelBillingRecord.start_time <= end_dt, + ) + ) + .order_by(desc(ModelBillingRecord.start_time)) + .limit(50) + ) + + for record, channel_name, user_name in model_records_result.all(): + # 计算 duration(从 response_time_ms 转换为秒) + duration = (record.response_time_ms or 0) / 1000 + call_records.append({ + "id": str(record.id), + "type": "model", + "timestamp": record.start_time.isoformat() if record.start_time else record.created_at.isoformat(), + "channelName": channel_name or "无渠道", + "tenantName": user_name, + "agentName": None, + "modelName": record.model_name, + "duration": round(duration, 2), + "eu": float(record.eu_consumed or 0), + "cost": float(record.total_cost or 0), + "inputTokens": record.input_tokens, + "outputTokens": record.output_tokens, + "totalTokens": record.total_tokens, + }) + + # 3. 按时间排序并限制数量 + call_records.sort(key=lambda x: x["timestamp"] or "", reverse=True) + call_records = call_records[:100] # 如果是导出请求 if export: @@ -3497,45 +3630,6 @@ async def get_admin_roles( # ============= 平台 Agent 资源申请审批 ============= -# 模板显示信息(用于在 Agent Manager 返回数据不包含显示名称时提供默认值) -TEMPLATE_DISPLAY_INFO: Dict[str, Dict[str, str]] = { - "echo_agent": { - "displayName": "Echo 测试服务", - "description": "简单的 Echo 服务,用于测试和调试", - "category": "testing", - }, - "chat_agent": { - "displayName": "聊天对话服务", - "description": "通用聊天对话 Agent,支持多轮对话", - "category": "assistant", - }, - "code_agent": { - "displayName": "代码执行服务", - "description": "代码生成和执行 Agent,支持多种编程语言", - "category": "development", - }, - "search_agent": { - "displayName": "通用搜索服务", - "description": "通用搜索 Agent,支持多种搜索引擎", - "category": "search", - }, - "jina_search_agent": { - "displayName": "Jina 搜索服务", - "description": "基于 Jina AI 的语义搜索 Agent", - "category": "search", - }, - "mysql_agent": { - "displayName": "MySQL 数据库客户端", - "description": "MySQL 数据库查询和管理 Agent", - "category": "database", - }, - "postgresql_agent": { - "displayName": "PostgreSQL 数据库客户端", - "description": "PostgreSQL 数据库查询和管理 Agent", - "category": "database", - }, -} - # 模板资源配置建议 TEMPLATE_RESOURCE_CONFIG: Dict[str, Dict[str, str]] = { "echo_agent": { @@ -3607,18 +3701,22 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession] result = [] for template in templates: template_name = template.template - display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {}) default_resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, {}) - # 优先使用数据库中的管理员配置,否则使用默认值 + # 优先使用 Agent Manager 返回的 displayName 和 description + agent_manager_display_name = template.display_name + agent_manager_description = template.description + agent_manager_category = template.category + + # 优先使用数据库中的管理员配置,否则使用 Agent Manager 返回值 db_config = db_configs.get(template_name) if db_config: # 使用数据库配置覆盖默认值 result.append({ "name": template_name, - "displayName": db_config.display_name or display_info.get("displayName", template_name), - "description": db_config.description or display_info.get("description", f"{template_name} Agent"), - "category": display_info.get("category", "general"), + "displayName": db_config.display_name or agent_manager_display_name or template_name, + "description": db_config.description or agent_manager_description or f"{template_name} Agent", + "category": agent_manager_category or "general", "version": "1.0.0", "port": template.port, "envInfo": template.env_info, @@ -3631,12 +3729,12 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession] "status": "available" if (db_config.is_enabled is None or db_config.is_enabled) else "disabled", }) else: - # 使用默认配置 + # 使用 Agent Manager 返回值和默认资源配置 result.append({ "name": template_name, - "displayName": display_info.get("displayName", template_name), - "description": display_info.get("description", f"{template_name} Agent"), - "category": display_info.get("category", "general"), + "displayName": agent_manager_display_name or template_name, + "description": agent_manager_description or f"{template_name} Agent", + "category": agent_manager_category or "general", "version": "1.0.0", "port": template.port, "envInfo": template.env_info, @@ -3653,45 +3751,9 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession] return result except Exception as e: - logger.warning("admin: 从 Agent Manager 获取平台模板失败,使用默认模板", error=str(e)) - # 返回基于 TEMPLATE_DISPLAY_INFO 的默认模板 - result = [] - for name, info in TEMPLATE_DISPLAY_INFO.items(): - default_resource_config = TEMPLATE_RESOURCE_CONFIG.get(name, {}) - - # 优先使用数据库中的管理员配置 - db_config = db_configs.get(name) - if db_config: - result.append({ - "name": name, - "displayName": db_config.display_name or info.get("displayName", name), - "description": db_config.description or info.get("description", f"{name} Agent"), - "category": info.get("category", "general"), - "version": "1.0.0", - "cpuRequest": db_config.cpu_request or default_resource_config.get("cpuRequest", "100m"), - "cpuLimit": db_config.cpu_limit or default_resource_config.get("cpuLimit", "500m"), - "memoryRequest": db_config.memory_request or default_resource_config.get("memoryRequest", "128Mi"), - "memoryLimit": db_config.memory_limit or default_resource_config.get("memoryLimit", "512Mi"), - "maxPods": db_config.max_pods if db_config.max_pods is not None else 10, - "isEnabled": db_config.is_enabled if db_config.is_enabled is not None else True, - "status": "available" if (db_config.is_enabled is None or db_config.is_enabled) else "disabled", - }) - else: - result.append({ - "name": name, - "displayName": info.get("displayName", name), - "description": info.get("description", f"{name} Agent"), - "category": info.get("category", "general"), - "version": "1.0.0", - "cpuRequest": default_resource_config.get("cpuRequest", "100m"), - "cpuLimit": default_resource_config.get("cpuLimit", "500m"), - "memoryRequest": default_resource_config.get("memoryRequest", "128Mi"), - "memoryLimit": default_resource_config.get("memoryLimit", "512Mi"), - "maxPods": 10, - "isEnabled": True, - "status": "available", - }) - return result + logger.error("admin: 从 Agent Manager 获取平台模板失败", error=str(e)) + # Agent Manager 不可用时,返回空列表 + return [] async def _validate_template_exists(template_name: str) -> bool: @@ -3702,8 +3764,8 @@ async def _validate_template_exists(template_name: str) -> bool: templates = await client.list_platform_templates() return any(t.template == template_name for t in templates) except Exception as e: - logger.warning("admin: 验证模板失败,使用本地验证", error=str(e)) - return template_name in TEMPLATE_DISPLAY_INFO + logger.error("admin: 验证模板失败", error=str(e)) + return False @router.get("/platform-agents/templates", response_model=SuccessResponse) @@ -3731,51 +3793,128 @@ async def list_platform_agent_templates( async def list_platform_agent_applications( status_filter: Optional[str] = Query(None, alias="status", pattern="^(pending|approved|rejected)$"), channel_id: Optional[str] = Query(None), + limit: int = Query(10, ge=1, le=50, description="返回数量,默认10条,最多50条"), principal: dict = Depends(require_auth), db: AsyncSession = Depends(get_db) ): """ 获取平台 Agent 申请列表(管理员视图) + 默认优先返回未审批(pending)的申请,如果未审批数量不足则用已审批的填充。 + 如果指定了 status 参数,则只返回该状态的申请。 + 权限:view:applications (所有管理员) """ _verify_read_permission(principal) - # 构建查询 - query = select(ResourceApplication, Channel).join( - Channel, ResourceApplication.channel_id == Channel.id - ).where( - ResourceApplication.resource_type == "platform_agent" - ) - - if status_filter: - query = query.where(ResourceApplication.status == status_filter) - - if channel_id: - query = query.where(ResourceApplication.channel_id == channel_id) - - query = query.order_by(desc(ResourceApplication.created_at)) - - result = await db.execute(query) - data = [] - for app, channel in result.all(): - display_info = TEMPLATE_DISPLAY_INFO.get(app.template_name, {}) - data.append({ - "id": str(app.id), - "channelId": str(app.channel_id), - "channelName": channel.name, - "resourceType": app.resource_type, - "templateName": app.template_name, - "templateDisplayName": display_info.get("displayName", app.template_name), - "requestedPodQuota": app.requested_pod_quota, - "approvedPodQuota": app.approved_pod_quota, - "reason": app.reason, - "status": app.status, - "reviewReason": app.review_reason, - "reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None, - "createdAt": app.created_at.isoformat(), - }) + + # 如果指定了状态筛选,直接按该状态查询 + if status_filter: + query = select(ResourceApplication, Channel).join( + Channel, ResourceApplication.channel_id == Channel.id + ).where( + and_( + ResourceApplication.resource_type == "platform_agent", + ResourceApplication.status == status_filter + ) + ) + + if channel_id: + query = query.where(ResourceApplication.channel_id == channel_id) + + query = query.order_by(desc(ResourceApplication.created_at)).limit(limit) + + result = await db.execute(query) + + for app, channel in result.all(): + data.append({ + "id": str(app.id), + "channelId": str(app.channel_id), + "channelName": channel.name, + "resourceType": app.resource_type, + "templateName": app.template_name, + "templateDisplayName": app.template_name, + "requestedPodQuota": app.requested_pod_quota, + "approvedPodQuota": app.approved_pod_quota, + "reason": app.reason, + "status": app.status, + "reviewReason": app.review_reason, + "reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None, + "createdAt": app.created_at.isoformat(), + }) + else: + # 未指定状态筛选时,优先返回 pending 状态的申请 + # 1. 先查询 pending 状态的申请(最多 limit 条) + pending_query = select(ResourceApplication, Channel).join( + Channel, ResourceApplication.channel_id == Channel.id + ).where( + and_( + ResourceApplication.resource_type == "platform_agent", + ResourceApplication.status == "pending" + ) + ) + + if channel_id: + pending_query = pending_query.where(ResourceApplication.channel_id == channel_id) + + pending_query = pending_query.order_by(desc(ResourceApplication.created_at)).limit(limit) + + pending_result = await db.execute(pending_query) + pending_apps = pending_result.all() + + for app, channel in pending_apps: + data.append({ + "id": str(app.id), + "channelId": str(app.channel_id), + "channelName": channel.name, + "resourceType": app.resource_type, + "templateName": app.template_name, + "templateDisplayName": app.template_name, + "requestedPodQuota": app.requested_pod_quota, + "approvedPodQuota": app.approved_pod_quota, + "reason": app.reason, + "status": app.status, + "reviewReason": app.review_reason, + "reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None, + "createdAt": app.created_at.isoformat(), + }) + + # 2. 如果 pending 数量不足 limit,用已审批的(approved/rejected)填充 + remaining = limit - len(data) + if remaining > 0: + reviewed_query = select(ResourceApplication, Channel).join( + Channel, ResourceApplication.channel_id == Channel.id + ).where( + and_( + ResourceApplication.resource_type == "platform_agent", + ResourceApplication.status.in_(["approved", "rejected"]) + ) + ) + + if channel_id: + reviewed_query = reviewed_query.where(ResourceApplication.channel_id == channel_id) + + reviewed_query = reviewed_query.order_by(desc(ResourceApplication.created_at)).limit(remaining) + + reviewed_result = await db.execute(reviewed_query) + + for app, channel in reviewed_result.all(): + data.append({ + "id": str(app.id), + "channelId": str(app.channel_id), + "channelName": channel.name, + "resourceType": app.resource_type, + "templateName": app.template_name, + "templateDisplayName": app.template_name, + "requestedPodQuota": app.requested_pod_quota, + "approvedPodQuota": app.approved_pod_quota, + "reason": app.reason, + "status": app.status, + "reviewReason": app.review_reason, + "reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None, + "createdAt": app.created_at.isoformat(), + }) return SuccessResponse(data={"applications": data}) @@ -3918,8 +4057,6 @@ async def list_platform_agent_allocations( data = [] for quota, channel in result.all(): - display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {}) - # 从模板配置中获取管理员设置的CPU和内存限制 template_config = template_configs.get(quota.template_name) cpu_limit = template_config.cpu_limit if template_config and template_config.cpu_limit else "100m" @@ -3930,7 +4067,7 @@ async def list_platform_agent_allocations( "channelId": str(quota.target_id), "channelName": channel.name if channel else "未知", "templateName": quota.template_name, - "templateDisplayName": display_info.get("displayName", quota.template_name), + "templateDisplayName": quota.template_name, "podQuota": quota.pod_quota, "podUsed": quota.pod_used, "podRemaining": quota.pod_quota - quota.pod_used, @@ -4024,14 +4161,12 @@ async def allocate_platform_agent_to_channel( await db.commit() - display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {}) - return SuccessResponse( data={ "channelId": str(channel_uuid), "channelName": channel.name, "templateName": template_name, - "templateDisplayName": display_info.get("displayName", template_name), + "templateDisplayName": template_name, "podQuota": pod_quota, }, message=message diff --git a/services/mcp-server/app/routes/agents.py b/services/mcp-server/app/routes/agents.py index 088bcf8..3c89d82 100644 --- a/services/mcp-server/app/routes/agents.py +++ b/services/mcp-server/app/routes/agents.py @@ -66,6 +66,8 @@ def _build_agent_card(agent: Agent) -> AgentCard: k8s_status=agent.k8s_status, service_port=agent.service_port, access_url=agent.access_url, + # 模型配置 + model_name=agent.model_name, # 资源配置 cpu_request=agent.cpu_request, cpu_limit=agent.cpu_limit, @@ -119,6 +121,9 @@ async def list_templates() -> TemplateListResponse: templates=[ TemplateInfo( template=t.template, + displayName=t.display_name, + description=t.description, + category=t.category, port=t.port, env_info=t.env_info ) @@ -150,6 +155,9 @@ async def list_platform_templates() -> TemplateListResponse: templates=[ TemplateInfo( template=t.template, + displayName=t.display_name, + description=t.description, + category=t.category, port=t.port, env_info=t.env_info ) @@ -182,6 +190,9 @@ async def list_custom_templates() -> TemplateListResponse: templates=[ TemplateInfo( template=t.template, + displayName=t.display_name, + description=t.description, + category=t.category, port=t.port, env_info=t.env_info ) diff --git a/services/mcp-server/app/routes/billing_webhook.py b/services/mcp-server/app/routes/billing_webhook.py index cea717a..683608d 100644 --- a/services/mcp-server/app/routes/billing_webhook.py +++ b/services/mcp-server/app/routes/billing_webhook.py @@ -455,7 +455,7 @@ async def agent_manager_callback( if existing_record: # ✅ 更新现有记录(避免重复创建) - previous_cost = Decimal(str(existing_record.cost or 0)) + # ⚠️ 不在此处扣款,由周期计费(periodic_billing.py)统一处理扣款 existing_record.end_time = end_time or datetime.utcnow() existing_record.duration_seconds = duration_seconds @@ -467,26 +467,10 @@ async def agent_manager_callback( billing_record = existing_record - # 计算增量成本(新成本 - 已扣成本) - cost_increment = new_cost - previous_cost - logger.info( - f"📝 更新现有计费记录: agent={callback_data.agentName}, " - f"之前成本={previous_cost}, 最终成本={new_cost}, 增量={cost_increment}" + f"📝 更新Agent计费记录(不扣款): agent={callback_data.agentName}, " + f"最终成本={new_cost},扣款由周期计费处理" ) - - # 只扣除增量部分(避免与周期计费重复扣款) - if cost_increment > 0: - success, message = await deduct_balance( - callback_data.userId, - cost_increment, - db, - f"Agent 结算(增量): {callback_data.agentName}" - ) - if not success: - logger.warning(f"增量余额扣除失败: {message}") - else: - logger.info(f"无需扣款(增量={cost_increment})") else: # ⚠️ 没有现有记录,创建新记录(异常情况的兜底) @@ -495,6 +479,8 @@ async def agent_manager_callback( f"agent={callback_data.agentName}, user={callback_data.userId}" ) + # ⚠️ 创建新记录(异常情况的兜底) + # ⚠️ 不在此处扣款,由周期计费(periodic_billing.py)统一处理扣款 billing_record = AgentBillingRecord( user_id=callback_data.userId, channel_id=user.channel_id if user.channel_id else None, @@ -514,16 +500,10 @@ async def agent_manager_callback( db.add(billing_record) - # 新记录需要全额扣款 - success, message = await deduct_balance( - callback_data.userId, - new_cost, - db, - f"Agent 运行: {callback_data.agentName}" + logger.info( + f"📝 创建Agent计费记录(不扣款): agent={callback_data.agentName}, " + f"最终成本={new_cost},扣款由周期计费处理" ) - - if not success: - logger.warning(f"余额扣除失败: {message}") await db.commit() await db.refresh(billing_record) diff --git a/services/mcp-server/app/routes/channel.py b/services/mcp-server/app/routes/channel.py index 9c02d35..deb3317 100644 --- a/services/mcp-server/app/routes/channel.py +++ b/services/mcp-server/app/routes/channel.py @@ -13,11 +13,12 @@ import structlog from database import get_db from models import ( User, Channel, Agent, ResourceAllocation, - BillingRecord, RechargeRecord, Application, ModelProvider, + RechargeRecord, Application, ModelProvider, ChannelProviderAccess, ProviderApplication, TenantCustomAgentQuota, ChannelCustomAgentQuota, ResourceApplication, PlatformAgentQuota, AgentBillingRecord, PlatformAgentTemplateConfig, TenantModelKey, Balance ) +# 注意:BillingRecord 已废弃,使用 AgentBillingRecord 和 ModelBillingRecord 替代 from app.auth import require_auth, get_password_hash from app.permissions import has_permission from app.schemas import ( @@ -2266,22 +2267,22 @@ async def get_channel_billing_stats( tenants = {str(t.id): t for t in tenants_result.scalars().all()} tenant_ids = list(tenants.keys()) - # 租户统计 + # 租户统计(从 AgentBillingRecord 统计) tenant_stats_result = await db.execute( select( - BillingRecord.tenant_id, - func.count(BillingRecord.id).label("calls"), - func.sum(BillingRecord.eu).label("total_eu"), - func.sum(BillingRecord.cost).label("total_cost"), + AgentBillingRecord.user_id.label("tenant_id"), + func.count(AgentBillingRecord.id).label("calls"), + func.sum(AgentBillingRecord.eu_consumed).label("total_eu"), + func.sum(AgentBillingRecord.cost).label("total_cost"), ) .where( and_( - BillingRecord.tenant_id.in_(tenant_ids), - BillingRecord.timestamp >= start_dt, - BillingRecord.timestamp <= end_dt, + AgentBillingRecord.user_id.in_(tenant_ids), + AgentBillingRecord.start_time >= start_dt, + AgentBillingRecord.start_time <= end_dt, ) ) - .group_by(BillingRecord.tenant_id) + .group_by(AgentBillingRecord.user_id) ) tenant_stats = [] @@ -2296,31 +2297,31 @@ async def get_channel_billing_stats( "totalCost": float(row.total_cost or 0), }) - # 调用记录 + # 调用记录(从 AgentBillingRecord 查询) records_result = await db.execute( - select(BillingRecord) + select(AgentBillingRecord) .where( and_( - BillingRecord.tenant_id.in_(tenant_ids), - BillingRecord.timestamp >= start_dt, - BillingRecord.timestamp <= end_dt, + AgentBillingRecord.user_id.in_(tenant_ids), + AgentBillingRecord.start_time >= start_dt, + AgentBillingRecord.start_time <= end_dt, ) ) - .order_by(desc(BillingRecord.timestamp)) + .order_by(desc(AgentBillingRecord.start_time)) .limit(100) ) call_records = [] for record in records_result.scalars().all(): - tenant = tenants.get(str(record.tenant_id)) + tenant = tenants.get(str(record.user_id)) if tenant: call_records.append({ "id": str(record.id), - "timestamp": record.timestamp.isoformat(), + "timestamp": record.start_time.isoformat() if record.start_time else record.created_at.isoformat(), "tenantName": tenant.name, "agentName": record.agent_name, - "duration": record.duration, - "eu": record.eu, + "duration": record.duration_seconds, + "eu": record.eu_consumed, "cost": float(record.cost), }) @@ -2659,45 +2660,6 @@ async def list_provider_access( # ============= 平台 Agent 资源申请 ============= -# 模板显示名称和描述映射(用于前端展示) -TEMPLATE_DISPLAY_INFO = { - "echo_agent": { - "displayName": "Echo 测试服务", - "description": "简单的 Echo 服务,用于测试和调试", - "category": "testing", - }, - "chat_agent": { - "displayName": "聊天对话服务", - "description": "智能聊天对话 Agent,支持多轮对话", - "category": "assistant", - }, - "code_agent": { - "displayName": "代码执行服务", - "description": "代码生成和执行 Agent,支持多种编程语言", - "category": "development", - }, - "search_agent": { - "displayName": "通用搜索服务", - "description": "通用搜索 Agent,支持多种搜索引擎", - "category": "search", - }, - "jina_search_agent": { - "displayName": "Jina 语义搜索服务", - "description": "基于 Jina AI 的语义搜索 Agent", - "category": "search", - }, - "mysql_agent": { - "displayName": "MySQL 数据库客户端", - "description": "MySQL 数据库查询和管理 Agent", - "category": "database", - }, - "postgresql_agent": { - "displayName": "PostgreSQL 数据库客户端", - "description": "PostgreSQL 数据库查询和管理 Agent", - "category": "database", - }, -} - # 模板资源配置建议 TEMPLATE_RESOURCE_CONFIG = { "echo_agent": {"cpuRequest": "100m", "cpuLimit": "500m", "memoryRequest": "128Mi", "memoryLimit": "512Mi"}, @@ -2738,9 +2700,13 @@ async def _get_platform_templates_from_agent_manager(db: AsyncSession = None) -> result = {} for t in templates: template_name = t.template - display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {}) admin_config = admin_configs.get(template_name) + # 优先使用 Agent Manager 返回的 displayName 和 description + agent_manager_display_name = t.display_name + agent_manager_description = t.description + agent_manager_category = t.category + # 如果有管理员配置,使用管理员配置的值;否则返回 null/0 if admin_config: resource_config = { @@ -2751,9 +2717,9 @@ async def _get_platform_templates_from_agent_manager(db: AsyncSession = None) -> "maxPods": admin_config.max_pods or 0, "isConfigured": True, } - # 如果管理员配置了显示名称和描述,使用管理员配置的 - display_name = admin_config.display_name or display_info.get("displayName", template_name) - description = admin_config.description or display_info.get("description", f"{template_name} Agent") + # 优先级:管理员配置 > Agent Manager 返回 > 模板名称 + display_name = admin_config.display_name or agent_manager_display_name or template_name + description = admin_config.description or agent_manager_description or f"{template_name} Agent" else: # 未配置时返回 null/0,表示管理员尚未配置 resource_config = { @@ -2764,14 +2730,15 @@ async def _get_platform_templates_from_agent_manager(db: AsyncSession = None) -> "maxPods": 0, "isConfigured": False, } - display_name = display_info.get("displayName", template_name) - description = display_info.get("description", f"{template_name} Agent") + # 优先级:Agent Manager 返回 > 模板名称 + display_name = agent_manager_display_name or template_name + description = agent_manager_description or f"{template_name} Agent" result[template_name] = { "name": template_name, "displayName": display_name, "description": description, - "category": display_info.get("category", "general"), + "category": agent_manager_category or "general", "version": "1.0.0", "port": t.port, "envInfo": t.env_info, @@ -2804,7 +2771,6 @@ async def _get_custom_templates_from_agent_manager() -> Dict[str, Dict[str, Any] result = {} for t in templates: template_name = t.template - display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {}) resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, { "cpuRequest": "100m", "cpuLimit": "500m", @@ -2812,11 +2778,16 @@ async def _get_custom_templates_from_agent_manager() -> Dict[str, Dict[str, Any] "memoryLimit": "512Mi" }) + # 优先使用 Agent Manager 返回的 displayName 和 description + agent_manager_display_name = t.display_name + agent_manager_description = t.description + agent_manager_category = t.category + result[template_name] = { "name": template_name, - "displayName": display_info.get("displayName", template_name), - "description": display_info.get("description", f"{template_name} Agent"), - "category": display_info.get("category", "general"), + "displayName": agent_manager_display_name or template_name, + "description": agent_manager_description or f"{template_name} Agent", + "category": agent_manager_category or "general", "version": "1.0.0", "port": t.port, "envInfo": t.env_info, @@ -3056,15 +3027,13 @@ async def list_platform_agent_applications( data = [] for app in applications: channel = channels_map.get(str(app.channel_id)) - # 使用 TEMPLATE_DISPLAY_INFO 获取显示名称 - display_info = TEMPLATE_DISPLAY_INFO.get(app.template_name, {}) data.append({ "id": str(app.id), "channelId": str(app.channel_id), "channelName": channel.name if channel else "未知", "resourceType": app.resource_type, "templateName": app.template_name, - "templateDisplayName": display_info.get("displayName", app.template_name), + "templateDisplayName": app.template_name, "requestedPodQuota": app.requested_pod_quota, "approvedPodQuota": app.approved_pod_quota, "reason": app.reason, @@ -3118,9 +3087,6 @@ async def list_channel_platform_agent_quotas( data = [] for quota in quotas: - # 使用 TEMPLATE_DISPLAY_INFO 获取显示名称 - display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {}) - # 从模板配置中获取管理员设置的CPU和内存限制 template_config = template_configs.get(quota.template_name) cpu_limit = template_config.cpu_limit if template_config and template_config.cpu_limit else "100m" @@ -3128,7 +3094,7 @@ async def list_channel_platform_agent_quotas( data.append({ "templateName": quota.template_name, - "templateDisplayName": display_info.get("displayName", quota.template_name), + "templateDisplayName": quota.template_name, "podQuota": quota.pod_quota, "podUsed": quota.pod_used, "podRemaining": quota.pod_quota - quota.pod_used, @@ -3400,11 +3366,9 @@ async def get_tenant_platform_agent_usage( data = [] for quota in quotas: - # 使用 TEMPLATE_DISPLAY_INFO 获取显示名称 - display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {}) data.append({ "templateName": quota.template_name, - "templateDisplayName": display_info.get("displayName", quota.template_name), + "templateDisplayName": quota.template_name, "podQuota": quota.pod_quota, "podUsed": quota.pod_used, "podRemaining": quota.pod_quota - quota.pod_used, diff --git a/services/mcp-server/app/routes/external_tools.py b/services/mcp-server/app/routes/external_tools.py index d8fe529..4ba775d 100644 --- a/services/mcp-server/app/routes/external_tools.py +++ b/services/mcp-server/app/routes/external_tools.py @@ -123,9 +123,7 @@ def build_full_auth_config(auth: Optional[ExternalToolAuthConfig]) -> Optional[D elif auth.type == "bearer": return { "type": "bearer", - "key": secret, - "in": "header", - "name": "Authorization" + "token": secret } elif auth.type == "basic": diff --git a/services/mcp-server/app/routes/platform_agent_quota.py b/services/mcp-server/app/routes/platform_agent_quota.py index 0fdae02..84f373b 100644 --- a/services/mcp-server/app/routes/platform_agent_quota.py +++ b/services/mcp-server/app/routes/platform_agent_quota.py @@ -218,9 +218,13 @@ async def get_available_platform_agents( template_list = [] for t in templates: template_name = t.template + # 优先使用 Agent Manager 返回的 displayName + display_name = t.display_name or template_name.replace("_", " ").title() template_list.append(PlatformAgentTemplateInfo( name=template_name, - display_name=template_name.replace("_", " ").title(), + display_name=display_name, + description=t.description, + category=t.category, port=t.port, has_access=template_name in quotas, current_quota=quotas.get(template_name, 0) diff --git a/services/mcp-server/app/routes/user.py b/services/mcp-server/app/routes/user.py index d286d17..7bed80d 100644 --- a/services/mcp-server/app/routes/user.py +++ b/services/mcp-server/app/routes/user.py @@ -2,7 +2,7 @@ 用户侧平台API路由 """ -import logging +import structlog from datetime import datetime, timedelta from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -17,6 +17,8 @@ from models import ( PlatformAgentQuota, AgentBillingRecord, TenantModelKey, ModelBillingRecord, Balance, PlatformAgentTemplateConfig ) +# ⚠️ 注意:BillingRecord 已废弃,新代码应使用 AgentBillingRecord 和 ModelBillingRecord +# 此文件中仍有部分代码使用 BillingRecord,需要后续迁移 from app.billing import ( get_agent_billing_stats, calculate_platform_agent_cost, @@ -46,7 +48,7 @@ from app.schemas import ( ScaleCustomAgentRequest, ) -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) router = APIRouter(prefix="/api/user", tags=["用户侧平台"]) @@ -984,6 +986,7 @@ async def get_my_custom_agent_quota( "totalCpu": agent_total_cpu, "totalMemory": agent_total_memory, "startTime": agent.start_time.isoformat() if agent.start_time else None, + "modelName": agent.model_name, # 返回模型名称 }) return SuccessResponse( @@ -1231,51 +1234,6 @@ async def update_tool( ) -@router.delete("/tools/{tool_id}", response_model=SuccessResponse) -async def delete_tool( - tool_id: str, - principal: dict = Depends(require_auth), - db: AsyncSession = Depends(get_db) -): - """ - 删除工具 - """ - user_id = principal.get("user_id") - - # 查询工具 - try: - tool_uuid = uuid.UUID(tool_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="工具ID格式无效" - ) - - result = await db.execute( - select(Tool).where( - and_( - Tool.id == tool_uuid, - Tool.owner_id == user_id - ) - ) - ) - tool = result.scalar_one_or_none() - - if not tool: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="工具不存在" - ) - - # 删除工具 - await db.delete(tool) - await db.commit() - - return SuccessResponse( - message="工具已删除" - ) - - # ============= 代理工厂 ============= @router.get("/agents/platform", response_model=SuccessResponse) @@ -1302,6 +1260,7 @@ async def list_platform_agents( "cpu": float(agent.cpu), "memory": float(agent.memory), "status": agent.status, + "modelName": agent.model_name, # 返回模型名称 } for agent in agents ] @@ -1458,6 +1417,7 @@ async def deploy_agent( status="active", cpu_request=quota.cpu_per_pod or "100m", memory_request=quota.memory_per_pod or "256Mi", + model_name=model_name, # 保存用户选择的模型名称 ) db.add(agent) @@ -1475,6 +1435,7 @@ async def deploy_agent( cpu_used=quota.cpu_per_pod or "100m", memory_used=quota.memory_per_pod or "256Mi", replicas=req.instances, + model_name=model_name, # 保存用户选择的模型名称 # ========== 保存访问信息 ========== external_ip=access_info.get("external_ip"), domain=access_info.get("domain"), @@ -2234,6 +2195,21 @@ async def get_available_platform_agents( ) quotas = result.scalars().all() + # 从 Agent Manager 获取平台模板信息 + from app.agent_manager_client import get_agent_manager_client + template_info_map = {} + try: + client = get_agent_manager_client() + templates = await client.list_platform_templates() + for t in templates: + template_info_map[t.template] = { + "display_name": t.display_name, + "description": t.description, + "category": t.category + } + except Exception as e: + logger.warning(f"获取 Agent Manager 模板信息失败: {e}") + # 查询所有模板配置,用于获取管理员设置的CPU和内存 template_configs_result = await db.execute(select(PlatformAgentTemplateConfig)) template_configs = {config.template_name: config for config in template_configs_result.scalars().all()} @@ -2272,17 +2248,23 @@ async def get_available_platform_agents( template=quota.template_name ) + # 获取 Agent Manager 返回的模板信息 + template_info = template_info_map.get(quota.template_name, {}) + display_name = template_info.get("display_name") or quota.template_name.replace("-", " ").replace("_", " ").title() + description = template_info.get("description") or f"Platform Agent: {quota.template_name}" + category = template_info.get("category") or "platform" + agents.append({ "id": str(quota.id), "templateName": quota.template_name, - "displayName": quota.template_name.replace("-", " ").replace("_", " ").title(), - "description": f"Platform Agent: {quota.template_name}", + "displayName": display_name, + "description": description, "podQuota": quota.pod_quota, "podUsed": actual_used, # 使用实际运行数量 "podRemaining": quota.pod_quota - actual_used, "cpuLimit": cpu_limit, "memoryLimit": memory_limit, - "category": "platform", + "category": category, "allocatedAt": quota.allocated_at.isoformat() if quota.allocated_at else None, }) @@ -2482,6 +2464,8 @@ async def deploy_platform_agent( quota.pod_used += 1 # 创建 Agent 记录 + # 获取注入的模型名称(如果有) + injected_model_name = env_vars.get("MODEL_NAME") or env_vars.get("LITELLM_MODEL") agent = Agent( name=instance_name, type="platform", @@ -2491,7 +2475,8 @@ async def deploy_platform_agent( k8s_status=result.status, service_port=result.service_port, owner_id=user_id, - status="active" + status="active", + model_name=injected_model_name, # 保存用户选择的模型名称 ) db.add(agent) @@ -2509,6 +2494,7 @@ async def deploy_platform_agent( cpu_used=quota.cpu_per_pod or "100m", memory_used=quota.memory_per_pod or "256Mi", replicas=1, + model_name=injected_model_name, # 保存用户选择的模型名称 # ========== 保存访问信息 ========== external_ip=access_info.get("external_ip"), domain=access_info.get("domain"), @@ -2768,6 +2754,7 @@ async def list_my_platform_agent_instances( "status": agent_status.status, "startTime": record.start_time.isoformat() if record.start_time else None, "runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0, + "modelName": record.model_name, # 返回模型名称 }) except AgentManagerError: # 实例可能已被删除 @@ -2777,6 +2764,7 @@ async def list_my_platform_agent_instances( "status": "unknown", "startTime": record.start_time.isoformat() if record.start_time else None, "runningSeconds": 0, + "modelName": record.model_name, # 返回模型名称 }) return SuccessResponse(data={"instances": instances}) @@ -2912,11 +2900,11 @@ async def create_custom_agent( user_id = principal.get("user_id") channel_id = principal.get("channel_id") - # 检查用户配额 + # 检查用户配额(使用行锁防止并发问题) quota_result = await db.execute( - select(TenantCustomAgentQuota).where( - TenantCustomAgentQuota.tenant_id == user_id - ) + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁,防止并发创建Agent导致超配 ) quota = quota_result.scalar_one_or_none() @@ -3195,6 +3183,36 @@ async def create_custom_agent( logger.warning(f"自动注入 OPENAI_API_KEY 失败: {str(e)}") # ============================================================ + # ✅ 预检查用户余额(防止余额不足仍创建Agent) + from app.billing import get_available_balance, calculate_agent_cost_by_resources + + # 预估至少运行1小时的成本 + cpu_cores = _parse_cpu_to_cores(req.cpuRequest) if req.cpuRequest else 0.1 + memory_gb = _parse_memory_to_gb(req.memoryRequest) if req.memoryRequest else 0.125 + estimated_duration = 3600 # 预估1小时(3600秒) + estimated_cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, estimated_duration) + + # 获取可用余额 + balance, credit_limit, available = await get_available_balance(str(user_id), db) + + if available < estimated_cost: + logger.warning( + f"余额不足,无法创建Agent: user_id={user_id}, " + f"可用余额={available:.2f} EU, 预估成本={estimated_cost:.2f} EU" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"余额不足,无法创建 Agent。" + f"预估成本(1小时): {estimated_cost:.2f} EU, " + f"当前可用余额: {available:.2f} EU," + f"请先充值" + ) + + logger.info( + f"余额检查通过: user_id={user_id}, " + f"可用余额={available:.2f} EU, 预估成本={estimated_cost:.2f} EU" + ) + try: client = get_agent_manager_client() @@ -3257,6 +3275,7 @@ async def create_custom_agent( cpu_used=req.cpuRequest, memory_used=req.memoryRequest, tools_used=[], + model_name=model_name, # 保存用户选择的模型名称 # ========== 保存访问信息 ========== external_ip=access_info.get("external_ip"), domain=access_info.get("domain"), @@ -3349,71 +3368,19 @@ async def delete_custom_agent( detail=f"未找到 Agent {name} 或该 Agent 不属于您" ) + # ✅ Step 1: 先删除 Pod(如果失败,整个操作终止,不修改数据库) + # 从 namespace 提取完整的 agent 名称(namespace 格式如 "agent-123123123-674ddd") + agent_full_name = name + if billing_record.namespace and billing_record.namespace.startswith("agent-"): + agent_full_name = billing_record.namespace[6:] # 去掉 "agent-" 前缀 + logger.info(f"使用完整 Agent 名称删除: {agent_full_name} (原名: {name}, namespace: {billing_record.namespace})") + + client = get_agent_manager_client() try: - # 先更新计费记录和释放配额(数据库操作) - billing_record.end_time = datetime.utcnow() - duration = (billing_record.end_time - billing_record.start_time).total_seconds() - billing_record.duration_seconds = int(duration) - billing_record.eu_consumed = _calculate_eu(int(duration)) - - # 计算成本并扣款(自定义Agent按资源使用量计费) - cpu_cores = _parse_cpu_to_cores(billing_record.cpu_used) if billing_record.cpu_used else 0.1 - memory_gb = _parse_memory_to_gb(billing_record.memory_used) if billing_record.memory_used else 0.125 - cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, int(duration)) - billing_record.cost = float(cost) - - # 扣除用户余额 - success, message = await deduct_balance( - user_id, cost, db, - f"自定义Agent使用: {name} (运行{int(duration)}秒)" - ) - if not success: - logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}") - - # 释放配额 - try: - cpu_released = _parse_cpu(billing_record.cpu_used or "0") - memory_released = _parse_memory(billing_record.memory_used or "0") - except (ValueError, AttributeError) as e: - logger.error(f"解析资源使用量失败: {e}, 使用默认值 0") - cpu_released = 0 - memory_released = 0 - - quota_result = await db.execute( - select(TenantCustomAgentQuota).where( - TenantCustomAgentQuota.tenant_id == user_id - ) - ) - quota = quota_result.scalar_one_or_none() - - if quota: - quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) - quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) - quota.agent_count = max(0, (quota.agent_count or 0) - 1) - - # 提交数据库更改 - await db.commit() - - # 数据库操作成功后,再删除 Agent - client = get_agent_manager_client() - try: - await client.delete_agent(name) - except Exception as agent_delete_error: - logger.error(f"Agent Manager 删除失败: {name}, 错误: {str(agent_delete_error)}") - # 注意:配额已释放,但 Agent 可能未删除 - # 可以考虑标记为"待清理"状态,供后台任务处理 - - return SuccessResponse( - message=f"自定义 Agent {name} 已删除", - data={ - "quotaReleased": { - "cpu": cpu_released, - "memory": memory_released, - } - } - ) - + await client.delete_agent(agent_full_name) + logger.info(f"✅ Pod 删除成功: {agent_full_name}") except AgentManagerError as e: + logger.error(f"❌ Pod 删除失败: {agent_full_name}, 错误: {e.message}") raise HTTPException( status_code=e.status_code, detail={ @@ -3422,6 +3389,66 @@ async def delete_custom_agent( "detail": e.detail } ) + except Exception as e: + logger.error(f"❌ Pod 删除失败(未知错误): {agent_full_name}, 错误: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"删除 Agent 失败: {str(e)}" + ) + + # ✅ Step 2: Pod 删除成功后,更新计费记录 + billing_record.end_time = datetime.utcnow() + duration = (billing_record.end_time - billing_record.start_time).total_seconds() + billing_record.duration_seconds = int(duration) + billing_record.eu_consumed = _calculate_eu(int(duration)) + + # ✅ Step 3: 计算成本并扣款(自定义Agent按资源使用量计费) + cpu_cores = _parse_cpu_to_cores(billing_record.cpu_used) if billing_record.cpu_used else 0.1 + memory_gb = _parse_memory_to_gb(billing_record.memory_used) if billing_record.memory_used else 0.125 + cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, int(duration)) + billing_record.cost = float(cost) + + # 扣除用户余额 + success, message = await deduct_balance( + user_id, cost, db, + f"自定义Agent使用: {name} (运行{int(duration)}秒)" + ) + if not success: + logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}") + + # ✅ Step 4: 释放配额(使用行锁防止并发问题) + try: + cpu_released = _parse_cpu(billing_record.cpu_used or "0") + memory_released = _parse_memory(billing_record.memory_used or "0") + except (ValueError, AttributeError) as e: + logger.error(f"解析资源使用量失败: {e}, 使用默认值 0") + cpu_released = 0 + memory_released = 0 + + quota_result = await db.execute( + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁,防止并发问题 + ) + quota = quota_result.scalar_one_or_none() + + if quota: + quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) + quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) + quota.agent_count = max(0, (quota.agent_count or 0) - 1) + + # ✅ Step 5: 提交数据库更改 + await db.commit() + + return SuccessResponse( + message=f"自定义 Agent {name} 已删除", + data={ + "quotaReleased": { + "cpu": cpu_released, + "memory": memory_released, + } + } + ) @router.put("/custom-agents/{name}/scale", response_model=SuccessResponse) @@ -3459,11 +3486,11 @@ async def scale_custom_agent_api( detail=f"未找到 Agent {name} 或该 Agent 不属于您" ) - # 获取用户配额 + # 获取用户配额(使用行锁防止并发问题) quota_result = await db.execute( - select(TenantCustomAgentQuota).where( - TenantCustomAgentQuota.tenant_id == user_id - ) + select(TenantCustomAgentQuota) + .where(TenantCustomAgentQuota.tenant_id == user_id) + .with_for_update() # ✅ 添加行锁,防止并发扩缩容导致配额计算错误 ) quota = quota_result.scalar_one_or_none() @@ -3641,11 +3668,12 @@ async def list_my_custom_agents( agent_info = { "name": record.agent_name, "template": record.agent_type, - "status": "unknown", + "status": "Pending", # 默认为 Pending 而不是 unknown "cpu": record.cpu_used, "memory": record.memory_used, "startTime": record.start_time.isoformat() if record.start_time else None, "runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0, + "modelName": record.model_name, # 返回模型名称 } if agent_manager_available: @@ -3653,9 +3681,10 @@ async def list_my_custom_agents( # 获取 Agent 状态 agent_status = await client.get_agent_status(record.agent_name) agent_info["status"] = agent_status.status - except Exception: + logger.debug(f"custom_agent_status_fetched: agent_name={record.agent_name}, status={agent_status.status}") + except Exception as e: # Agent Manager 服务不可用或 Agent 不存在 - pass + logger.warning(f"custom_agent_status_fetch_failed: agent_name={record.agent_name}, error={str(e)}") agents.append(agent_info) @@ -4110,6 +4139,7 @@ async def get_user_agents_info( "cpu": record.cpu_used, "memory": record.memory_used, "replicas": record.replicas, + "modelName": record.model_name, # 返回模型名称 "startTime": record.start_time.isoformat() if record.start_time else None, "runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0, } diff --git a/services/mcp-server/check_billing_health.py b/services/mcp-server/check_billing_health.py new file mode 100644 index 0000000..5608928 --- /dev/null +++ b/services/mcp-server/check_billing_health.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +""" +计费系统健康检查脚本 + +功能: +1. 检测假用量问题(配额数据与实际运行Agent不一致) +2. 检查余额不足用户 +3. 测试余额不足自动停止功能 +4. 生成详细健康报告 + +使用方法: +在mcp-server容器内运行: + +# 完整检查 +python check_billing_health.py + +# 只检查假用量 +python check_billing_health.py --check-quota-only + +# 只检查余额 +python check_billing_health.py --check-balance-only + +# 测试自动停止(需要指定用户ID) +python check_billing_health.py --test-auto-stop +""" + +import asyncio +import json +import logging +import argparse +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Any, Optional +from decimal import Decimal + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from models import ( + Agent, + TenantCustomAgentQuota, + AgentBillingRecord, + Balance, + User +) + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class BillingHealthChecker: + """计费系统健康检查器""" + + def __init__(self): + self.report_file = Path("/app/logs/billing_health_report.json") + self.report_file.parent.mkdir(exist_ok=True) + + async def check_quota_consistency(self, db: AsyncSession) -> Dict[str, Any]: + """ + 检查配额一致性(假用量检测) + + 对比: + - 配额表中的使用量 + - 实际运行的Agent数量和资源 + """ + logger.info("=" * 60) + logger.info("🔍 开始检查配额一致性(假用量检测)") + logger.info("=" * 60) + + # 获取所有配额记录 + result = await db.execute(select(TenantCustomAgentQuota)) + quotas = result.scalars().all() + + inconsistencies = [] + total_fake_cpu = 0.0 + total_fake_memory = 0.0 + total_fake_agents = 0 + + for quota in quotas: + tenant_id = str(quota.tenant_id) + + # 查询配额使用量 + quota_usage = { + 'cpu_used': float(quota.cpu_used or 0), + 'memory_used': float(quota.memory_used or 0), + 'agent_count': quota.agent_count or 0 + } + + # 查询实际运行的Agent + real_agents_result = await db.execute( + select(Agent) + .where(Agent.owner_id == tenant_id) + .where(Agent.type == 'custom') + .where(Agent.status == 'active') + ) + real_agents = real_agents_result.scalars().all() + + # 计算实际使用量 + real_usage = { + 'cpu_used': sum(float(a.cpu or 0) for a in real_agents), + 'memory_used': sum(float(a.memory or 0) for a in real_agents), + 'agent_count': len(real_agents) + } + + # 计算差异 + cpu_diff = quota_usage['cpu_used'] - real_usage['cpu_used'] + memory_diff = quota_usage['memory_used'] - real_usage['memory_used'] + agent_diff = quota_usage['agent_count'] - real_usage['agent_count'] + + # 允许小误差(0.01核,0.01GB) + has_inconsistency = ( + abs(cpu_diff) > 0.01 or + abs(memory_diff) > 0.01 or + agent_diff != 0 + ) + + if has_inconsistency: + inconsistency = { + 'tenant_id': tenant_id, + 'quota_usage': quota_usage, + 'real_usage': real_usage, + 'diff': { + 'cpu': cpu_diff, + 'memory': memory_diff, + 'agent_count': agent_diff + }, + 'severity': 'high' if (cpu_diff > 1 or memory_diff > 1 or agent_diff > 2) else 'medium' + } + inconsistencies.append(inconsistency) + + total_fake_cpu += cpu_diff + total_fake_memory += memory_diff + total_fake_agents += agent_diff + + logger.warning( + f"⚠️ 检测到配额不一致: 租户={tenant_id}\n" + f" 配额显示: CPU={quota_usage['cpu_used']:.2f}核, " + f"内存={quota_usage['memory_used']:.2f}GB, Agent={quota_usage['agent_count']}个\n" + f" 实际运行: CPU={real_usage['cpu_used']:.2f}核, " + f"内存={real_usage['memory_used']:.2f}GB, Agent={real_usage['agent_count']}个\n" + f" 假用量: CPU={cpu_diff:.2f}核, " + f"内存={memory_diff:.2f}GB, Agent={agent_diff}个" + ) + + summary = { + 'total_tenants': len(quotas), + 'inconsistent_tenants': len(inconsistencies), + 'consistency_rate': (len(quotas) - len(inconsistencies)) / len(quotas) * 100 if quotas else 100, + 'total_fake_cpu': total_fake_cpu, + 'total_fake_memory': total_fake_memory, + 'total_fake_agents': total_fake_agents, + 'details': inconsistencies + } + + if inconsistencies: + logger.error( + f"❌ 发现 {len(inconsistencies)} 个租户存在配额不一致问题!\n" + f" 假用量汇总: CPU={total_fake_cpu:.2f}核, " + f"内存={total_fake_memory:.2f}GB, Agent={total_fake_agents}个\n" + f" 建议运行: python fix_fake_quota.py" + ) + else: + logger.info("✅ 所有租户配额数据一致,无假用量问题") + + return summary + + async def check_balance_status(self, db: AsyncSession) -> Dict[str, Any]: + """ + 检查用户余额状态 + + 识别: + - 余额不足的用户 + - 透支用户 + - 有运行Agent但余额不足的用户(风险) + """ + logger.info("=" * 60) + logger.info("💰 开始检查用户余额状态") + logger.info("=" * 60) + + # 查询所有用户及其余额 + result = await db.execute( + select(User, Balance) + .outerjoin(Balance, User.id == Balance.user_id) + ) + users_data = result.all() + + low_balance_users = [] + overdraft_users = [] + risky_users = [] # 有运行Agent但余额不足 + + for user, balance in users_data: + user_id = str(user.id) + eu_balance = float(balance.eu_balance) if balance else 0.0 + credit_limit = float(user.credit_limit or 0) + available = eu_balance + credit_limit + + # 检查是否有运行中的Agent + running_agents_result = await db.execute( + select(func.count(AgentBillingRecord.id)) + .where(AgentBillingRecord.user_id == user_id) + .where(AgentBillingRecord.end_time == None) + ) + running_agent_count = running_agents_result.scalar() or 0 + + user_info = { + 'user_id': user_id, + 'email': user.email, + 'eu_balance': eu_balance, + 'credit_limit': credit_limit, + 'available_balance': available, + 'running_agents': running_agent_count + } + + # 透支(可用余额为负) + if available < 0: + overdraft_users.append(user_info) + logger.error( + f"💥 透支用户: {user.email} (ID: {user_id})\n" + f" 账户余额: {eu_balance:.2f} EU\n" + f" 授信额度: {credit_limit:.2f} EU\n" + f" 可用余额: {available:.2f} EU\n" + f" 运行Agent数: {running_agent_count}" + ) + + # 余额不足(低于10 EU) + elif available < 10: + low_balance_users.append(user_info) + logger.warning( + f"⚠️ 余额不足: {user.email} (ID: {user_id})\n" + f" 可用余额: {available:.2f} EU\n" + f" 运行Agent数: {running_agent_count}" + ) + + # 风险用户:有运行Agent但余额很低(< 5 EU) + if running_agent_count > 0 and available < 5: + risky_users.append(user_info) + logger.warning( + f"🚨 风险用户: {user.email} (ID: {user_id})\n" + f" 有 {running_agent_count} 个Agent运行但余额仅剩 {available:.2f} EU" + ) + + summary = { + 'total_users': len(users_data), + 'overdraft_users': len(overdraft_users), + 'low_balance_users': len(low_balance_users), + 'risky_users': len(risky_users), + 'overdraft_details': overdraft_users, + 'low_balance_details': low_balance_users, + 'risky_users_details': risky_users + } + + logger.info( + f"\n📊 余额状态汇总:\n" + f" 总用户数: {len(users_data)}\n" + f" 透支用户: {len(overdraft_users)}\n" + f" 余额不足: {len(low_balance_users)}\n" + f" 风险用户: {len(risky_users)}" + ) + + return summary + + async def test_auto_stop(self, db: AsyncSession, user_id: str) -> Dict[str, Any]: + """ + 测试余额不足自动停止功能 + + 模拟周期计费检测到余额不足时的行为 + """ + logger.info("=" * 60) + logger.info(f"🧪 测试自动停止功能: user_id={user_id}") + logger.info("=" * 60) + + from app.billing import get_available_balance + from app.periodic_billing import stop_user_agents + + # 检查用户余额 + balance, credit_limit, available = await get_available_balance(user_id, db) + + logger.info( + f"用户余额状态:\n" + f" 账户余额: {balance:.2f} EU\n" + f" 授信额度: {credit_limit:.2f} EU\n" + f" 可用余额: {available:.2f} EU" + ) + + # 检查运行中的Agent + running_agents_result = await db.execute( + select(AgentBillingRecord) + .where(AgentBillingRecord.user_id == user_id) + .where(AgentBillingRecord.end_time == None) + ) + running_agents = running_agents_result.scalars().all() + + logger.info(f"运行中的Agent数量: {len(running_agents)}") + for agent in running_agents: + logger.info(f" - {agent.agent_name} (启动时间: {agent.start_time})") + + result = { + 'user_id': user_id, + 'balance': float(balance), + 'credit_limit': float(credit_limit), + 'available': float(available), + 'running_agents_before': len(running_agents), + 'stopped_agents': [], + 'action_taken': 'none' + } + + # 如果余额不足,执行停止 + if available < 0: + logger.warning(f"⚠️ 可用余额为负 ({available:.2f} EU),将停止所有Agent...") + + stopped = await stop_user_agents(user_id, db) + result['stopped_agents'] = stopped + result['action_taken'] = 'stopped' + + logger.info( + f"✅ 已停止 {len(stopped)} 个Agent:\n" + + "\n".join(f" - {name}" for name in stopped) + ) + else: + logger.info(f"✅ 余额充足 ({available:.2f} EU),无需停止Agent") + result['action_taken'] = 'no_action_needed' + + return result + + async def generate_full_report(self, db: AsyncSession) -> Dict[str, Any]: + """生成完整的健康检查报告""" + logger.info("\n" + "=" * 60) + logger.info("📋 生成计费系统健康检查报告") + logger.info("=" * 60 + "\n") + + report = { + 'check_time': datetime.utcnow().isoformat(), + 'quota_consistency': await self.check_quota_consistency(db), + 'balance_status': await self.check_balance_status(db) + } + + # 生成健康评分 + quota_ok = report['quota_consistency']['inconsistent_tenants'] == 0 + no_overdraft = report['balance_status']['overdraft_users'] == 0 + few_risky = report['balance_status']['risky_users'] < 5 + + health_score = 0 + if quota_ok: + health_score += 40 + if no_overdraft: + health_score += 40 + if few_risky: + health_score += 20 + + report['health_score'] = health_score + report['health_status'] = ( + 'healthy' if health_score >= 90 else + 'warning' if health_score >= 70 else + 'critical' + ) + + # 生成建议 + recommendations = [] + if not quota_ok: + recommendations.append("运行 fix_fake_quota.py 修复配额不一致问题") + if not no_overdraft: + recommendations.append(f"有 {report['balance_status']['overdraft_users']} 个用户透支,建议停止其Agent或充值") + if not few_risky: + recommendations.append(f"有 {report['balance_status']['risky_users']} 个风险用户,建议提醒充值") + + report['recommendations'] = recommendations + + # 保存报告 + with open(self.report_file, 'w', encoding='utf-8') as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + logger.info(f"\n{'=' * 60}") + logger.info(f"📊 健康检查完成") + logger.info(f"{'=' * 60}") + logger.info(f"健康评分: {health_score}/100 ({report['health_status'].upper()})") + logger.info(f"报告保存: {self.report_file}") + + if recommendations: + logger.info(f"\n💡 建议:") + for i, rec in enumerate(recommendations, 1): + logger.info(f" {i}. {rec}") + + return report + + +async def main(): + """主函数""" + parser = argparse.ArgumentParser(description='计费系统健康检查') + parser.add_argument('--check-quota-only', action='store_true', help='只检查配额一致性') + parser.add_argument('--check-balance-only', action='store_true', help='只检查余额状态') + parser.add_argument('--test-auto-stop', type=str, metavar='USER_ID', help='测试自动停止功能(指定用户ID)') + + args = parser.parse_args() + + checker = BillingHealthChecker() + + async for db in get_db(): + try: + if args.check_quota_only: + await checker.check_quota_consistency(db) + elif args.check_balance_only: + await checker.check_balance_status(db) + elif args.test_auto_stop: + await checker.test_auto_stop(db, args.test_auto_stop) + else: + await checker.generate_full_report(db) + + except Exception as e: + logger.error(f"执行失败: {str(e)}", exc_info=True) + finally: + break + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/services/mcp-server/config.py b/services/mcp-server/config.py index 27c396c..5ee28ec 100644 --- a/services/mcp-server/config.py +++ b/services/mcp-server/config.py @@ -10,13 +10,6 @@ from pydantic_settings import BaseSettings BASE_DIR = Path(__file__).resolve().parent -SQLITE_FALLBACK_PATH = BASE_DIR / "data" / "mcp_fallback.db" -SQLITE_FALLBACK_PATH.parent.mkdir(parents=True, exist_ok=True) - -# 默认的PostgreSQL URL(主数据库)- 从环境变量获取 -DEFAULT_POSTGRES_URL = os.getenv("DATABASE_URL") or os.getenv("ASYNC_DATABASE_URL") or "" -# SQLite回退URL在需要紧急切换时可手动使用 -DEFAULT_SQLITE_URL = f"sqlite+aiosqlite:///{SQLITE_FALLBACK_PATH}" class Settings(BaseSettings): @@ -160,22 +153,14 @@ class ProductionSettings(Settings): log_level: str = "INFO" -class TestingSettings(Settings): - """测试环境配置""" - debug: bool = True - database_url: str = "sqlite+aiosqlite:///./test.db" - redis_url: str = "redis://localhost:6379/1" # 使用不同的数据库 - - def get_settings() -> Settings: """根据环境变量获取相应的配置""" environment = os.getenv("ENVIRONMENT", "development").lower() if environment == "production": return ProductionSettings() - elif environment == "testing": - return TestingSettings() else: + # 默认使用开发环境配置,不再支持测试环境配置 return DevelopmentSettings() diff --git a/services/mcp-server/data/mcp_fallback.db b/services/mcp-server/data/mcp_fallback.db deleted file mode 100644 index 3ca501d6886d3dab931c2c1fc41646f7bd3aac3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 200704 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 diff --git a/services/mcp-server/database.py b/services/mcp-server/database.py index 92e9a60..74145d9 100644 --- a/services/mcp-server/database.py +++ b/services/mcp-server/database.py @@ -48,40 +48,26 @@ def prepare_database_url(url: str) -> str: database_url = prepare_database_url(settings.database_url) database_url_obj = make_url(database_url) +# PostgreSQL 连接池配置 engine_kwargs = { "echo": settings.debug, "pool_pre_ping": True, + "pool_size": 10, + "max_overflow": 20, + "pool_recycle": 600, + "pool_timeout": 30, + "echo_pool": settings.debug, } -if database_url_obj.get_backend_name().startswith("sqlite"): - engine_kwargs["connect_args"] = {"check_same_thread": False} -else: - engine_kwargs.update({ - "pool_size": 10, # 优化:减少基础连接池大小 - "max_overflow": 20, # 优化:减少溢出连接数 - "pool_recycle": 600, # 优化:10分钟回收,避免连接泄漏 - "pool_timeout": 30, - "pool_pre_ping": True, # 确保连接可用 - "echo_pool": settings.debug, # 添加连接池日志 - }) - - # Azure Database for PostgreSQL 或任何包含 sslmode 的连接都需要 TLS - if (database_url_obj.host and database_url_obj.host.endswith("postgres.database.azure.com")) or \ - "sslmode" in settings.database_url: - # Azure Database for PostgreSQL requires TLS; provide a default SSL context. - ssl_context = ssl.create_default_context() - existing_connect_args = engine_kwargs.get("connect_args") or {} - existing_connect_args["ssl"] = ssl_context - engine_kwargs["connect_args"] = existing_connect_args +# Azure Database for PostgreSQL 需要 TLS +if (database_url_obj.host and database_url_obj.host.endswith("postgres.database.azure.com")) or \ + "sslmode" in settings.database_url: + ssl_context = ssl.create_default_context() + engine_kwargs["connect_args"] = {"ssl": ssl_context} # 创建异步数据库引擎 engine = create_async_engine(database_url, **engine_kwargs) -# 判断当前是否使用SQLite后端 -def is_sqlite_backend() -> bool: - backend = engine.url.get_backend_name() - return backend.startswith("sqlite") - # 创建异步会话工厂 AsyncSessionLocal = async_sessionmaker( engine, @@ -287,37 +273,22 @@ async def get_db_stats(): async def cleanup_old_records(): - """清理旧记录""" + """清理旧记录(仅支持 PostgreSQL)""" try: async with AsyncSessionLocal() as session: # 清理超过30天的执行记录 - if is_sqlite_backend(): - result = await session.execute(text(""" - DELETE FROM executions - WHERE created_at < datetime('now', '-30 days') - """)) - else: - result = await session.execute(text(""" - DELETE FROM executions - WHERE created_at < NOW() - INTERVAL '30 days' - """)) - + result = await session.execute(text(""" + DELETE FROM executions + WHERE created_at < NOW() - INTERVAL '30 days' + """)) deleted_executions = result.rowcount # 清理超过7天的会话记录 - if is_sqlite_backend(): - result = await session.execute(text(""" - DELETE FROM sessions - WHERE created_at < datetime('now', '-7 days') - AND status != 'active' - """)) - else: - result = await session.execute(text(""" - DELETE FROM sessions - WHERE created_at < NOW() - INTERVAL '7 days' - AND status != 'active' - """)) - + result = await session.execute(text(""" + DELETE FROM sessions + WHERE created_at < NOW() - INTERVAL '7 days' + AND status != 'active' + """)) deleted_sessions = result.rowcount await session.commit() @@ -335,28 +306,17 @@ async def cleanup_old_records(): async def backup_db(): - """数据库备份""" + """数据库备份(仅支持 PostgreSQL)""" try: import subprocess from datetime import datetime import os - import shutil # 生成备份文件名 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_dir = "/app/backups" os.makedirs(backup_dir, exist_ok=True) - if is_sqlite_backend(): - db_path = engine.url.database - if not db_path: - raise ValueError("SQLite数据库路径为空,无法备份") - db_path = os.path.abspath(db_path) - backup_file = os.path.join(backup_dir, f"mcp_sqlite_backup_{timestamp}.db") - shutil.copy2(db_path, backup_file) - get_logger().info(f"SQLite数据库备份成功: {backup_file}") - return backup_file - backup_file = f"{backup_dir}/taiji_db_backup_{timestamp}.sql" # 执行pg_dump命令 cmd = [ diff --git a/services/mcp-server/k8s/deployment.yaml b/services/mcp-server/k8s/deployment.yaml index e4960fd..fbd3c7e 100644 --- a/services/mcp-server/k8s/deployment.yaml +++ b/services/mcp-server/k8s/deployment.yaml @@ -35,12 +35,17 @@ spec: - name: DEBUG value: "false" - # 数据库配置(使用Azure Database for PostgreSQL) + # 数据库配置(使用Azure Database for PostgreSQL - 测试库 taiji) - name: DATABASE_URL valueFrom: secretKeyRef: name: taiji-secrets key: database-url + - name: ASYNC_DATABASE_URL + valueFrom: + secretKeyRef: + name: taiji-secrets + key: async-database-url # Redis配置(使用Azure Cache for Redis) - name: REDIS_URL diff --git a/services/mcp-server/k8s/secrets.yaml b/services/mcp-server/k8s/secrets.yaml index d592e41..8be6203 100644 --- a/services/mcp-server/k8s/secrets.yaml +++ b/services/mcp-server/k8s/secrets.yaml @@ -8,8 +8,9 @@ metadata: namespace: taiji-ai type: Opaque stringData: - # 数据库连接字符串 - 使用 taiji 数据库 - database-url: "postgresql+asyncpg://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require" + # 数据库连接字符串 - 测试环境使用 taiji 数据库 + database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require" + async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji" # Redis连接字符串(Redis 已迁移到 taiji2026 实例) redis-url: "rediss://:PzmWkM6CwfRrJTB1d2xLRxE9pzT7JKgvVAzCaEehmFE=@taiji2026.southeastasia.redis.azure.net:10000/0?ssl_cert_reqs=none" diff --git a/services/mcp-server/migrations/001_add_resource_control_fields.sql b/services/mcp-server/migrations/001_add_resource_control_fields.sql deleted file mode 100644 index 36a4980..0000000 --- a/services/mcp-server/migrations/001_add_resource_control_fields.sql +++ /dev/null @@ -1,32 +0,0 @@ --- 资源管控字段迁移脚本 --- 为users表添加资源限制字段 - --- 添加rpm_limit字段(每分钟请求数限制) -ALTER TABLE users -ADD COLUMN IF NOT EXISTS rpm_limit INTEGER DEFAULT 60; - --- 添加tpm_limit字段(每分钟Token数限制) -ALTER TABLE users -ADD COLUMN IF NOT EXISTS tpm_limit INTEGER DEFAULT 10000; - --- 添加daily_cost_limit字段(每日成本限制) -ALTER TABLE users -ADD COLUMN IF NOT EXISTS daily_cost_limit NUMERIC(12, 2) DEFAULT 100.00; - --- 为现有用户设置默认值 -UPDATE users -SET rpm_limit = 60 -WHERE rpm_limit IS NULL; - -UPDATE users -SET tpm_limit = 10000 -WHERE tpm_limit IS NULL; - -UPDATE users -SET daily_cost_limit = 100.00 -WHERE daily_cost_limit IS NULL; - --- 添加注释 -COMMENT ON COLUMN users.rpm_limit IS '每分钟请求数限制(Requests Per Minute)'; -COMMENT ON COLUMN users.tpm_limit IS '每分钟Token数限制(Tokens Per Minute)'; -COMMENT ON COLUMN users.daily_cost_limit IS '每日成本限制(元)'; diff --git a/services/mcp-server/migrations/002_add_pricing_and_quota_alerts.sql b/services/mcp-server/migrations/002_add_pricing_and_quota_alerts.sql deleted file mode 100644 index 63bf08d..0000000 --- a/services/mcp-server/migrations/002_add_pricing_and_quota_alerts.sql +++ /dev/null @@ -1,210 +0,0 @@ --- 阶段一:资源管控数据库迁移脚本 --- 创建模型定价表和配额预警表 - --- ===================================================== --- 1. 模型定价表 (model_pricing) --- ===================================================== -CREATE TABLE IF NOT EXISTS model_pricing ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - provider_id UUID NOT NULL REFERENCES model_providers(id) ON DELETE CASCADE, - model_name VARCHAR(200) NOT NULL, - - -- 定价信息(单位:USD) - input_price_per_1k NUMERIC(12, 6) NOT NULL, -- 输入价格/1K tokens (USD) - output_price_per_1k NUMERIC(12, 6) NOT NULL, -- 输出价格/1K tokens (USD) - eu_per_1k_tokens NUMERIC(12, 6) DEFAULT 0.1, -- EU转换率/1K tokens - - -- 模型限制 - max_context_length INTEGER DEFAULT 4096, -- 最大上下文长度 - max_output_tokens INTEGER DEFAULT 2048, -- 最大输出tokens - - -- 状态 - is_active BOOLEAN DEFAULT TRUE, - effective_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - effective_to TIMESTAMP NULL, - - -- 时间戳 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - - -- 约束 - CONSTRAINT uq_model_effective_date UNIQUE (model_name, effective_from) -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_model_pricing_lookup - ON model_pricing(model_name, is_active); - -CREATE INDEX IF NOT EXISTS idx_model_pricing_provider - ON model_pricing(provider_id); - -CREATE INDEX IF NOT EXISTS idx_model_pricing_effective - ON model_pricing(effective_from, effective_to) - WHERE is_active = TRUE; - --- 添加注释 -COMMENT ON TABLE model_pricing IS '模型定价表,支持历史定价追溯'; -COMMENT ON COLUMN model_pricing.input_price_per_1k IS '输入价格/1K tokens (USD)'; -COMMENT ON COLUMN model_pricing.output_price_per_1k IS '输出价格/1K tokens (USD)'; -COMMENT ON COLUMN model_pricing.eu_per_1k_tokens IS 'EU转换率/1K tokens'; -COMMENT ON COLUMN model_pricing.effective_from IS '生效开始时间'; -COMMENT ON COLUMN model_pricing.effective_to IS '生效结束时间(NULL表示当前有效)'; - - --- ===================================================== --- 2. 配额预警表 (quota_alerts) --- ===================================================== -CREATE TABLE IF NOT EXISTS quota_alerts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, - - -- 预警信息 - alert_type VARCHAR(50) NOT NULL, -- 预警类型 - threshold_percent INTEGER, -- 预警阈值百分比 - current_value NUMERIC(12, 2), -- 当前值 - threshold_value NUMERIC(12, 2), -- 阈值 - - -- 预警状态 - status VARCHAR(20) DEFAULT 'active' NOT NULL, -- active, acknowledged, resolved - acknowledged_at TIMESTAMP NULL, - acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL, - resolved_at TIMESTAMP NULL, - resolved_by UUID REFERENCES users(id) ON DELETE SET NULL, - - -- 时间戳 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - - -- 约束:user_id和channel_id至少有一个非空 - CONSTRAINT chk_quota_alert_target CHECK ( - user_id IS NOT NULL OR channel_id IS NOT NULL - ) -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_quota_alerts_status - ON quota_alerts(status, created_at); - -CREATE INDEX IF NOT EXISTS idx_quota_alerts_user - ON quota_alerts(user_id, status) - WHERE user_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_quota_alerts_channel - ON quota_alerts(channel_id, status) - WHERE channel_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_quota_alerts_type - ON quota_alerts(alert_type, status); - --- 添加注释 -COMMENT ON TABLE quota_alerts IS '配额预警表'; -COMMENT ON COLUMN quota_alerts.alert_type IS '预警类型: balance_warning, balance_critical, balance_exhausted, rate_limit, channel_quota等'; -COMMENT ON COLUMN quota_alerts.status IS '状态: active(活跃), acknowledged(已确认), resolved(已解决)'; -COMMENT ON COLUMN quota_alerts.threshold_percent IS '预警阈值百分比(如余额低于20%)'; - - --- ===================================================== --- 3. 插入初始模型定价数据(示例) --- ===================================================== - --- 假设已有provider,先查询一个provider_id用于示例 --- 实际使用时需要替换为真实的provider_id - --- 示例:OpenAI GPT-4o 定价 --- INSERT INTO model_pricing (provider_id, model_name, input_price_per_1k, output_price_per_1k, eu_per_1k_tokens, max_context_length, max_output_tokens) --- SELECT --- id, --- 'gpt-4o', --- 0.005, --- 0.015, --- 0.1, --- 128000, --- 4096 --- FROM model_providers --- WHERE name = 'OpenAI' --- LIMIT 1; - --- 示例:OpenAI GPT-4o-mini 定价 --- INSERT INTO model_pricing (provider_id, model_name, input_price_per_1k, output_price_per_1k, eu_per_1k_tokens, max_context_length, max_output_tokens) --- SELECT --- id, --- 'gpt-4o-mini', --- 0.00015, --- 0.0006, --- 0.05, --- 128000, --- 16384 --- FROM model_providers --- WHERE name = 'OpenAI' --- LIMIT 1; - - --- ===================================================== --- 4. 创建触发器:自动更新 updated_at --- ===================================================== - --- model_pricing 触发器 -CREATE OR REPLACE FUNCTION update_model_pricing_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_update_model_pricing_updated_at - BEFORE UPDATE ON model_pricing - FOR EACH ROW - EXECUTE FUNCTION update_model_pricing_updated_at(); - --- quota_alerts 触发器 -CREATE OR REPLACE FUNCTION update_quota_alerts_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_update_quota_alerts_updated_at - BEFORE UPDATE ON quota_alerts - FOR EACH ROW - EXECUTE FUNCTION update_quota_alerts_updated_at(); - - --- ===================================================== --- 5. 权限设置(可选,根据实际需求调整) --- ===================================================== - --- 授予应用用户读写权限 --- GRANT SELECT, INSERT, UPDATE, DELETE ON model_pricing TO taiji_app_user; --- GRANT SELECT, INSERT, UPDATE, DELETE ON quota_alerts TO taiji_app_user; --- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO taiji_app_user; - - --- ===================================================== --- 验证脚本 --- ===================================================== - --- 验证表是否创建成功 -SELECT - 'model_pricing' as table_name, - COUNT(*) as column_count -FROM information_schema.columns -WHERE table_name = 'model_pricing' -UNION ALL -SELECT - 'quota_alerts' as table_name, - COUNT(*) as column_count -FROM information_schema.columns -WHERE table_name = 'quota_alerts'; - --- 验证索引是否创建成功 -SELECT - tablename, - indexname, - indexdef -FROM pg_indexes -WHERE tablename IN ('model_pricing', 'quota_alerts') -ORDER BY tablename, indexname; diff --git a/services/mcp-server/migrations/003_add_eu_fields_to_users.sql b/services/mcp-server/migrations/003_add_eu_fields_to_users.sql deleted file mode 100644 index 5dbbc4a..0000000 --- a/services/mcp-server/migrations/003_add_eu_fields_to_users.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Migration: 添加EU计费字段到users表 --- Date: 2025-12-30 --- Description: 为users表添加eu_balance和total_eu_consumed字段用于执行单元计费 - --- 添加eu_balance字段(EU余额) -ALTER TABLE users -ADD COLUMN IF NOT EXISTS eu_balance NUMERIC(15, 2) DEFAULT 0; - --- 添加total_eu_consumed字段(总EU消耗) -ALTER TABLE users -ADD COLUMN IF NOT EXISTS total_eu_consumed NUMERIC(15, 2) DEFAULT 0; - --- 更新注释 -COMMENT ON COLUMN users.eu_balance IS 'EU余额(执行单元余额)'; -COMMENT ON COLUMN users.total_eu_consumed IS '历史总EU消耗量'; - --- 为现有用户初始化默认值 -UPDATE users SET eu_balance = 0 WHERE eu_balance IS NULL; -UPDATE users SET total_eu_consumed = 0 WHERE total_eu_consumed IS NULL; diff --git a/services/mcp-server/migrations/004_add_k8s_agent_fields.sql b/services/mcp-server/migrations/004_add_k8s_agent_fields.sql deleted file mode 100644 index bb9fd61..0000000 --- a/services/mcp-server/migrations/004_add_k8s_agent_fields.sql +++ /dev/null @@ -1,49 +0,0 @@ --- 迁移脚本:添加 Kubernetes Agent 相关字段 --- 版本:004 --- 日期:2024-12-31 --- 描述:为 Agent 表添加 Kubernetes Pod 管理所需的字段 - --- 添加 Pod 相关字段 -ALTER TABLE agents ADD COLUMN IF NOT EXISTS pod_name VARCHAR(100); -ALTER TABLE agents ADD COLUMN IF NOT EXISTS pod_ip VARCHAR(45); -ALTER TABLE agents ADD COLUMN IF NOT EXISTS template VARCHAR(100); -ALTER TABLE agents ADD COLUMN IF NOT EXISTS service_port INTEGER; -ALTER TABLE agents ADD COLUMN IF NOT EXISTS k8s_namespace VARCHAR(100) DEFAULT 'ai-agents'; -ALTER TABLE agents ADD COLUMN IF NOT EXISTS k8s_status VARCHAR(20) DEFAULT 'Unknown'; - --- 添加资源配置字段(K8s 格式) -ALTER TABLE agents ADD COLUMN IF NOT EXISTS cpu_request VARCHAR(20) DEFAULT '100m'; -ALTER TABLE agents ADD COLUMN IF NOT EXISTS cpu_limit VARCHAR(20) DEFAULT '500m'; -ALTER TABLE agents ADD COLUMN IF NOT EXISTS memory_request VARCHAR(20) DEFAULT '128Mi'; -ALTER TABLE agents ADD COLUMN IF NOT EXISTS memory_limit VARCHAR(20) DEFAULT '512Mi'; - --- 添加环境变量配置 -ALTER TABLE agents ADD COLUMN IF NOT EXISTS env_config JSONB DEFAULT '{}'; - --- 添加访问信息 -ALTER TABLE agents ADD COLUMN IF NOT EXISTS access_url VARCHAR(500); -ALTER TABLE agents ADD COLUMN IF NOT EXISTS endpoints JSONB DEFAULT '{}'; - --- 添加 Pod 创建时间(K8s 返回的时间) -ALTER TABLE agents ADD COLUMN IF NOT EXISTS pod_created_at TIMESTAMP; - --- 添加索引 -CREATE INDEX IF NOT EXISTS idx_agent_pod_name ON agents(pod_name); -CREATE INDEX IF NOT EXISTS idx_agent_template ON agents(template); -CREATE INDEX IF NOT EXISTS idx_agent_k8s_status ON agents(k8s_status); - --- 添加注释 -COMMENT ON COLUMN agents.pod_name IS 'Kubernetes Pod 名称'; -COMMENT ON COLUMN agents.pod_ip IS 'Pod IP 地址'; -COMMENT ON COLUMN agents.template IS 'Agent 模板类型(如 echo_agent, jina_search_agent)'; -COMMENT ON COLUMN agents.service_port IS '服务端口(HTTP 服务类型 Agent)'; -COMMENT ON COLUMN agents.k8s_namespace IS 'Kubernetes 命名空间'; -COMMENT ON COLUMN agents.k8s_status IS 'Pod 状态(Pending, Running, Succeeded, Failed, Unknown)'; -COMMENT ON COLUMN agents.cpu_request IS 'CPU 请求量(K8s 格式,如 100m)'; -COMMENT ON COLUMN agents.cpu_limit IS 'CPU 限制量(K8s 格式,如 500m)'; -COMMENT ON COLUMN agents.memory_request IS '内存请求量(K8s 格式,如 128Mi)'; -COMMENT ON COLUMN agents.memory_limit IS '内存限制量(K8s 格式,如 512Mi)'; -COMMENT ON COLUMN agents.env_config IS '环境变量配置(JSON 格式)'; -COMMENT ON COLUMN agents.access_url IS 'Agent 访问 URL'; -COMMENT ON COLUMN agents.endpoints IS 'Agent 端点信息(JSON 格式)'; -COMMENT ON COLUMN agents.pod_created_at IS 'Pod 创建时间(K8s 返回)'; diff --git a/services/mcp-server/migrations/005_refactor_agent_quota_system.sql b/services/mcp-server/migrations/005_refactor_agent_quota_system.sql deleted file mode 100644 index 05eeb55..0000000 --- a/services/mcp-server/migrations/005_refactor_agent_quota_system.sql +++ /dev/null @@ -1,180 +0,0 @@ --- Migration: 005_refactor_agent_quota_system --- Description: 重构 Agent 配额系统,支持平台端 Agent 和自定义 Agent 的资源分配 --- Date: 2026-01-04 - --- ===================================================== --- 1. 修改 agents 表,添加自定义 Agent 特有字段和健康监控字段 --- ===================================================== - --- 添加自定义 Agent 特有字段 -ALTER TABLE agents ADD COLUMN IF NOT EXISTS image_url VARCHAR(500); -ALTER TABLE agents ADD COLUMN IF NOT EXISTS program_path VARCHAR(500); -ALTER TABLE agents ADD COLUMN IF NOT EXISTS runtime_type VARCHAR(50); - --- 添加健康监控字段 -ALTER TABLE agents ADD COLUMN IF NOT EXISTS health_status VARCHAR(20) DEFAULT 'unknown'; -ALTER TABLE agents ADD COLUMN IF NOT EXISTS last_health_check TIMESTAMP; -ALTER TABLE agents ADD COLUMN IF NOT EXISTS health_message TEXT; - --- 添加健康状态索引 -CREATE INDEX IF NOT EXISTS idx_agent_health_status ON agents(health_status); - --- 添加字段注释 -COMMENT ON COLUMN agents.image_url IS '自定义 Agent 的镜像地址'; -COMMENT ON COLUMN agents.program_path IS '自定义 Agent 的程序路径'; -COMMENT ON COLUMN agents.runtime_type IS '自定义 Agent 的运行时类型:python, nodejs, docker 等'; -COMMENT ON COLUMN agents.health_status IS 'Agent 健康状态:healthy, warning, critical, unknown'; -COMMENT ON COLUMN agents.last_health_check IS '最后健康检查时间'; -COMMENT ON COLUMN agents.health_message IS '健康状态消息'; - --- ===================================================== --- 2. 修改 channels 表,添加自定义 Agent 配额字段 --- ===================================================== - --- 添加新的配额字段 -ALTER TABLE channels ADD COLUMN IF NOT EXISTS custom_agent_cpu_quota NUMERIC(12, 2) DEFAULT 0; -ALTER TABLE channels ADD COLUMN IF NOT EXISTS custom_agent_memory_quota NUMERIC(12, 2) DEFAULT 0; - --- 迁移旧数据:将 custom_agent_cpu/memory 的值复制到新字段 -UPDATE channels -SET custom_agent_cpu_quota = COALESCE(custom_agent_cpu, 0), - custom_agent_memory_quota = COALESCE(custom_agent_memory, 0) -WHERE custom_agent_cpu_quota = 0 OR custom_agent_memory_quota = 0; - --- 添加字段注释 -COMMENT ON COLUMN channels.custom_agent_cpu_quota IS '自定义 Agent CPU 配额上限(核心数),渠道可分配给租户的总配额'; -COMMENT ON COLUMN channels.custom_agent_memory_quota IS '自定义 Agent 内存配额上限(GB),渠道可分配给租户的总配额'; -COMMENT ON COLUMN channels.custom_agent_cpu IS '已废弃,使用 custom_agent_cpu_quota'; -COMMENT ON COLUMN channels.custom_agent_memory IS '已废弃,使用 custom_agent_memory_quota'; - --- ===================================================== --- 3. 创建租户自定义 Agent 配额表 --- ===================================================== - -CREATE TABLE IF NOT EXISTS tenant_custom_agent_quotas ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - - tenant_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - - -- 配额上限(由渠道分配) - cpu_quota NUMERIC(12, 2) DEFAULT 0, - memory_quota NUMERIC(12, 2) DEFAULT 0, - - -- 已使用量(由系统计算) - cpu_used NUMERIC(12, 2) DEFAULT 0, - memory_used NUMERIC(12, 2) DEFAULT 0, - agent_count INTEGER DEFAULT 0 -); - --- 添加索引 -CREATE INDEX IF NOT EXISTS idx_tenant_custom_agent_quota_tenant ON tenant_custom_agent_quotas(tenant_id); - --- 添加表注释 -COMMENT ON TABLE tenant_custom_agent_quotas IS '租户自定义 Agent 资源配额表'; -COMMENT ON COLUMN tenant_custom_agent_quotas.cpu_quota IS 'CPU 配额上限(核心数)'; -COMMENT ON COLUMN tenant_custom_agent_quotas.memory_quota IS '内存配额上限(GB)'; -COMMENT ON COLUMN tenant_custom_agent_quotas.cpu_used IS '已使用 CPU 核心数'; -COMMENT ON COLUMN tenant_custom_agent_quotas.memory_used IS '已使用内存 GB'; -COMMENT ON COLUMN tenant_custom_agent_quotas.agent_count IS '已创建的自定义 Agent 数量'; - --- ===================================================== --- 4. 创建渠道自定义 Agent 配额表 --- ===================================================== - -CREATE TABLE IF NOT EXISTS channel_custom_agent_quotas ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - - channel_id UUID NOT NULL UNIQUE REFERENCES channels(id) ON DELETE CASCADE, - - -- 配额上限(由管理员分配) - cpu_quota NUMERIC(12, 2) DEFAULT 0, - memory_quota NUMERIC(12, 2) DEFAULT 0, - - -- 已分配量(分配给租户的总量) - cpu_allocated NUMERIC(12, 2) DEFAULT 0, - memory_allocated NUMERIC(12, 2) DEFAULT 0 -); - --- 添加索引 -CREATE INDEX IF NOT EXISTS idx_channel_custom_agent_quota_channel ON channel_custom_agent_quotas(channel_id); - --- 添加表注释 -COMMENT ON TABLE channel_custom_agent_quotas IS '渠道自定义 Agent 资源配额表'; -COMMENT ON COLUMN channel_custom_agent_quotas.cpu_quota IS 'CPU 配额上限(核心数),由管理员分配'; -COMMENT ON COLUMN channel_custom_agent_quotas.memory_quota IS '内存配额上限(GB),由管理员分配'; -COMMENT ON COLUMN channel_custom_agent_quotas.cpu_allocated IS '已分配给租户的 CPU 核心数'; -COMMENT ON COLUMN channel_custom_agent_quotas.memory_allocated IS '已分配给租户的内存 GB'; - --- ===================================================== --- 5. 创建触发器:自动更新 updated_at --- ===================================================== - --- 创建更新时间戳的函数(如果不存在) -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - --- 为新表添加触发器 -DROP TRIGGER IF EXISTS update_tenant_custom_agent_quotas_updated_at ON tenant_custom_agent_quotas; -CREATE TRIGGER update_tenant_custom_agent_quotas_updated_at - BEFORE UPDATE ON tenant_custom_agent_quotas - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_channel_custom_agent_quotas_updated_at ON channel_custom_agent_quotas; -CREATE TRIGGER update_channel_custom_agent_quotas_updated_at - BEFORE UPDATE ON channel_custom_agent_quotas - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- ===================================================== --- 6. 初始化现有渠道的配额记录 --- ===================================================== - --- 为现有渠道创建配额记录(如果不存在) -INSERT INTO channel_custom_agent_quotas (channel_id, cpu_quota, memory_quota) -SELECT id, COALESCE(custom_agent_cpu_quota, custom_agent_cpu, 0), COALESCE(custom_agent_memory_quota, custom_agent_memory, 0) -FROM channels -WHERE id NOT IN (SELECT channel_id FROM channel_custom_agent_quotas) -ON CONFLICT (channel_id) DO NOTHING; - --- ===================================================== --- 7. 为现有租户创建配额记录(初始配额为0,需要渠道分配) --- ===================================================== - -INSERT INTO tenant_custom_agent_quotas (tenant_id, cpu_quota, memory_quota) -SELECT id, 0, 0 -FROM users -WHERE role = 'user' AND id NOT IN (SELECT tenant_id FROM tenant_custom_agent_quotas) -ON CONFLICT (tenant_id) DO NOTHING; - --- ===================================================== --- 8. 更新现有自定义 Agent 的配额使用量 --- ===================================================== - --- 计算每个租户的自定义 Agent 资源使用量并更新配额表 -WITH custom_agent_usage AS ( - SELECT - owner_id, - COUNT(*) as agent_count, - COALESCE(SUM(cpu), 0) as cpu_used, - COALESCE(SUM(memory), 0) as memory_used - FROM agents - WHERE type = 'custom' AND status != 'inactive' - GROUP BY owner_id -) -UPDATE tenant_custom_agent_quotas tq -SET - cpu_used = cau.cpu_used, - memory_used = cau.memory_used, - agent_count = cau.agent_count -FROM custom_agent_usage cau -WHERE tq.tenant_id = cau.owner_id; diff --git a/services/mcp-server/migrations/006_add_agent_resource_management.sql b/services/mcp-server/migrations/006_add_agent_resource_management.sql deleted file mode 100644 index 640bbd0..0000000 --- a/services/mcp-server/migrations/006_add_agent_resource_management.sql +++ /dev/null @@ -1,160 +0,0 @@ --- Migration: 006_add_agent_resource_management --- Description: 添加 Agent 资源管理相关表(资源申请、平台Agent配额、Agent计费记录) --- Date: 2026-01-05 - --- 1. 资源申请表(统一的申请审批) -CREATE TABLE IF NOT EXISTS resource_applications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - - channel_id UUID NOT NULL REFERENCES channels(id), - - -- 申请类型: platform_agent, provider, custom_agent_quota - resource_type VARCHAR(30) NOT NULL, - - -- 平台 Agent 申请字段 - template_name VARCHAR(100), - requested_pod_quota INTEGER, - - -- 模型供应商申请字段 - provider_id UUID REFERENCES model_providers(id), - requested_rpm INTEGER, - requested_tpm INTEGER, - - -- 自定义 Agent 资源申请字段 - requested_cpu_quota NUMERIC(12, 2), - requested_memory_quota NUMERIC(12, 2), - - -- 申请信息 - reason TEXT NOT NULL, - - -- 审批状态: pending, approved, rejected - status VARCHAR(20) DEFAULT 'pending', - - -- 审批结果 - approved_pod_quota INTEGER, - approved_rpm INTEGER, - approved_tpm INTEGER, - approved_cpu_quota NUMERIC(12, 2), - approved_memory_quota NUMERIC(12, 2), - - -- 审批信息 - reviewed_by UUID REFERENCES users(id), - review_reason TEXT, - reviewed_at TIMESTAMP -); - --- 资源申请表索引 -CREATE INDEX IF NOT EXISTS idx_resource_app_channel ON resource_applications(channel_id); -CREATE INDEX IF NOT EXISTS idx_resource_app_type ON resource_applications(resource_type); -CREATE INDEX IF NOT EXISTS idx_resource_app_status ON resource_applications(status); -CREATE INDEX IF NOT EXISTS idx_resource_app_created ON resource_applications(created_at); - --- 2. 平台 Agent 配额分配表 -CREATE TABLE IF NOT EXISTS platform_agent_quotas ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - - -- 分配目标 - target_id UUID NOT NULL, - target_type VARCHAR(20) NOT NULL, -- channel, tenant - - -- Agent 模板 - template_name VARCHAR(100) NOT NULL, - - -- 配额 - pod_quota INTEGER NOT NULL DEFAULT 0, - pod_used INTEGER DEFAULT 0, - - -- 分配信息 - allocated_by UUID REFERENCES users(id), - allocated_at TIMESTAMP DEFAULT NOW(), - - -- 唯一约束 - CONSTRAINT uq_platform_agent_quota UNIQUE (target_id, target_type, template_name) -); - --- 平台 Agent 配额表索引 -CREATE INDEX IF NOT EXISTS idx_platform_agent_quota_target ON platform_agent_quotas(target_id, target_type); -CREATE INDEX IF NOT EXISTS idx_platform_agent_quota_template ON platform_agent_quotas(template_name); - --- 3. Agent 计费记录表 -CREATE TABLE IF NOT EXISTS agent_billing_records ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - - user_id UUID NOT NULL REFERENCES users(id), - channel_id UUID REFERENCES channels(id), - - -- Agent 信息 - agent_name VARCHAR(100) NOT NULL, - agent_type VARCHAR(20) NOT NULL, -- platform, custom - template_name VARCHAR(100) NOT NULL, - - -- 使用量 - duration_seconds INTEGER NOT NULL, - cpu_seconds FLOAT DEFAULT 0, - memory_gb_seconds FLOAT DEFAULT 0, - request_count INTEGER DEFAULT 0, - - -- 费用 - cost NUMERIC(12, 4) NOT NULL, - currency VARCHAR(10) DEFAULT 'EU', - - -- 时间范围 - period_start TIMESTAMP NOT NULL, - period_end TIMESTAMP NOT NULL -); - --- Agent 计费记录表索引 -CREATE INDEX IF NOT EXISTS idx_agent_billing_user ON agent_billing_records(user_id); -CREATE INDEX IF NOT EXISTS idx_agent_billing_channel ON agent_billing_records(channel_id); -CREATE INDEX IF NOT EXISTS idx_agent_billing_period ON agent_billing_records(period_start, period_end); -CREATE INDEX IF NOT EXISTS idx_agent_billing_type ON agent_billing_records(agent_type); - --- 4. 添加触发器自动更新 updated_at -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - --- 为新表添加触发器 -DROP TRIGGER IF EXISTS update_resource_applications_updated_at ON resource_applications; -CREATE TRIGGER update_resource_applications_updated_at - BEFORE UPDATE ON resource_applications - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_platform_agent_quotas_updated_at ON platform_agent_quotas; -CREATE TRIGGER update_platform_agent_quotas_updated_at - BEFORE UPDATE ON platform_agent_quotas - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_agent_billing_records_updated_at ON agent_billing_records; -CREATE TRIGGER update_agent_billing_records_updated_at - BEFORE UPDATE ON agent_billing_records - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- 5. 添加注释 -COMMENT ON TABLE resource_applications IS '资源申请表 - 渠道申请平台Agent、供应商、自定义Agent资源'; -COMMENT ON TABLE platform_agent_quotas IS '平台Agent配额表 - 记录渠道和租户的平台Agent Pod配额'; -COMMENT ON TABLE agent_billing_records IS 'Agent计费记录表 - 记录Agent使用的计费信息'; - -COMMENT ON COLUMN resource_applications.resource_type IS '申请类型: platform_agent, provider, custom_agent_quota'; -COMMENT ON COLUMN resource_applications.template_name IS '平台Agent模板名称'; -COMMENT ON COLUMN resource_applications.status IS '审批状态: pending, approved, rejected'; - -COMMENT ON COLUMN platform_agent_quotas.target_type IS '分配目标类型: channel, tenant'; -COMMENT ON COLUMN platform_agent_quotas.pod_quota IS 'Pod数量配额'; -COMMENT ON COLUMN platform_agent_quotas.pod_used IS '已使用Pod数量'; - -COMMENT ON COLUMN agent_billing_records.agent_type IS 'Agent类型: platform, custom'; -COMMENT ON COLUMN agent_billing_records.duration_seconds IS '运行时长(秒)'; diff --git a/services/mcp-server/migrations/007_change_resource_id_to_string.sql b/services/mcp-server/migrations/007_change_resource_id_to_string.sql deleted file mode 100644 index c7f0eaa..0000000 --- a/services/mcp-server/migrations/007_change_resource_id_to_string.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Migration: 007_change_resource_id_to_string --- Description: 将 resource_allocations 表的 resource_id 字段从 UUID 改为 VARCHAR(100) --- 原因: resource_id 需要存储平台 Agent 模板名称(如 'code-reviewer')而不仅仅是 UUID --- Date: 2026-01-05 - --- 1. 删除旧索引(如果存在) -DROP INDEX IF EXISTS idx_resource_allocation_resource; - --- 2. 修改 resource_id 字段类型 --- 注意:PostgreSQL 需要使用 USING 子句来转换类型 -ALTER TABLE resource_allocations -ALTER COLUMN resource_id TYPE VARCHAR(100) -USING resource_id::VARCHAR(100); - --- 3. 重新创建索引 -CREATE INDEX idx_resource_allocation_resource ON resource_allocations(resource_id, resource_type); - --- 验证修改 --- SELECT column_name, data_type, character_maximum_length --- FROM information_schema.columns --- WHERE table_name = 'resource_allocations' AND column_name = 'resource_id'; diff --git a/services/mcp-server/migrations/008_add_quota_management.sql b/services/mcp-server/migrations/008_add_quota_management.sql deleted file mode 100644 index 1966f2f..0000000 --- a/services/mcp-server/migrations/008_add_quota_management.sql +++ /dev/null @@ -1,132 +0,0 @@ --- 阶段二:配额管理和申请审批数据库迁移 --- 版本: 008 --- 日期: 2026-01-05 --- 说明: 添加资源申请、平台Agent配额和Agent计费记录表 - --- 1. 资源申请表(统一的申请审批) -CREATE TABLE IF NOT EXISTS resource_applications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - channel_id UUID NOT NULL REFERENCES channels(id), - - -- 申请类型 - resource_type VARCHAR(30) NOT NULL, -- platform_agent, provider, custom_agent_quota - - -- 平台 Agent 申请字段 - template_name VARCHAR(100), - requested_pod_quota INTEGER, - - -- 模型供应商申请字段 - provider_id UUID REFERENCES model_providers(id), - requested_rpm INTEGER, - requested_tpm INTEGER, - - -- 自定义 Agent 资源申请字段 - requested_cpu_quota NUMERIC(12, 2), - requested_memory_quota NUMERIC(12, 2), - - -- 申请信息 - reason TEXT NOT NULL, - - -- 审批状态 - status VARCHAR(20) DEFAULT 'pending', -- pending, approved, rejected - - -- 审批结果 - approved_pod_quota INTEGER, - approved_rpm INTEGER, - approved_tpm INTEGER, - approved_cpu_quota NUMERIC(12, 2), - approved_memory_quota NUMERIC(12, 2), - - -- 审批信息 - reviewed_by UUID REFERENCES users(id), - review_reason TEXT, - reviewed_at TIMESTAMP, - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_resource_app_channel ON resource_applications(channel_id); -CREATE INDEX IF NOT EXISTS idx_resource_app_type ON resource_applications(resource_type); -CREATE INDEX IF NOT EXISTS idx_resource_app_status ON resource_applications(status); -CREATE INDEX IF NOT EXISTS idx_resource_app_created ON resource_applications(created_at); - --- 2. 平台 Agent 配额表 -CREATE TABLE IF NOT EXISTS platform_agent_quotas ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - target_id UUID NOT NULL, - target_type VARCHAR(20) NOT NULL, -- channel, tenant - template_name VARCHAR(100) NOT NULL, - pod_quota INTEGER NOT NULL DEFAULT 0, - pod_used INTEGER DEFAULT 0, - allocated_by UUID REFERENCES users(id), - allocated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(target_id, target_type, template_name) -); - -CREATE INDEX IF NOT EXISTS idx_platform_agent_quota_target ON platform_agent_quotas(target_id, target_type); -CREATE INDEX IF NOT EXISTS idx_platform_agent_quota_template ON platform_agent_quotas(template_name); - --- 3. Agent 计费记录表 -CREATE TABLE IF NOT EXISTS agent_billing_records ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - channel_id UUID REFERENCES channels(id), - agent_name VARCHAR(100) NOT NULL, - agent_type VARCHAR(20) NOT NULL, -- platform, custom - template_name VARCHAR(100) NOT NULL, - duration_seconds INTEGER NOT NULL, - cpu_seconds FLOAT DEFAULT 0, - memory_gb_seconds FLOAT DEFAULT 0, - request_count INTEGER DEFAULT 0, - cost NUMERIC(12, 4) NOT NULL, - currency VARCHAR(10) DEFAULT 'EU', - period_start TIMESTAMP NOT NULL, - period_end TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_agent_billing_user ON agent_billing_records(user_id); -CREATE INDEX IF NOT EXISTS idx_agent_billing_channel ON agent_billing_records(channel_id); -CREATE INDEX IF NOT EXISTS idx_agent_billing_period ON agent_billing_records(period_start, period_end); -CREATE INDEX IF NOT EXISTS idx_agent_billing_type ON agent_billing_records(agent_type); - --- 4. 添加触发器更新 updated_at -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ language 'plpgsql'; - -DROP TRIGGER IF EXISTS update_resource_applications_updated_at ON resource_applications; -CREATE TRIGGER update_resource_applications_updated_at - BEFORE UPDATE ON resource_applications - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_platform_agent_quotas_updated_at ON platform_agent_quotas; -CREATE TRIGGER update_platform_agent_quotas_updated_at - BEFORE UPDATE ON platform_agent_quotas - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_agent_billing_records_updated_at ON agent_billing_records; -CREATE TRIGGER update_agent_billing_records_updated_at - BEFORE UPDATE ON agent_billing_records - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- 5. 添加注释 -COMMENT ON TABLE resource_applications IS '资源申请表(统一的申请审批)'; -COMMENT ON TABLE platform_agent_quotas IS '平台 Agent 配额分配表'; -COMMENT ON TABLE agent_billing_records IS 'Agent 计费记录表'; - -COMMENT ON COLUMN resource_applications.resource_type IS '申请类型: platform_agent, provider, custom_agent_quota'; -COMMENT ON COLUMN resource_applications.status IS '审批状态: pending, approved, rejected'; -COMMENT ON COLUMN platform_agent_quotas.target_type IS '分配目标类型: channel, tenant'; -COMMENT ON COLUMN agent_billing_records.agent_type IS 'Agent 类型: platform, custom'; diff --git a/services/mcp-server/migrations/009_add_platform_agent_template_config.sql b/services/mcp-server/migrations/009_add_platform_agent_template_config.sql deleted file mode 100644 index 4f5dd03..0000000 --- a/services/mcp-server/migrations/009_add_platform_agent_template_config.sql +++ /dev/null @@ -1,50 +0,0 @@ --- 迁移脚本:添加平台 Agent 模板配置表 --- 版本:009 --- 日期:2026-01-06 --- 描述: --- 1. 创建 platform_agent_template_configs 表,存储管理员配置的模板资源限制 --- 2. 用于 Bug 修复:available-platform-agents 接口返回管理员配置的值 - --- 创建平台 Agent 模板配置表 -CREATE TABLE IF NOT EXISTS platform_agent_template_configs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- 模板名称(唯一) - template_name VARCHAR(100) UNIQUE NOT NULL, - - -- 资源配置 - cpu_request VARCHAR(20), -- CPU 请求量,如 "100m" - cpu_limit VARCHAR(20), -- CPU 限制量,如 "500m" - memory_request VARCHAR(20), -- 内存请求量,如 "128Mi" - memory_limit VARCHAR(20), -- 内存限制量,如 "512Mi" - max_pods INTEGER DEFAULT 0, -- 最大 Pod 数量(0 表示未配置) - - -- 配置信息 - configured_by UUID REFERENCES users(id), -- 配置人 - configured_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 配置时间 - - -- 状态 - is_enabled BOOLEAN DEFAULT TRUE, -- 是否启用 - - -- 描述信息(可选,覆盖默认描述) - display_name VARCHAR(200), -- 显示名称 - description TEXT, -- 描述 - - -- 时间戳 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_template_config_name ON platform_agent_template_configs(template_name); -CREATE INDEX IF NOT EXISTS idx_template_config_enabled ON platform_agent_template_configs(is_enabled); - --- 添加注释 -COMMENT ON TABLE platform_agent_template_configs IS '平台 Agent 模板配置表,存储管理员配置的资源限制'; -COMMENT ON COLUMN platform_agent_template_configs.template_name IS '模板名称,如 echo_agent, jina_search_agent'; -COMMENT ON COLUMN platform_agent_template_configs.cpu_request IS 'K8s CPU 请求量,如 100m, 500m'; -COMMENT ON COLUMN platform_agent_template_configs.cpu_limit IS 'K8s CPU 限制量,如 500m, 2000m'; -COMMENT ON COLUMN platform_agent_template_configs.memory_request IS 'K8s 内存请求量,如 128Mi, 1Gi'; -COMMENT ON COLUMN platform_agent_template_configs.memory_limit IS 'K8s 内存限制量,如 512Mi, 4Gi'; -COMMENT ON COLUMN platform_agent_template_configs.max_pods IS '最大 Pod 数量,0 表示未配置'; -COMMENT ON COLUMN platform_agent_template_configs.is_enabled IS '是否启用该模板'; diff --git a/services/mcp-server/migrations/010_add_agent_billing_fields.sql b/services/mcp-server/migrations/010_add_agent_billing_fields.sql deleted file mode 100644 index a933c59..0000000 --- a/services/mcp-server/migrations/010_add_agent_billing_fields.sql +++ /dev/null @@ -1,36 +0,0 @@ --- Migration: Add missing fields to agent_billing_records table --- Version: 010 --- Date: 2026-01-07 - --- Add is_platform_agent column -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS is_platform_agent BOOLEAN DEFAULT TRUE; - --- Add start_time column -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS start_time TIMESTAMP WITHOUT TIME ZONE; - --- Add end_time column -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS end_time TIMESTAMP WITHOUT TIME ZONE; - --- Make period_start and period_end nullable -ALTER TABLE agent_billing_records -ALTER COLUMN period_start DROP NOT NULL; - -ALTER TABLE agent_billing_records -ALTER COLUMN period_end DROP NOT NULL; - --- Make cost have a default value -ALTER TABLE agent_billing_records -ALTER COLUMN cost SET DEFAULT 0; - --- Make template_name nullable -ALTER TABLE agent_billing_records -ALTER COLUMN template_name DROP NOT NULL; - --- Create index for is_platform_agent -CREATE INDEX IF NOT EXISTS idx_agent_billing_is_platform ON agent_billing_records(is_platform_agent); - --- Create index for end_time (for finding running agents) -CREATE INDEX IF NOT EXISTS idx_agent_billing_end_time ON agent_billing_records(end_time); diff --git a/services/mcp-server/migrations/011_add_litellm_integration.sql b/services/mcp-server/migrations/011_add_litellm_integration.sql deleted file mode 100644 index 3604fa5..0000000 --- a/services/mcp-server/migrations/011_add_litellm_integration.sql +++ /dev/null @@ -1,73 +0,0 @@ --- 迁移脚本:添加 LiteLLM 集成相关表和字段 --- 版本:011 --- 日期:2026-01-07 --- 描述:实现模型供应商与租户模型使用设计方案 - --- 1. 给 channels 表添加 litellm_team_id 字段 -ALTER TABLE channels ADD COLUMN IF NOT EXISTS litellm_team_id VARCHAR(100); - --- 添加索引 -CREATE INDEX IF NOT EXISTS idx_channel_litellm_team ON channels(litellm_team_id); - --- 2. 创建 tenant_model_keys 表 -CREATE TABLE IF NOT EXISTS tenant_model_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES users(id), - channel_id UUID REFERENCES channels(id), - - -- 模型信息 - model_name VARCHAR(100) NOT NULL, - - -- LiteLLM Key 信息 - litellm_key_id VARCHAR(255) NOT NULL, -- LiteLLM 返回的完整 key - litellm_key_hash TEXT NOT NULL, -- 加密存储 - - -- 配额配置(与 LiteLLM 同步) - rpm_limit INTEGER DEFAULT 0, - tpm_limit INTEGER DEFAULT 0, - max_budget NUMERIC(12, 2), - budget_duration VARCHAR(20) DEFAULT 'monthly', - - -- 状态 - status VARCHAR(20) DEFAULT 'active', - - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - - -- 唯一约束:每个租户每个模型只能有一个 Key - CONSTRAINT uq_tenant_model UNIQUE(tenant_id, model_name) -); - --- 添加索引 -CREATE INDEX IF NOT EXISTS idx_tenant_model_key_tenant ON tenant_model_keys(tenant_id); -CREATE INDEX IF NOT EXISTS idx_tenant_model_key_model ON tenant_model_keys(model_name); -CREATE INDEX IF NOT EXISTS idx_tenant_model_key_status ON tenant_model_keys(status); - --- 3. 添加注释 -COMMENT ON TABLE tenant_model_keys IS '租户模型 Key 表,存储租户在 LiteLLM 中的 API Key 信息'; -COMMENT ON COLUMN tenant_model_keys.model_name IS '模型名称,如 azure/gpt-4, gemini/gemini-pro'; -COMMENT ON COLUMN tenant_model_keys.litellm_key_id IS 'LiteLLM 返回的完整 API Key'; -COMMENT ON COLUMN tenant_model_keys.litellm_key_hash IS '加密存储的 Key(用于解密后注入到 Agent)'; -COMMENT ON COLUMN tenant_model_keys.rpm_limit IS '每分钟请求数限制'; -COMMENT ON COLUMN tenant_model_keys.tpm_limit IS '每分钟 Token 数限制'; -COMMENT ON COLUMN tenant_model_keys.max_budget IS '最大预算'; -COMMENT ON COLUMN tenant_model_keys.budget_duration IS '预算周期:monthly(每月)或 total(总计)'; -COMMENT ON COLUMN tenant_model_keys.status IS '状态:active(活跃)、suspended(暂停)、expired(过期)'; - -COMMENT ON COLUMN channels.litellm_team_id IS 'LiteLLM team ID,创建渠道时同步创建'; - --- 4. 创建更新时间触发器 -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - --- 为 tenant_model_keys 表添加更新时间触发器 -DROP TRIGGER IF EXISTS update_tenant_model_keys_updated_at ON tenant_model_keys; -CREATE TRIGGER update_tenant_model_keys_updated_at - BEFORE UPDATE ON tenant_model_keys - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); diff --git a/services/mcp-server/migrations/012_add_model_billing_records.sql b/services/mcp-server/migrations/012_add_model_billing_records.sql deleted file mode 100644 index eece7b8..0000000 --- a/services/mcp-server/migrations/012_add_model_billing_records.sql +++ /dev/null @@ -1,53 +0,0 @@ --- 模型调用计费记录表 - LiteLLM Callback数据 -CREATE TABLE IF NOT EXISTS model_billing_records ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - -- 关联信息(从LiteLLM metadata解析) - tenant_id UUID REFERENCES users(id), - channel_id UUID REFERENCES channels(id), - - -- LiteLLM回调数据 - litellm_call_id VARCHAR(100) UNIQUE NOT NULL, - api_key VARCHAR(255), -- 使用的API Key(脱敏) - team_id VARCHAR(100), -- LiteLLM Team ID - - -- 模型信息 - model_name VARCHAR(100) NOT NULL, -- 如 azure/gpt-4 - model_id VARCHAR(100), - - -- Token用量 - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - total_tokens INTEGER NOT NULL DEFAULT 0, - - -- 费用计算 - input_cost NUMERIC(12, 6) DEFAULT 0, - output_cost NUMERIC(12, 6) DEFAULT 0, - total_cost NUMERIC(12, 6) DEFAULT 0, - eu_consumed NUMERIC(12, 4) DEFAULT 0, - - -- 调用信息 - request_id VARCHAR(100), - call_type VARCHAR(50) DEFAULT 'completion', - status VARCHAR(20) DEFAULT 'success', - - -- 时间信息 - start_time TIMESTAMP WITH TIME ZONE, - end_time TIMESTAMP WITH TIME ZONE, - response_time_ms INTEGER, - - -- 原始数据 - raw_callback_data JSONB -); - --- 创建索引 -CREATE INDEX idx_model_billing_tenant ON model_billing_records(tenant_id); -CREATE INDEX idx_model_billing_created ON model_billing_records(created_at); -CREATE INDEX idx_model_billing_model ON model_billing_records(model_name); -CREATE INDEX idx_model_billing_call_id ON model_billing_records(litellm_call_id); - -COMMENT ON TABLE model_billing_records IS '模型调用计费记录 - 来自LiteLLM的Token使用数据'; -COMMENT ON COLUMN model_billing_records.litellm_call_id IS 'LiteLLM调用ID,用于幂等性检查'; -COMMENT ON COLUMN model_billing_records.eu_consumed IS '根据Token计算的EU消耗'; diff --git a/services/mcp-server/migrations/013_add_agent_callback_fields.sql b/services/mcp-server/migrations/013_add_agent_callback_fields.sql deleted file mode 100644 index 9a43142..0000000 --- a/services/mcp-server/migrations/013_add_agent_callback_fields.sql +++ /dev/null @@ -1,30 +0,0 @@ --- 迁移脚本:为 agent_billing_records 表添加回调相关字段 --- 日期:2026-01-11 --- 目的:支持 Agent Manager 回调,记录工具使用和请求信息 - -BEGIN; - --- 1. 添加 tools_used 字段(JSON 类型,存储使用的工具列表) -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS tools_used JSONB; - --- 2. 添加 request_id 字段(存储请求 ID) -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS request_id VARCHAR(100); - --- 3. 添加 eu_consumed 字段(如果不存在) -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS eu_consumed INTEGER DEFAULT 0; - --- 4. 为 request_id 创建索引,方便查询 -CREATE INDEX IF NOT EXISTS idx_agent_billing_request_id ON agent_billing_records(request_id); - --- 5. 为 tools_used 创建 GIN 索引,支持 JSON 查询 -CREATE INDEX IF NOT EXISTS idx_agent_billing_tools_used ON agent_billing_records USING gin(tools_used); - --- 6. 添加注释 -COMMENT ON COLUMN agent_billing_records.tools_used IS 'Agent 使用的工具列表(JSON 数组)'; -COMMENT ON COLUMN agent_billing_records.request_id IS '请求 ID,用于关联 Agent Manager 的调用'; -COMMENT ON COLUMN agent_billing_records.eu_consumed IS 'EU 消耗量'; - -COMMIT; diff --git a/services/mcp-server/migrations/014_add_username_unique_constraint.sql b/services/mcp-server/migrations/014_add_username_unique_constraint.sql deleted file mode 100644 index 4515b19..0000000 --- a/services/mcp-server/migrations/014_add_username_unique_constraint.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Migration: 014_add_username_unique_constraint --- Description: 为 users 表的 username 字段添加唯一约束,防止并发注册竞态条件 --- Date: 2026-01-12 - --- 先检查是否已存在该约束 -DO $$ -BEGIN - -- 检查是否存在重复的 username(需要先处理) - IF EXISTS ( - SELECT username, COUNT(*) - FROM users - WHERE username IS NOT NULL - GROUP BY username - HAVING COUNT(*) > 1 - ) THEN - RAISE NOTICE '发现重复的 username,请先手动处理重复数据'; - -- 可以选择自动处理:为重复的 username 添加后缀 - -- UPDATE users SET username = username || '_' || id::text WHERE ... - END IF; - - -- 检查约束是否已存在 - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'users_username_key' - AND conrelid = 'users'::regclass - ) THEN - -- 添加唯一约束(允许 NULL 值,只对非 NULL 值检查唯一性) - ALTER TABLE users ADD CONSTRAINT users_username_key UNIQUE (username); - RAISE NOTICE '成功添加 username 唯一约束'; - ELSE - RAISE NOTICE 'username 唯一约束已存在,跳过'; - END IF; -END $$; - diff --git a/services/mcp-server/migrations/015_add_tool_template_fields.sql b/services/mcp-server/migrations/015_add_tool_template_fields.sql deleted file mode 100644 index d41d3a9..0000000 --- a/services/mcp-server/migrations/015_add_tool_template_fields.sql +++ /dev/null @@ -1,30 +0,0 @@ --- 迁移脚本:添加工具模板相关字段 --- 版本:015 --- 日期:2026-01-13 --- 描述: --- 1. 添加 template 字段到 tools 表,用于关联 Agent 模板 --- 2. 添加 env_config 字段到 tools 表,存储模板所需的环境变量配置 --- 3. 修改 schema 字段为可空(模板工具不需要 schema) --- 4. 创建索引优化按模板类型查询 --- --- 业务背景: --- 用户基于 Agent 模板(如 mysql_agent、postgresql_agent)创建工具时, --- 需要保存模板名称和填写的环境变量配置(如数据库连接信息)。 --- 创建自定义 Agent 时,从工具中获取模板类型和配置。 - --- 添加 template 字段(模板名称,如 "mysql_agent", "postgresql_agent") -ALTER TABLE tools ADD COLUMN IF NOT EXISTS template VARCHAR(100); - --- 添加 env_config 字段(环境变量配置,JSON 格式) -ALTER TABLE tools ADD COLUMN IF NOT EXISTS env_config JSONB DEFAULT '{}'; - --- 修改 schema 字段为可空(模板工具可能没有 schema) -ALTER TABLE tools ALTER COLUMN schema DROP NOT NULL; - --- 创建索引(用于按模板类型查询工具) -CREATE INDEX IF NOT EXISTS idx_tool_template ON tools(template); - --- 添加注释 -COMMENT ON COLUMN tools.template IS 'Agent 模板名称,如 mysql_agent、postgresql_agent。用于基于模板创建的工具'; -COMMENT ON COLUMN tools.env_config IS '环境变量配置(JSON格式),根据模板的 env_info 填写。如 MySQL 连接信息'; - diff --git a/services/mcp-server/migrations/016_add_agent_access_info_fields.sql b/services/mcp-server/migrations/016_add_agent_access_info_fields.sql deleted file mode 100644 index b82b285..0000000 --- a/services/mcp-server/migrations/016_add_agent_access_info_fields.sql +++ /dev/null @@ -1,61 +0,0 @@ --- Migration 016: Add Agent Access Info Fields --- Description: 为 agent_billing_records 表添加访问信息字段(域名、外网IP等) --- Date: 2026-01-14 - --- ========== 添加访问信息字段 ========== - --- 外网 IP 地址 -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS external_ip VARCHAR(45); - --- 域名 -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS domain VARCHAR(255); - --- 域名访问地址 -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS domain_url VARCHAR(500); - --- 推荐访问地址 -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS access_url VARCHAR(500); - --- 服务端口 -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS service_port INTEGER; - --- K8s 命名空间 -ALTER TABLE agent_billing_records -ADD COLUMN IF NOT EXISTS namespace VARCHAR(100); - --- ========== 添加索引 ========== - --- 按域名查询索引(用于根据域名查找 Agent) -CREATE INDEX IF NOT EXISTS idx_agent_billing_domain -ON agent_billing_records(domain); - --- ========== 添加注释 ========== - -COMMENT ON COLUMN agent_billing_records.external_ip IS '外网 IP 地址(Agent Manager 分配)'; -COMMENT ON COLUMN agent_billing_records.domain IS '域名(如 my-agent.taijiagent.com)'; -COMMENT ON COLUMN agent_billing_records.domain_url IS '域名访问地址(如 http://my-agent.taijiagent.com)'; -COMMENT ON COLUMN agent_billing_records.access_url IS '推荐访问地址(域名优先)'; -COMMENT ON COLUMN agent_billing_records.service_port IS '服务端口'; -COMMENT ON COLUMN agent_billing_records.namespace IS 'Kubernetes 命名空间'; - --- ========== 验证迁移 ========== - --- 检查字段是否添加成功 -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'agent_billing_records' - AND column_name = 'domain' - ) THEN - RAISE NOTICE 'Migration 016 completed successfully: domain field added'; - ELSE - RAISE EXCEPTION 'Migration 016 failed: domain field not found'; - END IF; -END $$; - diff --git a/services/mcp-server/migrations/019_add_agent_model_name.sql b/services/mcp-server/migrations/019_add_agent_model_name.sql new file mode 100644 index 0000000..5f46e59 --- /dev/null +++ b/services/mcp-server/migrations/019_add_agent_model_name.sql @@ -0,0 +1,21 @@ +-- 019_add_agent_model_name.sql +-- 为 Agent 添加模型名称字段 +-- 用于存储部署 Agent 时用户选择的 LLM 模型名称 + +-- 添加 model_name 字段到 agents 表 +ALTER TABLE agents ADD COLUMN IF NOT EXISTS model_name VARCHAR(100); + +-- 添加索引以支持按模型名称查询 +CREATE INDEX IF NOT EXISTS idx_agent_model_name ON agents(model_name); + +-- 添加注释 +COMMENT ON COLUMN agents.model_name IS 'Agent 使用的 LLM 模型名称,如 azure/gpt-4, gemini/gemini-pro'; + +-- 添加 model_name 字段到 agent_billing_records 表(用于自定义 Agent 计费记录) +ALTER TABLE agent_billing_records ADD COLUMN IF NOT EXISTS model_name VARCHAR(100); + +-- 添加索引以支持按模型名称查询计费记录 +CREATE INDEX IF NOT EXISTS idx_agent_billing_model_name ON agent_billing_records(model_name); + +-- 添加注释 +COMMENT ON COLUMN agent_billing_records.model_name IS 'Agent 使用的 LLM 模型名称,如 azure/gpt-4, gemini/gemini-pro'; diff --git a/services/mcp-server/migrations/011_fix_quota_defaults.sql b/services/mcp-server/migrations/020_fix_quota_defaults.sql similarity index 100% rename from services/mcp-server/migrations/011_fix_quota_defaults.sql rename to services/mcp-server/migrations/020_fix_quota_defaults.sql diff --git a/services/mcp-server/migrations/018_fix_agent_type_length.sql b/services/mcp-server/migrations/021_fix_agent_type_length.sql similarity index 100% rename from services/mcp-server/migrations/018_fix_agent_type_length.sql rename to services/mcp-server/migrations/021_fix_agent_type_length.sql diff --git a/services/mcp-server/migrations/add_last_login_fields.py b/services/mcp-server/migrations/add_last_login_fields.py deleted file mode 100644 index 792e884..0000000 --- a/services/mcp-server/migrations/add_last_login_fields.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -数据库迁移脚本 - 添加last_login_at字段 - -添加功能: -1. User表添加 last_login_at 字段 -2. Channel表添加 last_login_at 字段 - -执行方式: -python migrations/add_last_login_fields.py -""" - -import asyncio -import sys -import os - -# 添加项目根目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from sqlalchemy import text -from database import AsyncSessionLocal - - -async def migrate(): - """执行数据库迁移""" - async with AsyncSessionLocal() as session: - try: - print("开始数据库迁移...") - - # 1. 检查User表是否已有last_login_at字段 - print("\n1. 检查User表...") - result = await session.execute(text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name='users' AND column_name='last_login_at' - """)) - - if result.fetchone() is None: - print(" -> 添加 User.last_login_at 字段...") - await session.execute(text(""" - ALTER TABLE users - ADD COLUMN last_login_at TIMESTAMP - """)) - print(" -> ✅ User.last_login_at 添加成功") - else: - print(" -> ⏭️ User.last_login_at 已存在,跳过") - - # 2. 检查Channel表是否已有last_login_at字段 - print("\n2. 检查Channel表...") - result = await session.execute(text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name='channels' AND column_name='last_login_at' - """)) - - if result.fetchone() is None: - print(" -> 添加 Channel.last_login_at 字段...") - await session.execute(text(""" - ALTER TABLE channels - ADD COLUMN last_login_at TIMESTAMP - """)) - print(" -> ✅ Channel.last_login_at 添加成功") - else: - print(" -> ⏭️ Channel.last_login_at 已存在,跳过") - - # 提交更改 - await session.commit() - print("\n✅ 数据库迁移完成!") - - except Exception as e: - print(f"\n❌ 迁移失败: {e}") - await session.rollback() - raise - - -async def rollback(): - """回滚数据库迁移""" - async with AsyncSessionLocal() as session: - try: - print("开始回滚数据库迁移...") - - print("\n1. 删除 User.last_login_at 字段...") - await session.execute(text(""" - ALTER TABLE users - DROP COLUMN IF EXISTS last_login_at - """)) - - print("\n2. 删除 Channel.last_login_at 字段...") - await session.execute(text(""" - ALTER TABLE channels - DROP COLUMN IF EXISTS last_login_at - """)) - - await session.commit() - print("\n✅ 回滚完成!") - - except Exception as e: - print(f"\n❌ 回滚失败: {e}") - await session.rollback() - raise - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description="数据库迁移:添加最后登录时间字段") - parser.add_argument( - "--rollback", - action="store_true", - help="回滚迁移(删除字段)" - ) - - args = parser.parse_args() - - if args.rollback: - asyncio.run(rollback()) - else: - asyncio.run(migrate()) diff --git a/services/mcp-server/migrations/add_resource_control_fields.py b/services/mcp-server/migrations/add_resource_control_fields.py deleted file mode 100644 index b4321b1..0000000 --- a/services/mcp-server/migrations/add_resource_control_fields.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -数据库迁移脚本 - 添加资源管控字段 -为User表添加rpm_limit、tpm_limit和daily_cost_limit字段 -""" - -import sys -import os - -# 添加父目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from sqlalchemy import text -from database import AsyncSessionLocal -import asyncio - - -async def upgrade(): - """添加资源管控字段""" - async with AsyncSessionLocal() as db: - try: - # 添加rpm_limit字段 - await db.execute(text(""" - ALTER TABLE users - ADD COLUMN IF NOT EXISTS rpm_limit INTEGER DEFAULT 60 - """)) - - # 添加tpm_limit字段 - await db.execute(text(""" - ALTER TABLE users - ADD COLUMN IF NOT EXISTS tpm_limit INTEGER DEFAULT 10000 - """)) - - # 添加daily_cost_limit字段 - await db.execute(text(""" - ALTER TABLE users - ADD COLUMN IF NOT EXISTS daily_cost_limit NUMERIC(12, 2) DEFAULT 100.00 - """)) - - await db.commit() - print("✅ 资源管控字段添加成功") - - except Exception as e: - await db.rollback() - print(f"❌ 迁移失败: {e}") - raise - - -async def downgrade(): - """移除资源管控字段""" - async with AsyncSessionLocal() as db: - try: - # 移除rpm_limit字段 - await db.execute(text(""" - ALTER TABLE users - DROP COLUMN IF EXISTS rpm_limit - """)) - - # 移除tpm_limit字段 - await db.execute(text(""" - ALTER TABLE users - DROP COLUMN IF EXISTS tpm_limit - """)) - - # 移除daily_cost_limit字段 - await db.execute(text(""" - ALTER TABLE users - DROP COLUMN IF EXISTS daily_cost_limit - """)) - - await db.commit() - print("✅ 资源管控字段移除成功") - - except Exception as e: - await db.rollback() - print(f"❌ 回滚失败: {e}") - raise - - -if __name__ == "__main__": - import sys - - if len(sys.argv) > 1 and sys.argv[1] == "downgrade": - asyncio.run(downgrade()) - else: - asyncio.run(upgrade()) diff --git a/services/mcp-server/migrations/run_007_migration.py b/services/mcp-server/migrations/run_007_migration.py deleted file mode 100644 index ecb555e..0000000 --- a/services/mcp-server/migrations/run_007_migration.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -""" -执行数据库迁移:将 resource_allocations.resource_id 从 UUID 改为 VARCHAR(100) -""" - -import asyncio -import os -import sys -import ssl - -# 添加项目路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from sqlalchemy import text -from sqlalchemy.ext.asyncio import create_async_engine - - -async def run_migration(): - """执行迁移""" - # 从环境变量获取数据库 URL - database_url = os.getenv("DATABASE_URL") or os.getenv("ASYNC_DATABASE_URL") - - if not database_url: - print("错误: 请设置 DATABASE_URL 或 ASYNC_DATABASE_URL 环境变量") - sys.exit(1) - - # 确保使用 asyncpg 驱动 - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - # 移除 URL 中的 sslmode 参数(asyncpg 使用不同的 SSL 配置方式) - if "sslmode=" in database_url: - from urllib.parse import urlparse, parse_qs, urlencode, urlunparse - parsed = urlparse(database_url) - query_params = parse_qs(parsed.query) - query_params.pop('sslmode', None) - new_query = urlencode(query_params, doseq=True) - database_url = urlunparse(parsed._replace(query=new_query)) - - print(f"连接数据库...") - - # 创建 SSL 上下文 - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - - engine = create_async_engine( - database_url, - echo=True, - connect_args={"ssl": ssl_context} - ) - - try: - async with engine.begin() as conn: - print("执行迁移...") - - # 1. 删除旧索引 - print("步骤 1: 删除旧索引...") - await conn.execute(text("DROP INDEX IF EXISTS idx_resource_allocation_resource")) - - # 2. 修改字段类型 - print("步骤 2: 修改 resource_id 字段类型...") - await conn.execute(text(""" - ALTER TABLE resource_allocations - ALTER COLUMN resource_id TYPE VARCHAR(100) - USING resource_id::VARCHAR(100) - """)) - - # 3. 重新创建索引 - print("步骤 3: 重新创建索引...") - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS idx_resource_allocation_resource - ON resource_allocations(resource_id, resource_type) - """)) - - print("迁移成功完成!") - - # 验证修改 - result = await conn.execute(text(""" - SELECT column_name, data_type, character_maximum_length - FROM information_schema.columns - WHERE table_name = 'resource_allocations' AND column_name = 'resource_id' - """)) - row = result.fetchone() - if row: - print(f"验证结果: column={row[0]}, type={row[1]}, max_length={row[2]}") - - except Exception as e: - print(f"迁移失败: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - finally: - await engine.dispose() - - -if __name__ == "__main__": - asyncio.run(run_migration()) diff --git a/services/mcp-server/migrations/run_010_migration.py b/services/mcp-server/migrations/run_010_migration.py deleted file mode 100644 index c87e67a..0000000 --- a/services/mcp-server/migrations/run_010_migration.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -""" -数据库迁移脚本:添加 agent_billing_records 表缺失的字段 -""" - -import asyncio -import os -import sys - -# 添加项目路径 -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from sqlalchemy import text -from database import engine as async_engine - - -async def run_migration(): - """执行数据库迁移""" - - migration_sql = """ - -- Add is_platform_agent column - ALTER TABLE agent_billing_records - ADD COLUMN IF NOT EXISTS is_platform_agent BOOLEAN DEFAULT TRUE; - - -- Add start_time column - ALTER TABLE agent_billing_records - ADD COLUMN IF NOT EXISTS start_time TIMESTAMP WITHOUT TIME ZONE; - - -- Add end_time column - ALTER TABLE agent_billing_records - ADD COLUMN IF NOT EXISTS end_time TIMESTAMP WITHOUT TIME ZONE; - """ - - # 分别执行每个语句 - statements = [ - "ALTER TABLE agent_billing_records ADD COLUMN IF NOT EXISTS is_platform_agent BOOLEAN DEFAULT TRUE", - "ALTER TABLE agent_billing_records ADD COLUMN IF NOT EXISTS start_time TIMESTAMP WITHOUT TIME ZONE", - "ALTER TABLE agent_billing_records ADD COLUMN IF NOT EXISTS end_time TIMESTAMP WITHOUT TIME ZONE", - ] - - async with async_engine.begin() as conn: - for stmt in statements: - try: - await conn.execute(text(stmt)) - print(f"✅ 执行成功: {stmt[:60]}...") - except Exception as e: - if "already exists" in str(e).lower() or "duplicate column" in str(e).lower(): - print(f"⚠️ 列已存在,跳过: {stmt[:60]}...") - else: - print(f"❌ 执行失败: {stmt[:60]}... 错误: {e}") - - print("\n✅ 迁移完成!") - - -if __name__ == "__main__": - asyncio.run(run_migration()) diff --git a/services/mcp-server/migrations/run_011_migration.py b/services/mcp-server/migrations/run_011_migration.py deleted file mode 100644 index fcd703a..0000000 --- a/services/mcp-server/migrations/run_011_migration.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -""" -迁移脚本:添加 LiteLLM 集成相关表和字段 - -运行方式: - cd services/mcp-server - python migrations/run_011_migration.py -""" - -import asyncio -import os -import sys - -# 添加父目录到路径 -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from sqlalchemy import text -from database import engine - - -async def run_migration(): - """执行迁移""" - - # 读取 SQL 文件 - sql_file = os.path.join(os.path.dirname(__file__), "011_add_litellm_integration.sql") - - with open(sql_file, "r", encoding="utf-8") as f: - sql_content = f.read() - - # 分割 SQL 语句(按分号分割,但忽略函数体内的分号) - statements = [] - current_statement = [] - in_function = False - - for line in sql_content.split("\n"): - stripped = line.strip() - - # 跳过注释 - if stripped.startswith("--"): - continue - - # 检测函数开始 - if "AS $$" in line or "AS $" in line: - in_function = True - - # 检测函数结束 - if in_function and ("$$ language" in line.lower() or "$$ LANGUAGE" in line): - in_function = False - - current_statement.append(line) - - # 如果不在函数内且行以分号结尾,则完成一条语句 - if not in_function and stripped.endswith(";"): - statement = "\n".join(current_statement).strip() - if statement and not statement.startswith("--"): - statements.append(statement) - current_statement = [] - - # 处理最后一条语句 - if current_statement: - statement = "\n".join(current_statement).strip() - if statement and not statement.startswith("--"): - statements.append(statement) - - print("=" * 60) - print("LiteLLM 集成迁移脚本") - print("=" * 60) - print(f"共 {len(statements)} 条 SQL 语句待执行") - print() - - async with engine.begin() as conn: - for i, statement in enumerate(statements, 1): - # 显示语句摘要 - first_line = statement.split("\n")[0][:60] - print(f"[{i}/{len(statements)}] 执行: {first_line}...") - - try: - await conn.execute(text(statement)) - print(f" ✓ 成功") - except Exception as e: - error_msg = str(e) - # 忽略 "already exists" 类型的错误 - if "already exists" in error_msg.lower(): - print(f" ⚠ 已存在,跳过") - else: - print(f" ✗ 失败: {error_msg}") - raise - - print() - print("=" * 60) - print("迁移完成!") - print("=" * 60) - print() - print("新增内容:") - print(" - channels 表添加 litellm_team_id 字段") - print(" - 创建 tenant_model_keys 表") - print(" - 添加相关索引和触发器") - - -if __name__ == "__main__": - asyncio.run(run_migration()) diff --git a/services/mcp-server/migrations/run_013_migration.py b/services/mcp-server/migrations/run_013_migration.py deleted file mode 100644 index f2a8a06..0000000 --- a/services/mcp-server/migrations/run_013_migration.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -数据库迁移脚本:为 agent_billing_records 表添加回调相关字段 -运行时间:2026-01-11 -""" -import asyncio -import logging -from sqlalchemy import text -from database import engine, AsyncSessionLocal - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -async def run_migration(): - """执行数据库迁移""" - async with AsyncSessionLocal() as session: - try: - logger.info("开始迁移 013: 添加 Agent 回调相关字段") - - # 1. 添加 tools_used 字段 - logger.info("1. 添加 tools_used 字段...") - await session.execute(text( - "ALTER TABLE agent_billing_records ADD COLUMN IF NOT EXISTS tools_used JSONB" - )) - - # 2. 添加 request_id 字段 - logger.info("2. 添加 request_id 字段...") - await session.execute(text( - "ALTER TABLE agent_billing_records ADD COLUMN IF NOT EXISTS request_id VARCHAR(100)" - )) - - # 3. 添加 eu_consumed 字段(如果不存在) - logger.info("3. 添加 eu_consumed 字段...") - await session.execute(text( - "ALTER TABLE agent_billing_records ADD COLUMN IF NOT EXISTS eu_consumed INTEGER DEFAULT 0" - )) - - # 4. 创建 request_id 索引 - logger.info("4. 创建 request_id 索引...") - await session.execute(text( - "CREATE INDEX IF NOT EXISTS idx_agent_billing_request_id ON agent_billing_records(request_id)" - )) - - # 5. 创建 tools_used GIN 索引 - logger.info("5. 创建 tools_used GIN 索引...") - await session.execute(text( - "CREATE INDEX IF NOT EXISTS idx_agent_billing_tools_used ON agent_billing_records USING gin(tools_used)" - )) - - # 6. 添加注释 - logger.info("6. 添加字段注释...") - await session.execute(text( - "COMMENT ON COLUMN agent_billing_records.tools_used IS 'Agent 使用的工具列表(JSON 数组)'" - )) - await session.execute(text( - "COMMENT ON COLUMN agent_billing_records.request_id IS '请求 ID,用于关联 Agent Manager 的调用'" - )) - await session.execute(text( - "COMMENT ON COLUMN agent_billing_records.eu_consumed IS 'EU 消耗量'" - )) - - await session.commit() - logger.info("✅ 迁移 013 完成") - - except Exception as e: - logger.error(f"❌ 迁移失败: {e}") - await session.rollback() - raise - - -if __name__ == "__main__": - asyncio.run(run_migration()) diff --git a/services/mcp-server/migrations/run_014_migration.py b/services/mcp-server/migrations/run_014_migration.py deleted file mode 100644 index 77b1574..0000000 --- a/services/mcp-server/migrations/run_014_migration.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -运行 014 迁移:添加 username 唯一约束 -""" -import asyncio -import os -import sys - -# 添加项目根目录到 path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from sqlalchemy import text -from database import engine - - -async def run_migration(): - """执行迁移""" - migration_sql = """ - DO $$ - BEGIN - -- 检查是否存在重复的 username(需要先处理) - IF EXISTS ( - SELECT username, COUNT(*) - FROM users - WHERE username IS NOT NULL - GROUP BY username - HAVING COUNT(*) > 1 - ) THEN - RAISE NOTICE '发现重复的 username,请先手动处理重复数据'; - END IF; - - -- 检查约束是否已存在 - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'users_username_key' - AND conrelid = 'users'::regclass - ) THEN - -- 添加唯一约束(允许 NULL 值,只对非 NULL 值检查唯一性) - ALTER TABLE users ADD CONSTRAINT users_username_key UNIQUE (username); - RAISE NOTICE '成功添加 username 唯一约束'; - ELSE - RAISE NOTICE 'username 唯一约束已存在,跳过'; - END IF; - END $$; - """ - - print("=" * 60) - print("开始执行迁移: 014_add_username_unique_constraint") - print("=" * 60) - - async with engine.begin() as conn: - try: - # 先检查是否有重复数据 - result = await conn.execute(text(""" - SELECT username, COUNT(*) as cnt - FROM users - WHERE username IS NOT NULL - GROUP BY username - HAVING COUNT(*) > 1 - """)) - duplicates = result.fetchall() - - if duplicates: - print("\n⚠️ 发现重复的 username:") - for row in duplicates: - print(f" - {row[0]}: {row[1]} 条记录") - print("\n请先手动处理重复数据,然后重新运行迁移") - return False - - # 执行迁移 - await conn.execute(text(migration_sql)) - print("\n✅ 迁移执行成功!") - - # 验证约束是否创建成功 - result = await conn.execute(text(""" - SELECT 1 FROM pg_constraint - WHERE conname = 'users_username_key' - AND conrelid = 'users'::regclass - """)) - if result.fetchone(): - print("✅ 验证: users_username_key 约束已存在") - else: - print("❌ 验证失败: 约束未创建") - return False - - return True - - except Exception as e: - print(f"\n❌ 迁移失败: {e}") - raise - - -if __name__ == "__main__": - success = asyncio.run(run_migration()) - sys.exit(0 if success else 1) - diff --git a/services/mcp-server/migrations/run_015_migration.py b/services/mcp-server/migrations/run_015_migration.py deleted file mode 100644 index 8a93840..0000000 --- a/services/mcp-server/migrations/run_015_migration.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -数据库迁移脚本:为 tools 表添加模板相关字段 -运行时间:2026-01-13 - -业务背景: -用户基于 Agent 模板(如 mysql_agent、postgresql_agent)创建工具时, -需要保存模板名称和填写的环境变量配置(如数据库连接信息)。 -创建自定义 Agent 时,从工具中获取模板类型和配置。 -""" -import asyncio -import logging -from sqlalchemy import text -from database import engine, AsyncSessionLocal - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -async def run_migration(): - """执行数据库迁移""" - async with AsyncSessionLocal() as session: - try: - logger.info("开始迁移 015: 添加工具模板相关字段") - - # 1. 添加 template 字段 - logger.info("1. 添加 template 字段...") - await session.execute(text( - "ALTER TABLE tools ADD COLUMN IF NOT EXISTS template VARCHAR(100)" - )) - - # 2. 添加 env_config 字段 - logger.info("2. 添加 env_config 字段...") - await session.execute(text( - "ALTER TABLE tools ADD COLUMN IF NOT EXISTS env_config JSONB DEFAULT '{}'" - )) - - # 3. 修改 schema 字段为可空(模板工具可能没有 schema) - logger.info("3. 修改 schema 字段为可空...") - await session.execute(text( - "ALTER TABLE tools ALTER COLUMN schema DROP NOT NULL" - )) - - # 4. 创建 template 索引 - logger.info("4. 创建 template 索引...") - await session.execute(text( - "CREATE INDEX IF NOT EXISTS idx_tool_template ON tools(template)" - )) - - # 5. 添加字段注释 - logger.info("5. 添加字段注释...") - await session.execute(text( - "COMMENT ON COLUMN tools.template IS 'Agent 模板名称,如 mysql_agent、postgresql_agent。用于基于模板创建的工具'" - )) - await session.execute(text( - "COMMENT ON COLUMN tools.env_config IS '环境变量配置(JSON格式),根据模板的 env_info 填写。如 MySQL 连接信息'" - )) - - await session.commit() - logger.info("✅ 迁移 015 完成") - - except Exception as e: - logger.error(f"❌ 迁移失败: {e}") - await session.rollback() - raise - - -if __name__ == "__main__": - asyncio.run(run_migration()) - diff --git a/services/mcp-server/migrations/run_016_migration.py b/services/mcp-server/migrations/run_016_migration.py deleted file mode 100644 index b92b7c0..0000000 --- a/services/mcp-server/migrations/run_016_migration.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 -""" -Migration 016: Add Agent Access Info Fields - -为 agent_billing_records 表添加访问信息字段(域名、外网IP等) - -Usage: - python migrations/run_016_migration.py -""" - -import asyncio -import os -import sys - -# 添加项目根目录到 Python 路径 -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from sqlalchemy import text -from database import engine - - -async def run_migration(): - """运行迁移""" - print("=" * 60) - print("Migration 016: Add Agent Access Info Fields") - print("=" * 60) - - # 读取 SQL 文件 - sql_file = os.path.join(os.path.dirname(__file__), "016_add_agent_access_info_fields.sql") - with open(sql_file, "r", encoding="utf-8") as f: - sql_content = f.read() - - # 分割 SQL 语句 - statements = [] - current_stmt = [] - in_do_block = False - - for line in sql_content.split("\n"): - line_stripped = line.strip() - - # 跳过注释和空行(但保留 DO 块中的内容) - if not in_do_block: - if line_stripped.startswith("--") or not line_stripped: - continue - - # 检测 DO 块开始 - if line_stripped.upper().startswith("DO $$"): - in_do_block = True - current_stmt.append(line) - continue - - # 检测 DO 块结束 - if in_do_block and line_stripped == "END $$;": - current_stmt.append(line) - statements.append("\n".join(current_stmt)) - current_stmt = [] - in_do_block = False - continue - - if in_do_block: - current_stmt.append(line) - continue - - # 普通语句处理 - current_stmt.append(line) - if line_stripped.endswith(";"): - stmt = "\n".join(current_stmt) - if stmt.strip(): - statements.append(stmt) - current_stmt = [] - - # 执行迁移 - async with engine.begin() as conn: - for i, stmt in enumerate(statements, 1): - try: - # 打印语句摘要 - stmt_preview = stmt.strip()[:80].replace("\n", " ") - if len(stmt.strip()) > 80: - stmt_preview += "..." - print(f"\n[{i}/{len(statements)}] Executing: {stmt_preview}") - - await conn.execute(text(stmt)) - print(f" ✓ Success") - except Exception as e: - error_msg = str(e) - # 忽略 "column already exists" 错误 - if "already exists" in error_msg.lower(): - print(f" ⚠ Skipped (already exists)") - else: - print(f" ✗ Error: {error_msg}") - raise - - print("\n" + "=" * 60) - print("Migration 016 completed successfully!") - print("=" * 60) - - -async def verify_migration(): - """验证迁移结果""" - print("\nVerifying migration...") - - async with engine.begin() as conn: - # 检查新字段是否存在 - result = await conn.execute(text(""" - SELECT column_name, data_type, character_maximum_length - FROM information_schema.columns - WHERE table_name = 'agent_billing_records' - AND column_name IN ('external_ip', 'domain', 'domain_url', 'access_url', 'service_port', 'namespace') - ORDER BY column_name - """)) - columns = result.fetchall() - - print(f"\nNew columns in agent_billing_records table:") - print("-" * 50) - for col in columns: - col_name, data_type, max_len = col - type_info = f"{data_type}({max_len})" if max_len else data_type - print(f" ✓ {col_name}: {type_info}") - - expected_columns = {'external_ip', 'domain', 'domain_url', 'access_url', 'service_port', 'namespace'} - found_columns = {col[0] for col in columns} - - if found_columns == expected_columns: - print(f"\n✓ All {len(expected_columns)} columns verified successfully!") - else: - missing = expected_columns - found_columns - if missing: - print(f"\n✗ Missing columns: {missing}") - return False - - return True - - -if __name__ == "__main__": - try: - asyncio.run(run_migration()) - asyncio.run(verify_migration()) - except KeyboardInterrupt: - print("\nMigration cancelled.") - sys.exit(1) - except Exception as e: - print(f"\nMigration failed: {e}") - sys.exit(1) - diff --git a/services/mcp-server/migrations/run_019_migration.py b/services/mcp-server/migrations/run_019_migration.py new file mode 100644 index 0000000..61675e6 --- /dev/null +++ b/services/mcp-server/migrations/run_019_migration.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +运行 019_add_agent_model_name.sql 迁移脚本 + +为 agents 表和 agent_billing_records 表添加 model_name 字段, +用于存储部署 Agent 时用户选择的 LLM 模型名称。 + +使用方法: + python migrations/run_019_migration.py + +环境变量: + DATABASE_URL: 数据库连接字符串 +""" + +import asyncio +import os +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + + +async def run_migration(): + """执行迁移""" + # 获取数据库连接字符串 + database_url = os.getenv("DATABASE_URL") + + if not database_url: + # 尝试从 config 模块获取 + try: + from config import settings + database_url = settings.database_url + except ImportError: + print("错误: 未设置 DATABASE_URL 环境变量,且无法导入 config 模块") + sys.exit(1) + + # 确保使用异步驱动 + if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + elif database_url.startswith("postgres://"): + database_url = database_url.replace("postgres://", "postgresql+asyncpg://", 1) + + # 移除 sslmode 参数(asyncpg 不支持) + if "sslmode=" in database_url: + import re + database_url = re.sub(r'[?&]sslmode=[^&]*', '', database_url) + # 清理可能残留的 ? 或 & + database_url = database_url.rstrip('?').rstrip('&') + + print(f"连接数据库...") + + # 创建异步引擎 + engine = create_async_engine(database_url, echo=True) + + # 创建会话 + async_session = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async with async_session() as session: + try: + # 读取 SQL 迁移文件 + migration_file = Path(__file__).parent / "019_add_agent_model_name.sql" + + if not migration_file.exists(): + print(f"错误: 迁移文件不存在: {migration_file}") + sys.exit(1) + + sql_content = migration_file.read_text(encoding="utf-8") + + # 分割 SQL 语句(按分号分割,忽略注释) + statements = [] + current_statement = [] + + for line in sql_content.split("\n"): + stripped = line.strip() + + # 跳过空行和注释 + if not stripped or stripped.startswith("--"): + continue + + current_statement.append(line) + + # 如果行以分号结尾,则为完整语句 + if stripped.endswith(";"): + statements.append("\n".join(current_statement)) + current_statement = [] + + # 执行每个 SQL 语句 + print(f"\n开始执行迁移,共 {len(statements)} 条语句...\n") + + for i, stmt in enumerate(statements, 1): + print(f"[{i}/{len(statements)}] 执行: {stmt[:80]}...") + try: + await session.execute(text(stmt)) + print(f" ✓ 成功") + except Exception as e: + error_msg = str(e) + # 忽略 "already exists" 错误 + if "already exists" in error_msg.lower(): + print(f" ⚠ 跳过(已存在)") + else: + print(f" ✗ 失败: {error_msg}") + raise + + # 提交事务 + await session.commit() + print("\n✓ 迁移完成!") + + # 验证迁移结果 + print("\n验证迁移结果...") + + # 检查 agents 表的 model_name 字段 + result = await session.execute(text(""" + SELECT column_name, data_type, character_maximum_length + FROM information_schema.columns + WHERE table_name = 'agents' AND column_name = 'model_name' + """)) + agents_column = result.fetchone() + + if agents_column: + print(f" ✓ agents.model_name: {agents_column[1]}({agents_column[2]})") + else: + print(" ✗ agents.model_name 字段不存在") + + # 检查 agent_billing_records 表的 model_name 字段 + result = await session.execute(text(""" + SELECT column_name, data_type, character_maximum_length + FROM information_schema.columns + WHERE table_name = 'agent_billing_records' AND column_name = 'model_name' + """)) + billing_column = result.fetchone() + + if billing_column: + print(f" ✓ agent_billing_records.model_name: {billing_column[1]}({billing_column[2]})") + else: + print(" ✗ agent_billing_records.model_name 字段不存在") + + # 检查索引 + result = await session.execute(text(""" + SELECT indexname FROM pg_indexes + WHERE tablename = 'agents' AND indexname = 'idx_agent_model_name' + """)) + agents_index = result.fetchone() + + if agents_index: + print(f" ✓ 索引 idx_agent_model_name 已创建") + else: + print(" ⚠ 索引 idx_agent_model_name 不存在") + + result = await session.execute(text(""" + SELECT indexname FROM pg_indexes + WHERE tablename = 'agent_billing_records' AND indexname = 'idx_agent_billing_model_name' + """)) + billing_index = result.fetchone() + + if billing_index: + print(f" ✓ 索引 idx_agent_billing_model_name 已创建") + else: + print(" ⚠ 索引 idx_agent_billing_model_name 不存在") + + except Exception as e: + await session.rollback() + print(f"\n✗ 迁移失败: {e}") + sys.exit(1) + finally: + await engine.dispose() + + +if __name__ == "__main__": + print("=" * 60) + print("019_add_agent_model_name 迁移脚本") + print("=" * 60) + print() + print("此迁移将:") + print(" 1. 为 agents 表添加 model_name 字段") + print(" 2. 为 agent_billing_records 表添加 model_name 字段") + print(" 3. 创建相关索引") + print() + + asyncio.run(run_migration()) diff --git a/services/mcp-server/migrations/run_011_fix_quota_defaults.py b/services/mcp-server/migrations/run_020_fix_quota_defaults.py similarity index 97% rename from services/mcp-server/migrations/run_011_fix_quota_defaults.py rename to services/mcp-server/migrations/run_020_fix_quota_defaults.py index 9cd77de..2bb9dc4 100644 --- a/services/mcp-server/migrations/run_011_fix_quota_defaults.py +++ b/services/mcp-server/migrations/run_020_fix_quota_defaults.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -执行 011_fix_quota_defaults.sql 迁移脚本 +执行 020_fix_quota_defaults.sql 迁移脚本 修复配额字段的默认值和可空性 """ @@ -38,7 +38,7 @@ async def run_migration(): engine = create_async_engine(database_url, echo=False) # 读取迁移脚本 - migration_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "011_fix_quota_defaults.sql") + migration_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "020_fix_quota_defaults.sql") logger.info(f"读取迁移脚本: {migration_file}") with open(migration_file, 'r', encoding='utf-8') as f: @@ -134,7 +134,7 @@ async def main(): """主函数""" print("="*80) print("执行配额字段默认值修复迁移") - print("迁移文件: 011_fix_quota_defaults.sql") + print("迁移文件: 020_fix_quota_defaults.sql") print("="*80) print() diff --git a/services/mcp-server/migrations/verify_013.py b/services/mcp-server/migrations/verify_013.py deleted file mode 100644 index 6571593..0000000 --- a/services/mcp-server/migrations/verify_013.py +++ /dev/null @@ -1,36 +0,0 @@ -"""验证迁移结果""" -import asyncio -from database import AsyncSessionLocal -from sqlalchemy import text - -async def verify_migration(): - async with AsyncSessionLocal() as session: - # 检查新增字段 - result = await session.execute(text(""" - SELECT column_name, data_type - FROM information_schema.columns - WHERE table_name='agent_billing_records' - AND column_name IN ('tools_used', 'request_id', 'eu_consumed') - ORDER BY column_name - """)) - - print("\n✅ 新增字段验证:") - rows = result.fetchall() - for row in rows: - print(f" - {row[0]}: {row[1]}") - - # 检查索引 - idx_result = await session.execute(text(""" - SELECT indexname - FROM pg_indexes - WHERE tablename='agent_billing_records' - AND indexname LIKE '%tools_used%' OR indexname LIKE '%request_id%' - """)) - - print("\n✅ 新增索引:") - idx_rows = idx_result.fetchall() - for row in idx_rows: - print(f" - {row[0]}") - -if __name__ == "__main__": - asyncio.run(verify_migration()) diff --git a/services/mcp-server/models.py b/services/mcp-server/models.py index 819ec7c..65a4a2a 100644 --- a/services/mcp-server/models.py +++ b/services/mcp-server/models.py @@ -81,11 +81,10 @@ class User(BaseModel, Base): # 订阅和计费 subscription_tier = Column(String(20), default="free") discount = Column(sa.Numeric(5, 2), default=0) - balance = Column(sa.Numeric(12, 2), default=0) # 账户余额 [DEPRECATED - 使用 Balance 表] credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度 # EU计费(执行单元) - eu_balance = Column(sa.Numeric(15, 2), default=0) # EU余额 [DEPRECATED - 使用 Balance 表] + # ❌ 废弃字段已删除:balance, eu_balance(使用 Balance 表替代) total_eu_consumed = Column(sa.Numeric(15, 2), default=0) # 总EU消耗 # 资源限制 @@ -166,6 +165,9 @@ class Agent(BaseModel, Base): endpoints = Column(JSON, default=dict) # 端点信息 pod_created_at = Column(DateTime) # Pod 创建时间 + # 模型配置 + model_name = Column(String(100)) # Agent 使用的 LLM 模型名称,如 azure/gpt-4 + # 自定义 Agent 特有字段 image_url = Column(String(500)) # 用户上传的镜像地址 program_path = Column(String(500)) # 程序路径 @@ -487,7 +489,15 @@ class APIKey(BaseModel, Base): class Billing(BaseModel, Base): - """计费详情模型(按执行记录计费)""" + """计费详情模型(按执行记录计费) + + ⚠️ 已废弃 (DEPRECATED) + 此表已废弃,不再使用。实际计费数据存储在: + - AgentBillingRecord: Agent 运行时计费 + - ModelBillingRecord: 模型调用计费(LiteLLM) + + 保留此模型仅用于历史数据兼容,新代码请勿使用。 + """ __tablename__ = "billing" execution_id = Column(GUID(), ForeignKey("executions.id"), nullable=False) @@ -532,7 +542,15 @@ class Balance(BaseModel, Base): class BillingRecord(BaseModel, Base): - """计费记录模型""" + """计费记录模型 + + ⚠️ 已废弃 (DEPRECATED) + 此表已废弃,不再使用。实际计费数据存储在: + - AgentBillingRecord: Agent 运行时计费(表名: agent_billing_records) + - ModelBillingRecord: 模型调用计费(表名: model_billing_records) + + 保留此模型仅用于历史数据兼容,新代码请勿使用。 + """ __tablename__ = "billing_records" timestamp = Column(DateTime, nullable=False, default=datetime.utcnow) @@ -1190,7 +1208,7 @@ class AgentBillingRecord(BaseModel, Base): period_end = Column(DateTime, nullable=True) # 运行时间(用于追踪 Agent 运行状态) - start_time = Column(DateTime, nullable=True) # Agent 启动时间 + start_time = Column(DateTime, nullable=False) # ✅ Agent 启动时间(必填,防止计费遗漏) end_time = Column(DateTime, nullable=True) # Agent 停止时间(None 表示正在运行) # 工具使用信息(新增字段) @@ -1206,6 +1224,10 @@ class AgentBillingRecord(BaseModel, Base): namespace = Column(String(100), nullable=True) # K8s 命名空间 # ====================================================== + # ========== 模型配置 ========== + model_name = Column(String(100), nullable=True) # Agent 使用的 LLM 模型名称,如 azure/gpt-4 + # ============================== + # 关联关系 user = relationship("User") channel = relationship("Channel") diff --git a/services/mcp-server/reinit_database.py b/services/mcp-server/reinit_database.py deleted file mode 100644 index 2be397d..0000000 --- a/services/mcp-server/reinit_database.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -""" -重新初始化数据库表 -用于在数据库表丢失时重新创建所有表结构 -""" - -import sys -import os -import asyncio - -# 添加services/mcp-server到路径,以便导入模块 -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'services', 'mcp-server')) - -from sqlalchemy.ext.asyncio import create_async_engine -from sqlalchemy import text -from models import Base -from config import settings -from database import prepare_database_url -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -async def reinit_database(): - """重新初始化数据库表""" - try: - # 准备数据库URL - database_url = prepare_database_url(settings.database_url) - - # 确保使用asyncpg驱动 - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - logger.info(f"正在连接到数据库: {database_url.split('@')[1] if '@' in database_url else '本地数据库'}") - - # 创建数据库引擎 - engine = create_async_engine(database_url, echo=False) - - logger.info("开始创建数据库表...") - - # 创建所有表 - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - logger.info("✓ 数据库表创建成功!") - - # 列出所有创建的表 - async with engine.begin() as conn: - # 获取所有表名 - if "postgresql" in database_url: - result = await conn.execute( - text(""" - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - ORDER BY table_name - """) - ) - else: - # SQLite - result = await conn.execute( - text(""" - SELECT name - FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - ORDER BY name - """) - ) - - tables = result.fetchall() - table_names = [row[0] for row in tables] - - logger.info(f"\n已创建的表列表(共 {len(table_names)} 个):") - for i, table_name in enumerate(table_names, 1): - logger.info(f" {i}. {table_name}") - - await engine.dispose() - - logger.info("\n数据库初始化完成!") - logger.info("注意:此脚本只创建表结构,不会创建初始数据。") - logger.info("如果需要创建初始数据,请运行服务或使用其他初始化脚本。") - - return True - - except Exception as e: - logger.error(f"✗ 数据库初始化失败: {e}") - import traceback - traceback.print_exc() - return False - - -async def main(): - """主函数""" - print("="*80) - print("重新初始化数据库表") - print("="*80) - print() - - # 确认操作 - print("警告:此操作将创建所有数据库表。") - print("如果表已存在,SQLAlchemy不会删除或修改现有表。") - print() - - success = await reinit_database() - - if success: - print("\n" + "="*80) - print("✓ 数据库表初始化成功!") - print("="*80) - sys.exit(0) - else: - print("\n" + "="*80) - print("✗ 数据库表初始化失败!") - print("="*80) - sys.exit(1) - - -if __name__ == "__main__": - asyncio.run(main()) - diff --git a/services/mcp-server/schemas.py b/services/mcp-server/schemas.py index a7ccad7..fef2508 100644 --- a/services/mcp-server/schemas.py +++ b/services/mcp-server/schemas.py @@ -291,6 +291,9 @@ class AgentCard(BaseSchema): service_port: Optional[int] = None access_url: Optional[str] = None + # 模型配置 + model_name: Optional[str] = None # Agent 使用的 LLM 模型名称 + # 资源配置 cpu_request: Optional[str] = None cpu_limit: Optional[str] = None @@ -367,6 +370,9 @@ class AgentMetricsResponse(BaseSchema): class TemplateInfo(BaseModel): """模板信息""" template: str + displayName: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None port: Optional[int] = None env_info: Dict[str, Any] = {} diff --git a/services/mcp-server/test_agent_callback_full.py b/services/mcp-server/test_agent_callback_full.py deleted file mode 100644 index 70ba959..0000000 --- a/services/mcp-server/test_agent_callback_full.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -""" -完整的 Agent 回调测试脚本 -自动从数据库获取真实用户并测试回调 -""" - -import asyncio -import httpx -import sys -from datetime import datetime - -# 添加路径 -sys.path.insert(0, '/app') - -from database import AsyncSessionLocal -from sqlalchemy import text - - -async def get_test_user_id(): - """从数据库获取一个测试用户""" - async with AsyncSessionLocal() as session: - result = await session.execute(text( - "SELECT id, username, role, eu_balance FROM users ORDER BY created_at DESC LIMIT 1" - )) - row = result.fetchone() - if row: - print(f"\n使用测试用户:") - print(f" ID: {row[0]}") - print(f" Username: {row[1]}") - print(f" Role: {row[2]}") - print(f" EU Balance: {row[3]}") - return str(row[0]) - return None - - -async def test_agent_callback(user_id: str): - """测试 Agent 回调接口""" - - callback_url = "http://localhost:8000/api/v1/billing/agent-callback" - - # 测试数据 - callback_data = { - "agentName": "test-agent-auto", - "userId": user_id, - "podRunningTimeSeconds": 120, - "toolsUsed": ["web_search", "calculator", "file_reader"], - "startTime": datetime.utcnow().isoformat() + "Z", - "endTime": datetime.utcnow().isoformat() + "Z", - "requestId": "test-auto-123" - } - - print(f"\n{'='*60}") - print("测试 Agent 回调") - print(f"{'='*60}") - print(f"URL: {callback_url}") - print(f"Agent: {callback_data['agentName']}") - print(f"User ID: {user_id}") - print(f"运行时间: {callback_data['podRunningTimeSeconds']}秒") - print(f"工具: {callback_data['toolsUsed']}") - print(f"{'='*60}\n") - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post(callback_url, json=callback_data) - - print(f"状态码: {response.status_code}") - - if response.status_code == 200: - result = response.json() - print(f"响应: {result}") - print(f"\n✅ 测试成功!") - print(f" 计费记录 ID: {result.get('recordId')}") - return result.get('recordId') - else: - print(f"错误: {response.text}") - print(f"\n❌ 测试失败") - return None - - except Exception as e: - print(f"\n❌ 请求失败: {e}") - return None - - -async def verify_billing_record(record_id: str): - """验证计费记录""" - if not record_id: - return - - print(f"\n{'='*60}") - print("验证计费记录") - print(f"{'='*60}") - - async with AsyncSessionLocal() as session: - result = await session.execute(text(""" - SELECT - agent_name, - duration_seconds, - eu_consumed, - cost, - tools_used, - request_id, - created_at - FROM agent_billing_records - WHERE id = :record_id - """), {"record_id": record_id}) - - row = result.fetchone() - if row: - print(f"Agent 名称: {row[0]}") - print(f"运行时长: {row[1]}秒") - print(f"EU 消耗: {row[2]}") - print(f"成本: ${row[3]}") - print(f"工具使用: {row[4]}") - print(f"请求 ID: {row[5]}") - print(f"创建时间: {row[6]}") - print(f"\n✅ 计费记录验证成功!") - else: - print(f"❌ 未找到计费记录") - - -async def run_full_test(): - """运行完整测试""" - print("\n" + "="*60) - print("Agent Manager 回调接口完整测试") - print("="*60) - - # 1. 获取测试用户 - user_id = await get_test_user_id() - if not user_id: - print("\n❌ 未找到测试用户,请先创建用户") - return False - - # 2. 测试回调 - record_id = await test_agent_callback(user_id) - - # 3. 验证记录 - if record_id: - await verify_billing_record(record_id) - return True - - return False - - -if __name__ == "__main__": - success = asyncio.run(run_full_test()) - sys.exit(0 if success else 1) diff --git a/services/mcp-server/test_cre_admins.py b/services/mcp-server/test_cre_admins.py deleted file mode 100755 index 933af3d..0000000 --- a/services/mcp-server/test_cre_admins.py +++ /dev/null @@ -1,433 +0,0 @@ -#!/usr/bin/env python3 -""" -创建管理员账户脚本 -用于创建4个管理员角色: -- 超级管理员 (super_admin) -- 计费管理员 (billing_admin) -- 运维管理员 (operations_admin) -- 渠道管理员 (channel_admin) -""" - -import requests -import sys -import os -import asyncio -from typing import Optional -import bcrypt - -# 添加services/mcp-server到路径,以便导入模块 -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'services', 'mcp-server')) - -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy import select -from models import User, Channel, ModelPricing, QuotaAlert -from config import settings - -def get_password_hash(password: str) -> str: - """加密密码(使用bcrypt)""" - password_bytes = password.encode('utf-8') - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(password_bytes, salt) - return hashed.decode('utf-8') - -BASE_URL = "http://localhost:8002" - -# 要创建的管理员列表 -ADMINS = [ - { - "name": "超级管理员", - "email": "superadmin@taiji-ai.com", - "password": "Admin@123456", - "role": "super_admin", - }, - { - "name": "计费管理员", - "email": "newbilling@test.com", - "password": "Billing@123456", - "role": "billing_admin", - "channel_name": "测试渠道", # 计费管理员也需要关联渠道 - "channel_email": "test-channel@test.com", # 共享渠道邮箱 - }, - { - "name": "运维管理员", - "email": "newops@test.com", - "password": "Ops@123456", - "role": "operations_admin", - "channel_name": "测试渠道", # 运维管理员也需要关联渠道 - "channel_email": "test-channel@test.com", # 共享渠道邮箱(与计费管理员共享) - }, - { - "name": "渠道管理员", - "email": "channel-a@test.com", - "password": "ChannelA@123456", - "role": "channel_admin", - "channel_name": "渠道A", # 渠道名称 - } -] - - -def login_admin(email: str, password: str, role: str = "super_admin") -> Optional[str]: - """登录管理员账户,返回token""" - try: - resp = requests.post( - f"{BASE_URL}/api/auth/login", - json={ - "email": email, - "password": password, - "role": role - }, - timeout=10 - ) - - if resp.status_code == 200: - data = resp.json() - token = data.get("data", {}).get("token") - if token: - print(f" ✓ 登录成功: {email}") - return token - else: - print(f" ✗ 登录失败: 响应中未找到token") - return None - else: - error = resp.json().get("detail", resp.text) - print(f" ✗ 登录失败: {error}") - return None - except Exception as e: - print(f" ✗ 登录出错: {e}") - return None - - -async def create_admin_via_api(token: str, admin_info: dict) -> bool: - """通过API创建管理员(需要先创建渠道)""" - try: - # 注意:API只能创建billing_admin和operations_admin - # 超级管理员和渠道管理员需要直接操作数据库 - if admin_info["role"] not in ["billing_admin", "operations_admin"]: - print(f" ⚠ 跳过: {admin_info['role']} 需要通过数据库直接创建") - return False - - # 先创建或获取渠道 - channel_id = None - if admin_info.get("channel_name"): - channel_id = await get_or_create_channel_for_api(admin_info) - - # 构建请求数据 - request_data = { - "name": admin_info["name"], - "email": admin_info["email"], - "password": admin_info["password"], - "role": admin_info["role"] - } - if channel_id: - request_data["channelId"] = str(channel_id) - - resp = requests.post( - f"{BASE_URL}/api/admin/admins/create", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - }, - json=request_data, - timeout=10 - ) - - if resp.status_code == 200: - data = resp.json() - channel_id_from_response = data.get("data", {}).get("channelId") - print(f" ✓ 创建成功: {admin_info['email']}" + (f" (渠道ID: {channel_id_from_response})" if channel_id_from_response else "")) - return True - else: - error = resp.json().get("detail", resp.text) - if "邮箱已被使用" in error or "already exists" in error.lower(): - print(f" ⚠ 已存在: {admin_info['email']}") - # 如果已存在,尝试更新channel_id - if channel_id: - await update_existing_user_channel(admin_info["email"], channel_id) - return True # 已存在也算成功 - else: - print(f" ✗ 创建失败: {error}") - return False - except Exception as e: - print(f" ✗ 创建出错: {e}") - import traceback - traceback.print_exc() - return False - - -async def get_or_create_channel_for_api(admin_info: dict) -> str: - """为API创建获取或创建渠道,返回channel_id字符串""" - try: - database_url = settings.database_url - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - engine = create_async_engine(database_url, echo=False) - AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with AsyncSessionLocal() as session: - channel_name = admin_info.get("channel_name", f"渠道-{admin_info['name']}") - channel_email = admin_info.get("channel_email", f"channel-{channel_name.lower().replace(' ', '-')}@test.com") - channel = await get_or_create_channel(session, channel_email, channel_name) - await session.commit() - channel_id = str(channel.id) - await engine.dispose() - return channel_id - except Exception as e: - print(f" ⚠ 创建渠道失败: {e}") - return None - - -async def update_existing_user_channel(email: str, channel_id: str): - """更新已存在用户的channel_id""" - try: - database_url = settings.database_url - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - engine = create_async_engine(database_url, echo=False) - AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with AsyncSessionLocal() as session: - result = await session.execute(select(User).where(User.email == email)) - user = result.scalar_one_or_none() - if user: - import uuid - user.channel_id = uuid.UUID(channel_id) - await session.commit() - print(f" ✓ 已更新用户的渠道ID: {channel_id}") - await engine.dispose() - except Exception as e: - print(f" ⚠ 更新用户渠道ID失败: {e}") - - -async def verify_database_migration(): - """验证数据库迁移是否成功""" - engine = None - try: - database_url = settings.database_url - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - engine = create_async_engine(database_url, echo=False) - AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with AsyncSessionLocal() as session: - # 检查model_pricing表 - try: - result = await session.execute(select(ModelPricing).limit(1)) - print(" ✓ model_pricing 表存在") - except Exception as e: - print(f" ✗ model_pricing 表不存在或有问题: {e}") - - # 检查quota_alerts表 - try: - result = await session.execute(select(QuotaAlert).limit(1)) - print(" ✓ quota_alerts 表存在") - except Exception as e: - print(f" ✗ quota_alerts 表不存在或有问题: {e}") - - except Exception as e: - print(f" ✗ 数据库连接失败: {e}") - finally: - if engine: - await engine.dispose() - - -async def get_or_create_channel(session: AsyncSession, channel_email: str, channel_name: str) -> Channel: - """获取或创建渠道""" - # 先查找是否已存在 - result = await session.execute( - select(Channel).where(Channel.email == channel_email) - ) - channel = result.scalar_one_or_none() - - if channel: - return channel - - # 创建新渠道 - channel = Channel( - name=channel_name, - email=channel_email, - password_hash=get_password_hash("Channel@123456"), # 默认密码 - commission_rate=10.0, - channel_credit=0, - custom_agent_cpu=2, - custom_agent_memory=4, - status="active", - ) - session.add(channel) - await session.flush() # 获取ID但不提交 - await session.refresh(channel) - print(f" ✓ 创建渠道: {channel_name} (ID: {channel.id})") - return channel - - -async def create_admin_via_db(admin_info: dict) -> bool: - """直接通过数据库创建管理员""" - engine = None - try: - # 准备数据库URL - database_url = settings.database_url - if database_url.startswith("postgresql://") and "+asyncpg" not in database_url: - database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - # 创建数据库引擎 - engine = create_async_engine(database_url, echo=False) - AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with AsyncSessionLocal() as session: - # 如果是渠道管理员、计费管理员或运维管理员,需要先创建或获取渠道 - channel_id = None - if admin_info["role"] in ["channel_admin", "billing_admin", "operations_admin"]: - # 为管理员创建对应的渠道 - channel_name = admin_info.get("channel_name", f"渠道-{admin_info['name']}") - # 使用一个统一的渠道邮箱(如果多个管理员共享同一个渠道) - channel_email = admin_info.get("channel_email", f"channel-{channel_name.lower().replace(' ', '-')}@test.com") - channel = await get_or_create_channel(session, channel_email, channel_name) - channel_id = channel.id - await session.commit() # 提交渠道创建 - - # 检查用户是否已存在 - result = await session.execute( - select(User).where(User.email == admin_info["email"]) - ) - existing_user = result.scalar_one_or_none() - - if existing_user: - print(f" ⚠ 用户已存在: {admin_info['email']}") - # 更新角色和密码 - existing_user.role = admin_info["role"] - existing_user.password_hash = get_password_hash(admin_info["password"]) - existing_user.hashed_password = existing_user.password_hash - existing_user.name = admin_info["name"] - existing_user.username = admin_info["email"].split("@")[0] - existing_user.full_name = admin_info["name"] - existing_user.is_active = True - if admin_info["role"] == "super_admin": - existing_user.is_admin = True - # 如果是需要渠道的角色,更新channel_id - if channel_id and admin_info["role"] in ["channel_admin", "billing_admin", "operations_admin"]: - existing_user.channel_id = channel_id - await session.commit() - print(f" ✓ 更新成功: {admin_info['email']}" + (f" (渠道ID: {channel_id})" if channel_id else "")) - return True - - # 创建新用户 - password_hash = get_password_hash(admin_info["password"]) - user = User( - name=admin_info["name"], - email=admin_info["email"], - password_hash=password_hash, - hashed_password=password_hash, - username=admin_info["email"].split("@")[0], - full_name=admin_info["name"], - role=admin_info["role"], - channel_id=channel_id, # 关联渠道ID - is_active=True, - is_admin=(admin_info["role"] == "super_admin"), - status="active", - balance=0, - credit_limit=0, - # 新增字段支持 - eu_balance=0, # EU余额 - total_eu_consumed=0, # 总EU消耗 - ) - - session.add(user) - await session.commit() - await session.refresh(user) - - print(f" ✓ 创建成功: {admin_info['email']} (角色: {admin_info['role']})" + (f" (渠道ID: {channel_id})" if channel_id else "")) - return True - - except Exception as e: - print(f" ✗ 数据库创建失败: {e}") - import traceback - traceback.print_exc() - return False - finally: - if engine: - await engine.dispose() - - -async def main(): - """主函数""" - print("="*80) - print("创建管理员账户") - print("="*80) - print() - - # 验证新表是否创建成功 - print("步骤0: 验证数据库迁移...") - await verify_database_migration() - print() - - # 首先尝试登录默认admin账户 - print("步骤1: 尝试登录默认管理员账户...") - default_admin_email = "admin@taiji-ai.com" - default_admin_password = "admin123" - - token = login_admin(default_admin_email, default_admin_password, "super_admin") - - # 如果没有默认admin,尝试创建超级管理员 - if not token: - print("\n步骤2: 默认管理员不存在,直接创建超级管理员...") - super_admin = ADMINS[0] # 第一个是超级管理员 - success = await create_admin_via_db(super_admin) - if success: - print("\n步骤3: 使用新创建的超级管理员登录...") - token = login_admin(super_admin["email"], super_admin["password"], "super_admin") - else: - print(" ✗ 无法创建超级管理员,请检查数据库连接") - return - - if not token: - print(" ✗ 无法获取管理员token,请检查服务是否运行") - return - - print(f"\n步骤4: 创建其他管理员账户...") - print("-" * 80) - - results = [] - for admin in ADMINS: - print(f"\n创建 {admin['name']} ({admin['email']})...") - - # 超级管理员和渠道管理员需要直接操作数据库 - if admin["role"] in ["super_admin", "channel_admin"]: - success = await create_admin_via_db(admin) - else: - # billing_admin和operations_admin可以通过API创建 - success = await create_admin_via_api(token, admin) - - results.append({ - "name": admin["name"], - "email": admin["email"], - "role": admin["role"], - "success": success - }) - - # 输出结果汇总 - print("\n" + "="*80) - print("创建结果汇总") - print("="*80) - print(f"\n{'角色':<20} {'邮箱':<35} {'状态'}") - print("-" * 80) - - for result in results: - status = "✓ 成功" if result["success"] else "✗ 失败" - print(f"{result['name']:<20} {result['email']:<35} {status}") - - success_count = sum(1 for r in results if r["success"]) - print(f"\n总计: {success_count}/{len(results)} 个账户创建成功") - - # 输出账户信息 - print("\n" + "="*80) - print("账户信息") - print("="*80) - for admin in ADMINS: - print(f"{admin['name']:<20} | {admin['email']:<35} | 密码: {admin['password']}") - - -if __name__ == "__main__": - asyncio.run(main()) - diff --git a/services/mcp-server/test_new_agent_api.py b/services/mcp-server/test_new_agent_api.py deleted file mode 100644 index 9a0f14e..0000000 --- a/services/mcp-server/test_new_agent_api.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env python3 -""" -测试新的 Agent Manager API 格式 - -新的 API 格式适配: -1. 添加了 replicas 字段(副本数量) -2. 环境变量字段名统一为 env -3. 支持 LiteLLM 和服务端口配置 -""" - -import asyncio -import sys -from app.agent_manager_client import AgentManagerClient, AgentConfig - - -async def test_platform_agent(): - """测试创建平台 Agent(echo_agent)""" - print("\n=== 测试平台 Agent 部署 ===") - - client = AgentManagerClient() - - try: - # 创建单副本平台 Agent - config = AgentConfig( - user_id="test-user-123", - cpu_request="100m", - cpu_limit="500m", - memory_request="128Mi", - memory_limit="512Mi", - replicas=1 - ) - - result = await client.create_platform_agent( - name="test-echo-agent", - template="echo_agent", - user_id="test-user-123", - config=config - ) - - print(f"✓ 平台 Agent 创建成功:") - print(f" 名称: {result.name}") - print(f" 命名空间: {result.namespace}") - print(f" 状态: {result.status}") - print(f" 服务端口: {result.service_port}") - - return True - - except Exception as e: - print(f"✗ 平台 Agent 创建失败: {e}") - return False - finally: - await client.close() - - -async def test_azure_blob_agent(): - """测试创建 Azure Blob Agent(自定义 Agent)""" - print("\n=== 测试 Azure Blob Agent 部署 ===") - - client = AgentManagerClient() - - try: - # 创建 Azure Blob Agent 配置 - config = AgentConfig( - user_id="test-user-456", - cpu_request="100m", - cpu_limit="500m", - memory_request="256Mi", - memory_limit="1Gi", - replicas=2 # 多副本 - ) - - # 环境变量(包含 LiteLLM 和 Azure 配置) - env = { - "LITELLM_API_BASE": "http://litellm-service:4000", - "LITELLM_MODEL": "gpt-4", - "LITELLM_API_KEY": "sk-test-key", - "AZURE_STORAGE_CONNECTION_STRING": "DefaultEndpointsProtocol=https;AccountName=test;AccountKey=testkey;EndpointSuffix=core.windows.net", - "SERVICE_PORT": "8080" - } - - result = await client.create_custom_agent( - name="test-azure-blob-agent", - template="azure_blob_agent", - user_id="test-user-456", - env_vars=env, - config=config - ) - - print(f"✓ Azure Blob Agent 创建成功:") - print(f" 名称: {result.name}") - print(f" 命名空间: {result.namespace}") - print(f" 状态: {result.status}") - print(f" 服务端口: {result.service_port}") - print(f" 副本数: 2") - - return True - - except Exception as e: - print(f"✗ Azure Blob Agent 创建失败: {e}") - return False - finally: - await client.close() - - -async def test_payload_format(): - """测试生成的 API payload 格式是否正确""" - print("\n=== 验证 API Payload 格式 ===") - - # 测试配置 - config = AgentConfig( - user_id="test-user", - cpu_request="100m", - cpu_limit="500m", - memory_request="128Mi", - memory_limit="512Mi", - replicas=3 - ) - - # 环境变量 - env_vars = { - "LITELLM_API_BASE": "http://litellm-service:4000", - "LITELLM_MODEL": "gpt-4", - "SERVICE_PORT": "8080" - } - - # 预期的 payload 格式 - expected_payload = { - "name": "my-agent", - "template": "my_template", - "config": { - "user_id": "test-user", - "cpu_request": "100m", - "cpu_limit": "500m", - "memory_request": "128Mi", - "memory_limit": "512Mi", - "replicas": 3 - }, - "env": env_vars, - "replicas": 3 - } - - print("预期的 API Payload 格式:") - print(f" ✓ name: {expected_payload['name']}") - print(f" ✓ template: {expected_payload['template']}") - print(f" ✓ replicas: {expected_payload['replicas']} (顶层)") - print(f" ✓ config.replicas: {expected_payload['config']['replicas']} (配置内)") - print(f" ✓ env: {len(expected_payload['env'])} 个环境变量") - print(f" ✓ 字段名称: env") - - return True - - -async def main(): - """运行所有测试""" - print("=" * 60) - print("Agent Manager API 新格式适配测试") - print("=" * 60) - - results = [] - - # 测试 1: 验证 payload 格式 - results.append(("Payload 格式验证", await test_payload_format())) - - # 测试 2: 平台 Agent(需要 Agent Manager 运行) - print("\n注意: 以下测试需要 Agent Manager 服务运行") - print("如果服务未运行,测试将失败但不影响代码正确性\n") - - # results.append(("平台 Agent 部署", await test_platform_agent())) - # results.append(("Azure Blob Agent 部署", await test_azure_blob_agent())) - - # 显示结果摘要 - print("\n" + "=" * 60) - print("测试结果摘要:") - print("=" * 60) - - for test_name, passed in results: - status = "✓ 通过" if passed else "✗ 失败" - print(f"{status}: {test_name}") - - success_count = sum(1 for _, passed in results if passed) - print(f"\n总计: {success_count}/{len(results)} 个测试通过") - - return success_count == len(results) - - -if __name__ == "__main__": - try: - success = asyncio.run(main()) - sys.exit(0 if success else 1) - except KeyboardInterrupt: - print("\n\n测试被用户中断") - sys.exit(1) - except Exception as e: - print(f"\n\n测试执行出错: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/services/mcp-server/test_user_api.py b/services/mcp-server/test_user_api.py deleted file mode 100644 index 01aaecf..0000000 --- a/services/mcp-server/test_user_api.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -""" -测试用户接口 -- GET /api/user/models - 获取可用模型列表 -- GET /api/user/custom-agents/templates - 获取框架模板列表 -""" - -import asyncio -import httpx -import os -from typing import Dict - -# 测试配置 -BASE_URL = os.getenv("BASE_URL", "http://localhost:8002") -# 需要先登录获取 token,这里使用测试token -TEST_TOKEN = os.getenv("TEST_TOKEN", "") - - -async def test_get_models(client: httpx.AsyncClient, token: str): - """测试获取模型列表接口""" - print("\n" + "="*60) - print("测试: GET /api/user/models") - print("="*60) - - headers = {"Authorization": f"Bearer {token}"} - - try: - response = await client.get( - f"{BASE_URL}/api/user/models", - headers=headers, - timeout=10.0 - ) - - print(f"状态码: {response.status_code}") - print(f"响应体:\n{response.text}") - - if response.status_code == 200: - data = response.json() - if data.get("success"): - models = data.get("data", {}).get("models", []) - print(f"\n✅ 成功获取 {len(models)} 个模型") - for model in models: - print(f" - {model.get('name')} ({model.get('id')})") - print(f" Provider: {model.get('provider')}, Context: {model.get('contextWindow')}") - else: - print(f"❌ 失败: {data.get('message')}") - else: - print(f"❌ 请求失败: {response.status_code}") - - except Exception as e: - print(f"❌ 异常: {str(e)}") - - -async def test_get_templates(client: httpx.AsyncClient, token: str): - """测试获取框架模板列表接口""" - print("\n" + "="*60) - print("测试: GET /api/user/custom-agents/templates") - print("="*60) - - headers = {"Authorization": f"Bearer {token}"} - - try: - response = await client.get( - f"{BASE_URL}/api/user/custom-agents/templates", - headers=headers, - timeout=10.0 - ) - - print(f"状态码: {response.status_code}") - print(f"响应体:\n{response.text}") - - if response.status_code == 200: - data = response.json() - if data.get("success"): - templates = data.get("data", {}).get("templates", []) - print(f"\n✅ 成功获取 {len(templates)} 个框架模板") - for template in templates: - print(f" - {template}") - else: - print(f"❌ 失败: {data.get('message')}") - else: - print(f"❌ 请求失败: {response.status_code}") - - except Exception as e: - print(f"❌ 异常: {str(e)}") - - -async def login(client: httpx.AsyncClient, username: str, password: str) -> str: - """登录获取token""" - print("\n" + "="*60) - print("登录获取Token") - print("="*60) - - try: - response = await client.post( - f"{BASE_URL}/api/auth/login", - json={"username": username, "password": password}, - timeout=10.0 - ) - - if response.status_code == 200: - data = response.json() - if data.get("success"): - token = data.get("data", {}).get("access_token") - print(f"✅ 登录成功") - return token - else: - print(f"❌ 登录失败: {data.get('message')}") - else: - print(f"❌ 登录失败: {response.status_code}") - print(f"响应: {response.text}") - - except Exception as e: - print(f"❌ 登录异常: {str(e)}") - - return "" - - -async def main(): - """主函数""" - print("用户接口测试工具") - print(f"服务地址: {BASE_URL}") - - async with httpx.AsyncClient() as client: - # 获取token - token = TEST_TOKEN - if not token: - # 如果没有提供token,尝试登录 - username = os.getenv("TEST_USERNAME", "admin") - password = os.getenv("TEST_PASSWORD", "admin123") - token = await login(client, username, password) - - if not token: - print("\n❌ 无法获取Token,请设置 TEST_TOKEN 环境变量或提供登录凭据") - return - - print(f"\n使用Token: {token[:20]}...") - - # 测试接口 - await test_get_models(client, token) - await test_get_templates(client, token) - - print("\n" + "="*60) - print("测试完成") - print("="*60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/services/mcp-server/verify_api_format.py b/services/mcp-server/verify_api_format.py deleted file mode 100644 index 5733931..0000000 --- a/services/mcp-server/verify_api_format.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -""" -简化的 Agent Manager API 格式验证脚本 -不依赖外部模块,仅验证数据结构 -""" - - -def test_payload_format(): - """验证新的 API Payload 格式""" - print("=" * 60) - print("Agent Manager API 新格式验证") - print("=" * 60) - - # 模拟 AgentConfig - class AgentConfig: - def __init__(self, user_id=None, cpu_request="100m", cpu_limit="500m", - memory_request="128Mi", memory_limit="512Mi", replicas=1): - self.user_id = user_id - self.cpu_request = cpu_request - self.cpu_limit = cpu_limit - self.memory_request = memory_request - self.memory_limit = memory_limit - self.replicas = replicas - - def to_dict(self): - result = {} - if self.user_id: - result["user_id"] = self.user_id - if self.cpu_request: - result["cpu_request"] = self.cpu_request - if self.cpu_limit: - result["cpu_limit"] = self.cpu_limit - if self.memory_request: - result["memory_request"] = self.memory_request - if self.memory_limit: - result["memory_limit"] = self.memory_limit - if self.replicas is not None: - result["replicas"] = self.replicas - return result - - # 测试 1: 平台 Agent(单副本) - print("\n[测试 1] 平台 Agent 部署格式") - print("-" * 60) - - config1 = AgentConfig( - user_id="user-123", - cpu_request="100m", - cpu_limit="500m", - memory_request="128Mi", - memory_limit="512Mi", - replicas=1 - ) - - env1 = {} - - payload1 = { - "name": "echo-agent-user123", - "template": "echo_agent", - "config": config1.to_dict() - } - - if env1: - payload1["env"] = env1 - - if config1.replicas: - payload1["replicas"] = config1.replicas - - print(f"✓ Agent 名称: {payload1['name']}") - print(f"✓ 模板类型: {payload1['template']}") - print(f"✓ 副本数(顶层): {payload1.get('replicas', 'N/A')}") - print(f"✓ 配置内副本数: {payload1['config'].get('replicas', 'N/A')}") - print(f"✓ 环境变量字段: {'env' if 'env' in payload1 else '无'}") - print(f"✓ 用户 ID: {payload1['config'].get('user_id')}") - - # 测试 2: Azure Blob Agent(多副本 + 环境变量) - print("\n[测试 2] Azure Blob Agent 部署格式") - print("-" * 60) - - config2 = AgentConfig( - user_id="user-456", - cpu_request="100m", - cpu_limit="500m", - memory_request="256Mi", - memory_limit="1Gi", - replicas=2 - ) - - env2 = { - "LITELLM_API_BASE": "http://litellm-service:4000", - "LITELLM_MODEL": "gpt-4", - "LITELLM_API_KEY": "sk-test-key", - "AZURE_STORAGE_CONNECTION_STRING": "DefaultEndpointsProtocol=https;...", - "SERVICE_PORT": "8080" - } - - payload2 = { - "name": "azure-blob-agent-user456", - "template": "azure_blob_agent", - "config": config2.to_dict() - } - - if env2: - payload2["env"] = env2 - - if config2.replicas: - payload2["replicas"] = config2.replicas - - print(f"✓ Agent 名称: {payload2['name']}") - print(f"✓ 模板类型: {payload2['template']}") - print(f"✓ 副本数(顶层): {payload2.get('replicas', 'N/A')}") - print(f"✓ 配置内副本数: {payload2['config'].get('replicas', 'N/A')}") - print(f"✓ 环境变量字段: {'env' if 'env' in payload2 else '无'}") - print(f"✓ 环境变量数量: {len(payload2.get('env', {}))}") - print(f"✓ LiteLLM 配置:") - if 'env' in payload2: - print(f" - LITELLM_API_BASE: {payload2['env'].get('LITELLM_API_BASE')}") - print(f" - LITELLM_MODEL: {payload2['env'].get('LITELLM_MODEL')}") - print(f" - SERVICE_PORT: {payload2['env'].get('SERVICE_PORT')}") - - # 测试 3: 关键差异对比 - print("\n[测试 3] 新旧格式关键差异") - print("-" * 60) - - old_format = { - "name": "my-agent", - "template": "my_template", - "config": {"user_id": "user", "cpu_request": "100m"}, - "env": {"KEY": "value"} # 旧字段名 - } - - new_format = { - "name": "my-agent", - "template": "my_template", - "replicas": 2, # 新增顶层字段 - "config": { - "user_id": "user", - "cpu_request": "100m", - "replicas": 2 # 新增配置字段 - }, - "env": {"KEY": "value"} # 统一后字段名 - } - - print("旧格式:") - print(f" - 环境变量字段: 'env_variables' (不统一)") - print(f" - 副本数字段: ❌ 不支持") - print(f" - config.replicas: ❌ 不支持") - - print("\n新格式 (统一后):") - print(f" - 环境变量字段: 'env' ✓") - print(f" - 副本数字段: 'replicas' ✓") - print(f" - config.replicas: {new_format['config']['replicas']} ✓") - - # 测试 4: 字段映射验证 - print("\n[测试 4] 字段映射验证") - print("-" * 60) - - checks = [ - ("AgentConfig 包含 replicas 字段", True), - ("AgentConfig.to_dict() 返回 replicas", 'replicas' in config2.to_dict()), - ("payload 使用 env 而非 env_variables", 'env' in payload2 and 'env_variables' not in payload2), - ("payload 包含顶层 replicas 字段", 'replicas' in payload2), - ("config 和顶层 replicas 值一致", payload2.get('replicas') == payload2['config'].get('replicas')), - ] - - all_passed = True - for check_name, passed in checks: - status = "✓ 通过" if passed else "✗ 失败" - print(f"{status}: {check_name}") - if not passed: - all_passed = False - - # 总结 - print("\n" + "=" * 60) - print("验证结果总结") - print("=" * 60) - - if all_passed: - print("✓ 所有验证项通过") - print("✓ 代码已成功适配新的 Agent Manager API 格式") - print("\n主要变更:") - print(" 1. AgentConfig 新增 replicas 字段(默认为 1)") - print(" 2. API payload 统一使用 env") - print(" 3. 支持多副本部署") - print(" 4. 兼容 LiteLLM 和 Azure 配置") - return True - else: - print("✗ 部分验证项失败,请检查代码") - return False - - -if __name__ == "__main__": - import sys - try: - success = test_payload_format() - sys.exit(0 if success else 1) - except Exception as e: - print(f"\n✗ 验证过程出错: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/test_directmail_api.py b/test_directmail_api.py deleted file mode 100644 index 102576e..0000000 --- a/test_directmail_api.py +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env python3 -""" -阿里云 DirectMail API 邮件发送测试脚本 -使用 SingleSendMail API 发送邮件(无需 SMTP 密码) - -文档参考: https://www.alibabacloud.com/help/en/direct-mail/singlesendmail -""" - -import os -import sys - -# ==================== 配置区域 ==================== -# 阿里云 AccessKey(可以在阿里云控制台 -> AccessKey 管理中获取) -ACCESS_KEY_ID = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID", "your_access_key_id") -ACCESS_KEY_SECRET = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "your_access_key_secret") - -# DirectMail 配置 -# 注意:使用新加坡区域的 endpoint -REGION_ID = "ap-southeast-1" # 新加坡 -ENDPOINT = "dm.ap-southeast-1.aliyuncs.com" - -# 发件人配置(必须是在 DirectMail 控制台已验证的发信地址) -ACCOUNT_NAME = "supportagnet@taijiaicloud.com" - -# 收件人 -TO_ADDRESS = "zsbgnw@gmail.com" - -# ==================== 测试代码 ==================== - -def check_sdk(): - """检查并安装 SDK""" - try: - from alibabacloud_dm20151123.client import Client - from alibabacloud_dm20151123 import models as dm_models - from alibabacloud_tea_openapi import models as open_api_models - print("✓ 阿里云 DirectMail SDK 已安装") - return True - except ImportError: - print("✗ 缺少阿里云 SDK,请运行以下命令安装:") - print(" pip install alibabacloud-dm20151123") - return False - - -def send_email_via_api(): - """使用 SingleSendMail API 发送邮件""" - from alibabacloud_dm20151123.client import Client - from alibabacloud_dm20151123 import models as dm_models - from alibabacloud_tea_openapi import models as open_api_models - from alibabacloud_tea_util import models as util_models - - print(f"\n{'='*50}") - print("使用 SingleSendMail API 发送邮件") - print(f"{'='*50}") - print(f"发件人: {ACCOUNT_NAME}") - print(f"收件人: {TO_ADDRESS}") - print(f"区域: {REGION_ID}") - print(f"{'='*50}\n") - - # 检查 AccessKey - if ACCESS_KEY_ID == "your_access_key_id" or ACCESS_KEY_SECRET == "your_access_key_secret": - print("✗ 错误: 请配置 AccessKey") - print("\n方式1: 设置环境变量") - print(" export ALIBABA_CLOUD_ACCESS_KEY_ID='你的AccessKeyId'") - print(" export ALIBABA_CLOUD_ACCESS_KEY_SECRET='你的AccessKeySecret'") - print("\n方式2: 直接修改脚本中的配置") - return False - - try: - print("1. 创建 DirectMail 客户端...") - config = open_api_models.Config( - access_key_id=ACCESS_KEY_ID, - access_key_secret=ACCESS_KEY_SECRET, - endpoint=ENDPOINT - ) - client = Client(config) - print(" ✓ 客户端创建成功") - - print("2. 构建邮件请求...") - request = dm_models.SingleSendMailRequest( - account_name=ACCOUNT_NAME, - address_type=1, # 1: 发信地址 - reply_to_address="false", # 不使用回复地址 - subject="Taiji AI-PAD 测试邮件 (API)", - to_address=TO_ADDRESS, - html_body=""" - - -

Taiji AI-PAD 邮件测试

-

这是一封通过 阿里云 DirectMail API 发送的测试邮件。

-

如果您收到此邮件,说明 API 配置正确!

-
-

- --- Taiji AI-PAD 团队 -

- - - """, - tag_name="test" # 可选:邮件标签 - ) - print(" ✓ 请求构建完成") - - print("3. 发送邮件...") - runtime = util_models.RuntimeOptions() - response = client.single_send_mail_with_options(request, runtime) - - print(" ✓ 邮件发送成功!") - print(f"\n响应详情:") - print(f" RequestId: {response.body.request_id}") - print(f" EnvId: {response.body.env_id}") - - return True - - except Exception as e: - print(f" ✗ 发送失败: {e}") - - # 解析常见错误 - error_msg = str(e) - if "InvalidAccessKeyId" in error_msg: - print("\n可能原因: AccessKeyId 无效") - elif "SignatureDoesNotMatch" in error_msg: - print("\n可能原因: AccessKeySecret 错误") - elif "InvalidMailAddress.NotFound" in error_msg: - print("\n可能原因: 发信地址未在 DirectMail 控制台配置或未验证") - elif "InvalidIP.NotFound" in error_msg: - print("\n可能原因: 如果启用了 IP 保护,当前 IP 不在白名单中") - - return False - - -def send_email_simple(): - """简化版发送(直接使用 HTTP 请求,不依赖 SDK)""" - import hmac - import hashlib - import base64 - import urllib.parse - import urllib.request - import uuid - import datetime - import json - - print(f"\n{'='*50}") - print("使用 HTTP 请求直接调用 API") - print(f"{'='*50}\n") - - if ACCESS_KEY_ID == "your_access_key_id": - print("✗ 请先配置 AccessKey") - return False - - # 构建请求参数 - params = { - "Action": "SingleSendMail", - "AccountName": ACCOUNT_NAME, - "AddressType": "1", - "ReplyToAddress": "false", - "Subject": "Taiji AI-PAD 测试邮件 (HTTP)", - "ToAddress": TO_ADDRESS, - "TextBody": "这是一封测试邮件。如果您收到此邮件,说明配置正确。\n\n--- Taiji AI-PAD 团队", - "Format": "JSON", - "Version": "2015-11-23", - "AccessKeyId": ACCESS_KEY_ID, - "SignatureMethod": "HMAC-SHA1", - "Timestamp": datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), - "SignatureVersion": "1.0", - "SignatureNonce": str(uuid.uuid4()), - } - - # 计算签名 - def compute_signature(params, access_key_secret): - sorted_params = sorted(params.items()) - query_string = urllib.parse.urlencode(sorted_params, quote_via=urllib.parse.quote) - string_to_sign = "GET&%2F&" + urllib.parse.quote(query_string, safe="") - key = (access_key_secret + "&").encode("utf-8") - signature = base64.b64encode( - hmac.new(key, string_to_sign.encode("utf-8"), hashlib.sha1).digest() - ).decode("utf-8") - return signature - - params["Signature"] = compute_signature(params, ACCESS_KEY_SECRET) - - # 发送请求 - url = f"https://{ENDPOINT}/?" + urllib.parse.urlencode(params) - - try: - print("发送请求...") - req = urllib.request.Request(url) - with urllib.request.urlopen(req, timeout=30) as response: - result = json.loads(response.read().decode("utf-8")) - print("✓ 发送成功!") - print(f" RequestId: {result.get('RequestId')}") - print(f" EnvId: {result.get('EnvId')}") - return True - except urllib.error.HTTPError as e: - error_body = e.read().decode("utf-8") - print(f"✗ 发送失败: {e.code}") - print(f" 错误信息: {error_body}") - return False - except Exception as e: - print(f"✗ 发送失败: {e}") - return False - - -if __name__ == "__main__": - print("\n" + "="*60) - print(" 阿里云 DirectMail API 邮件发送测试") - print("="*60) - - # 检查 SDK - sdk_available = check_sdk() - - if sdk_available: - # 使用 SDK 发送 - send_email_via_api() - else: - print("\n如果不想安装 SDK,可以使用简化版(HTTP 直接请求)") - choice = input("是否使用简化版发送? (y/n): ").strip().lower() - if choice == 'y': - send_email_simple() - - print("\n" + "="*60) - print("测试完成") - print("="*60 + "\n") - diff --git a/test_smtp.py b/test_smtp.py deleted file mode 100644 index f1ed09a..0000000 --- a/test_smtp.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -""" -SMTP邮件发送测试脚本 -用于调试阿里云企业邮箱/邮件推送服务配置 -""" - -import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart - -# ==================== 配置区域 ==================== -# 请根据实际情况修改以下配置 - -# 189邮箱(天翼邮箱)配置 -SMTP_SERVER = "smtp.189.cn" # 189邮箱SMTP服务器 - -SMTP_PORT = 465 # SSL端口 - -# 发件人配置 -SMTP_EMAIL = "taijiagent@189.cn" -SMTP_PASSWORD = "eR)8hD@1Q)3sU%2q" # 授权码 - -# 收件人 -TO_EMAIL = "zsbgnw@gmail.com" - -# ==================== 测试代码 ==================== - -def test_smtp_ssl(): - """测试SSL连接(端口465)""" - print(f"\n{'='*50}") - print(f"测试 SSL 连接") - print(f"服务器: {SMTP_SERVER}:{SMTP_PORT}") - print(f"发件人: {SMTP_EMAIL}") - print(f"收件人: {TO_EMAIL}") - print(f"{'='*50}\n") - - try: - print("1. 创建SSL连接...") - server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=30) - print(" ✓ SSL连接成功") - - print("2. 登录认证...") - server.login(SMTP_EMAIL, SMTP_PASSWORD) - print(" ✓ 认证成功") - - print("3. 构建邮件...") - msg = MIMEMultipart() - msg['From'] = SMTP_EMAIL - msg['To'] = TO_EMAIL - msg['Subject'] = "Taiji AI-PAD 测试邮件" - - body = """ -这是一封测试邮件。 - -如果您收到此邮件,说明SMTP配置正确。 - ---- -Taiji AI-PAD 团队 -""" - msg.attach(MIMEText(body, 'plain', 'utf-8')) - print(" ✓ 邮件构建完成") - - print("4. 发送邮件...") - server.send_message(msg) - print(" ✓ 邮件发送成功!") - - server.quit() - return True - - except smtplib.SMTPAuthenticationError as e: - print(f" ✗ 认证失败: {e}") - print("\n可能的原因:") - print(" - 邮箱地址或密码错误") - print(" - 需要使用授权码而非登录密码") - print(" - 邮箱未开启SMTP服务") - return False - except smtplib.SMTPConnectError as e: - print(f" ✗ 连接失败: {e}") - return False - except Exception as e: - print(f" ✗ 错误: {type(e).__name__}: {e}") - return False - - -def test_smtp_starttls(): - """测试STARTTLS连接(端口587/25)""" - port = 587 - print(f"\n{'='*50}") - print(f"测试 STARTTLS 连接") - print(f"服务器: {SMTP_SERVER}:{port}") - print(f"发件人: {SMTP_EMAIL}") - print(f"{'='*50}\n") - - try: - print("1. 创建连接...") - server = smtplib.SMTP(SMTP_SERVER, port, timeout=5) - print(" ✓ 连接成功") - - print("2. 启用TLS...") - server.starttls() - print(" ✓ TLS启用成功") - - print("3. 登录认证...") - server.login(SMTP_EMAIL, SMTP_PASSWORD) - print(" ✓ 认证成功") - - print("4. 构建并发送邮件...") - msg = MIMEMultipart() - msg['From'] = SMTP_EMAIL - msg['To'] = TO_EMAIL - msg['Subject'] = "Taiji AI-PAD 测试邮件 (STARTTLS)" - msg.attach(MIMEText("STARTTLS测试邮件", 'plain', 'utf-8')) - - server.send_message(msg) - print(" ✓ 邮件发送成功!") - - server.quit() - return True - - except Exception as e: - print(f" ✗ 错误: {type(e).__name__}: {e}") - return False - - -if __name__ == "__main__": - print("\n" + "="*60) - print(" SMTP 邮件发送测试") - print("="*60) - - # 测试SSL连接 - ssl_result = test_smtp_ssl() - - if not ssl_result: - print("\n尝试 STARTTLS 方式...") - test_smtp_starttls() - - print("\n" + "="*60) - print("测试完成") - print("="*60 + "\n") -