生产备份

This commit is contained in:
zhanggangyong
2026-03-13 02:53:27 +00:00
parent 7ddac1bea7
commit 648921b26c
23 changed files with 1800 additions and 1627 deletions
-279
View File
@@ -1,279 +0,0 @@
# 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
@@ -1,115 +0,0 @@
# 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)
@@ -124,7 +124,7 @@ http GET http://localhost:8002/api/user/dashboard/billing-overview \
}, },
"balance": { "balance": {
"eu": 50000.0, "eu": 50000.0,
"cash": 5000.00 "cash": 50000.0
}, },
"euHistory": [ "euHistory": [
{ {
@@ -40,7 +40,7 @@
}, },
"balance": { "balance": {
"eu": 50000.0, "eu": 50000.0,
"cash": 5000.00 "cash": 50000.0
}, },
"euHistory": [ "euHistory": [
{ {
@@ -163,8 +163,8 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| eu | float | 当前EU余额 | | eu | float | 当前EU余额(1 EU = 1 美元) |
| cash | float | 当前现金余额(CNY) | | cash | float | 当前现金余额(美元),与 eu 相等(1 美元 = 1 EU) |
### euHistory (EU消费历史) ### euHistory (EU消费历史)
+662
View File
@@ -0,0 +1,662 @@
# 生产环境数据库同步指南
生成时间: 2026-03-12
测试环境数据库: `taiji`
生产环境数据库: `taiji_prod`
## 📋 迁移总览
测试环境在 2026年3月9-12日期间执行了 **8个数据库迁移**,需要同步到生产环境:
| 迁移编号 | 文件名 | 类型 | 影响范围 | 风险等级 |
|---------|--------|------|----------|---------|
| 017 | add_external_data_tools.sql | 新增表 | 外部数据工具功能 | 🟢 低 |
| 018 | add_external_toolkits.sql | 新增表 | 外部工具集功能 | 🟢 低 |
| 019 | add_agent_model_name.sql | 字段新增 | `agents`、`agent_billing_records` | 🟢 低 |
| 020 | fix_quota_defaults.sql | 字段修改 | 配额表(3个表) | 🟡 中 |
| 021 | fix_agent_type_length.sql | 字段修改 | `agent_billing_records` | 🟢 低 |
| 022 | fix_eu_equals_cost.sql | 字段类型+数据修正 | `agent_billing_records`、`model_billing_records` | 🟡 中 |
| 023 | fix_billing_channel_id.sql | 数据修正 | `agent_billing_records`、`model_billing_records` | 🟢 低 |
| 024 | add_record_type.sql | 字段新增+数据修正 | `agent_billing_records` | 🟡 中 |
---
## 📝 详细迁移内容
### 迁移 017: 添加外部数据工具表
**文件**: `services/mcp-server/migrations/017_add_external_data_tools.sql`
**目的**: 支持用户创建和管理自定义的外部API数据工具
**操作**:
```sql
-- 创建表
CREATE TABLE external_data_tools (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
url VARCHAR(500) NOT NULL,
method VARCHAR(10) DEFAULT 'POST',
auth_type VARCHAR(20) DEFAULT 'none',
tool_ref_id VARCHAR(100) UNIQUE,
status VARCHAR(20) DEFAULT 'pending',
error_message TEXT,
owner_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID REFERENCES users(id),
channel_id UUID REFERENCES channels(id),
is_active BOOLEAN DEFAULT TRUE,
usage_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 索引
CREATE INDEX idx_external_data_tool_owner ON external_data_tools(owner_id);
CREATE INDEX idx_external_data_tool_ref ON external_data_tools(tool_ref_id);
CREATE INDEX idx_external_data_tool_status ON external_data_tools(status);
```
**影响**: 新功能,对现有数据无影响
**回滚方案**:
```sql
DROP TABLE IF EXISTS external_data_tools CASCADE;
```
---
### 迁移 018: 添加外部数据工具集表
**文件**: `services/mcp-server/migrations/018_add_external_toolkits.sql`
**目的**: 允许用户将多个外部工具组合成工具集,方便部署自定义Agent
**操作**:
```sql
-- 创建表
CREATE TABLE external_toolkits (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
tool_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
owner_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID REFERENCES users(id),
channel_id UUID REFERENCES channels(id),
is_active BOOLEAN DEFAULT TRUE,
usage_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_toolkit_name_owner UNIQUE (name, owner_id)
);
-- 索引
CREATE INDEX idx_external_toolkit_owner ON external_toolkits(owner_id);
```
**影响**: 新功能,对现有数据无影响
**回滚方案**:
```sql
DROP TABLE IF EXISTS external_toolkits CASCADE;
```
---
### 迁移 019: 为Agent添加模型名称字段 ⭐ 重要
**文件**: `services/mcp-server/migrations/019_add_agent_model_name.sql`
**目的**:
- 记录每个Agent使用的LLM模型(如 `azure/gpt-4`, `gemini/gemini-pro`)
- 支持计费记录中追踪模型使用情况
**操作**:
```sql
-- 1. agents 表添加 model_name 字段
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);
-- 2. agent_billing_records 表添加 model_name 字段
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);
```
**影响**:
- 现有Agent的 `model_name` 为 NULL(可接受)
- 新创建的Agent将记录模型信息
**注意事项**:
- ⚠️ 如果生产环境已有该字段,使用 `IF NOT EXISTS` 将安全跳过
- 该字段为 **可空**,不强制现有记录填充
**回滚方案**:
```sql
ALTER TABLE agents DROP COLUMN IF EXISTS model_name;
ALTER TABLE agent_billing_records DROP COLUMN IF EXISTS model_name;
DROP INDEX IF EXISTS idx_agent_model_name;
DROP INDEX IF EXISTS idx_agent_billing_model_name;
```
---
### 迁移 020: 修复配额字段默认值 ⚠️ 需要谨慎
**文件**: `services/mcp-server/migrations/020_fix_quota_defaults.sql`
**目的**:
- 消除配额字段的 NULL 值,避免计算错误
- 添加 NOT NULL 约束和检查约束
**操作**:
```sql
-- 1. tenant_custom_agent_quotas 表
ALTER TABLE tenant_custom_agent_quotas
ALTER COLUMN cpu_quota SET DEFAULT 0, ALTER COLUMN cpu_quota SET NOT NULL,
ALTER COLUMN memory_quota SET DEFAULT 0, ALTER COLUMN memory_quota SET NOT NULL,
ALTER COLUMN cpu_used SET DEFAULT 0, ALTER COLUMN cpu_used SET NOT NULL,
ALTER COLUMN memory_used SET DEFAULT 0, ALTER COLUMN memory_used SET NOT NULL,
ALTER COLUMN agent_count SET DEFAULT 0, ALTER COLUMN agent_count SET NOT NULL;
UPDATE tenant_custom_agent_quotas SET cpu_quota = 0 WHERE cpu_quota IS NULL;
UPDATE tenant_custom_agent_quotas SET memory_quota = 0 WHERE memory_quota IS NULL;
UPDATE tenant_custom_agent_quotas SET cpu_used = 0 WHERE cpu_used IS NULL;
UPDATE tenant_custom_agent_quotas SET memory_used = 0 WHERE memory_used IS NULL;
UPDATE tenant_custom_agent_quotas SET agent_count = 0 WHERE agent_count IS NULL;
-- 添加检查约束
ALTER TABLE tenant_custom_agent_quotas
ADD CONSTRAINT chk_tenant_cpu_used CHECK (cpu_used <= cpu_quota),
ADD CONSTRAINT chk_tenant_memory_used CHECK (memory_used <= memory_quota);
-- 2. channel_custom_agent_quotas 表(类似操作)
-- 3. platform_agent_quotas 表(类似操作)
```
**影响**:
- ⚠️ **数据修改**: 将所有 NULL 值更新为 0
- ⚠️ **约束添加**: 新增检查约束,使用量不能超过配额
**⚠️ 执行前检查**:
```sql
-- 检查生产环境是否有 NULL 值
SELECT
COUNT(*) as total_records,
COUNT(CASE WHEN cpu_quota IS NULL THEN 1 END) as null_cpu_quota,
COUNT(CASE WHEN memory_quota IS NULL THEN 1 END) as null_memory_quota
FROM tenant_custom_agent_quotas;
-- 检查是否有使用量超过配额的记录(会导致约束添加失败)
SELECT * FROM tenant_custom_agent_quotas WHERE cpu_used > cpu_quota;
SELECT * FROM tenant_custom_agent_quotas WHERE memory_used > memory_quota;
```
**回滚方案**:
```sql
-- 删除约束(如果需要)
ALTER TABLE tenant_custom_agent_quotas DROP CONSTRAINT IF EXISTS chk_tenant_cpu_used;
ALTER TABLE tenant_custom_agent_quotas DROP CONSTRAINT IF EXISTS chk_tenant_memory_used;
-- 注意:无法回滚 NOT NULL 约束和数据修改
```
---
### 迁移 021: 修复agent_type字段长度
**文件**: `services/mcp-server/migrations/021_fix_agent_type_length.sql`
**目的**: 修复 `microsoft_learn_agent`(22字符)超出 VARCHAR(20) 限制的问题
**操作**:
```sql
ALTER TABLE agent_billing_records ALTER COLUMN agent_type TYPE VARCHAR(100);
```
**影响**: 扩展字段长度,对现有数据无负面影响
**测试建议**:
```sql
-- 检查是否有被截断的数据
SELECT agent_type, LENGTH(agent_type) as len, COUNT(*)
FROM agent_billing_records
GROUP BY agent_type
ORDER BY len DESC;
```
**回滚方案**:
```sql
-- 仅在确认所有值都 ≤20 字符时才能回滚
ALTER TABLE agent_billing_records ALTER COLUMN agent_type TYPE VARCHAR(20);
```
---
### 迁移 022: 修复EU计算逻辑 ⚠️ 重要数据修正
**文件**: `services/mcp-server/migrations/022_fix_eu_equals_cost.sql`
**目的**:
- **旧逻辑**: `EU = ceil(duration_seconds / 10)`,即 1 EU = 10秒
- **新逻辑**: `EU = Cost(美元)`,即 1 EU = 1 美元
- 将历史数据的 `eu_consumed` 修正为 `cost` 的值
**操作**:
```sql
-- 1. 修改字段类型以支持小数
ALTER TABLE agent_billing_records
ALTER COLUMN eu_consumed TYPE NUMERIC(12, 4);
-- 2. 更新历史数据
UPDATE agent_billing_records
SET eu_consumed = cost
WHERE cost IS NOT NULL AND cost > 0;
UPDATE model_billing_records
SET eu_consumed = total_cost
WHERE total_cost IS NOT NULL AND total_cost > 0;
-- 3. 添加注释说明
COMMENT ON COLUMN agent_billing_records.eu_consumed IS 'EU 消耗(1 EU = 1 美元,EU = Cost)';
```
**影响**:
- ⚠️ **修改所有历史计费记录**的 `eu_consumed` 值
- 会导致历史数据的EU统计发生变化
**⚠️ 执行前备份**:
```sql
-- 备份历史数据
CREATE TABLE agent_billing_records_backup_20260312 AS
SELECT * FROM agent_billing_records;
CREATE TABLE model_billing_records_backup_20260312 AS
SELECT * FROM model_billing_records;
```
**验证**:
```sql
-- 检查 EU 和 Cost 是否一致
SELECT id, agent_name, eu_consumed, cost,
CASE WHEN ABS(eu_consumed - cost) < 0.0001 THEN 'OK' ELSE 'MISMATCH' END as status
FROM agent_billing_records
WHERE cost > 0
LIMIT 20;
```
**回滚方案**:
```sql
-- 从备份表恢复
UPDATE agent_billing_records abr
SET eu_consumed = backup.eu_consumed
FROM agent_billing_records_backup_20260312 backup
WHERE abr.id = backup.id;
```
---
### 迁移 023: 修复计费记录中缺失的channel_id
**文件**: `services/mcp-server/migrations/023_fix_billing_channel_id.sql`
**目的**: 修复计费记录创建时未正确设置 `channel_id` 的问题
**操作**:
```sql
-- 1. 更新 AgentBillingRecord
UPDATE agent_billing_records abr
SET channel_id = u.channel_id
FROM users u
WHERE abr.user_id = u.id
AND abr.channel_id IS NULL
AND u.channel_id IS NOT NULL;
-- 2. 更新 ModelBillingRecord
UPDATE model_billing_records mbr
SET channel_id = u.channel_id
FROM users u
WHERE mbr.tenant_id = u.id
AND mbr.channel_id IS NULL
AND u.channel_id IS NOT NULL;
```
**影响**: 补充缺失的 `channel_id`,提高数据完整性
**执行前检查**:
```sql
-- 检查有多少记录缺失 channel_id
SELECT
'AgentBillingRecord' as table_name,
COUNT(*) as total,
COUNT(channel_id) as with_channel,
COUNT(*) - COUNT(channel_id) as without_channel
FROM agent_billing_records
UNION ALL
SELECT
'ModelBillingRecord' as table_name,
COUNT(*) as total,
COUNT(channel_id) as with_channel,
COUNT(*) - COUNT(channel_id) as without_channel
FROM model_billing_records;
```
**回滚方案**: 无需回滚(数据修正)
---
### 迁移 024: 添加record_type字段 ⚠️ 重要业务逻辑变更
**文件**: `services/mcp-server/migrations/024_add_record_type.sql`
**目的**: 区分两种计费方式
- `vm_runtime`: VM运行时间计费(一个Agent = 一条记录,周期性更新)
- `api_call`: API调用计费(每次调用 = 一条新记录)
**操作**:
```sql
-- 1. 添加字段
ALTER TABLE agent_billing_records
ADD COLUMN IF NOT EXISTS record_type VARCHAR(20) NOT NULL DEFAULT 'vm_runtime';
-- 2. 推断现有记录类型
UPDATE agent_billing_records
SET record_type = 'api_call'
WHERE request_id IS NOT NULL
AND request_id != ''
AND end_time IS NOT NULL;
-- 3. 添加索引
CREATE INDEX IF NOT EXISTS idx_agent_billing_record_type
ON agent_billing_records(record_type);
CREATE INDEX IF NOT EXISTS idx_agent_billing_vm_runtime
ON agent_billing_records(record_type, end_time)
WHERE record_type = 'vm_runtime' AND end_time IS NULL;
```
**影响**:
- 新增业务逻辑字段
- 周期计费任务将只更新 `record_type='vm_runtime'` 的记录
**验证**:
```sql
SELECT
record_type,
COUNT(*) as count,
COUNT(CASE WHEN end_time IS NULL THEN 1 END) as running_count,
COUNT(CASE WHEN end_time IS NOT NULL THEN 1 END) as completed_count
FROM agent_billing_records
GROUP BY record_type;
```
**回滚方案**:
```sql
ALTER TABLE agent_billing_records DROP COLUMN IF EXISTS record_type;
DROP INDEX IF EXISTS idx_agent_billing_record_type;
DROP INDEX IF EXISTS idx_agent_billing_vm_runtime;
```
---
## 🚀 执行顺序和依赖关系
迁移必须按以下顺序执行(有依赖关系):
```
017 ─────┐
├──→ 可并行执行
018 ─────┘
019 ─→ 020 ─→ 021 ─→ 022 ─→ 023 ─→ 024
↑ ↑
│ │
修改配额约束 修改计费逻辑(最关键)
```
**推荐分批执行**:
- **第一批(新功能)**: 017, 018, 019
- **第二批(数据修正)**: 020, 021, 023
- **第三批(核心逻辑)**: 022, 024
---
## ⚙️ 执行步骤
### 步骤 1: 备份生产数据库 🔴 必做
```bash
# 使用项目提供的备份脚本
cd /home/taiji/tools/taiji-AI-PAD
bash scripts/backup_postgres.sh
# 或手动备份
kubectl exec -n taiji-ai-pad <postgres-pod> -- \
pg_dump -U postgres taiji_prod > backup_taiji_prod_$(date +%Y%m%d_%H%M%S).dump
```
### 步骤 2: 检查生产环境状态
```bash
# 检查数据库连接
python services/mcp-server/check_prod_database_status.py
# 检查计费健康状态
python services/mcp-server/check_billing_health.py
# 检查配额数据
python services/mcp-server/check_quota_data.py
```
### 步骤 3: 使用自动同步脚本(推荐)
```bash
# 使用交互式同步脚本
bash scripts/sync_prod_database.sh
```
该脚本会:
- ✅ 自动检查K8s集群连接
- ✅ 显示当前数据库配置
- ✅ 逐个执行迁移,每步都需要确认
- ✅ 记录执行日志
### 步骤 4: 手动执行(如果需要更细粒度控制)
```bash
# 进入mcp-server Pod
POD_NAME=$(kubectl get pods -n taiji-ai-pad -l app=mcp-server -o jsonpath='{.items[0].metadata.name}')
kubectl exec -it -n taiji-ai-pad $POD_NAME -- bash
# 切换到migrations目录
cd /app/migrations
# 执行迁移(按顺序)
python run_017_migration.py
python run_018_migration.py
python run_019_migration.py
python run_020_fix_quota_defaults.py
# ... 依次执行
```
### 步骤 5: 验证结果
```bash
# 检查表结构
kubectl exec -n taiji-ai-pad <postgres-pod> -- \
psql -U postgres -d taiji_prod -c "\d agent_billing_records"
# 检查数据完整性
kubectl exec -n taiji-ai-pad <postgres-pod> -- \
psql -U postgres -d taiji_prod -c "
SELECT
COUNT(*) as total_records,
COUNT(record_type) as with_record_type,
COUNT(model_name) as with_model_name,
COUNT(channel_id) as with_channel_id
FROM agent_billing_records;
"
# 检查EU和Cost的一致性
kubectl exec -n taiji-ai-pad <postgres-pod> -- \
psql -U postgres -d taiji_prod -c "
SELECT
COUNT(*) as total,
COUNT(CASE WHEN ABS(eu_consumed - cost) < 0.0001 THEN 1 END) as consistent,
COUNT(CASE WHEN ABS(eu_consumed - cost) >= 0.0001 THEN 1 END) as inconsistent
FROM agent_billing_records
WHERE cost > 0;
"
```
### 步骤 6: 重启服务(如果需要)
```bash
# 重启mcp-server以应用新配置
kubectl rollout restart deployment/mcp-server -n taiji-ai-pad
# 等待Pod就绪
kubectl rollout status deployment/mcp-server -n taiji-ai-pad
```
---
## ⚠️ 风险和注意事项
### 🔴 高风险操作
1. **迁移 022(EU计算逻辑修改)**
- 会修改所有历史计费记录
- 建议在业务低峰期执行
- 必须先备份
2. **迁移 020(配额约束)**
- 会添加检查约束
- 如果有数据不一致(使用量>配额),迁移会失败
- 需要先修复数据
### 🟡 中风险操作
1. **迁移 024(record_type字段)**
- 修改了计费逻辑
- 需要确保周期计费任务已更新代码
### 🟢 低风险操作
- 迁移 017, 018, 019, 021, 023
- 这些主要是新增字段/表,对现有业务无影响
---
## 🔄 回滚计划
如果迁移后发现问题,按以下步骤回滚:
### 快速回滚(恢复备份)
```bash
# 停止服务
kubectl scale deployment/mcp-server -n taiji-ai-pad --replicas=0
# 恢复数据库
kubectl exec -n taiji-ai-pad <postgres-pod> -- \
psql -U postgres -c "DROP DATABASE taiji_prod;"
kubectl exec -n taiji-ai-pad <postgres-pod> -- \
psql -U postgres -c "CREATE DATABASE taiji_prod;"
kubectl exec -i -n taiji-ai-pad <postgres-pod> -- \
psql -U postgres taiji_prod < backup_taiji_prod_20260312_HHMMSS.dump
# 重启服务
kubectl scale deployment/mcp-server -n taiji-ai-pad --replicas=1
```
### 部分回滚(单个迁移)
参考每个迁移的"回滚方案"章节,执行对应的 SQL 语句。
---
## 📊 预期影响评估
### 数据量影响
```sql
-- 评估受影响的记录数
SELECT
'agent_billing_records' as table_name,
COUNT(*) as total_records,
pg_size_pretty(pg_total_relation_size('agent_billing_records')) as table_size
FROM agent_billing_records
UNION ALL
SELECT
'model_billing_records' as table_name,
COUNT(*) as total_records,
pg_size_pretty(pg_total_relation_size('model_billing_records')) as table_size
FROM model_billing_records;
```
### 停机时间估算
- **新表创建(017, 018)**: < 1秒
- **字段添加(019, 021, 024)**: 1-5秒(取决于表大小)
- **数据修正(020, 022, 023)**: 1-10分钟(取决于记录数)
**建议总停机时间**: 15-30分钟(保守估计)
---
## ✅ 完成检查清单
执行完成后,请确认:
- [ ] 所有迁移脚本执行成功,无错误
- [ ] 新表已创建:`external_data_tools`, `external_toolkits`
- [ ] 新字段已添加:`model_name`, `record_type`
- [ ] `eu_consumed` 和 `cost` 数据一致
- [ ] `channel_id` 缺失值已修复
- [ ] 配额约束已添加且无冲突
- [ ] 服务正常启动,无报错
- [ ] 健康检查通过
- [ ] 计费任务正常运行
- [ ] 备份文件已妥善保存
---
## 📞 问题反馈
如果迁移过程中遇到问题,请检查:
1. **Pod日志**:
```bash
kubectl logs -n taiji-ai-pad deployment/mcp-server --tail=100
```
2. **数据库日志**:
```bash
kubectl logs -n taiji-ai-pad <postgres-pod> --tail=100
```
3. **运行健康检查**:
```bash
python services/mcp-server/check_billing_health.py
```
---
## 📚 相关文档
- [计费系统安全性分析](./BILLING_SECURITY_ANALYSIS.md)
- [数据库配置文档](./config/database-config.md)
- [计费系统架构文档](./Docs/项目文档/计费管理三维度接口文档.md)
---
## 📅 变更记录
| 日期 | 操作人 | 环境 | 迁移 | 结果 | 备注 |
|------|--------|------|------|------|------|
| 2026-03-09~12 | - | 测试环境 | 017-024 | ✅ 成功 | 初次执行 |
| _待填写_ | _待填写_ | 生产环境 | 017-024 | _待填写_ | _待填写_ |
---
**最后更新**: 2026-03-12
**文档版本**: v1.0
Binary file not shown.
+320
View File
@@ -0,0 +1,320 @@
#!/bin/bash
###############################################################################
# 生产环境数据库同步脚本
#
# 用途:将测试环境(taiji数据库)在3月9-12日期间的数据库迁移同步到生产环境(taiji_prod)
#
# 使用方法:
# 1. 确保已连接到生产环境K8s集群
# 2. 运行: bash scripts/sync_prod_database.sh
# 3. 按照提示逐步执行或跳过每个迁移步骤
#
# 注意事项:
# - 执行前请确保已备份生产数据库
# - 建议在业务低峰期执行
# - 每步执行后都会暂停等待确认
###############################################################################
set -e # 遇到错误立即退出
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 打印带颜色的消息
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 确认函数
confirm() {
local message=$1
local default=${2:-n}
if [[ $default == "y" ]]; then
prompt="[Y/n]"
else
prompt="[y/N]"
fi
read -p "$(echo -e ${YELLOW}$message $prompt:${NC} )" response
response=${response:-$default}
[[ "$response" =~ ^[Yy]$ ]]
}
# 获取mcp-server Pod名称
get_mcp_pod() {
kubectl get pods -n taiji-ai-pad -l app=mcp-server -o jsonpath='{.items[0].metadata.name}' 2>/dev/null
}
# 检查K8s连接
check_k8s_connection() {
print_info "检查K8s集群连接..."
if ! kubectl cluster-info &>/dev/null; then
print_error "无法连接到K8s集群,请确认kubectl配置正确"
exit 1
fi
print_success "K8s集群连接正常"
}
# 检查mcp-server pod
check_mcp_pod() {
print_info "检查mcp-server Pod..."
POD_NAME=$(get_mcp_pod)
if [[ -z "$POD_NAME" ]]; then
print_error "未找到mcp-server Pod"
exit 1
fi
print_success "找到mcp-server Pod: $POD_NAME"
}
# 显示当前数据库配置
show_database_config() {
print_info "当前数据库配置:"
kubectl exec -n taiji-ai-pad $POD_NAME -- printenv | grep DATABASE_URL || true
}
# 执行迁移
run_migration() {
local migration_name=$1
local migration_file=$2
local description=$3
echo ""
echo "========================================================================"
print_info "迁移: $migration_name"
print_info "描述: $description"
echo "========================================================================"
if ! confirm "是否执行此迁移?" "n"; then
print_warning "跳过迁移: $migration_name"
return 0
fi
print_info "开始执行迁移..."
if kubectl exec -n taiji-ai-pad $POD_NAME -- python $migration_file; then
print_success "迁移执行成功: $migration_name"
if confirm "是否继续下一个迁移?" "y"; then
return 0
else
print_warning "用户选择停止执行"
exit 0
fi
else
print_error "迁移执行失败: $migration_name"
if confirm "是否继续下一个迁移?" "n"; then
return 0
else
exit 1
fi
fi
}
# 执行数据修复脚本
run_fix_script() {
local script_name=$1
local script_file=$2
local description=$3
echo ""
echo "========================================================================"
print_info "数据修复脚本: $script_name"
print_info "描述: $description"
echo "========================================================================"
if ! confirm "是否执行此脚本?" "n"; then
print_warning "跳过脚本: $script_name"
return 0
fi
print_info "开始执行脚本..."
if kubectl exec -n taiji-ai-pad $POD_NAME -- python $script_file; then
print_success "脚本执行成功: $script_name"
if confirm "是否继续?" "y"; then
return 0
else
print_warning "用户选择停止执行"
exit 0
fi
else
print_error "脚本执行失败: $script_name"
if confirm "是否继续?" "n"; then
return 0
else
exit 1
fi
fi
}
# 主流程
main() {
echo ""
echo "========================================================================"
echo " 生产环境数据库同步脚本 (taiji -> taiji_prod) "
echo "========================================================================"
echo ""
print_warning "⚠️ 重要提醒:"
echo " 1. 此脚本将在生产环境(taiji_prod)执行数据库迁移"
echo " 2. 请确保已经备份生产数据库"
echo " 3. 建议在业务低峰期执行"
echo " 4. 每步执行前都会询问确认"
echo ""
if ! confirm "确认已阅读上述提醒并继续?" "n"; then
print_warning "用户取消操作"
exit 0
fi
# 前置检查
check_k8s_connection
check_mcp_pod
show_database_config
echo ""
print_warning "请再次确认数据库配置中使用的是 taiji_prod 数据库"
if ! confirm "确认数据库配置正确?" "n"; then
print_error "请检查数据库配置后重试"
exit 1
fi
# 备份确认
echo ""
print_warning "开始执行前,请确认已完成数据库备份"
if ! confirm "已完成数据库备份?" "n"; then
print_error "请先备份数据库"
print_info "可使用命令: bash scripts/backup_postgres.sh taiji_prod"
exit 1
fi
# 执行迁移
echo ""
print_info "==================== 第一批:新功能表创建(低风险) ===================="
run_migration \
"Migration 017 - 添加外部数据工具表" \
"migrations/run_017_migration.py" \
"创建external_data_tools表,支持用户自定义外部API工具"
run_migration \
"Migration 018 - 添加外部工具集表" \
"migrations/run_018_migration.py" \
"创建external_toolkits表,支持用户组合多个工具"
echo ""
print_info "==================== 第二批:字段新增和修改(低-中风险) ===================="
run_migration \
"Migration 019 - 添加模型名称字段" \
"migrations/run_019_migration.py" \
"为agents和agent_billing_records表添加model_name字段"
run_migration \
"Migration 020 - 修复配额字段默认值" \
"migrations/run_020_fix_quota_defaults.py" \
"修复配额表字段的默认值和NULL约束,添加检查约束"
run_migration \
"Migration 021 - 修复agent_type字段长度" \
"migrations/run_021_fix_agent_type_length.py" \
"扩展agent_type字段从VARCHAR(20)到VARCHAR(100)"
echo ""
print_info "==================== 第三批:数据修正和业务逻辑变更(中-高风险) ===================="
print_warning "⚠️ 以下迁移会修改历史数据,请确保已完成备份!"
echo ""
run_migration \
"Migration 022 - 修复EU计算逻辑" \
"migrations/run_022_fix_eu_equals_cost.py" \
"⚠️ 将EU计算方式从时间改为成本(1 EU = 1 美元),会修改所有历史记录"
run_migration \
"Migration 023 - 修复计费channel_id" \
"migrations/run_023_fix_billing_channel_id.py" \
"填充计费记录中缺失的channel_id字段"
run_migration \
"Migration 024 - 添加计费记录类型字段" \
"migrations/run_024_add_record_type.py" \
"添加record_type字段,区分VM运行计费和API调用计费"
# 数据修复脚本(可选)
echo ""
print_info "==================== 第四批:数据修复脚本(可选) ===================="
print_warning "以下脚本为可选执行,请根据生产环境实际情况决定是否需要"
run_fix_script \
"修复渠道配额记录" \
"fix_channel_quota_records.py" \
"为已使用资源但缺少配额记录的渠道创建记录"
run_fix_script \
"修复Agent资源配置" \
"fix_agent_quotas.py" \
"修复自定义Agent的K8s资源配置格式"
print_warning "注意:fix_fake_quota.py 脚本会修改配额数据,建议单独执行并充分测试"
if confirm "是否执行 fix_fake_quota.py?" "n"; then
run_fix_script \
"清理假用量数据" \
"fix_fake_quota.py" \
"重新统计真实配额使用量"
fi
# 完成
echo ""
echo "========================================================================"
print_success "所有迁移执行完成!"
echo "========================================================================"
print_info "后续步骤:"
echo " 1. 验证数据库字段和数据是否正确"
echo " 2. 执行功能测试"
echo " 3. 部署最新代码到生产环境"
echo " 4. 监控应用运行状态"
echo ""
print_info "验证命令参考:"
echo " 查看详细验证步骤,请参考:"
echo " - DATABASE_MIGRATION_QUICK_START.md(快速开始)"
echo " - PRODUCTION_DATABASE_SYNC_GUIDE.md(完整指南)"
echo ""
print_info "快速验证:"
echo " # 检查新表"
echo " kubectl exec -n taiji-ai-pad \$POD_NAME -- python -c \\"
echo " 'from database import get_db; import asyncio; \\"
echo " async def check(): \\"
echo " async for db in get_db(): \\"
echo " result = await db.execute(\"SELECT COUNT(*) FROM external_data_tools\"); \\"
echo " print(f\"external_data_tools: {result.scalar()}\"); \\"
echo " asyncio.run(check())'"
echo ""
}
# 执行主流程
main "$@"
@@ -0,0 +1,69 @@
"""
添加 record_type 列到 agent_billing_records 表
这个脚本用于数据库迁移,添加用于区分 VM 运行时间计费和 API 调用计费的字段。
"""
import asyncio
import logging
from sqlalchemy import text
from database import AsyncSessionLocal
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def add_record_type_column():
"""添加 record_type 列"""
async with AsyncSessionLocal() as db:
try:
# 检查列是否已存在
check_query = text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='agent_billing_records'
AND column_name='record_type';
""")
result = await db.execute(check_query)
exists = result.fetchone()
if exists:
logger.info("✅ record_type 列已存在,无需添加")
return
logger.info("🔧 开始添加 record_type 列...")
# 添加列(默认值为 'vm_runtime',表示现有记录都是 VM 运行时间计费)
alter_query = text("""
ALTER TABLE agent_billing_records
ADD COLUMN record_type VARCHAR(20) NOT NULL DEFAULT 'vm_runtime';
""")
await db.execute(alter_query)
await db.commit()
logger.info("✅ 成功添加 record_type 列")
logger.info(" - 列名: record_type")
logger.info(" - 类型: VARCHAR(20)")
logger.info(" - 默认值: 'vm_runtime'")
logger.info(" - 可选值: 'vm_runtime' (VM运行时间计费) 或 'api_call' (API调用计费)")
# 创建索引以提高查询性能
logger.info("🔧 创建索引...")
index_query = text("""
CREATE INDEX IF NOT EXISTS idx_agent_billing_record_type
ON agent_billing_records(record_type);
""")
await db.execute(index_query)
await db.commit()
logger.info("✅ 成功创建索引: idx_agent_billing_record_type")
except Exception as e:
logger.error(f"❌ 迁移失败: {e}")
await db.rollback()
raise
if __name__ == "__main__":
asyncio.run(add_record_type_column())
+140 -47
View File
@@ -28,6 +28,7 @@ eu_consumed 字段的值应该等于 cost 字段的值。
""" """
import math import math
import logging
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -36,6 +37,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models import User, Channel, BillingRecord, Agent, Balance, AgentBillingRecord from models import User, Channel, BillingRecord, Agent, Balance, AgentBillingRecord
logger = logging.getLogger(__name__)
# ============= EU定价配置(已废弃) ============= # ============= EU定价配置(已废弃) =============
# #
@@ -190,34 +193,32 @@ async def deduct_balance(
amount: Decimal, amount: Decimal,
db: AsyncSession, db: AsyncSession,
description: str = "消费", description: str = "消费",
auto_commit: bool = False auto_commit: bool = True # 🔧 修改:默认自动提交,确保扣款立即生效
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
""" """
扣除余额(优先扣除账户余额,不足时使用授信额度) 扣除余额(优先扣除账户余额,不足时使用授信额度)
使用行锁保护并发扣款操作,防止超扣。 🔧 安全修复:使用真正的原子操作,在一条SQL中完成余额检查和扣款,
彻底避免竞态条件。
Args: Args:
user_id: 用户ID user_id: 用户ID
amount: 扣除金额 amount: 扣除金额(自动统一精度到6位小数)
db: 数据库会话 db: 数据库会话
description: 描述 description: 描述
auto_commit: 是否自动提交(默认False,由调用者管理事务) auto_commit: 是否自动提交(默认True,确保扣款立即生效)
Returns: Returns:
(是否成功, 消息) (是否成功, 消息)
Note: Note:
默认不会 commit,由调用者统一管理事务。 修复后默认 auto_commit=True,确保扣款立即生效,避免长时间事务导致的问题。
如需独立提交,请设置 auto_commit=True。 如需批量操作,请明确设置 auto_commit=False。
""" """
# 使用 FOR UPDATE 锁定余额行,防止并发扣款 from sqlalchemy import text
balance_result = await db.execute(
select(Balance) # 🔧 修复1: 统一精度到6位小数,避免累积误差
.where(Balance.user_id == user_id) amount = amount.quantize(Decimal('0.000001'))
.with_for_update()
)
balance_obj = balance_result.scalar_one_or_none()
# 从 User 表获取授信额度 # 从 User 表获取授信额度
user_result = await db.execute( user_result = await db.execute(
@@ -228,28 +229,74 @@ async def deduct_balance(
if not user: if not user:
return False, "用户不存在" return False, "用户不存在"
if balance_obj is None:
# 如果余额记录不存在,创建一个新的(初始余额为0)
balance_obj = Balance(user_id=user_id, eu_balance=0.0)
db.add(balance_obj)
await db.flush() # 确保记录创建后再继续
balance = Decimal(str(balance_obj.eu_balance))
credit_limit = Decimal(str(user.credit_limit)) credit_limit = Decimal(str(user.credit_limit))
available = balance + credit_limit amount_str = str(amount)
credit_str = str(credit_limit)
if available < amount: # 🔧 修复2: 真正的原子操作 - 分步执行但在同一事务中
return False, f"余额不足,当前可用额度: {available}, 需要: {amount}" # Step 1: 确保余额记录存在(生成UUID)
await db.execute(
text("""
INSERT INTO balances (id, user_id, eu_balance, created_at, updated_at)
VALUES (gen_random_uuid(), :uid, 0.0, NOW(), NOW())
ON CONFLICT (user_id) DO NOTHING
"""),
{"uid": user_id}
)
# 优先扣除账户余额,允许透支到授信额度 # Step 2: 原子更新 - 只有在余额充足时才扣款
new_balance = balance - amount result = await db.execute(
balance_obj.eu_balance = float(new_balance) text("""
WITH current AS (
SELECT eu_balance FROM balances WHERE user_id = :uid FOR UPDATE
)
UPDATE balances
SET eu_balance = eu_balance - CAST(:amount AS NUMERIC(15, 6)),
updated_at = NOW()
WHERE user_id = :uid
AND (SELECT eu_balance FROM current) + CAST(:credit AS NUMERIC(15, 6)) >= CAST(:amount AS NUMERIC(15, 6))
RETURNING
eu_balance as new_balance,
eu_balance + CAST(:amount AS NUMERIC(15, 6)) as old_balance
"""),
{"uid": user_id, "amount": amount_str, "credit": credit_str}
)
row = result.fetchone()
if row is None:
# UPDATE 影响了0行,说明余额不足
# 查询当前余额用于错误信息
balance_result = await db.execute(
text("SELECT eu_balance FROM balances WHERE user_id = :uid"),
{"uid": user_id}
)
balance_row = balance_result.fetchone()
current = Decimal(str(balance_row[0])) if balance_row else Decimal("0")
available = current + credit_limit
if auto_commit:
await db.commit() # 即使失败也提交(没有实际修改)
return False, f"余额不足,当前可用: {available:.6f}, 需要: {amount:.6f}"
new_balance = Decimal(str(row[0]))
old_balance = Decimal(str(row[1]))
# 可选:自动提交 # 可选:自动提交
if auto_commit: if auto_commit:
await db.commit() await db.commit()
logger.info(
f"💰 扣款成功并已提交: user={user_id[:8]}, "
f"金额={amount:.6f}, 余额: {old_balance:.6f} → {new_balance:.6f}"
)
else:
logger.info(
f"💰 扣款成功待提交: user={user_id[:8]}, "
f"金额={amount:.6f}, 余额: {old_balance:.6f} → {new_balance:.6f}"
)
return True, f"成功扣除 {amount} 元" return True, f"成功扣除 {amount:.6f} 元,余额: {old_balance:.6f} → {new_balance:.6f}"
async def add_balance( async def add_balance(
@@ -257,27 +304,31 @@ async def add_balance(
amount: Decimal, amount: Decimal,
db: AsyncSession, db: AsyncSession,
description: str = "充值", description: str = "充值",
auto_commit: bool = False auto_commit: bool = True # 🔧 修改:默认自动提交,与deduct_balance保持一致
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
""" """
增加余额 增加余额
使用行锁保护并发充值操作,确保余额准确。 🔧 安全修复:使用原子操作,统一精度处理
Args: Args:
user_id: 用户ID user_id: 用户ID
amount: 充值金额 amount: 充值金额(自动统一精度到6位小数)
db: 数据库会话 db: 数据库会话
description: 描述 description: 描述
auto_commit: 是否自动提交(默认False,由调用者管理事务) auto_commit: 是否自动提交(默认True)
Returns: Returns:
(是否成功, 消息) (是否成功, 消息)
Note: Note:
默认不会 commit,由调用者统一管理事务。 修复后默认 auto_commit=True,与 deduct_balance 保持一致。
如需独立提交,请设置 auto_commit=True。
""" """
from sqlalchemy import text
# 🔧 修复:统一精度到6位小数
amount = amount.quantize(Decimal('0.000001'))
# 检查用户是否存在 # 检查用户是否存在
user_result = await db.execute( user_result = await db.execute(
select(User).where(User.id == user_id) select(User).where(User.id == user_id)
@@ -287,27 +338,51 @@ async def add_balance(
if not user: if not user:
return False, "用户不存在" return False, "用户不存在"
# 使用 FOR UPDATE 锁定余额行,防止并发更新 amount_str = str(amount)
balance_result = await db.execute(
select(Balance) # 🔧 修复:使用原子操作 - 分步执行
.where(Balance.user_id == user_id) # Step 1: 确保余额记录存在(生成UUID)
.with_for_update() await db.execute(
text("""
INSERT INTO balances (id, user_id, eu_balance, created_at, updated_at)
VALUES (gen_random_uuid(), :uid, 0.0, NOW(), NOW())
ON CONFLICT (user_id) DO NOTHING
"""),
{"uid": user_id}
) )
balance_obj = balance_result.scalar_one_or_none()
if balance_obj is None: # Step 2: 原子更新 - 增加余额
# 如果余额记录不存在,创建一个新的 result = await db.execute(
balance_obj = Balance(user_id=user_id, eu_balance=0.0) text("""
db.add(balance_obj) UPDATE balances
await db.flush() # 确保记录创建后再继续 SET eu_balance = eu_balance + CAST(:amount AS NUMERIC(15, 6)),
updated_at = NOW()
WHERE user_id = :uid
RETURNING
eu_balance as new_balance,
eu_balance - CAST(:amount AS NUMERIC(15, 6)) as old_balance
"""),
{"uid": user_id, "amount": amount_str}
)
old_balance = Decimal(str(balance_obj.eu_balance)) row = result.fetchone()
new_balance = old_balance + amount new_balance = Decimal(str(row[0])) if row else amount
balance_obj.eu_balance = float(new_balance) old_balance = Decimal(str(row[1])) if row else Decimal("0")
# 可选:自动提交 # 可选:自动提交
if auto_commit: if auto_commit:
await db.commit() await db.commit()
logger.info(
f"💰 充值成功并已提交: user={user_id[:8]}, "
f"金额={amount:.6f}, 余额: {old_balance:.6f} → {new_balance:.6f}"
)
else:
logger.info(
f"💰 充值成功待提交: user={user_id[:8]}, "
f"金额={amount:.6f}, 余额: {old_balance:.6f} → {new_balance:.6f}"
)
return True, f"成功充值 {amount:.6f} 元,余额: {old_balance:.6f} → {new_balance:.6f}"
return True, f"成功充值 {amount} 元,当前余额: {new_balance}" return True, f"成功充值 {amount} 元,当前余额: {new_balance}"
@@ -614,6 +689,11 @@ PLATFORM_AGENT_PRICING = {
"default": Decimal("0.10"), # 默认每小时 $0.10 "default": Decimal("0.10"), # 默认每小时 $0.10
} }
# API 调用计费配置(Agent Manager 回调)
API_CALL_PRICING = {
"per_call": Decimal("0.01"), # 每次 API 调用 $0.01(0.01 EU/call)
}
def get_platform_agent_hourly_price(template_name: str) -> Decimal: def get_platform_agent_hourly_price(template_name: str) -> Decimal:
""" """
@@ -672,6 +752,19 @@ def calculate_platform_agent_cost(
return hours * hourly_price return hours * hourly_price
def calculate_api_call_cost() -> Decimal:
"""
计算 API 调用成本(固定费用)
用于 Agent Manager 回调的 API 调用计费。
每次调用固定收费 0.001 EU(= $0.001),与运行时间无关。
Returns:
成本金额(USD):0.001
"""
return API_CALL_PRICING["per_call"]
# ============= Agent 计费记录创建 ============= # ============= Agent 计费记录创建 =============
async def create_agent_billing_record( async def create_agent_billing_record(
+74 -38
View File
@@ -48,7 +48,6 @@ logger = logging.getLogger(__name__)
# 计费周期配置 # 计费周期配置
BILLING_INTERVAL_SECONDS = 3600 # 每小时执行一次 BILLING_INTERVAL_SECONDS = 3600 # 每小时执行一次
QUOTA_CHECK_INTERVAL_HOURS = 24 # 配额一致性检查周期:每24小时一次 QUOTA_CHECK_INTERVAL_HOURS = 24 # 配额一致性检查周期:每24小时一次
VM_EU_PER_HOUR = Decimal("0.5") # VM计算 0.5 EU/hour
# 上次配额检查时间 # 上次配额检查时间
_last_quota_check: Optional[datetime] = None _last_quota_check: Optional[datetime] = None
@@ -86,12 +85,13 @@ async def stop_user_agents(user_id: str, db: AsyncSession) -> list:
stopped_agents = [] stopped_agents = []
now = datetime.utcnow() now = datetime.utcnow()
# 查询该用户所有运行中的Agent # 查询该用户所有运行中的Agent(只查询vm_runtime类型)
result = await db.execute( result = await db.execute(
select(AgentBillingRecord).where( select(AgentBillingRecord).where(
and_( and_(
AgentBillingRecord.user_id == user_id, AgentBillingRecord.user_id == user_id,
AgentBillingRecord.end_time == None AgentBillingRecord.end_time == None,
AgentBillingRecord.record_type == "vm_runtime" # 只查询VM运行时间计费
) )
) )
) )
@@ -224,9 +224,11 @@ async def update_running_agent_billing(db: AsyncSession) -> dict:
""" """
更新所有运行中 Agent 的计费记录 更新所有运行中 Agent 的计费记录
🔧 安全修复:每个Agent使用独立事务,避免批量回滚导致收入损失
功能: 功能:
1. 更新EU消耗和成本 1. 更新EU消耗和成本
2. 增量扣款 2. 增量扣款(每个Agent独立事务)
3. 余额不足时自动停止Agent 3. 余额不足时自动停止Agent
Returns: Returns:
@@ -243,37 +245,63 @@ async def update_running_agent_billing(db: AsyncSession) -> dict:
"stopped_agents": [], # 因余额不足而停止的Agent "stopped_agents": [], # 因余额不足而停止的Agent
} }
# 🔧 修复:先查询所有运行中的Agent(使用只读查询)
try: try:
# 查询所有运行中的 Agent(end_time 为空)
result = await db.execute( result = await db.execute(
select(AgentBillingRecord).where( select(AgentBillingRecord).where(
AgentBillingRecord.end_time == None and_(
AgentBillingRecord.end_time == None,
AgentBillingRecord.record_type == "vm_runtime" # 只更新VM运行时间计费
)
) )
) )
running_agents = result.scalars().all() running_agents = result.scalars().all()
now = datetime.utcnow() # 提取Agent ID列表,避免在循环中持有长时间事务
agent_records = [
{
'id': str(record.id),
'agent_name': record.agent_name,
'user_id': str(record.user_id),
'start_time': record.start_time,
'is_platform_agent': record.is_platform_agent,
'agent_type': record.agent_type,
'cpu_used': record.cpu_used,
'memory_used': record.memory_used,
'cost': record.cost or 0
}
for record in running_agents
]
except Exception as e:
logger.error(f"查询运行中Agent失败: {e}")
stats["errors"].append(f"查询错误: {str(e)}")
return stats
for record in running_agents: now = datetime.utcnow()
# 🔧 修复:为每个Agent创建独立事务
for agent_info in agent_records:
# 为每个Agent创建独立的数据库会话
async with AsyncSessionLocal() as agent_db:
try: try:
# 跳过没有开始时间的记录 # 跳过没有开始时间的记录
if record.start_time is None: if agent_info['start_time'] is None:
logger.warning(f"Agent {record.agent_name} 缺少 start_time,跳过计费") logger.warning(f"Agent {agent_info['agent_name']} 缺少 start_time,跳过计费")
stats["failed"] += 1 stats["failed"] += 1
stats["errors"].append(f"Agent {record.agent_name}: start_time 为空") stats["errors"].append(f"Agent {agent_info['agent_name']}: start_time 为空")
continue continue
# 计算从开始到现在的运行时长 # 计算从开始到现在的运行时长
duration = (now - record.start_time).total_seconds() duration = (now - agent_info['start_time']).total_seconds()
duration_seconds = int(duration) duration_seconds = int(duration)
# 计算成本 # 计算成本
if record.is_platform_agent: if agent_info['is_platform_agent']:
cost = calculate_platform_agent_cost(record.agent_type, duration_seconds) cost = calculate_platform_agent_cost(agent_info['agent_type'], duration_seconds)
stats["platform_agents"] += 1 stats["platform_agents"] += 1
else: else:
cpu_cores = _parse_cpu_to_cores(record.cpu_used) if record.cpu_used else 0.1 cpu_cores = _parse_cpu_to_cores(agent_info['cpu_used']) if agent_info['cpu_used'] else 0.1
memory_gb = _parse_memory_to_gb(record.memory_used) if record.memory_used else 0.125 memory_gb = _parse_memory_to_gb(agent_info['memory_used']) if agent_info['memory_used'] else 0.125
cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, duration_seconds) cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, duration_seconds)
stats["custom_agents"] += 1 stats["custom_agents"] += 1
@@ -281,52 +309,60 @@ async def update_running_agent_billing(db: AsyncSession) -> dict:
eu_consumed = float(cost) eu_consumed = float(cost)
# 计算本次周期需要扣除的增量 # 计算本次周期需要扣除的增量
previous_cost = Decimal(str(record.cost or 0)) previous_cost = Decimal(str(agent_info['cost']))
cost_increment = cost - previous_cost cost_increment = cost - previous_cost
# 更新记录 # 如果有增量,进行扣款(使用独立会话,自动提交)
record.duration_seconds = duration_seconds
record.eu_consumed = eu_consumed # EU = Cost(美元)
record.cost = float(cost)
# 如果有增量,进行扣款
if cost_increment > 0: if cost_increment > 0:
success, message = await deduct_balance( success, message = await deduct_balance(
str(record.user_id), cost_increment, db, agent_info['user_id'],
f"Agent周期计费: {record.agent_name}" cost_increment,
agent_db, # 使用独立会话
f"Agent周期计费: {agent_info['agent_name']}",
auto_commit=True # 🔧 关键:自动提交,确保扣款立即生效
) )
if not success: if not success:
logger.warning( logger.warning(
f"周期计费扣款失败: {message}, " f"周期计费扣款失败: {message}, "
f"用户: {record.user_id}, Agent: {record.agent_name}" f"用户: {agent_info['user_id']}, Agent: {agent_info['agent_name']}"
) )
stats["failed"] += 1
stats["errors"].append(f"{agent_info['agent_name']}: 扣款失败 - {message}")
continue # 扣款失败则跳过更新记录
# 🔧 重新查询并更新计费记录(在同一个独立会话中)
record_result = await agent_db.execute(
select(AgentBillingRecord).where(AgentBillingRecord.id == agent_info['id'])
)
record = record_result.scalar_one_or_none()
if record:
record.duration_seconds = duration_seconds
record.eu_consumed = eu_consumed # EU = Cost(美元)
record.cost = float(cost)
await agent_db.commit() # 提交记录更新
# 检查用户余额,如果透支则停止该用户的所有Agent # 检查用户余额,如果透支则停止该用户的所有Agent
balance, credit_limit, available = await get_available_balance( balance, credit_limit, available = await get_available_balance(
str(record.user_id), db agent_info['user_id'], agent_db
) )
if available < 0: if available < 0:
logger.warning( logger.warning(
f"用户 {record.user_id} 余额不足 (可用: {available}),将停止所有Agent" f"用户 {agent_info['user_id']} 余额不足 (可用: {available}),将停止所有Agent"
) )
stopped = await stop_user_agents(str(record.user_id), db) stopped = await stop_user_agents(agent_info['user_id'], agent_db)
stats["stopped_agents"].extend(stopped) stats["stopped_agents"].extend(stopped)
await agent_db.commit() # 提交停止操作
stats["processed"] += 1 stats["processed"] += 1
stats["total_eu_consumed"] += Decimal(str(eu_consumed)) stats["total_eu_consumed"] += Decimal(str(eu_consumed))
stats["total_cost"] += cost stats["total_cost"] += cost
except Exception as e: except Exception as e:
await agent_db.rollback()
stats["failed"] += 1 stats["failed"] += 1
stats["errors"].append(f"Agent {record.agent_name}: {str(e)}") stats["errors"].append(f"Agent {agent_info['agent_name']}: {str(e)}")
logger.error(f"处理 Agent {record.agent_name} 计费失败: {e}") logger.error(f"处理 Agent {agent_info['agent_name']} 计费失败: {e}")
await db.commit()
except Exception as e:
logger.error(f"周期性计费任务失败: {e}")
stats["errors"].append(f"全局错误: {str(e)}")
await db.rollback()
return stats return stats
+4 -1
View File
@@ -292,10 +292,13 @@ class ResourceController:
user = user_result.scalar_one_or_none() user = user_result.scalar_one_or_none()
user_channel_id = user.channel_id if user else None user_channel_id = user.channel_id if user else None
# ✅ 确保 agent_name 不为空字符串
agent_name = resource_id if resource_id else "unknown"
billing_record = AgentBillingRecord( billing_record = AgentBillingRecord(
user_id=user_id, user_id=user_id,
channel_id=user_channel_id, # 从用户表获取 channel_id channel_id=user_channel_id, # 从用户表获取 channel_id
agent_name=resource_id or "unknown", agent_name=agent_name, # ✅ 使用验证后的名称
agent_type="custom", # 默认为自定义 Agent agent_type="custom", # 默认为自定义 Agent
is_platform_agent=False, is_platform_agent=False,
duration_seconds=int(execution_time_ms / 1000), duration_seconds=int(execution_time_ms / 1000),
+18 -7
View File
@@ -2841,8 +2841,8 @@ async def get_running_agents_billing(
@router.get("/billing/overview", response_model=SuccessResponse) @router.get("/billing/overview", response_model=SuccessResponse)
async def get_billing_overview( async def get_billing_overview(
startTime: str = Query(...), startTime: Optional[str] = Query(None, description="开始时间(ISO 8601格式),不传则默认为当前时间前1个月"),
endTime: str = Query(...), endTime: Optional[str] = Query(None, description="结束时间(ISO 8601格式),不传则默认为当前时间"),
channelName: Optional[str] = Query(None), channelName: Optional[str] = Query(None),
tenantName: Optional[str] = Query(None), tenantName: Optional[str] = Query(None),
minCalls: Optional[int] = Query(None), minCalls: Optional[int] = Query(None),
@@ -2857,13 +2857,24 @@ async def get_billing_overview(
从 AgentBillingRecord 和 ModelBillingRecord 表查询计费数据, 从 AgentBillingRecord 和 ModelBillingRecord 表查询计费数据,
返回渠道维度、租户维度和调用记录三个维度的统计。 返回渠道维度、租户维度和调用记录三个维度的统计。
时间参数:
- 不传参数时,默认查询最近1个月(endTime=当前时间,startTime=当前时间-30天)
- 传参时按照传入的时间范围查询,格式为 ISO 8601(如:2026-02-10T09:43:48.048Z)
EU 计算规则:1 EU = 10 秒运行时间(向上取整) EU 计算规则:1 EU = 10 秒运行时间(向上取整)
""" """
_verify_read_permission(principal) _verify_read_permission(principal)
# 解析时间,移除时区信息以匹配数据库中的 naive datetime # 处理时间参数:不传则使用默认值(最近1个月)
start_dt = datetime.fromisoformat(startTime.replace("Z", "+00:00")) if endTime is None:
end_dt = datetime.fromisoformat(endTime.replace("Z", "+00:00")) end_dt = datetime.utcnow()
else:
end_dt = datetime.fromisoformat(endTime.replace("Z", "+00:00"))
if startTime is None:
start_dt = end_dt - timedelta(days=30)
else:
start_dt = datetime.fromisoformat(startTime.replace("Z", "+00:00"))
# 转换为 naive datetime(移除时区信息) # 转换为 naive datetime(移除时区信息)
if start_dt.tzinfo is not None: if start_dt.tzinfo is not None:
@@ -3048,8 +3059,8 @@ async def get_billing_overview(
"timestamp": record.start_time.isoformat() if record.start_time else None, "timestamp": record.start_time.isoformat() if record.start_time else None,
"channelName": channel_name or "无渠道", "channelName": channel_name or "无渠道",
"tenantName": user_name, "tenantName": user_name,
"agentName": record.agent_name, "agentName": record.agent_name or "unknown", # ✅ 提供默认值
"modelName": record.model_name, "modelName": record.model_name or "N/A", # ✅ 提供默认值
"duration": record.duration_seconds or 0, "duration": record.duration_seconds or 0,
"eu": record.eu_consumed or 0, "eu": record.eu_consumed or 0,
"cost": float(record.cost or 0), "cost": float(record.cost or 0),
@@ -9,13 +9,17 @@ LiteLLM Callback Webhook路由
- EU计算:基于Token数量和模型类型 - EU计算:基于Token数量和模型类型
- 公式:EU = total_tokens * MODEL_EU_RATE[model_name] - 公式:EU = total_tokens * MODEL_EU_RATE[model_name]
- 存储表:model_billing_records - 存储表:model_billing_records
- 扣款时机:立即扣款
2. Agent Manager 回调(/agent-callback) 2. Agent Manager 回调(/agent-callback)
- 数据来源:Agent Manager 的运行结束通知 - 数据来源:Agent Manager 的 API 调用通知
- EU计算:基于Pod运行时长(调用 app.billing.calculate_eu) - 计费类型:API 调用计费(record_type="api_call")
- 公式:EU = ceil(duration_seconds / 10) - 计费标准:每次调用固定 0.001 EU(= $0.001),与运行时间无关
- 公式:Cost = 0.001 USD/call,EU = Cost
- 存储表:agent_billing_records - 存储表:agent_billing_records
- 注意:周期性计费任务会预先扣款,此回调只扣除增量部分 - 扣款时机:每次 API 调用立即扣款
- 注意:这与 VM 运行时间计费(record_type="vm_runtime")不同,
VM 运行时间计费按小时费率由周期任务处理
""" """
import json import json
import logging import logging
@@ -32,6 +36,7 @@ from database import get_db
from models import ModelBillingRecord, TenantModelKey, Balance, User from models import ModelBillingRecord, TenantModelKey, Balance, User
from app.schemas import AgentManagerCallbackData, AgentManagerCallbackResponse from app.schemas import AgentManagerCallbackData, AgentManagerCallbackResponse
from app.db_utils import ensure_idempotent, atomic_eu_consume, with_retry from app.db_utils import ensure_idempotent, atomic_eu_consume, with_retry
from app.billing import calculate_api_call_cost
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/billing", tags=["计费Webhook"]) router = APIRouter(prefix="/api/v1/billing", tags=["计费Webhook"])
@@ -382,16 +387,19 @@ async def agent_manager_callback(
接收 Agent Manager 的回调数据 接收 Agent Manager 的回调数据
Agent Manager 在用户调用 agent 后返回运行时信息: Agent Manager 在用户调用 agent 后返回运行时信息:
- Pod 运行时间(必填) - Pod 运行时间(记录但不用于计费)
- 使用的工具列表(可选) - 使用的工具列表(可选)
此接口用于更新或创建 Agent 的计费记录: 此接口用于创建 Agent 的 API 调用计费记录:
- 如果存在运行中的计费记录(end_time=None),则更新该记录 - record_type="api_call"(API 调用计费)
- 如果不存在运行中的记录,则创建新记录(异常情况的兜底) - 每次调用固定费用:0.001 EU(= $0.001)
- 与运行时间无关,按调用次数计费
计费逻辑: 计费逻辑:
- 周期计费任务会对运行中的 Agent 进行增量扣款 - API 调用计费:每次调用立即扣款 0.001 EU
- 此回调负责结算最终费用,只扣除增量部分,避免重复扣款 - VM 运行时间计费(record_type="vm_runtime"):由周期计费任务处理,按小时费率计费
注意:API 调用计费与 VM 运行时间计费是两种不同的计费方式
""" """
from models import AgentBillingRecord from models import AgentBillingRecord
from sqlalchemy import and_ from sqlalchemy import and_
@@ -432,96 +440,109 @@ async def agent_manager_callback(
except Exception as e: except Exception as e:
logger.warning(f"结束时间解析失败: {e}") logger.warning(f"结束时间解析失败: {e}")
# 计算成本 # ✅ 验证必填字段,防止空值
duration_seconds = callback_data.podRunningTimeSeconds agent_name = callback_data.agentName or "unknown"
if not callback_data.agentName:
logger.warning(
f"⚠️ Agent Manager回调缺少agentName: "
f"user_id={callback_data.userId}, request_id={callback_data.requestId}"
)
# 假设是平台Agent(可以根据agent_name前缀判断) # ✅ 计算 API 调用成本:每次调用固定 0.001 EU(= $0.001)
# API 调用计费与运行时间无关,每次调用固定费用
duration_seconds = callback_data.podRunningTimeSeconds # 仅记录,不用于计费
# 假设是平台Agent(可以从agent_name前缀判断)
is_platform_agent = True is_platform_agent = True
agent_type = "platform" # 可以从agent名称中提取 agent_type = "platform" # 可以从agent名称中提取
new_cost = calculate_platform_agent_cost(agent_type, duration_seconds)
# ✅ 使用 API 调用计费函数:固定 0.001 EU/call
cost = calculate_api_call_cost()
# 调试日志 - 使用 print 确保输出
print(f"\n⭐️⭐️⭐️ Agent Manager 回调 ⭐️⭐️⭐️")
print(f"User: {callback_data.userId}")
print(f"Agent: {agent_name}")
print(f"Cost: {cost} (type: {type(cost)})")
print(f"Duration: {duration_seconds}秒")
print("⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️\n", flush=True)
logger.info(f"📝 Agent Manager 回调收到: user={callback_data.userId}, agent={agent_name}, cost={cost}, type={type(cost)}")
# EU = Cost(1 EU = 1 美元) # EU = Cost(1 EU = 1 美元)
eu_consumed = float(new_cost) eu_consumed = float(cost)
# ✅ 先查找现有的运行中计费记录(end_time == None) # ✅ 新逻辑:每次调用都创建新的 API 调用记录
existing_result = await db.execute( # 这是 API 调用计费,不是 VM 运行时间计费
select(AgentBillingRecord).where( # VM 运行时间计费由周期任务处理
and_(
AgentBillingRecord.agent_name == callback_data.agentName, billing_record = AgentBillingRecord(
AgentBillingRecord.user_id == callback_data.userId, record_type="api_call", # ✅ API 调用计费
AgentBillingRecord.end_time == None # 运行中的记录 user_id=callback_data.userId,
) channel_id=user.channel_id if user.channel_id else None,
) agent_name=agent_name,
agent_type=agent_type,
is_platform_agent=is_platform_agent,
duration_seconds=duration_seconds, # 本次调用的处理时间
request_count=1, # 这是一次调用
eu_consumed=eu_consumed,
cost=float(cost),
start_time=start_time or datetime.utcnow(),
end_time=end_time or datetime.utcnow(), # API 调用已完成
period_start=start_time or datetime.utcnow(),
period_end=end_time or datetime.utcnow(),
tools_used=callback_data.toolsUsed, # 本次调用使用的工具
request_id=callback_data.requestId, # 本次调用的唯一ID
) )
existing_record = existing_result.scalar_one_or_none()
if existing_record: db.add(billing_record)
# ✅ 更新现有记录(避免重复创建)
# ⚠️ 不在此处扣款,由周期计费(periodic_billing.py)统一处理扣款
existing_record.end_time = end_time or datetime.utcnow() # ✅ API 调用立即扣款
existing_record.duration_seconds = duration_seconds print(f"\n🔍 准备扣款: cost={cost}, type={type(cost)}, cost>0={cost > 0}", flush=True)
existing_record.eu_consumed = eu_consumed logger.info(f"🔍 准备扣款: cost={cost}, type={type(cost)}, cost>0={cost > 0}")
existing_record.cost = float(new_cost) if cost > 0:
existing_record.period_end = end_time or datetime.utcnow() print(f"✅ Cost > 0, 开始调用 deduct_balance", flush=True)
existing_record.tools_used = callback_data.toolsUsed logger.info(f"🔍 开始调用 deduct_balance: user_id={callback_data.userId}, cost={cost}")
existing_record.request_id = callback_data.requestId success, message = await deduct_balance(
callback_data.userId, cost, db,
billing_record = existing_record f"Agent API调用: {agent_name}",
auto_commit=False # 🔧 修复:与billing_record在同一事务中
logger.info(
f"📝 更新Agent计费记录(不扣款): agent={callback_data.agentName}, "
f"最终成本={new_cost},扣款由周期计费处理"
) )
print(f"✅ deduct_balance 返回: success={success}, message={message}", flush=True)
logger.info(f"🔍 deduct_balance 返回: success={success}, message={message}")
if success:
logger.info(
f"💰 Agent API调用计费成功: agent={agent_name}, "
f"request_id={callback_data.requestId}, duration={duration_seconds}秒, "
f"tools={callback_data.toolsUsed}, cost={cost}"
)
else:
logger.warning(
f"⚠️ Agent API调用扣款失败: agent={agent_name}, "
f"request_id={callback_data.requestId}, cost={cost}, 原因={message}"
)
else: else:
# ⚠️ 没有现有记录,创建新记录(异常情况的兜底)
logger.warning(
f"⚠️ 未找到运行中的计费记录,将创建新记录: "
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,
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(),
period_start=start_time or datetime.utcnow(),
period_end=end_time or datetime.utcnow(),
tools_used=callback_data.toolsUsed,
request_id=callback_data.requestId,
)
db.add(billing_record)
logger.info( logger.info(
f"📝 创建Agent计费记录(不扣款): agent={callback_data.agentName}, " f"📝 创建Agent API调用记录(费用为0): agent={agent_name}, "
f"最终成本={new_cost},扣款由周期计费处理" f"request_id={callback_data.requestId}"
) )
print(f"\n🔥 准备 commit...", flush=True)
await db.commit() await db.commit()
print(f"🔥 commit 完成!\n", flush=True)
await db.refresh(billing_record) await db.refresh(billing_record)
logger.info( logger.info(
f"✅ Agent 计费记录{'更新' if existing_record else '创建'}成功: " f"✅ Agent API调用计费记录创建成功: "
f"agent={callback_data.agentName}, duration={duration_seconds}秒, " f"agent={agent_name}, request_id={callback_data.requestId}, "
f"EU={eu_consumed}, cost={new_cost}, tools={callback_data.toolsUsed}" f"duration={duration_seconds}秒, EU={eu_consumed}, cost={cost}, "
f"tools={callback_data.toolsUsed}"
) )
return AgentManagerCallbackResponse( return AgentManagerCallbackResponse(
success=True, success=True,
message=f"Agent 计费记录{'更新' if existing_record else '创建'}成功", message="Agent API调用计费记录创建成功",
recordId=str(billing_record.id) recordId=str(billing_record.id)
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
+14 -25
View File
@@ -794,10 +794,10 @@ async def get_billing_overview(
"percentage": round(cost_comparison, 1), "percentage": round(cost_comparison, 1),
"direction": "up" if cost_comparison > 0 else "down" if cost_comparison < 0 else "stable" "direction": "up" if cost_comparison > 0 else "down" if cost_comparison < 0 else "stable"
}, },
# 余额信息 # 余额信息(1 美元 = 1 EU,cash 和 eu 返回相同的值)
"balance": { "balance": {
"eu": float(balance.eu_balance), "eu": float(balance.eu_balance),
"cash": 0 # cash_balance 字段已移除,保留字段以兼容前端 "cash": float(balance.eu_balance) # 1 美元 = 1 EU,保持前端兼容
}, },
# EU消费历史(图表数据) # EU消费历史(图表数据)
"euHistory": eu_history, "euHistory": eu_history,
@@ -1481,6 +1481,7 @@ async def deploy_agent(
# 记录计费(包含访问信息) # 记录计费(包含访问信息)
access_info = result.access_info or {} access_info = result.access_info or {}
billing_record = AgentBillingRecord( billing_record = AgentBillingRecord(
record_type="vm_runtime", # VM运行时间计费
user_id=user_id, user_id=user_id,
channel_id=channel_id, channel_id=channel_id,
agent_type=quota.template_name, agent_type=quota.template_name,
@@ -2602,6 +2603,7 @@ async def deploy_platform_agent(
# 记录计费(包含访问信息) # 记录计费(包含访问信息)
access_info = result.access_info or {} access_info = result.access_info or {}
billing_record = AgentBillingRecord( billing_record = AgentBillingRecord(
record_type="vm_runtime", # VM运行时间计费
user_id=user_id, user_id=user_id,
channel_id=channel_id, channel_id=channel_id,
agent_type=req.agentType, agent_type=req.agentType,
@@ -2789,6 +2791,7 @@ async def use_platform_agent(
# 记录计费(包含访问信息) # 记录计费(包含访问信息)
access_info = result.access_info or {} access_info = result.access_info or {}
billing_record = AgentBillingRecord( billing_record = AgentBillingRecord(
record_type="vm_runtime", # VM运行时间计费
user_id=user_id, user_id=user_id,
channel_id=channel_id, channel_id=channel_id,
agent_type=req.agentType, agent_type=req.agentType,
@@ -3386,6 +3389,7 @@ async def create_custom_agent(
# 记录计费(包含访问信息) # 记录计费(包含访问信息)
access_info = result.access_info or {} access_info = result.access_info or {}
billing_record = AgentBillingRecord( billing_record = AgentBillingRecord(
record_type="vm_runtime", # VM运行时间计费
user_id=user_id, user_id=user_id,
channel_id=channel_id, channel_id=channel_id,
agent_type=template_name, # template 默认和 name 一致 agent_type=template_name, # template 默认和 name 一致
@@ -3470,26 +3474,18 @@ async def delete_custom_agent(
user_id = principal.get("user_id") user_id = principal.get("user_id")
# 查找计费记录 # 查找计费记录(只查询vm_runtime类型)
billing_result = await db.execute( billing_result = await db.execute(
select(AgentBillingRecord).where( select(AgentBillingRecord).where(
and_( and_(
AgentBillingRecord.agent_name == name, AgentBillingRecord.agent_name == name,
AgentBillingRecord.user_id == user_id, AgentBillingRecord.user_id == user_id,
AgentBillingRecord.is_platform_agent == False, AgentBillingRecord.is_platform_agent == False,
AgentBillingRecord.end_time == None AgentBillingRecord.end_time == None,
AgentBillingRecord.record_type == "vm_runtime" # 只查询VM运行时间计费记录
) )
) )
) )
billing_record = billing_result.scalar_one_or_none()
if not billing_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"未找到 Agent {name} 或该 Agent 不属于您"
)
# ✅ Step 1: 先删除 Pod(如果失败,整个操作终止,不修改数据库)
# 从 namespace 提取完整的 agent 名称(namespace 格式如 "agent-123123123-674ddd") # 从 namespace 提取完整的 agent 名称(namespace 格式如 "agent-123123123-674ddd")
agent_full_name = name agent_full_name = name
if billing_record.namespace and billing_record.namespace.startswith("agent-"): if billing_record.namespace and billing_record.namespace.startswith("agent-"):
@@ -3532,7 +3528,8 @@ async def delete_custom_agent(
# 扣除用户余额 # 扣除用户余额
success, message = await deduct_balance( success, message = await deduct_balance(
user_id, cost, db, user_id, cost, db,
f"自定义Agent使用: {name} (运行{int(duration)}秒)" f"自定义Agent使用: {name} (运行{int(duration)}秒)",
auto_commit=False # 🔧 修复:与billing_record和quota更新在同一事务中
) )
if not success: if not success:
logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}") logger.warning(f"扣款失败: {message}, 用户: {user_id}, Agent: {name}")
@@ -3588,26 +3585,18 @@ async def scale_custom_agent_api(
user_id = principal.get("user_id") user_id = principal.get("user_id")
# 查找计费记录 # 查找计费记录(只查询vm_runtime类型)
billing_result = await db.execute( billing_result = await db.execute(
select(AgentBillingRecord).where( select(AgentBillingRecord).where(
and_( and_(
AgentBillingRecord.agent_name == name, AgentBillingRecord.agent_name == name,
AgentBillingRecord.user_id == user_id, AgentBillingRecord.user_id == user_id,
AgentBillingRecord.is_platform_agent == False, AgentBillingRecord.is_platform_agent == False,
AgentBillingRecord.end_time == None AgentBillingRecord.end_time == None,
AgentBillingRecord.record_type == "vm_runtime" # 只查询VM运行时间计费记录
) )
) )
) )
billing_record = billing_result.scalar_one_or_none()
if not billing_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"未找到 Agent {name} 或该 Agent 不属于您"
)
# 获取用户配额(使用行锁防止并发问题)
quota_result = await db.execute( quota_result = await db.execute(
select(TenantCustomAgentQuota) select(TenantCustomAgentQuota)
.where(TenantCustomAgentQuota.tenant_id == user_id) .where(TenantCustomAgentQuota.tenant_id == user_id)
-420
View File
@@ -1,420 +0,0 @@
#!/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())
-117
View File
@@ -1,117 +0,0 @@
#!/usr/bin/env python3
"""
检查配额数据是否正确写入数据库
"""
import asyncio
import sys
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
# 添加项目路径
sys.path.insert(0, '/app')
from database import engine
from models import TenantCustomAgentQuota, ChannelCustomAgentQuota, User
async def check_quota_data():
"""检查配额数据"""
async with AsyncSession(engine) as db:
print("=" * 80)
print("检查租户自定义 Agent 配额数据")
print("=" * 80)
# 查询所有租户配额
result = await db.execute(
select(TenantCustomAgentQuota)
)
tenant_quotas = result.scalars().all()
print(f"\n找到 {len(tenant_quotas)} 条租户配额记录:\n")
for quota in tenant_quotas:
# 获取租户信息
user_result = await db.execute(
select(User).where(User.id == quota.tenant_id)
)
user = user_result.scalar_one_or_none()
print(f"租户ID: {quota.tenant_id}")
print(f" - 租户名称: {user.name if user else 'N/A'}")
print(f" - 租户邮箱: {user.email if user else 'N/A'}")
print(f" - CPU 配额: {quota.cpu_quota} 核")
print(f" - 内存配额: {quota.memory_quota} GB")
print(f" - CPU 已使用: {quota.cpu_used} 核")
print(f" - 内存已使用: {quota.memory_used} GB")
print(f" - Agent 数量: {quota.agent_count}")
print(f" - CPU 剩余: {float(quota.cpu_quota) - float(quota.cpu_used)} 核")
print(f" - 内存剩余: {float(quota.memory_quota) - float(quota.memory_used)} GB")
print()
print("=" * 80)
print("检查渠道自定义 Agent 配额数据")
print("=" * 80)
# 查询所有渠道配额
result = await db.execute(
select(ChannelCustomAgentQuota)
)
channel_quotas = result.scalars().all()
print(f"\n找到 {len(channel_quotas)} 条渠道配额记录:\n")
for quota in channel_quotas:
print(f"渠道ID: {quota.channel_id}")
print(f" - CPU 配额: {quota.cpu_quota} 核")
print(f" - 内存配额: {quota.memory_quota} GB")
print(f" - CPU 已分配: {quota.cpu_allocated} 核")
print(f" - 内存已分配: {quota.memory_allocated} GB")
print(f" - CPU 剩余: {float(quota.cpu_quota) - float(quota.cpu_allocated)} 核")
print(f" - 内存剩余: {float(quota.memory_quota) - float(quota.memory_allocated)} GB")
print()
# 特别检查指定的租户ID
target_tenant_id = "b00a7b8e-9e8b-463d-9593-a3b4d0006778"
print("=" * 80)
print(f"检查目标租户: {target_tenant_id}")
print("=" * 80)
result = await db.execute(
select(TenantCustomAgentQuota).where(
TenantCustomAgentQuota.tenant_id == target_tenant_id
)
)
target_quota = result.scalar_one_or_none()
if target_quota:
print(f"\n找到配额记录:")
print(f" - CPU 配额: {target_quota.cpu_quota} 核")
print(f" - 内存配额: {target_quota.memory_quota} GB")
print(f" - CPU 已使用: {target_quota.cpu_used} 核")
print(f" - 内存已使用: {target_quota.memory_used} GB")
print(f" - Agent 数量: {target_quota.agent_count}")
print(f" - CPU 剩余: {float(target_quota.cpu_quota) - float(target_quota.cpu_used)} 核")
print(f" - 内存剩余: {float(target_quota.memory_quota) - float(target_quota.memory_used)} GB")
else:
print(f"\n❌ 未找到该租户的配额记录!")
print(f"这就是为什么会出现 '剩余: 0.00 核' 的原因")
# 检查该租户是否存在
user_result = await db.execute(
select(User).where(User.id == target_tenant_id)
)
user = user_result.scalar_one_or_none()
if user:
print(f"\n租户信息:")
print(f" - 名称: {user.name}")
print(f" - 邮箱: {user.email}")
print(f" - 角色: {user.role}")
print(f" - 渠道ID: {user.channel_id}")
else:
print(f"\n❌ 该租户不存在!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(check_quota_data())
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""修复Agent资源配置和配额记录
修复两个问题:
1. 旧的自定义Agent缺少K8s格式的资源配置
2. 租户配额使用量与实际Agent资源不匹配
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + '/../services/mcp-server')
from sqlalchemy import select, func
from database import AsyncSessionLocal
from models import Agent, TenantCustomAgentQuota
import asyncio
def parse_cpu(cpu_str):
"""解析 CPU 字符串为浮点数(核心数)"""
if not cpu_str:
return 0.0
cpu_str = str(cpu_str)
if cpu_str.endswith('m'):
return float(cpu_str[:-1]) / 1000
return float(cpu_str)
def parse_memory(mem_str):
"""解析内存字符串为浮点数(GB)"""
if not mem_str:
return 0.0
mem_str = str(mem_str)
if mem_str.endswith('Gi'):
return float(mem_str[:-2])
elif mem_str.endswith('G'):
return float(mem_str[:-1])
elif mem_str.endswith('Mi'):
return float(mem_str[:-2]) / 1024
return float(mem_str)
async def fix_agent_resources():
"""修复Agent资源配置"""
async with AsyncSessionLocal() as db:
# 查询所有自定义Agent
result = await db.execute(
select(Agent).where(Agent.type == 'custom')
)
agents = result.scalars().all()
print(f"找到 {len(agents)} 个自定义Agent")
print("=" * 60)
fixed_count = 0
for agent in agents:
# 检查是否需要修复(cpu_limit 不匹配 cpu)
current_cpu_limit = parse_cpu(agent.cpu_limit)
expected_cpu = float(agent.cpu or 0)
current_memory_limit = parse_memory(agent.memory_limit)
expected_memory = float(agent.memory or 0)
# 判断是否需要更新
needs_update = False
if abs(current_cpu_limit - expected_cpu) > 0.01:
needs_update = True
if abs(current_memory_limit - expected_memory) > 0.01:
needs_update = True
if not needs_update:
continue
print(f"\n🔧 修复Agent: {agent.name} ({agent.id})")
print(f" 所有者: {agent.owner_id}")
print(f" 当前 CPU: {agent.cpu} 核 -> limit: {agent.cpu_limit} ({current_cpu_limit} 核)")
print(f" 当前 Memory: {agent.memory} GB -> limit: {agent.memory_limit} ({current_memory_limit} GB)")
# 更新 K8s 格式资源配置
cpu_limit_k8s = f"{int(expected_cpu * 1000)}m"
cpu_request_k8s = f"{max(100, int(expected_cpu * 100))}m"
memory_limit_k8s = f"{expected_memory}Gi"
memory_request_k8s = f"{max(0.128, expected_memory * 0.25):.3f}Gi"
agent.cpu_limit = cpu_limit_k8s
agent.cpu_request = cpu_request_k8s
agent.memory_limit = memory_limit_k8s
agent.memory_request = memory_request_k8s
print(f" 新 CPU limit: {cpu_limit_k8s}, request: {cpu_request_k8s}")
print(f" 新 Memory limit: {memory_limit_k8s}, request: {memory_request_k8s}")
fixed_count += 1
if fixed_count > 0:
await db.commit()
print("\n" + "=" * 60)
print(f"✅ Agent资源配置修复完成,共修复 {fixed_count} 个Agent")
else:
print("\n" + "=" * 60)
print("✅ 所有Agent资源配置正常,无需修复")
print("=" * 60)
async def recalculate_quotas():
"""重新计算租户配额使用量"""
async with AsyncSessionLocal() as db:
# 查询所有租户配额记录
result = await db.execute(select(TenantCustomAgentQuota))
quotas = result.scalars().all()
print(f"\n找到 {len(quotas)} 个租户配额记录")
print("=" * 60)
for quota in quotas:
tenant_id = quota.tenant_id
# 查询该租户的所有Agent
agent_result = await db.execute(
select(Agent).where(
Agent.owner_id == tenant_id,
Agent.type == 'custom'
)
)
agents = agent_result.scalars().all()
# 计算实际使用量
total_cpu = 0.0
total_memory = 0.0
for agent in agents:
total_cpu += float(agent.cpu or 0)
total_memory += float(agent.memory or 0)
# 获取当前记录的使用量
current_cpu = float(quota.cpu_used or 0)
current_memory = float(quota.memory_used or 0)
# 检查是否需要更新
if abs(current_cpu - total_cpu) < 0.01 and abs(current_memory - total_memory) < 0.01:
continue
print(f"\n🔧 修复租户配额: {tenant_id}")
print(f" Agent数量: {len(agents)}")
print(f" 当前记录: CPU={current_cpu} 核, Memory={current_memory} GB")
print(f" 实际使用: CPU={total_cpu} 核, Memory={total_memory} GB")
# 更新配额使用量
quota.cpu_used = total_cpu
quota.memory_used = total_memory
quota.agent_count = len(agents)
print(f" ✅ 已更新配额使用量")
await db.commit()
print("\n" + "=" * 60)
print("✅ 配额使用量重新计算完成")
print("=" * 60)
async def main():
"""主流程"""
print("=" * 60)
print("修复Agent资源配置和配额记录")
print("=" * 60)
# 1. 修复Agent资源配置
await fix_agent_resources()
# 2. 重新计算配额使用量
await recalculate_quotas()
print("\n✅ 所有修复完成!")
if __name__ == "__main__":
try:
asyncio.run(main())
except Exception as e:
print(f"\n❌ 修复失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
@@ -1,91 +0,0 @@
#!/usr/bin/env python3
"""修复已存在的渠道配额记录
对于已经使用 customAgentResources 分配过资源的渠道,
但还没有 ChannelCustomAgentQuota 记录的,创建对应的记录。
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + '/../services/mcp-server')
from sqlalchemy import select
from database import engine, AsyncSessionLocal
from models import Channel, ChannelCustomAgentQuota
import asyncio
async def fix_channel_quotas():
"""修复渠道配额记录"""
async with AsyncSessionLocal() as db:
# 查询所有渠道
result = await db.execute(select(Channel))
channels = result.scalars().all()
print(f"找到 {len(channels)} 个渠道")
print("=" * 60)
fixed_count = 0
for channel in channels:
# 检查是否有旧格式的配额(custom_agent_cpu 或 custom_agent_memory > 0)
has_old_quota = (
(channel.custom_agent_cpu and channel.custom_agent_cpu > 0) or
(channel.custom_agent_memory and channel.custom_agent_memory > 0)
)
if not has_old_quota:
continue
# 检查是否已经有 ChannelCustomAgentQuota 记录
quota_result = await db.execute(
select(ChannelCustomAgentQuota).where(
ChannelCustomAgentQuota.channel_id == channel.id
)
)
existing_quota = quota_result.scalar_one_or_none()
if existing_quota:
print(f"✅ 渠道 {channel.name} ({channel.id}) 已有配额记录,跳过")
continue
# 创建新的配额记录
cpu_quota = float(channel.custom_agent_cpu or 0)
memory_quota = float(channel.custom_agent_memory or 0)
print(f"\n🔧 修复渠道 {channel.name} ({channel.id})")
print(f" 旧格式配额: CPU={cpu_quota}核, Memory={memory_quota}GB")
# 同时更新新格式字段
channel.custom_agent_cpu_quota = cpu_quota
channel.custom_agent_memory_quota = memory_quota
# 创建配额记录
new_quota = ChannelCustomAgentQuota(
channel_id=channel.id,
cpu_quota=cpu_quota,
memory_quota=memory_quota,
cpu_allocated=0,
memory_allocated=0,
)
db.add(new_quota)
print(f" ✅ 创建 ChannelCustomAgentQuota 记录")
fixed_count += 1
if fixed_count > 0:
await db.commit()
print("\n" + "=" * 60)
print(f"✅ 修复完成,共修复 {fixed_count} 个渠道")
else:
print("\n" + "=" * 60)
print("✅ 没有需要修复的渠道")
print("=" * 60)
if __name__ == "__main__":
try:
asyncio.run(fix_channel_quotas())
except Exception as e:
print(f"\n❌ 修复失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
-228
View File
@@ -1,228 +0,0 @@
#!/usr/bin/env python3
"""
修复脚本:清理历史假用量数据
此脚本用于清理因旧接口(/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
注意:此脚本会修改数据库,请务必先备份数据库!
"""
import asyncio
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import Agent, TenantCustomAgentQuota
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class QuotaFixer:
"""配额修复器"""
def __init__(self):
self.backup_file = Path("/app/logs/quota_backup.json")
self.report_file = Path("/app/logs/quota_fix_report.json")
async def backup_current_quota(self, db: AsyncSession) -> Dict[str, Any]:
"""备份当前quota数据"""
logger.info("开始备份当前quota数据...")
result = await db.execute(
select(TenantCustomAgentQuota)
)
quotas = result.scalars().all()
backup_data = {
"backup_time": datetime.utcnow().isoformat(),
"quotas": []
}
for quota in quotas:
backup_data["quotas"].append({
"tenant_id": str(quota.tenant_id),
"cpu_quota": float(quota.cpu_quota or 0),
"memory_quota": float(quota.memory_quota or 0),
"cpu_used": float(quota.cpu_used or 0),
"memory_used": float(quota.memory_used or 0),
"agent_count": quota.agent_count or 0
})
# 确保logs目录存在
self.backup_file.parent.mkdir(exist_ok=True)
# 保存备份
with open(self.backup_file, 'w', encoding='utf-8') as f:
json.dump(backup_data, f, indent=2, ensure_ascii=False)
logger.info(f"备份完成,已保存到: {self.backup_file}")
return backup_data
async def calculate_real_usage(self, db: AsyncSession, tenant_id: str) -> Dict[str, float]:
"""计算租户的真实资源使用量
只统计type='custom'且status='active'的Agent
"""
# 查询该租户所有运行中的自定义Agent
result = await db.execute(
select(
func.count(Agent.id).label('agent_count'),
func.sum(Agent.cpu).label('total_cpu'),
func.sum(Agent.memory).label('total_memory')
)
.where(Agent.owner_id == tenant_id)
.where(Agent.type == 'custom')
.where(Agent.status == 'active')
)
row = result.first()
real_usage = {
'agent_count': row.agent_count or 0,
'cpu_used': float(row.total_cpu or 0),
'memory_used': float(row.total_memory or 0)
}
logger.info(f"租户 {tenant_id} 真实用量: {real_usage}")
return real_usage
async def fix_tenant_quota(self, db: AsyncSession, quota: TenantCustomAgentQuota) -> Dict[str, Any]:
"""修复单个租户的quota"""
tenant_id = str(quota.tenant_id)
# 获取当前记录的用量
current_usage = {
'cpu_used': float(quota.cpu_used or 0),
'memory_used': float(quota.memory_used or 0),
'agent_count': quota.agent_count or 0
}
# 计算真实用量
real_usage = await self.calculate_real_usage(db, tenant_id)
# 计算差异
diff = {
'cpu_diff': real_usage['cpu_used'] - current_usage['cpu_used'],
'memory_diff': real_usage['memory_used'] - current_usage['memory_used'],
'count_diff': real_usage['agent_count'] - current_usage['agent_count']
}
# 更新quota
quota.cpu_used = real_usage['cpu_used']
quota.memory_used = real_usage['memory_used']
quota.agent_count = real_usage['agent_count']
await db.commit()
await db.refresh(quota)
logger.info(f"租户 {tenant_id} quota已更新: {current_usage} -> {real_usage}")
return {
'tenant_id': tenant_id,
'before': current_usage,
'after': real_usage,
'diff': diff
}
async def fix_all_quotas(self, db: AsyncSession) -> Dict[str, Any]:
"""修复所有租户的quota"""
logger.info("开始修复所有租户的quota...")
# 获取所有quota记录
result = await db.execute(
select(TenantCustomAgentQuota)
)
quotas = result.scalars().all()
logger.info(f"找到 {len(quotas)} 个租户的quota记录")
# 备份数据
backup_data = await self.backup_current_quota(db)
# 修复报告
fix_report = {
"fix_time": datetime.utcnow().isoformat(),
"total_tenants": len(quotas),
"fixed_tenants": [],
"summary": {
"total_fake_cpu": 0.0,
"total_fake_memory": 0.0,
"total_fake_agents": 0
}
}
for quota in quotas:
try:
fix_result = await self.fix_tenant_quota(db, quota)
fix_report["fixed_tenants"].append(fix_result)
# 累加假用量
fix_report["summary"]["total_fake_cpu"] += fix_result["diff"]["cpu_diff"]
fix_report["summary"]["total_fake_memory"] += fix_result["diff"]["memory_diff"]
fix_report["summary"]["total_fake_agents"] += fix_result["diff"]["count_diff"]
except Exception as e:
logger.error(f"修复租户 {quota.tenant_id} 失败: {str(e)}")
fix_report["fixed_tenants"].append({
"tenant_id": str(quota.tenant_id),
"error": str(e)
})
# 保存修复报告
with open(self.report_file, 'w', encoding='utf-8') as f:
json.dump(fix_report, f, indent=2, ensure_ascii=False)
logger.info(f"修复完成!报告已保存到: {self.report_file}")
logger.info(f"总共清理假用量: CPU {fix_report['summary']['total_fake_cpu']:.2f}核, "
f"内存 {fix_report['summary']['total_fake_memory']:.2f}GB, "
f"Agent {fix_report['summary']['total_fake_agents']}个")
return fix_report
async def run(self):
"""运行修复任务"""
logger.info("开始执行配额修复任务...")
async for db in get_db():
try:
report = await self.fix_all_quotas(db)
logger.info("配额修复任务完成!")
return report
except Exception as e:
logger.error(f"修复任务失败: {str(e)}")
raise
finally:
await db.close()
async def main():
"""主函数"""
fixer = QuotaFixer()
await fixer.run()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,52 @@
-- Migration 024: 添加 record_type 字段区分 VM 运行计费和 API 调用计费
--
-- 背景:
-- 原逻辑混淆了两种计费方式:
-- 1. VM 运行时间计费:Agent 创建时创建记录(end_time=NULL),周期任务持续更新并增量扣款
-- 2. API 调用计费:每次调用 Agent 时 Agent Manager 回调创建新记录,记录单次调用详情
--
-- 修改:
-- 添加 record_type 字段:
-- - "vm_runtime": VM 运行时间计费(一个 Agent = 一条记录,持续更新)
-- - "api_call": API 调用计费(每次调用 = 一条新记录)
--
-- 影响:
-- - 现有记录默认设为 "vm_runtime"(兼容旧逻辑)
-- - Agent Manager 回调将创建 "api_call" 类型的新记录
-- - 周期计费任务只更新 "vm_runtime" 记录
-- 1. 添加 record_type 字段
ALTER TABLE agent_billing_records
ADD COLUMN IF NOT EXISTS record_type VARCHAR(20) NOT NULL DEFAULT 'vm_runtime';
-- 2. 为现有记录设置类型(根据业务逻辑推断)
-- 如果有 request_id 且 end_time 不为空,可能是 API 调用记录
UPDATE agent_billing_records
SET record_type = 'api_call'
WHERE request_id IS NOT NULL
AND request_id != ''
AND end_time IS NOT NULL;
-- 其余记录保持为 vm_runtime(默认值已设置)
-- 3. 添加索引以优化查询性能
CREATE INDEX IF NOT EXISTS idx_agent_billing_record_type
ON agent_billing_records(record_type);
CREATE INDEX IF NOT EXISTS idx_agent_billing_vm_runtime
ON agent_billing_records(record_type, end_time)
WHERE record_type = 'vm_runtime' AND end_time IS NULL;
-- 4. 添加注释
COMMENT ON COLUMN agent_billing_records.record_type IS '计费记录类型:vm_runtime=VM运行时间计费,api_call=API调用计费';
-- 验证结果
-- SELECT
-- record_type,
-- COUNT(*) as count,
-- COUNT(CASE WHEN end_time IS NULL THEN 1 END) as running_count,
-- COUNT(CASE WHEN end_time IS NOT NULL THEN 1 END) as completed_count,
-- COUNT(request_id) as with_request_id
-- FROM agent_billing_records
-- GROUP BY record_type
-- ORDER BY record_type;
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
Migration 021: 修复 agent_type 字段长度
问题:
microsoft_learn_agent (22字符) 超出 VARCHAR(20) 限制
修改:
将 agent_billing_records.agent_type 字段从 VARCHAR(20) 扩展到 VARCHAR(100)
使用方法:
cd services/mcp-server
python migrations/run_021_fix_agent_type_length.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 sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
async def run_migration():
"""执行迁移"""
# 从环境变量获取数据库连接
database_url = os.environ.get("DATABASE_URL")
if not database_url:
print("❌ 错误:未设置 DATABASE_URL 环境变量")
return False
# 转换为异步 URL
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "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('?&')
print(f"📦 连接数据库...")
# 创建异步引擎
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
try:
async with async_session() as session:
print("✅ 数据库连接成功")
print("\n" + "="*80)
print("开始执行 Migration 021: 修复 agent_type 字段长度")
print("="*80 + "\n")
# 步骤1: 检查当前字段类型
print("📊 步骤1: 检查当前字段类型...")
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 = 'agent_type'
"""))
column_info = result.fetchone()
if column_info:
print(f" 当前字段: {column_info[0]}")
print(f" 数据类型: {column_info[1]}")
print(f" 当前长度: {column_info[2]}")
else:
print(" ⚠️ 未找到 agent_type 字段!")
return False
# 步骤2: 检查是否有超长数据
print("\n📊 步骤2: 检查是否有超长的 agent_type 值...")
result = await session.execute(text("""
SELECT DISTINCT
agent_type,
LENGTH(agent_type) as len,
COUNT(*) as count
FROM agent_billing_records
WHERE LENGTH(agent_type) > 20
GROUP BY agent_type
ORDER BY len DESC
LIMIT 10
"""))
long_types = result.fetchall()
if long_types:
print(f" ⚠️ 发现 {len(long_types)} 种超过20字符的 agent_type:")
for agent_type, length, count in long_types:
print(f" - {agent_type!r} (长度={length}, 数量={count})")
else:
print(" ✅ 没有发现超过20字符的 agent_type")
# 步骤3: 修改字段长度
print("\n🔧 步骤3: 修改字段长度...")
print(" 执行: ALTER TABLE agent_billing_records ALTER COLUMN agent_type TYPE VARCHAR(100)")
await session.execute(text("""
ALTER TABLE agent_billing_records
ALTER COLUMN agent_type TYPE VARCHAR(100)
"""))
await session.commit()
print(" ✅ 字段长度修改成功")
# 步骤4: 验证修改结果
print("\n✅ 步骤4: 验证修改结果...")
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 = 'agent_type'
"""))
column_info = result.fetchone()
if column_info:
print(f" 字段: {column_info[0]}")
print(f" 数据类型: {column_info[1]}")
print(f" 新长度: {column_info[2]}")
if column_info[2] == 100:
print(" ✅ 验证通过:字段长度已成功更新为 100")
else:
print(f" ⚠️ 字段长度为 {column_info[2]},期望为 100")
# 步骤5: 统计 agent_type 分布
print("\n📊 步骤5: 统计 agent_type 长度分布...")
result = await session.execute(text("""
SELECT
LENGTH(agent_type) as len,
COUNT(*) as count
FROM agent_billing_records
GROUP BY LENGTH(agent_type)
ORDER BY len DESC
LIMIT 10
"""))
length_distribution = result.fetchall()
if length_distribution:
print(" agent_type 长度分布(Top 10):")
for length, count in length_distribution:
print(f" 长度 {length:3d}: {count:6d} 条记录")
print("\n" + "="*80)
print("✅ Migration 021 执行完成!")
print("="*80 + "\n")
return True
except Exception as e:
print(f"\n❌ 迁移执行失败:{str(e)}")
import traceback
traceback.print_exc()
return False
finally:
await engine.dispose()
if __name__ == "__main__":
print("""
╔════════════════════════════════════════════════════════════════════════════╗
║ Migration 021: 修复 agent_type 字段长度 ║
║ ║
║ 操作:将 agent_billing_records.agent_type 从 VARCHAR(20) 扩展到 VARCHAR(100) ║
║ 风险:低(只扩展字段长度,不影响现有数据) ║
╚════════════════════════════════════════════════════════════════════════════╝
""")
success = asyncio.run(run_migration())
sys.exit(0 if success else 1)
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Migration 024: 添加 record_type 字段区分 VM 运行计费和 API 调用计费
背景:
原逻辑混淆了两种计费方式:
1. VM 运行时间计费:Agent 创建时创建记录(end_time=NULL),周期任务持续更新并增量扣款
2. API 调用计费:每次调用 Agent 时 Agent Manager 回调创建新记录,记录单次调用详情
修改:
添加 record_type 字段:
- "vm_runtime": VM 运行时间计费(一个 Agent = 一条记录,持续更新)
- "api_call": API 调用计费(每次调用 = 一条新记录)
使用方法:
cd services/mcp-server
python migrations/run_024_add_record_type.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 sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
async def run_migration():
"""执行迁移"""
# 从环境变量获取数据库连接
database_url = os.environ.get("DATABASE_URL")
if not database_url:
print("❌ 错误:未设置 DATABASE_URL 环境变量")
return False
# 转换为异步 URL
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "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('?&')
print(f"📦 连接数据库...")
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
try:
# 1. 检查字段是否已存在
print("\n🔍 检查 record_type 字段是否已存在...")
result = await session.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='agent_billing_records'
AND column_name='record_type'
"""))
if result.fetchone():
print("✅ record_type 字段已存在,跳过迁移")
return True
# 2. 检查当前记录数
print("\n📊 检查当前记录数...")
result = await session.execute(text("""
SELECT COUNT(*) as total FROM agent_billing_records
"""))
total_records = result.scalar()
print(f" 总记录数: {total_records}")
# 3. 添加 record_type 字段
print("\n🔄 添加 record_type 字段...")
await session.execute(text("""
ALTER TABLE agent_billing_records
ADD COLUMN IF NOT EXISTS record_type VARCHAR(20) NOT NULL DEFAULT 'vm_runtime'
"""))
print("✅ record_type 字段添加成功")
# 4. 为现有记录设置类型(根据业务逻辑推断)
print("\n🔄 推断并设置现有记录的类型...")
result = await session.execute(text("""
UPDATE agent_billing_records
SET record_type = 'api_call'
WHERE request_id IS NOT NULL
AND request_id != ''
AND end_time IS NOT NULL
"""))
api_call_count = result.rowcount
print(f" 标记为 api_call: {api_call_count} 条(有 request_id 且已完成)")
print(f" 保持为 vm_runtime: {total_records - api_call_count} 条")
# 5. 添加索引
print("\n🔄 创建索引...")
await session.execute(text("""
CREATE INDEX IF NOT EXISTS idx_agent_billing_record_type
ON agent_billing_records(record_type)
"""))
print("✅ 创建索引: idx_agent_billing_record_type")
await session.execute(text("""
CREATE INDEX IF NOT EXISTS idx_agent_billing_vm_runtime
ON agent_billing_records(record_type, end_time)
WHERE record_type = 'vm_runtime' AND end_time IS NULL
"""))
print("✅ 创建部分索引: idx_agent_billing_vm_runtime")
# 6. 提交事务
await session.commit()
# 7. 验证结果
print("\n📊 验证迁移结果...")
result = await session.execute(text("""
SELECT
record_type,
COUNT(*) as count,
COUNT(CASE WHEN end_time IS NULL THEN 1 END) as running_count,
COUNT(CASE WHEN end_time IS NOT NULL THEN 1 END) as completed_count,
COUNT(request_id) as with_request_id
FROM agent_billing_records
GROUP BY record_type
ORDER BY record_type
"""))
print("\n 记录类型统计:")
print(" ┌─────────────┬───────┬──────────┬────────────┬────────────┐")
print(" │ 记录类型 │ 总数 │ 运行中 │ 已完成 │ 有请求ID │")
print(" ├─────────────┼───────┼──────────┼────────────┼────────────┤")
for row in result:
print(f" │ {row[0]:<11} │ {row[1]:5} │ {row[2]:8} │ {row[3]:10} │ {row[4]:10} │")
print(" └─────────────┴───────┴──────────┴────────────┴────────────┘")
print("\n✅ 迁移成功完成!")
print("\n💡 说明:")
print(" - vm_runtime: VM 运行时间计费(周期任务持续更新)")
print(" - api_call: API 调用计费(每次调用创建新记录)")
print("\n🔧 后续步骤:")
print(" 1. 修改 billing_webhook.py: Agent Manager 回调创建 api_call 记录")
print(" 2. 修改 periodic_billing.py: 周期任务只更新 vm_runtime 记录")
print(" 3. 修改 user.py: 创建/删除 Agent 时设置正确的 record_type")
return True
except Exception as e:
await session.rollback()
print(f"\n❌ 迁移失败: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = asyncio.run(run_migration())
sys.exit(0 if success else 1)
+3
View File
@@ -1187,6 +1187,9 @@ class AgentBillingRecord(BaseModel, Base):
is_platform_agent = Column(Boolean, default=True) # 是否为平台 Agent is_platform_agent = Column(Boolean, default=True) # 是否为平台 Agent
template_name = Column(String(100), nullable=True) # 模板名称(可选) template_name = Column(String(100), nullable=True) # 模板名称(可选)
# 计费记录类型:区分 VM 运行时间计费 和 API 调用计费
record_type = Column(String(20), nullable=False, default="vm_runtime") # "vm_runtime" 或 "api_call"
# 使用量 # 使用量
duration_seconds = Column(Integer, nullable=False, default=0) # 运行时长(秒) duration_seconds = Column(Integer, nullable=False, default=0) # 运行时长(秒)
cpu_seconds = Column(sa.Float, default=0) # CPU 秒数 cpu_seconds = Column(sa.Float, default=0) # CPU 秒数