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 8d60b30..0000000 Binary files a/postgres_backup.dump and /dev/null differ 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 3ca501d..0000000 Binary files a/services/mcp-server/data/mcp_fallback.db and /dev/null differ 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") -