更新超级管理员收入

This commit is contained in:
zhanggangyong
2026-01-12 07:47:50 +00:00
parent 0ad63198f5
commit b75f9671ff
12 changed files with 1120 additions and 116 deletions
+40
View File
@@ -175,6 +175,46 @@
| `/tenants/{id}/recharge` | POST | 租户充值 | 为租户充值 EU |
| `/tenants/{id}/credit` | PUT | 设置授信额度 | 设置租户授信额度 |
| `/tenants/{id}/password` | PUT | 重置/修改租户密码 | 管理员为租户重置或设置新密码(超级管理员需提供 `channel_id` 查询参数,其他渠道管理员使用 token 中的 `channelId`) |
#### 管理员为租户重置密码
接口: `PUT /api/channel/tenants/{tenant_id}/password`
描述: 渠道管理员或超级管理员为指定租户设置一个新的登录密码。该接口由渠道内有 `manage:tenants` 权限的管理员调用。
权限:
- 需要 `manage:tenants` 权限(角色示例: `channel_admin`, `billing_admin`, `super_admin`)
- 超级管理员调用时必须在查询参数中提供 `channel_id`,且只能操作该渠道下的租户;渠道管理员使用其 Token 中的 `channelId` 自动确定渠道。
请求体示例 (JSON):
```json
{
"newPassword": "NewSecurePass123"
}
```
返回示例 (成功):
```json
{
"data": {
"tenantId": "<tenant-id>",
"name": "Tenant Name"
},
"message": "租户密码已重置",
"success": true
}
```
错误与注意事项:
- 如果 `tenant_id` 格式非法,返回 400 错误("无效的租户ID格式")。
- 如果租户不存在或不属于该渠道,返回 404 错误("租户不存在或不属于该渠道")。
- `newPassword` 目前只做最小长度校验(>= 8),建议前端/后端强制更严格的密码复杂度策略(包含大写、小写、数字、特殊字符)以提升安全。
- 建议在重置密码操作中记录审计日志(操作者、时间、原因),并可选择触发邮件通知租户以提示安全变更。
### 3.4 租户模型分配(LiteLLM 集成)
| 接口 | 方法 | 功能描述 | 业务逻辑 |
+516
View File
@@ -0,0 +1,516 @@
# 自由注册 API 接口文档
> 生成时间: 2026-01-11
>
> 本文档描述了 Taiji AI-PAD 平台的用户自由注册功能,包括邮箱验证码发送和用户注册接口。
---
## 目录
1. [接口概览](#1-接口概览)
2. [发送验证码接口](#2-发送验证码接口)
3. [用户注册接口](#3-用户注册接口)
4. [错误码说明](#4-错误码说明)
5. [使用示例](#5-使用示例)
6. [注意事项](#6-注意事项)
---
## 1. 接口概览
### 基础信息
- **Base URL**: `/api/auth`
- **认证方式**: 无需认证(公开接口)
- **数据格式**: JSON
### 接口列表
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 发送验证码 | POST | `/api/auth/register/send-code` | 发送邮箱验证码 |
| 用户注册 | POST | `/api/auth/register` | 用户注册并自动分配资源 |
---
## 2. 发送验证码接口
### 接口信息
- **路径**: `/api/auth/register/send-code`
- **方法**: `POST`
- **说明**: 向指定邮箱发送6位数字验证码,验证码有效期为10分钟
### 请求参数
#### Query Parameters
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|--------|------|------|------|------|
| email | string | 是 | 邮箱地址 | `user@example.com` |
### 请求示例
```bash
POST /api/auth/register/send-code?email=user@example.com
```
### 响应格式
#### 成功响应
**状态码**: `200 OK`
```json
{
"success": true,
"message": "验证码已发送到您的邮箱,请查收",
"data": null
}
```
#### 错误响应
**状态码**: `400 Bad Request`
```json
{
"success": false,
"error": {
"code": "EMAIL_ALREADY_REGISTERED",
"message": "该邮箱已被注册"
}
}
```
### 错误情况
| 状态码 | 错误信息 | 说明 |
|--------|----------|------|
| 400 | 该邮箱已被注册 | 邮箱已存在于系统中 |
| 500 | 验证码发送失败,请稍后重试 | 邮件服务异常或 Redis 连接失败 |
---
## 3. 用户注册接口
### 接口信息
- **路径**: `/api/auth/register`
- **方法**: `POST`
- **说明**: 用户注册新账户,注册成功后自动分配默认资源并返回登录 Token
### 请求参数
#### Request Body
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|--------|------|------|------|------|
| username | string | 是 | 用户名,3-50个字符 | `"john_doe"` |
| email | string | 是 | 邮箱地址,需符合邮箱格式 | `"user@example.com"` |
| password | string | 是 | 密码,至少8个字符 | `"password123"` |
| verification_code | string | 是 | 邮箱验证码,6位数字 | `"123456"` |
| full_name | string | 否 | 全名/显示名称 | `"John Doe"` |
### 请求示例
```bash
POST /api/auth/register
Content-Type: application/json
{
"username": "john_doe",
"email": "user@example.com",
"password": "password123",
"verification_code": "123456",
"full_name": "John Doe"
}
```
### 响应格式
#### 成功响应
**状态码**: `200 OK`
```json
{
"success": true,
"message": "注册成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"email": "user@example.com",
"username": "john_doe",
"role": "user",
"channelId": "b415e70b-8d37-481c-b229-bc3b7871607b"
}
}
}
```
#### 错误响应
**状态码**: `400 Bad Request`
```json
{
"success": false,
"error": {
"code": "VERIFICATION_CODE_INVALID",
"message": "验证码错误或已过期"
}
}
```
### 错误情况
| 状态码 | 错误信息 | 说明 |
|--------|----------|------|
| 400 | 验证码错误或已过期 | 验证码不正确或超过10分钟有效期 |
| 400 | 该邮箱已被注册 | 邮箱已存在于系统中 |
| 400 | 该用户名已被使用 | 用户名已被其他用户使用 |
| 500 | 渠道不存在,请联系管理员 | 系统配置错误 |
| 500 | 注册失败: {错误详情} | 资源分配失败或其他系统错误 |
### 自动分配的资源
注册成功后,系统会自动为新用户分配以下资源:
#### 1. 渠道分配
- **渠道ID**: `b415e70b-8d37-481c-b229-bc3b7871607b` (taiji 渠道)
- 用户将自动归属到该渠道
#### 2. 自定义 Agent 配额
- **CPU 配额**: 2 核
- **内存配额**: 2 GB
- 用户可以在配额内创建多个自定义 Agent
#### 3. 平台 Agent 配额
- 为每个可用的平台 Agent 模板分配 **1 个 Pod 配额**
- 平台 Agent 模板由系统动态获取
#### 4. 供应商模型
- 为所有活跃供应商的所有模型创建 LiteLLM Key
- 每个模型分配默认配额:
- **RPM**: 60(或供应商默认值)
- **TPM**: 10000(或供应商默认值)
- **最大预算**: $500/月
#### 5. 账户余额
- **初始余额**: 20 元(EU)
- 可用于 Agent 运行和模型调用
---
## 4. 错误码说明
### 通用错误码
| 错误码 | HTTP 状态码 | 说明 |
|--------|-------------|------|
| `EMAIL_ALREADY_REGISTERED` | 400 | 邮箱已被注册 |
| `USERNAME_ALREADY_EXISTS` | 400 | 用户名已被使用 |
| `VERIFICATION_CODE_INVALID` | 400 | 验证码错误或已过期 |
| `VERIFICATION_CODE_EXPIRED` | 400 | 验证码已过期 |
| `CHANNEL_NOT_FOUND` | 500 | 渠道不存在 |
| `RESOURCE_ALLOCATION_FAILED` | 500 | 资源分配失败 |
| `EMAIL_SEND_FAILED` | 500 | 邮件发送失败 |
| `INTERNAL_SERVER_ERROR` | 500 | 服务器内部错误 |
---
## 5. 使用示例
### 完整注册流程
#### 步骤 1: 发送验证码
```bash
curl -X POST "https://api.taiji-ai.com/api/auth/register/send-code?email=user@example.com"
```
**响应**:
```json
{
"success": true,
"message": "验证码已发送到您的邮箱,请查收"
}
```
#### 步骤 2: 查看邮箱获取验证码
用户收到邮件,验证码为:`123456`
#### 步骤 3: 提交注册
```bash
curl -X POST "https://api.taiji-ai.com/api/auth/register" \
-H "Content-Type: application/json" \
-d '{
"username": "john_doe",
"email": "user@example.com",
"password": "SecurePass123!",
"verification_code": "123456",
"full_name": "John Doe"
}'
```
**响应**:
```json
{
"success": true,
"message": "注册成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"email": "user@example.com",
"username": "john_doe",
"role": "user",
"channelId": "b415e70b-8d37-481c-b229-bc3b7871607b"
}
}
}
```
#### 步骤 4: 使用 Token 访问其他接口
```bash
curl -X GET "https://api.taiji-ai.com/api/user/profile" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
### JavaScript/TypeScript 示例
```typescript
// 1. 发送验证码
async function sendVerificationCode(email: string) {
const response = await fetch(
`https://api.taiji-ai.com/api/auth/register/send-code?email=${encodeURIComponent(email)}`,
{ method: 'POST' }
);
const data = await response.json();
return data;
}
// 2. 注册用户
async function register(userData: {
username: string;
email: string;
password: string;
verification_code: string;
full_name?: string;
}) {
const response = await fetch('https://api.taiji-ai.com/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
const data = await response.json();
if (data.success) {
// 保存 token 到本地存储
localStorage.setItem('token', data.data.token);
localStorage.setItem('refreshToken', data.data.refreshToken);
}
return data;
}
// 使用示例
async function registerFlow() {
const email = 'user@example.com';
// 发送验证码
await sendVerificationCode(email);
// 等待用户输入验证码(实际应用中从表单获取)
const verificationCode = '123456';
// 注册
const result = await register({
username: 'john_doe',
email: email,
password: 'SecurePass123!',
verification_code: verificationCode,
full_name: 'John Doe',
});
if (result.success) {
console.log('注册成功!', result.data.user);
} else {
console.error('注册失败:', result.error.message);
}
}
```
### Python 示例
```python
import requests
BASE_URL = "https://api.taiji-ai.com"
# 1. 发送验证码
def send_verification_code(email: str):
url = f"{BASE_URL}/api/auth/register/send-code"
params = {"email": email}
response = requests.post(url, params=params)
return response.json()
# 2. 注册用户
def register(username: str, email: str, password: str,
verification_code: str, full_name: str = None):
url = f"{BASE_URL}/api/auth/register"
data = {
"username": username,
"email": email,
"password": password,
"verification_code": verification_code,
}
if full_name:
data["full_name"] = full_name
response = requests.post(url, json=data)
return response.json()
# 使用示例
if __name__ == "__main__":
email = "user@example.com"
# 发送验证码
result = send_verification_code(email)
print("验证码发送结果:", result)
# 等待用户输入验证码
verification_code = input("请输入验证码: ")
# 注册
result = register(
username="john_doe",
email=email,
password="SecurePass123!",
verification_code=verification_code,
full_name="John Doe"
)
if result.get("success"):
print("注册成功!")
print("Token:", result["data"]["token"])
print("用户信息:", result["data"]["user"])
else:
print("注册失败:", result.get("error", {}).get("message"))
```
---
## 6. 注意事项
### 验证码相关
1. **有效期**: 验证码有效期为 **10 分钟**,过期后需要重新发送
2. **一次性使用**: 验证码验证成功后会自动删除,不能重复使用
3. **发送频率**: 建议限制同一邮箱的验证码发送频率,避免滥用
4. **邮件延迟**: 邮件发送可能存在延迟,请耐心等待
### 注册相关
1. **用户名规则**:
- 长度:3-50 个字符
- 建议使用字母、数字、下划线
- 不能与已存在的用户名重复
2. **密码规则**:
- 长度:至少 8 个字符
- 建议包含大小写字母、数字和特殊字符
- 不要使用常见密码
3. **邮箱规则**:
- 必须符合标准邮箱格式
- 不能与已注册的邮箱重复
- 建议使用常用邮箱服务商
4. **资源分配**:
- 资源分配是异步进行的,某些资源分配失败不会影响用户注册
- 如果平台 Agent 模板获取失败,会跳过平台 Agent 分配,但用户仍可注册成功
- 如果 LiteLLM 服务不可用,会跳过模型分配,但用户仍可注册成功
5. **Token 使用**:
- 注册成功后返回的 Token 可以直接用于后续 API 调用
- Token 有效期:24 小时
- 建议将 Token 存储在安全的地方(如 localStorage 或 secure cookie)
### 安全建议
1. **HTTPS**: 所有 API 调用必须使用 HTTPS
2. **密码安全**: 不要在客户端存储明文密码
3. **Token 安全**: 不要在 URL 中传递 Token,使用 Authorization Header
4. **验证码安全**: 验证码不应在前端显示,应通过邮件发送
5. **错误处理**: 不要向用户暴露详细的错误信息,避免信息泄露
### 常见问题
#### Q: 验证码收不到怎么办?
A:
1. 检查邮箱的垃圾邮件文件夹
2. 确认邮箱地址输入正确
3. 等待几分钟后重试
4. 如果仍然收不到,请联系客服
#### Q: 验证码输入错误怎么办?
A: 验证码输入错误后,需要重新发送验证码
#### Q: 注册后资源没有分配怎么办?
A:
1. 检查账户余额和配额是否已分配
2. 如果资源未分配,请联系客服或管理员
3. 管理员可以通过后台手动分配资源
#### Q: 可以修改默认分配的资源吗?
A: 注册时的默认资源分配是系统预设的,注册后可以通过渠道管理员或超级管理员调整资源配额
---
## 7. 更新日志
### 2026-01-11
- 初始版本发布
- 支持邮箱验证码注册
- 支持自动资源分配
---
## 8. 相关接口
注册成功后,用户可以使用以下相关接口:
- **用户信息**: `GET /api/user/profile` - 获取用户信息
- **余额查询**: `GET /api/user/billing/balance` - 查询账户余额
- **Agent 管理**: `GET /api/user/agents` - 查看 Agent 列表
- **平台 Agent**: `GET /api/user/platform-agents/available` - 查看可用的平台 Agent
更多接口请参考 [API 接口完整清单](./API接口完整清单-详细版.md)
---
## 9. 技术支持
如有问题或建议,请联系:
- **技术支持邮箱**: supportagnet@taijiaicloud.com
- **文档更新**: 本文档会随系统更新而更新,请关注最新版本
---
**文档版本**: v1.0
**最后更新**: 2026-01-11
@@ -339,7 +339,8 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| tenant_id | string | 是 | 租户ID |
curl -i -X DELETE "http://localhost:8002/api/channel/tenants/2c45b96d-c158-466f-b5fc-3eccdc24a8bb?channel_id=6e6fc470-76f8-4bb1-8ea4-625dc5b12bc6" \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN"
**响应字段**:
```json
{
+5
View File
@@ -89,6 +89,11 @@ 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}
- SMTP_EMAIL=${SMTP_EMAIL:-supportagnet@taijiaicloud.com}
- SMTP_PASSWORD=${SMTP_PASSWORD:-l5YYL7TOK2WvRKtf}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
@@ -0,0 +1,269 @@
"""
邮箱验证码功能
"""
import os
import random
import string
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
import structlog
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_EMAIL = os.getenv("SMTP_EMAIL", "supportagnet@taijiaicloud.com")
# 注意:生产环境应该通过环境变量设置SMTP_PASSWORD,不要硬编码密码
# 这里使用默认值仅用于开发测试,生产环境必须通过环境变量配置
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "l5YYL7TOK2WvRKtf")
# 验证码配置
VERIFICATION_CODE_LENGTH = 6
VERIFICATION_CODE_EXPIRE_SECONDS = 600 # 10分钟
def _send_email_sync(msg: MIMEMultipart) -> None:
"""同步发送邮件(在 executor 中运行)"""
if not SMTP_PASSWORD:
raise ValueError("SMTP_PASSWORD环境变量未设置,无法发送邮件")
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
try:
server.starttls()
server.login(SMTP_EMAIL, SMTP_PASSWORD)
server.send_message(msg)
logger.info("邮件发送成功", to=msg['To'])
except smtplib.SMTPAuthenticationError as e:
logger.error("SMTP认证失败", error=str(e), smtp_server=SMTP_SERVER, smtp_email=SMTP_EMAIL)
raise
except smtplib.SMTPException as e:
logger.error("SMTP发送失败", error=str(e))
raise
finally:
server.quit()
def generate_verification_code() -> str:
"""生成6位数字验证码"""
return ''.join(random.choices(string.digits, k=VERIFICATION_CODE_LENGTH))
async def send_verification_code(email: str, code: str) -> bool:
"""
发送验证码邮件
Args:
email: 收件人邮箱
code: 验证码
Returns:
是否发送成功
"""
if not SMTP_PASSWORD:
logger.error("SMTP密码未配置,无法发送邮件", email=email)
return False
try:
# 创建邮件
msg = MIMEMultipart()
msg['From'] = SMTP_EMAIL
msg['To'] = email
msg['Subject'] = "Taiji AI-PAD 注册验证码"
# 邮件正文
body = f"""
尊敬的用户:
您的注册验证码是:{code}
验证码有效期为10分钟,请勿泄露给他人。
如果您没有进行注册操作,请忽略此邮件。
此邮件由系统自动发送,请勿回复。
---
Taiji AI-PAD 团队
"""
msg.attach(MIMEText(body, 'plain', 'utf-8'))
# 发送邮件(使用同步方式,因为 smtplib 不支持异步)
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _send_email_sync, msg)
logger.info("验证码邮件发送成功", email=email)
return True
except smtplib.SMTPAuthenticationError as e:
logger.error(
"SMTP认证失败,请检查SMTP_PASSWORD是否正确",
email=email,
error=str(e),
smtp_server=SMTP_SERVER,
smtp_email=SMTP_EMAIL,
hint="Office365可能需要使用应用专用密码(App Password)而不是普通密码"
)
return False
except smtplib.SMTPException as e:
logger.error("SMTP发送失败", email=email, error=str(e), smtp_server=SMTP_SERVER)
return False
except Exception as e:
logger.error("验证码邮件发送失败", email=email, error=str(e), error_type=type(e).__name__)
return False
async def store_verification_code(email: str, code: str) -> bool:
"""
存储验证码到Redis
Args:
email: 邮箱地址
code: 验证码
Returns:
是否存储成功
"""
try:
state = get_state()
if not state.redis_client:
logger.warning("Redis未连接,无法存储验证码")
return False
key = f"verification_code:{email}"
await state.redis_client.setex(
key,
VERIFICATION_CODE_EXPIRE_SECONDS,
code
)
logger.info("验证码已存储", email=email)
return True
except Exception as e:
logger.error("验证码存储失败", email=email, error=str(e))
return False
async def verify_code(email: str, code: str) -> bool:
"""
验证验证码
Args:
email: 邮箱地址
code: 验证码
Returns:
是否验证成功
"""
try:
state = get_state()
if not state.redis_client:
logger.warning("Redis未连接,无法验证验证码")
return False
key = f"verification_code:{email}"
# 处理Redis集群的MOVED重定向
max_retries = 3
for attempt in range(max_retries):
try:
stored_code = await state.redis_client.get(key)
break
except Exception as e:
error_str = str(e)
if "MOVED" in error_str and attempt < max_retries - 1:
# Redis集群重定向,等待后重试
import asyncio
await asyncio.sleep(0.1)
logger.debug("Redis集群重定向,重试中", attempt=attempt+1, error=error_str)
continue
else:
raise
if not stored_code:
logger.warning("验证码不存在或已过期", email=email, provided_code=code)
return False
# 确保都是字符串类型进行比较
stored_code = str(stored_code).strip()
code = str(code).strip()
if stored_code != code:
logger.warning(
"验证码错误",
email=email,
provided_code=code,
stored_code=stored_code,
provided_type=type(code).__name__,
stored_type=type(stored_code).__name__
)
return False
# 验证成功后删除验证码(同样处理集群重定向)
for attempt in range(max_retries):
try:
await state.redis_client.delete(key)
break
except Exception as e:
error_str = str(e)
if "MOVED" in error_str and attempt < max_retries - 1:
import asyncio
await asyncio.sleep(0.1)
continue
else:
logger.warning("删除验证码失败,但验证已成功", email=email, error=error_str)
break
logger.info("验证码验证成功", email=email)
return True
except Exception as e:
logger.error("验证码验证失败", email=email, code=code, error=str(e), error_type=type(e).__name__)
return False
async def send_and_store_verification_code(email: str) -> Optional[str]:
"""
生成、发送并存储验证码
Args:
email: 邮箱地址
Returns:
验证码(如果成功),None(如果失败)
"""
import os
code = generate_verification_code()
# 先存储验证码(即使邮件发送失败,验证码也已存储,可以手动查看Redis)
store_success = await store_verification_code(email, code)
if not store_success:
logger.error("验证码存储失败,无法继续", email=email)
return None
# 发送邮件
send_success = await send_verification_code(email, code)
# 测试模式:即使邮件发送失败也返回验证码(仅用于开发/测试环境)
test_mode = os.getenv("ENABLE_TEST_MODE", "false").lower() == "true" or os.getenv("DEBUG", "false").lower() == "true"
if not send_success:
if test_mode:
# 测试模式:记录验证码到日志(仅测试环境)
logger.warning(
"测试模式:邮件发送失败,但验证码已存储到Redis",
email=email,
verification_code=code,
hint="验证码已存储到Redis,可通过Redis获取或查看日志(仅测试环境)"
)
return code
else:
# 生产模式:邮件发送失败则不返回验证码
logger.error("邮件发送失败,验证码已存储但未发送", email=email)
return None
logger.info("验证码已发送并存储", email=email)
return code
+31 -5
View File
@@ -39,11 +39,37 @@ def register_lifecycle_events(app: FastAPI) -> None:
# Redis是可选的,连接失败不影响服务启动
try:
if settings.redis_url:
state.redis_client = redis.from_url(
settings.redis_url,
encoding="utf-8",
decode_responses=True,
)
# 检查是否是集群模式(通过URL或错误信息判断)
# Azure Redis Cache集群模式需要使用集群客户端
try:
# 先尝试普通连接
test_client = redis.from_url(
settings.redis_url,
encoding="utf-8",
decode_responses=True,
)
await test_client.ping()
await test_client.aclose()
# 普通连接成功,使用普通客户端
state.redis_client = redis.from_url(
settings.redis_url,
encoding="utf-8",
decode_responses=True,
)
except Exception as cluster_test:
# 如果普通连接失败,可能是集群模式,尝试使用集群客户端
# 注意:redis-py的集群支持需要额外配置
# 这里先使用普通连接,但添加重定向处理
logger.warning("Redis普通连接失败,尝试集群模式", error=str(cluster_test))
# 对于Azure Redis Cache,通常使用普通连接但需要处理MOVED重定向
# 使用skip_full_coverage_check=True来允许部分节点连接
state.redis_client = redis.from_url(
settings.redis_url,
encoding="utf-8",
decode_responses=True,
skip_full_coverage_check=True,
)
await state.redis_client.ping()
redis_connections.set(1)
logger.info("Redis连接成功")
+19 -7
View File
@@ -18,7 +18,7 @@ from models import (
BillingRecord, Application, ModelProvider,
ChannelProviderAccess, ProviderApplication,
ChannelCustomAgentQuota, ResourceApplication, PlatformAgentQuota,
PlatformAgentTemplateConfig
PlatformAgentTemplateConfig, AgentBillingRecord, ModelBillingRecord
)
from app.auth import require_auth, get_password_hash
from app.schemas import (
@@ -483,13 +483,25 @@ async def get_admin_dashboard_stats(
# 总 Agent 数
total_agents = platform_agents_count + custom_agents_count
# 总调用次数
calls_count = await db.execute(select(func.count(BillingRecord.id)))
total_calls = calls_count.scalar() or 0
# 总调用次数 - 从 AgentBillingRecord 和 ModelBillingRecord 统计
agent_calls_count = await db.execute(select(func.count(AgentBillingRecord.id)))
agent_calls = agent_calls_count.scalar() or 0
# 总收入
revenue = await db.execute(select(func.sum(BillingRecord.cost)))
total_revenue = float(revenue.scalar() or 0)
model_calls_count = await db.execute(select(func.count(ModelBillingRecord.id)))
model_calls = model_calls_count.scalar() or 0
total_calls = agent_calls + model_calls
# 总收入 - 从 AgentBillingRecord 和 ModelBillingRecord 统计
# AgentBillingRecord.cost 是 Agent 使用费用
agent_revenue = await db.execute(select(func.sum(AgentBillingRecord.cost)))
agent_total = float(agent_revenue.scalar() or 0)
# ModelBillingRecord.total_cost 是模型调用费用
model_revenue = await db.execute(select(func.sum(ModelBillingRecord.total_cost)))
model_total = float(model_revenue.scalar() or 0)
total_revenue = agent_total + model_total
# 从 Agent Manager 获取 K8s 中实际运行的平台端 Agent 资源统计
k8s_agents_count = 0
+216 -5
View File
@@ -3,7 +3,7 @@
"""
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import secrets
@@ -11,7 +11,10 @@ import hashlib
from typing import Optional
from database import get_db
from models import User, Channel, APIKey
from models import (
User, Channel, APIKey, Balance, TenantCustomAgentQuota,
PlatformAgentQuota, TenantModelKey, ResourceAllocation, ModelProvider
)
from app.auth import (
authenticate_user,
create_access_token,
@@ -28,7 +31,16 @@ from app.schemas import (
RegenerateAPIKeyResponse,
UserCreate,
)
from app.email_verification import verify_code, send_and_store_verification_code
from app.agent_manager_client import get_agent_manager_client, AgentManagerError
from app.litellm_client import get_litellm_client, LiteLLMClientError
from config import settings
import uuid
from datetime import datetime
from sqlalchemy import and_
import structlog
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api/auth", tags=["认证"])
@@ -410,13 +422,60 @@ async def regenerate_api_key(
)
@router.post("/register/send-code", response_model=SuccessResponse)
async def send_verification_code_endpoint(
email: str = Query(..., description="邮箱地址"),
db: AsyncSession = Depends(get_db)
):
"""
发送邮箱验证码
在用户注册前,先调用此接口发送验证码到邮箱
"""
# 检查邮箱是否已存在
result = await db.execute(select(User).where(User.email == email))
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已被注册"
)
# 发送验证码
code = await send_and_store_verification_code(email)
if not code:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="验证码发送失败,请稍后重试"
)
return SuccessResponse(
message="验证码已发送到您的邮箱,请查收"
)
@router.post("/register", response_model=SuccessResponse)
async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
"""
用户注册接口
允许用户自由注册,创建普通用户账户
注册成功后自动分配默认资源:
- 分配到 taiji 渠道 (channelId: b415e70b-8d37-481c-b229-bc3b7871607b)
- 自定义 agent 配额:2 CPU, 2 GB 内存
- 平台 agent 各1个
- 供应商所有模型
- 余额: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="验证码错误或已过期"
)
# 检查邮箱是否已存在
result = await db.execute(select(User).where(User.email == req.email))
existing_user = result.scalar_one_or_none()
@@ -427,7 +486,7 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
detail="该邮箱已被注册"
)
# 检查用户名是否已存在(如果提供了username)
# 检查用户名是否已存在
if req.username:
result = await db.execute(select(User).where(User.username == req.username))
existing_username = result.scalar_one_or_none()
@@ -437,6 +496,20 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
detail="该用户名已被使用"
)
# taiji 渠道 ID
TAIJI_CHANNEL_ID = uuid.UUID("b415e70b-8d37-481c-b229-bc3b7871607b")
# 验证渠道存在
channel_result = await db.execute(
select(Channel).where(Channel.id == TAIJI_CHANNEL_ID)
)
channel = channel_result.scalar_one_or_none()
if not channel:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="渠道不存在,请联系管理员"
)
# 创建新用户
password_hash = get_password_hash(req.password)
username = req.username or req.email.split("@")[0]
@@ -450,6 +523,7 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
username=username,
full_name=req.full_name or name,
role="user", # 默认角色为普通用户
channel_id=TAIJI_CHANNEL_ID, # 分配到 taiji 渠道
status="active",
is_active=True,
is_admin=False,
@@ -460,8 +534,143 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
await db.flush() # 获取 user.id
user_id = new_user.id
try:
# 1. 创建余额记录,初始余额 20 元
balance = Balance(
user_id=user_id,
eu_balance=20.0
)
db.add(balance)
# 2. 分配自定义 Agent 配额:2 CPU, 2 GB 内存
custom_agent_quota = TenantCustomAgentQuota(
tenant_id=user_id,
cpu_quota=2.0,
memory_quota=2.0,
cpu_used=0.0,
memory_used=0.0,
agent_count=0
)
db.add(custom_agent_quota)
# 3. 获取所有平台 Agent 模板,为每个模板分配 1 个配额
try:
agent_manager_client = get_agent_manager_client()
platform_templates = await agent_manager_client.list_platform_templates()
for template in platform_templates:
template_name = template.template
# 创建平台 Agent 配额记录
platform_quota = PlatformAgentQuota(
target_id=user_id,
target_type="tenant",
template_name=template_name,
pod_quota=1, # 每个平台 agent 分配 1 个配额
pod_used=0,
allocated_by=None, # 系统自动分配
allocated_at=datetime.utcnow()
)
db.add(platform_quota)
# 同时创建 ResourceAllocation 记录(兼容旧逻辑)
allocation = ResourceAllocation(
target_id=user_id,
target_type="tenant",
resource_type="agent",
resource_id=template_name,
quantity=1
)
db.add(allocation)
except (AgentManagerError, Exception) as e:
logger.warning(f"获取平台 Agent 模板失败,跳过平台 Agent 分配: {e}")
# 4. 分配所有供应商模型
providers_result = await db.execute(
select(ModelProvider).where(ModelProvider.is_active == True)
)
providers = providers_result.scalars().all()
if channel.litellm_team_id:
litellm_client = get_litellm_client()
for provider in providers:
# 为每个供应商的每个模型创建 TenantModelKey
for model_name in provider.supported_models:
try:
# 在 LiteLLM 中创建 Key
key = await litellm_client.generate_key(
team_id=channel.litellm_team_id,
models=[model_name],
rpm_limit=provider.rpm or 60,
tpm_limit=provider.tpm or 10000,
max_budget=500.0, # 默认预算
budget_duration="monthly",
key_name=f"tenant-{user_id}-{model_name}",
metadata={
"tenant_id": str(user_id),
"tenant_name": name,
"channel_id": str(TAIJI_CHANNEL_ID),
"channel_name": channel.name,
"model": model_name,
}
)
# 加密存储 Key
encrypted_key = litellm_client.encrypt_key(key.key)
# 保存到数据库
tenant_key = TenantModelKey(
tenant_id=user_id,
channel_id=TAIJI_CHANNEL_ID,
model_name=model_name,
litellm_key_id=key.key,
litellm_key_hash=encrypted_key,
rpm_limit=provider.rpm or 60,
tpm_limit=provider.tpm or 10000,
max_budget=500.0,
budget_duration="monthly",
status="active",
)
db.add(tenant_key)
# 同时记录到 ResourceAllocation
model_allocation = ResourceAllocation(
target_id=user_id,
target_type="tenant",
resource_type="model",
resource_id=str(provider.id),
rpm=provider.rpm or 60,
tpm=provider.tpm or 10000,
)
db.add(model_allocation)
except (LiteLLMClientError, Exception) as e:
logger.warning(f"为模型 {model_name} 创建 LiteLLM Key 失败: {e}")
# 继续处理其他模型
else:
logger.warning(f"渠道 {TAIJI_CHANNEL_ID} 未配置 LiteLLM team,跳过模型分配")
# 提交所有更改
await db.commit()
await db.refresh(new_user)
logger.info(
f"用户注册成功并分配默认资源",
user_id=str(user_id),
email=req.email,
channel_id=str(TAIJI_CHANNEL_ID)
)
except Exception as e:
await db.rollback()
logger.error(f"用户注册失败: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"注册失败: {str(e)}"
)
# 创建JWT token,自动登录
token = create_access_token(
@@ -470,6 +679,7 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
"email": new_user.email,
"role": new_user.role,
"user_id": str(new_user.id),
"channelId": str(TAIJI_CHANNEL_ID),
}
)
@@ -483,6 +693,7 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
"email": new_user.email,
"username": new_user.username,
"role": new_user.role,
"channelId": str(TAIJI_CHANNEL_ID),
}
},
message="注册成功"
+10 -2
View File
@@ -295,11 +295,19 @@ async def allocate_tenant_resources(
detail="无法获取渠道ID"
)
# 验证租户存在且属于指定渠道
# 验证 tenant_id 格式并确认租户属于指定渠道
try:
tenant_uuid = uuid.UUID(tenant_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的租户ID格式"
)
result = await db.execute(
select(User).where(
and_(
User.id == tenant_id,
User.id == tenant_uuid,
User.channel_id == channel_id
)
)
@@ -897,102 +897,8 @@ async def admin_login(payload: Dict[str, str], db: AsyncSession = Depends(get_db
return {"token": token, "tokenType": "bearer", "email": email, "expiresIn": 60 * 60}
@router.get("/admin/dashboard/stats")
async def admin_dashboard_stats(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
import logging
logger = logging.getLogger(__name__)
agent_count = (await db.execute(select(func.count(Agent.id)))).scalar() or 0
channels_count = (await db.execute(select(func.count(Channel.id)))).scalar() or 0
tenants_count = (await db.execute(select(func.count(User.id)).where(User.role == "user"))).scalar() or 0
balances = (await db.execute(select(func.coalesce(func.sum(Balance.eu_balance), 0)))).scalar() or 0
# 统计平台端 Agent (type='platform')
platform_agents = (await db.execute(
select(Agent).where(Agent.type == "platform")
)).scalars().all()
platform_agent_count = len(platform_agents)
platform_cpu = sum(float(agent.cpu or 0) for agent in platform_agents)
platform_memory = sum(float(agent.memory or 0) for agent in platform_agents)
# 统计自定义 Agent (type='custom')
custom_agents = (await db.execute(
select(Agent).where(Agent.type == "custom")
)).scalars().all()
custom_agent_count = len(custom_agents)
custom_cpu = sum(float(agent.cpu or 0) for agent in custom_agents)
custom_memory = sum(float(agent.memory or 0) for agent in custom_agents)
# 从 Agent Manager 获取 K8s 中实际运行的 Agent 资源统计
k8s_agents_count = 0
k8s_total_cpu = 0.0
k8s_total_memory = 0.0
try:
from app.agent_manager_client import get_agent_manager_client
client = get_agent_manager_client()
# 获取所有运行中的 Agent
k8s_agents_result = await client.list_agents()
k8s_agents = k8s_agents_result.agents # 从 AgentListResult 中获取 agents 列表
k8s_agents_count = len(k8s_agents)
# 获取每个 Agent 的资源配置
for agent in k8s_agents:
agent_name = agent.get("name")
if agent_name:
try:
metrics = await client.get_agent_metrics(agent_name)
# 解析 CPU (如 "500m" -> 0.5 核)
cpu_limit = metrics.limits.get("cpu", "0")
if cpu_limit.endswith("m"):
k8s_total_cpu += float(cpu_limit[:-1]) / 1000
else:
k8s_total_cpu += float(cpu_limit)
# 解析内存 (如 "512Mi" -> 0.5 GB)
memory_limit = metrics.limits.get("memory", "0")
if memory_limit.endswith("Mi"):
k8s_total_memory += float(memory_limit[:-2]) / 1024
elif memory_limit.endswith("Gi"):
k8s_total_memory += float(memory_limit[:-2])
elif memory_limit.endswith("Ki"):
k8s_total_memory += float(memory_limit[:-2]) / (1024 * 1024)
except Exception as e:
logger.warning(f"获取 Agent {agent_name} 资源指标失败: {e}")
except Exception as e:
logger.warning(f"连接 Agent Manager 失败,使用数据库统计: {e}")
# 如果 Agent Manager 不可用,使用数据库中的平台端 Agent 统计
k8s_total_cpu = platform_cpu
k8s_total_memory = platform_memory
# 总 Agent 数:平台端 + 自定义
total_agents = platform_agent_count + custom_agent_count
return {
"success": True,
"data": {
"totalChannels": channels_count,
"totalTenants": tenants_count,
"totalAgents": total_agents,
"totalCalls": 0,
"totalRevenue": round(float(balances), 2),
"totalAllocatedCpu": round(k8s_total_cpu + custom_cpu, 2),
"totalAllocatedMemory": round(k8s_total_memory + custom_memory, 2),
"platformAgents": {
"count": k8s_agents_count if k8s_agents_count > 0 else platform_agent_count,
"cpu": round(k8s_total_cpu if k8s_total_cpu > 0 else platform_cpu, 2),
"memory": round(k8s_total_memory if k8s_total_memory > 0 else platform_memory, 2),
},
"customAgents": {
"count": custom_agent_count,
"cpu": round(custom_cpu, 2),
"memory": round(custom_memory, 2),
},
},
"message": None,
}
# 注意: /admin/dashboard/stats 接口已移至 admin.py,避免重复定义
# 该接口从 AgentBillingRecord 和 ModelBillingRecord 统计收入和调用次数
@router.get("/admin/channels")
+9
View File
@@ -51,6 +51,15 @@ class PasswordChangeRequest(BaseModel):
new_password: str
class UserCreate(BaseModel):
"""创建用户请求(自由注册)"""
username: str = Field(..., min_length=3, max_length=50, description="用户名,3-50个字符")
email: EmailStr = Field(..., description="邮箱地址")
password: str = Field(..., min_length=8, description="密码,至少8个字符")
verification_code: str = Field(..., min_length=6, max_length=6, description="邮箱验证码,6位数字")
full_name: Optional[str] = Field(None, description="全名/显示名称")
# ============= 密钥管理 =============
class APIKeyInfo(BaseModel):
+1
View File
@@ -398,6 +398,7 @@ class UserCreate(BaseModel):
email: str = Field(..., pattern=r'^[^@]+@[^@]+\.[^@]+$')
password: str = Field(..., min_length=8)
full_name: Optional[str] = None
verification_code: str = Field(..., min_length=6, max_length=6, description="邮箱验证码")
class UserUpdate(BaseModel):