forked from xiaohei/taiji-AI-PAD
更新计费
This commit is contained in:
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# Taiji AI-PAD 环境变量配置
|
# Taiji AI-PAD 环境变量配置
|
||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres
|
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/postgres?sslmode=require
|
DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require
|
||||||
|
|
||||||
# Redis配置 (Azure Cache for Redis - 端口10000)
|
# Redis配置 (Azure Cache for Redis - 端口10000)
|
||||||
# REDIS_URL已配置为Azure Redis (端口10000)
|
# REDIS_URL已配置为Azure Redis (端口10000)
|
||||||
|
|||||||
@@ -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 的新响应格式,并保持向后兼容性。✅
|
||||||
@@ -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)
|
||||||
@@ -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`)
|
||||||
@@ -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 使用记录和模型调用记录
|
||||||
+10
-10
@@ -4,36 +4,36 @@
|
|||||||
|
|
||||||
| 环境 | 数据库名 | 配置文件 |
|
| 环境 | 数据库名 | 配置文件 |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| **生产环境 (AKS)** | `taiji` | `k8s/secrets.yaml` |
|
| **生产环境 (AKS)** | `taiji_prod` | `k8s/secrets.yaml` |
|
||||||
| **测试环境 (docker-compose)** | `postgres` | `.env` 文件 |
|
| **测试环境 (docker-compose)** | `taiji` | `.env` 文件 |
|
||||||
|
|
||||||
## 生产环境配置 (AKS)
|
## 生产环境配置 (AKS)
|
||||||
|
|
||||||
生产环境通过 `k8s/secrets.yaml` 配置,使用 `taiji` 数据库:
|
生产环境通过 `k8s/secrets.yaml` 配置,使用 `taiji_prod` 数据库:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
database-url: "postgresql://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require"
|
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"
|
async-database-url: "postgresql+asyncpg://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji_prod"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试环境配置 (docker-compose)
|
## 测试环境配置 (docker-compose)
|
||||||
|
|
||||||
测试环境通过 `.env` 文件配置,使用 `postgres` 数据库:
|
测试环境通过 `.env` 文件配置,使用 `taiji` 数据库:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env 文件配置
|
# .env 文件配置
|
||||||
DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require
|
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/postgres
|
ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置要点
|
## 配置要点
|
||||||
|
|
||||||
1. **生产环境部署到 AKS 时**:
|
1. **生产环境部署到 AKS 时**:
|
||||||
- 确保 `k8s/secrets.yaml` 中数据库名为 `taiji`
|
- 确保 `k8s/secrets.yaml` 中数据库名为 `taiji_prod`
|
||||||
- 通过 `kubectl apply -f k8s/secrets.yaml` 部署
|
- 通过 `kubectl apply -f k8s/secrets.yaml` 部署
|
||||||
|
|
||||||
2. **本地测试环境**:
|
2. **本地测试环境**:
|
||||||
- 在 `.env` 文件中配置数据库名为 `postgres`
|
- 在 `.env` 文件中配置数据库名为 `taiji`
|
||||||
- 运行 `docker-compose up` 启动服务
|
- 运行 `docker-compose up` 启动服务
|
||||||
|
|
||||||
## 数据库信息
|
## 数据库信息
|
||||||
|
|||||||
+3
-3
@@ -14,10 +14,10 @@ type: Opaque
|
|||||||
stringData:
|
stringData:
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# 数据库配置 (Azure Database for PostgreSQL)
|
# 数据库配置 (Azure Database for PostgreSQL)
|
||||||
# 生产环境使用 postgres 数据库
|
# 生产环境使用 taiji_prod 数据库
|
||||||
# ===========================================
|
# ===========================================
|
||||||
database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require"
|
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/postgres"
|
async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji_prod"
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Redis配置 (Azure Cache for Redis with SSL)
|
# Redis配置 (Azure Cache for Redis with SSL)
|
||||||
|
|||||||
@@ -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 <mcp-server-pod> -n <namespace> -- bash
|
||||||
|
|
||||||
|
# 执行迁移
|
||||||
|
cd /app
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤5:重启服务
|
||||||
|
```bash
|
||||||
|
# 重启 mcp-server
|
||||||
|
kubectl rollout restart deployment mcp-server -n <namespace>
|
||||||
|
|
||||||
|
# 确认服务正常
|
||||||
|
kubectl get pods -n <namespace> -l app=mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤6:验证修复
|
||||||
|
|
||||||
|
#### 验证1:重复扣款已消除
|
||||||
|
```bash
|
||||||
|
# 监控日志,确认回调不再扣款
|
||||||
|
kubectl logs -f <mcp-server-pod> -n <namespace> | 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
|
||||||
|
**状态**: ✅ 修复完成,待部署
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
**审核状态**: 待审核
|
||||||
Binary file not shown.
@@ -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 ""
|
||||||
Executable
+83
@@ -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 ""
|
||||||
@@ -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())
|
|
||||||
|
|
||||||
@@ -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()
|
|
||||||
Executable
+58
@@ -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 ""
|
||||||
@@ -127,23 +127,14 @@ class ProductionSettings(Settings):
|
|||||||
max_workers: int = 8
|
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:
|
def get_settings() -> Settings:
|
||||||
"""根据环境变量获取相应的配置"""
|
"""根据环境变量获取相应的配置"""
|
||||||
environment = os.getenv("ENVIRONMENT", "development").lower()
|
environment = os.getenv("ENVIRONMENT", "development").lower()
|
||||||
|
|
||||||
if environment == "production":
|
if environment == "production":
|
||||||
return ProductionSettings()
|
return ProductionSettings()
|
||||||
elif environment == "testing":
|
|
||||||
return TestingSettings()
|
|
||||||
else:
|
else:
|
||||||
|
# 默认使用开发环境配置,不再支持测试环境配置
|
||||||
return DevelopmentSettings()
|
return DevelopmentSettings()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,8 +86,19 @@ class TemplateInfo:
|
|||||||
Template Information
|
Template Information
|
||||||
|
|
||||||
Adapts to the Agent Manager GET /templates interface response
|
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
|
template: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
port: Optional[int] = None
|
port: Optional[int] = None
|
||||||
env_info: Dict[str, Any] = field(default_factory=dict)
|
env_info: Dict[str, Any] = field(default_factory=dict)
|
||||||
template_type: Optional[str] = None # platform or custom
|
template_type: Optional[str] = None # platform or custom
|
||||||
@@ -688,6 +699,9 @@ class AgentManagerClient:
|
|||||||
for t in data.get("templates", []):
|
for t in data.get("templates", []):
|
||||||
templates.append(TemplateInfo(
|
templates.append(TemplateInfo(
|
||||||
template=t["template"],
|
template=t["template"],
|
||||||
|
display_name=t.get("displayName"),
|
||||||
|
description=t.get("description"),
|
||||||
|
category=t.get("category"),
|
||||||
port=t.get("port"),
|
port=t.get("port"),
|
||||||
env_info=t.get("env_info", {})
|
env_info=t.get("env_info", {})
|
||||||
))
|
))
|
||||||
@@ -709,6 +723,9 @@ class AgentManagerClient:
|
|||||||
for t in data.get("templates", []):
|
for t in data.get("templates", []):
|
||||||
templates.append(TemplateInfo(
|
templates.append(TemplateInfo(
|
||||||
template=t["template"],
|
template=t["template"],
|
||||||
|
display_name=t.get("display_name"),
|
||||||
|
description=t.get("description"),
|
||||||
|
category=t.get("category"),
|
||||||
port=t.get("port"),
|
port=t.get("port"),
|
||||||
env_info=t.get("env_info", {}),
|
env_info=t.get("env_info", {}),
|
||||||
template_type="platform"
|
template_type="platform"
|
||||||
@@ -733,6 +750,9 @@ class AgentManagerClient:
|
|||||||
for t in data.get("templates", []):
|
for t in data.get("templates", []):
|
||||||
templates.append(TemplateInfo(
|
templates.append(TemplateInfo(
|
||||||
template=t["template"],
|
template=t["template"],
|
||||||
|
display_name=t.get("display_name"),
|
||||||
|
description=t.get("description"),
|
||||||
|
category=t.get("category"),
|
||||||
port=t.get("port"),
|
port=t.get("port"),
|
||||||
env_info=t.get("env_info", {}),
|
env_info=t.get("env_info", {}),
|
||||||
template_type="custom"
|
template_type="custom"
|
||||||
@@ -1082,7 +1102,21 @@ class AgentManagerClient:
|
|||||||
- status.cpu_request/cpu_limit: CPU configuration
|
- status.cpu_request/cpu_limit: CPU configuration
|
||||||
- status.memory_request/memory_limit: Memory 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
|
# Parse container status list
|
||||||
containers = []
|
containers = []
|
||||||
@@ -1118,10 +1152,15 @@ class AgentManagerClient:
|
|||||||
access_url = access_info.get("recommended") or data.get("access_url")
|
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(
|
return AgentStatusResult(
|
||||||
name=data["name"],
|
name=data.get("name", agent_name),
|
||||||
namespace=data["namespace"],
|
namespace=data.get("namespace", "ai-agents"),
|
||||||
status=data["status"],
|
status=agent_status,
|
||||||
health_status=data.get("health_status", "unknown"),
|
health_status=data.get("health_status", "unknown"),
|
||||||
created_at=data.get("created_at"),
|
created_at=data.get("created_at"),
|
||||||
pod_ip=data.get("pod_ip"),
|
pod_ip=data.get("pod_ip"),
|
||||||
@@ -1302,10 +1341,13 @@ class AgentManagerClient:
|
|||||||
payload["headers"] = headers
|
payload["headers"] = headers
|
||||||
if auth:
|
if auth:
|
||||||
payload["auth"] = auth
|
payload["auth"] = auth
|
||||||
if request_params:
|
|
||||||
payload["request_params"] = request_params
|
# Agent Manager 期望的是 input_schema,而不是 request_params/request_body
|
||||||
if request_body:
|
# input_schema 统一使用 JSON Schema 格式定义工具的输入参数
|
||||||
payload["request_body"] = request_body
|
input_schema = request_params or request_body
|
||||||
|
if input_schema:
|
||||||
|
payload["input_schema"] = input_schema
|
||||||
|
|
||||||
if response_mapping:
|
if response_mapping:
|
||||||
payload["response_mapping"] = response_mapping
|
payload["response_mapping"] = response_mapping
|
||||||
if timeout:
|
if timeout:
|
||||||
@@ -1318,7 +1360,8 @@ class AgentManagerClient:
|
|||||||
name=name,
|
name=name,
|
||||||
url=url,
|
url=url,
|
||||||
method=method,
|
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)
|
return await self._request("POST", "/external-tools/generate", json=payload)
|
||||||
@@ -1373,10 +1416,12 @@ class AgentManagerClient:
|
|||||||
payload["headers"] = headers
|
payload["headers"] = headers
|
||||||
if auth:
|
if auth:
|
||||||
payload["auth"] = auth
|
payload["auth"] = auth
|
||||||
if request_params:
|
|
||||||
payload["request_params"] = request_params
|
# Agent Manager 期望的是 input_schema,而不是 request_params/request_body
|
||||||
if request_body:
|
input_schema = request_params or request_body
|
||||||
payload["request_body"] = request_body
|
if input_schema:
|
||||||
|
payload["input_schema"] = input_schema
|
||||||
|
|
||||||
if response_mapping:
|
if response_mapping:
|
||||||
payload["response_mapping"] = response_mapping
|
payload["response_mapping"] = response_mapping
|
||||||
if timeout:
|
if timeout:
|
||||||
@@ -1388,7 +1433,8 @@ class AgentManagerClient:
|
|||||||
"updating_external_tool",
|
"updating_external_tool",
|
||||||
tool_ref_id=tool_ref_id,
|
tool_ref_id=tool_ref_id,
|
||||||
name=name,
|
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)
|
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
|
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)
|
# 检查业务逻辑是否成功(agent-manager 可能返回 HTTP 200 但 success=false)
|
||||||
if isinstance(data, dict) and data.get("success") is False:
|
if isinstance(response, dict) and response.get("success") is False:
|
||||||
error_code = data.get("error", "unknown_error")
|
error_code = response.get("error", "unknown_error")
|
||||||
error_message = data.get("message", "创建 Agent 失败")
|
error_message = response.get("message", "创建 Agent 失败")
|
||||||
logger.error(
|
logger.error(
|
||||||
"create_agent_with_tools_failed",
|
"create_agent_with_tools_failed",
|
||||||
name=name,
|
name=name,
|
||||||
@@ -1511,12 +1557,32 @@ class AgentManagerClient:
|
|||||||
detail={"error": error_code, "message": error_message}
|
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(
|
return AgentCreateResult(
|
||||||
name=data["name"],
|
name=data.get("name", name),
|
||||||
namespace=data["namespace"],
|
namespace=data.get("namespace", "ai-agents"),
|
||||||
status=data["status"],
|
status=agent_status,
|
||||||
created_at=data["created_at"],
|
created_at=data.get("created_at", ""),
|
||||||
template=data["template"],
|
template=data.get("template", template),
|
||||||
service_port=data.get("service_port"),
|
service_port=data.get("service_port"),
|
||||||
access_info=data.get("access_info"),
|
access_info=data.get("access_info"),
|
||||||
pod_id=data.get("pod_id"),
|
pod_id=data.get("pod_id"),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from typing import Optional, Tuple
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models import User, Channel, BillingRecord, Agent, Balance
|
from models import User, Channel, BillingRecord, Agent, Balance, AgentBillingRecord
|
||||||
|
|
||||||
|
|
||||||
# ============= EU定价配置 =============
|
# ============= EU定价配置 =============
|
||||||
@@ -287,7 +287,7 @@ async def add_balance(
|
|||||||
return True, f"成功充值 {amount} 元,当前余额: {new_balance}"
|
return True, f"成功充值 {amount} 元,当前余额: {new_balance}"
|
||||||
|
|
||||||
|
|
||||||
# ============= 计费记录创建 =============
|
# ============= 计费记录创建(旧版,已废弃) =============
|
||||||
|
|
||||||
async def create_billing_record(
|
async def create_billing_record(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -300,6 +300,10 @@ async def create_billing_record(
|
|||||||
"""
|
"""
|
||||||
创建计费记录
|
创建计费记录
|
||||||
|
|
||||||
|
⚠️ 已废弃 (DEPRECATED)
|
||||||
|
此函数使用旧的 BillingRecord 表,已废弃。
|
||||||
|
请使用 create_agent_billing_record() 函数替代。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tenant_id: 租户ID
|
tenant_id: 租户ID
|
||||||
agent_id: Agent ID
|
agent_id: Agent ID
|
||||||
@@ -317,6 +321,12 @@ async def create_billing_record(
|
|||||||
- 专业级(Pro):$0.02 / EU
|
- 专业级(Pro):$0.02 / EU
|
||||||
- 企业级(Enterprise):$0.03 / EU
|
- 企业级(Enterprise):$0.03 / EU
|
||||||
"""
|
"""
|
||||||
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
"create_billing_record() 已废弃,请使用 create_agent_billing_record() 替代",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2
|
||||||
|
)
|
||||||
# 获取用户信息(包括订阅等级和channel_id)
|
# 获取用户信息(包括订阅等级和channel_id)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User.channel_id, User.subscription_tier).where(User.id == tenant_id)
|
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:
|
async def calculate_monthly_cost(user_id: str, db: AsyncSession) -> Decimal:
|
||||||
"""
|
"""
|
||||||
计算用户本月消费
|
计算用户本月消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: 用户ID
|
user_id: 用户ID
|
||||||
@@ -485,19 +495,30 @@ async def calculate_monthly_cost(user_id: str, db: AsyncSession) -> Decimal:
|
|||||||
本月消费金额
|
本月消费金额
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from models import ModelBillingRecord
|
||||||
|
|
||||||
# 获取本月第一天
|
# 获取本月第一天
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
result = await db.execute(
|
# Agent 计费
|
||||||
select(func.sum(BillingRecord.cost))
|
agent_result = await db.execute(
|
||||||
.where(BillingRecord.tenant_id == user_id)
|
select(func.sum(AgentBillingRecord.cost))
|
||||||
.where(BillingRecord.timestamp >= month_start)
|
.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(
|
async def calculate_channel_commission(
|
||||||
@@ -507,7 +528,7 @@ async def calculate_channel_commission(
|
|||||||
db: AsyncSession
|
db: AsyncSession
|
||||||
) -> Tuple[Decimal, Decimal]:
|
) -> Tuple[Decimal, Decimal]:
|
||||||
"""
|
"""
|
||||||
计算渠道佣金
|
计算渠道佣金(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel_id: 渠道ID
|
channel_id: 渠道ID
|
||||||
@@ -519,6 +540,7 @@ async def calculate_channel_commission(
|
|||||||
(总收入, 佣金金额)
|
(总收入, 佣金金额)
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from models import ModelBillingRecord
|
||||||
|
|
||||||
# 查询渠道佣金率
|
# 查询渠道佣金率
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -527,17 +549,25 @@ async def calculate_channel_commission(
|
|||||||
row = result.first()
|
row = result.first()
|
||||||
commission_rate = Decimal(str(row[0])) / 100 if row else Decimal(0)
|
commission_rate = Decimal(str(row[0])) / 100 if row else Decimal(0)
|
||||||
|
|
||||||
# 查询总收入
|
# Agent 计费收入
|
||||||
result = await db.execute(
|
agent_result = await db.execute(
|
||||||
select(func.sum(BillingRecord.cost))
|
select(func.sum(AgentBillingRecord.cost))
|
||||||
.where(BillingRecord.channel_id == channel_id)
|
.where(AgentBillingRecord.channel_id == channel_id)
|
||||||
.where(BillingRecord.timestamp >= start_date)
|
.where(AgentBillingRecord.start_time >= start_date)
|
||||||
.where(BillingRecord.timestamp <= end_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
|
commission = total_revenue * commission_rate
|
||||||
|
|
||||||
return total_revenue, commission
|
return total_revenue, commission
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from sqlalchemy import select, and_
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import AsyncSessionLocal
|
from database import AsyncSessionLocal
|
||||||
from models import AgentBillingRecord, User, Balance
|
from models import AgentBillingRecord, User, Balance, TenantCustomAgentQuota, Agent
|
||||||
from app.billing import (
|
from app.billing import (
|
||||||
calculate_platform_agent_cost,
|
calculate_platform_agent_cost,
|
||||||
calculate_agent_cost_by_resources,
|
calculate_agent_cost_by_resources,
|
||||||
@@ -47,8 +47,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# 计费周期配置
|
# 计费周期配置
|
||||||
BILLING_INTERVAL_SECONDS = 3600 # 每小时执行一次
|
BILLING_INTERVAL_SECONDS = 3600 # 每小时执行一次
|
||||||
|
QUOTA_CHECK_INTERVAL_HOURS = 24 # 配额一致性检查周期:每24小时一次
|
||||||
VM_EU_PER_HOUR = Decimal("0.5") # VM计算 0.5 EU/hour
|
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:
|
def _calculate_eu(duration_seconds: int) -> int:
|
||||||
"""计算EU:1 EU = 10秒,不足10秒按1 EU计算"""
|
"""计算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
|
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:
|
async def update_running_agent_billing(db: AsyncSession) -> dict:
|
||||||
"""
|
"""
|
||||||
更新所有运行中 Agent 的计费记录
|
更新所有运行中 Agent 的计费记录
|
||||||
@@ -223,12 +315,16 @@ async def periodic_billing_task():
|
|||||||
周期性计费任务(后台运行)
|
周期性计费任务(后台运行)
|
||||||
|
|
||||||
每小时执行一次,更新所有运行中 Agent 的计费记录
|
每小时执行一次,更新所有运行中 Agent 的计费记录
|
||||||
|
每24小时执行一次配额一致性检查(假用量检测)
|
||||||
"""
|
"""
|
||||||
|
global _last_quota_check
|
||||||
|
|
||||||
logger.info("周期性计费任务启动")
|
logger.info("周期性计费任务启动")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
|
# 1. 执行计费更新(每小时)
|
||||||
stats = await update_running_agent_billing(db)
|
stats = await update_running_agent_billing(db)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -245,6 +341,22 @@ async def periodic_billing_task():
|
|||||||
if stats["errors"]:
|
if stats["errors"]:
|
||||||
logger.warning(f"计费错误: {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:
|
except Exception as e:
|
||||||
logger.error(f"周期性计费任务异常: {e}")
|
logger.error(f"周期性计费任务异常: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
预付费配额管理模块
|
预付费配额管理模块
|
||||||
配额预警与限制
|
配额预警与限制
|
||||||
|
|
||||||
|
注意:计费数据已迁移到新表:
|
||||||
|
- AgentBillingRecord: Agent 运行时计费
|
||||||
|
- ModelBillingRecord: 模型调用计费(LiteLLM)
|
||||||
|
旧的 BillingRecord 表已废弃,不再使用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
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 import select, func, and_, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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))
|
credit_limit = Decimal(str(user.credit_limit))
|
||||||
available = balance + credit_limit
|
available = balance + credit_limit
|
||||||
|
|
||||||
# 获取用户平均日消费
|
# 获取用户平均日消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
|
||||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
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(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.tenant_id == user_id,
|
AgentBillingRecord.user_id == user_id,
|
||||||
BillingRecord.timestamp >= thirty_days_ago,
|
AgentBillingRecord.start_time >= thirty_days_ago,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
total_cost = cost_result.scalar() or 0
|
agent_cost = agent_cost_result.scalar() or 0
|
||||||
daily_avg = Decimal(str(total_cost)) / 30
|
|
||||||
|
# 模型调用计费
|
||||||
|
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:
|
if daily_avg > 0:
|
||||||
@@ -115,18 +136,34 @@ async def check_channel_quota(
|
|||||||
|
|
||||||
channel_credit = Decimal(str(channel.channel_credit))
|
channel_credit = Decimal(str(channel.channel_credit))
|
||||||
|
|
||||||
# 获取渠道下所有租户的总消费
|
# 获取渠道下所有租户的总消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
|
||||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
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(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.channel_id == channel_id,
|
AgentBillingRecord.channel_id == channel_id,
|
||||||
BillingRecord.timestamp >= thirty_days_ago,
|
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 = {
|
details = {
|
||||||
"channelCredit": float(channel_credit),
|
"channelCredit": float(channel_credit),
|
||||||
@@ -323,19 +360,34 @@ async def check_rate_limit(
|
|||||||
Returns:
|
Returns:
|
||||||
(是否允许, 当前使用量, 限制量)
|
(是否允许, 当前使用量, 限制量)
|
||||||
"""
|
"""
|
||||||
# 获取最近1分钟的调用次数
|
# 获取最近1分钟的调用次数(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
|
||||||
one_minute_ago = datetime.utcnow() - timedelta(minutes=1)
|
one_minute_ago = datetime.utcnow() - timedelta(minutes=1)
|
||||||
|
|
||||||
result = await db.execute(
|
# Agent 调用次数
|
||||||
select(func.count(BillingRecord.id))
|
agent_result = await db.execute(
|
||||||
|
select(func.count(AgentBillingRecord.id))
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.tenant_id == user_id,
|
AgentBillingRecord.user_id == user_id,
|
||||||
BillingRecord.timestamp >= one_minute_ago,
|
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限制(从资源分配表)
|
# 获取用户的RPM限制(从资源分配表)
|
||||||
limit_result = await db.execute(
|
limit_result = await db.execute(
|
||||||
@@ -408,7 +460,7 @@ async def get_user_quota_summary(
|
|||||||
"alerts": [...]
|
"alerts": [...]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
from models import User, QuotaAlert, BillingRecord, ResourceUsage
|
from models import User, QuotaAlert, ResourceUsage
|
||||||
|
|
||||||
# 1. 获取用户信息
|
# 1. 获取用户信息
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -428,19 +480,35 @@ async def get_user_quota_summary(
|
|||||||
credit_limit = Decimal(str(user.credit_limit or 0))
|
credit_limit = Decimal(str(user.credit_limit or 0))
|
||||||
available = balance + credit_limit
|
available = balance + credit_limit
|
||||||
|
|
||||||
# 计算近30天平均日消费
|
# 计算近30天平均日消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
|
||||||
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
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(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.tenant_id == user_id,
|
AgentBillingRecord.user_id == user_id,
|
||||||
BillingRecord.timestamp >= thirty_days_ago,
|
AgentBillingRecord.start_time >= thirty_days_ago,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
total_cost = cost_result.scalar() or 0
|
agent_cost = agent_cost_result.scalar() or 0
|
||||||
daily_avg = Decimal(str(total_cost)) / 30 if total_cost > 0 else Decimal("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:
|
if daily_avg > 0:
|
||||||
@@ -536,7 +604,7 @@ async def get_channel_quota_summary(
|
|||||||
"usagePercent": float
|
"usagePercent": float
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
from models import Channel, User, QuotaAlert, BillingRecord
|
from models import Channel, User, QuotaAlert
|
||||||
|
|
||||||
# 1. 获取渠道信息
|
# 1. 获取渠道信息
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -547,21 +615,37 @@ async def get_channel_quota_summary(
|
|||||||
if not channel:
|
if not channel:
|
||||||
raise ValueError(f"渠道不存在: {channel_id}")
|
raise ValueError(f"渠道不存在: {channel_id}")
|
||||||
|
|
||||||
# 2. 计算本月使用量
|
# 2. 计算本月使用量(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
month_start = datetime(now.year, now.month, 1)
|
month_start = datetime(now.year, now.month, 1)
|
||||||
|
|
||||||
usage_result = await db.execute(
|
# Agent 计费
|
||||||
select(func.sum(BillingRecord.cost))
|
agent_usage_result = await db.execute(
|
||||||
.join(User, BillingRecord.tenant_id == User.id)
|
select(func.sum(AgentBillingRecord.cost))
|
||||||
|
.join(User, AgentBillingRecord.user_id == User.id)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
User.channel_id == channel_id,
|
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
|
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
|
usage_percent = float(monthly_usage / channel_credit * 100) if channel_credit > 0 else 0
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
资源管控模块
|
资源管控模块
|
||||||
提供请求级别的资源管控、速率限制和配额检查
|
提供请求级别的资源管控、速率限制和配额检查
|
||||||
|
|
||||||
|
注意:计费数据已迁移到新表:
|
||||||
|
- AgentBillingRecord: Agent 运行时计费
|
||||||
|
- ModelBillingRecord: 模型调用计费(LiteLLM)
|
||||||
|
旧的 BillingRecord 表已废弃,不再使用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -11,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
import structlog
|
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.quota_manager import check_user_balance_quota, check_channel_quota
|
||||||
from app.db_utils import with_retry
|
from app.db_utils import with_retry
|
||||||
|
|
||||||
@@ -276,19 +281,23 @@ class ResourceController:
|
|||||||
)
|
)
|
||||||
db.add(usage)
|
db.add(usage)
|
||||||
|
|
||||||
# 记录到BillingRecord表
|
# 记录到 AgentBillingRecord 表(新计费表)
|
||||||
billing_record = BillingRecord(
|
# 注意:这里只记录 Agent 类型的资源消耗
|
||||||
tenant_id=user_id,
|
# 模型调用计费由 LiteLLM Callback 处理,存入 ModelBillingRecord
|
||||||
resource_type=resource_type,
|
if resource_type == "agent":
|
||||||
resource_id=resource_id,
|
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,
|
cost=cost,
|
||||||
timestamp=now,
|
eu_consumed=int(cost), # 简化:1 EU = 1 cost
|
||||||
details={
|
start_time=now,
|
||||||
"execution_time_ms": execution_time_ms,
|
period_start=now,
|
||||||
"cpu_usage": cpu_usage,
|
|
||||||
"memory_usage": memory_usage,
|
|
||||||
"network_io": network_io
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
db.add(billing_record)
|
db.add(billing_record)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
资源使用监控模块
|
资源使用监控模块
|
||||||
采集和统计用户资源使用情况
|
采集和统计用户资源使用情况
|
||||||
|
|
||||||
|
注意:计费数据已迁移到新表:
|
||||||
|
- AgentBillingRecord: Agent 运行时计费
|
||||||
|
- ModelBillingRecord: 模型调用计费(LiteLLM)
|
||||||
|
旧的 BillingRecord 表已废弃,不再使用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -9,7 +14,7 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
from sqlalchemy import select, func, and_
|
from sqlalchemy import select, func, and_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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(
|
async def record_resource_usage(
|
||||||
@@ -249,6 +254,10 @@ async def get_platform_resource_overview(db: AsyncSession) -> Dict:
|
|||||||
"""
|
"""
|
||||||
获取平台资源概览(管理员视图)
|
获取平台资源概览(管理员视图)
|
||||||
|
|
||||||
|
数据来源:
|
||||||
|
- AgentBillingRecord: Agent 运行时计费
|
||||||
|
- ModelBillingRecord: 模型调用计费(LiteLLM)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: 数据库会话
|
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)
|
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)
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
# 今日调用次数
|
# 今日调用次数(Agent + Model)
|
||||||
today_calls = await db.execute(
|
today_agent_calls = await db.execute(
|
||||||
select(func.count(BillingRecord.id))
|
select(func.count(AgentBillingRecord.id))
|
||||||
.where(BillingRecord.timestamp >= today_start)
|
.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)
|
||||||
|
|
||||||
# 本月调用次数
|
# 本月调用次数(Agent + Model)
|
||||||
month_calls = await db.execute(
|
month_agent_calls = await db.execute(
|
||||||
select(func.count(BillingRecord.id))
|
select(func.count(AgentBillingRecord.id))
|
||||||
.where(BillingRecord.timestamp >= month_start)
|
.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)
|
||||||
|
|
||||||
# 活跃用户数
|
# 活跃用户数(今日有 Agent 或 Model 调用的用户)
|
||||||
active_users = await db.execute(
|
active_agent_users = await db.execute(
|
||||||
select(func.count(func.distinct(BillingRecord.tenant_id)))
|
select(func.count(func.distinct(AgentBillingRecord.user_id)))
|
||||||
.where(BillingRecord.timestamp >= today_start)
|
.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数
|
# 活跃Agent数
|
||||||
active_agents = await db.execute(
|
active_agents = await db.execute(
|
||||||
@@ -283,18 +308,23 @@ async def get_platform_resource_overview(db: AsyncSession) -> Dict:
|
|||||||
.where(Agent.status == "active")
|
.where(Agent.status == "active")
|
||||||
)
|
)
|
||||||
|
|
||||||
# 总EU消耗
|
# 总EU消耗(Agent + Model)
|
||||||
total_eu = await db.execute(
|
agent_eu = await db.execute(
|
||||||
select(func.sum(BillingRecord.eu))
|
select(func.sum(AgentBillingRecord.eu_consumed))
|
||||||
.where(BillingRecord.timestamp >= month_start)
|
.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 {
|
return {
|
||||||
"todayCalls": today_calls.scalar() or 0,
|
"todayCalls": today_calls_total,
|
||||||
"monthCalls": month_calls.scalar() or 0,
|
"monthCalls": month_calls_total,
|
||||||
"activeUsersToday": active_users.scalar() or 0,
|
"activeUsersToday": active_users_total,
|
||||||
"activeAgents": active_agents.scalar() or 0,
|
"activeAgents": active_agents.scalar() or 0,
|
||||||
"monthTotalEu": int(total_eu.scalar() or 0),
|
"monthTotalEu": int(total_eu),
|
||||||
"timestamp": now.isoformat(),
|
"timestamp": now.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2853,6 +2853,11 @@ async def get_billing_overview(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取三维度计费统计(所有管理员可查看)
|
获取三维度计费统计(所有管理员可查看)
|
||||||
|
|
||||||
|
从 AgentBillingRecord 和 ModelBillingRecord 表查询计费数据,
|
||||||
|
返回渠道维度、租户维度和调用记录三个维度的统计。
|
||||||
|
|
||||||
|
EU 计算规则:1 EU = 10 秒运行时间(向上取整)
|
||||||
"""
|
"""
|
||||||
_verify_read_permission(principal)
|
_verify_read_permission(principal)
|
||||||
|
|
||||||
@@ -2866,99 +2871,227 @@ async def get_billing_overview(
|
|||||||
if end_dt.tzinfo is not None:
|
if end_dt.tzinfo is not None:
|
||||||
end_dt = end_dt.replace(tzinfo=None)
|
end_dt = end_dt.replace(tzinfo=None)
|
||||||
|
|
||||||
# 渠道统计
|
# ========== 渠道统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ==========
|
||||||
channel_stats_result = await db.execute(
|
|
||||||
|
# 1. 从 AgentBillingRecord 统计 Agent 使用
|
||||||
|
agent_channel_stats = await db.execute(
|
||||||
select(
|
select(
|
||||||
Channel.id,
|
Channel.id,
|
||||||
Channel.name,
|
Channel.name,
|
||||||
func.count(BillingRecord.id).label("calls"),
|
func.count(AgentBillingRecord.id).label("calls"),
|
||||||
func.sum(BillingRecord.eu).label("total_eu"),
|
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
|
||||||
func.sum(BillingRecord.cost).label("total_cost"),
|
func.sum(AgentBillingRecord.cost).label("total_cost"),
|
||||||
)
|
)
|
||||||
.select_from(BillingRecord)
|
.select_from(AgentBillingRecord)
|
||||||
.join(Channel, BillingRecord.channel_id == Channel.id)
|
.join(Channel, AgentBillingRecord.channel_id == Channel.id)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.timestamp >= start_dt,
|
AgentBillingRecord.start_time >= start_dt,
|
||||||
BillingRecord.timestamp <= end_dt,
|
AgentBillingRecord.start_time <= end_dt,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.group_by(Channel.id, Channel.name)
|
.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 = [
|
# 2. 从 ModelBillingRecord 统计模型调用
|
||||||
{
|
model_channel_stats = await db.execute(
|
||||||
"channelId": str(row.id),
|
select(
|
||||||
"channelName": row.name,
|
Channel.id,
|
||||||
"calls": row.calls,
|
Channel.name,
|
||||||
"totalEU": float(row.total_eu or 0),
|
func.count(ModelBillingRecord.id).label("calls"),
|
||||||
"totalCost": float(row.total_cost or 0),
|
func.sum(ModelBillingRecord.eu_consumed).label("total_eu"),
|
||||||
}
|
func.sum(ModelBillingRecord.total_cost).label("total_cost"),
|
||||||
for row in channel_stats_result.all()
|
)
|
||||||
]
|
.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()}
|
||||||
|
|
||||||
# 租户统计
|
# 3. 合并渠道统计
|
||||||
tenant_stats_result = await db.execute(
|
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(
|
select(
|
||||||
User.id,
|
User.id,
|
||||||
User.name,
|
User.name,
|
||||||
Channel.name.label("channel_name"),
|
Channel.name.label("channel_name"),
|
||||||
func.count(BillingRecord.id).label("calls"),
|
func.count(AgentBillingRecord.id).label("calls"),
|
||||||
func.sum(BillingRecord.eu).label("total_eu"),
|
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
|
||||||
func.sum(BillingRecord.cost).label("total_cost"),
|
func.sum(AgentBillingRecord.cost).label("total_cost"),
|
||||||
)
|
)
|
||||||
.select_from(BillingRecord)
|
.select_from(AgentBillingRecord)
|
||||||
.join(User, BillingRecord.tenant_id == User.id)
|
.join(User, AgentBillingRecord.user_id == User.id)
|
||||||
.join(Channel, User.channel_id == Channel.id)
|
.outerjoin(Channel, User.channel_id == Channel.id)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.timestamp >= start_dt,
|
AgentBillingRecord.start_time >= start_dt,
|
||||||
BillingRecord.timestamp <= end_dt,
|
AgentBillingRecord.start_time <= end_dt,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.group_by(User.id, User.name, Channel.name)
|
.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 = [
|
# 2. 从 ModelBillingRecord 统计
|
||||||
{
|
model_tenant_stats = await db.execute(
|
||||||
"tenantId": str(row.id),
|
select(
|
||||||
"tenantName": row.name,
|
User.id,
|
||||||
"channelName": row.channel_name,
|
User.name,
|
||||||
"calls": row.calls,
|
Channel.name.label("channel_name"),
|
||||||
"totalEU": float(row.total_eu or 0),
|
func.count(ModelBillingRecord.id).label("calls"),
|
||||||
"totalCost": float(row.total_cost or 0),
|
func.sum(ModelBillingRecord.eu_consumed).label("total_eu"),
|
||||||
}
|
func.sum(ModelBillingRecord.total_cost).label("total_cost"),
|
||||||
for row in tenant_stats_result.all()
|
)
|
||||||
]
|
.select_from(ModelBillingRecord)
|
||||||
|
.join(User, ModelBillingRecord.tenant_id == User.id)
|
||||||
# 调用记录
|
.outerjoin(Channel, User.channel_id == Channel.id)
|
||||||
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)
|
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.timestamp >= start_dt,
|
ModelBillingRecord.start_time >= start_dt,
|
||||||
BillingRecord.timestamp <= end_dt,
|
ModelBillingRecord.start_time <= end_dt,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(desc(BillingRecord.timestamp))
|
.group_by(User.id, User.name, Channel.name)
|
||||||
.limit(100)
|
)
|
||||||
|
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),
|
"id": str(record.id),
|
||||||
"timestamp": record.timestamp.isoformat(),
|
"type": "agent",
|
||||||
"channelName": channel_name,
|
"timestamp": record.start_time.isoformat() if record.start_time else None,
|
||||||
"tenantName": tenant_name,
|
"channelName": channel_name or "无渠道",
|
||||||
|
"tenantName": user_name,
|
||||||
"agentName": record.agent_name,
|
"agentName": record.agent_name,
|
||||||
"duration": record.duration,
|
"modelName": record.model_name,
|
||||||
"eu": record.eu,
|
"duration": record.duration_seconds or 0,
|
||||||
"cost": float(record.cost),
|
"eu": record.eu_consumed or 0,
|
||||||
}
|
"cost": float(record.cost or 0),
|
||||||
for record, channel_name, tenant_name in records_result.all()
|
})
|
||||||
]
|
|
||||||
|
# 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:
|
if export:
|
||||||
@@ -3497,45 +3630,6 @@ async def get_admin_roles(
|
|||||||
|
|
||||||
# ============= 平台 Agent 资源申请审批 =============
|
# ============= 平台 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]] = {
|
TEMPLATE_RESOURCE_CONFIG: Dict[str, Dict[str, str]] = {
|
||||||
"echo_agent": {
|
"echo_agent": {
|
||||||
@@ -3607,18 +3701,22 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession]
|
|||||||
result = []
|
result = []
|
||||||
for template in templates:
|
for template in templates:
|
||||||
template_name = template.template
|
template_name = template.template
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
|
|
||||||
default_resource_config = TEMPLATE_RESOURCE_CONFIG.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)
|
db_config = db_configs.get(template_name)
|
||||||
if db_config:
|
if db_config:
|
||||||
# 使用数据库配置覆盖默认值
|
# 使用数据库配置覆盖默认值
|
||||||
result.append({
|
result.append({
|
||||||
"name": template_name,
|
"name": template_name,
|
||||||
"displayName": db_config.display_name or display_info.get("displayName", template_name),
|
"displayName": db_config.display_name or agent_manager_display_name or template_name,
|
||||||
"description": db_config.description or display_info.get("description", f"{template_name} Agent"),
|
"description": db_config.description or agent_manager_description or f"{template_name} Agent",
|
||||||
"category": display_info.get("category", "general"),
|
"category": agent_manager_category or "general",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"port": template.port,
|
"port": template.port,
|
||||||
"envInfo": template.env_info,
|
"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",
|
"status": "available" if (db_config.is_enabled is None or db_config.is_enabled) else "disabled",
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# 使用默认配置
|
# 使用 Agent Manager 返回值和默认资源配置
|
||||||
result.append({
|
result.append({
|
||||||
"name": template_name,
|
"name": template_name,
|
||||||
"displayName": display_info.get("displayName", template_name),
|
"displayName": agent_manager_display_name or template_name,
|
||||||
"description": display_info.get("description", f"{template_name} Agent"),
|
"description": agent_manager_description or f"{template_name} Agent",
|
||||||
"category": display_info.get("category", "general"),
|
"category": agent_manager_category or "general",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"port": template.port,
|
"port": template.port,
|
||||||
"envInfo": template.env_info,
|
"envInfo": template.env_info,
|
||||||
@@ -3653,45 +3751,9 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession]
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("admin: 从 Agent Manager 获取平台模板失败,使用默认模板", error=str(e))
|
logger.error("admin: 从 Agent Manager 获取平台模板失败", error=str(e))
|
||||||
# 返回基于 TEMPLATE_DISPLAY_INFO 的默认模板
|
# Agent Manager 不可用时,返回空列表
|
||||||
result = []
|
return []
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_template_exists(template_name: str) -> bool:
|
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()
|
templates = await client.list_platform_templates()
|
||||||
return any(t.template == template_name for t in templates)
|
return any(t.template == template_name for t in templates)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("admin: 验证模板失败,使用本地验证", error=str(e))
|
logger.error("admin: 验证模板失败", error=str(e))
|
||||||
return template_name in TEMPLATE_DISPLAY_INFO
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/platform-agents/templates", response_model=SuccessResponse)
|
@router.get("/platform-agents/templates", response_model=SuccessResponse)
|
||||||
@@ -3731,43 +3793,120 @@ async def list_platform_agent_templates(
|
|||||||
async def list_platform_agent_applications(
|
async def list_platform_agent_applications(
|
||||||
status_filter: Optional[str] = Query(None, alias="status", pattern="^(pending|approved|rejected)$"),
|
status_filter: Optional[str] = Query(None, alias="status", pattern="^(pending|approved|rejected)$"),
|
||||||
channel_id: Optional[str] = Query(None),
|
channel_id: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(10, ge=1, le=50, description="返回数量,默认10条,最多50条"),
|
||||||
principal: dict = Depends(require_auth),
|
principal: dict = Depends(require_auth),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取平台 Agent 申请列表(管理员视图)
|
获取平台 Agent 申请列表(管理员视图)
|
||||||
|
|
||||||
|
默认优先返回未审批(pending)的申请,如果未审批数量不足则用已审批的填充。
|
||||||
|
如果指定了 status 参数,则只返回该状态的申请。
|
||||||
|
|
||||||
权限:view:applications (所有管理员)
|
权限:view:applications (所有管理员)
|
||||||
"""
|
"""
|
||||||
_verify_read_permission(principal)
|
_verify_read_permission(principal)
|
||||||
|
|
||||||
# 构建查询
|
data = []
|
||||||
|
|
||||||
|
# 如果指定了状态筛选,直接按该状态查询
|
||||||
|
if status_filter:
|
||||||
query = select(ResourceApplication, Channel).join(
|
query = select(ResourceApplication, Channel).join(
|
||||||
Channel, ResourceApplication.channel_id == Channel.id
|
Channel, ResourceApplication.channel_id == Channel.id
|
||||||
).where(
|
).where(
|
||||||
ResourceApplication.resource_type == "platform_agent"
|
and_(
|
||||||
|
ResourceApplication.resource_type == "platform_agent",
|
||||||
|
ResourceApplication.status == status_filter
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if status_filter:
|
|
||||||
query = query.where(ResourceApplication.status == status_filter)
|
|
||||||
|
|
||||||
if channel_id:
|
if channel_id:
|
||||||
query = query.where(ResourceApplication.channel_id == channel_id)
|
query = query.where(ResourceApplication.channel_id == channel_id)
|
||||||
|
|
||||||
query = query.order_by(desc(ResourceApplication.created_at))
|
query = query.order_by(desc(ResourceApplication.created_at)).limit(limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
|
|
||||||
data = []
|
|
||||||
for app, channel in result.all():
|
for app, channel in result.all():
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(app.template_name, {})
|
|
||||||
data.append({
|
data.append({
|
||||||
"id": str(app.id),
|
"id": str(app.id),
|
||||||
"channelId": str(app.channel_id),
|
"channelId": str(app.channel_id),
|
||||||
"channelName": channel.name,
|
"channelName": channel.name,
|
||||||
"resourceType": app.resource_type,
|
"resourceType": app.resource_type,
|
||||||
"templateName": app.template_name,
|
"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,
|
||||||
|
"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,
|
"requestedPodQuota": app.requested_pod_quota,
|
||||||
"approvedPodQuota": app.approved_pod_quota,
|
"approvedPodQuota": app.approved_pod_quota,
|
||||||
"reason": app.reason,
|
"reason": app.reason,
|
||||||
@@ -3918,8 +4057,6 @@ async def list_platform_agent_allocations(
|
|||||||
|
|
||||||
data = []
|
data = []
|
||||||
for quota, channel in result.all():
|
for quota, channel in result.all():
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
|
|
||||||
|
|
||||||
# 从模板配置中获取管理员设置的CPU和内存限制
|
# 从模板配置中获取管理员设置的CPU和内存限制
|
||||||
template_config = template_configs.get(quota.template_name)
|
template_config = template_configs.get(quota.template_name)
|
||||||
cpu_limit = template_config.cpu_limit if template_config and template_config.cpu_limit else "100m"
|
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),
|
"channelId": str(quota.target_id),
|
||||||
"channelName": channel.name if channel else "未知",
|
"channelName": channel.name if channel else "未知",
|
||||||
"templateName": quota.template_name,
|
"templateName": quota.template_name,
|
||||||
"templateDisplayName": display_info.get("displayName", quota.template_name),
|
"templateDisplayName": quota.template_name,
|
||||||
"podQuota": quota.pod_quota,
|
"podQuota": quota.pod_quota,
|
||||||
"podUsed": quota.pod_used,
|
"podUsed": quota.pod_used,
|
||||||
"podRemaining": quota.pod_quota - quota.pod_used,
|
"podRemaining": quota.pod_quota - quota.pod_used,
|
||||||
@@ -4024,14 +4161,12 @@ async def allocate_platform_agent_to_channel(
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
|
|
||||||
|
|
||||||
return SuccessResponse(
|
return SuccessResponse(
|
||||||
data={
|
data={
|
||||||
"channelId": str(channel_uuid),
|
"channelId": str(channel_uuid),
|
||||||
"channelName": channel.name,
|
"channelName": channel.name,
|
||||||
"templateName": template_name,
|
"templateName": template_name,
|
||||||
"templateDisplayName": display_info.get("displayName", template_name),
|
"templateDisplayName": template_name,
|
||||||
"podQuota": pod_quota,
|
"podQuota": pod_quota,
|
||||||
},
|
},
|
||||||
message=message
|
message=message
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ def _build_agent_card(agent: Agent) -> AgentCard:
|
|||||||
k8s_status=agent.k8s_status,
|
k8s_status=agent.k8s_status,
|
||||||
service_port=agent.service_port,
|
service_port=agent.service_port,
|
||||||
access_url=agent.access_url,
|
access_url=agent.access_url,
|
||||||
|
# 模型配置
|
||||||
|
model_name=agent.model_name,
|
||||||
# 资源配置
|
# 资源配置
|
||||||
cpu_request=agent.cpu_request,
|
cpu_request=agent.cpu_request,
|
||||||
cpu_limit=agent.cpu_limit,
|
cpu_limit=agent.cpu_limit,
|
||||||
@@ -119,6 +121,9 @@ async def list_templates() -> TemplateListResponse:
|
|||||||
templates=[
|
templates=[
|
||||||
TemplateInfo(
|
TemplateInfo(
|
||||||
template=t.template,
|
template=t.template,
|
||||||
|
displayName=t.display_name,
|
||||||
|
description=t.description,
|
||||||
|
category=t.category,
|
||||||
port=t.port,
|
port=t.port,
|
||||||
env_info=t.env_info
|
env_info=t.env_info
|
||||||
)
|
)
|
||||||
@@ -150,6 +155,9 @@ async def list_platform_templates() -> TemplateListResponse:
|
|||||||
templates=[
|
templates=[
|
||||||
TemplateInfo(
|
TemplateInfo(
|
||||||
template=t.template,
|
template=t.template,
|
||||||
|
displayName=t.display_name,
|
||||||
|
description=t.description,
|
||||||
|
category=t.category,
|
||||||
port=t.port,
|
port=t.port,
|
||||||
env_info=t.env_info
|
env_info=t.env_info
|
||||||
)
|
)
|
||||||
@@ -182,6 +190,9 @@ async def list_custom_templates() -> TemplateListResponse:
|
|||||||
templates=[
|
templates=[
|
||||||
TemplateInfo(
|
TemplateInfo(
|
||||||
template=t.template,
|
template=t.template,
|
||||||
|
displayName=t.display_name,
|
||||||
|
description=t.description,
|
||||||
|
category=t.category,
|
||||||
port=t.port,
|
port=t.port,
|
||||||
env_info=t.env_info
|
env_info=t.env_info
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ async def agent_manager_callback(
|
|||||||
|
|
||||||
if existing_record:
|
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.end_time = end_time or datetime.utcnow()
|
||||||
existing_record.duration_seconds = duration_seconds
|
existing_record.duration_seconds = duration_seconds
|
||||||
@@ -467,27 +467,11 @@ async def agent_manager_callback(
|
|||||||
|
|
||||||
billing_record = existing_record
|
billing_record = existing_record
|
||||||
|
|
||||||
# 计算增量成本(新成本 - 已扣成本)
|
|
||||||
cost_increment = new_cost - previous_cost
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"📝 更新现有计费记录: agent={callback_data.agentName}, "
|
f"📝 更新Agent计费记录(不扣款): agent={callback_data.agentName}, "
|
||||||
f"之前成本={previous_cost}, 最终成本={new_cost}, 增量={cost_increment}"
|
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:
|
else:
|
||||||
# ⚠️ 没有现有记录,创建新记录(异常情况的兜底)
|
# ⚠️ 没有现有记录,创建新记录(异常情况的兜底)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -495,6 +479,8 @@ async def agent_manager_callback(
|
|||||||
f"agent={callback_data.agentName}, user={callback_data.userId}"
|
f"agent={callback_data.agentName}, user={callback_data.userId}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ⚠️ 创建新记录(异常情况的兜底)
|
||||||
|
# ⚠️ 不在此处扣款,由周期计费(periodic_billing.py)统一处理扣款
|
||||||
billing_record = AgentBillingRecord(
|
billing_record = AgentBillingRecord(
|
||||||
user_id=callback_data.userId,
|
user_id=callback_data.userId,
|
||||||
channel_id=user.channel_id if user.channel_id else None,
|
channel_id=user.channel_id if user.channel_id else None,
|
||||||
@@ -514,17 +500,11 @@ async def agent_manager_callback(
|
|||||||
|
|
||||||
db.add(billing_record)
|
db.add(billing_record)
|
||||||
|
|
||||||
# 新记录需要全额扣款
|
logger.info(
|
||||||
success, message = await deduct_balance(
|
f"📝 创建Agent计费记录(不扣款): agent={callback_data.agentName}, "
|
||||||
callback_data.userId,
|
f"最终成本={new_cost},扣款由周期计费处理"
|
||||||
new_cost,
|
|
||||||
db,
|
|
||||||
f"Agent 运行: {callback_data.agentName}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
|
||||||
logger.warning(f"余额扣除失败: {message}")
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(billing_record)
|
await db.refresh(billing_record)
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import structlog
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from models import (
|
from models import (
|
||||||
User, Channel, Agent, ResourceAllocation,
|
User, Channel, Agent, ResourceAllocation,
|
||||||
BillingRecord, RechargeRecord, Application, ModelProvider,
|
RechargeRecord, Application, ModelProvider,
|
||||||
ChannelProviderAccess, ProviderApplication, TenantCustomAgentQuota,
|
ChannelProviderAccess, ProviderApplication, TenantCustomAgentQuota,
|
||||||
ChannelCustomAgentQuota, ResourceApplication, PlatformAgentQuota,
|
ChannelCustomAgentQuota, ResourceApplication, PlatformAgentQuota,
|
||||||
AgentBillingRecord, PlatformAgentTemplateConfig, TenantModelKey, Balance
|
AgentBillingRecord, PlatformAgentTemplateConfig, TenantModelKey, Balance
|
||||||
)
|
)
|
||||||
|
# 注意:BillingRecord 已废弃,使用 AgentBillingRecord 和 ModelBillingRecord 替代
|
||||||
from app.auth import require_auth, get_password_hash
|
from app.auth import require_auth, get_password_hash
|
||||||
from app.permissions import has_permission
|
from app.permissions import has_permission
|
||||||
from app.schemas import (
|
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()}
|
tenants = {str(t.id): t for t in tenants_result.scalars().all()}
|
||||||
tenant_ids = list(tenants.keys())
|
tenant_ids = list(tenants.keys())
|
||||||
|
|
||||||
# 租户统计
|
# 租户统计(从 AgentBillingRecord 统计)
|
||||||
tenant_stats_result = await db.execute(
|
tenant_stats_result = await db.execute(
|
||||||
select(
|
select(
|
||||||
BillingRecord.tenant_id,
|
AgentBillingRecord.user_id.label("tenant_id"),
|
||||||
func.count(BillingRecord.id).label("calls"),
|
func.count(AgentBillingRecord.id).label("calls"),
|
||||||
func.sum(BillingRecord.eu).label("total_eu"),
|
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
|
||||||
func.sum(BillingRecord.cost).label("total_cost"),
|
func.sum(AgentBillingRecord.cost).label("total_cost"),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.tenant_id.in_(tenant_ids),
|
AgentBillingRecord.user_id.in_(tenant_ids),
|
||||||
BillingRecord.timestamp >= start_dt,
|
AgentBillingRecord.start_time >= start_dt,
|
||||||
BillingRecord.timestamp <= end_dt,
|
AgentBillingRecord.start_time <= end_dt,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.group_by(BillingRecord.tenant_id)
|
.group_by(AgentBillingRecord.user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
tenant_stats = []
|
tenant_stats = []
|
||||||
@@ -2296,31 +2297,31 @@ async def get_channel_billing_stats(
|
|||||||
"totalCost": float(row.total_cost or 0),
|
"totalCost": float(row.total_cost or 0),
|
||||||
})
|
})
|
||||||
|
|
||||||
# 调用记录
|
# 调用记录(从 AgentBillingRecord 查询)
|
||||||
records_result = await db.execute(
|
records_result = await db.execute(
|
||||||
select(BillingRecord)
|
select(AgentBillingRecord)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
BillingRecord.tenant_id.in_(tenant_ids),
|
AgentBillingRecord.user_id.in_(tenant_ids),
|
||||||
BillingRecord.timestamp >= start_dt,
|
AgentBillingRecord.start_time >= start_dt,
|
||||||
BillingRecord.timestamp <= end_dt,
|
AgentBillingRecord.start_time <= end_dt,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(desc(BillingRecord.timestamp))
|
.order_by(desc(AgentBillingRecord.start_time))
|
||||||
.limit(100)
|
.limit(100)
|
||||||
)
|
)
|
||||||
|
|
||||||
call_records = []
|
call_records = []
|
||||||
for record in records_result.scalars().all():
|
for record in records_result.scalars().all():
|
||||||
tenant = tenants.get(str(record.tenant_id))
|
tenant = tenants.get(str(record.user_id))
|
||||||
if tenant:
|
if tenant:
|
||||||
call_records.append({
|
call_records.append({
|
||||||
"id": str(record.id),
|
"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,
|
"tenantName": tenant.name,
|
||||||
"agentName": record.agent_name,
|
"agentName": record.agent_name,
|
||||||
"duration": record.duration,
|
"duration": record.duration_seconds,
|
||||||
"eu": record.eu,
|
"eu": record.eu_consumed,
|
||||||
"cost": float(record.cost),
|
"cost": float(record.cost),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2659,45 +2660,6 @@ async def list_provider_access(
|
|||||||
|
|
||||||
# ============= 平台 Agent 资源申请 =============
|
# ============= 平台 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 = {
|
TEMPLATE_RESOURCE_CONFIG = {
|
||||||
"echo_agent": {"cpuRequest": "100m", "cpuLimit": "500m", "memoryRequest": "128Mi", "memoryLimit": "512Mi"},
|
"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 = {}
|
result = {}
|
||||||
for t in templates:
|
for t in templates:
|
||||||
template_name = t.template
|
template_name = t.template
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
|
|
||||||
admin_config = admin_configs.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
|
# 如果有管理员配置,使用管理员配置的值;否则返回 null/0
|
||||||
if admin_config:
|
if admin_config:
|
||||||
resource_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,
|
"maxPods": admin_config.max_pods or 0,
|
||||||
"isConfigured": True,
|
"isConfigured": True,
|
||||||
}
|
}
|
||||||
# 如果管理员配置了显示名称和描述,使用管理员配置的
|
# 优先级:管理员配置 > Agent Manager 返回 > 模板名称
|
||||||
display_name = admin_config.display_name or display_info.get("displayName", template_name)
|
display_name = admin_config.display_name or agent_manager_display_name or template_name
|
||||||
description = admin_config.description or display_info.get("description", f"{template_name} Agent")
|
description = admin_config.description or agent_manager_description or f"{template_name} Agent"
|
||||||
else:
|
else:
|
||||||
# 未配置时返回 null/0,表示管理员尚未配置
|
# 未配置时返回 null/0,表示管理员尚未配置
|
||||||
resource_config = {
|
resource_config = {
|
||||||
@@ -2764,14 +2730,15 @@ async def _get_platform_templates_from_agent_manager(db: AsyncSession = None) ->
|
|||||||
"maxPods": 0,
|
"maxPods": 0,
|
||||||
"isConfigured": False,
|
"isConfigured": False,
|
||||||
}
|
}
|
||||||
display_name = display_info.get("displayName", template_name)
|
# 优先级:Agent Manager 返回 > 模板名称
|
||||||
description = display_info.get("description", f"{template_name} Agent")
|
display_name = agent_manager_display_name or template_name
|
||||||
|
description = agent_manager_description or f"{template_name} Agent"
|
||||||
|
|
||||||
result[template_name] = {
|
result[template_name] = {
|
||||||
"name": template_name,
|
"name": template_name,
|
||||||
"displayName": display_name,
|
"displayName": display_name,
|
||||||
"description": description,
|
"description": description,
|
||||||
"category": display_info.get("category", "general"),
|
"category": agent_manager_category or "general",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"port": t.port,
|
"port": t.port,
|
||||||
"envInfo": t.env_info,
|
"envInfo": t.env_info,
|
||||||
@@ -2804,7 +2771,6 @@ async def _get_custom_templates_from_agent_manager() -> Dict[str, Dict[str, Any]
|
|||||||
result = {}
|
result = {}
|
||||||
for t in templates:
|
for t in templates:
|
||||||
template_name = t.template
|
template_name = t.template
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
|
|
||||||
resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, {
|
resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, {
|
||||||
"cpuRequest": "100m",
|
"cpuRequest": "100m",
|
||||||
"cpuLimit": "500m",
|
"cpuLimit": "500m",
|
||||||
@@ -2812,11 +2778,16 @@ async def _get_custom_templates_from_agent_manager() -> Dict[str, Dict[str, Any]
|
|||||||
"memoryLimit": "512Mi"
|
"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] = {
|
result[template_name] = {
|
||||||
"name": template_name,
|
"name": template_name,
|
||||||
"displayName": display_info.get("displayName", template_name),
|
"displayName": agent_manager_display_name or template_name,
|
||||||
"description": display_info.get("description", f"{template_name} Agent"),
|
"description": agent_manager_description or f"{template_name} Agent",
|
||||||
"category": display_info.get("category", "general"),
|
"category": agent_manager_category or "general",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"port": t.port,
|
"port": t.port,
|
||||||
"envInfo": t.env_info,
|
"envInfo": t.env_info,
|
||||||
@@ -3056,15 +3027,13 @@ async def list_platform_agent_applications(
|
|||||||
data = []
|
data = []
|
||||||
for app in applications:
|
for app in applications:
|
||||||
channel = channels_map.get(str(app.channel_id))
|
channel = channels_map.get(str(app.channel_id))
|
||||||
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
|
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(app.template_name, {})
|
|
||||||
data.append({
|
data.append({
|
||||||
"id": str(app.id),
|
"id": str(app.id),
|
||||||
"channelId": str(app.channel_id),
|
"channelId": str(app.channel_id),
|
||||||
"channelName": channel.name if channel else "未知",
|
"channelName": channel.name if channel else "未知",
|
||||||
"resourceType": app.resource_type,
|
"resourceType": app.resource_type,
|
||||||
"templateName": app.template_name,
|
"templateName": app.template_name,
|
||||||
"templateDisplayName": display_info.get("displayName", app.template_name),
|
"templateDisplayName": app.template_name,
|
||||||
"requestedPodQuota": app.requested_pod_quota,
|
"requestedPodQuota": app.requested_pod_quota,
|
||||||
"approvedPodQuota": app.approved_pod_quota,
|
"approvedPodQuota": app.approved_pod_quota,
|
||||||
"reason": app.reason,
|
"reason": app.reason,
|
||||||
@@ -3118,9 +3087,6 @@ async def list_channel_platform_agent_quotas(
|
|||||||
|
|
||||||
data = []
|
data = []
|
||||||
for quota in quotas:
|
for quota in quotas:
|
||||||
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
|
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
|
|
||||||
|
|
||||||
# 从模板配置中获取管理员设置的CPU和内存限制
|
# 从模板配置中获取管理员设置的CPU和内存限制
|
||||||
template_config = template_configs.get(quota.template_name)
|
template_config = template_configs.get(quota.template_name)
|
||||||
cpu_limit = template_config.cpu_limit if template_config and template_config.cpu_limit else "100m"
|
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({
|
data.append({
|
||||||
"templateName": quota.template_name,
|
"templateName": quota.template_name,
|
||||||
"templateDisplayName": display_info.get("displayName", quota.template_name),
|
"templateDisplayName": quota.template_name,
|
||||||
"podQuota": quota.pod_quota,
|
"podQuota": quota.pod_quota,
|
||||||
"podUsed": quota.pod_used,
|
"podUsed": quota.pod_used,
|
||||||
"podRemaining": quota.pod_quota - quota.pod_used,
|
"podRemaining": quota.pod_quota - quota.pod_used,
|
||||||
@@ -3400,11 +3366,9 @@ async def get_tenant_platform_agent_usage(
|
|||||||
|
|
||||||
data = []
|
data = []
|
||||||
for quota in quotas:
|
for quota in quotas:
|
||||||
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
|
|
||||||
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
|
|
||||||
data.append({
|
data.append({
|
||||||
"templateName": quota.template_name,
|
"templateName": quota.template_name,
|
||||||
"templateDisplayName": display_info.get("displayName", quota.template_name),
|
"templateDisplayName": quota.template_name,
|
||||||
"podQuota": quota.pod_quota,
|
"podQuota": quota.pod_quota,
|
||||||
"podUsed": quota.pod_used,
|
"podUsed": quota.pod_used,
|
||||||
"podRemaining": quota.pod_quota - quota.pod_used,
|
"podRemaining": quota.pod_quota - quota.pod_used,
|
||||||
|
|||||||
@@ -123,9 +123,7 @@ def build_full_auth_config(auth: Optional[ExternalToolAuthConfig]) -> Optional[D
|
|||||||
elif auth.type == "bearer":
|
elif auth.type == "bearer":
|
||||||
return {
|
return {
|
||||||
"type": "bearer",
|
"type": "bearer",
|
||||||
"key": secret,
|
"token": secret
|
||||||
"in": "header",
|
|
||||||
"name": "Authorization"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elif auth.type == "basic":
|
elif auth.type == "basic":
|
||||||
|
|||||||
@@ -218,9 +218,13 @@ async def get_available_platform_agents(
|
|||||||
template_list = []
|
template_list = []
|
||||||
for t in templates:
|
for t in templates:
|
||||||
template_name = t.template
|
template_name = t.template
|
||||||
|
# 优先使用 Agent Manager 返回的 displayName
|
||||||
|
display_name = t.display_name or template_name.replace("_", " ").title()
|
||||||
template_list.append(PlatformAgentTemplateInfo(
|
template_list.append(PlatformAgentTemplateInfo(
|
||||||
name=template_name,
|
name=template_name,
|
||||||
display_name=template_name.replace("_", " ").title(),
|
display_name=display_name,
|
||||||
|
description=t.description,
|
||||||
|
category=t.category,
|
||||||
port=t.port,
|
port=t.port,
|
||||||
has_access=template_name in quotas,
|
has_access=template_name in quotas,
|
||||||
current_quota=quotas.get(template_name, 0)
|
current_quota=quotas.get(template_name, 0)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
用户侧平台API路由
|
用户侧平台API路由
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import structlog
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
@@ -17,6 +17,8 @@ from models import (
|
|||||||
PlatformAgentQuota, AgentBillingRecord, TenantModelKey, ModelBillingRecord, Balance,
|
PlatformAgentQuota, AgentBillingRecord, TenantModelKey, ModelBillingRecord, Balance,
|
||||||
PlatformAgentTemplateConfig
|
PlatformAgentTemplateConfig
|
||||||
)
|
)
|
||||||
|
# ⚠️ 注意:BillingRecord 已废弃,新代码应使用 AgentBillingRecord 和 ModelBillingRecord
|
||||||
|
# 此文件中仍有部分代码使用 BillingRecord,需要后续迁移
|
||||||
from app.billing import (
|
from app.billing import (
|
||||||
get_agent_billing_stats,
|
get_agent_billing_stats,
|
||||||
calculate_platform_agent_cost,
|
calculate_platform_agent_cost,
|
||||||
@@ -46,7 +48,7 @@ from app.schemas import (
|
|||||||
ScaleCustomAgentRequest,
|
ScaleCustomAgentRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
router = APIRouter(prefix="/api/user", tags=["用户侧平台"])
|
router = APIRouter(prefix="/api/user", tags=["用户侧平台"])
|
||||||
|
|
||||||
|
|
||||||
@@ -984,6 +986,7 @@ async def get_my_custom_agent_quota(
|
|||||||
"totalCpu": agent_total_cpu,
|
"totalCpu": agent_total_cpu,
|
||||||
"totalMemory": agent_total_memory,
|
"totalMemory": agent_total_memory,
|
||||||
"startTime": agent.start_time.isoformat() if agent.start_time else None,
|
"startTime": agent.start_time.isoformat() if agent.start_time else None,
|
||||||
|
"modelName": agent.model_name, # 返回模型名称
|
||||||
})
|
})
|
||||||
|
|
||||||
return SuccessResponse(
|
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)
|
@router.get("/agents/platform", response_model=SuccessResponse)
|
||||||
@@ -1302,6 +1260,7 @@ async def list_platform_agents(
|
|||||||
"cpu": float(agent.cpu),
|
"cpu": float(agent.cpu),
|
||||||
"memory": float(agent.memory),
|
"memory": float(agent.memory),
|
||||||
"status": agent.status,
|
"status": agent.status,
|
||||||
|
"modelName": agent.model_name, # 返回模型名称
|
||||||
}
|
}
|
||||||
for agent in agents
|
for agent in agents
|
||||||
]
|
]
|
||||||
@@ -1458,6 +1417,7 @@ async def deploy_agent(
|
|||||||
status="active",
|
status="active",
|
||||||
cpu_request=quota.cpu_per_pod or "100m",
|
cpu_request=quota.cpu_per_pod or "100m",
|
||||||
memory_request=quota.memory_per_pod or "256Mi",
|
memory_request=quota.memory_per_pod or "256Mi",
|
||||||
|
model_name=model_name, # 保存用户选择的模型名称
|
||||||
)
|
)
|
||||||
db.add(agent)
|
db.add(agent)
|
||||||
|
|
||||||
@@ -1475,6 +1435,7 @@ async def deploy_agent(
|
|||||||
cpu_used=quota.cpu_per_pod or "100m",
|
cpu_used=quota.cpu_per_pod or "100m",
|
||||||
memory_used=quota.memory_per_pod or "256Mi",
|
memory_used=quota.memory_per_pod or "256Mi",
|
||||||
replicas=req.instances,
|
replicas=req.instances,
|
||||||
|
model_name=model_name, # 保存用户选择的模型名称
|
||||||
# ========== 保存访问信息 ==========
|
# ========== 保存访问信息 ==========
|
||||||
external_ip=access_info.get("external_ip"),
|
external_ip=access_info.get("external_ip"),
|
||||||
domain=access_info.get("domain"),
|
domain=access_info.get("domain"),
|
||||||
@@ -2234,6 +2195,21 @@ async def get_available_platform_agents(
|
|||||||
)
|
)
|
||||||
quotas = result.scalars().all()
|
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和内存
|
# 查询所有模板配置,用于获取管理员设置的CPU和内存
|
||||||
template_configs_result = await db.execute(select(PlatformAgentTemplateConfig))
|
template_configs_result = await db.execute(select(PlatformAgentTemplateConfig))
|
||||||
template_configs = {config.template_name: config for config in template_configs_result.scalars().all()}
|
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
|
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({
|
agents.append({
|
||||||
"id": str(quota.id),
|
"id": str(quota.id),
|
||||||
"templateName": quota.template_name,
|
"templateName": quota.template_name,
|
||||||
"displayName": quota.template_name.replace("-", " ").replace("_", " ").title(),
|
"displayName": display_name,
|
||||||
"description": f"Platform Agent: {quota.template_name}",
|
"description": description,
|
||||||
"podQuota": quota.pod_quota,
|
"podQuota": quota.pod_quota,
|
||||||
"podUsed": actual_used, # 使用实际运行数量
|
"podUsed": actual_used, # 使用实际运行数量
|
||||||
"podRemaining": quota.pod_quota - actual_used,
|
"podRemaining": quota.pod_quota - actual_used,
|
||||||
"cpuLimit": cpu_limit,
|
"cpuLimit": cpu_limit,
|
||||||
"memoryLimit": memory_limit,
|
"memoryLimit": memory_limit,
|
||||||
"category": "platform",
|
"category": category,
|
||||||
"allocatedAt": quota.allocated_at.isoformat() if quota.allocated_at else None,
|
"allocatedAt": quota.allocated_at.isoformat() if quota.allocated_at else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2482,6 +2464,8 @@ async def deploy_platform_agent(
|
|||||||
quota.pod_used += 1
|
quota.pod_used += 1
|
||||||
|
|
||||||
# 创建 Agent 记录
|
# 创建 Agent 记录
|
||||||
|
# 获取注入的模型名称(如果有)
|
||||||
|
injected_model_name = env_vars.get("MODEL_NAME") or env_vars.get("LITELLM_MODEL")
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name=instance_name,
|
name=instance_name,
|
||||||
type="platform",
|
type="platform",
|
||||||
@@ -2491,7 +2475,8 @@ async def deploy_platform_agent(
|
|||||||
k8s_status=result.status,
|
k8s_status=result.status,
|
||||||
service_port=result.service_port,
|
service_port=result.service_port,
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
status="active"
|
status="active",
|
||||||
|
model_name=injected_model_name, # 保存用户选择的模型名称
|
||||||
)
|
)
|
||||||
db.add(agent)
|
db.add(agent)
|
||||||
|
|
||||||
@@ -2509,6 +2494,7 @@ async def deploy_platform_agent(
|
|||||||
cpu_used=quota.cpu_per_pod or "100m",
|
cpu_used=quota.cpu_per_pod or "100m",
|
||||||
memory_used=quota.memory_per_pod or "256Mi",
|
memory_used=quota.memory_per_pod or "256Mi",
|
||||||
replicas=1,
|
replicas=1,
|
||||||
|
model_name=injected_model_name, # 保存用户选择的模型名称
|
||||||
# ========== 保存访问信息 ==========
|
# ========== 保存访问信息 ==========
|
||||||
external_ip=access_info.get("external_ip"),
|
external_ip=access_info.get("external_ip"),
|
||||||
domain=access_info.get("domain"),
|
domain=access_info.get("domain"),
|
||||||
@@ -2768,6 +2754,7 @@ async def list_my_platform_agent_instances(
|
|||||||
"status": agent_status.status,
|
"status": agent_status.status,
|
||||||
"startTime": record.start_time.isoformat() if record.start_time else None,
|
"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,
|
"runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0,
|
||||||
|
"modelName": record.model_name, # 返回模型名称
|
||||||
})
|
})
|
||||||
except AgentManagerError:
|
except AgentManagerError:
|
||||||
# 实例可能已被删除
|
# 实例可能已被删除
|
||||||
@@ -2777,6 +2764,7 @@ async def list_my_platform_agent_instances(
|
|||||||
"status": "unknown",
|
"status": "unknown",
|
||||||
"startTime": record.start_time.isoformat() if record.start_time else None,
|
"startTime": record.start_time.isoformat() if record.start_time else None,
|
||||||
"runningSeconds": 0,
|
"runningSeconds": 0,
|
||||||
|
"modelName": record.model_name, # 返回模型名称
|
||||||
})
|
})
|
||||||
|
|
||||||
return SuccessResponse(data={"instances": instances})
|
return SuccessResponse(data={"instances": instances})
|
||||||
@@ -2912,11 +2900,11 @@ async def create_custom_agent(
|
|||||||
user_id = principal.get("user_id")
|
user_id = principal.get("user_id")
|
||||||
channel_id = principal.get("channel_id")
|
channel_id = principal.get("channel_id")
|
||||||
|
|
||||||
# 检查用户配额
|
# 检查用户配额(使用行锁防止并发问题)
|
||||||
quota_result = await db.execute(
|
quota_result = await db.execute(
|
||||||
select(TenantCustomAgentQuota).where(
|
select(TenantCustomAgentQuota)
|
||||||
TenantCustomAgentQuota.tenant_id == user_id
|
.where(TenantCustomAgentQuota.tenant_id == user_id)
|
||||||
)
|
.with_for_update() # ✅ 添加行锁,防止并发创建Agent导致超配
|
||||||
)
|
)
|
||||||
quota = quota_result.scalar_one_or_none()
|
quota = quota_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -3195,6 +3183,36 @@ async def create_custom_agent(
|
|||||||
logger.warning(f"自动注入 OPENAI_API_KEY 失败: {str(e)}")
|
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:
|
try:
|
||||||
client = get_agent_manager_client()
|
client = get_agent_manager_client()
|
||||||
|
|
||||||
@@ -3257,6 +3275,7 @@ async def create_custom_agent(
|
|||||||
cpu_used=req.cpuRequest,
|
cpu_used=req.cpuRequest,
|
||||||
memory_used=req.memoryRequest,
|
memory_used=req.memoryRequest,
|
||||||
tools_used=[],
|
tools_used=[],
|
||||||
|
model_name=model_name, # 保存用户选择的模型名称
|
||||||
# ========== 保存访问信息 ==========
|
# ========== 保存访问信息 ==========
|
||||||
external_ip=access_info.get("external_ip"),
|
external_ip=access_info.get("external_ip"),
|
||||||
domain=access_info.get("domain"),
|
domain=access_info.get("domain"),
|
||||||
@@ -3349,14 +3368,41 @@ async def delete_custom_agent(
|
|||||||
detail=f"未找到 Agent {name} 或该 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:
|
try:
|
||||||
# 先更新计费记录和释放配额(数据库操作)
|
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={
|
||||||
|
"error": "delete_agent_failed",
|
||||||
|
"message": f"删除失败: {e.message}",
|
||||||
|
"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()
|
billing_record.end_time = datetime.utcnow()
|
||||||
duration = (billing_record.end_time - billing_record.start_time).total_seconds()
|
duration = (billing_record.end_time - billing_record.start_time).total_seconds()
|
||||||
billing_record.duration_seconds = int(duration)
|
billing_record.duration_seconds = int(duration)
|
||||||
billing_record.eu_consumed = _calculate_eu(int(duration))
|
billing_record.eu_consumed = _calculate_eu(int(duration))
|
||||||
|
|
||||||
# 计算成本并扣款(自定义Agent按资源使用量计费)
|
# ✅ Step 3: 计算成本并扣款(自定义Agent按资源使用量计费)
|
||||||
cpu_cores = _parse_cpu_to_cores(billing_record.cpu_used) if billing_record.cpu_used else 0.1
|
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
|
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))
|
cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, int(duration))
|
||||||
@@ -3370,7 +3416,7 @@ async def delete_custom_agent(
|
|||||||
if not success:
|
if not success:
|
||||||
logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}")
|
logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}")
|
||||||
|
|
||||||
# 释放配额
|
# ✅ Step 4: 释放配额(使用行锁防止并发问题)
|
||||||
try:
|
try:
|
||||||
cpu_released = _parse_cpu(billing_record.cpu_used or "0")
|
cpu_released = _parse_cpu(billing_record.cpu_used or "0")
|
||||||
memory_released = _parse_memory(billing_record.memory_used or "0")
|
memory_released = _parse_memory(billing_record.memory_used or "0")
|
||||||
@@ -3380,9 +3426,9 @@ async def delete_custom_agent(
|
|||||||
memory_released = 0
|
memory_released = 0
|
||||||
|
|
||||||
quota_result = await db.execute(
|
quota_result = await db.execute(
|
||||||
select(TenantCustomAgentQuota).where(
|
select(TenantCustomAgentQuota)
|
||||||
TenantCustomAgentQuota.tenant_id == user_id
|
.where(TenantCustomAgentQuota.tenant_id == user_id)
|
||||||
)
|
.with_for_update() # ✅ 添加行锁,防止并发问题
|
||||||
)
|
)
|
||||||
quota = quota_result.scalar_one_or_none()
|
quota = quota_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -3391,18 +3437,9 @@ async def delete_custom_agent(
|
|||||||
quota.memory_used = max(0, float(quota.memory_used or 0) - memory_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.agent_count = max(0, (quota.agent_count or 0) - 1)
|
||||||
|
|
||||||
# 提交数据库更改
|
# ✅ Step 5: 提交数据库更改
|
||||||
await db.commit()
|
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(
|
return SuccessResponse(
|
||||||
message=f"自定义 Agent {name} 已删除",
|
message=f"自定义 Agent {name} 已删除",
|
||||||
data={
|
data={
|
||||||
@@ -3413,16 +3450,6 @@ async def delete_custom_agent(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except AgentManagerError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=e.status_code,
|
|
||||||
detail={
|
|
||||||
"error": "delete_agent_failed",
|
|
||||||
"message": f"删除失败: {e.message}",
|
|
||||||
"detail": e.detail
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/custom-agents/{name}/scale", response_model=SuccessResponse)
|
@router.put("/custom-agents/{name}/scale", response_model=SuccessResponse)
|
||||||
async def scale_custom_agent_api(
|
async def scale_custom_agent_api(
|
||||||
@@ -3459,11 +3486,11 @@ async def scale_custom_agent_api(
|
|||||||
detail=f"未找到 Agent {name} 或该 Agent 不属于您"
|
detail=f"未找到 Agent {name} 或该 Agent 不属于您"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取用户配额
|
# 获取用户配额(使用行锁防止并发问题)
|
||||||
quota_result = await db.execute(
|
quota_result = await db.execute(
|
||||||
select(TenantCustomAgentQuota).where(
|
select(TenantCustomAgentQuota)
|
||||||
TenantCustomAgentQuota.tenant_id == user_id
|
.where(TenantCustomAgentQuota.tenant_id == user_id)
|
||||||
)
|
.with_for_update() # ✅ 添加行锁,防止并发扩缩容导致配额计算错误
|
||||||
)
|
)
|
||||||
quota = quota_result.scalar_one_or_none()
|
quota = quota_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -3641,11 +3668,12 @@ async def list_my_custom_agents(
|
|||||||
agent_info = {
|
agent_info = {
|
||||||
"name": record.agent_name,
|
"name": record.agent_name,
|
||||||
"template": record.agent_type,
|
"template": record.agent_type,
|
||||||
"status": "unknown",
|
"status": "Pending", # 默认为 Pending 而不是 unknown
|
||||||
"cpu": record.cpu_used,
|
"cpu": record.cpu_used,
|
||||||
"memory": record.memory_used,
|
"memory": record.memory_used,
|
||||||
"startTime": record.start_time.isoformat() if record.start_time else None,
|
"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,
|
"runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0,
|
||||||
|
"modelName": record.model_name, # 返回模型名称
|
||||||
}
|
}
|
||||||
|
|
||||||
if agent_manager_available:
|
if agent_manager_available:
|
||||||
@@ -3653,9 +3681,10 @@ async def list_my_custom_agents(
|
|||||||
# 获取 Agent 状态
|
# 获取 Agent 状态
|
||||||
agent_status = await client.get_agent_status(record.agent_name)
|
agent_status = await client.get_agent_status(record.agent_name)
|
||||||
agent_info["status"] = agent_status.status
|
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 不存在
|
# Agent Manager 服务不可用或 Agent 不存在
|
||||||
pass
|
logger.warning(f"custom_agent_status_fetch_failed: agent_name={record.agent_name}, error={str(e)}")
|
||||||
|
|
||||||
agents.append(agent_info)
|
agents.append(agent_info)
|
||||||
|
|
||||||
@@ -4110,6 +4139,7 @@ async def get_user_agents_info(
|
|||||||
"cpu": record.cpu_used,
|
"cpu": record.cpu_used,
|
||||||
"memory": record.memory_used,
|
"memory": record.memory_used,
|
||||||
"replicas": record.replicas,
|
"replicas": record.replicas,
|
||||||
|
"modelName": record.model_name, # 返回模型名称
|
||||||
"startTime": record.start_time.isoformat() if record.start_time else None,
|
"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,
|
"runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <user_id>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -10,13 +10,6 @@ from pydantic_settings import BaseSettings
|
|||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
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):
|
class Settings(BaseSettings):
|
||||||
@@ -160,22 +153,14 @@ class ProductionSettings(Settings):
|
|||||||
log_level: str = "INFO"
|
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:
|
def get_settings() -> Settings:
|
||||||
"""根据环境变量获取相应的配置"""
|
"""根据环境变量获取相应的配置"""
|
||||||
environment = os.getenv("ENVIRONMENT", "development").lower()
|
environment = os.getenv("ENVIRONMENT", "development").lower()
|
||||||
|
|
||||||
if environment == "production":
|
if environment == "production":
|
||||||
return ProductionSettings()
|
return ProductionSettings()
|
||||||
elif environment == "testing":
|
|
||||||
return TestingSettings()
|
|
||||||
else:
|
else:
|
||||||
|
# 默认使用开发环境配置,不再支持测试环境配置
|
||||||
return DevelopmentSettings()
|
return DevelopmentSettings()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -48,40 +48,26 @@ def prepare_database_url(url: str) -> str:
|
|||||||
database_url = prepare_database_url(settings.database_url)
|
database_url = prepare_database_url(settings.database_url)
|
||||||
database_url_obj = make_url(database_url)
|
database_url_obj = make_url(database_url)
|
||||||
|
|
||||||
|
# PostgreSQL 连接池配置
|
||||||
engine_kwargs = {
|
engine_kwargs = {
|
||||||
"echo": settings.debug,
|
"echo": settings.debug,
|
||||||
"pool_pre_ping": True,
|
"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"):
|
# Azure Database for PostgreSQL 需要 TLS
|
||||||
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 \
|
if (database_url_obj.host and database_url_obj.host.endswith("postgres.database.azure.com")) or \
|
||||||
"sslmode" in settings.database_url:
|
"sslmode" in settings.database_url:
|
||||||
# Azure Database for PostgreSQL requires TLS; provide a default SSL context.
|
|
||||||
ssl_context = ssl.create_default_context()
|
ssl_context = ssl.create_default_context()
|
||||||
existing_connect_args = engine_kwargs.get("connect_args") or {}
|
engine_kwargs["connect_args"] = {"ssl": ssl_context}
|
||||||
existing_connect_args["ssl"] = ssl_context
|
|
||||||
engine_kwargs["connect_args"] = existing_connect_args
|
|
||||||
|
|
||||||
# 创建异步数据库引擎
|
# 创建异步数据库引擎
|
||||||
engine = create_async_engine(database_url, **engine_kwargs)
|
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(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
engine,
|
engine,
|
||||||
@@ -287,37 +273,22 @@ async def get_db_stats():
|
|||||||
|
|
||||||
|
|
||||||
async def cleanup_old_records():
|
async def cleanup_old_records():
|
||||||
"""清理旧记录"""
|
"""清理旧记录(仅支持 PostgreSQL)"""
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# 清理超过30天的执行记录
|
# 清理超过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("""
|
result = await session.execute(text("""
|
||||||
DELETE FROM executions
|
DELETE FROM executions
|
||||||
WHERE created_at < NOW() - INTERVAL '30 days'
|
WHERE created_at < NOW() - INTERVAL '30 days'
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
deleted_executions = result.rowcount
|
deleted_executions = result.rowcount
|
||||||
|
|
||||||
# 清理超过7天的会话记录
|
# 清理超过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("""
|
result = await session.execute(text("""
|
||||||
DELETE FROM sessions
|
DELETE FROM sessions
|
||||||
WHERE created_at < NOW() - INTERVAL '7 days'
|
WHERE created_at < NOW() - INTERVAL '7 days'
|
||||||
AND status != 'active'
|
AND status != 'active'
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
deleted_sessions = result.rowcount
|
deleted_sessions = result.rowcount
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -335,28 +306,17 @@ async def cleanup_old_records():
|
|||||||
|
|
||||||
|
|
||||||
async def backup_db():
|
async def backup_db():
|
||||||
"""数据库备份"""
|
"""数据库备份(仅支持 PostgreSQL)"""
|
||||||
try:
|
try:
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
|
|
||||||
# 生成备份文件名
|
# 生成备份文件名
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
backup_dir = "/app/backups"
|
backup_dir = "/app/backups"
|
||||||
os.makedirs(backup_dir, exist_ok=True)
|
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"
|
backup_file = f"{backup_dir}/taiji_db_backup_{timestamp}.sql"
|
||||||
# 执行pg_dump命令
|
# 执行pg_dump命令
|
||||||
cmd = [
|
cmd = [
|
||||||
|
|||||||
@@ -35,12 +35,17 @@ spec:
|
|||||||
- name: DEBUG
|
- name: DEBUG
|
||||||
value: "false"
|
value: "false"
|
||||||
|
|
||||||
# 数据库配置(使用Azure Database for PostgreSQL)
|
# 数据库配置(使用Azure Database for PostgreSQL - 测试库 taiji)
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: taiji-secrets
|
name: taiji-secrets
|
||||||
key: database-url
|
key: database-url
|
||||||
|
- name: ASYNC_DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: taiji-secrets
|
||||||
|
key: async-database-url
|
||||||
|
|
||||||
# Redis配置(使用Azure Cache for Redis)
|
# Redis配置(使用Azure Cache for Redis)
|
||||||
- name: REDIS_URL
|
- name: REDIS_URL
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ metadata:
|
|||||||
namespace: taiji-ai
|
namespace: taiji-ai
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
# 数据库连接字符串 - 使用 taiji 数据库
|
# 数据库连接字符串 - 测试环境使用 taiji 数据库
|
||||||
database-url: "postgresql+asyncpg://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require"
|
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连接字符串(Redis 已迁移到 taiji2026 实例)
|
||||||
redis-url: "rediss://:PzmWkM6CwfRrJTB1d2xLRxE9pzT7JKgvVAzCaEehmFE=@taiji2026.southeastasia.redis.azure.net:10000/0?ssl_cert_reqs=none"
|
redis-url: "rediss://:PzmWkM6CwfRrJTB1d2xLRxE9pzT7JKgvVAzCaEehmFE=@taiji2026.southeastasia.redis.azure.net:10000/0?ssl_cert_reqs=none"
|
||||||
|
|||||||
@@ -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 '每日成本限制(元)';
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 返回)';
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 '运行时长(秒)';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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 '是否启用该模板';
|
|
||||||
@@ -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);
|
|
||||||
@@ -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();
|
|
||||||
@@ -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消耗';
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 $$;
|
|
||||||
|
|
||||||
@@ -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 连接信息';
|
|
||||||
|
|
||||||
@@ -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 $$;
|
|
||||||
|
|
||||||
@@ -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';
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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())
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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())
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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)
|
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}")
|
logger.info(f"读取迁移脚本: {migration_file}")
|
||||||
|
|
||||||
with open(migration_file, 'r', encoding='utf-8') as f:
|
with open(migration_file, 'r', encoding='utf-8') as f:
|
||||||
@@ -134,7 +134,7 @@ async def main():
|
|||||||
"""主函数"""
|
"""主函数"""
|
||||||
print("="*80)
|
print("="*80)
|
||||||
print("执行配额字段默认值修复迁移")
|
print("执行配额字段默认值修复迁移")
|
||||||
print("迁移文件: 011_fix_quota_defaults.sql")
|
print("迁移文件: 020_fix_quota_defaults.sql")
|
||||||
print("="*80)
|
print("="*80)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -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())
|
|
||||||
@@ -81,11 +81,10 @@ class User(BaseModel, Base):
|
|||||||
# 订阅和计费
|
# 订阅和计费
|
||||||
subscription_tier = Column(String(20), default="free")
|
subscription_tier = Column(String(20), default="free")
|
||||||
discount = Column(sa.Numeric(5, 2), default=0)
|
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) # 授信额度
|
credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度
|
||||||
|
|
||||||
# EU计费(执行单元)
|
# 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消耗
|
total_eu_consumed = Column(sa.Numeric(15, 2), default=0) # 总EU消耗
|
||||||
|
|
||||||
# 资源限制
|
# 资源限制
|
||||||
@@ -166,6 +165,9 @@ class Agent(BaseModel, Base):
|
|||||||
endpoints = Column(JSON, default=dict) # 端点信息
|
endpoints = Column(JSON, default=dict) # 端点信息
|
||||||
pod_created_at = Column(DateTime) # Pod 创建时间
|
pod_created_at = Column(DateTime) # Pod 创建时间
|
||||||
|
|
||||||
|
# 模型配置
|
||||||
|
model_name = Column(String(100)) # Agent 使用的 LLM 模型名称,如 azure/gpt-4
|
||||||
|
|
||||||
# 自定义 Agent 特有字段
|
# 自定义 Agent 特有字段
|
||||||
image_url = Column(String(500)) # 用户上传的镜像地址
|
image_url = Column(String(500)) # 用户上传的镜像地址
|
||||||
program_path = Column(String(500)) # 程序路径
|
program_path = Column(String(500)) # 程序路径
|
||||||
@@ -487,7 +489,15 @@ class APIKey(BaseModel, Base):
|
|||||||
|
|
||||||
|
|
||||||
class Billing(BaseModel, Base):
|
class Billing(BaseModel, Base):
|
||||||
"""计费详情模型(按执行记录计费)"""
|
"""计费详情模型(按执行记录计费)
|
||||||
|
|
||||||
|
⚠️ 已废弃 (DEPRECATED)
|
||||||
|
此表已废弃,不再使用。实际计费数据存储在:
|
||||||
|
- AgentBillingRecord: Agent 运行时计费
|
||||||
|
- ModelBillingRecord: 模型调用计费(LiteLLM)
|
||||||
|
|
||||||
|
保留此模型仅用于历史数据兼容,新代码请勿使用。
|
||||||
|
"""
|
||||||
__tablename__ = "billing"
|
__tablename__ = "billing"
|
||||||
|
|
||||||
execution_id = Column(GUID(), ForeignKey("executions.id"), nullable=False)
|
execution_id = Column(GUID(), ForeignKey("executions.id"), nullable=False)
|
||||||
@@ -532,7 +542,15 @@ class Balance(BaseModel, Base):
|
|||||||
|
|
||||||
|
|
||||||
class BillingRecord(BaseModel, Base):
|
class BillingRecord(BaseModel, Base):
|
||||||
"""计费记录模型"""
|
"""计费记录模型
|
||||||
|
|
||||||
|
⚠️ 已废弃 (DEPRECATED)
|
||||||
|
此表已废弃,不再使用。实际计费数据存储在:
|
||||||
|
- AgentBillingRecord: Agent 运行时计费(表名: agent_billing_records)
|
||||||
|
- ModelBillingRecord: 模型调用计费(表名: model_billing_records)
|
||||||
|
|
||||||
|
保留此模型仅用于历史数据兼容,新代码请勿使用。
|
||||||
|
"""
|
||||||
__tablename__ = "billing_records"
|
__tablename__ = "billing_records"
|
||||||
|
|
||||||
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow)
|
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
@@ -1190,7 +1208,7 @@ class AgentBillingRecord(BaseModel, Base):
|
|||||||
period_end = Column(DateTime, nullable=True)
|
period_end = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# 运行时间(用于追踪 Agent 运行状态)
|
# 运行时间(用于追踪 Agent 运行状态)
|
||||||
start_time = Column(DateTime, nullable=True) # Agent 启动时间
|
start_time = Column(DateTime, nullable=False) # ✅ Agent 启动时间(必填,防止计费遗漏)
|
||||||
end_time = Column(DateTime, nullable=True) # Agent 停止时间(None 表示正在运行)
|
end_time = Column(DateTime, nullable=True) # Agent 停止时间(None 表示正在运行)
|
||||||
|
|
||||||
# 工具使用信息(新增字段)
|
# 工具使用信息(新增字段)
|
||||||
@@ -1206,6 +1224,10 @@ class AgentBillingRecord(BaseModel, Base):
|
|||||||
namespace = Column(String(100), nullable=True) # K8s 命名空间
|
namespace = Column(String(100), nullable=True) # K8s 命名空间
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
||||||
|
# ========== 模型配置 ==========
|
||||||
|
model_name = Column(String(100), nullable=True) # Agent 使用的 LLM 模型名称,如 azure/gpt-4
|
||||||
|
# ==============================
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
user = relationship("User")
|
user = relationship("User")
|
||||||
channel = relationship("Channel")
|
channel = relationship("Channel")
|
||||||
|
|||||||
@@ -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())
|
|
||||||
|
|
||||||
@@ -291,6 +291,9 @@ class AgentCard(BaseSchema):
|
|||||||
service_port: Optional[int] = None
|
service_port: Optional[int] = None
|
||||||
access_url: Optional[str] = None
|
access_url: Optional[str] = None
|
||||||
|
|
||||||
|
# 模型配置
|
||||||
|
model_name: Optional[str] = None # Agent 使用的 LLM 模型名称
|
||||||
|
|
||||||
# 资源配置
|
# 资源配置
|
||||||
cpu_request: Optional[str] = None
|
cpu_request: Optional[str] = None
|
||||||
cpu_limit: Optional[str] = None
|
cpu_limit: Optional[str] = None
|
||||||
@@ -367,6 +370,9 @@ class AgentMetricsResponse(BaseSchema):
|
|||||||
class TemplateInfo(BaseModel):
|
class TemplateInfo(BaseModel):
|
||||||
"""模板信息"""
|
"""模板信息"""
|
||||||
template: str
|
template: str
|
||||||
|
displayName: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
port: Optional[int] = None
|
port: Optional[int] = None
|
||||||
env_info: Dict[str, Any] = {}
|
env_info: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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())
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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())
|
|
||||||
@@ -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)
|
|
||||||
@@ -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="""
|
|
||||||
<html>
|
|
||||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
|
||||||
<h2 style="color: #333;">Taiji AI-PAD 邮件测试</h2>
|
|
||||||
<p>这是一封通过 <strong>阿里云 DirectMail API</strong> 发送的测试邮件。</p>
|
|
||||||
<p>如果您收到此邮件,说明 API 配置正确!</p>
|
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
|
||||||
<p style="color: #666; font-size: 12px;">
|
|
||||||
--- Taiji AI-PAD 团队
|
|
||||||
</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""",
|
|
||||||
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")
|
|
||||||
|
|
||||||
-140
@@ -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")
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user