更新计费

This commit is contained in:
zhanggangyong
2026-03-10 06:40:38 +00:00
parent 368198f53c
commit a540e6d61a
77 changed files with 5721 additions and 4788 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# Taiji AI-PAD 环境变量配置
# 数据库配置
ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres
DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require
ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji
DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require
# Redis配置 (Azure Cache for Redis - 端口10000)
# REDIS_URL已配置为Azure Redis (端口10000)
+279
View File
@@ -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 的新响应格式,并保持向后兼容性。✅
+115
View File
@@ -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
View File
@@ -4,36 +4,36 @@
| 环境 | 数据库名 | 配置文件 |
|------|----------|----------|
| **生产环境 (AKS)** | `taiji` | `k8s/secrets.yaml` |
| **测试环境 (docker-compose)** | `postgres` | `.env` 文件 |
| **生产环境 (AKS)** | `taiji_prod` | `k8s/secrets.yaml` |
| **测试环境 (docker-compose)** | `taiji` | `.env` 文件 |
## 生产环境配置 (AKS)
生产环境通过 `k8s/secrets.yaml` 配置,使用 `taiji` 数据库:
生产环境通过 `k8s/secrets.yaml` 配置,使用 `taiji_prod` 数据库:
```yaml
database-url: "postgresql://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require"
async-database-url: "postgresql+asyncpg://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji"
database-url: "postgresql://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji_prod?sslmode=require"
async-database-url: "postgresql+asyncpg://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji_prod"
```
## 测试环境配置 (docker-compose)
测试环境通过 `.env` 文件配置,使用 `postgres` 数据库:
测试环境通过 `.env` 文件配置,使用 `taiji` 数据库:
```bash
# .env 文件配置
DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require
ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres
DATABASE_URL=postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require
ASYNC_DATABASE_URL=postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji
```
## 配置要点
1. **生产环境部署到 AKS 时**:
- 确保 `k8s/secrets.yaml` 中数据库名为 `taiji`
- 确保 `k8s/secrets.yaml` 中数据库名为 `taiji_prod`
- 通过 `kubectl apply -f k8s/secrets.yaml` 部署
2. **本地测试环境**:
- 在 `.env` 文件中配置数据库名为 `postgres`
- 在 `.env` 文件中配置数据库名为 `taiji`
- 运行 `docker-compose up` 启动服务
## 数据库信息
+3 -3
View File
@@ -14,10 +14,10 @@ type: Opaque
stringData:
# ===========================================
# 数据库配置 (Azure Database for PostgreSQL)
# 生产环境使用 postgres 数据库
# 生产环境使用 taiji_prod 数据库
# ===========================================
database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres?sslmode=require"
async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/postgres"
database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji_prod?sslmode=require"
async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji_prod"
# ===========================================
# Redis配置 (Azure Cache for Redis with SSL)
+472
View File
@@ -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
+733
View File
@@ -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.
+149
View File
@@ -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 ""
+83
View File
@@ -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 ""
-392
View File
@@ -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())
-89
View File
@@ -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()
+58
View File
@@ -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 ""
+1 -10
View File
@@ -127,23 +127,14 @@ class ProductionSettings(Settings):
max_workers: int = 8
class TestingSettings(Settings):
"""测试环境配置"""
debug: bool = True
redis_url: str = "redis://localhost:6379/1" # 使用不同的数据库
cache_ttl: int = 60 # 1分钟
rapidapi_key: str = "test-key"
def get_settings() -> Settings:
"""根据环境变量获取相应的配置"""
environment = os.getenv("ENVIRONMENT", "development").lower()
if environment == "production":
return ProductionSettings()
elif environment == "testing":
return TestingSettings()
else:
# 默认使用开发环境配置,不再支持测试环境配置
return DevelopmentSettings()
+89 -23
View File
@@ -86,8 +86,19 @@ class TemplateInfo:
Template Information
Adapts to the Agent Manager GET /templates interface response
Agent Manager now returns:
- template: technical name (e.g., echo_agent)
- displayName: human-readable name (e.g., "Echo 测试服务")
- description: template description
- category: template category (e.g., testing, assistant)
- port: service port
- env_info: environment variable configuration
"""
template: str
display_name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
port: Optional[int] = None
env_info: Dict[str, Any] = field(default_factory=dict)
template_type: Optional[str] = None # platform or custom
@@ -688,6 +699,9 @@ class AgentManagerClient:
for t in data.get("templates", []):
templates.append(TemplateInfo(
template=t["template"],
display_name=t.get("displayName"),
description=t.get("description"),
category=t.get("category"),
port=t.get("port"),
env_info=t.get("env_info", {})
))
@@ -709,6 +723,9 @@ class AgentManagerClient:
for t in data.get("templates", []):
templates.append(TemplateInfo(
template=t["template"],
display_name=t.get("display_name"),
description=t.get("description"),
category=t.get("category"),
port=t.get("port"),
env_info=t.get("env_info", {}),
template_type="platform"
@@ -733,6 +750,9 @@ class AgentManagerClient:
for t in data.get("templates", []):
templates.append(TemplateInfo(
template=t["template"],
display_name=t.get("display_name"),
description=t.get("description"),
category=t.get("category"),
port=t.get("port"),
env_info=t.get("env_info", {}),
template_type="custom"
@@ -1082,7 +1102,21 @@ class AgentManagerClient:
- status.cpu_request/cpu_limit: CPU configuration
- status.memory_request/memory_limit: Memory configuration
"""
data = await self._request("GET", f"/agents/{agent_name}/status")
response = await self._request("GET", f"/agents/{agent_name}/status")
# Agent Manager 可能返回 {"success": true, "data": {...}} 或直接返回数据
if isinstance(response, dict) and "data" in response:
data = response["data"]
else:
data = response
# 记录返回的原始数据,便于调试
logger.debug(
"agent_status_response",
agent_name=agent_name,
response_keys=list(data.keys()) if isinstance(data, dict) else None,
status=data.get("status") if isinstance(data, dict) else None
)
# Parse container status list
containers = []
@@ -1118,10 +1152,15 @@ class AgentManagerClient:
access_url = access_info.get("recommended") or data.get("access_url")
# ======================================================
# 确保状态有有效值
agent_status = data.get("status", "Pending")
if not agent_status or agent_status.lower() == "unknown":
agent_status = "Pending"
return AgentStatusResult(
name=data["name"],
namespace=data["namespace"],
status=data["status"],
name=data.get("name", agent_name),
namespace=data.get("namespace", "ai-agents"),
status=agent_status,
health_status=data.get("health_status", "unknown"),
created_at=data.get("created_at"),
pod_ip=data.get("pod_ip"),
@@ -1302,10 +1341,13 @@ class AgentManagerClient:
payload["headers"] = headers
if auth:
payload["auth"] = auth
if request_params:
payload["request_params"] = request_params
if request_body:
payload["request_body"] = request_body
# Agent Manager 期望的是 input_schema,而不是 request_params/request_body
# input_schema 统一使用 JSON Schema 格式定义工具的输入参数
input_schema = request_params or request_body
if input_schema:
payload["input_schema"] = input_schema
if response_mapping:
payload["response_mapping"] = response_mapping
if timeout:
@@ -1318,7 +1360,8 @@ class AgentManagerClient:
name=name,
url=url,
method=method,
user_id=user_id
user_id=user_id,
has_input_schema=input_schema is not None
)
return await self._request("POST", "/external-tools/generate", json=payload)
@@ -1373,10 +1416,12 @@ class AgentManagerClient:
payload["headers"] = headers
if auth:
payload["auth"] = auth
if request_params:
payload["request_params"] = request_params
if request_body:
payload["request_body"] = request_body
# Agent Manager 期望的是 input_schema,而不是 request_params/request_body
input_schema = request_params or request_body
if input_schema:
payload["input_schema"] = input_schema
if response_mapping:
payload["response_mapping"] = response_mapping
if timeout:
@@ -1388,7 +1433,8 @@ class AgentManagerClient:
"updating_external_tool",
tool_ref_id=tool_ref_id,
name=name,
user_id=user_id
user_id=user_id,
has_input_schema=input_schema is not None
)
return await self._request("PUT", f"/external-tools/{tool_ref_id}", json=payload)
@@ -1493,12 +1539,12 @@ class AgentManagerClient:
config=config.to_dict() if config else None
)
data = await self._request("POST", "/external-tools/agents/create-with-tools", json=payload)
response = await self._request("POST", "/external-tools/agents/create-with-tools", json=payload)
# 检查业务逻辑是否成功(agent-manager 可能返回 HTTP 200 但 success=false)
if isinstance(data, dict) and data.get("success") is False:
error_code = data.get("error", "unknown_error")
error_message = data.get("message", "创建 Agent 失败")
if isinstance(response, dict) and response.get("success") is False:
error_code = response.get("error", "unknown_error")
error_message = response.get("message", "创建 Agent 失败")
logger.error(
"create_agent_with_tools_failed",
name=name,
@@ -1511,12 +1557,32 @@ class AgentManagerClient:
detail={"error": error_code, "message": error_message}
)
# Agent Manager 返回格式: {"success": true, "data": {...}} 或直接返回数据
# 需要兼容两种格式
if isinstance(response, dict) and "data" in response:
data = response["data"]
else:
data = response
# 确保状态字段有默认值,避免 unknown
agent_status = data.get("status", "Pending")
if not agent_status or agent_status.lower() == "unknown":
# 如果 Agent 已经创建成功,但状态未知,默认设为 Pending
agent_status = "Pending"
logger.info(
"agent_with_tools_created",
name=name,
status=agent_status,
response_keys=list(data.keys()) if isinstance(data, dict) else None
)
return AgentCreateResult(
name=data["name"],
namespace=data["namespace"],
status=data["status"],
created_at=data["created_at"],
template=data["template"],
name=data.get("name", name),
namespace=data.get("namespace", "ai-agents"),
status=agent_status,
created_at=data.get("created_at", ""),
template=data.get("template", template),
service_port=data.get("service_port"),
access_info=data.get("access_info"),
pod_id=data.get("pod_id"),
+48 -18
View File
@@ -36,7 +36,7 @@ from typing import Optional, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models import User, Channel, BillingRecord, Agent, Balance
from models import User, Channel, BillingRecord, Agent, Balance, AgentBillingRecord
# ============= EU定价配置 =============
@@ -287,7 +287,7 @@ async def add_balance(
return True, f"成功充值 {amount} 元,当前余额: {new_balance}"
# ============= 计费记录创建 =============
# ============= 计费记录创建(旧版,已废弃) =============
async def create_billing_record(
tenant_id: str,
@@ -300,6 +300,10 @@ async def create_billing_record(
"""
创建计费记录
⚠️ 已废弃 (DEPRECATED)
此函数使用旧的 BillingRecord 表,已废弃。
请使用 create_agent_billing_record() 函数替代。
Args:
tenant_id: 租户ID
agent_id: Agent ID
@@ -317,6 +321,12 @@ async def create_billing_record(
- 专业级(Pro):$0.02 / EU
- 企业级(Enterprise):$0.03 / EU
"""
import warnings
warnings.warn(
"create_billing_record() 已废弃,请使用 create_agent_billing_record() 替代",
DeprecationWarning,
stacklevel=2
)
# 获取用户信息(包括订阅等级和channel_id)
result = await db.execute(
select(User.channel_id, User.subscription_tier).where(User.id == tenant_id)
@@ -475,7 +485,7 @@ def get_platform_agent_resources() -> dict:
async def calculate_monthly_cost(user_id: str, db: AsyncSession) -> Decimal:
"""
计算用户本月消费
计算用户本月消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
Args:
user_id: 用户ID
@@ -485,19 +495,30 @@ async def calculate_monthly_cost(user_id: str, db: AsyncSession) -> Decimal:
本月消费金额
"""
from sqlalchemy import func
from models import ModelBillingRecord
# 获取本月第一天
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.sum(BillingRecord.cost))
.where(BillingRecord.tenant_id == user_id)
.where(BillingRecord.timestamp >= month_start)
# Agent 计费
agent_result = await db.execute(
select(func.sum(AgentBillingRecord.cost))
.where(AgentBillingRecord.user_id == user_id)
.where(AgentBillingRecord.start_time >= month_start)
)
agent_cost = agent_result.scalar() or 0
total = result.scalar()
return Decimal(str(total)) if total else Decimal(0)
# 模型调用计费
model_result = await db.execute(
select(func.sum(ModelBillingRecord.total_cost))
.where(ModelBillingRecord.tenant_id == user_id)
.where(ModelBillingRecord.created_at >= month_start)
)
model_cost = model_result.scalar() or 0
total = Decimal(str(agent_cost)) + Decimal(str(model_cost))
return total
async def calculate_channel_commission(
@@ -507,7 +528,7 @@ async def calculate_channel_commission(
db: AsyncSession
) -> Tuple[Decimal, Decimal]:
"""
计算渠道佣金
计算渠道佣金(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
Args:
channel_id: 渠道ID
@@ -519,6 +540,7 @@ async def calculate_channel_commission(
(总收入, 佣金金额)
"""
from sqlalchemy import func
from models import ModelBillingRecord
# 查询渠道佣金率
result = await db.execute(
@@ -527,17 +549,25 @@ async def calculate_channel_commission(
row = result.first()
commission_rate = Decimal(str(row[0])) / 100 if row else Decimal(0)
# 查询总收入
result = await db.execute(
select(func.sum(BillingRecord.cost))
.where(BillingRecord.channel_id == channel_id)
.where(BillingRecord.timestamp >= start_date)
.where(BillingRecord.timestamp <= end_date)
# Agent 计费收入
agent_result = await db.execute(
select(func.sum(AgentBillingRecord.cost))
.where(AgentBillingRecord.channel_id == channel_id)
.where(AgentBillingRecord.start_time >= start_date)
.where(AgentBillingRecord.start_time <= end_date)
)
agent_revenue = agent_result.scalar() or 0
total_revenue = result.scalar()
total_revenue = Decimal(str(total_revenue)) if total_revenue else Decimal(0)
# 模型调用计费收入
model_result = await db.execute(
select(func.sum(ModelBillingRecord.total_cost))
.where(ModelBillingRecord.channel_id == channel_id)
.where(ModelBillingRecord.created_at >= start_date)
.where(ModelBillingRecord.created_at <= end_date)
)
model_revenue = model_result.scalar() or 0
total_revenue = Decimal(str(agent_revenue)) + Decimal(str(model_revenue))
commission = total_revenue * commission_rate
return total_revenue, commission
+113 -1
View File
@@ -33,7 +33,7 @@ from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from database import AsyncSessionLocal
from models import AgentBillingRecord, User, Balance
from models import AgentBillingRecord, User, Balance, TenantCustomAgentQuota, Agent
from app.billing import (
calculate_platform_agent_cost,
calculate_agent_cost_by_resources,
@@ -47,8 +47,12 @@ logger = logging.getLogger(__name__)
# 计费周期配置
BILLING_INTERVAL_SECONDS = 3600 # 每小时执行一次
QUOTA_CHECK_INTERVAL_HOURS = 24 # 配额一致性检查周期:每24小时一次
VM_EU_PER_HOUR = Decimal("0.5") # VM计算 0.5 EU/hour
# 上次配额检查时间
_last_quota_check: Optional[datetime] = None
def _calculate_eu(duration_seconds: int) -> int:
"""计算EU:1 EU = 10秒,不足10秒按1 EU计算"""
@@ -107,6 +111,94 @@ async def stop_user_agents(user_id: str, db: AsyncSession) -> list:
return stopped_agents
async def check_quota_consistency(db: AsyncSession) -> dict:
"""
检查配额一致性(假用量检测)
对比配额表中的使用量与实际运行的Agent,识别假用量问题。
如果发现不一致,记录警告日志,运维人员可以运行 fix_fake_quota.py 修复。
Returns:
检查结果统计
"""
logger.info("🔍 开始配额一致性检查(假用量检测)")
try:
# 查询所有配额记录
result = await db.execute(select(TenantCustomAgentQuota))
quotas = result.scalars().all()
inconsistencies = []
total_fake_cpu = 0.0
total_fake_memory = 0.0
for quota in quotas:
tenant_id = str(quota.tenant_id)
# 查询配额显示的使用量
quota_cpu = float(quota.cpu_used or 0)
quota_memory = float(quota.memory_used or 0)
quota_count = quota.agent_count or 0
# 查询实际运行的Agent
agents_result = await db.execute(
select(Agent)
.where(Agent.owner_id == tenant_id)
.where(Agent.type == 'custom')
.where(Agent.status == 'active')
)
agents = agents_result.scalars().all()
# 计算实际使用量
real_cpu = sum(float(a.cpu or 0) for a in agents)
real_memory = sum(float(a.memory or 0) for a in agents)
real_count = len(agents)
# 计算差异(允许小误差 0.01)
cpu_diff = quota_cpu - real_cpu
memory_diff = quota_memory - real_memory
count_diff = quota_count - real_count
if abs(cpu_diff) > 0.01 or abs(memory_diff) > 0.01 or count_diff != 0:
inconsistencies.append({
'tenant_id': tenant_id,
'quota': {'cpu': quota_cpu, 'memory': quota_memory, 'count': quota_count},
'real': {'cpu': real_cpu, 'memory': real_memory, 'count': real_count},
'diff': {'cpu': cpu_diff, 'memory': memory_diff, 'count': count_diff}
})
total_fake_cpu += cpu_diff
total_fake_memory += memory_diff
logger.warning(
f"⚠️ 配额不一致: 租户={tenant_id}, "
f"配额显示=[CPU:{quota_cpu:.2f}核, 内存:{quota_memory:.2f}GB, Agent:{quota_count}个], "
f"实际运行=[CPU:{real_cpu:.2f}核, 内存:{real_memory:.2f}GB, Agent:{real_count}个], "
f"假用量=[CPU:{cpu_diff:.2f}核, 内存:{memory_diff:.2f}GB, Agent:{count_diff}个]"
)
if inconsistencies:
logger.error(
f"❌ 发现 {len(inconsistencies)} 个租户存在配额不一致(假用量)!\n"
f" 假用量汇总: CPU={total_fake_cpu:.2f}核, 内存={total_fake_memory:.2f}GB\n"
f" 🔧 请运行修复脚本: python fix_fake_quota.py"
)
else:
logger.info("✅ 配额一致性检查通过,无假用量问题")
return {
'total_tenants': len(quotas),
'inconsistent_tenants': len(inconsistencies),
'total_fake_cpu': total_fake_cpu,
'total_fake_memory': total_fake_memory,
'details': inconsistencies
}
except Exception as e:
logger.error(f"配额一致性检查失败: {e}")
return {'error': str(e)}
async def update_running_agent_billing(db: AsyncSession) -> dict:
"""
更新所有运行中 Agent 的计费记录
@@ -223,12 +315,16 @@ async def periodic_billing_task():
周期性计费任务(后台运行)
每小时执行一次,更新所有运行中 Agent 的计费记录
每24小时执行一次配额一致性检查(假用量检测)
"""
global _last_quota_check
logger.info("周期性计费任务启动")
while True:
try:
async with AsyncSessionLocal() as db:
# 1. 执行计费更新(每小时)
stats = await update_running_agent_billing(db)
logger.info(
@@ -245,6 +341,22 @@ async def periodic_billing_task():
if stats["errors"]:
logger.warning(f"计费错误: {stats['errors']}")
# 2. 配额一致性检查(每24小时一次)
now = datetime.utcnow()
should_check_quota = (
_last_quota_check is None or
(now - _last_quota_check).total_seconds() >= QUOTA_CHECK_INTERVAL_HOURS * 3600
)
if should_check_quota:
logger.info(f"⏰ 触发配额一致性检查(距上次检查: {(now - _last_quota_check).total_seconds() / 3600:.1f}小时)" if _last_quota_check else "⏰ 首次执行配额一致性检查")
quota_stats = await check_quota_consistency(db)
_last_quota_check = now
# 如果发现假用量,记录到统计中
if quota_stats.get('inconsistent_tenants', 0) > 0:
stats['quota_check'] = quota_stats
except Exception as e:
logger.error(f"周期性计费任务异常: {e}")
+119 -35
View File
@@ -1,6 +1,11 @@
"""
预付费配额管理模块
配额预警与限制
注意:计费数据已迁移到新表:
- AgentBillingRecord: Agent 运行时计费
- ModelBillingRecord: 模型调用计费(LiteLLM)
旧的 BillingRecord 表已废弃,不再使用。
"""
from datetime import datetime, timedelta
@@ -9,7 +14,7 @@ from typing import Dict, List, Optional, Tuple
from sqlalchemy import select, func, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from models import User, Channel, QuotaAlert, BillingRecord, ResourceAllocation, Balance
from models import User, Channel, QuotaAlert, ResourceAllocation, Balance, AgentBillingRecord, ModelBillingRecord
# 配额预警阈值配置
@@ -52,19 +57,35 @@ async def check_user_balance_quota(
credit_limit = Decimal(str(user.credit_limit))
available = balance + credit_limit
# 获取用户平均日消费
# 获取用户平均日消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
cost_result = await db.execute(
select(func.sum(BillingRecord.cost))
# Agent 计费
agent_cost_result = await db.execute(
select(func.sum(AgentBillingRecord.cost))
.where(
and_(
BillingRecord.tenant_id == user_id,
BillingRecord.timestamp >= thirty_days_ago,
AgentBillingRecord.user_id == user_id,
AgentBillingRecord.start_time >= thirty_days_ago,
)
)
)
total_cost = cost_result.scalar() or 0
daily_avg = Decimal(str(total_cost)) / 30
agent_cost = agent_cost_result.scalar() or 0
# 模型调用计费
model_cost_result = await db.execute(
select(func.sum(ModelBillingRecord.total_cost))
.where(
and_(
ModelBillingRecord.tenant_id == user_id,
ModelBillingRecord.created_at >= thirty_days_ago,
)
)
)
model_cost = model_cost_result.scalar() or 0
total_cost = Decimal(str(agent_cost)) + Decimal(str(model_cost))
daily_avg = total_cost / 30
# 预估可用天数
if daily_avg > 0:
@@ -115,18 +136,34 @@ async def check_channel_quota(
channel_credit = Decimal(str(channel.channel_credit))
# 获取渠道下所有租户的总消费
# 获取渠道下所有租户的总消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
cost_result = await db.execute(
select(func.sum(BillingRecord.cost))
# Agent 计费
agent_cost_result = await db.execute(
select(func.sum(AgentBillingRecord.cost))
.where(
and_(
BillingRecord.channel_id == channel_id,
BillingRecord.timestamp >= thirty_days_ago,
AgentBillingRecord.channel_id == channel_id,
AgentBillingRecord.start_time >= thirty_days_ago,
)
)
)
total_cost = cost_result.scalar() or 0
agent_cost = agent_cost_result.scalar() or 0
# 模型调用计费
model_cost_result = await db.execute(
select(func.sum(ModelBillingRecord.total_cost))
.where(
and_(
ModelBillingRecord.channel_id == channel_id,
ModelBillingRecord.created_at >= thirty_days_ago,
)
)
)
model_cost = model_cost_result.scalar() or 0
total_cost = float(agent_cost) + float(model_cost)
details = {
"channelCredit": float(channel_credit),
@@ -323,19 +360,34 @@ async def check_rate_limit(
Returns:
(是否允许, 当前使用量, 限制量)
"""
# 获取最近1分钟的调用次数
# 获取最近1分钟的调用次数(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
one_minute_ago = datetime.utcnow() - timedelta(minutes=1)
result = await db.execute(
select(func.count(BillingRecord.id))
# Agent 调用次数
agent_result = await db.execute(
select(func.count(AgentBillingRecord.id))
.where(
and_(
BillingRecord.tenant_id == user_id,
BillingRecord.timestamp >= one_minute_ago,
AgentBillingRecord.user_id == user_id,
AgentBillingRecord.start_time >= one_minute_ago,
)
)
)
current_rpm = result.scalar() or 0
agent_rpm = agent_result.scalar() or 0
# 模型调用次数
model_result = await db.execute(
select(func.count(ModelBillingRecord.id))
.where(
and_(
ModelBillingRecord.tenant_id == user_id,
ModelBillingRecord.created_at >= one_minute_ago,
)
)
)
model_rpm = model_result.scalar() or 0
current_rpm = agent_rpm + model_rpm
# 获取用户的RPM限制(从资源分配表)
limit_result = await db.execute(
@@ -408,7 +460,7 @@ async def get_user_quota_summary(
"alerts": [...]
}
"""
from models import User, QuotaAlert, BillingRecord, ResourceUsage
from models import User, QuotaAlert, ResourceUsage
# 1. 获取用户信息
result = await db.execute(
@@ -428,19 +480,35 @@ async def get_user_quota_summary(
credit_limit = Decimal(str(user.credit_limit or 0))
available = balance + credit_limit
# 计算近30天平均日消费
# 计算近30天平均日消费(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
cost_result = await db.execute(
select(func.sum(BillingRecord.cost))
# Agent 计费
agent_cost_result = await db.execute(
select(func.sum(AgentBillingRecord.cost))
.where(
and_(
BillingRecord.tenant_id == user_id,
BillingRecord.timestamp >= thirty_days_ago,
AgentBillingRecord.user_id == user_id,
AgentBillingRecord.start_time >= thirty_days_ago,
)
)
)
total_cost = cost_result.scalar() or 0
daily_avg = Decimal(str(total_cost)) / 30 if total_cost > 0 else Decimal("0")
agent_cost = agent_cost_result.scalar() or 0
# 模型调用计费
model_cost_result = await db.execute(
select(func.sum(ModelBillingRecord.total_cost))
.where(
and_(
ModelBillingRecord.tenant_id == user_id,
ModelBillingRecord.created_at >= thirty_days_ago,
)
)
)
model_cost = model_cost_result.scalar() or 0
total_cost = Decimal(str(agent_cost)) + Decimal(str(model_cost))
daily_avg = total_cost / 30 if total_cost > 0 else Decimal("0")
# 预估可用天数
if daily_avg > 0:
@@ -536,7 +604,7 @@ async def get_channel_quota_summary(
"usagePercent": float
}
"""
from models import Channel, User, QuotaAlert, BillingRecord
from models import Channel, User, QuotaAlert
# 1. 获取渠道信息
result = await db.execute(
@@ -547,21 +615,37 @@ async def get_channel_quota_summary(
if not channel:
raise ValueError(f"渠道不存在: {channel_id}")
# 2. 计算本月使用量
# 2. 计算本月使用量(从 AgentBillingRecord 和 ModelBillingRecord 合并统计)
now = datetime.utcnow()
month_start = datetime(now.year, now.month, 1)
usage_result = await db.execute(
select(func.sum(BillingRecord.cost))
.join(User, BillingRecord.tenant_id == User.id)
# Agent 计费
agent_usage_result = await db.execute(
select(func.sum(AgentBillingRecord.cost))
.join(User, AgentBillingRecord.user_id == User.id)
.where(
and_(
User.channel_id == channel_id,
BillingRecord.timestamp >= month_start
AgentBillingRecord.start_time >= month_start
)
)
)
monthly_usage = Decimal(str(usage_result.scalar() or 0))
agent_usage = agent_usage_result.scalar() or 0
# 模型调用计费
model_usage_result = await db.execute(
select(func.sum(ModelBillingRecord.total_cost))
.join(User, ModelBillingRecord.tenant_id == User.id)
.where(
and_(
User.channel_id == channel_id,
ModelBillingRecord.created_at >= month_start
)
)
)
model_usage = model_usage_result.scalar() or 0
monthly_usage = Decimal(str(agent_usage)) + Decimal(str(model_usage))
channel_credit = Decimal(str(channel.channel_credit or 0)) # 修复:使用正确的字段名 channel_credit
usage_percent = float(monthly_usage / channel_credit * 100) if channel_credit > 0 else 0
+22 -13
View File
@@ -1,6 +1,11 @@
"""
资源管控模块
提供请求级别的资源管控、速率限制和配额检查
注意:计费数据已迁移到新表:
- AgentBillingRecord: Agent 运行时计费
- ModelBillingRecord: 模型调用计费(LiteLLM)
旧的 BillingRecord 表已废弃,不再使用。
"""
from datetime import datetime, timedelta
@@ -11,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException, status
import structlog
from models import User, Channel, ResourceUsage, BillingRecord
from models import User, Channel, ResourceUsage, AgentBillingRecord
from app.quota_manager import check_user_balance_quota, check_channel_quota
from app.db_utils import with_retry
@@ -276,19 +281,23 @@ class ResourceController:
)
db.add(usage)
# 记录到BillingRecord表
billing_record = BillingRecord(
tenant_id=user_id,
resource_type=resource_type,
resource_id=resource_id,
# 记录到 AgentBillingRecord 表(新计费表)
# 注意:这里只记录 Agent 类型的资源消耗
# 模型调用计费由 LiteLLM Callback 处理,存入 ModelBillingRecord
if resource_type == "agent":
billing_record = AgentBillingRecord(
user_id=user_id,
agent_name=resource_id or "unknown",
agent_type="custom", # 默认为自定义 Agent
is_platform_agent=False,
duration_seconds=int(execution_time_ms / 1000),
cpu_seconds=cpu_usage * (execution_time_ms / 1000.0),
memory_gb_seconds=memory_usage / 1024 * (execution_time_ms / 1000.0),
request_count=1,
cost=cost,
timestamp=now,
details={
"execution_time_ms": execution_time_ms,
"cpu_usage": cpu_usage,
"memory_usage": memory_usage,
"network_io": network_io
}
eu_consumed=int(cost), # 简化:1 EU = 1 cost
start_time=now,
period_start=now,
)
db.add(billing_record)
+51 -21
View File
@@ -1,6 +1,11 @@
"""
资源使用监控模块
采集和统计用户资源使用情况
注意:计费数据已迁移到新表:
- AgentBillingRecord: Agent 运行时计费
- ModelBillingRecord: 模型调用计费(LiteLLM)
旧的 BillingRecord 表已废弃,不再使用。
"""
from datetime import datetime, timedelta
@@ -9,7 +14,7 @@ from typing import Dict, List, Optional, Tuple
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from models import ResourceUsage, User, Agent, BillingRecord, Execution
from models import ResourceUsage, User, Agent, Execution, AgentBillingRecord, ModelBillingRecord
async def record_resource_usage(
@@ -249,6 +254,10 @@ async def get_platform_resource_overview(db: AsyncSession) -> Dict:
"""
获取平台资源概览(管理员视图)
数据来源:
- AgentBillingRecord: Agent 运行时计费
- ModelBillingRecord: 模型调用计费(LiteLLM)
Args:
db: 数据库会话
@@ -259,23 +268,39 @@ async def get_platform_resource_overview(db: AsyncSession) -> Dict:
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# 今日调用次数
today_calls = await db.execute(
select(func.count(BillingRecord.id))
.where(BillingRecord.timestamp >= today_start)
# 今日调用次数(Agent + Model)
today_agent_calls = await db.execute(
select(func.count(AgentBillingRecord.id))
.where(AgentBillingRecord.start_time >= today_start)
)
today_model_calls = await db.execute(
select(func.count(ModelBillingRecord.id))
.where(ModelBillingRecord.created_at >= today_start)
)
today_calls_total = (today_agent_calls.scalar() or 0) + (today_model_calls.scalar() or 0)
# 本月调用次数
month_calls = await db.execute(
select(func.count(BillingRecord.id))
.where(BillingRecord.timestamp >= month_start)
# 本月调用次数(Agent + Model)
month_agent_calls = await db.execute(
select(func.count(AgentBillingRecord.id))
.where(AgentBillingRecord.start_time >= month_start)
)
month_model_calls = await db.execute(
select(func.count(ModelBillingRecord.id))
.where(ModelBillingRecord.created_at >= month_start)
)
month_calls_total = (month_agent_calls.scalar() or 0) + (month_model_calls.scalar() or 0)
# 活跃用户数
active_users = await db.execute(
select(func.count(func.distinct(BillingRecord.tenant_id)))
.where(BillingRecord.timestamp >= today_start)
# 活跃用户数(今日有 Agent 或 Model 调用的用户)
active_agent_users = await db.execute(
select(func.count(func.distinct(AgentBillingRecord.user_id)))
.where(AgentBillingRecord.start_time >= today_start)
)
active_model_users = await db.execute(
select(func.count(func.distinct(ModelBillingRecord.tenant_id)))
.where(ModelBillingRecord.created_at >= today_start)
)
# 注意:这里简单相加可能有重复,但作为概览统计可以接受
active_users_total = max(active_agent_users.scalar() or 0, active_model_users.scalar() or 0)
# 活跃Agent数
active_agents = await db.execute(
@@ -283,18 +308,23 @@ async def get_platform_resource_overview(db: AsyncSession) -> Dict:
.where(Agent.status == "active")
)
# 总EU消耗
total_eu = await db.execute(
select(func.sum(BillingRecord.eu))
.where(BillingRecord.timestamp >= month_start)
# 总EU消耗(Agent + Model)
agent_eu = await db.execute(
select(func.sum(AgentBillingRecord.eu_consumed))
.where(AgentBillingRecord.start_time >= month_start)
)
model_eu = await db.execute(
select(func.sum(ModelBillingRecord.eu_consumed))
.where(ModelBillingRecord.created_at >= month_start)
)
total_eu = (agent_eu.scalar() or 0) + (model_eu.scalar() or 0)
return {
"todayCalls": today_calls.scalar() or 0,
"monthCalls": month_calls.scalar() or 0,
"activeUsersToday": active_users.scalar() or 0,
"todayCalls": today_calls_total,
"monthCalls": month_calls_total,
"activeUsersToday": active_users_total,
"activeAgents": active_agents.scalar() or 0,
"monthTotalEu": int(total_eu.scalar() or 0),
"monthTotalEu": int(total_eu),
"timestamp": now.isoformat(),
}
+300 -165
View File
@@ -2853,6 +2853,11 @@ async def get_billing_overview(
):
"""
获取三维度计费统计(所有管理员可查看)
从 AgentBillingRecord 和 ModelBillingRecord 表查询计费数据,
返回渠道维度、租户维度和调用记录三个维度的统计。
EU 计算规则:1 EU = 10 秒运行时间(向上取整)
"""
_verify_read_permission(principal)
@@ -2866,99 +2871,227 @@ async def get_billing_overview(
if end_dt.tzinfo is not None:
end_dt = end_dt.replace(tzinfo=None)
# 渠道统计
channel_stats_result = await db.execute(
# ========== 渠道统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ==========
# 1. 从 AgentBillingRecord 统计 Agent 使用
agent_channel_stats = await db.execute(
select(
Channel.id,
Channel.name,
func.count(BillingRecord.id).label("calls"),
func.sum(BillingRecord.eu).label("total_eu"),
func.sum(BillingRecord.cost).label("total_cost"),
func.count(AgentBillingRecord.id).label("calls"),
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
func.sum(AgentBillingRecord.cost).label("total_cost"),
)
.select_from(BillingRecord)
.join(Channel, BillingRecord.channel_id == Channel.id)
.select_from(AgentBillingRecord)
.join(Channel, AgentBillingRecord.channel_id == Channel.id)
.where(
and_(
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
AgentBillingRecord.start_time >= start_dt,
AgentBillingRecord.start_time <= end_dt,
)
)
.group_by(Channel.id, Channel.name)
)
agent_channel_data = {str(row.id): {"name": row.name, "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in agent_channel_stats.all()}
channel_stats = [
{
"channelId": str(row.id),
"channelName": row.name,
"calls": row.calls,
"totalEU": float(row.total_eu or 0),
"totalCost": float(row.total_cost or 0),
}
for row in channel_stats_result.all()
]
# 2. 从 ModelBillingRecord 统计模型调用
model_channel_stats = await db.execute(
select(
Channel.id,
Channel.name,
func.count(ModelBillingRecord.id).label("calls"),
func.sum(ModelBillingRecord.eu_consumed).label("total_eu"),
func.sum(ModelBillingRecord.total_cost).label("total_cost"),
)
.select_from(ModelBillingRecord)
.join(Channel, ModelBillingRecord.channel_id == Channel.id)
.where(
and_(
ModelBillingRecord.start_time >= start_dt,
ModelBillingRecord.start_time <= end_dt,
)
)
.group_by(Channel.id, Channel.name)
)
model_channel_data = {str(row.id): {"name": row.name, "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in model_channel_stats.all()}
# 租户统计
tenant_stats_result = await db.execute(
# 3. 合并渠道统计
all_channel_ids = set(agent_channel_data.keys()) | set(model_channel_data.keys())
channel_stats = []
for channel_id in all_channel_ids:
agent_data = agent_channel_data.get(channel_id, {"name": "", "calls": 0, "eu": 0, "cost": 0})
model_data = model_channel_data.get(channel_id, {"name": "", "calls": 0, "eu": 0, "cost": 0})
channel_name = agent_data["name"] or model_data["name"]
total_calls = agent_data["calls"] + model_data["calls"]
total_eu = agent_data["eu"] + model_data["eu"]
total_cost = agent_data["cost"] + model_data["cost"]
# 应用筛选条件
if channelName and channelName.lower() not in channel_name.lower():
continue
if minCalls is not None and total_calls < minCalls:
continue
if maxCalls is not None and total_calls > maxCalls:
continue
channel_stats.append({
"channelId": channel_id,
"channelName": channel_name,
"calls": total_calls,
"totalEU": round(total_eu, 2),
"totalCost": round(total_cost, 4),
})
# ========== 租户统计(从 AgentBillingRecord 和 ModelBillingRecord 聚合) ==========
# 1. 从 AgentBillingRecord 统计
agent_tenant_stats = await db.execute(
select(
User.id,
User.name,
Channel.name.label("channel_name"),
func.count(BillingRecord.id).label("calls"),
func.sum(BillingRecord.eu).label("total_eu"),
func.sum(BillingRecord.cost).label("total_cost"),
func.count(AgentBillingRecord.id).label("calls"),
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
func.sum(AgentBillingRecord.cost).label("total_cost"),
)
.select_from(BillingRecord)
.join(User, BillingRecord.tenant_id == User.id)
.join(Channel, User.channel_id == Channel.id)
.select_from(AgentBillingRecord)
.join(User, AgentBillingRecord.user_id == User.id)
.outerjoin(Channel, User.channel_id == Channel.id)
.where(
and_(
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
AgentBillingRecord.start_time >= start_dt,
AgentBillingRecord.start_time <= end_dt,
)
)
.group_by(User.id, User.name, Channel.name)
)
agent_tenant_data = {str(row.id): {"name": row.name, "channel_name": row.channel_name or "无渠道", "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in agent_tenant_stats.all()}
tenant_stats = [
{
"tenantId": str(row.id),
"tenantName": row.name,
"channelName": row.channel_name,
"calls": row.calls,
"totalEU": float(row.total_eu or 0),
"totalCost": float(row.total_cost or 0),
}
for row in tenant_stats_result.all()
]
# 调用记录
records_result = await db.execute(
select(BillingRecord, Channel.name, User.name)
.join(Channel, BillingRecord.channel_id == Channel.id)
.join(User, BillingRecord.tenant_id == User.id)
# 2. 从 ModelBillingRecord 统计
model_tenant_stats = await db.execute(
select(
User.id,
User.name,
Channel.name.label("channel_name"),
func.count(ModelBillingRecord.id).label("calls"),
func.sum(ModelBillingRecord.eu_consumed).label("total_eu"),
func.sum(ModelBillingRecord.total_cost).label("total_cost"),
)
.select_from(ModelBillingRecord)
.join(User, ModelBillingRecord.tenant_id == User.id)
.outerjoin(Channel, User.channel_id == Channel.id)
.where(
and_(
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
ModelBillingRecord.start_time >= start_dt,
ModelBillingRecord.start_time <= end_dt,
)
)
.order_by(desc(BillingRecord.timestamp))
.limit(100)
.group_by(User.id, User.name, Channel.name)
)
model_tenant_data = {str(row.id): {"name": row.name, "channel_name": row.channel_name or "无渠道", "calls": row.calls or 0, "eu": float(row.total_eu or 0), "cost": float(row.total_cost or 0)} for row in model_tenant_stats.all()}
# 3. 合并租户统计
all_tenant_ids = set(agent_tenant_data.keys()) | set(model_tenant_data.keys())
tenant_stats = []
for tenant_id in all_tenant_ids:
agent_data = agent_tenant_data.get(tenant_id, {"name": "", "channel_name": "无渠道", "calls": 0, "eu": 0, "cost": 0})
model_data = model_tenant_data.get(tenant_id, {"name": "", "channel_name": "无渠道", "calls": 0, "eu": 0, "cost": 0})
tenant_name_val = agent_data["name"] or model_data["name"]
channel_name_val = agent_data["channel_name"] or model_data["channel_name"]
total_calls = agent_data["calls"] + model_data["calls"]
total_eu = agent_data["eu"] + model_data["eu"]
total_cost = agent_data["cost"] + model_data["cost"]
# 应用筛选条件
if tenantName and tenantName.lower() not in tenant_name_val.lower():
continue
if minCalls is not None and total_calls < minCalls:
continue
if maxCalls is not None and total_calls > maxCalls:
continue
# 计算平均消费
avg_cost = total_cost / total_calls if total_calls > 0 else 0
tenant_stats.append({
"tenantId": tenant_id,
"tenantName": tenant_name_val,
"channelName": channel_name_val,
"calls": total_calls,
"totalEU": round(total_eu, 2),
"totalCost": round(total_cost, 4),
"avgCost": round(avg_cost, 4),
})
# ========== 调用记录(合并 AgentBillingRecord 和 ModelBillingRecord) ==========
call_records = []
# 1. 从 AgentBillingRecord 获取记录
agent_records_result = await db.execute(
select(AgentBillingRecord, Channel.name.label("channel_name"), User.name.label("user_name"))
.outerjoin(Channel, AgentBillingRecord.channel_id == Channel.id)
.join(User, AgentBillingRecord.user_id == User.id)
.where(
and_(
AgentBillingRecord.start_time >= start_dt,
AgentBillingRecord.start_time <= end_dt,
)
)
.order_by(desc(AgentBillingRecord.start_time))
.limit(50)
)
call_records = [
{
for record, channel_name, user_name in agent_records_result.all():
call_records.append({
"id": str(record.id),
"timestamp": record.timestamp.isoformat(),
"channelName": channel_name,
"tenantName": tenant_name,
"type": "agent",
"timestamp": record.start_time.isoformat() if record.start_time else None,
"channelName": channel_name or "无渠道",
"tenantName": user_name,
"agentName": record.agent_name,
"duration": record.duration,
"eu": record.eu,
"cost": float(record.cost),
}
for record, channel_name, tenant_name in records_result.all()
]
"modelName": record.model_name,
"duration": record.duration_seconds or 0,
"eu": record.eu_consumed or 0,
"cost": float(record.cost or 0),
})
# 2. 从 ModelBillingRecord 获取记录
model_records_result = await db.execute(
select(ModelBillingRecord, Channel.name.label("channel_name"), User.name.label("user_name"))
.outerjoin(Channel, ModelBillingRecord.channel_id == Channel.id)
.join(User, ModelBillingRecord.tenant_id == User.id)
.where(
and_(
ModelBillingRecord.start_time >= start_dt,
ModelBillingRecord.start_time <= end_dt,
)
)
.order_by(desc(ModelBillingRecord.start_time))
.limit(50)
)
for record, channel_name, user_name in model_records_result.all():
# 计算 duration(从 response_time_ms 转换为秒)
duration = (record.response_time_ms or 0) / 1000
call_records.append({
"id": str(record.id),
"type": "model",
"timestamp": record.start_time.isoformat() if record.start_time else record.created_at.isoformat(),
"channelName": channel_name or "无渠道",
"tenantName": user_name,
"agentName": None,
"modelName": record.model_name,
"duration": round(duration, 2),
"eu": float(record.eu_consumed or 0),
"cost": float(record.total_cost or 0),
"inputTokens": record.input_tokens,
"outputTokens": record.output_tokens,
"totalTokens": record.total_tokens,
})
# 3. 按时间排序并限制数量
call_records.sort(key=lambda x: x["timestamp"] or "", reverse=True)
call_records = call_records[:100]
# 如果是导出请求
if export:
@@ -3497,45 +3630,6 @@ async def get_admin_roles(
# ============= 平台 Agent 资源申请审批 =============
# 模板显示信息(用于在 Agent Manager 返回数据不包含显示名称时提供默认值)
TEMPLATE_DISPLAY_INFO: Dict[str, Dict[str, str]] = {
"echo_agent": {
"displayName": "Echo 测试服务",
"description": "简单的 Echo 服务,用于测试和调试",
"category": "testing",
},
"chat_agent": {
"displayName": "聊天对话服务",
"description": "通用聊天对话 Agent,支持多轮对话",
"category": "assistant",
},
"code_agent": {
"displayName": "代码执行服务",
"description": "代码生成和执行 Agent,支持多种编程语言",
"category": "development",
},
"search_agent": {
"displayName": "通用搜索服务",
"description": "通用搜索 Agent,支持多种搜索引擎",
"category": "search",
},
"jina_search_agent": {
"displayName": "Jina 搜索服务",
"description": "基于 Jina AI 的语义搜索 Agent",
"category": "search",
},
"mysql_agent": {
"displayName": "MySQL 数据库客户端",
"description": "MySQL 数据库查询和管理 Agent",
"category": "database",
},
"postgresql_agent": {
"displayName": "PostgreSQL 数据库客户端",
"description": "PostgreSQL 数据库查询和管理 Agent",
"category": "database",
},
}
# 模板资源配置建议
TEMPLATE_RESOURCE_CONFIG: Dict[str, Dict[str, str]] = {
"echo_agent": {
@@ -3607,18 +3701,22 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession]
result = []
for template in templates:
template_name = template.template
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
default_resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, {})
# 优先使用数据库中的管理员配置,否则使用默认值
# 优先使用 Agent Manager 返回的 displayName 和 description
agent_manager_display_name = template.display_name
agent_manager_description = template.description
agent_manager_category = template.category
# 优先使用数据库中的管理员配置,否则使用 Agent Manager 返回值
db_config = db_configs.get(template_name)
if db_config:
# 使用数据库配置覆盖默认值
result.append({
"name": template_name,
"displayName": db_config.display_name or display_info.get("displayName", template_name),
"description": db_config.description or display_info.get("description", f"{template_name} Agent"),
"category": display_info.get("category", "general"),
"displayName": db_config.display_name or agent_manager_display_name or template_name,
"description": db_config.description or agent_manager_description or f"{template_name} Agent",
"category": agent_manager_category or "general",
"version": "1.0.0",
"port": template.port,
"envInfo": template.env_info,
@@ -3631,12 +3729,12 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession]
"status": "available" if (db_config.is_enabled is None or db_config.is_enabled) else "disabled",
})
else:
# 使用默认配置
# 使用 Agent Manager 返回值和默认资源配置
result.append({
"name": template_name,
"displayName": display_info.get("displayName", template_name),
"description": display_info.get("description", f"{template_name} Agent"),
"category": display_info.get("category", "general"),
"displayName": agent_manager_display_name or template_name,
"description": agent_manager_description or f"{template_name} Agent",
"category": agent_manager_category or "general",
"version": "1.0.0",
"port": template.port,
"envInfo": template.env_info,
@@ -3653,45 +3751,9 @@ async def _get_platform_templates_from_agent_manager(db: Optional[AsyncSession]
return result
except Exception as e:
logger.warning("admin: 从 Agent Manager 获取平台模板失败,使用默认模板", error=str(e))
# 返回基于 TEMPLATE_DISPLAY_INFO 的默认模板
result = []
for name, info in TEMPLATE_DISPLAY_INFO.items():
default_resource_config = TEMPLATE_RESOURCE_CONFIG.get(name, {})
# 优先使用数据库中的管理员配置
db_config = db_configs.get(name)
if db_config:
result.append({
"name": name,
"displayName": db_config.display_name or info.get("displayName", name),
"description": db_config.description or info.get("description", f"{name} Agent"),
"category": info.get("category", "general"),
"version": "1.0.0",
"cpuRequest": db_config.cpu_request or default_resource_config.get("cpuRequest", "100m"),
"cpuLimit": db_config.cpu_limit or default_resource_config.get("cpuLimit", "500m"),
"memoryRequest": db_config.memory_request or default_resource_config.get("memoryRequest", "128Mi"),
"memoryLimit": db_config.memory_limit or default_resource_config.get("memoryLimit", "512Mi"),
"maxPods": db_config.max_pods if db_config.max_pods is not None else 10,
"isEnabled": db_config.is_enabled if db_config.is_enabled is not None else True,
"status": "available" if (db_config.is_enabled is None or db_config.is_enabled) else "disabled",
})
else:
result.append({
"name": name,
"displayName": info.get("displayName", name),
"description": info.get("description", f"{name} Agent"),
"category": info.get("category", "general"),
"version": "1.0.0",
"cpuRequest": default_resource_config.get("cpuRequest", "100m"),
"cpuLimit": default_resource_config.get("cpuLimit", "500m"),
"memoryRequest": default_resource_config.get("memoryRequest", "128Mi"),
"memoryLimit": default_resource_config.get("memoryLimit", "512Mi"),
"maxPods": 10,
"isEnabled": True,
"status": "available",
})
return result
logger.error("admin: 从 Agent Manager 获取平台模板失败", error=str(e))
# Agent Manager 不可用时,返回空列表
return []
async def _validate_template_exists(template_name: str) -> bool:
@@ -3702,8 +3764,8 @@ async def _validate_template_exists(template_name: str) -> bool:
templates = await client.list_platform_templates()
return any(t.template == template_name for t in templates)
except Exception as e:
logger.warning("admin: 验证模板失败,使用本地验证", error=str(e))
return template_name in TEMPLATE_DISPLAY_INFO
logger.error("admin: 验证模板失败", error=str(e))
return False
@router.get("/platform-agents/templates", response_model=SuccessResponse)
@@ -3731,43 +3793,120 @@ async def list_platform_agent_templates(
async def list_platform_agent_applications(
status_filter: Optional[str] = Query(None, alias="status", pattern="^(pending|approved|rejected)$"),
channel_id: Optional[str] = Query(None),
limit: int = Query(10, ge=1, le=50, description="返回数量,默认10条,最多50条"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取平台 Agent 申请列表(管理员视图)
默认优先返回未审批(pending)的申请,如果未审批数量不足则用已审批的填充。
如果指定了 status 参数,则只返回该状态的申请。
权限:view:applications (所有管理员)
"""
_verify_read_permission(principal)
# 构建查询
data = []
# 如果指定了状态筛选,直接按该状态查询
if status_filter:
query = select(ResourceApplication, Channel).join(
Channel, ResourceApplication.channel_id == Channel.id
).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:
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)
data = []
for app, channel in result.all():
display_info = TEMPLATE_DISPLAY_INFO.get(app.template_name, {})
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name,
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": display_info.get("displayName", app.template_name),
"templateDisplayName": app.template_name,
"requestedPodQuota": app.requested_pod_quota,
"approvedPodQuota": app.approved_pod_quota,
"reason": app.reason,
"status": app.status,
"reviewReason": app.review_reason,
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
"createdAt": app.created_at.isoformat(),
})
else:
# 未指定状态筛选时,优先返回 pending 状态的申请
# 1. 先查询 pending 状态的申请(最多 limit 条)
pending_query = select(ResourceApplication, Channel).join(
Channel, ResourceApplication.channel_id == Channel.id
).where(
and_(
ResourceApplication.resource_type == "platform_agent",
ResourceApplication.status == "pending"
)
)
if channel_id:
pending_query = pending_query.where(ResourceApplication.channel_id == channel_id)
pending_query = pending_query.order_by(desc(ResourceApplication.created_at)).limit(limit)
pending_result = await db.execute(pending_query)
pending_apps = pending_result.all()
for app, channel in pending_apps:
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name,
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": app.template_name,
"requestedPodQuota": app.requested_pod_quota,
"approvedPodQuota": app.approved_pod_quota,
"reason": app.reason,
"status": app.status,
"reviewReason": app.review_reason,
"reviewedAt": app.reviewed_at.isoformat() if app.reviewed_at else None,
"createdAt": app.created_at.isoformat(),
})
# 2. 如果 pending 数量不足 limit,用已审批的(approved/rejected)填充
remaining = limit - len(data)
if remaining > 0:
reviewed_query = select(ResourceApplication, Channel).join(
Channel, ResourceApplication.channel_id == Channel.id
).where(
and_(
ResourceApplication.resource_type == "platform_agent",
ResourceApplication.status.in_(["approved", "rejected"])
)
)
if channel_id:
reviewed_query = reviewed_query.where(ResourceApplication.channel_id == channel_id)
reviewed_query = reviewed_query.order_by(desc(ResourceApplication.created_at)).limit(remaining)
reviewed_result = await db.execute(reviewed_query)
for app, channel in reviewed_result.all():
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name,
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": app.template_name,
"requestedPodQuota": app.requested_pod_quota,
"approvedPodQuota": app.approved_pod_quota,
"reason": app.reason,
@@ -3918,8 +4057,6 @@ async def list_platform_agent_allocations(
data = []
for quota, channel in result.all():
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
# 从模板配置中获取管理员设置的CPU和内存限制
template_config = template_configs.get(quota.template_name)
cpu_limit = template_config.cpu_limit if template_config and template_config.cpu_limit else "100m"
@@ -3930,7 +4067,7 @@ async def list_platform_agent_allocations(
"channelId": str(quota.target_id),
"channelName": channel.name if channel else "未知",
"templateName": quota.template_name,
"templateDisplayName": display_info.get("displayName", quota.template_name),
"templateDisplayName": quota.template_name,
"podQuota": quota.pod_quota,
"podUsed": quota.pod_used,
"podRemaining": quota.pod_quota - quota.pod_used,
@@ -4024,14 +4161,12 @@ async def allocate_platform_agent_to_channel(
await db.commit()
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
return SuccessResponse(
data={
"channelId": str(channel_uuid),
"channelName": channel.name,
"templateName": template_name,
"templateDisplayName": display_info.get("displayName", template_name),
"templateDisplayName": template_name,
"podQuota": pod_quota,
},
message=message
+11
View File
@@ -66,6 +66,8 @@ def _build_agent_card(agent: Agent) -> AgentCard:
k8s_status=agent.k8s_status,
service_port=agent.service_port,
access_url=agent.access_url,
# 模型配置
model_name=agent.model_name,
# 资源配置
cpu_request=agent.cpu_request,
cpu_limit=agent.cpu_limit,
@@ -119,6 +121,9 @@ async def list_templates() -> TemplateListResponse:
templates=[
TemplateInfo(
template=t.template,
displayName=t.display_name,
description=t.description,
category=t.category,
port=t.port,
env_info=t.env_info
)
@@ -150,6 +155,9 @@ async def list_platform_templates() -> TemplateListResponse:
templates=[
TemplateInfo(
template=t.template,
displayName=t.display_name,
description=t.description,
category=t.category,
port=t.port,
env_info=t.env_info
)
@@ -182,6 +190,9 @@ async def list_custom_templates() -> TemplateListResponse:
templates=[
TemplateInfo(
template=t.template,
displayName=t.display_name,
description=t.description,
category=t.category,
port=t.port,
env_info=t.env_info
)
@@ -455,7 +455,7 @@ async def agent_manager_callback(
if existing_record:
# ✅ 更新现有记录(避免重复创建)
previous_cost = Decimal(str(existing_record.cost or 0))
# ⚠️ 不在此处扣款,由周期计费(periodic_billing.py)统一处理扣款
existing_record.end_time = end_time or datetime.utcnow()
existing_record.duration_seconds = duration_seconds
@@ -467,27 +467,11 @@ async def agent_manager_callback(
billing_record = existing_record
# 计算增量成本(新成本 - 已扣成本)
cost_increment = new_cost - previous_cost
logger.info(
f"📝 更新现有计费记录: agent={callback_data.agentName}, "
f"之前成本={previous_cost}, 最终成本={new_cost}, 增量={cost_increment}"
f"📝 更新Agent计费记录(不扣款): agent={callback_data.agentName}, "
f"最终成本={new_cost},扣款由周期计费处理"
)
# 只扣除增量部分(避免与周期计费重复扣款)
if cost_increment > 0:
success, message = await deduct_balance(
callback_data.userId,
cost_increment,
db,
f"Agent 结算(增量): {callback_data.agentName}"
)
if not success:
logger.warning(f"增量余额扣除失败: {message}")
else:
logger.info(f"无需扣款(增量={cost_increment})")
else:
# ⚠️ 没有现有记录,创建新记录(异常情况的兜底)
logger.warning(
@@ -495,6 +479,8 @@ async def agent_manager_callback(
f"agent={callback_data.agentName}, user={callback_data.userId}"
)
# ⚠️ 创建新记录(异常情况的兜底)
# ⚠️ 不在此处扣款,由周期计费(periodic_billing.py)统一处理扣款
billing_record = AgentBillingRecord(
user_id=callback_data.userId,
channel_id=user.channel_id if user.channel_id else None,
@@ -514,17 +500,11 @@ async def agent_manager_callback(
db.add(billing_record)
# 新记录需要全额扣款
success, message = await deduct_balance(
callback_data.userId,
new_cost,
db,
f"Agent 运行: {callback_data.agentName}"
logger.info(
f"📝 创建Agent计费记录(不扣款): agent={callback_data.agentName}, "
f"最终成本={new_cost},扣款由周期计费处理"
)
if not success:
logger.warning(f"余额扣除失败: {message}")
await db.commit()
await db.refresh(billing_record)
+44 -80
View File
@@ -13,11 +13,12 @@ import structlog
from database import get_db
from models import (
User, Channel, Agent, ResourceAllocation,
BillingRecord, RechargeRecord, Application, ModelProvider,
RechargeRecord, Application, ModelProvider,
ChannelProviderAccess, ProviderApplication, TenantCustomAgentQuota,
ChannelCustomAgentQuota, ResourceApplication, PlatformAgentQuota,
AgentBillingRecord, PlatformAgentTemplateConfig, TenantModelKey, Balance
)
# 注意:BillingRecord 已废弃,使用 AgentBillingRecord 和 ModelBillingRecord 替代
from app.auth import require_auth, get_password_hash
from app.permissions import has_permission
from app.schemas import (
@@ -2266,22 +2267,22 @@ async def get_channel_billing_stats(
tenants = {str(t.id): t for t in tenants_result.scalars().all()}
tenant_ids = list(tenants.keys())
# 租户统计
# 租户统计(从 AgentBillingRecord 统计)
tenant_stats_result = await db.execute(
select(
BillingRecord.tenant_id,
func.count(BillingRecord.id).label("calls"),
func.sum(BillingRecord.eu).label("total_eu"),
func.sum(BillingRecord.cost).label("total_cost"),
AgentBillingRecord.user_id.label("tenant_id"),
func.count(AgentBillingRecord.id).label("calls"),
func.sum(AgentBillingRecord.eu_consumed).label("total_eu"),
func.sum(AgentBillingRecord.cost).label("total_cost"),
)
.where(
and_(
BillingRecord.tenant_id.in_(tenant_ids),
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
AgentBillingRecord.user_id.in_(tenant_ids),
AgentBillingRecord.start_time >= start_dt,
AgentBillingRecord.start_time <= end_dt,
)
)
.group_by(BillingRecord.tenant_id)
.group_by(AgentBillingRecord.user_id)
)
tenant_stats = []
@@ -2296,31 +2297,31 @@ async def get_channel_billing_stats(
"totalCost": float(row.total_cost or 0),
})
# 调用记录
# 调用记录(从 AgentBillingRecord 查询)
records_result = await db.execute(
select(BillingRecord)
select(AgentBillingRecord)
.where(
and_(
BillingRecord.tenant_id.in_(tenant_ids),
BillingRecord.timestamp >= start_dt,
BillingRecord.timestamp <= end_dt,
AgentBillingRecord.user_id.in_(tenant_ids),
AgentBillingRecord.start_time >= start_dt,
AgentBillingRecord.start_time <= end_dt,
)
)
.order_by(desc(BillingRecord.timestamp))
.order_by(desc(AgentBillingRecord.start_time))
.limit(100)
)
call_records = []
for record in records_result.scalars().all():
tenant = tenants.get(str(record.tenant_id))
tenant = tenants.get(str(record.user_id))
if tenant:
call_records.append({
"id": str(record.id),
"timestamp": record.timestamp.isoformat(),
"timestamp": record.start_time.isoformat() if record.start_time else record.created_at.isoformat(),
"tenantName": tenant.name,
"agentName": record.agent_name,
"duration": record.duration,
"eu": record.eu,
"duration": record.duration_seconds,
"eu": record.eu_consumed,
"cost": float(record.cost),
})
@@ -2659,45 +2660,6 @@ async def list_provider_access(
# ============= 平台 Agent 资源申请 =============
# 模板显示名称和描述映射(用于前端展示)
TEMPLATE_DISPLAY_INFO = {
"echo_agent": {
"displayName": "Echo 测试服务",
"description": "简单的 Echo 服务,用于测试和调试",
"category": "testing",
},
"chat_agent": {
"displayName": "聊天对话服务",
"description": "智能聊天对话 Agent,支持多轮对话",
"category": "assistant",
},
"code_agent": {
"displayName": "代码执行服务",
"description": "代码生成和执行 Agent,支持多种编程语言",
"category": "development",
},
"search_agent": {
"displayName": "通用搜索服务",
"description": "通用搜索 Agent,支持多种搜索引擎",
"category": "search",
},
"jina_search_agent": {
"displayName": "Jina 语义搜索服务",
"description": "基于 Jina AI 的语义搜索 Agent",
"category": "search",
},
"mysql_agent": {
"displayName": "MySQL 数据库客户端",
"description": "MySQL 数据库查询和管理 Agent",
"category": "database",
},
"postgresql_agent": {
"displayName": "PostgreSQL 数据库客户端",
"description": "PostgreSQL 数据库查询和管理 Agent",
"category": "database",
},
}
# 模板资源配置建议
TEMPLATE_RESOURCE_CONFIG = {
"echo_agent": {"cpuRequest": "100m", "cpuLimit": "500m", "memoryRequest": "128Mi", "memoryLimit": "512Mi"},
@@ -2738,9 +2700,13 @@ async def _get_platform_templates_from_agent_manager(db: AsyncSession = None) ->
result = {}
for t in templates:
template_name = t.template
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
admin_config = admin_configs.get(template_name)
# 优先使用 Agent Manager 返回的 displayName 和 description
agent_manager_display_name = t.display_name
agent_manager_description = t.description
agent_manager_category = t.category
# 如果有管理员配置,使用管理员配置的值;否则返回 null/0
if admin_config:
resource_config = {
@@ -2751,9 +2717,9 @@ async def _get_platform_templates_from_agent_manager(db: AsyncSession = None) ->
"maxPods": admin_config.max_pods or 0,
"isConfigured": True,
}
# 如果管理员配置了显示名称和描述,使用管理员配置的
display_name = admin_config.display_name or display_info.get("displayName", template_name)
description = admin_config.description or display_info.get("description", f"{template_name} Agent")
# 优先级:管理员配置 > Agent Manager 返回 > 模板名称
display_name = admin_config.display_name or agent_manager_display_name or template_name
description = admin_config.description or agent_manager_description or f"{template_name} Agent"
else:
# 未配置时返回 null/0,表示管理员尚未配置
resource_config = {
@@ -2764,14 +2730,15 @@ async def _get_platform_templates_from_agent_manager(db: AsyncSession = None) ->
"maxPods": 0,
"isConfigured": False,
}
display_name = display_info.get("displayName", template_name)
description = display_info.get("description", f"{template_name} Agent")
# 优先级:Agent Manager 返回 > 模板名称
display_name = agent_manager_display_name or template_name
description = agent_manager_description or f"{template_name} Agent"
result[template_name] = {
"name": template_name,
"displayName": display_name,
"description": description,
"category": display_info.get("category", "general"),
"category": agent_manager_category or "general",
"version": "1.0.0",
"port": t.port,
"envInfo": t.env_info,
@@ -2804,7 +2771,6 @@ async def _get_custom_templates_from_agent_manager() -> Dict[str, Dict[str, Any]
result = {}
for t in templates:
template_name = t.template
display_info = TEMPLATE_DISPLAY_INFO.get(template_name, {})
resource_config = TEMPLATE_RESOURCE_CONFIG.get(template_name, {
"cpuRequest": "100m",
"cpuLimit": "500m",
@@ -2812,11 +2778,16 @@ async def _get_custom_templates_from_agent_manager() -> Dict[str, Dict[str, Any]
"memoryLimit": "512Mi"
})
# 优先使用 Agent Manager 返回的 displayName 和 description
agent_manager_display_name = t.display_name
agent_manager_description = t.description
agent_manager_category = t.category
result[template_name] = {
"name": template_name,
"displayName": display_info.get("displayName", template_name),
"description": display_info.get("description", f"{template_name} Agent"),
"category": display_info.get("category", "general"),
"displayName": agent_manager_display_name or template_name,
"description": agent_manager_description or f"{template_name} Agent",
"category": agent_manager_category or "general",
"version": "1.0.0",
"port": t.port,
"envInfo": t.env_info,
@@ -3056,15 +3027,13 @@ async def list_platform_agent_applications(
data = []
for app in applications:
channel = channels_map.get(str(app.channel_id))
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
display_info = TEMPLATE_DISPLAY_INFO.get(app.template_name, {})
data.append({
"id": str(app.id),
"channelId": str(app.channel_id),
"channelName": channel.name if channel else "未知",
"resourceType": app.resource_type,
"templateName": app.template_name,
"templateDisplayName": display_info.get("displayName", app.template_name),
"templateDisplayName": app.template_name,
"requestedPodQuota": app.requested_pod_quota,
"approvedPodQuota": app.approved_pod_quota,
"reason": app.reason,
@@ -3118,9 +3087,6 @@ async def list_channel_platform_agent_quotas(
data = []
for quota in quotas:
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
# 从模板配置中获取管理员设置的CPU和内存限制
template_config = template_configs.get(quota.template_name)
cpu_limit = template_config.cpu_limit if template_config and template_config.cpu_limit else "100m"
@@ -3128,7 +3094,7 @@ async def list_channel_platform_agent_quotas(
data.append({
"templateName": quota.template_name,
"templateDisplayName": display_info.get("displayName", quota.template_name),
"templateDisplayName": quota.template_name,
"podQuota": quota.pod_quota,
"podUsed": quota.pod_used,
"podRemaining": quota.pod_quota - quota.pod_used,
@@ -3400,11 +3366,9 @@ async def get_tenant_platform_agent_usage(
data = []
for quota in quotas:
# 使用 TEMPLATE_DISPLAY_INFO 获取显示名称
display_info = TEMPLATE_DISPLAY_INFO.get(quota.template_name, {})
data.append({
"templateName": quota.template_name,
"templateDisplayName": display_info.get("displayName", quota.template_name),
"templateDisplayName": quota.template_name,
"podQuota": quota.pod_quota,
"podUsed": quota.pod_used,
"podRemaining": quota.pod_quota - quota.pod_used,
@@ -123,9 +123,7 @@ def build_full_auth_config(auth: Optional[ExternalToolAuthConfig]) -> Optional[D
elif auth.type == "bearer":
return {
"type": "bearer",
"key": secret,
"in": "header",
"name": "Authorization"
"token": secret
}
elif auth.type == "basic":
@@ -218,9 +218,13 @@ async def get_available_platform_agents(
template_list = []
for t in templates:
template_name = t.template
# 优先使用 Agent Manager 返回的 displayName
display_name = t.display_name or template_name.replace("_", " ").title()
template_list.append(PlatformAgentTemplateInfo(
name=template_name,
display_name=template_name.replace("_", " ").title(),
display_name=display_name,
description=t.description,
category=t.category,
port=t.port,
has_access=template_name in quotas,
current_quota=quotas.get(template_name, 0)
+118 -88
View File
@@ -2,7 +2,7 @@
用户侧平台API路由
"""
import logging
import structlog
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -17,6 +17,8 @@ from models import (
PlatformAgentQuota, AgentBillingRecord, TenantModelKey, ModelBillingRecord, Balance,
PlatformAgentTemplateConfig
)
# ⚠️ 注意:BillingRecord 已废弃,新代码应使用 AgentBillingRecord 和 ModelBillingRecord
# 此文件中仍有部分代码使用 BillingRecord,需要后续迁移
from app.billing import (
get_agent_billing_stats,
calculate_platform_agent_cost,
@@ -46,7 +48,7 @@ from app.schemas import (
ScaleCustomAgentRequest,
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api/user", tags=["用户侧平台"])
@@ -984,6 +986,7 @@ async def get_my_custom_agent_quota(
"totalCpu": agent_total_cpu,
"totalMemory": agent_total_memory,
"startTime": agent.start_time.isoformat() if agent.start_time else None,
"modelName": agent.model_name, # 返回模型名称
})
return SuccessResponse(
@@ -1231,51 +1234,6 @@ async def update_tool(
)
@router.delete("/tools/{tool_id}", response_model=SuccessResponse)
async def delete_tool(
tool_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
删除工具
"""
user_id = principal.get("user_id")
# 查询工具
try:
tool_uuid = uuid.UUID(tool_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="工具ID格式无效"
)
result = await db.execute(
select(Tool).where(
and_(
Tool.id == tool_uuid,
Tool.owner_id == user_id
)
)
)
tool = result.scalar_one_or_none()
if not tool:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="工具不存在"
)
# 删除工具
await db.delete(tool)
await db.commit()
return SuccessResponse(
message="工具已删除"
)
# ============= 代理工厂 =============
@router.get("/agents/platform", response_model=SuccessResponse)
@@ -1302,6 +1260,7 @@ async def list_platform_agents(
"cpu": float(agent.cpu),
"memory": float(agent.memory),
"status": agent.status,
"modelName": agent.model_name, # 返回模型名称
}
for agent in agents
]
@@ -1458,6 +1417,7 @@ async def deploy_agent(
status="active",
cpu_request=quota.cpu_per_pod or "100m",
memory_request=quota.memory_per_pod or "256Mi",
model_name=model_name, # 保存用户选择的模型名称
)
db.add(agent)
@@ -1475,6 +1435,7 @@ async def deploy_agent(
cpu_used=quota.cpu_per_pod or "100m",
memory_used=quota.memory_per_pod or "256Mi",
replicas=req.instances,
model_name=model_name, # 保存用户选择的模型名称
# ========== 保存访问信息 ==========
external_ip=access_info.get("external_ip"),
domain=access_info.get("domain"),
@@ -2234,6 +2195,21 @@ async def get_available_platform_agents(
)
quotas = result.scalars().all()
# 从 Agent Manager 获取平台模板信息
from app.agent_manager_client import get_agent_manager_client
template_info_map = {}
try:
client = get_agent_manager_client()
templates = await client.list_platform_templates()
for t in templates:
template_info_map[t.template] = {
"display_name": t.display_name,
"description": t.description,
"category": t.category
}
except Exception as e:
logger.warning(f"获取 Agent Manager 模板信息失败: {e}")
# 查询所有模板配置,用于获取管理员设置的CPU和内存
template_configs_result = await db.execute(select(PlatformAgentTemplateConfig))
template_configs = {config.template_name: config for config in template_configs_result.scalars().all()}
@@ -2272,17 +2248,23 @@ async def get_available_platform_agents(
template=quota.template_name
)
# 获取 Agent Manager 返回的模板信息
template_info = template_info_map.get(quota.template_name, {})
display_name = template_info.get("display_name") or quota.template_name.replace("-", " ").replace("_", " ").title()
description = template_info.get("description") or f"Platform Agent: {quota.template_name}"
category = template_info.get("category") or "platform"
agents.append({
"id": str(quota.id),
"templateName": quota.template_name,
"displayName": quota.template_name.replace("-", " ").replace("_", " ").title(),
"description": f"Platform Agent: {quota.template_name}",
"displayName": display_name,
"description": description,
"podQuota": quota.pod_quota,
"podUsed": actual_used, # 使用实际运行数量
"podRemaining": quota.pod_quota - actual_used,
"cpuLimit": cpu_limit,
"memoryLimit": memory_limit,
"category": "platform",
"category": category,
"allocatedAt": quota.allocated_at.isoformat() if quota.allocated_at else None,
})
@@ -2482,6 +2464,8 @@ async def deploy_platform_agent(
quota.pod_used += 1
# 创建 Agent 记录
# 获取注入的模型名称(如果有)
injected_model_name = env_vars.get("MODEL_NAME") or env_vars.get("LITELLM_MODEL")
agent = Agent(
name=instance_name,
type="platform",
@@ -2491,7 +2475,8 @@ async def deploy_platform_agent(
k8s_status=result.status,
service_port=result.service_port,
owner_id=user_id,
status="active"
status="active",
model_name=injected_model_name, # 保存用户选择的模型名称
)
db.add(agent)
@@ -2509,6 +2494,7 @@ async def deploy_platform_agent(
cpu_used=quota.cpu_per_pod or "100m",
memory_used=quota.memory_per_pod or "256Mi",
replicas=1,
model_name=injected_model_name, # 保存用户选择的模型名称
# ========== 保存访问信息 ==========
external_ip=access_info.get("external_ip"),
domain=access_info.get("domain"),
@@ -2768,6 +2754,7 @@ async def list_my_platform_agent_instances(
"status": agent_status.status,
"startTime": record.start_time.isoformat() if record.start_time else None,
"runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0,
"modelName": record.model_name, # 返回模型名称
})
except AgentManagerError:
# 实例可能已被删除
@@ -2777,6 +2764,7 @@ async def list_my_platform_agent_instances(
"status": "unknown",
"startTime": record.start_time.isoformat() if record.start_time else None,
"runningSeconds": 0,
"modelName": record.model_name, # 返回模型名称
})
return SuccessResponse(data={"instances": instances})
@@ -2912,11 +2900,11 @@ async def create_custom_agent(
user_id = principal.get("user_id")
channel_id = principal.get("channel_id")
# 检查用户配额
# 检查用户配额(使用行锁防止并发问题)
quota_result = await db.execute(
select(TenantCustomAgentQuota).where(
TenantCustomAgentQuota.tenant_id == user_id
)
select(TenantCustomAgentQuota)
.where(TenantCustomAgentQuota.tenant_id == user_id)
.with_for_update() # ✅ 添加行锁,防止并发创建Agent导致超配
)
quota = quota_result.scalar_one_or_none()
@@ -3195,6 +3183,36 @@ async def create_custom_agent(
logger.warning(f"自动注入 OPENAI_API_KEY 失败: {str(e)}")
# ============================================================
# ✅ 预检查用户余额(防止余额不足仍创建Agent)
from app.billing import get_available_balance, calculate_agent_cost_by_resources
# 预估至少运行1小时的成本
cpu_cores = _parse_cpu_to_cores(req.cpuRequest) if req.cpuRequest else 0.1
memory_gb = _parse_memory_to_gb(req.memoryRequest) if req.memoryRequest else 0.125
estimated_duration = 3600 # 预估1小时(3600秒)
estimated_cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, estimated_duration)
# 获取可用余额
balance, credit_limit, available = await get_available_balance(str(user_id), db)
if available < estimated_cost:
logger.warning(
f"余额不足,无法创建Agent: user_id={user_id}, "
f"可用余额={available:.2f} EU, 预估成本={estimated_cost:.2f} EU"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"余额不足,无法创建 Agent。"
f"预估成本(1小时): {estimated_cost:.2f} EU, "
f"当前可用余额: {available:.2f} EU,"
f"请先充值"
)
logger.info(
f"余额检查通过: user_id={user_id}, "
f"可用余额={available:.2f} EU, 预估成本={estimated_cost:.2f} EU"
)
try:
client = get_agent_manager_client()
@@ -3257,6 +3275,7 @@ async def create_custom_agent(
cpu_used=req.cpuRequest,
memory_used=req.memoryRequest,
tools_used=[],
model_name=model_name, # 保存用户选择的模型名称
# ========== 保存访问信息 ==========
external_ip=access_info.get("external_ip"),
domain=access_info.get("domain"),
@@ -3349,14 +3368,41 @@ async def delete_custom_agent(
detail=f"未找到 Agent {name} 或该 Agent 不属于您"
)
# ✅ Step 1: 先删除 Pod(如果失败,整个操作终止,不修改数据库)
# 从 namespace 提取完整的 agent 名称(namespace 格式如 "agent-123123123-674ddd")
agent_full_name = name
if billing_record.namespace and billing_record.namespace.startswith("agent-"):
agent_full_name = billing_record.namespace[6:] # 去掉 "agent-" 前缀
logger.info(f"使用完整 Agent 名称删除: {agent_full_name} (原名: {name}, namespace: {billing_record.namespace})")
client = get_agent_manager_client()
try:
# 先更新计费记录和释放配额(数据库操作)
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()
duration = (billing_record.end_time - billing_record.start_time).total_seconds()
billing_record.duration_seconds = int(duration)
billing_record.eu_consumed = _calculate_eu(int(duration))
# 计算成本并扣款(自定义Agent按资源使用量计费)
# ✅ Step 3: 计算成本并扣款(自定义Agent按资源使用量计费)
cpu_cores = _parse_cpu_to_cores(billing_record.cpu_used) if billing_record.cpu_used else 0.1
memory_gb = _parse_memory_to_gb(billing_record.memory_used) if billing_record.memory_used else 0.125
cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, int(duration))
@@ -3370,7 +3416,7 @@ async def delete_custom_agent(
if not success:
logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}")
# 释放配额
# ✅ Step 4: 释放配额(使用行锁防止并发问题)
try:
cpu_released = _parse_cpu(billing_record.cpu_used or "0")
memory_released = _parse_memory(billing_record.memory_used or "0")
@@ -3380,9 +3426,9 @@ async def delete_custom_agent(
memory_released = 0
quota_result = await db.execute(
select(TenantCustomAgentQuota).where(
TenantCustomAgentQuota.tenant_id == user_id
)
select(TenantCustomAgentQuota)
.where(TenantCustomAgentQuota.tenant_id == user_id)
.with_for_update() # ✅ 添加行锁,防止并发问题
)
quota = quota_result.scalar_one_or_none()
@@ -3391,18 +3437,9 @@ async def delete_custom_agent(
quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released)
quota.agent_count = max(0, (quota.agent_count or 0) - 1)
# 提交数据库更改
# ✅ Step 5: 提交数据库更改
await db.commit()
# 数据库操作成功后,再删除 Agent
client = get_agent_manager_client()
try:
await client.delete_agent(name)
except Exception as agent_delete_error:
logger.error(f"Agent Manager 删除失败: {name}, 错误: {str(agent_delete_error)}")
# 注意:配额已释放,但 Agent 可能未删除
# 可以考虑标记为"待清理"状态,供后台任务处理
return SuccessResponse(
message=f"自定义 Agent {name} 已删除",
data={
@@ -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)
async def scale_custom_agent_api(
@@ -3459,11 +3486,11 @@ async def scale_custom_agent_api(
detail=f"未找到 Agent {name} 或该 Agent 不属于您"
)
# 获取用户配额
# 获取用户配额(使用行锁防止并发问题)
quota_result = await db.execute(
select(TenantCustomAgentQuota).where(
TenantCustomAgentQuota.tenant_id == user_id
)
select(TenantCustomAgentQuota)
.where(TenantCustomAgentQuota.tenant_id == user_id)
.with_for_update() # ✅ 添加行锁,防止并发扩缩容导致配额计算错误
)
quota = quota_result.scalar_one_or_none()
@@ -3641,11 +3668,12 @@ async def list_my_custom_agents(
agent_info = {
"name": record.agent_name,
"template": record.agent_type,
"status": "unknown",
"status": "Pending", # 默认为 Pending 而不是 unknown
"cpu": record.cpu_used,
"memory": record.memory_used,
"startTime": record.start_time.isoformat() if record.start_time else None,
"runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0,
"modelName": record.model_name, # 返回模型名称
}
if agent_manager_available:
@@ -3653,9 +3681,10 @@ async def list_my_custom_agents(
# 获取 Agent 状态
agent_status = await client.get_agent_status(record.agent_name)
agent_info["status"] = agent_status.status
except Exception:
logger.debug(f"custom_agent_status_fetched: agent_name={record.agent_name}, status={agent_status.status}")
except Exception as e:
# Agent Manager 服务不可用或 Agent 不存在
pass
logger.warning(f"custom_agent_status_fetch_failed: agent_name={record.agent_name}, error={str(e)}")
agents.append(agent_info)
@@ -4110,6 +4139,7 @@ async def get_user_agents_info(
"cpu": record.cpu_used,
"memory": record.memory_used,
"replicas": record.replicas,
"modelName": record.model_name, # 返回模型名称
"startTime": record.start_time.isoformat() if record.start_time else None,
"runningSeconds": int((datetime.utcnow() - record.start_time).total_seconds()) if record.start_time else 0,
}
+420
View File
@@ -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())
+1 -16
View File
@@ -10,13 +10,6 @@ from pydantic_settings import BaseSettings
BASE_DIR = Path(__file__).resolve().parent
SQLITE_FALLBACK_PATH = BASE_DIR / "data" / "mcp_fallback.db"
SQLITE_FALLBACK_PATH.parent.mkdir(parents=True, exist_ok=True)
# 默认的PostgreSQL URL(主数据库)- 从环境变量获取
DEFAULT_POSTGRES_URL = os.getenv("DATABASE_URL") or os.getenv("ASYNC_DATABASE_URL") or ""
# SQLite回退URL在需要紧急切换时可手动使用
DEFAULT_SQLITE_URL = f"sqlite+aiosqlite:///{SQLITE_FALLBACK_PATH}"
class Settings(BaseSettings):
@@ -160,22 +153,14 @@ class ProductionSettings(Settings):
log_level: str = "INFO"
class TestingSettings(Settings):
"""测试环境配置"""
debug: bool = True
database_url: str = "sqlite+aiosqlite:///./test.db"
redis_url: str = "redis://localhost:6379/1" # 使用不同的数据库
def get_settings() -> Settings:
"""根据环境变量获取相应的配置"""
environment = os.getenv("ENVIRONMENT", "development").lower()
if environment == "production":
return ProductionSettings()
elif environment == "testing":
return TestingSettings()
else:
# 默认使用开发环境配置,不再支持测试环境配置
return DevelopmentSettings()
Binary file not shown.
+10 -50
View File
@@ -48,40 +48,26 @@ def prepare_database_url(url: str) -> str:
database_url = prepare_database_url(settings.database_url)
database_url_obj = make_url(database_url)
# PostgreSQL 连接池配置
engine_kwargs = {
"echo": settings.debug,
"pool_pre_ping": True,
"pool_size": 10,
"max_overflow": 20,
"pool_recycle": 600,
"pool_timeout": 30,
"echo_pool": settings.debug,
}
if database_url_obj.get_backend_name().startswith("sqlite"):
engine_kwargs["connect_args"] = {"check_same_thread": False}
else:
engine_kwargs.update({
"pool_size": 10, # 优化:减少基础连接池大小
"max_overflow": 20, # 优化:减少溢出连接数
"pool_recycle": 600, # 优化:10分钟回收,避免连接泄漏
"pool_timeout": 30,
"pool_pre_ping": True, # 确保连接可用
"echo_pool": settings.debug, # 添加连接池日志
})
# Azure Database for PostgreSQL 或任何包含 sslmode 的连接都需要 TLS
# Azure Database for PostgreSQL 需要 TLS
if (database_url_obj.host and database_url_obj.host.endswith("postgres.database.azure.com")) or \
"sslmode" in settings.database_url:
# Azure Database for PostgreSQL requires TLS; provide a default SSL context.
ssl_context = ssl.create_default_context()
existing_connect_args = engine_kwargs.get("connect_args") or {}
existing_connect_args["ssl"] = ssl_context
engine_kwargs["connect_args"] = existing_connect_args
engine_kwargs["connect_args"] = {"ssl": ssl_context}
# 创建异步数据库引擎
engine = create_async_engine(database_url, **engine_kwargs)
# 判断当前是否使用SQLite后端
def is_sqlite_backend() -> bool:
backend = engine.url.get_backend_name()
return backend.startswith("sqlite")
# 创建异步会话工厂
AsyncSessionLocal = async_sessionmaker(
engine,
@@ -287,37 +273,22 @@ async def get_db_stats():
async def cleanup_old_records():
"""清理旧记录"""
"""清理旧记录(仅支持 PostgreSQL)"""
try:
async with AsyncSessionLocal() as session:
# 清理超过30天的执行记录
if is_sqlite_backend():
result = await session.execute(text("""
DELETE FROM executions
WHERE created_at < datetime('now', '-30 days')
"""))
else:
result = await session.execute(text("""
DELETE FROM executions
WHERE created_at < NOW() - INTERVAL '30 days'
"""))
deleted_executions = result.rowcount
# 清理超过7天的会话记录
if is_sqlite_backend():
result = await session.execute(text("""
DELETE FROM sessions
WHERE created_at < datetime('now', '-7 days')
AND status != 'active'
"""))
else:
result = await session.execute(text("""
DELETE FROM sessions
WHERE created_at < NOW() - INTERVAL '7 days'
AND status != 'active'
"""))
deleted_sessions = result.rowcount
await session.commit()
@@ -335,28 +306,17 @@ async def cleanup_old_records():
async def backup_db():
"""数据库备份"""
"""数据库备份(仅支持 PostgreSQL)"""
try:
import subprocess
from datetime import datetime
import os
import shutil
# 生成备份文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = "/app/backups"
os.makedirs(backup_dir, exist_ok=True)
if is_sqlite_backend():
db_path = engine.url.database
if not db_path:
raise ValueError("SQLite数据库路径为空,无法备份")
db_path = os.path.abspath(db_path)
backup_file = os.path.join(backup_dir, f"mcp_sqlite_backup_{timestamp}.db")
shutil.copy2(db_path, backup_file)
get_logger().info(f"SQLite数据库备份成功: {backup_file}")
return backup_file
backup_file = f"{backup_dir}/taiji_db_backup_{timestamp}.sql"
# 执行pg_dump命令
cmd = [
+6 -1
View File
@@ -35,12 +35,17 @@ spec:
- name: DEBUG
value: "false"
# 数据库配置(使用Azure Database for PostgreSQL)
# 数据库配置(使用Azure Database for PostgreSQL - 测试库 taiji)
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: database-url
- name: ASYNC_DATABASE_URL
valueFrom:
secretKeyRef:
name: taiji-secrets
key: async-database-url
# Redis配置(使用Azure Cache for Redis)
- name: REDIS_URL
+3 -2
View File
@@ -8,8 +8,9 @@ metadata:
namespace: taiji-ai
type: Opaque
stringData:
# 数据库连接字符串 - 使用 taiji 数据库
database-url: "postgresql+asyncpg://taiji:PASSWORD@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require"
# 数据库连接字符串 - 测试环境使用 taiji 数据库
database-url: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji?sslmode=require"
async-database-url: "postgresql+asyncpg://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taiji"
# Redis连接字符串(Redis 已迁移到 taiji2026 实例)
redis-url: "rediss://:PzmWkM6CwfRrJTB1d2xLRxE9pzT7JKgvVAzCaEehmFE=@taiji2026.southeastasia.redis.azure.net:10000/0?ssl_cert_reqs=none"
@@ -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())
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
执行 011_fix_quota_defaults.sql 迁移脚本
执行 020_fix_quota_defaults.sql 迁移脚本
修复配额字段的默认值和可空性
"""
@@ -38,7 +38,7 @@ async def run_migration():
engine = create_async_engine(database_url, echo=False)
# 读取迁移脚本
migration_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "011_fix_quota_defaults.sql")
migration_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "020_fix_quota_defaults.sql")
logger.info(f"读取迁移脚本: {migration_file}")
with open(migration_file, 'r', encoding='utf-8') as f:
@@ -134,7 +134,7 @@ async def main():
"""主函数"""
print("="*80)
print("执行配额字段默认值修复迁移")
print("迁移文件: 011_fix_quota_defaults.sql")
print("迁移文件: 020_fix_quota_defaults.sql")
print("="*80)
print()
@@ -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())
+27 -5
View File
@@ -81,11 +81,10 @@ class User(BaseModel, Base):
# 订阅和计费
subscription_tier = Column(String(20), default="free")
discount = Column(sa.Numeric(5, 2), default=0)
balance = Column(sa.Numeric(12, 2), default=0) # 账户余额 [DEPRECATED - 使用 Balance 表]
credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度
# EU计费(执行单元)
eu_balance = Column(sa.Numeric(15, 2), default=0) # EU余额 [DEPRECATED - 使用 Balance 表]
# ❌ 废弃字段已删除:balance, eu_balance(使用 Balance 表替代)
total_eu_consumed = Column(sa.Numeric(15, 2), default=0) # 总EU消耗
# 资源限制
@@ -166,6 +165,9 @@ class Agent(BaseModel, Base):
endpoints = Column(JSON, default=dict) # 端点信息
pod_created_at = Column(DateTime) # Pod 创建时间
# 模型配置
model_name = Column(String(100)) # Agent 使用的 LLM 模型名称,如 azure/gpt-4
# 自定义 Agent 特有字段
image_url = Column(String(500)) # 用户上传的镜像地址
program_path = Column(String(500)) # 程序路径
@@ -487,7 +489,15 @@ class APIKey(BaseModel, Base):
class Billing(BaseModel, Base):
"""计费详情模型(按执行记录计费)"""
"""计费详情模型(按执行记录计费)
⚠️ 已废弃 (DEPRECATED)
此表已废弃,不再使用。实际计费数据存储在:
- AgentBillingRecord: Agent 运行时计费
- ModelBillingRecord: 模型调用计费(LiteLLM)
保留此模型仅用于历史数据兼容,新代码请勿使用。
"""
__tablename__ = "billing"
execution_id = Column(GUID(), ForeignKey("executions.id"), nullable=False)
@@ -532,7 +542,15 @@ class Balance(BaseModel, Base):
class BillingRecord(BaseModel, Base):
"""计费记录模型"""
"""计费记录模型
⚠️ 已废弃 (DEPRECATED)
此表已废弃,不再使用。实际计费数据存储在:
- AgentBillingRecord: Agent 运行时计费(表名: agent_billing_records)
- ModelBillingRecord: 模型调用计费(表名: model_billing_records)
保留此模型仅用于历史数据兼容,新代码请勿使用。
"""
__tablename__ = "billing_records"
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow)
@@ -1190,7 +1208,7 @@ class AgentBillingRecord(BaseModel, Base):
period_end = Column(DateTime, nullable=True)
# 运行时间(用于追踪 Agent 运行状态)
start_time = Column(DateTime, nullable=True) # Agent 启动时间
start_time = Column(DateTime, nullable=False) # ✅ Agent 启动时间(必填,防止计费遗漏)
end_time = Column(DateTime, nullable=True) # Agent 停止时间(None 表示正在运行)
# 工具使用信息(新增字段)
@@ -1206,6 +1224,10 @@ class AgentBillingRecord(BaseModel, Base):
namespace = Column(String(100), nullable=True) # K8s 命名空间
# ======================================================
# ========== 模型配置 ==========
model_name = Column(String(100), nullable=True) # Agent 使用的 LLM 模型名称,如 azure/gpt-4
# ==============================
# 关联关系
user = relationship("User")
channel = relationship("Channel")
-121
View File
@@ -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())
+6
View File
@@ -291,6 +291,9 @@ class AgentCard(BaseSchema):
service_port: Optional[int] = None
access_url: Optional[str] = None
# 模型配置
model_name: Optional[str] = None # Agent 使用的 LLM 模型名称
# 资源配置
cpu_request: Optional[str] = None
cpu_limit: Optional[str] = None
@@ -367,6 +370,9 @@ class AgentMetricsResponse(BaseSchema):
class TemplateInfo(BaseModel):
"""模板信息"""
template: str
displayName: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
port: Optional[int] = None
env_info: Dict[str, Any] = {}
@@ -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)
-433
View File
@@ -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())
-197
View File
@@ -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)
-149
View File
@@ -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())
-200
View File
@@ -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)
-222
View File
@@ -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
View File
@@ -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")