更新agent manager数据接口

This commit is contained in:
zhanggangyong
2026-01-12 13:52:38 +00:00
parent e3d2cee85a
commit cd4dea8a0d
25 changed files with 2087 additions and 1265 deletions
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
# Agent Manager 数据传递文档
> 本文档详细说明 MCP-Server 传递给 Agent Manager 的数据结构,帮助 Agent Manager 实现 Pod 启动和 Agent 功能。
---
## 一、数据传递总览
MCP-Server 向 Agent Manager 传递数据的核心场景:
| 场景 | 接口 | 数据用途 |
|------|------|---------|
| **创建 Agent** | `POST /agents` | 启动 Pod,注入配置 |
| 查询状态 | `GET /agents/{name}/status` | 获取 Pod 运行状态 |
| 删除 Agent | `DELETE /agents/{name}` | 停止并清理 Pod |
---
## 二、创建 Agent 传递的完整数据结构
### 2.1 完整请求 JSON 结构
```json
{
"name": "mysql-agent-abc12345-f8c9d2",
"template": "mysql_agent",
"config": {
"user_id": "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cpu_request": "100m",
"cpu_limit": "500m",
"memory_request": "128Mi",
"memory_limit": "512Mi",
"replicas": 1
},
"env": {
"FRAMEWORK_TYPE": "MCP",
"OPENAI_API_BASE": "https://litellm.example.com/v1",
"OPENAI_API_KEY": "sk-xxx...",
"MODEL_NAME": "gpt-4",
"LITELLM_MODEL": "gpt-4",
"ENDPOINT": "https://user-service.example.com",
"API_KEY": "user-api-key-xxx",
"MYSQL_HOST": "mysql.example.com",
"MYSQL_PORT": "3306",
"MYSQL_USER": "root",
"MYSQL_PASSWORD": "password123",
"MYSQL_DATABASE": "mydb",
"TOOLS": "[\"web_search\",\"calculator\",\"file_reader\"]"
}
}
```
---
## 三、字段详细说明
### 3.1 基础字段
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | string | ✅ | Agent 实例名称,用于唯一标识 Pod |
| `template` | string | ✅ | **模板类型**,决定启动哪个 Agent 镜像 |
| `config` | object | ❌ | 资源配置 |
| `env` | object | ❌ | 环境变量,传递所有配置信息 |
### 3.2 config 资源配置
```json
{
"user_id": "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cpu_request": "100m",
"cpu_limit": "500m",
"memory_request": "128Mi",
"memory_limit": "512Mi",
"replicas": 1
}
```
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `user_id` | string | - | **用户ID**,用于多租户隔离、计费、资源归属 |
| `cpu_request` | string | `"100m"` | CPU 请求量(K8s 格式,如 `100m` = 0.1 核)|
| `cpu_limit` | string | `"500m"` | CPU 上限 |
| `memory_request` | string | `"128Mi"` | 内存请求量 |
| `memory_limit` | string | `"512Mi"` | 内存上限 |
| `replicas` | int | `1` | 副本数量 |
### 3.3 env 环境变量(核心配置)
**环境变量是传递所有业务配置的主要方式**,Agent Manager 需要将这些变量注入到 Pod 中。
---
## 四、模板类型(template)
`template` 字段决定启动哪个 Agent 镜像,目前支持以下类型:
### 4.1 平台 Agent 模板
平台预置的 Agent,用户无需额外配置:
| template 值 | 说明 | 默认端口 |
|-------------|------|----------|
| `echo_agent` | 回声测试 Agent | 8080 |
| `chat_agent` | 对话 Agent | 8080 |
| `jina_search_agent` | Jina 搜索 Agent | 8080 |
### 4.2 数据存储模板(自定义 Agent)
连接数据库/存储的 Agent,需要用户配置连接信息:
| template 值 | 说明 | 必需环境变量 |
|-------------|------|--------------|
| `mysql_agent` | MySQL 数据库 Agent | `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` |
| `postgresql_agent` | PostgreSQL 数据库 Agent | `PG_HOST`, `PG_USER`, `PG_PASSWORD`, `PG_DATABASE` |
| `redis_agent` | Redis Agent | `REDIS_HOST`, `REDIS_PASSWORD` |
| `mongodb_agent` | MongoDB Agent | `MONGO_URI` |
---
## 五、环境变量分类详解
### 5.1 框架类型配置
```json
{
"FRAMEWORK_TYPE": "MCP"
}
```
| 变量名 | 可选值 | 说明 |
|--------|--------|------|
| `FRAMEWORK_TYPE` | `MCP`, `A2A`, `langchain` | **Agent 框架类型**,决定 Agent 的运行模式 |
### 5.2 模型/LLM 配置
当用户选择使用平台模型时,MCP-Server 会注入以下变量:
```json
{
"OPENAI_API_BASE": "https://litellm.example.com/v1",
"OPENAI_API_KEY": "sk-litellm-xxx",
"MODEL_NAME": "gpt-4",
"LITELLM_MODEL": "gpt-4"
}
```
| 变量名 | 说明 |
|--------|------|
| `OPENAI_API_BASE` | LiteLLM 网关地址(OpenAI 兼容) |
| `OPENAI_API_KEY` | 租户的 LiteLLM API Key(已加密后传递解密值) |
| `MODEL_NAME` | 模型名称 |
| `LITELLM_MODEL` | LiteLLM 模型标识 |
### 5.3 用户自定义端点配置
```json
{
"ENDPOINT": "https://user-service.example.com/api",
"API_KEY": "user-own-api-key"
}
```
| 变量名 | 说明 |
|--------|------|
| `ENDPOINT` | 用户自定义的服务端点 |
| `API_KEY` | 用户自己的 API 密钥 |
### 5.4 数据库连接配置
根据选择的数据存储模板,传递相应的连接信息:
#### MySQL 配置
```json
{
"MYSQL_HOST": "mysql.example.com",
"MYSQL_PORT": "3306",
"MYSQL_USER": "root",
"MYSQL_PASSWORD": "password123",
"MYSQL_DATABASE": "mydb",
"MYSQL_CHARSET": "utf8mb4"
}
```
#### PostgreSQL 配置
```json
{
"PG_HOST": "postgres.example.com",
"PG_PORT": "5432",
"PG_USER": "postgres",
"PG_PASSWORD": "password123",
"PG_DATABASE": "mydb"
}
```
#### Redis 配置
```json
{
"REDIS_HOST": "redis.example.com",
"REDIS_PORT": "6379",
"REDIS_PASSWORD": "password123",
"REDIS_DB": "0"
}
```
#### MongoDB 配置
```json
{
"MONGO_URI": "mongodb://user:password@mongo.example.com:27017/mydb"
}
```
### 5.5 工具配置(Tools)
用户选择的工具列表会以 JSON 字符串形式传递:
```json
{
"TOOLS": "[\"web_search\",\"calculator\",\"file_reader\",\"code_executor\"]"
}
```
| 变量名 | 类型 | 说明 |
|--------|------|------|
| `TOOLS` | JSON string | 工具ID列表的 JSON 字符串 |
**Agent Manager 需要解析此字段并启用相应的工具功能。**
---
## 六、完整场景示例
### 6.1 部署平台 Agent(最简单)
用户部署一个平台预置的 echo_agent:
```json
{
"name": "echo-agent-abc12345-f8c9d2",
"template": "echo_agent",
"config": {
"user_id": "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cpu_request": "100m",
"cpu_limit": "500m",
"memory_request": "128Mi",
"memory_limit": "512Mi",
"replicas": 1
}
}
```
### 6.2 部署带模型的平台 Agent
用户选择了 gpt-4 模型:
```json
{
"name": "chat-agent-abc12345-a1b2c3",
"template": "chat_agent",
"config": {
"user_id": "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cpu_request": "200m",
"cpu_limit": "1000m",
"memory_request": "256Mi",
"memory_limit": "1Gi",
"replicas": 1
},
"env": {
"OPENAI_API_BASE": "https://litellm.taiji.com/v1",
"OPENAI_API_KEY": "sk-xxx-decrypted-key",
"MODEL_NAME": "gpt-4",
"LITELLM_MODEL": "gpt-4"
}
}
```
### 6.3 部署 MySQL 数据存储 Agent
用户创建连接 MySQL 数据库的 Agent:
```json
{
"name": "mysql-agent-abc12345-d4e5f6",
"template": "mysql_agent",
"config": {
"user_id": "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cpu_request": "100m",
"cpu_limit": "500m",
"memory_request": "128Mi",
"memory_limit": "512Mi",
"replicas": 1
},
"env": {
"FRAMEWORK_TYPE": "MCP",
"MYSQL_HOST": "rm-xxx.mysql.rds.aliyuncs.com",
"MYSQL_PORT": "3306",
"MYSQL_USER": "db_user",
"MYSQL_PASSWORD": "db_password_123",
"MYSQL_DATABASE": "production_db",
"OPENAI_API_BASE": "https://litellm.taiji.com/v1",
"OPENAI_API_KEY": "sk-xxx-decrypted-key",
"MODEL_NAME": "gpt-4",
"LITELLM_MODEL": "gpt-4"
}
}
```
### 6.4 部署带工具的自定义 Agent
用户创建自定义 Agent 并选择了多个工具:
```json
{
"name": "my-assistant-abc12345-g7h8i9",
"template": "langchain_agent",
"config": {
"user_id": "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cpu_request": "500m",
"cpu_limit": "2000m",
"memory_request": "512Mi",
"memory_limit": "2Gi",
"replicas": 1
},
"env": {
"FRAMEWORK_TYPE": "langchain",
"ENDPOINT": "https://my-backend.example.com/api",
"API_KEY": "my-own-api-key-xxx",
"OPENAI_API_BASE": "https://litellm.taiji.com/v1",
"OPENAI_API_KEY": "sk-xxx-decrypted-key",
"MODEL_NAME": "gpt-4-turbo",
"LITELLM_MODEL": "gpt-4-turbo",
"TOOLS": "[\"web_search\",\"calculator\",\"code_executor\",\"file_reader\"]"
}
}
```
### 6.5 部署 A2A 框架 Agent
用户选择 A2A 框架:
```json
{
"name": "a2a-agent-abc12345-j0k1l2",
"template": "a2a_agent",
"config": {
"user_id": "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cpu_request": "200m",
"cpu_limit": "1000m",
"memory_request": "256Mi",
"memory_limit": "1Gi",
"replicas": 1
},
"env": {
"FRAMEWORK_TYPE": "A2A",
"OPENAI_API_BASE": "https://litellm.taiji.com/v1",
"OPENAI_API_KEY": "sk-xxx-decrypted-key",
"MODEL_NAME": "claude-3-sonnet",
"LITELLM_MODEL": "claude-3-sonnet",
"TOOLS": "[\"document_qa\",\"web_browser\"]"
}
}
```
---
## 七、环境变量汇总表
| 变量名 | 来源 | 是否敏感 | 说明 |
|--------|------|----------|------|
| `FRAMEWORK_TYPE` | 用户选择 | ❌ | 框架类型:`MCP`/`A2A`/`langchain` |
| `OPENAI_API_BASE` | 系统配置 | ❌ | LiteLLM 网关地址 |
| `OPENAI_API_KEY` | 系统解密 | ✅ | LiteLLM API Key |
| `MODEL_NAME` | 用户选择 | ❌ | 模型名称 |
| `LITELLM_MODEL` | 用户选择 | ❌ | LiteLLM 模型标识 |
| `ENDPOINT` | 用户配置 | ❌ | 用户自定义服务端点 |
| `API_KEY` | 用户配置 | ✅ | 用户自己的 API 密钥 |
| `TOOLS` | 用户选择 | ❌ | 工具列表(JSON 字符串) |
| `MYSQL_HOST` | 用户配置 | ❌ | MySQL 主机地址 |
| `MYSQL_PORT` | 用户配置 | ❌ | MySQL 端口(默认 3306) |
| `MYSQL_USER` | 用户配置 | ❌ | MySQL 用户名 |
| `MYSQL_PASSWORD` | 用户配置 | ✅ | MySQL 密码 |
| `MYSQL_DATABASE` | 用户配置 | ❌ | MySQL 数据库名 |
| `PG_HOST` | 用户配置 | ❌ | PostgreSQL 主机地址 |
| `PG_PORT` | 用户配置 | ❌ | PostgreSQL 端口(默认 5432) |
| `PG_USER` | 用户配置 | ❌ | PostgreSQL 用户名 |
| `PG_PASSWORD` | 用户配置 | ✅ | PostgreSQL 密码 |
| `PG_DATABASE` | 用户配置 | ❌ | PostgreSQL 数据库名 |
| `REDIS_HOST` | 用户配置 | ❌ | Redis 主机地址 |
| `REDIS_PORT` | 用户配置 | ❌ | Redis 端口(默认 6379) |
| `REDIS_PASSWORD` | 用户配置 | ✅ | Redis 密码 |
| `REDIS_DB` | 用户配置 | ❌ | Redis 数据库索引 |
| `MONGO_URI` | 用户配置 | ✅ | MongoDB 连接 URI |
---
## 八、Agent Manager 需要实现的功能
### 8.1 创建 Pod 时
1. **解析 template** - 根据 template 字段选择对应的镜像
2. **应用资源配置** - 使用 config 中的 CPU/内存配置创建 Pod
3. **注入环境变量** - 将 env 中的所有键值对注入到 Pod 环境变量
4. **创建 Service** - 为 Pod 创建对应的 K8s Service
### 8.2 处理敏感变量
以下变量包含敏感信息,建议使用 K8s Secret 存储:
- `OPENAI_API_KEY`
- `API_KEY`
- `MYSQL_PASSWORD`
- `PG_PASSWORD`
- `REDIS_PASSWORD`
- `MONGO_URI`
### 8.3 工具功能实现
当 `TOOLS` 环境变量存在时:
1. 解析 JSON 字符串获取工具 ID 列表
2. 根据工具 ID 加载对应的工具实现
3. 在 Agent 运行时启用这些工具
---
## 九、期望的响应格式
### 9.1 创建成功响应
```json
{
"name": "mysql-agent-abc12345-d4e5f6",
"namespace": "ai-agents",
"status": "Pending",
"created_at": "2026-01-12T10:00:00Z",
"template": "mysql_agent",
"service_port": 8080,
"access_info": {
"url": "http://mysql-agent-abc12345-d4e5f6.ai-agents.svc.cluster.local:8080"
},
"pod_id": "pod-xxx",
"pod_ip": null,
"host_ip": null,
"node_name": null
}
```
### 9.2 状态查询响应
```json
{
"name": "mysql-agent-abc12345-d4e5f6",
"namespace": "ai-agents",
"status": "Running",
"health_status": "healthy",
"created_at": "2026-01-12T10:00:00Z",
"pod_ip": "10.244.1.100",
"host_ip": "192.168.1.10",
"node_name": "node-1",
"labels": {
"template": "mysql_agent",
"user_id": "abc12345"
},
"service_port": 8080,
"access_url": "http://mysql-agent-abc12345-d4e5f6.ai-agents.svc.cluster.local:8080",
"containers": [
{
"name": "agent",
"ready": true,
"restart_count": 0,
"state": "running"
}
],
"resources": {
"requests": {"cpu": "100m", "memory": "128Mi"},
"limits": {"cpu": "500m", "memory": "512Mi"}
}
}
```
---
## 十、回调 MCP-Server 计费
Agent Manager 在 Agent 运行期间需要定期回调 MCP-Server 上报使用情况。
### 回调地址
```
POST http://mcp-server:8000/callback/agent-manager
```
### 回调数据
```json
{
"agentName": "mysql-agent-abc12345-d4e5f6",
"userId": "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"podRunningTimeSeconds": 3600,
"toolsUsed": ["web_search", "calculator"],
"startTime": "2026-01-12T10:00:00Z",
"endTime": "2026-01-12T11:00:00Z",
"requestId": "req-xxx-123"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `agentName` | ✅ | Agent 名称 |
| `userId` | ✅ | 用户 ID |
| `podRunningTimeSeconds` | ✅ | Pod 运行时间(秒) |
| `toolsUsed` | ❌ | 实际使用的工具列表 |
| `startTime` | ❌ | 开始时间 |
| `endTime` | ❌ | 结束时间 |
| `requestId` | ❌ | 请求 ID |
---
## 十一、API 接口列表
| 接口 | 方法 | 说明 |
|------|------|------|
| `POST /agents` | POST | 创建 Agent(启动 Pod) |
| `GET /agents` | GET | 获取所有 Agent 列表 |
| `GET /agents/{name}/status` | GET | 获取 Agent 详细状态 |
| `GET /agents/{name}/metrics` | GET | 获取资源使用情况 |
| `DELETE /agents/{name}` | DELETE | 删除 Agent(停止 Pod) |
| `GET /templates` | GET | 获取所有模板 |
| `GET /templates/platform` | GET | 获取平台模板 |
| `GET /templates/custom` | GET | 获取自定义模板 |
| `GET /` | GET | 健康检查 |
---
## 十二、模板配置建议
Agent Manager 应维护一个模板配置,例如:
```yaml
templates:
platform:
echo_agent:
image: "registry.example.com/agents/echo-agent:v1"
port: 8080
env_info: {}
chat_agent:
image: "registry.example.com/agents/chat-agent:v1"
port: 8080
env_info: {}
custom:
mysql_agent:
image: "registry.example.com/agents/mysql-agent:v1"
port: 8080
env_info:
required:
- MYSQL_HOST
- MYSQL_USER
- MYSQL_PASSWORD
- MYSQL_DATABASE
optional:
- MYSQL_PORT
- MYSQL_CHARSET
postgresql_agent:
image: "registry.example.com/agents/postgresql-agent:v1"
port: 8080
env_info:
required:
- PG_HOST
- PG_USER
- PG_PASSWORD
- PG_DATABASE
optional:
- PG_PORT
langchain_agent:
image: "registry.example.com/agents/langchain-agent:v1"
port: 8080
env_info:
required:
- FRAMEWORK_TYPE
optional:
- TOOLS
- ENDPOINT
- API_KEY
a2a_agent:
image: "registry.example.com/agents/a2a-agent:v1"
port: 8080
env_info:
required:
- FRAMEWORK_TYPE
optional:
- TOOLS
```
---
**文档版本:** v2.0
**最后更新:** 2026-01-12
**重点更新:** 详细说明传递的数据结构,增加工具和数据存储模板配置
@@ -0,0 +1,396 @@
# 🔴 后端接口对照与待实现清单
## 快速概览
| 状态 | 数量 | 说明 |
|------|------|------|
| ✅ 已实现(路径匹配) | 2 | 可直接使用 |
| ⚠️ 已实现(路径不同) | 4 | 需前端调整调用方式 |
| ❌ 待实现 | 1 | 需后端新增 |
---
## ✅ 完全匹配的接口(2个)
### 1. 获取角色权限列表
| 项目 | 内容 |
|------|------|
| **接口路径** | `GET /api/admin/roles` |
| **功能** | 获取系统中所有可用的角色类型及其权限描述 |
| **权限** | 所有管理员(需认证) |
**请求参数**: 无
**响应示例**:
```json
{
"success": true,
"data": {
"roles": [
{
"id": "super_admin",
"name": "超级管理员",
"description": "拥有系统所有权限",
"permissions": ["*"]
},
{
"id": "billing_admin",
"name": "计费管理员",
"description": "完整写入权限,可创建渠道、管理租户、计费操作",
"permissions": ["read:*", "write:channels", "write:tenants", "write:billing"]
},
{
"id": "operations_admin",
"name": "运维管理员",
"description": "只读权限,仅查看和监控",
"permissions": ["read:*"]
},
{
"id": "channel_admin",
"name": "渠道管理员",
"description": "渠道内部管理权限",
"permissions": ["read:channel", "write:tenants", "read:billing"]
},
{
"id": "user",
"name": "普通用户",
"description": "标准用户权限",
"permissions": ["read:self", "use:agents"]
}
]
}
}
```
---
### 2. 渠道资源管理
| 项目 | 内容 |
|------|------|
| **接口路径** | `PUT /api/admin/channels/{channel_id}/resources` |
| **功能** | 统一管理渠道资源(模型和Agent配额) |
| **权限** | super_admin, billing_admin |
**路径参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| channel_id | string (UUID) | ✅ | 渠道ID |
**请求体**:
```json
{
"models": ["供应商ID或模型名称", "taiji/gpt-4o"],
"agents": [
{
"templateName": "echo_agent",
"podQuota": 5
}
]
}
```
**响应示例**:
```json
{
"success": true,
"data": {
"channelId": "uuid",
"channelName": "渠道名称",
"allocatedModels": ["gpt-4o", "gpt-3.5-turbo"],
"allocatedAgents": [
{
"templateName": "echo_agent",
"podQuota": 5
}
]
},
"message": "渠道资源分配成功"
}
```
---
## ⚠️ 已实现但路径不同的接口(4个)
> **说明**: 以下接口功能已实现,但路径或参数格式与前端期望不同,需要前端调整调用方式。
---
### 1. 渠道租户列表
| 项目 | 前端期望 | 后端实际 |
|------|---------|---------|
| **路径** | `GET /api/admin/channels/{channelId}/tenants` | `GET /api/admin/tenants?channel_id={channelId}` |
| **差异** | 路径参数 | 查询参数 |
| 项目 | 内容 |
|------|------|
| **接口路径** | `GET /api/admin/tenants` |
| **功能** | 获取指定渠道下的租户列表 |
| **权限** | 所有管理员(非super_admin只能查看自己渠道) |
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| channel_id | string (UUID) | ✅ | 渠道ID(查询参数) |
| status | string | ❌ | 筛选状态:active / inactive / suspended |
**请求示例**:
```
GET /api/admin/tenants?channel_id=550e8400-e29b-41d4-a716-446655440000&status=active
```
**响应示例**:
```json
{
"success": true,
"data": {
"tenants": [
{
"id": "uuid",
"name": "租户名称",
"email": "tenant@example.com",
"channelId": "uuid",
"channelName": "渠道名称",
"subscriptionTier": "standard",
"balance": 100.50,
"creditLimit": 500.00,
"status": "active",
"permissions": ["use:platform_agents", "read:billing"],
"createdAt": "2026-01-12T10:00:00Z"
}
],
"total": 1,
"channelId": "uuid",
"channelName": "渠道名称"
}
}
```
---
### 2. 平台 Agent 模板配置更新
| 项目 | 前端期望 | 后端实际 |
|------|---------|---------|
| **路径** | `PUT /api/admin/agent-templates/{agentId}/config` | `PUT /api/admin/platform-agents/templates/{template_name}/config` |
| **差异** | 使用 agentId | 使用 template_name |
| 项目 | 内容 |
|------|------|
| **接口路径** | `PUT /api/admin/platform-agents/templates/{template_name}/config` |
| **功能** | 配置平台 Agent 模板的资源限制和参数 |
| **权限** | admin, super_admin |
**路径参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| template_name | string | ✅ | 模板名称(如 echo_agent, chat_agent) |
**请求体**:
```json
{
"cpuRequest": "100m",
"cpuLimit": "500m",
"memoryRequest": "128Mi",
"memoryLimit": "512Mi",
"maxPods": 100,
"isEnabled": true,
"displayName": "Echo 测试服务",
"description": "简单的 Echo 服务,用于测试和调试"
}
```
**请求字段说明**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| cpuRequest | string | ❌ | CPU 请求量,如 "100m" |
| cpuLimit | string | ❌ | CPU 限制量,如 "500m" |
| memoryRequest | string | ❌ | 内存请求量,如 "128Mi" |
| memoryLimit | string | ❌ | 内存限制量,如 "512Mi" |
| maxPods | int | ❌ | 最大 Pod 数量 |
| isEnabled | bool | ❌ | 是否启用,默认 true |
| displayName | string | ❌ | 显示名称,最长200字符 |
| description | string | ❌ | 描述信息 |
**响应示例**:
```json
{
"templateName": "echo_agent",
"cpuRequest": "100m",
"cpuLimit": "500m",
"memoryRequest": "128Mi",
"memoryLimit": "512Mi",
"maxPods": 100,
"isEnabled": true,
"displayName": "Echo 测试服务",
"description": "简单的 Echo 服务,用于测试和调试",
"configuredAt": "2026-01-12T10:00:00Z",
"configuredBy": "uuid"
}
```
> **注意**: 如果是自定义 Agent 的配置,使用 `PUT /api/admin/resources/agents/{agent_id}/config`
---
### 3. 平台 Agent 配额分配
| 项目 | 前端期望 | 后端实际 |
|------|---------|---------|
| **路径** | `POST /api/admin/channels/{channelId}/allocate-agents` | `POST /api/admin/platform-agents/allocate?channel_id&template_name&pod_quota` |
| **差异** | 请求体传参 | 查询参数传参 |
| 项目 | 内容 |
|------|------|
| **接口路径** | `POST /api/admin/platform-agents/allocate` |
| **功能** | 直接给渠道分配平台 Agent 配额(无需申请审批) |
| **权限** | super_admin, billing_admin |
**请求参数(Query Parameters)**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| channel_id | string (UUID) | ✅ | 渠道 ID |
| template_name | string | ✅ | 模板名称(如 echo_agent) |
| pod_quota | int | ✅ | Pod 配额(≥1) |
**请求示例**:
```
POST /api/admin/platform-agents/allocate?channel_id=uuid&template_name=echo_agent&pod_quota=5
```
**响应示例**:
```json
{
"success": true,
"data": {
"channelId": "uuid",
"channelName": "渠道名称",
"templateName": "echo_agent",
"templateDisplayName": "Echo 测试服务",
"podQuota": 5
},
"message": "平台 Agent 配额分配成功"
}
```
---
### 4. 更新租户权限
| 项目 | 前端期望 | 后端实际 |
|------|---------|---------|
| **路径** | `PUT /api/admin/tenants/{tenantId}/permissions` | `PUT /api/channel/tenants/{tenant_id}/permissions` |
| **差异** | `/api/admin/` 前缀 | `/api/channel/` 前缀 |
| 项目 | 内容 |
|------|------|
| **接口路径** | `PUT /api/channel/tenants/{tenant_id}/permissions` |
| **功能** | 更新租户的功能权限 |
| **权限** | channel_admin, billing_admin, super_admin |
**路径参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| tenant_id | string (UUID) | ✅ | 租户ID |
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| channel_id | string (UUID) | ⚠️ | 渠道ID(超级管理员必填,其他角色自动使用所属渠道) |
**请求体**:
```json
{
"permissions": ["use:platform_agents", "use:custom_agents", "read:billing"]
}
```
**可用权限列表**:
| 权限 | 说明 |
|------|------|
| `use:platform_agents` | 使用平台 Agent |
| `use:custom_agents` | 使用自定义 Agent |
| `create:agents` | 创建 Agent |
| `read:billing` | 查看计费信息 |
| `export:data` | 导出数据 |
**响应示例**:
```json
{
"success": true,
"data": {
"tenantId": "uuid",
"name": "租户名称",
"permissions": ["use:platform_agents", "use:custom_agents", "read:billing"]
},
"message": "租户权限已更新"
}
```
---
## ❌ 待实现的接口(1个)
### 更新角色权限
| 项目 | 内容 |
|------|------|
| **接口路径** | `PUT /api/admin/roles/{roleId}` |
| **功能** | 动态修改角色的权限配置 |
| **状态** | ❌ 未实现(当前角色为硬编码静态数据) |
| **优先级** | 🟡 P2(暂不实现) |
**说明**:
- 当前系统的角色(super_admin、billing_admin、operations_admin 等)是硬编码的
- 如需动态管理角色权限,需要:
1. 创建 Role 数据库模型
2. 实现角色 CRUD 接口
3. 修改权限验证逻辑从数据库读取
**暂不实现原因**: 角色体系相对固定,动态修改需求不紧急
---
## 📋 自定义 Agent vs 平台 Agent 接口区分
> ⚠️ **重要**: 前端需要区分两类 Agent 的接口
### 平台 Agent(Platform Agent)
由平台统一提供的标准 Agent 模板,渠道申请配额后使用。
| 功能 | 接口 |
|------|------|
| 获取模板列表 | `GET /api/admin/platform-agents/templates` |
| 配置模板 | `PUT /api/admin/platform-agents/templates/{template_name}/config` |
| 分配配额给渠道 | `POST /api/admin/platform-agents/allocate` |
| 撤销渠道配额 | `DELETE /api/admin/platform-agents/allocate` |
| 查看配额分配 | `GET /api/admin/platform-agents/allocations` |
### 自定义 Agent(Custom Agent)
渠道/租户自己创建的 Agent 资源。
| 功能 | 接口 |
|------|------|
| 获取 Agent 列表 | `GET /api/admin/resources/agents` |
| 更新 Agent 配置 | `PUT /api/admin/resources/agents/{agent_id}/config` |
| 删除 Agent | `DELETE /api/admin/resources/agents/{agent_id}` |
---
## 🔗 前端调整建议
### 需要修改的 Hook 调用
| Hook | 原调用 | 修改为 |
|------|--------|--------|
| `useChannels.ts` (loadChannelTenants) | `GET /api/admin/channels/{id}/tenants` | `GET /api/admin/tenants?channel_id={id}` |
| `useResources.ts` (saveAgentConfig) | `PUT /api/admin/agent-templates/{id}/config` | `PUT /api/admin/platform-agents/templates/{name}/config` |
| `useResources.ts` (allocateResources) | `POST /api/admin/channels/{id}/allocate-agents` | `POST /api/admin/platform-agents/allocate?params` |
| `useSettings.ts` (updateTenantPermission) | `PUT /api/admin/tenants/{id}/permissions` | `PUT /api/channel/tenants/{id}/permissions` |
---
**最后更新**: 2026-01-12
**文档版本**: 2.0
**维护者**: AI Assistant
+3 -3
View File
@@ -89,9 +89,9 @@ services:
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-sk-1f06b8f0d2e34c9b8a9f3d75a1c4e9b7-7e3a2c6bd9f441d8}
- AGENT_MANAGER_URL=${AGENT_MANAGER_URL:-http://host.docker.internal:8000}
- ENVIRONMENT=production
- ENABLE_TEST_MODE=true
- SMTP_SERVER=${SMTP_SERVER:-smtp.office365.com}
- SMTP_PORT=${SMTP_PORT:-587}
- ENABLE_TEST_MODE=false
- SMTP_SERVER=${SMTP_SERVER:-smtpdm-ap-southeast-1.aliyun.com}
- SMTP_PORT=${SMTP_PORT:-465}
- SMTP_EMAIL=${SMTP_EMAIL:-supportagnet@taijiaicloud.com}
- SMTP_PASSWORD=${SMTP_PASSWORD:-l5YYL7TOK2WvRKtf}
extra_hosts:
+29
View File
@@ -31,12 +31,41 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
创建访问令牌 (Access Token)
默认有效期:根据配置 jwt_expire_minutes(通常为 24 小时)
"""
to_encode = data.copy()
now = datetime.utcnow()
expire = now + (expires_delta or timedelta(minutes=settings.jwt_expire_minutes))
to_encode.update({
"exp": expire,
"iat": now, # 添加签发时间,用于登出验证
"type": "access", # 标识 token 类型
})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
return encoded_jwt
# Refresh Token 有效期:7 天
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
创建刷新令牌 (Refresh Token)
默认有效期:7 天,比 Access Token 更长
Refresh Token 仅用于获取新的 Access Token
"""
to_encode = data.copy()
now = datetime.utcnow()
expire = now + (expires_delta or timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS))
to_encode.update({
"exp": expire,
"iat": now,
"type": "refresh", # 标识 token 类型
})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
return encoded_jwt
+40 -18
View File
@@ -137,23 +137,33 @@ async def deduct_balance(
user_id: str,
amount: Decimal,
db: AsyncSession,
description: str = "消费"
description: str = "消费",
auto_commit: bool = False
) -> Tuple[bool, str]:
"""
扣除余额(优先扣除账户余额,不足时使用授信额度)
使用行锁保护并发扣款操作,防止超扣。
Args:
user_id: 用户ID
amount: 扣除金额
db: 数据库会话
description: 描述
auto_commit: 是否自动提交(默认False,由调用者管理事务)
Returns:
(是否成功, 消息)
Note:
默认不会 commit,由调用者统一管理事务。
如需独立提交,请设置 auto_commit=True。
"""
# 从 Balance 表获取余额
# 使用 FOR UPDATE 锁定余额行,防止并发扣款
balance_result = await db.execute(
select(Balance).where(Balance.user_id == user_id)
select(Balance)
.where(Balance.user_id == user_id)
.with_for_update()
)
balance_obj = balance_result.scalar_one_or_none()
@@ -170,6 +180,7 @@ async def deduct_balance(
# 如果余额记录不存在,创建一个新的(初始余额为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))
@@ -178,16 +189,13 @@ async def deduct_balance(
if available < amount:
return False, f"余额不足,当前可用额度: {available}, 需要: {amount}"
# 优先扣除账户余额
if balance >= amount:
balance_obj.eu_balance = float(balance - amount)
else:
# 余额不足,使用授信额度
balance_obj.eu_balance = 0.0
# 注意:授信额度是额度上限,不是实际金额,这里简化处理
# 实际应该有单独的授信使用记录表
# 优先扣除账户余额,允许透支到授信额度
new_balance = balance - amount
balance_obj.eu_balance = float(new_balance)
await db.commit()
# 可选:自动提交
if auto_commit:
await db.commit()
return True, f"成功扣除 {amount} 元"
@@ -196,19 +204,27 @@ async def add_balance(
user_id: str,
amount: Decimal,
db: AsyncSession,
description: str = "充值"
description: str = "充值",
auto_commit: bool = False
) -> Tuple[bool, str]:
"""
增加余额
使用行锁保护并发充值操作,确保余额准确。
Args:
user_id: 用户ID
amount: 充值金额
db: 数据库会话
description: 描述
auto_commit: 是否自动提交(默认False,由调用者管理事务)
Returns:
(是否成功, 消息)
Note:
默认不会 commit,由调用者统一管理事务。
如需独立提交,请设置 auto_commit=True。
"""
# 检查用户是否存在
user_result = await db.execute(
@@ -219,9 +235,11 @@ async def add_balance(
if not user:
return False, "用户不存在"
# 从 Balance 表获取或创建余额记录
# 使用 FOR UPDATE 锁定余额行,防止并发更新
balance_result = await db.execute(
select(Balance).where(Balance.user_id == user_id)
select(Balance)
.where(Balance.user_id == user_id)
.with_for_update()
)
balance_obj = balance_result.scalar_one_or_none()
@@ -229,13 +247,17 @@ async def add_balance(
# 如果余额记录不存在,创建一个新的
balance_obj = Balance(user_id=user_id, eu_balance=0.0)
db.add(balance_obj)
await db.flush() # 确保记录创建后再继续
old_balance = Decimal(str(balance_obj.eu_balance))
balance_obj.eu_balance = float(old_balance + amount)
new_balance = old_balance + amount
balance_obj.eu_balance = float(new_balance)
await db.commit()
# 可选:自动提交
if auto_commit:
await db.commit()
return True, f"成功充值 {amount} 元,当前余额: {balance_obj.eu_balance}"
return True, f"成功充值 {amount} 元,当前余额: {new_balance}"
# ============= 计费记录创建 =============
+417
View File
@@ -0,0 +1,417 @@
"""
数据库工具模块
提供事务重试机制、行锁保护等并发安全工具
"""
import asyncio
import functools
from typing import TypeVar, Callable, Any, Optional
from decimal import Decimal
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import OperationalError, DBAPIError
from sqlalchemy.orm.exc import StaleDataError
import structlog
logger = structlog.get_logger(__name__)
T = TypeVar('T')
# 数据库重试配置
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_DELAY = 0.1 # 秒
DEFAULT_RETRY_BACKOFF = 2.0 # 指数退避倍数
class DatabaseRetryError(Exception):
"""数据库重试失败异常"""
def __init__(self, message: str, original_error: Exception = None):
super().__init__(message)
self.original_error = original_error
class OptimisticLockError(Exception):
"""乐观锁冲突异常"""
pass
async def with_retry(
func: Callable[..., T],
*args,
max_retries: int = DEFAULT_MAX_RETRIES,
retry_delay: float = DEFAULT_RETRY_DELAY,
backoff: float = DEFAULT_RETRY_BACKOFF,
**kwargs
) -> T:
"""
带重试的异步函数执行器
Args:
func: 要执行的异步函数
*args: 函数参数
max_retries: 最大重试次数
retry_delay: 初始重试延迟(秒)
backoff: 退避倍数
**kwargs: 函数关键字参数
Returns:
函数执行结果
Raises:
DatabaseRetryError: 重试次数用尽后仍然失败
"""
last_error = None
current_delay = retry_delay
for attempt in range(max_retries + 1):
try:
return await func(*args, **kwargs)
except (OperationalError, DBAPIError, StaleDataError) as e:
last_error = e
if attempt < max_retries:
logger.warning(
"database_operation_retry",
attempt=attempt + 1,
max_retries=max_retries,
error=str(e),
delay=current_delay
)
await asyncio.sleep(current_delay)
current_delay *= backoff
else:
logger.error(
"database_operation_failed",
attempts=max_retries + 1,
error=str(e)
)
raise DatabaseRetryError(
f"数据库操作在 {max_retries + 1} 次尝试后失败",
original_error=last_error
)
def retry_on_conflict(
max_retries: int = DEFAULT_MAX_RETRIES,
retry_delay: float = DEFAULT_RETRY_DELAY,
backoff: float = DEFAULT_RETRY_BACKOFF
):
"""
装饰器:自动重试数据库冲突操作
用法:
@retry_on_conflict(max_retries=3)
async def my_db_operation(db: AsyncSession):
...
"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@functools.wraps(func)
async def wrapper(*args, **kwargs) -> T:
return await with_retry(
func, *args,
max_retries=max_retries,
retry_delay=retry_delay,
backoff=backoff,
**kwargs
)
return wrapper
return decorator
# ==================== 余额操作工具 ====================
async def atomic_balance_update(
db: AsyncSession,
user_id: str,
amount: Decimal,
operation: str = "deduct", # "deduct" 或 "add"
check_sufficient: bool = True
) -> tuple[bool, Decimal, str]:
"""
原子性余额更新(使用行锁)
Args:
db: 数据库会话
user_id: 用户ID
amount: 金额(正数)
operation: 操作类型 "deduct" 扣减 / "add" 增加
check_sufficient: 扣减时是否检查余额充足
Returns:
(是否成功, 新余额, 消息)
Note:
此函数不会 commit,调用者负责事务管理
"""
from models import Balance, User
# 使用 FOR UPDATE 锁定余额行
balance_result = await db.execute(
select(Balance)
.where(Balance.user_id == user_id)
.with_for_update()
)
balance_obj = balance_result.scalar_one_or_none()
# 获取用户授信额度
user_result = await db.execute(
select(User.credit_limit).where(User.id == user_id)
)
credit_row = user_result.first()
credit_limit = Decimal(str(credit_row[0])) if credit_row and credit_row[0] else Decimal(0)
if balance_obj is None:
# 创建余额记录
balance_obj = Balance(user_id=user_id, eu_balance=0)
db.add(balance_obj)
await db.flush() # 确保记录创建
current_balance = Decimal(str(balance_obj.eu_balance))
if operation == "deduct":
available = current_balance + credit_limit
if check_sufficient and available < amount:
return False, current_balance, f"余额不足,可用: {available}, 需要: {amount}"
# 优先扣除余额
if current_balance >= amount:
new_balance = current_balance - amount
else:
# 余额不足,使用授信额度(余额可为负)
new_balance = current_balance - amount
balance_obj.eu_balance = float(new_balance)
return True, new_balance, f"成功扣除 {amount}"
elif operation == "add":
new_balance = current_balance + amount
balance_obj.eu_balance = float(new_balance)
return True, new_balance, f"成功充值 {amount},当前余额: {new_balance}"
else:
raise ValueError(f"未知操作类型: {operation}")
async def atomic_eu_consume(
db: AsyncSession,
user_id: str,
eu_amount: Decimal
) -> tuple[bool, str]:
"""
原子性 EU 消耗(同时更新余额和用户消耗统计)
Args:
db: 数据库会话
user_id: 用户ID
eu_amount: EU 消耗量
Returns:
(是否成功, 消息)
"""
from models import User
# 扣减余额
success, new_balance, msg = await atomic_balance_update(
db, user_id, eu_amount, operation="deduct", check_sufficient=False
)
if not success:
return False, msg
# 更新用户 total_eu_consumed(使用原子更新)
await db.execute(
update(User)
.where(User.id == user_id)
.values(total_eu_consumed=User.total_eu_consumed + float(eu_amount))
)
# 余额警告(不阻止操作)
if new_balance < 0:
logger.warning(
"user_balance_negative",
user_id=user_id,
balance=float(new_balance),
eu_consumed=float(eu_amount)
)
return True, msg
# ==================== 配额操作工具 ====================
async def atomic_quota_update(
db: AsyncSession,
model_class,
conditions: dict,
field_name: str,
delta: int,
check_limit: bool = False,
limit_field: str = None,
max_value: int = None
) -> tuple[bool, int, str]:
"""
原子性配额更新(使用行锁和原子操作)
Args:
db: 数据库会话
model_class: 模型类
conditions: 查询条件字典
field_name: 要更新的字段名
delta: 变化量(正数增加,负数减少)
check_limit: 是否检查上限
limit_field: 上限字段名
max_value: 固定上限值(与 limit_field 二选一)
Returns:
(是否成功, 新值, 消息)
"""
from sqlalchemy import and_
# 构建查询条件
where_clauses = [getattr(model_class, k) == v for k, v in conditions.items()]
# 使用 FOR UPDATE 锁定行
query = select(model_class).where(and_(*where_clauses)).with_for_update()
result = await db.execute(query)
record = result.scalar_one_or_none()
if not record:
return False, 0, "记录不存在"
current_value = getattr(record, field_name) or 0
new_value = current_value + delta
# 检查下限
if new_value < 0:
return False, current_value, f"配额不足,当前: {current_value}, 变化: {delta}"
# 检查上限
if check_limit:
if limit_field:
limit_value = getattr(record, limit_field) or 0
elif max_value is not None:
limit_value = max_value
else:
limit_value = float('inf')
if new_value > limit_value:
return False, current_value, f"超出配额限制,上限: {limit_value}, 请求: {new_value}"
# 使用原子更新
field = getattr(model_class, field_name)
await db.execute(
update(model_class)
.where(and_(*where_clauses))
.values({field_name: field + delta})
)
return True, new_value, f"配额更新成功: {current_value} -> {new_value}"
async def atomic_increment(
db: AsyncSession,
model_class,
conditions: dict,
updates: dict[str, Any]
) -> bool:
"""
原子性字段增量更新
Args:
db: 数据库会话
model_class: 模型类
conditions: 查询条件
updates: 更新字典 {字段名: 增量值}
Returns:
是否成功
"""
from sqlalchemy import and_
where_clauses = [getattr(model_class, k) == v for k, v in conditions.items()]
# 构建原子更新表达式
update_values = {}
for field_name, delta in updates.items():
field = getattr(model_class, field_name)
update_values[field_name] = field + delta
result = await db.execute(
update(model_class)
.where(and_(*where_clauses))
.values(**update_values)
)
return result.rowcount > 0
# ==================== 幂等性工具 ====================
async def ensure_idempotent(
db: AsyncSession,
model_class,
unique_field: str,
unique_value: str
) -> bool:
"""
检查操作是否已执行(幂等性检查)
Args:
db: 数据库会话
model_class: 模型类
unique_field: 唯一字段名
unique_value: 唯一字段值
Returns:
True 如果记录已存在(操作已执行),False 如果不存在
"""
result = await db.execute(
select(model_class.id)
.where(getattr(model_class, unique_field) == unique_value)
.limit(1)
)
return result.scalar_one_or_none() is not None
async def get_or_create_with_lock(
db: AsyncSession,
model_class,
defaults: dict,
**lookup_kwargs
) -> tuple[Any, bool]:
"""
获取或创建记录(带锁保护)
Args:
db: 数据库会话
model_class: 模型类
defaults: 创建时的默认值
**lookup_kwargs: 查找条件
Returns:
(记录对象, 是否新创建)
"""
from sqlalchemy import and_
where_clauses = [getattr(model_class, k) == v for k, v in lookup_kwargs.items()]
# 尝试获取(带锁)
result = await db.execute(
select(model_class)
.where(and_(*where_clauses))
.with_for_update()
)
instance = result.scalar_one_or_none()
if instance:
return instance, False
# 创建新记录
create_kwargs = {**lookup_kwargs, **defaults}
instance = model_class(**create_kwargs)
db.add(instance)
await db.flush()
return instance, True
+78 -7
View File
@@ -15,16 +15,19 @@ from app.state import get_state
logger = structlog.get_logger(__name__)
# 邮箱配置 - 从环境变量读取
SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.office365.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_SERVER = os.getenv("SMTP_SERVER", "smtpdm-ap-southeast-1.aliyun.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_EMAIL = os.getenv("SMTP_EMAIL", "supportagnet@taijiaicloud.com")
# 注意:生产环境应该通过环境变量设置SMTP_PASSWORD,不要硬编码密码
# 这里使用默认值仅用于开发测试,生产环境必须通过环境变量配置
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "l5YYL7TOK2WvRKtf")
# 注意:生产环境必须通过环境变量设置SMTP_PASSWORD
# 如果未设置,邮件发送功能将不可用(测试模式下可通过日志或Redis获取验证码)
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
# 是否使用SSL(端口465使用SSL,端口587使用STARTTLS)
SMTP_USE_SSL = os.getenv("SMTP_USE_SSL", "true").lower() == "true"
# 验证码配置
VERIFICATION_CODE_LENGTH = 6
VERIFICATION_CODE_EXPIRE_SECONDS = 600 # 10分钟
VERIFICATION_CODE_RATE_LIMIT_SECONDS = 60 # 发送频率限制:60秒内只能发送一次
def _send_email_sync(msg: MIMEMultipart) -> None:
@@ -32,9 +35,14 @@ def _send_email_sync(msg: MIMEMultipart) -> None:
if not SMTP_PASSWORD:
raise ValueError("SMTP_PASSWORD环境变量未设置,无法发送邮件")
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
try:
# 根据端口选择连接方式:465用SSL,587用STARTTLS
if SMTP_PORT == 465 or SMTP_USE_SSL:
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT)
else:
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
server.starttls()
try:
server.login(SMTP_EMAIL, SMTP_PASSWORD)
server.send_message(msg)
logger.info("邮件发送成功", to=msg['To'])
@@ -117,6 +125,64 @@ Taiji AI-PAD 团队
return False
async def check_rate_limit(email: str) -> tuple[bool, int]:
"""
检查验证码发送频率限制
Args:
email: 邮箱地址
Returns:
(是否可以发送, 剩余等待秒数)
"""
try:
state = get_state()
if not state.redis_client:
logger.warning("Redis未连接,跳过频率限制检查")
return True, 0
rate_limit_key = f"verification_rate_limit:{email}"
# 检查是否存在频率限制
ttl = await state.redis_client.ttl(rate_limit_key)
if ttl > 0:
logger.warning("验证码发送频率限制", email=email, remaining_seconds=ttl)
return False, ttl
return True, 0
except Exception as e:
logger.error("检查频率限制失败", email=email, error=str(e))
# 出错时允许发送,避免阻塞用户
return True, 0
async def set_rate_limit(email: str) -> bool:
"""
设置验证码发送频率限制
Args:
email: 邮箱地址
Returns:
是否设置成功
"""
try:
state = get_state()
if not state.redis_client:
return False
rate_limit_key = f"verification_rate_limit:{email}"
await state.redis_client.setex(
rate_limit_key,
VERIFICATION_CODE_RATE_LIMIT_SECONDS,
"1"
)
return True
except Exception as e:
logger.error("设置频率限制失败", email=email, error=str(e))
return False
async def store_verification_code(email: str, code: str) -> bool:
"""
存储验证码到Redis
@@ -258,12 +324,17 @@ async def send_and_store_verification_code(email: str) -> Optional[str]:
verification_code=code,
hint="验证码已存储到Redis,可通过Redis获取或查看日志(仅测试环境)"
)
# 设置发送频率限制
await set_rate_limit(email)
return code
else:
# 生产模式:邮件发送失败则不返回验证码
logger.error("邮件发送失败,验证码已存储但未发送", email=email)
return None
# 发送成功后设置频率限制
await set_rate_limit(email)
logger.info("验证码已发送并存储", email=email)
return code
+6 -2
View File
@@ -156,6 +156,8 @@ async def create_quota_alert(
"""
创建配额预警记录
使用行锁保护并发创建/更新预警记录。
Args:
user_id: 用户ID
channel_id: 渠道ID
@@ -168,15 +170,17 @@ async def create_quota_alert(
Returns:
QuotaAlert记录
"""
# 检查是否已有相同的活跃预警
# 检查是否已有相同的活跃预警(使用行锁防止并发创建重复预警)
result = await db.execute(
select(QuotaAlert).where(
select(QuotaAlert)
.where(
and_(
QuotaAlert.user_id == user_id,
QuotaAlert.alert_type == alert_type,
QuotaAlert.status == "active",
)
)
.with_for_update() # 行锁
)
existing = result.scalar_one_or_none()
+32 -12
View File
@@ -6,13 +6,14 @@
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Dict, Optional, Tuple
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException, status
import structlog
from models import User, Channel, ResourceUsage, BillingRecord
from app.quota_manager import check_user_balance_quota, check_channel_quota
from app.db_utils import with_retry
logger = structlog.get_logger()
@@ -208,6 +209,8 @@ class ResourceController:
"""
记录资源消耗
使用行锁和重试机制防止并发更新丢失。
Args:
user_id: 用户ID
resource_type: 资源类型
@@ -219,39 +222,52 @@ class ResourceController:
network_io: 网络IO(KB)
db: 数据库会话
"""
try:
async def _do_record():
now = datetime.utcnow()
# 记录到ResourceUsage表(每小时聚合)
period_start = now.replace(minute=0, second=0, microsecond=0)
period_end = period_start + timedelta(hours=1)
# 检查是否已有该小时的记录
# 计算增量值
cpu_increment = cpu_usage * (execution_time_ms / 1000.0)
memory_increment = memory_usage * (execution_time_ms / 1000.0)
network_increment = int(network_io * 1024)
# 使用行锁保护并发更新(FOR UPDATE)
result = await db.execute(
select(ResourceUsage).where(
select(ResourceUsage)
.where(
and_(
ResourceUsage.user_id == user_id,
ResourceUsage.period_start == period_start,
ResourceUsage.granularity == "hourly"
)
)
.with_for_update() # 行锁
)
usage = result.scalar_one_or_none()
if usage:
# 更新现有记录
usage.cpu_seconds += cpu_usage * (execution_time_ms / 1000.0)
usage.memory_mb_seconds += memory_usage * (execution_time_ms / 1000.0)
usage.network_bytes += int(network_io * 1024)
usage.api_calls += 1
# 使用原子更新语句,而不是 ORM 属性修改
await db.execute(
update(ResourceUsage)
.where(ResourceUsage.id == usage.id)
.values(
cpu_seconds=ResourceUsage.cpu_seconds + cpu_increment,
memory_mb_seconds=ResourceUsage.memory_mb_seconds + memory_increment,
network_bytes=ResourceUsage.network_bytes + network_increment,
api_calls=ResourceUsage.api_calls + 1
)
)
else:
# 创建新记录
usage = ResourceUsage(
user_id=user_id,
agent_id=resource_id if resource_type == "agent" else None,
cpu_seconds=cpu_usage * (execution_time_ms / 1000.0),
memory_mb_seconds=memory_usage * (execution_time_ms / 1000.0),
network_bytes=int(network_io * 1024),
cpu_seconds=cpu_increment,
memory_mb_seconds=memory_increment,
network_bytes=network_increment,
storage_bytes=0,
api_calls=1,
period_start=period_start,
@@ -285,6 +301,10 @@ class ResourceController:
cost=float(cost),
execution_time_ms=execution_time_ms
)
try:
# 使用重试机制处理数据库冲突
await with_retry(_do_record, max_retries=3)
except Exception as e:
logger.error(
+19 -4
View File
@@ -81,8 +81,23 @@ def _build_agent_card(agent: Agent) -> AgentCard:
)
async def _get_balance(db: AsyncSession, user_id: uuid.UUID) -> Balance:
result = await db.execute(select(Balance).where(Balance.user_id == user_id))
async def _get_balance(db: AsyncSession, user_id: uuid.UUID, for_update: bool = False) -> Balance:
"""
获取或创建用户余额记录
Args:
db: 数据库会话
user_id: 用户ID
for_update: 是否使用行锁(用于更新操作)
Returns:
Balance 对象
"""
query = select(Balance).where(Balance.user_id == user_id)
if for_update:
query = query.with_for_update() # 行锁保护并发更新
result = await db.execute(query)
balance = result.scalar_one_or_none()
if balance is None:
balance = Balance(user_id=user_id, eu_balance=0.0)
@@ -852,9 +867,9 @@ async def execute_agent(
)
db.add(billing)
balance = await _get_balance(db, agent.owner_id)
# 使用行锁保护余额更新,防止并发扣款
balance = await _get_balance(db, agent.owner_id, for_update=True)
balance.eu_balance = (balance.eu_balance or 0) - execution.eu_consumed
db.add(balance)
# ========== 记录资源消耗 ==========
# 记录到资源监控系统
+106 -66
View File
@@ -6,6 +6,7 @@ from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError
import secrets
import hashlib
from typing import Optional
@@ -18,6 +19,7 @@ from models import (
from app.auth import (
authenticate_user,
create_access_token,
create_refresh_token,
get_password_hash,
verify_password,
require_auth,
@@ -31,7 +33,7 @@ from app.schemas import (
RegenerateAPIKeyResponse,
UserCreate,
)
from app.email_verification import verify_code, send_and_store_verification_code
from app.email_verification import verify_code, send_and_store_verification_code, check_rate_limit
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
from app.litellm_client import get_litellm_client, LiteLLMClientError
from config import settings
@@ -84,19 +86,19 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
await db.commit()
# 创建JWT token
token = create_access_token(
data={
"sub": str(entity.id),
"email": entity.email,
"role": "channel_admin",
"channelId": str(entity.id),
}
)
token_data = {
"sub": str(entity.id),
"email": entity.email,
"role": "channel_admin",
"channelId": str(entity.id),
}
access_token = create_access_token(data=token_data)
refresh_token = create_refresh_token(data=token_data)
return SuccessResponse(
data={
"token": token,
"refreshToken": token, # 简化处理,实际应该生成单独的refresh token
"token": access_token,
"refreshToken": refresh_token,
"user": {
"id": str(entity.id),
"name": entity.name,
@@ -159,19 +161,19 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
await db.commit()
# 创建JWT token
token = create_access_token(
data={
"sub": str(user.id),
"email": user.email,
"role": user_role,
"channelId": str(user.channel_id) if user.channel_id else None,
}
)
token_data = {
"sub": str(user.id),
"email": user.email,
"role": user_role,
"channelId": str(user.channel_id) if user.channel_id else None,
}
access_token = create_access_token(data=token_data)
refresh_token = create_refresh_token(data=token_data)
return SuccessResponse(
data={
"token": token,
"refreshToken": token,
"token": access_token,
"refreshToken": refresh_token,
"user": {
"id": str(user.id),
"name": user.name or user.full_name,
@@ -248,14 +250,21 @@ async def logout(
@router.post("/refresh", response_model=SuccessResponse)
async def refresh_token(
async def refresh_token_endpoint(
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
刷新访问令牌
使用 Refresh Token 获取新的 Access Token
"""
user_id = principal.get("user_id")
claims = principal.get("claims", {})
# 验证是否为 refresh token(可选,如果前端确保传入的是 refresh token)
token_type = claims.get("type")
# 为了向后兼容,不强制要求 type 为 refresh
# 查询用户信息
result = await db.execute(select(User).where(User.id == user_id))
@@ -267,20 +276,20 @@ async def refresh_token(
detail="用户不存在"
)
# 生成新token
token = create_access_token(
data={
"sub": str(user.id),
"email": user.email,
"role": user.role,
"channelId": str(user.channel_id) if user.channel_id else None,
}
)
# 生成新的 access token 和 refresh token
token_data = {
"sub": str(user.id),
"email": user.email,
"role": user.role,
"channelId": str(user.channel_id) if user.channel_id else None,
}
new_access_token = create_access_token(data=token_data)
new_refresh_token = create_refresh_token(data=token_data)
return SuccessResponse(
data={
"token": token,
"refreshToken": token,
"token": new_access_token,
"refreshToken": new_refresh_token,
}
)
@@ -431,8 +440,18 @@ async def send_verification_code_endpoint(
发送邮箱验证码
在用户注册前,先调用此接口发送验证码到邮箱
频率限制:同一邮箱60秒内只能发送一次
"""
# 检查邮箱是否已存在
# 1. 检查发送频率限制
can_send, remaining_seconds = await check_rate_limit(email)
if not can_send:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请等待{remaining_seconds}秒后再重新发送验证码"
)
# 2. 检查邮箱是否已存在
result = await db.execute(select(User).where(User.email == email))
existing_user = result.scalar_one_or_none()
@@ -442,7 +461,7 @@ async def send_verification_code_endpoint(
detail="该邮箱已被注册"
)
# 发送验证码
# 3. 发送验证码
code = await send_and_store_verification_code(email)
if not code:
raise HTTPException(
@@ -468,15 +487,7 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
- 供应商所有模型
- 余额:20元
"""
# 验证邮箱验证码
is_valid = await verify_code(req.email, req.verification_code)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期"
)
# 检查邮箱是否已存在
# 1. 先检查邮箱是否已存在(不消耗验证码)
result = await db.execute(select(User).where(User.email == req.email))
existing_user = result.scalar_one_or_none()
@@ -486,15 +497,22 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
detail="该邮箱已被注册"
)
# 检查用户名是否已存在
if req.username:
result = await db.execute(select(User).where(User.username == req.username))
existing_username = result.scalar_one_or_none()
if existing_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该用户名已被使用"
)
# 2. 检查用户名是否已存在(username 是必填字段)
result = await db.execute(select(User).where(User.username == req.username))
existing_username = result.scalar_one_or_none()
if existing_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该用户名已被使用"
)
# 3. 最后验证邮箱验证码(验证成功后会消耗验证码)
is_valid = await verify_code(req.email, req.verification_code)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码错误或已过期"
)
# taiji 渠道 ID
TAIJI_CHANNEL_ID = uuid.UUID("b415e70b-8d37-481c-b229-bc3b7871607b")
@@ -533,8 +551,28 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
total_eu_consumed=0,
)
db.add(new_user)
await db.flush() # 获取 user.id
try:
db.add(new_user)
await db.flush() # 获取 user.id,这里会触发唯一约束检查
except IntegrityError as e:
await db.rollback()
error_str = str(e.orig) if e.orig else str(e)
if "email" in error_str.lower() or "users_email_key" in error_str.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已被注册"
)
elif "username" in error_str.lower() or "users_username_key" in error_str.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该用户名已被使用"
)
else:
logger.error(f"注册时数据库约束冲突: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="注册信息与现有用户冲突,请更换邮箱或用户名"
)
user_id = new_user.id
@@ -673,20 +711,22 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
)
# 创建JWT token,自动登录
token = create_access_token(
data={
"sub": str(new_user.id),
"email": new_user.email,
"role": new_user.role,
"user_id": str(new_user.id),
"channelId": str(TAIJI_CHANNEL_ID),
}
)
token_data = {
"sub": str(new_user.id),
"email": new_user.email,
"role": new_user.role,
"user_id": str(new_user.id),
"channelId": str(TAIJI_CHANNEL_ID),
}
# 创建 Access Token(短期有效)和 Refresh Token(长期有效)
access_token = create_access_token(data=token_data)
refresh_token = create_refresh_token(data=token_data)
return SuccessResponse(
data={
"token": token,
"refreshToken": token,
"token": access_token,
"refreshToken": refresh_token,
"user": {
"id": str(new_user.id),
"name": new_user.name,
@@ -10,12 +10,13 @@ from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, update
from pydantic import BaseModel, Field
from database import get_db
from models import ModelBillingRecord, TenantModelKey, Balance, User
from app.schemas import AgentManagerCallbackData, AgentManagerCallbackResponse
from app.db_utils import ensure_idempotent, atomic_eu_consume, with_retry
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/billing", tags=["计费Webhook"])
@@ -220,46 +221,38 @@ async def process_single_callback(callback_data: LiteLLMCallbackData, db: AsyncS
except Exception as e:
logger.warning(f"时间解析失败: {e}")
# 创建计费记录
record = ModelBillingRecord(
tenant_id=tenant_id,
channel_id=channel_id,
litellm_call_id=call_id,
api_key=api_key[:8] + "..." + api_key[-4:] if api_key and len(api_key) > 12 else api_key,
team_id=team_id,
model_name=callback_data.model,
input_tokens=prompt_tokens,
output_tokens=completion_tokens,
total_tokens=total_tokens,
total_cost=Decimal(callback_data.response_cost or 0),
eu_consumed=eu_consumed,
status=callback_data.status,
start_time=start_time,
end_time=end_time,
response_time_ms=int((callback_data.response_time or 0) * 1000),
raw_callback_data=callback_data.model_dump() if hasattr(callback_data, 'model_dump') else callback_data.dict()
)
# ✅ 幂等性检查 - 防止重复处理
if call_id:
existing = await db.execute(
select(ModelBillingRecord).where(
ModelBillingRecord.litellm_call_id == call_id
)
)
if existing.scalar_one_or_none():
logger.info(f"LiteLLM回调已处理过: {call_id}")
return {"message": "Already processed", "call_id": call_id}
# ✅ 原子操作:同时创建记录和更新余额
if not tenant_id:
logger.error(f"无法解析租户ID,跳过计费: call_id={call_id}")
raise HTTPException(status_code=400, detail="无法解析租户ID")
try:
# 查询或创建用户余额记录
# 创建计费记录
record = ModelBillingRecord(
tenant_id=tenant_id,
channel_id=channel_id,
litellm_call_id=call_id,
api_key=api_key[:8] + "..." + api_key[-4:] if api_key and len(api_key) > 12 else api_key,
team_id=team_id,
model_name=callback_data.model,
input_tokens=prompt_tokens,
output_tokens=completion_tokens,
total_tokens=total_tokens,
total_cost=Decimal(callback_data.response_cost or 0),
eu_consumed=eu_consumed,
status=callback_data.status,
start_time=start_time,
end_time=end_time,
response_time_ms=int((callback_data.response_time or 0) * 1000),
raw_callback_data=callback_data.model_dump() if hasattr(callback_data, 'model_dump') else callback_data.dict()
)
db.add(record)
# 使用行锁保护余额更新,防止并发扣款
balance_result = await db.execute(
select(Balance).where(Balance.user_id == tenant_id)
select(Balance)
.where(Balance.user_id == tenant_id)
.with_for_update() # 行锁
)
balance = balance_result.scalar_one_or_none()
@@ -267,6 +260,7 @@ async def process_single_callback(callback_data: LiteLLMCallbackData, db: AsyncS
# 如果余额记录不存在,创建一个新的(初始余额为0)
balance = Balance(user_id=tenant_id, eu_balance=0)
db.add(balance)
await db.flush() # 确保记录创建
logger.warning(f"用户 {tenant_id} 余额记录不存在,已创建初始余额为0")
# 扣减EU余额(使用Decimal精确计算)
@@ -274,20 +268,12 @@ async def process_single_callback(callback_data: LiteLLMCallbackData, db: AsyncS
new_balance = old_balance - Decimal(str(eu_consumed))
balance.eu_balance = float(new_balance)
# 创建计费记录
db.add(record)
# 同时更新用户表的total_eu_consumed字段
user_result = await db.execute(
select(User).where(User.id == tenant_id)
# 使用原子操作更新用户表的 total_eu_consumed 字段
await db.execute(
update(User)
.where(User.id == tenant_id)
.values(total_eu_consumed=User.total_eu_consumed + float(eu_consumed))
)
user = user_result.scalar_one_or_none()
if user:
user.total_eu_consumed = float(Decimal(str(user.total_eu_consumed or 0)) + Decimal(str(eu_consumed)))
logger.info(
f"✅ 更新用户EU消耗: user_id={tenant_id}, "
f"total_eu_consumed={user.total_eu_consumed}"
)
logger.info(
f"✅ 扣减用户余额: user_id={tenant_id}, "
+24 -13
View File
@@ -5,7 +5,7 @@
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func, and_, desc, or_
from sqlalchemy import select, func, and_, desc, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
import structlog
@@ -349,15 +349,17 @@ async def allocate_tenant_resources(
# agentId 在这里是模板名称(如 echo_agent)
template_name = agent_alloc.agentId
# 获取渠道的平台 Agent 配额
# 获取渠道的平台 Agent 配额(使用行锁防止并发更新)
channel_quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_id == channel_id,
PlatformAgentQuota.target_type == "channel",
PlatformAgentQuota.template_name == template_name
)
)
.with_for_update() # 行锁
)
channel_quota = channel_quota_result.scalar_one_or_none()
@@ -378,15 +380,17 @@ async def allocate_tenant_resources(
)
other_quota = other_tenants_quota_result.scalar() or 0
# 获取当前租户已有配额
# 获取当前租户已有配额(使用行锁防止并发更新)
tenant_quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_id == tenant_id,
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.template_name == template_name
)
)
.with_for_update() # 行锁
)
tenant_quota = tenant_quota_result.scalar_one_or_none()
current_tenant_quota = tenant_quota.pod_quota if tenant_quota else 0
@@ -816,9 +820,11 @@ async def recharge_tenant(
detail="租户不存在或不属于该渠道"
)
# 从 Balance 表获取或创建余额记录
# 使用行锁保护余额更新,防止并发充值问题
balance_result = await db.execute(
select(Balance).where(Balance.user_id == tenant_id)
select(Balance)
.where(Balance.user_id == tenant_id)
.with_for_update() # 行锁
)
balance_obj = balance_result.scalar_one_or_none()
@@ -826,11 +832,12 @@ async def recharge_tenant(
# 如果余额记录不存在,创建一个新的
balance_obj = Balance(user_id=tenant_id, eu_balance=0.0)
db.add(balance_obj)
await db.flush() # 确保记录创建
# 更新余额(使用 Balance 表)
old_balance = float(balance_obj.eu_balance)
balance_obj.eu_balance = old_balance + req.amount
new_balance = balance_obj.eu_balance
new_balance = old_balance + req.amount
balance_obj.eu_balance = new_balance
# 创建充值记录
recharge = RechargeRecord(
@@ -3152,15 +3159,17 @@ async def allocate_platform_agent_to_tenant(
detail=f"平台 Agent 模板 '{req.templateName}' 不存在"
)
# 获取渠道的配额
# 获取渠道的配额(使用行锁防止并发更新)
channel_quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_id == channel_id,
PlatformAgentQuota.target_type == "channel",
PlatformAgentQuota.template_name == req.templateName
)
)
.with_for_update() # 行锁
)
channel_quota = channel_quota_result.scalar_one_or_none()
@@ -3194,15 +3203,17 @@ async def allocate_platform_agent_to_tenant(
detail=f"配额超出渠道剩余配额。渠道剩余: {remaining},请求: {req.podQuota}"
)
# 查找或创建租户配额记录
# 查找或创建租户配额记录(使用行锁防止并发更新)
tenant_quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_id == tenant_id,
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.template_name == req.templateName
)
)
.with_for_update() # 行锁
)
tenant_quota = tenant_quota_result.scalar_one_or_none()
@@ -431,30 +431,34 @@ async def allocate_platform_agent_to_tenant(
if not tenant or tenant.channel_id != channel_uuid:
raise HTTPException(status_code=404, detail="租户不存在或不属于该渠道")
# 检查渠道配额
# 检查渠道配额(使用行锁防止并发更新)
channel_quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_id == channel_uuid,
PlatformAgentQuota.target_type == "channel",
PlatformAgentQuota.template_name == request.templateName
)
)
.with_for_update() # 行锁
)
channel_quota = channel_quota_result.scalar_one_or_none()
if not channel_quota:
raise HTTPException(status_code=400, detail=f"渠道没有 {request.templateName} 的配额")
# 检查租户是否已有该模板的配额
# 检查租户是否已有该模板的配额(使用行锁防止并发更新)
existing_quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_id == tenant_uuid,
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.template_name == request.templateName
)
)
.with_for_update() # 行锁
)
existing_quota = existing_quota_result.scalar_one_or_none()
@@ -1055,15 +1059,17 @@ async def stop_platform_agent(
else:
logger.warning(f"未找到Agent {agent_name} 的计费记录")
# 更新租户配额
# 更新租户配额(使用行锁防止并发更新)
tenant_quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_id == uuid.UUID(user_id),
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.template_name == agent.template
)
)
.with_for_update() # 行锁
)
tenant_quota = tenant_quota_result.scalar_one_or_none()
if tenant_quota and tenant_quota.pod_used > 0:
+16 -8
View File
@@ -1231,15 +1231,17 @@ async def deploy_agent(
user_id = principal.get("user_id")
channel_id = principal.get("channel_id")
# 查询平台Agent配额记录(agentId 是配额ID,来自 /platform-agents/available)
# 查询平台Agent配额记录(使用行锁防止并发部署超限)
quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.id == req.agentId,
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.target_id == user_id
)
)
.with_for_update() # 行锁
)
quota = quota_result.scalar_one_or_none()
@@ -2175,15 +2177,17 @@ async def deploy_platform_agent(
user_id = principal.get("user_id")
channel_id = principal.get("channel_id")
# 检查用户是否有该 Agent 类型的配额
# 检查用户是否有该 Agent 类型的配额(使用行锁防止并发部署超限)
quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.target_id == user_id,
PlatformAgentQuota.template_name == req.agentType
)
)
.with_for_update() # 行锁
)
quota = quota_result.scalar_one_or_none()
@@ -2355,15 +2359,17 @@ async def use_platform_agent(
user_id = principal.get("user_id")
channel_id = principal.get("channel_id")
# 检查用户是否有该 Agent 类型的配额
# 检查用户是否有该 Agent 类型的配额(使用行锁防止并发部署超限)
quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.target_id == user_id,
PlatformAgentQuota.template_name == req.agentType
)
)
.with_for_update() # 行锁
)
quota = quota_result.scalar_one_or_none()
@@ -2496,15 +2502,17 @@ async def stop_platform_agent(
billing_record.duration_seconds = int(duration)
billing_record.eu_consumed = _calculate_eu(int(duration))
# 释放配额
# 释放配额(使用行锁防止并发更新配额)
quota_result = await db.execute(
select(PlatformAgentQuota).where(
select(PlatformAgentQuota)
.where(
and_(
PlatformAgentQuota.target_type == "tenant",
PlatformAgentQuota.target_id == user_id,
PlatformAgentQuota.template_name == billing_record.agent_type
)
)
.with_for_update() # 行锁
)
quota = quota_result.scalar_one_or_none()
if quota and quota.pod_used > 0:
@@ -0,0 +1,34 @@
-- Migration: 014_add_username_unique_constraint
-- Description: 为 users 表的 username 字段添加唯一约束,防止并发注册竞态条件
-- Date: 2026-01-12
-- 先检查是否已存在该约束
DO $$
BEGIN
-- 检查是否存在重复的 username(需要先处理)
IF EXISTS (
SELECT username, COUNT(*)
FROM users
WHERE username IS NOT NULL
GROUP BY username
HAVING COUNT(*) > 1
) THEN
RAISE NOTICE '发现重复的 username,请先手动处理重复数据';
-- 可以选择自动处理:为重复的 username 添加后缀
-- UPDATE users SET username = username || '_' || id::text WHERE ...
END IF;
-- 检查约束是否已存在
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_username_key'
AND conrelid = 'users'::regclass
) THEN
-- 添加唯一约束(允许 NULL 值,只对非 NULL 值检查唯一性)
ALTER TABLE users ADD CONSTRAINT users_username_key UNIQUE (username);
RAISE NOTICE '成功添加 username 唯一约束';
ELSE
RAISE NOTICE 'username 唯一约束已存在,跳过';
END IF;
END $$;
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
运行 014 迁移:添加 username 唯一约束
"""
import asyncio
import os
import sys
# 添加项目根目录到 path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from database import engine
async def run_migration():
"""执行迁移"""
migration_sql = """
DO $$
BEGIN
-- 检查是否存在重复的 username(需要先处理)
IF EXISTS (
SELECT username, COUNT(*)
FROM users
WHERE username IS NOT NULL
GROUP BY username
HAVING COUNT(*) > 1
) THEN
RAISE NOTICE '发现重复的 username,请先手动处理重复数据';
END IF;
-- 检查约束是否已存在
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_username_key'
AND conrelid = 'users'::regclass
) THEN
-- 添加唯一约束(允许 NULL 值,只对非 NULL 值检查唯一性)
ALTER TABLE users ADD CONSTRAINT users_username_key UNIQUE (username);
RAISE NOTICE '成功添加 username 唯一约束';
ELSE
RAISE NOTICE 'username 唯一约束已存在,跳过';
END IF;
END $$;
"""
print("=" * 60)
print("开始执行迁移: 014_add_username_unique_constraint")
print("=" * 60)
async with engine.begin() as conn:
try:
# 先检查是否有重复数据
result = await conn.execute(text("""
SELECT username, COUNT(*) as cnt
FROM users
WHERE username IS NOT NULL
GROUP BY username
HAVING COUNT(*) > 1
"""))
duplicates = result.fetchall()
if duplicates:
print("\n⚠️ 发现重复的 username:")
for row in duplicates:
print(f" - {row[0]}: {row[1]} 条记录")
print("\n请先手动处理重复数据,然后重新运行迁移")
return False
# 执行迁移
await conn.execute(text(migration_sql))
print("\n✅ 迁移执行成功!")
# 验证约束是否创建成功
result = await conn.execute(text("""
SELECT 1 FROM pg_constraint
WHERE conname = 'users_username_key'
AND conrelid = 'users'::regclass
"""))
if result.fetchone():
print("✅ 验证: users_username_key 约束已存在")
else:
print("❌ 验证失败: 约束未创建")
return False
return True
except Exception as e:
print(f"\n❌ 迁移失败: {e}")
raise
if __name__ == "__main__":
success = asyncio.run(run_migration())
sys.exit(0 if success else 1)
+1 -1
View File
@@ -102,7 +102,7 @@ class User(BaseModel, Base):
permissions = Column(JSON, default=list) # 用户权限列表,如 ["use:platform_agents", "read:billing"]
# 兼容旧字段
username = Column(String(50))
username = Column(String(50), unique=True, nullable=True) # 添加唯一约束防止并发竞态
hashed_password = Column(String(255))
full_name = Column(String(100))
is_active = Column(Boolean, default=True)
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
SMTP邮件发送测试脚本
用于调试阿里云企业邮箱/邮件推送服务配置
"""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
# ==================== 配置区域 ====================
# 请根据实际情况修改以下配置
# 阿里云企业邮箱配置
SMTP_SERVER = "smtp.mxhichina.com" # 企业邮箱
# SMTP_SERVER = "smtpdm-ap-southeast-1.aliyun.com" # 邮件推送服务(新加坡)
# SMTP_SERVER = "smtp.qiye.aliyun.com" # 企业邮箱(国内)
SMTP_PORT = 465 # SSL端口,也可以尝试 25 或 587
# 发件人配置
SMTP_EMAIL = "supportagnet@taijiaicloud.com"
SMTP_PASSWORD = "l5YYL7TOK2WvRKtf"
# 收件人
TO_EMAIL = "zsbgnw@gmail.com"
# ==================== 测试代码 ====================
def test_smtp_ssl():
"""测试SSL连接(端口465)"""
print(f"\n{'='*50}")
print(f"测试 SSL 连接")
print(f"服务器: {SMTP_SERVER}:{SMTP_PORT}")
print(f"发件人: {SMTP_EMAIL}")
print(f"收件人: {TO_EMAIL}")
print(f"{'='*50}\n")
try:
print("1. 创建SSL连接...")
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=30)
print(" ✓ SSL连接成功")
print("2. 登录认证...")
server.login(SMTP_EMAIL, SMTP_PASSWORD)
print(" ✓ 认证成功")
print("3. 构建邮件...")
msg = MIMEMultipart()
msg['From'] = SMTP_EMAIL
msg['To'] = TO_EMAIL
msg['Subject'] = "Taiji AI-PAD 测试邮件"
body = """
这是一封测试邮件。
如果您收到此邮件,说明SMTP配置正确。
---
Taiji AI-PAD 团队
"""
msg.attach(MIMEText(body, 'plain', 'utf-8'))
print(" ✓ 邮件构建完成")
print("4. 发送邮件...")
server.send_message(msg)
print(" ✓ 邮件发送成功!")
server.quit()
return True
except smtplib.SMTPAuthenticationError as e:
print(f" ✗ 认证失败: {e}")
print("\n可能的原因:")
print(" - 邮箱地址或密码错误")
print(" - 需要使用授权码而非登录密码")
print(" - 邮箱未开启SMTP服务")
return False
except smtplib.SMTPConnectError as e:
print(f" ✗ 连接失败: {e}")
return False
except Exception as e:
print(f" ✗ 错误: {type(e).__name__}: {e}")
return False
def test_smtp_starttls():
"""测试STARTTLS连接(端口587/25)"""
port = 587
print(f"\n{'='*50}")
print(f"测试 STARTTLS 连接")
print(f"服务器: {SMTP_SERVER}:{port}")
print(f"发件人: {SMTP_EMAIL}")
print(f"{'='*50}\n")
try:
print("1. 创建连接...")
server = smtplib.SMTP(SMTP_SERVER, port, timeout=30)
print(" ✓ 连接成功")
print("2. 启用TLS...")
server.starttls()
print(" ✓ TLS启用成功")
print("3. 登录认证...")
server.login(SMTP_EMAIL, SMTP_PASSWORD)
print(" ✓ 认证成功")
print("4. 构建并发送邮件...")
msg = MIMEMultipart()
msg['From'] = SMTP_EMAIL
msg['To'] = TO_EMAIL
msg['Subject'] = "Taiji AI-PAD 测试邮件 (STARTTLS)"
msg.attach(MIMEText("STARTTLS测试邮件", 'plain', 'utf-8'))
server.send_message(msg)
print(" ✓ 邮件发送成功!")
server.quit()
return True
except Exception as e:
print(f" ✗ 错误: {type(e).__name__}: {e}")
return False
if __name__ == "__main__":
print("\n" + "="*60)
print(" SMTP 邮件发送测试")
print("="*60)
# 测试SSL连接
ssl_result = test_smtp_ssl()
if not ssl_result:
print("\n尝试 STARTTLS 方式...")
test_smtp_starttls()
print("\n" + "="*60)
print("测试完成")
print("="*60 + "\n")