forked from xiaohei/taiji-AI-PAD
更新agent manager数据接口
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
# ============= 计费记录创建 =============
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
# ========== 记录资源消耗 ==========
|
||||
# 记录到资源监控系统
|
||||
|
||||
@@ -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}, "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user