Commit ·
36cbcc0
1
Parent(s): 23fd521
feat:flow2api初版
Browse files- .dockerignore +77 -0
- .gitignore +54 -0
- Dockerfile +12 -0
- README.md +260 -1
- config/setting.toml +35 -0
- config/setting_warp.toml +35 -0
- docker-compose.proxy.yml +36 -0
- docker-compose.yml +14 -0
- main.py +13 -0
- requirements.txt +9 -0
- src/api/__init__.py +6 -0
- src/api/admin.py +669 -0
- src/api/routes.py +147 -0
- src/core/__init__.py +7 -0
- src/core/auth.py +39 -0
- src/core/config.py +183 -0
- src/core/database.py +879 -0
- src/core/logger.py +243 -0
- src/core/models.py +145 -0
- src/main.py +162 -0
- src/services/__init__.py +17 -0
- src/services/concurrency_manager.py +190 -0
- src/services/file_cache.py +199 -0
- src/services/flow_client.py +657 -0
- src/services/generation_handler.py +850 -0
- src/services/load_balancer.py +87 -0
- src/services/proxy_manager.py +25 -0
- src/services/token_manager.py +384 -0
- static/login.html +53 -0
- static/manage.html +586 -0
.dockerignore
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
.gitattributes
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.so
|
| 11 |
+
.Python
|
| 12 |
+
build/
|
| 13 |
+
develop-eggs/
|
| 14 |
+
dist/
|
| 15 |
+
downloads/
|
| 16 |
+
eggs/
|
| 17 |
+
.eggs/
|
| 18 |
+
lib/
|
| 19 |
+
lib64/
|
| 20 |
+
parts/
|
| 21 |
+
sdist/
|
| 22 |
+
var/
|
| 23 |
+
wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
*.manifest
|
| 29 |
+
*.spec
|
| 30 |
+
pip-log.txt
|
| 31 |
+
pip-delete-this-directory.txt
|
| 32 |
+
|
| 33 |
+
# Virtual Environment
|
| 34 |
+
venv/
|
| 35 |
+
env/
|
| 36 |
+
ENV/
|
| 37 |
+
.venv
|
| 38 |
+
|
| 39 |
+
# IDE
|
| 40 |
+
.vscode/
|
| 41 |
+
.idea/
|
| 42 |
+
*.swp
|
| 43 |
+
*.swo
|
| 44 |
+
*~
|
| 45 |
+
.DS_Store
|
| 46 |
+
|
| 47 |
+
# Project specific
|
| 48 |
+
data/*.db
|
| 49 |
+
data/*.db-journal
|
| 50 |
+
tmp/*
|
| 51 |
+
logs/*
|
| 52 |
+
*.log
|
| 53 |
+
|
| 54 |
+
# Docker
|
| 55 |
+
Dockerfile
|
| 56 |
+
docker-compose*.yml
|
| 57 |
+
.dockerignore
|
| 58 |
+
|
| 59 |
+
# Documentation
|
| 60 |
+
README.md
|
| 61 |
+
DEPLOYMENT.md
|
| 62 |
+
LICENSE
|
| 63 |
+
*.md
|
| 64 |
+
|
| 65 |
+
# Test files
|
| 66 |
+
tests/
|
| 67 |
+
test_*.py
|
| 68 |
+
*_test.py
|
| 69 |
+
|
| 70 |
+
# CI/CD
|
| 71 |
+
.github/
|
| 72 |
+
.gitlab-ci.yml
|
| 73 |
+
.travis.yml
|
| 74 |
+
|
| 75 |
+
# Environment files
|
| 76 |
+
.env
|
| 77 |
+
.env.*
|
.gitignore
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
venv/
|
| 9 |
+
ENV/
|
| 10 |
+
build/
|
| 11 |
+
develop-eggs/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
|
| 26 |
+
# Database
|
| 27 |
+
*.db
|
| 28 |
+
*.sqlite
|
| 29 |
+
*.sqlite3
|
| 30 |
+
data/*.db
|
| 31 |
+
|
| 32 |
+
# Logs
|
| 33 |
+
*.log
|
| 34 |
+
logs.txt
|
| 35 |
+
|
| 36 |
+
# IDE
|
| 37 |
+
.vscode/
|
| 38 |
+
.idea/
|
| 39 |
+
*.swp
|
| 40 |
+
*.swo
|
| 41 |
+
*~
|
| 42 |
+
.DS_Store
|
| 43 |
+
|
| 44 |
+
# Environment
|
| 45 |
+
.env
|
| 46 |
+
.env.local
|
| 47 |
+
|
| 48 |
+
# Config (optional)
|
| 49 |
+
# config/setting.toml
|
| 50 |
+
|
| 51 |
+
# Temporary files
|
| 52 |
+
*.tmp
|
| 53 |
+
*.bak
|
| 54 |
+
*.cache
|
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 8000
|
| 11 |
+
|
| 12 |
+
CMD ["python", "main.py"]
|
README.md
CHANGED
|
@@ -1 +1,260 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Flow2API
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+
[](LICENSE)
|
| 6 |
+
[](https://www.python.org/)
|
| 7 |
+
[](https://fastapi.tiangolo.com/)
|
| 8 |
+
[](https://www.docker.com/)
|
| 9 |
+
|
| 10 |
+
**一个功能完整的 OpenAI 兼容 API 服务,为 Flow 提供统一的接口**
|
| 11 |
+
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
## ✨ 核心特性
|
| 15 |
+
|
| 16 |
+
- 🎨 **文生图** / **图生图**
|
| 17 |
+
- 🎬 **文生视频** / **图生视频**
|
| 18 |
+
- 🎞️ **首尾帧视频**
|
| 19 |
+
- 🔄 **AT自动刷新**
|
| 20 |
+
- 📊 **余额显示** - 实时查询和显示 VideoFX Credits
|
| 21 |
+
- 🚀 **负载均衡** - 多 Token 轮询和并发控制
|
| 22 |
+
- 🌐 **代理支持** - 支持 HTTP/SOCKS5 代理
|
| 23 |
+
- 📱 **Web 管理界面** - 直观的 Token 和配置管理
|
| 24 |
+
|
| 25 |
+
## 🚀 快速开始
|
| 26 |
+
|
| 27 |
+
### 前置要求
|
| 28 |
+
|
| 29 |
+
- Docker 和 Docker Compose(推荐)
|
| 30 |
+
- 或 Python 3.8+
|
| 31 |
+
|
| 32 |
+
### 方式一:Docker 部署(推荐)
|
| 33 |
+
|
| 34 |
+
#### 标准模式(不使用代理)
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
# 克隆项目
|
| 38 |
+
git clone https://github.com/TheSmallHanCat/flow2api.git
|
| 39 |
+
cd sora2api
|
| 40 |
+
|
| 41 |
+
# 启动服务
|
| 42 |
+
docker-compose up -d
|
| 43 |
+
|
| 44 |
+
# 查看日志
|
| 45 |
+
docker-compose logs -f
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
#### WARP 模式(使用代理)
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
# 使用 WARP 代理启动
|
| 52 |
+
docker-compose -f docker-compose.warp.yml up -d
|
| 53 |
+
|
| 54 |
+
# 查看日志
|
| 55 |
+
docker-compose -f docker-compose.warp.yml logs -f
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### 方式二:本地部署
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
# 克隆项目
|
| 62 |
+
git clone https://github.com/TheSmallHanCat/flow2api.git
|
| 63 |
+
cd sora2api
|
| 64 |
+
|
| 65 |
+
# 创建虚拟环境
|
| 66 |
+
python -m venv venv
|
| 67 |
+
|
| 68 |
+
# 激活虚拟环境
|
| 69 |
+
# Windows
|
| 70 |
+
venv\Scripts\activate
|
| 71 |
+
# Linux/Mac
|
| 72 |
+
source venv/bin/activate
|
| 73 |
+
|
| 74 |
+
# 安装依赖
|
| 75 |
+
pip install -r requirements.txt
|
| 76 |
+
|
| 77 |
+
# 启动服务
|
| 78 |
+
python main.py
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
### 首次访问
|
| 82 |
+
|
| 83 |
+
服务启动后,访问管理后台: **http://localhost:8000**
|
| 84 |
+
|
| 85 |
+
- **用户名**: `admin`
|
| 86 |
+
- **密码**: `admin`
|
| 87 |
+
|
| 88 |
+
⚠️ **重要**: 首次登录后请立即修改密码!
|
| 89 |
+
|
| 90 |
+
## 📋 支持的模型
|
| 91 |
+
|
| 92 |
+
### 图片生成
|
| 93 |
+
|
| 94 |
+
| 模型名称 | 说明| 尺寸 |
|
| 95 |
+
|---------|--------|--------|
|
| 96 |
+
| `gemini-2.5-flash-image-landscape` | 图/文生图 | 横屏 |
|
| 97 |
+
| `gemini-2.5-flash-image-portrait` | 图/文生图 | 竖屏 |
|
| 98 |
+
| `gemini-3.0-pro-image-landscape` | 图/文生图 | 横屏 |
|
| 99 |
+
| `gemini-3.0-pro-image-portrait` | 图/文生图 | 竖屏 |
|
| 100 |
+
| `imagen-4.0-generate-preview-landscape` | 图/文生图 | 横屏 |
|
| 101 |
+
| `imagen-4.0-generate-preview-portrait` | 图/文生图 | 竖屏 |
|
| 102 |
+
|
| 103 |
+
### 视频生成
|
| 104 |
+
|
| 105 |
+
#### 文生视频 (T2V - Text to Video)
|
| 106 |
+
⚠️ **不支持上传图片**
|
| 107 |
+
|
| 108 |
+
| 模型名称 | 说明| 尺寸 |
|
| 109 |
+
|---------|---------|--------|
|
| 110 |
+
| `veo_3_1_t2v_fast_portrait` | 文生视频 | 竖屏 |
|
| 111 |
+
| `veo_3_1_t2v_fast_landscape` | 文生视频 | 横屏 |
|
| 112 |
+
| `veo_2_1_fast_d_15_t2v_portrait` | 文生视频 | 竖屏 |
|
| 113 |
+
| `veo_2_1_fast_d_15_t2v_landscape` | 文生视频 | 横屏 |
|
| 114 |
+
| `veo_2_0_t2v_portrait` | 文生视频 | 竖屏 |
|
| 115 |
+
| `veo_2_0_t2v_landscape` | 文生视频 | 横屏 |
|
| 116 |
+
|
| 117 |
+
#### 首尾帧模型 (I2V - Image to Video)
|
| 118 |
+
📸 **支持1-2张图片:首尾帧**
|
| 119 |
+
|
| 120 |
+
| 模型名称 | 说明| 尺寸 |
|
| 121 |
+
|---------|---------|--------|
|
| 122 |
+
| `veo_3_1_i2v_s_fast_fl_portrait` | 图生视频 | 竖屏 |
|
| 123 |
+
| `veo_3_1_i2v_s_fast_fl_landscape` | 图生视频 | 横屏 |
|
| 124 |
+
| `veo_2_1_fast_d_15_i2v_portrait` | 图生视频 | 竖屏 |
|
| 125 |
+
| `veo_2_1_fast_d_15_i2v_landscape` | 图生视频 | 横屏 |
|
| 126 |
+
| `veo_2_0_i2v_portrait` | 图生视频 | 竖屏 |
|
| 127 |
+
| `veo_2_0_i2v_landscape` | 图生视频 | 横屏 |
|
| 128 |
+
|
| 129 |
+
#### 多图生成 (R2V - Reference Images to Video)
|
| 130 |
+
🖼️ **支持多张图片**
|
| 131 |
+
|
| 132 |
+
| 模型名称 | 说明| 尺寸 |
|
| 133 |
+
|---------|---------|--------|
|
| 134 |
+
| `veo_3_0_r2v_fast_portrait` | 图生视频 | 竖屏 |
|
| 135 |
+
| `veo_3_0_r2v_fast_landscape` | 图生视频 | 横屏 |
|
| 136 |
+
|
| 137 |
+
## 📡 API 使用示例(需要使用流式)
|
| 138 |
+
|
| 139 |
+
### 文生图
|
| 140 |
+
|
| 141 |
+
```bash
|
| 142 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 143 |
+
-H "Authorization: Bearer han1234" \
|
| 144 |
+
-H "Content-Type: application/json" \
|
| 145 |
+
-d '{
|
| 146 |
+
"model": "gemini-2.5-flash-image-landscape",
|
| 147 |
+
"messages": [
|
| 148 |
+
{
|
| 149 |
+
"role": "user",
|
| 150 |
+
"content": "一只可爱的猫咪在花园里玩耍"
|
| 151 |
+
}
|
| 152 |
+
],
|
| 153 |
+
"stream": true
|
| 154 |
+
}'
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### 图生图
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 161 |
+
-H "Authorization: Bearer han1234" \
|
| 162 |
+
-H "Content-Type: application/json" \
|
| 163 |
+
-d '{
|
| 164 |
+
"model": "imagen-4.0-generate-preview-landscape",
|
| 165 |
+
"messages": [
|
| 166 |
+
{
|
| 167 |
+
"role": "user",
|
| 168 |
+
"content": [
|
| 169 |
+
{
|
| 170 |
+
"type": "text",
|
| 171 |
+
"text": "将这张图片变成水彩画风格"
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"type": "image_url",
|
| 175 |
+
"image_url": {
|
| 176 |
+
"url": "data:image/jpeg;base64,<base64_encoded_image>"
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
]
|
| 180 |
+
}
|
| 181 |
+
],
|
| 182 |
+
"stream": true
|
| 183 |
+
}'
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
### 文生视频
|
| 187 |
+
|
| 188 |
+
```bash
|
| 189 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 190 |
+
-H "Authorization: Bearer han1234" \
|
| 191 |
+
-H "Content-Type: application/json" \
|
| 192 |
+
-d '{
|
| 193 |
+
"model": "veo_3_1_t2v_fast_landscape",
|
| 194 |
+
"messages": [
|
| 195 |
+
{
|
| 196 |
+
"role": "user",
|
| 197 |
+
"content": "一只小猫在草地上追逐蝴蝶"
|
| 198 |
+
}
|
| 199 |
+
],
|
| 200 |
+
"stream": true
|
| 201 |
+
}'
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
### 首尾帧生成视频
|
| 205 |
+
|
| 206 |
+
```bash
|
| 207 |
+
curl -X POST "http://localhost:8000/v1/chat/completions" \
|
| 208 |
+
-H "Authorization: Bearer han1234" \
|
| 209 |
+
-H "Content-Type: application/json" \
|
| 210 |
+
-d '{
|
| 211 |
+
"model": "veo_3_1_i2v_s_fast_fl_landscape",
|
| 212 |
+
"messages": [
|
| 213 |
+
{
|
| 214 |
+
"role": "user",
|
| 215 |
+
"content": [
|
| 216 |
+
{
|
| 217 |
+
"type": "text",
|
| 218 |
+
"text": "从第一张图过渡到第二张图"
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"type": "image_url",
|
| 222 |
+
"image_url": {
|
| 223 |
+
"url": "data:image/jpeg;base64,<首帧base64>"
|
| 224 |
+
}
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"type": "image_url",
|
| 228 |
+
"image_url": {
|
| 229 |
+
"url": "data:image/jpeg;base64,<尾帧base64>"
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
]
|
| 233 |
+
}
|
| 234 |
+
],
|
| 235 |
+
"stream": true
|
| 236 |
+
}'
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
## 📄 许可证
|
| 242 |
+
|
| 243 |
+
本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
## 🙏 致谢
|
| 248 |
+
|
| 249 |
+
感谢所有贡献者和使用者的支持!
|
| 250 |
+
|
| 251 |
+
---
|
| 252 |
+
|
| 253 |
+
## 📞 联系方式
|
| 254 |
+
|
| 255 |
+
- 提交 Issue:[GitHub Issues](https://github.com/TheSmallHanCat/flow2api/issues)
|
| 256 |
+
- 讨论:[GitHub Discussions](https://github.com/TheSmallHanCat/flow2api/discussions)
|
| 257 |
+
|
| 258 |
+
---
|
| 259 |
+
|
| 260 |
+
**⭐ 如果这个项目对你有帮助,请给个 Star!**
|
config/setting.toml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[global]
|
| 2 |
+
api_key = "han1234"
|
| 3 |
+
admin_username = "admin"
|
| 4 |
+
admin_password = "admin"
|
| 5 |
+
|
| 6 |
+
[flow]
|
| 7 |
+
labs_base_url = "https://labs.google/fx/api"
|
| 8 |
+
api_base_url = "https://aisandbox-pa.googleapis.com/v1"
|
| 9 |
+
timeout = 120
|
| 10 |
+
max_retries = 3
|
| 11 |
+
poll_interval = 3.0
|
| 12 |
+
max_poll_attempts = 200
|
| 13 |
+
|
| 14 |
+
[server]
|
| 15 |
+
host = "0.0.0.0"
|
| 16 |
+
port = 8000
|
| 17 |
+
|
| 18 |
+
[debug]
|
| 19 |
+
enabled = false
|
| 20 |
+
log_requests = true
|
| 21 |
+
log_responses = true
|
| 22 |
+
mask_token = true
|
| 23 |
+
|
| 24 |
+
[proxy]
|
| 25 |
+
proxy_enabled = false
|
| 26 |
+
proxy_url = ""
|
| 27 |
+
|
| 28 |
+
[generation]
|
| 29 |
+
image_timeout = 300
|
| 30 |
+
video_timeout = 1500
|
| 31 |
+
|
| 32 |
+
[cache]
|
| 33 |
+
enabled = false
|
| 34 |
+
timeout = 7200 # 缓存超时时间(秒), 默认2小时
|
| 35 |
+
base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
|
config/setting_warp.toml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[global]
|
| 2 |
+
api_key = "han1234"
|
| 3 |
+
admin_username = "admin"
|
| 4 |
+
admin_password = "admin"
|
| 5 |
+
|
| 6 |
+
[flow]
|
| 7 |
+
labs_base_url = "https://labs.google/fx/api"
|
| 8 |
+
api_base_url = "https://aisandbox-pa.googleapis.com/v1"
|
| 9 |
+
timeout = 120
|
| 10 |
+
max_retries = 3
|
| 11 |
+
poll_interval = 3.0
|
| 12 |
+
max_poll_attempts = 200
|
| 13 |
+
|
| 14 |
+
[server]
|
| 15 |
+
host = "0.0.0.0"
|
| 16 |
+
port = 8000
|
| 17 |
+
|
| 18 |
+
[debug]
|
| 19 |
+
enabled = false
|
| 20 |
+
log_requests = true
|
| 21 |
+
log_responses = true
|
| 22 |
+
mask_token = true
|
| 23 |
+
|
| 24 |
+
[proxy]
|
| 25 |
+
proxy_enabled = true
|
| 26 |
+
proxy_url = "socks5://warp:1080"
|
| 27 |
+
|
| 28 |
+
[generation]
|
| 29 |
+
image_timeout = 300
|
| 30 |
+
video_timeout = 1500
|
| 31 |
+
|
| 32 |
+
[cache]
|
| 33 |
+
enabled = false
|
| 34 |
+
timeout = 7200 # 缓存超时时间(秒), 默认2小时
|
| 35 |
+
base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
|
docker-compose.proxy.yml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
flow2api:
|
| 5 |
+
image: thesmallhancat/flow2api:latest
|
| 6 |
+
container_name: flow2api
|
| 7 |
+
ports:
|
| 8 |
+
- "8000:8000"
|
| 9 |
+
volumes:
|
| 10 |
+
- ./data:/app/data
|
| 11 |
+
- ./config/setting_warp.toml:/app/config/setting.toml
|
| 12 |
+
environment:
|
| 13 |
+
- PYTHONUNBUFFERED=1
|
| 14 |
+
restart: unless-stopped
|
| 15 |
+
depends_on:
|
| 16 |
+
- warp
|
| 17 |
+
|
| 18 |
+
warp:
|
| 19 |
+
image: caomingjun/warp
|
| 20 |
+
container_name: warp
|
| 21 |
+
restart: always
|
| 22 |
+
devices:
|
| 23 |
+
- /dev/net/tun:/dev/net/tun
|
| 24 |
+
ports:
|
| 25 |
+
- "1080:1080"
|
| 26 |
+
environment:
|
| 27 |
+
- WARP_SLEEP=2
|
| 28 |
+
cap_add:
|
| 29 |
+
- MKNOD
|
| 30 |
+
- AUDIT_WRITE
|
| 31 |
+
- NET_ADMIN
|
| 32 |
+
sysctls:
|
| 33 |
+
- net.ipv6.conf.all.disable_ipv6=0
|
| 34 |
+
- net.ipv4.conf.all.src_valid_mark=1
|
| 35 |
+
volumes:
|
| 36 |
+
- ./data:/var/lib/cloudflare-warp
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
flow2api:
|
| 5 |
+
image: thesmallhancat/flow2api:latest
|
| 6 |
+
container_name: flow2api
|
| 7 |
+
ports:
|
| 8 |
+
- "8000:8000"
|
| 9 |
+
volumes:
|
| 10 |
+
- ./data:/app/data
|
| 11 |
+
- ./config/setting.toml:/app/config/setting.toml
|
| 12 |
+
environment:
|
| 13 |
+
- PYTHONUNBUFFERED=1
|
| 14 |
+
restart: unless-stopped
|
main.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flow2API - Main Entry Point"""
|
| 2 |
+
from src.main import app
|
| 3 |
+
import uvicorn
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
from src.core.config import config
|
| 7 |
+
|
| 8 |
+
uvicorn.run(
|
| 9 |
+
"src.main:app",
|
| 10 |
+
host=config.server_host,
|
| 11 |
+
port=config.server_port,
|
| 12 |
+
reload=False
|
| 13 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.119.0
|
| 2 |
+
uvicorn[standard]==0.32.1
|
| 3 |
+
aiosqlite==0.20.0
|
| 4 |
+
pydantic==2.10.4
|
| 5 |
+
curl-cffi==0.7.3
|
| 6 |
+
tomli==2.2.1
|
| 7 |
+
bcrypt==4.2.1
|
| 8 |
+
python-multipart==0.0.20
|
| 9 |
+
python-dateutil==2.8.2
|
src/api/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API modules"""
|
| 2 |
+
|
| 3 |
+
from .routes import router as api_router
|
| 4 |
+
from .admin import router as admin_router
|
| 5 |
+
|
| 6 |
+
__all__ = ["api_router", "admin_router"]
|
src/api/admin.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin API routes"""
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, Header
|
| 3 |
+
from fastapi.responses import JSONResponse
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from typing import Optional, List
|
| 6 |
+
from ..core.auth import AuthManager
|
| 7 |
+
from ..core.database import Database
|
| 8 |
+
from ..services.token_manager import TokenManager
|
| 9 |
+
from ..services.proxy_manager import ProxyManager
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
# Dependency injection
|
| 14 |
+
token_manager: TokenManager = None
|
| 15 |
+
proxy_manager: ProxyManager = None
|
| 16 |
+
db: Database = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database):
|
| 20 |
+
"""Set service instances"""
|
| 21 |
+
global token_manager, proxy_manager, db
|
| 22 |
+
token_manager = tm
|
| 23 |
+
proxy_manager = pm
|
| 24 |
+
db = database
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ========== Request Models ==========
|
| 28 |
+
|
| 29 |
+
class LoginRequest(BaseModel):
|
| 30 |
+
username: str
|
| 31 |
+
password: str
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class AddTokenRequest(BaseModel):
|
| 35 |
+
st: str
|
| 36 |
+
project_id: Optional[str] = None # 用户可选输入project_id
|
| 37 |
+
project_name: Optional[str] = None
|
| 38 |
+
remark: Optional[str] = None
|
| 39 |
+
image_enabled: bool = True
|
| 40 |
+
video_enabled: bool = True
|
| 41 |
+
image_concurrency: int = -1
|
| 42 |
+
video_concurrency: int = -1
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class UpdateTokenRequest(BaseModel):
|
| 46 |
+
st: str # Session Token (必填,用于刷新AT)
|
| 47 |
+
project_id: Optional[str] = None # 用户可选输入project_id
|
| 48 |
+
project_name: Optional[str] = None
|
| 49 |
+
remark: Optional[str] = None
|
| 50 |
+
image_enabled: Optional[bool] = None
|
| 51 |
+
video_enabled: Optional[bool] = None
|
| 52 |
+
image_concurrency: Optional[int] = None
|
| 53 |
+
video_concurrency: Optional[int] = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ProxyConfigRequest(BaseModel):
|
| 57 |
+
proxy_enabled: bool
|
| 58 |
+
proxy_url: Optional[str] = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class GenerationConfigRequest(BaseModel):
|
| 62 |
+
image_timeout: int
|
| 63 |
+
video_timeout: int
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ChangePasswordRequest(BaseModel):
|
| 67 |
+
old_password: str
|
| 68 |
+
new_password: str
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class UpdateAPIKeyRequest(BaseModel):
|
| 72 |
+
new_api_key: str
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class UpdateDebugConfigRequest(BaseModel):
|
| 76 |
+
enabled: bool
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ST2ATRequest(BaseModel):
|
| 80 |
+
"""ST转AT请求"""
|
| 81 |
+
st: str
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ========== Auth Middleware ==========
|
| 85 |
+
|
| 86 |
+
async def verify_admin_token(authorization: str = Header(None)):
|
| 87 |
+
"""Verify admin token"""
|
| 88 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 89 |
+
raise HTTPException(status_code=401, detail="Missing authorization")
|
| 90 |
+
|
| 91 |
+
token = authorization[7:]
|
| 92 |
+
admin_config = await db.get_admin_config()
|
| 93 |
+
|
| 94 |
+
# Simple token verification: check if matches api_key
|
| 95 |
+
if token != admin_config.api_key:
|
| 96 |
+
raise HTTPException(status_code=401, detail="Invalid admin token")
|
| 97 |
+
|
| 98 |
+
return token
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ========== Auth Endpoints ==========
|
| 102 |
+
|
| 103 |
+
@router.post("/api/admin/login")
|
| 104 |
+
async def admin_login(request: LoginRequest):
|
| 105 |
+
"""Admin login"""
|
| 106 |
+
admin_config = await db.get_admin_config()
|
| 107 |
+
|
| 108 |
+
if not AuthManager.verify_admin(request.username, request.password):
|
| 109 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
"success": True,
|
| 113 |
+
"token": admin_config.api_key,
|
| 114 |
+
"username": admin_config.username
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.post("/api/admin/change-password")
|
| 119 |
+
async def change_password(
|
| 120 |
+
request: ChangePasswordRequest,
|
| 121 |
+
token: str = Depends(verify_admin_token)
|
| 122 |
+
):
|
| 123 |
+
"""Change admin password"""
|
| 124 |
+
admin_config = await db.get_admin_config()
|
| 125 |
+
|
| 126 |
+
# Verify old password
|
| 127 |
+
if not AuthManager.verify_admin(admin_config.username, request.old_password):
|
| 128 |
+
raise HTTPException(status_code=400, detail="旧密码错误")
|
| 129 |
+
|
| 130 |
+
# Update password
|
| 131 |
+
await db.update_admin_config(password=request.new_password)
|
| 132 |
+
|
| 133 |
+
return {"success": True, "message": "密码修改成功"}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ========== Token Management ==========
|
| 137 |
+
|
| 138 |
+
@router.get("/api/tokens")
|
| 139 |
+
async def get_tokens(token: str = Depends(verify_admin_token)):
|
| 140 |
+
"""Get all tokens with statistics"""
|
| 141 |
+
tokens = await token_manager.get_all_tokens()
|
| 142 |
+
result = []
|
| 143 |
+
|
| 144 |
+
for t in tokens:
|
| 145 |
+
stats = await db.get_token_stats(t.id)
|
| 146 |
+
|
| 147 |
+
result.append({
|
| 148 |
+
"id": t.id,
|
| 149 |
+
"st": t.st, # Session Token for editing
|
| 150 |
+
"at": t.at, # Access Token for editing (从ST转换而来)
|
| 151 |
+
"at_expires": t.at_expires.isoformat() if t.at_expires else None, # 🆕 AT过期时间
|
| 152 |
+
"token": t.at, # 兼容前端 token.token 的访问方式
|
| 153 |
+
"email": t.email,
|
| 154 |
+
"name": t.name,
|
| 155 |
+
"remark": t.remark,
|
| 156 |
+
"is_active": t.is_active,
|
| 157 |
+
"created_at": t.created_at.isoformat() if t.created_at else None,
|
| 158 |
+
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
|
| 159 |
+
"use_count": t.use_count,
|
| 160 |
+
"credits": t.credits, # 🆕 余额
|
| 161 |
+
"user_paygate_tier": t.user_paygate_tier,
|
| 162 |
+
"current_project_id": t.current_project_id, # 🆕 项目ID
|
| 163 |
+
"current_project_name": t.current_project_name, # 🆕 项目名称
|
| 164 |
+
"image_enabled": t.image_enabled,
|
| 165 |
+
"video_enabled": t.video_enabled,
|
| 166 |
+
"image_concurrency": t.image_concurrency,
|
| 167 |
+
"video_concurrency": t.video_concurrency,
|
| 168 |
+
"image_count": stats.image_count if stats else 0,
|
| 169 |
+
"video_count": stats.video_count if stats else 0,
|
| 170 |
+
"error_count": stats.error_count if stats else 0
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
return result # 直接返回数组,兼容前端
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.post("/api/tokens")
|
| 177 |
+
async def add_token(
|
| 178 |
+
request: AddTokenRequest,
|
| 179 |
+
token: str = Depends(verify_admin_token)
|
| 180 |
+
):
|
| 181 |
+
"""Add a new token"""
|
| 182 |
+
try:
|
| 183 |
+
new_token = await token_manager.add_token(
|
| 184 |
+
st=request.st,
|
| 185 |
+
project_id=request.project_id, # 🆕 支持用户指定project_id
|
| 186 |
+
project_name=request.project_name,
|
| 187 |
+
remark=request.remark,
|
| 188 |
+
image_enabled=request.image_enabled,
|
| 189 |
+
video_enabled=request.video_enabled,
|
| 190 |
+
image_concurrency=request.image_concurrency,
|
| 191 |
+
video_concurrency=request.video_concurrency
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
return {
|
| 195 |
+
"success": True,
|
| 196 |
+
"message": "Token添加成功",
|
| 197 |
+
"token": {
|
| 198 |
+
"id": new_token.id,
|
| 199 |
+
"email": new_token.email,
|
| 200 |
+
"credits": new_token.credits,
|
| 201 |
+
"project_id": new_token.current_project_id,
|
| 202 |
+
"project_name": new_token.current_project_name
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
except ValueError as e:
|
| 206 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 207 |
+
except Exception as e:
|
| 208 |
+
raise HTTPException(status_code=500, detail=f"添加Token失败: {str(e)}")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@router.put("/api/tokens/{token_id}")
|
| 212 |
+
async def update_token(
|
| 213 |
+
token_id: int,
|
| 214 |
+
request: UpdateTokenRequest,
|
| 215 |
+
token: str = Depends(verify_admin_token)
|
| 216 |
+
):
|
| 217 |
+
"""Update token - 使用ST自动刷新AT"""
|
| 218 |
+
try:
|
| 219 |
+
# 先ST转AT
|
| 220 |
+
result = await token_manager.flow_client.st_to_at(request.st)
|
| 221 |
+
at = result["access_token"]
|
| 222 |
+
expires = result.get("expires")
|
| 223 |
+
|
| 224 |
+
# 解析过期时间
|
| 225 |
+
from datetime import datetime
|
| 226 |
+
at_expires = None
|
| 227 |
+
if expires:
|
| 228 |
+
try:
|
| 229 |
+
at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
|
| 230 |
+
except:
|
| 231 |
+
pass
|
| 232 |
+
|
| 233 |
+
# 更新token (包含AT、ST、AT过期时间、project_id和project_name)
|
| 234 |
+
await token_manager.update_token(
|
| 235 |
+
token_id=token_id,
|
| 236 |
+
st=request.st,
|
| 237 |
+
at=at,
|
| 238 |
+
at_expires=at_expires, # 🆕 更新AT过期时间
|
| 239 |
+
project_id=request.project_id,
|
| 240 |
+
project_name=request.project_name,
|
| 241 |
+
remark=request.remark,
|
| 242 |
+
image_enabled=request.image_enabled,
|
| 243 |
+
video_enabled=request.video_enabled,
|
| 244 |
+
image_concurrency=request.image_concurrency,
|
| 245 |
+
video_concurrency=request.video_concurrency
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
return {"success": True, "message": "Token更新成功"}
|
| 249 |
+
except Exception as e:
|
| 250 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@router.delete("/api/tokens/{token_id}")
|
| 254 |
+
async def delete_token(
|
| 255 |
+
token_id: int,
|
| 256 |
+
token: str = Depends(verify_admin_token)
|
| 257 |
+
):
|
| 258 |
+
"""Delete token"""
|
| 259 |
+
try:
|
| 260 |
+
await token_manager.delete_token(token_id)
|
| 261 |
+
return {"success": True, "message": "Token删除成功"}
|
| 262 |
+
except Exception as e:
|
| 263 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@router.post("/api/tokens/{token_id}/enable")
|
| 267 |
+
async def enable_token(
|
| 268 |
+
token_id: int,
|
| 269 |
+
token: str = Depends(verify_admin_token)
|
| 270 |
+
):
|
| 271 |
+
"""Enable token"""
|
| 272 |
+
await token_manager.enable_token(token_id)
|
| 273 |
+
return {"success": True, "message": "Token已启用"}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
@router.post("/api/tokens/{token_id}/disable")
|
| 277 |
+
async def disable_token(
|
| 278 |
+
token_id: int,
|
| 279 |
+
token: str = Depends(verify_admin_token)
|
| 280 |
+
):
|
| 281 |
+
"""Disable token"""
|
| 282 |
+
await token_manager.disable_token(token_id)
|
| 283 |
+
return {"success": True, "message": "Token已禁用"}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@router.post("/api/tokens/{token_id}/refresh-credits")
|
| 287 |
+
async def refresh_credits(
|
| 288 |
+
token_id: int,
|
| 289 |
+
token: str = Depends(verify_admin_token)
|
| 290 |
+
):
|
| 291 |
+
"""刷新Token余额 🆕"""
|
| 292 |
+
try:
|
| 293 |
+
credits = await token_manager.refresh_credits(token_id)
|
| 294 |
+
return {
|
| 295 |
+
"success": True,
|
| 296 |
+
"message": "余额刷新成功",
|
| 297 |
+
"credits": credits
|
| 298 |
+
}
|
| 299 |
+
except Exception as e:
|
| 300 |
+
raise HTTPException(status_code=500, detail=f"刷新余额失败: {str(e)}")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@router.post("/api/tokens/{token_id}/refresh-at")
|
| 304 |
+
async def refresh_at(
|
| 305 |
+
token_id: int,
|
| 306 |
+
token: str = Depends(verify_admin_token)
|
| 307 |
+
):
|
| 308 |
+
"""手动刷新Token的AT (使用ST转换) 🆕"""
|
| 309 |
+
try:
|
| 310 |
+
# 调用token_manager的内部刷新方法
|
| 311 |
+
success = await token_manager._refresh_at(token_id)
|
| 312 |
+
|
| 313 |
+
if success:
|
| 314 |
+
# 获取更新后的token信息
|
| 315 |
+
updated_token = await token_manager.get_token(token_id)
|
| 316 |
+
return {
|
| 317 |
+
"success": True,
|
| 318 |
+
"message": "AT刷新成功",
|
| 319 |
+
"token": {
|
| 320 |
+
"id": updated_token.id,
|
| 321 |
+
"email": updated_token.email,
|
| 322 |
+
"at_expires": updated_token.at_expires.isoformat() if updated_token.at_expires else None
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
else:
|
| 326 |
+
raise HTTPException(status_code=500, detail="AT刷新失败")
|
| 327 |
+
except Exception as e:
|
| 328 |
+
raise HTTPException(status_code=500, detail=f"刷新AT失败: {str(e)}")
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@router.post("/api/tokens/st2at")
|
| 332 |
+
async def st_to_at(
|
| 333 |
+
request: ST2ATRequest,
|
| 334 |
+
token: str = Depends(verify_admin_token)
|
| 335 |
+
):
|
| 336 |
+
"""Convert Session Token to Access Token (仅转换,不添加到数据库)"""
|
| 337 |
+
try:
|
| 338 |
+
result = await token_manager.flow_client.st_to_at(request.st)
|
| 339 |
+
return {
|
| 340 |
+
"success": True,
|
| 341 |
+
"message": "ST converted to AT successfully",
|
| 342 |
+
"access_token": result["access_token"],
|
| 343 |
+
"email": result.get("user", {}).get("email"),
|
| 344 |
+
"expires": result.get("expires")
|
| 345 |
+
}
|
| 346 |
+
except Exception as e:
|
| 347 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# ========== Config Management ==========
|
| 351 |
+
|
| 352 |
+
@router.get("/api/config/proxy")
|
| 353 |
+
async def get_proxy_config(token: str = Depends(verify_admin_token)):
|
| 354 |
+
"""Get proxy configuration"""
|
| 355 |
+
config = await proxy_manager.get_proxy_config()
|
| 356 |
+
return {
|
| 357 |
+
"success": True,
|
| 358 |
+
"config": {
|
| 359 |
+
"enabled": config.enabled,
|
| 360 |
+
"proxy_url": config.proxy_url
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@router.get("/api/proxy/config")
|
| 366 |
+
async def get_proxy_config_alias(token: str = Depends(verify_admin_token)):
|
| 367 |
+
"""Get proxy configuration (alias for frontend compatibility)"""
|
| 368 |
+
config = await proxy_manager.get_proxy_config()
|
| 369 |
+
return {
|
| 370 |
+
"proxy_enabled": config.enabled, # Frontend expects proxy_enabled
|
| 371 |
+
"proxy_url": config.proxy_url
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@router.post("/api/proxy/config")
|
| 376 |
+
async def update_proxy_config_alias(
|
| 377 |
+
request: ProxyConfigRequest,
|
| 378 |
+
token: str = Depends(verify_admin_token)
|
| 379 |
+
):
|
| 380 |
+
"""Update proxy configuration (alias for frontend compatibility)"""
|
| 381 |
+
await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
|
| 382 |
+
return {"success": True, "message": "代理配置更新成功"}
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
@router.post("/api/config/proxy")
|
| 386 |
+
async def update_proxy_config(
|
| 387 |
+
request: ProxyConfigRequest,
|
| 388 |
+
token: str = Depends(verify_admin_token)
|
| 389 |
+
):
|
| 390 |
+
"""Update proxy configuration"""
|
| 391 |
+
await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
|
| 392 |
+
return {"success": True, "message": "代理配置更新成功"}
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
@router.get("/api/config/generation")
|
| 396 |
+
async def get_generation_config(token: str = Depends(verify_admin_token)):
|
| 397 |
+
"""Get generation timeout configuration"""
|
| 398 |
+
config = await db.get_generation_config()
|
| 399 |
+
return {
|
| 400 |
+
"success": True,
|
| 401 |
+
"config": {
|
| 402 |
+
"image_timeout": config.image_timeout,
|
| 403 |
+
"video_timeout": config.video_timeout
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
@router.post("/api/config/generation")
|
| 409 |
+
async def update_generation_config(
|
| 410 |
+
request: GenerationConfigRequest,
|
| 411 |
+
token: str = Depends(verify_admin_token)
|
| 412 |
+
):
|
| 413 |
+
"""Update generation timeout configuration"""
|
| 414 |
+
await db.update_generation_config(request.image_timeout, request.video_timeout)
|
| 415 |
+
return {"success": True, "message": "生成配置更新成功"}
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# ========== System Info ==========
|
| 419 |
+
|
| 420 |
+
@router.get("/api/system/info")
|
| 421 |
+
async def get_system_info(token: str = Depends(verify_admin_token)):
|
| 422 |
+
"""Get system information"""
|
| 423 |
+
tokens = await token_manager.get_all_tokens()
|
| 424 |
+
active_tokens = [t for t in tokens if t.is_active]
|
| 425 |
+
|
| 426 |
+
total_credits = sum(t.credits for t in active_tokens)
|
| 427 |
+
|
| 428 |
+
return {
|
| 429 |
+
"success": True,
|
| 430 |
+
"info": {
|
| 431 |
+
"total_tokens": len(tokens),
|
| 432 |
+
"active_tokens": len(active_tokens),
|
| 433 |
+
"total_credits": total_credits,
|
| 434 |
+
"version": "1.0.0"
|
| 435 |
+
}
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
# ========== Additional Routes for Frontend Compatibility ==========
|
| 440 |
+
|
| 441 |
+
@router.post("/api/login")
|
| 442 |
+
async def login(request: LoginRequest):
|
| 443 |
+
"""Login endpoint (alias for /api/admin/login)"""
|
| 444 |
+
return await admin_login(request)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
@router.get("/api/stats")
|
| 448 |
+
async def get_stats(token: str = Depends(verify_admin_token)):
|
| 449 |
+
"""Get statistics for dashboard"""
|
| 450 |
+
tokens = await token_manager.get_all_tokens()
|
| 451 |
+
active_tokens = [t for t in tokens if t.is_active]
|
| 452 |
+
|
| 453 |
+
# Calculate totals
|
| 454 |
+
total_images = 0
|
| 455 |
+
total_videos = 0
|
| 456 |
+
total_errors = 0
|
| 457 |
+
today_images = 0
|
| 458 |
+
today_videos = 0
|
| 459 |
+
today_errors = 0
|
| 460 |
+
|
| 461 |
+
for t in tokens:
|
| 462 |
+
stats = await db.get_token_stats(t.id)
|
| 463 |
+
if stats:
|
| 464 |
+
total_images += stats.image_count
|
| 465 |
+
total_videos += stats.video_count
|
| 466 |
+
total_errors += stats.error_count
|
| 467 |
+
today_images += stats.today_image_count
|
| 468 |
+
today_videos += stats.today_video_count
|
| 469 |
+
today_errors += stats.today_error_count
|
| 470 |
+
|
| 471 |
+
return {
|
| 472 |
+
"total_tokens": len(tokens),
|
| 473 |
+
"active_tokens": len(active_tokens),
|
| 474 |
+
"total_images": total_images,
|
| 475 |
+
"total_videos": total_videos,
|
| 476 |
+
"total_errors": total_errors,
|
| 477 |
+
"today_images": today_images,
|
| 478 |
+
"today_videos": today_videos,
|
| 479 |
+
"today_errors": today_errors
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
@router.get("/api/logs")
|
| 484 |
+
async def get_logs(
|
| 485 |
+
limit: int = 100,
|
| 486 |
+
token: str = Depends(verify_admin_token)
|
| 487 |
+
):
|
| 488 |
+
"""Get request logs with token email"""
|
| 489 |
+
logs = await db.get_logs(limit=limit)
|
| 490 |
+
|
| 491 |
+
return [{
|
| 492 |
+
"id": log.get("id"),
|
| 493 |
+
"token_id": log.get("token_id"),
|
| 494 |
+
"token_email": log.get("token_email"),
|
| 495 |
+
"token_username": log.get("token_username"),
|
| 496 |
+
"operation": log.get("operation"),
|
| 497 |
+
"status_code": log.get("status_code"),
|
| 498 |
+
"duration": log.get("duration"),
|
| 499 |
+
"created_at": log.get("created_at")
|
| 500 |
+
} for log in logs]
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
@router.get("/api/admin/config")
|
| 504 |
+
async def get_admin_config(token: str = Depends(verify_admin_token)):
|
| 505 |
+
"""Get admin configuration"""
|
| 506 |
+
from ..core.config import config
|
| 507 |
+
|
| 508 |
+
admin_config = await db.get_admin_config()
|
| 509 |
+
|
| 510 |
+
return {
|
| 511 |
+
"admin_username": admin_config.username,
|
| 512 |
+
"api_key": admin_config.api_key,
|
| 513 |
+
"error_ban_threshold": 3, # Default value
|
| 514 |
+
"debug_enabled": config.debug_enabled # Return actual debug status
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
@router.post("/api/admin/password")
|
| 519 |
+
async def update_admin_password(
|
| 520 |
+
request: ChangePasswordRequest,
|
| 521 |
+
token: str = Depends(verify_admin_token)
|
| 522 |
+
):
|
| 523 |
+
"""Update admin password"""
|
| 524 |
+
return await change_password(request, token)
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
@router.post("/api/admin/apikey")
|
| 528 |
+
async def update_api_key(
|
| 529 |
+
request: UpdateAPIKeyRequest,
|
| 530 |
+
token: str = Depends(verify_admin_token)
|
| 531 |
+
):
|
| 532 |
+
"""Update API key"""
|
| 533 |
+
await db.update_admin_config(api_key=request.new_api_key)
|
| 534 |
+
return {"success": True, "message": "API Key更新成功"}
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
@router.post("/api/admin/debug")
|
| 538 |
+
async def update_debug_config(
|
| 539 |
+
request: UpdateDebugConfigRequest,
|
| 540 |
+
token: str = Depends(verify_admin_token)
|
| 541 |
+
):
|
| 542 |
+
"""Update debug configuration"""
|
| 543 |
+
try:
|
| 544 |
+
# Import config instance
|
| 545 |
+
from ..core.config import config
|
| 546 |
+
|
| 547 |
+
# Update in-memory config
|
| 548 |
+
config.set_debug_enabled(request.enabled)
|
| 549 |
+
|
| 550 |
+
status = "enabled" if request.enabled else "disabled"
|
| 551 |
+
return {"success": True, "message": f"Debug mode {status}", "enabled": request.enabled}
|
| 552 |
+
except Exception as e:
|
| 553 |
+
raise HTTPException(status_code=500, detail=f"Failed to update debug config: {str(e)}")
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
@router.get("/api/generation/timeout")
|
| 557 |
+
async def get_generation_timeout(token: str = Depends(verify_admin_token)):
|
| 558 |
+
"""Get generation timeout configuration"""
|
| 559 |
+
return await get_generation_config(token)
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
@router.post("/api/generation/timeout")
|
| 563 |
+
async def update_generation_timeout(
|
| 564 |
+
request: GenerationConfigRequest,
|
| 565 |
+
token: str = Depends(verify_admin_token)
|
| 566 |
+
):
|
| 567 |
+
"""Update generation timeout configuration"""
|
| 568 |
+
return await update_generation_config(request, token)
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
# ========== AT Auto Refresh Config ==========
|
| 572 |
+
|
| 573 |
+
@router.get("/api/token-refresh/config")
|
| 574 |
+
async def get_token_refresh_config(token: str = Depends(verify_admin_token)):
|
| 575 |
+
"""Get AT auto refresh configuration (默认启用)"""
|
| 576 |
+
return {
|
| 577 |
+
"success": True,
|
| 578 |
+
"config": {
|
| 579 |
+
"at_auto_refresh_enabled": True # Flow2API默认启用AT自动刷新
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
@router.post("/api/token-refresh/enabled")
|
| 585 |
+
async def update_token_refresh_enabled(
|
| 586 |
+
token: str = Depends(verify_admin_token)
|
| 587 |
+
):
|
| 588 |
+
"""Update AT auto refresh enabled (Flow2API固定启用,此接口仅用于前端兼容)"""
|
| 589 |
+
return {
|
| 590 |
+
"success": True,
|
| 591 |
+
"message": "Flow2API的AT自动刷新默认启用且无法关闭"
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
# ========== Cache Configuration Endpoints ==========
|
| 596 |
+
|
| 597 |
+
@router.get("/api/cache/config")
|
| 598 |
+
async def get_cache_config(token: str = Depends(verify_admin_token)):
|
| 599 |
+
"""Get cache configuration"""
|
| 600 |
+
cache_config = await db.get_cache_config()
|
| 601 |
+
|
| 602 |
+
# Calculate effective base URL
|
| 603 |
+
effective_base_url = cache_config.cache_base_url if cache_config.cache_base_url else f"http://127.0.0.1:8000"
|
| 604 |
+
|
| 605 |
+
return {
|
| 606 |
+
"success": True,
|
| 607 |
+
"config": {
|
| 608 |
+
"enabled": cache_config.cache_enabled,
|
| 609 |
+
"timeout": cache_config.cache_timeout,
|
| 610 |
+
"base_url": cache_config.cache_base_url or "",
|
| 611 |
+
"effective_base_url": effective_base_url
|
| 612 |
+
}
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
@router.post("/api/cache/enabled")
|
| 617 |
+
async def update_cache_enabled(
|
| 618 |
+
request: dict,
|
| 619 |
+
token: str = Depends(verify_admin_token)
|
| 620 |
+
):
|
| 621 |
+
"""Update cache enabled status"""
|
| 622 |
+
enabled = request.get("enabled", False)
|
| 623 |
+
await db.update_cache_config(enabled=enabled)
|
| 624 |
+
|
| 625 |
+
# Update runtime config
|
| 626 |
+
from ..core.config import config
|
| 627 |
+
config.set_cache_enabled(enabled)
|
| 628 |
+
|
| 629 |
+
return {"success": True, "message": f"缓存已{'启用' if enabled else '禁用'}"}
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
@router.post("/api/cache/config")
|
| 633 |
+
async def update_cache_config_full(
|
| 634 |
+
request: dict,
|
| 635 |
+
token: str = Depends(verify_admin_token)
|
| 636 |
+
):
|
| 637 |
+
"""Update complete cache configuration"""
|
| 638 |
+
enabled = request.get("enabled")
|
| 639 |
+
timeout = request.get("timeout")
|
| 640 |
+
base_url = request.get("base_url")
|
| 641 |
+
|
| 642 |
+
await db.update_cache_config(enabled=enabled, timeout=timeout, base_url=base_url)
|
| 643 |
+
|
| 644 |
+
# Update runtime config
|
| 645 |
+
from ..core.config import config
|
| 646 |
+
if enabled is not None:
|
| 647 |
+
config.set_cache_enabled(enabled)
|
| 648 |
+
if timeout is not None:
|
| 649 |
+
config.set_cache_timeout(timeout)
|
| 650 |
+
if base_url is not None:
|
| 651 |
+
config.set_cache_base_url(base_url)
|
| 652 |
+
|
| 653 |
+
return {"success": True, "message": "缓存配置更新成功"}
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
@router.post("/api/cache/base-url")
|
| 657 |
+
async def update_cache_base_url(
|
| 658 |
+
request: dict,
|
| 659 |
+
token: str = Depends(verify_admin_token)
|
| 660 |
+
):
|
| 661 |
+
"""Update cache base URL"""
|
| 662 |
+
base_url = request.get("base_url", "")
|
| 663 |
+
await db.update_cache_config(base_url=base_url)
|
| 664 |
+
|
| 665 |
+
# Update runtime config
|
| 666 |
+
from ..core.config import config
|
| 667 |
+
config.set_cache_base_url(base_url)
|
| 668 |
+
|
| 669 |
+
return {"success": True, "message": "缓存Base URL更新成功"}
|
src/api/routes.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API routes - OpenAI compatible endpoints"""
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
+
from fastapi.responses import StreamingResponse, JSONResponse
|
| 4 |
+
from typing import List
|
| 5 |
+
import base64
|
| 6 |
+
import re
|
| 7 |
+
import json
|
| 8 |
+
from ..core.auth import verify_api_key_header
|
| 9 |
+
from ..core.models import ChatCompletionRequest
|
| 10 |
+
from ..services.generation_handler import GenerationHandler, MODEL_CONFIG
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
# Dependency injection will be set up in main.py
|
| 15 |
+
generation_handler: GenerationHandler = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def set_generation_handler(handler: GenerationHandler):
|
| 19 |
+
"""Set generation handler instance"""
|
| 20 |
+
global generation_handler
|
| 21 |
+
generation_handler = handler
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/v1/models")
|
| 25 |
+
async def list_models(api_key: str = Depends(verify_api_key_header)):
|
| 26 |
+
"""List available models"""
|
| 27 |
+
models = []
|
| 28 |
+
|
| 29 |
+
for model_id, config in MODEL_CONFIG.items():
|
| 30 |
+
description = f"{config['type'].capitalize()} generation"
|
| 31 |
+
if config['type'] == 'image':
|
| 32 |
+
description += f" - {config['model_name']}"
|
| 33 |
+
else:
|
| 34 |
+
description += f" - {config['model_key']}"
|
| 35 |
+
|
| 36 |
+
models.append({
|
| 37 |
+
"id": model_id,
|
| 38 |
+
"object": "model",
|
| 39 |
+
"owned_by": "flow2api",
|
| 40 |
+
"description": description
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
return {
|
| 44 |
+
"object": "list",
|
| 45 |
+
"data": models
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.post("/v1/chat/completions")
|
| 50 |
+
async def create_chat_completion(
|
| 51 |
+
request: ChatCompletionRequest,
|
| 52 |
+
api_key: str = Depends(verify_api_key_header)
|
| 53 |
+
):
|
| 54 |
+
"""Create chat completion (unified endpoint for image and video generation)"""
|
| 55 |
+
try:
|
| 56 |
+
# Extract prompt from messages
|
| 57 |
+
if not request.messages:
|
| 58 |
+
raise HTTPException(status_code=400, detail="Messages cannot be empty")
|
| 59 |
+
|
| 60 |
+
last_message = request.messages[-1]
|
| 61 |
+
content = last_message.content
|
| 62 |
+
|
| 63 |
+
# Handle both string and array format (OpenAI multimodal)
|
| 64 |
+
prompt = ""
|
| 65 |
+
images: List[bytes] = []
|
| 66 |
+
|
| 67 |
+
if isinstance(content, str):
|
| 68 |
+
# Simple text format
|
| 69 |
+
prompt = content
|
| 70 |
+
elif isinstance(content, list):
|
| 71 |
+
# Multimodal format
|
| 72 |
+
for item in content:
|
| 73 |
+
if item.get("type") == "text":
|
| 74 |
+
prompt = item.get("text", "")
|
| 75 |
+
elif item.get("type") == "image_url":
|
| 76 |
+
# Extract base64 image
|
| 77 |
+
image_url = item.get("image_url", {}).get("url", "")
|
| 78 |
+
if image_url.startswith("data:image"):
|
| 79 |
+
# Parse base64
|
| 80 |
+
match = re.search(r"base64,(.+)", image_url)
|
| 81 |
+
if match:
|
| 82 |
+
image_base64 = match.group(1)
|
| 83 |
+
image_bytes = base64.b64decode(image_base64)
|
| 84 |
+
images.append(image_bytes)
|
| 85 |
+
|
| 86 |
+
# Fallback to deprecated image parameter
|
| 87 |
+
if request.image and not images:
|
| 88 |
+
if request.image.startswith("data:image"):
|
| 89 |
+
match = re.search(r"base64,(.+)", request.image)
|
| 90 |
+
if match:
|
| 91 |
+
image_base64 = match.group(1)
|
| 92 |
+
image_bytes = base64.b64decode(image_base64)
|
| 93 |
+
images.append(image_bytes)
|
| 94 |
+
|
| 95 |
+
if not prompt:
|
| 96 |
+
raise HTTPException(status_code=400, detail="Prompt cannot be empty")
|
| 97 |
+
|
| 98 |
+
# Call generation handler
|
| 99 |
+
if request.stream:
|
| 100 |
+
# Streaming response
|
| 101 |
+
async def generate():
|
| 102 |
+
async for chunk in generation_handler.handle_generation(
|
| 103 |
+
model=request.model,
|
| 104 |
+
prompt=prompt,
|
| 105 |
+
images=images if images else None,
|
| 106 |
+
stream=True
|
| 107 |
+
):
|
| 108 |
+
yield chunk
|
| 109 |
+
|
| 110 |
+
# Send [DONE] signal
|
| 111 |
+
yield "data: [DONE]\n\n"
|
| 112 |
+
|
| 113 |
+
return StreamingResponse(
|
| 114 |
+
generate(),
|
| 115 |
+
media_type="text/event-stream",
|
| 116 |
+
headers={
|
| 117 |
+
"Cache-Control": "no-cache",
|
| 118 |
+
"Connection": "keep-alive",
|
| 119 |
+
"X-Accel-Buffering": "no"
|
| 120 |
+
}
|
| 121 |
+
)
|
| 122 |
+
else:
|
| 123 |
+
# Non-streaming response
|
| 124 |
+
result = None
|
| 125 |
+
async for chunk in generation_handler.handle_generation(
|
| 126 |
+
model=request.model,
|
| 127 |
+
prompt=prompt,
|
| 128 |
+
images=images if images else None,
|
| 129 |
+
stream=False
|
| 130 |
+
):
|
| 131 |
+
result = chunk
|
| 132 |
+
|
| 133 |
+
if result:
|
| 134 |
+
# Parse the result JSON string
|
| 135 |
+
try:
|
| 136 |
+
result_json = json.loads(result)
|
| 137 |
+
return JSONResponse(content=result_json)
|
| 138 |
+
except json.JSONDecodeError:
|
| 139 |
+
# If not JSON, return as-is
|
| 140 |
+
return JSONResponse(content={"result": result})
|
| 141 |
+
else:
|
| 142 |
+
raise HTTPException(status_code=500, detail="Generation failed: No response from handler")
|
| 143 |
+
|
| 144 |
+
except HTTPException:
|
| 145 |
+
raise
|
| 146 |
+
except Exception as e:
|
| 147 |
+
raise HTTPException(status_code=500, detail=str(e))
|
src/core/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core modules"""
|
| 2 |
+
|
| 3 |
+
from .config import config
|
| 4 |
+
from .auth import AuthManager, verify_api_key_header
|
| 5 |
+
from .logger import debug_logger
|
| 6 |
+
|
| 7 |
+
__all__ = ["config", "AuthManager", "verify_api_key_header", "debug_logger"]
|
src/core/auth.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication module"""
|
| 2 |
+
import bcrypt
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from fastapi import HTTPException, Security
|
| 5 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 6 |
+
from .config import config
|
| 7 |
+
|
| 8 |
+
security = HTTPBearer()
|
| 9 |
+
|
| 10 |
+
class AuthManager:
|
| 11 |
+
"""Authentication manager"""
|
| 12 |
+
|
| 13 |
+
@staticmethod
|
| 14 |
+
def verify_api_key(api_key: str) -> bool:
|
| 15 |
+
"""Verify API key"""
|
| 16 |
+
return api_key == config.api_key
|
| 17 |
+
|
| 18 |
+
@staticmethod
|
| 19 |
+
def verify_admin(username: str, password: str) -> bool:
|
| 20 |
+
"""Verify admin credentials"""
|
| 21 |
+
# Compare with current config (which may be from database or config file)
|
| 22 |
+
return username == config.admin_username and password == config.admin_password
|
| 23 |
+
|
| 24 |
+
@staticmethod
|
| 25 |
+
def hash_password(password: str) -> str:
|
| 26 |
+
"""Hash password"""
|
| 27 |
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
| 28 |
+
|
| 29 |
+
@staticmethod
|
| 30 |
+
def verify_password(password: str, hashed: str) -> bool:
|
| 31 |
+
"""Verify password"""
|
| 32 |
+
return bcrypt.checkpw(password.encode(), hashed.encode())
|
| 33 |
+
|
| 34 |
+
async def verify_api_key_header(credentials: HTTPAuthorizationCredentials = Security(security)) -> str:
|
| 35 |
+
"""Verify API key from Authorization header"""
|
| 36 |
+
api_key = credentials.credentials
|
| 37 |
+
if not AuthManager.verify_api_key(api_key):
|
| 38 |
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
| 39 |
+
return api_key
|
src/core/config.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration management for Flow2API"""
|
| 2 |
+
import tomli
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Dict, Any, Optional
|
| 5 |
+
|
| 6 |
+
class Config:
|
| 7 |
+
"""Application configuration"""
|
| 8 |
+
|
| 9 |
+
def __init__(self):
|
| 10 |
+
self._config = self._load_config()
|
| 11 |
+
self._admin_username: Optional[str] = None
|
| 12 |
+
self._admin_password: Optional[str] = None
|
| 13 |
+
|
| 14 |
+
def _load_config(self) -> Dict[str, Any]:
|
| 15 |
+
"""Load configuration from setting.toml"""
|
| 16 |
+
config_path = Path(__file__).parent.parent.parent / "config" / "setting.toml"
|
| 17 |
+
with open(config_path, "rb") as f:
|
| 18 |
+
return tomli.load(f)
|
| 19 |
+
|
| 20 |
+
def reload_config(self):
|
| 21 |
+
"""Reload configuration from file"""
|
| 22 |
+
self._config = self._load_config()
|
| 23 |
+
|
| 24 |
+
def get_raw_config(self) -> Dict[str, Any]:
|
| 25 |
+
"""Get raw configuration dictionary"""
|
| 26 |
+
return self._config
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def admin_username(self) -> str:
|
| 30 |
+
# If admin_username is set from database, use it; otherwise fall back to config file
|
| 31 |
+
if self._admin_username is not None:
|
| 32 |
+
return self._admin_username
|
| 33 |
+
return self._config["global"]["admin_username"]
|
| 34 |
+
|
| 35 |
+
@admin_username.setter
|
| 36 |
+
def admin_username(self, value: str):
|
| 37 |
+
self._admin_username = value
|
| 38 |
+
self._config["global"]["admin_username"] = value
|
| 39 |
+
|
| 40 |
+
def set_admin_username_from_db(self, username: str):
|
| 41 |
+
"""Set admin username from database"""
|
| 42 |
+
self._admin_username = username
|
| 43 |
+
|
| 44 |
+
# Flow2API specific properties
|
| 45 |
+
@property
|
| 46 |
+
def flow_labs_base_url(self) -> str:
|
| 47 |
+
"""Google Labs base URL for project management"""
|
| 48 |
+
return self._config["flow"]["labs_base_url"]
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def flow_api_base_url(self) -> str:
|
| 52 |
+
"""Google AI Sandbox API base URL for generation"""
|
| 53 |
+
return self._config["flow"]["api_base_url"]
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def flow_timeout(self) -> int:
|
| 57 |
+
return self._config["flow"]["timeout"]
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def flow_max_retries(self) -> int:
|
| 61 |
+
return self._config["flow"]["max_retries"]
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def poll_interval(self) -> float:
|
| 65 |
+
return self._config["flow"]["poll_interval"]
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def max_poll_attempts(self) -> int:
|
| 69 |
+
return self._config["flow"]["max_poll_attempts"]
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def server_host(self) -> str:
|
| 73 |
+
return self._config["server"]["host"]
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def server_port(self) -> int:
|
| 77 |
+
return self._config["server"]["port"]
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def debug_enabled(self) -> bool:
|
| 81 |
+
return self._config.get("debug", {}).get("enabled", False)
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def debug_log_requests(self) -> bool:
|
| 85 |
+
return self._config.get("debug", {}).get("log_requests", True)
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def debug_log_responses(self) -> bool:
|
| 89 |
+
return self._config.get("debug", {}).get("log_responses", True)
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def debug_mask_token(self) -> bool:
|
| 93 |
+
return self._config.get("debug", {}).get("mask_token", True)
|
| 94 |
+
|
| 95 |
+
# Mutable properties for runtime updates
|
| 96 |
+
@property
|
| 97 |
+
def api_key(self) -> str:
|
| 98 |
+
return self._config["global"]["api_key"]
|
| 99 |
+
|
| 100 |
+
@api_key.setter
|
| 101 |
+
def api_key(self, value: str):
|
| 102 |
+
self._config["global"]["api_key"] = value
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def admin_password(self) -> str:
|
| 106 |
+
# If admin_password is set from database, use it; otherwise fall back to config file
|
| 107 |
+
if self._admin_password is not None:
|
| 108 |
+
return self._admin_password
|
| 109 |
+
return self._config["global"]["admin_password"]
|
| 110 |
+
|
| 111 |
+
@admin_password.setter
|
| 112 |
+
def admin_password(self, value: str):
|
| 113 |
+
self._admin_password = value
|
| 114 |
+
self._config["global"]["admin_password"] = value
|
| 115 |
+
|
| 116 |
+
def set_admin_password_from_db(self, password: str):
|
| 117 |
+
"""Set admin password from database"""
|
| 118 |
+
self._admin_password = password
|
| 119 |
+
|
| 120 |
+
def set_debug_enabled(self, enabled: bool):
|
| 121 |
+
"""Set debug mode enabled/disabled"""
|
| 122 |
+
if "debug" not in self._config:
|
| 123 |
+
self._config["debug"] = {}
|
| 124 |
+
self._config["debug"]["enabled"] = enabled
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def image_timeout(self) -> int:
|
| 128 |
+
"""Get image generation timeout in seconds"""
|
| 129 |
+
return self._config.get("generation", {}).get("image_timeout", 300)
|
| 130 |
+
|
| 131 |
+
def set_image_timeout(self, timeout: int):
|
| 132 |
+
"""Set image generation timeout in seconds"""
|
| 133 |
+
if "generation" not in self._config:
|
| 134 |
+
self._config["generation"] = {}
|
| 135 |
+
self._config["generation"]["image_timeout"] = timeout
|
| 136 |
+
|
| 137 |
+
@property
|
| 138 |
+
def video_timeout(self) -> int:
|
| 139 |
+
"""Get video generation timeout in seconds"""
|
| 140 |
+
return self._config.get("generation", {}).get("video_timeout", 1500)
|
| 141 |
+
|
| 142 |
+
def set_video_timeout(self, timeout: int):
|
| 143 |
+
"""Set video generation timeout in seconds"""
|
| 144 |
+
if "generation" not in self._config:
|
| 145 |
+
self._config["generation"] = {}
|
| 146 |
+
self._config["generation"]["video_timeout"] = timeout
|
| 147 |
+
|
| 148 |
+
# Cache configuration
|
| 149 |
+
@property
|
| 150 |
+
def cache_enabled(self) -> bool:
|
| 151 |
+
"""Get cache enabled status"""
|
| 152 |
+
return self._config.get("cache", {}).get("enabled", False)
|
| 153 |
+
|
| 154 |
+
def set_cache_enabled(self, enabled: bool):
|
| 155 |
+
"""Set cache enabled status"""
|
| 156 |
+
if "cache" not in self._config:
|
| 157 |
+
self._config["cache"] = {}
|
| 158 |
+
self._config["cache"]["enabled"] = enabled
|
| 159 |
+
|
| 160 |
+
@property
|
| 161 |
+
def cache_timeout(self) -> int:
|
| 162 |
+
"""Get cache timeout in seconds"""
|
| 163 |
+
return self._config.get("cache", {}).get("timeout", 7200)
|
| 164 |
+
|
| 165 |
+
def set_cache_timeout(self, timeout: int):
|
| 166 |
+
"""Set cache timeout in seconds"""
|
| 167 |
+
if "cache" not in self._config:
|
| 168 |
+
self._config["cache"] = {}
|
| 169 |
+
self._config["cache"]["timeout"] = timeout
|
| 170 |
+
|
| 171 |
+
@property
|
| 172 |
+
def cache_base_url(self) -> str:
|
| 173 |
+
"""Get cache base URL"""
|
| 174 |
+
return self._config.get("cache", {}).get("base_url", "")
|
| 175 |
+
|
| 176 |
+
def set_cache_base_url(self, base_url: str):
|
| 177 |
+
"""Set cache base URL"""
|
| 178 |
+
if "cache" not in self._config:
|
| 179 |
+
self._config["cache"] = {}
|
| 180 |
+
self._config["cache"]["base_url"] = base_url
|
| 181 |
+
|
| 182 |
+
# Global config instance
|
| 183 |
+
config = Config()
|
src/core/database.py
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database storage layer for Flow2API"""
|
| 2 |
+
import aiosqlite
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional, List
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from .models import Token, TokenStats, Task, RequestLog, AdminConfig, ProxyConfig, GenerationConfig, CacheConfig, Project
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Database:
|
| 11 |
+
"""SQLite database manager"""
|
| 12 |
+
|
| 13 |
+
def __init__(self, db_path: str = None):
|
| 14 |
+
if db_path is None:
|
| 15 |
+
# Store database in data directory
|
| 16 |
+
data_dir = Path(__file__).parent.parent.parent / "data"
|
| 17 |
+
data_dir.mkdir(exist_ok=True)
|
| 18 |
+
db_path = str(data_dir / "flow.db")
|
| 19 |
+
self.db_path = db_path
|
| 20 |
+
|
| 21 |
+
def db_exists(self) -> bool:
|
| 22 |
+
"""Check if database file exists"""
|
| 23 |
+
return Path(self.db_path).exists()
|
| 24 |
+
|
| 25 |
+
async def _table_exists(self, db, table_name: str) -> bool:
|
| 26 |
+
"""Check if a table exists in the database"""
|
| 27 |
+
cursor = await db.execute(
|
| 28 |
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
| 29 |
+
(table_name,)
|
| 30 |
+
)
|
| 31 |
+
result = await cursor.fetchone()
|
| 32 |
+
return result is not None
|
| 33 |
+
|
| 34 |
+
async def _column_exists(self, db, table_name: str, column_name: str) -> bool:
|
| 35 |
+
"""Check if a column exists in a table"""
|
| 36 |
+
try:
|
| 37 |
+
cursor = await db.execute(f"PRAGMA table_info({table_name})")
|
| 38 |
+
columns = await cursor.fetchall()
|
| 39 |
+
return any(col[1] == column_name for col in columns)
|
| 40 |
+
except:
|
| 41 |
+
return False
|
| 42 |
+
|
| 43 |
+
async def _ensure_config_rows(self, db, config_dict: dict = None):
|
| 44 |
+
"""Ensure all config tables have their default rows
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
db: Database connection
|
| 48 |
+
config_dict: Configuration dictionary from setting.toml (optional)
|
| 49 |
+
If None, use default values instead of reading from TOML.
|
| 50 |
+
"""
|
| 51 |
+
# Ensure admin_config has a row
|
| 52 |
+
cursor = await db.execute("SELECT COUNT(*) FROM admin_config")
|
| 53 |
+
count = await cursor.fetchone()
|
| 54 |
+
if count[0] == 0:
|
| 55 |
+
admin_username = "admin"
|
| 56 |
+
admin_password = "admin"
|
| 57 |
+
api_key = "han1234"
|
| 58 |
+
|
| 59 |
+
if config_dict:
|
| 60 |
+
global_config = config_dict.get("global", {})
|
| 61 |
+
admin_username = global_config.get("admin_username", "admin")
|
| 62 |
+
admin_password = global_config.get("admin_password", "admin")
|
| 63 |
+
api_key = global_config.get("api_key", "han1234")
|
| 64 |
+
|
| 65 |
+
await db.execute("""
|
| 66 |
+
INSERT INTO admin_config (id, username, password, api_key)
|
| 67 |
+
VALUES (1, ?, ?, ?)
|
| 68 |
+
""", (admin_username, admin_password, api_key))
|
| 69 |
+
|
| 70 |
+
# Ensure proxy_config has a row
|
| 71 |
+
cursor = await db.execute("SELECT COUNT(*) FROM proxy_config")
|
| 72 |
+
count = await cursor.fetchone()
|
| 73 |
+
if count[0] == 0:
|
| 74 |
+
proxy_enabled = False
|
| 75 |
+
proxy_url = None
|
| 76 |
+
|
| 77 |
+
if config_dict:
|
| 78 |
+
proxy_config = config_dict.get("proxy", {})
|
| 79 |
+
proxy_enabled = proxy_config.get("proxy_enabled", False)
|
| 80 |
+
proxy_url = proxy_config.get("proxy_url", "")
|
| 81 |
+
proxy_url = proxy_url if proxy_url else None
|
| 82 |
+
|
| 83 |
+
await db.execute("""
|
| 84 |
+
INSERT INTO proxy_config (id, enabled, proxy_url)
|
| 85 |
+
VALUES (1, ?, ?)
|
| 86 |
+
""", (proxy_enabled, proxy_url))
|
| 87 |
+
|
| 88 |
+
# Ensure generation_config has a row
|
| 89 |
+
cursor = await db.execute("SELECT COUNT(*) FROM generation_config")
|
| 90 |
+
count = await cursor.fetchone()
|
| 91 |
+
if count[0] == 0:
|
| 92 |
+
image_timeout = 300
|
| 93 |
+
video_timeout = 1500
|
| 94 |
+
|
| 95 |
+
if config_dict:
|
| 96 |
+
generation_config = config_dict.get("generation", {})
|
| 97 |
+
image_timeout = generation_config.get("image_timeout", 300)
|
| 98 |
+
video_timeout = generation_config.get("video_timeout", 1500)
|
| 99 |
+
|
| 100 |
+
await db.execute("""
|
| 101 |
+
INSERT INTO generation_config (id, image_timeout, video_timeout)
|
| 102 |
+
VALUES (1, ?, ?)
|
| 103 |
+
""", (image_timeout, video_timeout))
|
| 104 |
+
|
| 105 |
+
# Ensure cache_config has a row
|
| 106 |
+
cursor = await db.execute("SELECT COUNT(*) FROM cache_config")
|
| 107 |
+
count = await cursor.fetchone()
|
| 108 |
+
if count[0] == 0:
|
| 109 |
+
cache_enabled = False
|
| 110 |
+
cache_timeout = 7200
|
| 111 |
+
cache_base_url = None
|
| 112 |
+
|
| 113 |
+
if config_dict:
|
| 114 |
+
cache_config = config_dict.get("cache", {})
|
| 115 |
+
cache_enabled = cache_config.get("enabled", False)
|
| 116 |
+
cache_timeout = cache_config.get("timeout", 7200)
|
| 117 |
+
cache_base_url = cache_config.get("base_url", "")
|
| 118 |
+
# Convert empty string to None
|
| 119 |
+
cache_base_url = cache_base_url if cache_base_url else None
|
| 120 |
+
|
| 121 |
+
await db.execute("""
|
| 122 |
+
INSERT INTO cache_config (id, cache_enabled, cache_timeout, cache_base_url)
|
| 123 |
+
VALUES (1, ?, ?, ?)
|
| 124 |
+
""", (cache_enabled, cache_timeout, cache_base_url))
|
| 125 |
+
|
| 126 |
+
async def check_and_migrate_db(self, config_dict: dict = None):
|
| 127 |
+
"""Check database integrity and perform migrations if needed
|
| 128 |
+
|
| 129 |
+
This method is called during upgrade mode to:
|
| 130 |
+
1. Create missing tables (if they don't exist)
|
| 131 |
+
2. Add missing columns to existing tables
|
| 132 |
+
3. Ensure all config tables have default rows
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
config_dict: Configuration dictionary from setting.toml (optional)
|
| 136 |
+
Used only to initialize missing config rows with default values.
|
| 137 |
+
Existing config rows will NOT be overwritten.
|
| 138 |
+
"""
|
| 139 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 140 |
+
print("Checking database integrity and performing migrations...")
|
| 141 |
+
|
| 142 |
+
# ========== Step 1: Create missing tables ==========
|
| 143 |
+
# Check and create cache_config table if missing
|
| 144 |
+
if not await self._table_exists(db, "cache_config"):
|
| 145 |
+
print(" ✓ Creating missing table: cache_config")
|
| 146 |
+
await db.execute("""
|
| 147 |
+
CREATE TABLE cache_config (
|
| 148 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 149 |
+
cache_enabled BOOLEAN DEFAULT 0,
|
| 150 |
+
cache_timeout INTEGER DEFAULT 7200,
|
| 151 |
+
cache_base_url TEXT,
|
| 152 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 153 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 154 |
+
)
|
| 155 |
+
""")
|
| 156 |
+
|
| 157 |
+
# ========== Step 2: Add missing columns to existing tables ==========
|
| 158 |
+
# Check and add missing columns to tokens table
|
| 159 |
+
if await self._table_exists(db, "tokens"):
|
| 160 |
+
columns_to_add = [
|
| 161 |
+
("at", "TEXT"), # Access Token
|
| 162 |
+
("at_expires", "TIMESTAMP"), # AT expiration time
|
| 163 |
+
("credits", "INTEGER DEFAULT 0"), # Balance
|
| 164 |
+
("user_paygate_tier", "TEXT"), # User tier
|
| 165 |
+
("current_project_id", "TEXT"), # Current project UUID
|
| 166 |
+
("current_project_name", "TEXT"), # Project name
|
| 167 |
+
("image_enabled", "BOOLEAN DEFAULT 1"),
|
| 168 |
+
("video_enabled", "BOOLEAN DEFAULT 1"),
|
| 169 |
+
("image_concurrency", "INTEGER DEFAULT -1"),
|
| 170 |
+
("video_concurrency", "INTEGER DEFAULT -1"),
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
for col_name, col_type in columns_to_add:
|
| 174 |
+
if not await self._column_exists(db, "tokens", col_name):
|
| 175 |
+
try:
|
| 176 |
+
await db.execute(f"ALTER TABLE tokens ADD COLUMN {col_name} {col_type}")
|
| 177 |
+
print(f" ✓ Added column '{col_name}' to tokens table")
|
| 178 |
+
except Exception as e:
|
| 179 |
+
print(f" ✗ Failed to add column '{col_name}': {e}")
|
| 180 |
+
|
| 181 |
+
# Check and add missing columns to token_stats table
|
| 182 |
+
if await self._table_exists(db, "token_stats"):
|
| 183 |
+
stats_columns_to_add = [
|
| 184 |
+
("today_image_count", "INTEGER DEFAULT 0"),
|
| 185 |
+
("today_video_count", "INTEGER DEFAULT 0"),
|
| 186 |
+
("today_error_count", "INTEGER DEFAULT 0"),
|
| 187 |
+
("today_date", "DATE"),
|
| 188 |
+
]
|
| 189 |
+
|
| 190 |
+
for col_name, col_type in stats_columns_to_add:
|
| 191 |
+
if not await self._column_exists(db, "token_stats", col_name):
|
| 192 |
+
try:
|
| 193 |
+
await db.execute(f"ALTER TABLE token_stats ADD COLUMN {col_name} {col_type}")
|
| 194 |
+
print(f" ✓ Added column '{col_name}' to token_stats table")
|
| 195 |
+
except Exception as e:
|
| 196 |
+
print(f" ✗ Failed to add column '{col_name}': {e}")
|
| 197 |
+
|
| 198 |
+
# ========== Step 3: Ensure all config tables have default rows ==========
|
| 199 |
+
# Note: This will NOT overwrite existing config rows
|
| 200 |
+
# It only ensures missing rows are created with default values
|
| 201 |
+
await self._ensure_config_rows(db, config_dict=None)
|
| 202 |
+
|
| 203 |
+
await db.commit()
|
| 204 |
+
print("Database migration check completed.")
|
| 205 |
+
|
| 206 |
+
async def init_db(self):
|
| 207 |
+
"""Initialize database tables"""
|
| 208 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 209 |
+
# Tokens table (Flow2API版本)
|
| 210 |
+
await db.execute("""
|
| 211 |
+
CREATE TABLE IF NOT EXISTS tokens (
|
| 212 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 213 |
+
st TEXT UNIQUE NOT NULL,
|
| 214 |
+
at TEXT,
|
| 215 |
+
at_expires TIMESTAMP,
|
| 216 |
+
email TEXT NOT NULL,
|
| 217 |
+
name TEXT,
|
| 218 |
+
remark TEXT,
|
| 219 |
+
is_active BOOLEAN DEFAULT 1,
|
| 220 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 221 |
+
last_used_at TIMESTAMP,
|
| 222 |
+
use_count INTEGER DEFAULT 0,
|
| 223 |
+
credits INTEGER DEFAULT 0,
|
| 224 |
+
user_paygate_tier TEXT,
|
| 225 |
+
current_project_id TEXT,
|
| 226 |
+
current_project_name TEXT,
|
| 227 |
+
image_enabled BOOLEAN DEFAULT 1,
|
| 228 |
+
video_enabled BOOLEAN DEFAULT 1,
|
| 229 |
+
image_concurrency INTEGER DEFAULT -1,
|
| 230 |
+
video_concurrency INTEGER DEFAULT -1
|
| 231 |
+
)
|
| 232 |
+
""")
|
| 233 |
+
|
| 234 |
+
# Projects table (新增)
|
| 235 |
+
await db.execute("""
|
| 236 |
+
CREATE TABLE IF NOT EXISTS projects (
|
| 237 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 238 |
+
project_id TEXT UNIQUE NOT NULL,
|
| 239 |
+
token_id INTEGER NOT NULL,
|
| 240 |
+
project_name TEXT NOT NULL,
|
| 241 |
+
tool_name TEXT DEFAULT 'PINHOLE',
|
| 242 |
+
is_active BOOLEAN DEFAULT 1,
|
| 243 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 244 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id)
|
| 245 |
+
)
|
| 246 |
+
""")
|
| 247 |
+
|
| 248 |
+
# Token stats table
|
| 249 |
+
await db.execute("""
|
| 250 |
+
CREATE TABLE IF NOT EXISTS token_stats (
|
| 251 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 252 |
+
token_id INTEGER NOT NULL,
|
| 253 |
+
image_count INTEGER DEFAULT 0,
|
| 254 |
+
video_count INTEGER DEFAULT 0,
|
| 255 |
+
success_count INTEGER DEFAULT 0,
|
| 256 |
+
error_count INTEGER DEFAULT 0,
|
| 257 |
+
last_success_at TIMESTAMP,
|
| 258 |
+
last_error_at TIMESTAMP,
|
| 259 |
+
today_image_count INTEGER DEFAULT 0,
|
| 260 |
+
today_video_count INTEGER DEFAULT 0,
|
| 261 |
+
today_error_count INTEGER DEFAULT 0,
|
| 262 |
+
today_date DATE,
|
| 263 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id)
|
| 264 |
+
)
|
| 265 |
+
""")
|
| 266 |
+
|
| 267 |
+
# Tasks table
|
| 268 |
+
await db.execute("""
|
| 269 |
+
CREATE TABLE IF NOT EXISTS tasks (
|
| 270 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 271 |
+
task_id TEXT UNIQUE NOT NULL,
|
| 272 |
+
token_id INTEGER NOT NULL,
|
| 273 |
+
model TEXT NOT NULL,
|
| 274 |
+
prompt TEXT NOT NULL,
|
| 275 |
+
status TEXT NOT NULL DEFAULT 'processing',
|
| 276 |
+
progress INTEGER DEFAULT 0,
|
| 277 |
+
result_urls TEXT,
|
| 278 |
+
error_message TEXT,
|
| 279 |
+
scene_id TEXT,
|
| 280 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 281 |
+
completed_at TIMESTAMP,
|
| 282 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id)
|
| 283 |
+
)
|
| 284 |
+
""")
|
| 285 |
+
|
| 286 |
+
# Request logs table
|
| 287 |
+
await db.execute("""
|
| 288 |
+
CREATE TABLE IF NOT EXISTS request_logs (
|
| 289 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 290 |
+
token_id INTEGER,
|
| 291 |
+
operation TEXT NOT NULL,
|
| 292 |
+
request_body TEXT,
|
| 293 |
+
response_body TEXT,
|
| 294 |
+
status_code INTEGER NOT NULL,
|
| 295 |
+
duration FLOAT NOT NULL,
|
| 296 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 297 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id)
|
| 298 |
+
)
|
| 299 |
+
""")
|
| 300 |
+
|
| 301 |
+
# Admin config table
|
| 302 |
+
await db.execute("""
|
| 303 |
+
CREATE TABLE IF NOT EXISTS admin_config (
|
| 304 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 305 |
+
username TEXT DEFAULT 'admin',
|
| 306 |
+
password TEXT DEFAULT 'admin',
|
| 307 |
+
api_key TEXT DEFAULT 'han1234',
|
| 308 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 309 |
+
)
|
| 310 |
+
""")
|
| 311 |
+
|
| 312 |
+
# Proxy config table
|
| 313 |
+
await db.execute("""
|
| 314 |
+
CREATE TABLE IF NOT EXISTS proxy_config (
|
| 315 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 316 |
+
enabled BOOLEAN DEFAULT 0,
|
| 317 |
+
proxy_url TEXT,
|
| 318 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 319 |
+
)
|
| 320 |
+
""")
|
| 321 |
+
|
| 322 |
+
# Generation config table
|
| 323 |
+
await db.execute("""
|
| 324 |
+
CREATE TABLE IF NOT EXISTS generation_config (
|
| 325 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 326 |
+
image_timeout INTEGER DEFAULT 300,
|
| 327 |
+
video_timeout INTEGER DEFAULT 1500,
|
| 328 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 329 |
+
)
|
| 330 |
+
""")
|
| 331 |
+
|
| 332 |
+
# Cache config table
|
| 333 |
+
await db.execute("""
|
| 334 |
+
CREATE TABLE IF NOT EXISTS cache_config (
|
| 335 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 336 |
+
cache_enabled BOOLEAN DEFAULT 0,
|
| 337 |
+
cache_timeout INTEGER DEFAULT 7200,
|
| 338 |
+
cache_base_url TEXT,
|
| 339 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 340 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 341 |
+
)
|
| 342 |
+
""")
|
| 343 |
+
|
| 344 |
+
# Create indexes
|
| 345 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
| 346 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_token_st ON tokens(st)")
|
| 347 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_project_id ON projects(project_id)")
|
| 348 |
+
|
| 349 |
+
# Migrate request_logs table if needed
|
| 350 |
+
await self._migrate_request_logs(db)
|
| 351 |
+
|
| 352 |
+
await db.commit()
|
| 353 |
+
|
| 354 |
+
async def _migrate_request_logs(self, db):
|
| 355 |
+
"""Migrate request_logs table from old schema to new schema"""
|
| 356 |
+
try:
|
| 357 |
+
# Check if old columns exist
|
| 358 |
+
has_model = await self._column_exists(db, "request_logs", "model")
|
| 359 |
+
has_operation = await self._column_exists(db, "request_logs", "operation")
|
| 360 |
+
|
| 361 |
+
if has_model and not has_operation:
|
| 362 |
+
# Old schema detected, need migration
|
| 363 |
+
print("🔄 检测到旧的request_logs表结构,开始迁移...")
|
| 364 |
+
|
| 365 |
+
# Rename old table
|
| 366 |
+
await db.execute("ALTER TABLE request_logs RENAME TO request_logs_old")
|
| 367 |
+
|
| 368 |
+
# Create new table with new schema
|
| 369 |
+
await db.execute("""
|
| 370 |
+
CREATE TABLE request_logs (
|
| 371 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 372 |
+
token_id INTEGER,
|
| 373 |
+
operation TEXT NOT NULL,
|
| 374 |
+
request_body TEXT,
|
| 375 |
+
response_body TEXT,
|
| 376 |
+
status_code INTEGER NOT NULL,
|
| 377 |
+
duration FLOAT NOT NULL,
|
| 378 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 379 |
+
FOREIGN KEY (token_id) REFERENCES tokens(id)
|
| 380 |
+
)
|
| 381 |
+
""")
|
| 382 |
+
|
| 383 |
+
# Migrate data from old table (basic migration)
|
| 384 |
+
await db.execute("""
|
| 385 |
+
INSERT INTO request_logs (token_id, operation, request_body, status_code, duration, created_at)
|
| 386 |
+
SELECT
|
| 387 |
+
token_id,
|
| 388 |
+
model as operation,
|
| 389 |
+
json_object('model', model, 'prompt', substr(prompt, 1, 100)) as request_body,
|
| 390 |
+
CASE
|
| 391 |
+
WHEN status = 'completed' THEN 200
|
| 392 |
+
WHEN status = 'failed' THEN 500
|
| 393 |
+
ELSE 0
|
| 394 |
+
END as status_code,
|
| 395 |
+
response_time as duration,
|
| 396 |
+
created_at
|
| 397 |
+
FROM request_logs_old
|
| 398 |
+
""")
|
| 399 |
+
|
| 400 |
+
# Drop old table
|
| 401 |
+
await db.execute("DROP TABLE request_logs_old")
|
| 402 |
+
|
| 403 |
+
print("✅ request_logs表迁移完成")
|
| 404 |
+
except Exception as e:
|
| 405 |
+
print(f"⚠️ request_logs表迁移失败: {e}")
|
| 406 |
+
# Continue even if migration fails
|
| 407 |
+
|
| 408 |
+
# Token operations
|
| 409 |
+
async def add_token(self, token: Token) -> int:
|
| 410 |
+
"""Add a new token"""
|
| 411 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 412 |
+
cursor = await db.execute("""
|
| 413 |
+
INSERT INTO tokens (st, at, at_expires, email, name, remark, is_active,
|
| 414 |
+
credits, user_paygate_tier, current_project_id, current_project_name,
|
| 415 |
+
image_enabled, video_enabled, image_concurrency, video_concurrency)
|
| 416 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 417 |
+
""", (token.st, token.at, token.at_expires, token.email, token.name, token.remark,
|
| 418 |
+
token.is_active, token.credits, token.user_paygate_tier,
|
| 419 |
+
token.current_project_id, token.current_project_name,
|
| 420 |
+
token.image_enabled, token.video_enabled,
|
| 421 |
+
token.image_concurrency, token.video_concurrency))
|
| 422 |
+
await db.commit()
|
| 423 |
+
token_id = cursor.lastrowid
|
| 424 |
+
|
| 425 |
+
# Create stats entry
|
| 426 |
+
await db.execute("""
|
| 427 |
+
INSERT INTO token_stats (token_id) VALUES (?)
|
| 428 |
+
""", (token_id,))
|
| 429 |
+
await db.commit()
|
| 430 |
+
|
| 431 |
+
return token_id
|
| 432 |
+
|
| 433 |
+
async def get_token(self, token_id: int) -> Optional[Token]:
|
| 434 |
+
"""Get token by ID"""
|
| 435 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 436 |
+
db.row_factory = aiosqlite.Row
|
| 437 |
+
cursor = await db.execute("SELECT * FROM tokens WHERE id = ?", (token_id,))
|
| 438 |
+
row = await cursor.fetchone()
|
| 439 |
+
if row:
|
| 440 |
+
return Token(**dict(row))
|
| 441 |
+
return None
|
| 442 |
+
|
| 443 |
+
async def get_token_by_st(self, st: str) -> Optional[Token]:
|
| 444 |
+
"""Get token by ST"""
|
| 445 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 446 |
+
db.row_factory = aiosqlite.Row
|
| 447 |
+
cursor = await db.execute("SELECT * FROM tokens WHERE st = ?", (st,))
|
| 448 |
+
row = await cursor.fetchone()
|
| 449 |
+
if row:
|
| 450 |
+
return Token(**dict(row))
|
| 451 |
+
return None
|
| 452 |
+
|
| 453 |
+
async def get_all_tokens(self) -> List[Token]:
|
| 454 |
+
"""Get all tokens"""
|
| 455 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 456 |
+
db.row_factory = aiosqlite.Row
|
| 457 |
+
cursor = await db.execute("SELECT * FROM tokens ORDER BY created_at DESC")
|
| 458 |
+
rows = await cursor.fetchall()
|
| 459 |
+
return [Token(**dict(row)) for row in rows]
|
| 460 |
+
|
| 461 |
+
async def get_active_tokens(self) -> List[Token]:
|
| 462 |
+
"""Get all active tokens"""
|
| 463 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 464 |
+
db.row_factory = aiosqlite.Row
|
| 465 |
+
cursor = await db.execute("SELECT * FROM tokens WHERE is_active = 1 ORDER BY last_used_at ASC")
|
| 466 |
+
rows = await cursor.fetchall()
|
| 467 |
+
return [Token(**dict(row)) for row in rows]
|
| 468 |
+
|
| 469 |
+
async def update_token(self, token_id: int, **kwargs):
|
| 470 |
+
"""Update token fields"""
|
| 471 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 472 |
+
updates = []
|
| 473 |
+
params = []
|
| 474 |
+
|
| 475 |
+
for key, value in kwargs.items():
|
| 476 |
+
if value is not None:
|
| 477 |
+
updates.append(f"{key} = ?")
|
| 478 |
+
params.append(value)
|
| 479 |
+
|
| 480 |
+
if updates:
|
| 481 |
+
params.append(token_id)
|
| 482 |
+
query = f"UPDATE tokens SET {', '.join(updates)} WHERE id = ?"
|
| 483 |
+
await db.execute(query, params)
|
| 484 |
+
await db.commit()
|
| 485 |
+
|
| 486 |
+
async def delete_token(self, token_id: int):
|
| 487 |
+
"""Delete token and related data"""
|
| 488 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 489 |
+
await db.execute("DELETE FROM token_stats WHERE token_id = ?", (token_id,))
|
| 490 |
+
await db.execute("DELETE FROM projects WHERE token_id = ?", (token_id,))
|
| 491 |
+
await db.execute("DELETE FROM tokens WHERE id = ?", (token_id,))
|
| 492 |
+
await db.commit()
|
| 493 |
+
|
| 494 |
+
# Project operations
|
| 495 |
+
async def add_project(self, project: Project) -> int:
|
| 496 |
+
"""Add a new project"""
|
| 497 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 498 |
+
cursor = await db.execute("""
|
| 499 |
+
INSERT INTO projects (project_id, token_id, project_name, tool_name, is_active)
|
| 500 |
+
VALUES (?, ?, ?, ?, ?)
|
| 501 |
+
""", (project.project_id, project.token_id, project.project_name,
|
| 502 |
+
project.tool_name, project.is_active))
|
| 503 |
+
await db.commit()
|
| 504 |
+
return cursor.lastrowid
|
| 505 |
+
|
| 506 |
+
async def get_project_by_id(self, project_id: str) -> Optional[Project]:
|
| 507 |
+
"""Get project by UUID"""
|
| 508 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 509 |
+
db.row_factory = aiosqlite.Row
|
| 510 |
+
cursor = await db.execute("SELECT * FROM projects WHERE project_id = ?", (project_id,))
|
| 511 |
+
row = await cursor.fetchone()
|
| 512 |
+
if row:
|
| 513 |
+
return Project(**dict(row))
|
| 514 |
+
return None
|
| 515 |
+
|
| 516 |
+
async def get_projects_by_token(self, token_id: int) -> List[Project]:
|
| 517 |
+
"""Get all projects for a token"""
|
| 518 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 519 |
+
db.row_factory = aiosqlite.Row
|
| 520 |
+
cursor = await db.execute(
|
| 521 |
+
"SELECT * FROM projects WHERE token_id = ? ORDER BY created_at DESC",
|
| 522 |
+
(token_id,)
|
| 523 |
+
)
|
| 524 |
+
rows = await cursor.fetchall()
|
| 525 |
+
return [Project(**dict(row)) for row in rows]
|
| 526 |
+
|
| 527 |
+
async def delete_project(self, project_id: str):
|
| 528 |
+
"""Delete project"""
|
| 529 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 530 |
+
await db.execute("DELETE FROM projects WHERE project_id = ?", (project_id,))
|
| 531 |
+
await db.commit()
|
| 532 |
+
|
| 533 |
+
# Task operations
|
| 534 |
+
async def create_task(self, task: Task) -> int:
|
| 535 |
+
"""Create a new task"""
|
| 536 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 537 |
+
cursor = await db.execute("""
|
| 538 |
+
INSERT INTO tasks (task_id, token_id, model, prompt, status, progress, scene_id)
|
| 539 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 540 |
+
""", (task.task_id, task.token_id, task.model, task.prompt,
|
| 541 |
+
task.status, task.progress, task.scene_id))
|
| 542 |
+
await db.commit()
|
| 543 |
+
return cursor.lastrowid
|
| 544 |
+
|
| 545 |
+
async def get_task(self, task_id: str) -> Optional[Task]:
|
| 546 |
+
"""Get task by ID"""
|
| 547 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 548 |
+
db.row_factory = aiosqlite.Row
|
| 549 |
+
cursor = await db.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,))
|
| 550 |
+
row = await cursor.fetchone()
|
| 551 |
+
if row:
|
| 552 |
+
task_dict = dict(row)
|
| 553 |
+
# Parse result_urls from JSON
|
| 554 |
+
if task_dict.get("result_urls"):
|
| 555 |
+
task_dict["result_urls"] = json.loads(task_dict["result_urls"])
|
| 556 |
+
return Task(**task_dict)
|
| 557 |
+
return None
|
| 558 |
+
|
| 559 |
+
async def update_task(self, task_id: str, **kwargs):
|
| 560 |
+
"""Update task"""
|
| 561 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 562 |
+
updates = []
|
| 563 |
+
params = []
|
| 564 |
+
|
| 565 |
+
for key, value in kwargs.items():
|
| 566 |
+
if value is not None:
|
| 567 |
+
# Convert list to JSON string for result_urls
|
| 568 |
+
if key == "result_urls" and isinstance(value, list):
|
| 569 |
+
value = json.dumps(value)
|
| 570 |
+
updates.append(f"{key} = ?")
|
| 571 |
+
params.append(value)
|
| 572 |
+
|
| 573 |
+
if updates:
|
| 574 |
+
params.append(task_id)
|
| 575 |
+
query = f"UPDATE tasks SET {', '.join(updates)} WHERE task_id = ?"
|
| 576 |
+
await db.execute(query, params)
|
| 577 |
+
await db.commit()
|
| 578 |
+
|
| 579 |
+
# Token stats operations (kept for compatibility, now delegates to specific methods)
|
| 580 |
+
async def increment_token_stats(self, token_id: int, stat_type: str):
|
| 581 |
+
"""Increment token statistics (delegates to specific methods)"""
|
| 582 |
+
if stat_type == "image":
|
| 583 |
+
await self.increment_image_count(token_id)
|
| 584 |
+
elif stat_type == "video":
|
| 585 |
+
await self.increment_video_count(token_id)
|
| 586 |
+
elif stat_type == "error":
|
| 587 |
+
await self.increment_error_count(token_id)
|
| 588 |
+
|
| 589 |
+
async def get_token_stats(self, token_id: int) -> Optional[TokenStats]:
|
| 590 |
+
"""Get token statistics"""
|
| 591 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 592 |
+
db.row_factory = aiosqlite.Row
|
| 593 |
+
cursor = await db.execute("SELECT * FROM token_stats WHERE token_id = ?", (token_id,))
|
| 594 |
+
row = await cursor.fetchone()
|
| 595 |
+
if row:
|
| 596 |
+
return TokenStats(**dict(row))
|
| 597 |
+
return None
|
| 598 |
+
|
| 599 |
+
async def increment_image_count(self, token_id: int):
|
| 600 |
+
"""Increment image generation count with daily reset"""
|
| 601 |
+
from datetime import date
|
| 602 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 603 |
+
today = str(date.today())
|
| 604 |
+
# Get current stats
|
| 605 |
+
cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
|
| 606 |
+
row = await cursor.fetchone()
|
| 607 |
+
|
| 608 |
+
# If date changed, reset today's count
|
| 609 |
+
if row and row[0] != today:
|
| 610 |
+
await db.execute("""
|
| 611 |
+
UPDATE token_stats
|
| 612 |
+
SET image_count = image_count + 1,
|
| 613 |
+
today_image_count = 1,
|
| 614 |
+
today_date = ?
|
| 615 |
+
WHERE token_id = ?
|
| 616 |
+
""", (today, token_id))
|
| 617 |
+
else:
|
| 618 |
+
# Same day, just increment both
|
| 619 |
+
await db.execute("""
|
| 620 |
+
UPDATE token_stats
|
| 621 |
+
SET image_count = image_count + 1,
|
| 622 |
+
today_image_count = today_image_count + 1,
|
| 623 |
+
today_date = ?
|
| 624 |
+
WHERE token_id = ?
|
| 625 |
+
""", (today, token_id))
|
| 626 |
+
await db.commit()
|
| 627 |
+
|
| 628 |
+
async def increment_video_count(self, token_id: int):
|
| 629 |
+
"""Increment video generation count with daily reset"""
|
| 630 |
+
from datetime import date
|
| 631 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 632 |
+
today = str(date.today())
|
| 633 |
+
# Get current stats
|
| 634 |
+
cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
|
| 635 |
+
row = await cursor.fetchone()
|
| 636 |
+
|
| 637 |
+
# If date changed, reset today's count
|
| 638 |
+
if row and row[0] != today:
|
| 639 |
+
await db.execute("""
|
| 640 |
+
UPDATE token_stats
|
| 641 |
+
SET video_count = video_count + 1,
|
| 642 |
+
today_video_count = 1,
|
| 643 |
+
today_date = ?
|
| 644 |
+
WHERE token_id = ?
|
| 645 |
+
""", (today, token_id))
|
| 646 |
+
else:
|
| 647 |
+
# Same day, just increment both
|
| 648 |
+
await db.execute("""
|
| 649 |
+
UPDATE token_stats
|
| 650 |
+
SET video_count = video_count + 1,
|
| 651 |
+
today_video_count = today_video_count + 1,
|
| 652 |
+
today_date = ?
|
| 653 |
+
WHERE token_id = ?
|
| 654 |
+
""", (today, token_id))
|
| 655 |
+
await db.commit()
|
| 656 |
+
|
| 657 |
+
async def increment_error_count(self, token_id: int):
|
| 658 |
+
"""Increment error count with daily reset"""
|
| 659 |
+
from datetime import date
|
| 660 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 661 |
+
today = str(date.today())
|
| 662 |
+
# Get current stats
|
| 663 |
+
cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
|
| 664 |
+
row = await cursor.fetchone()
|
| 665 |
+
|
| 666 |
+
# If date changed, reset today's error count
|
| 667 |
+
if row and row[0] != today:
|
| 668 |
+
await db.execute("""
|
| 669 |
+
UPDATE token_stats
|
| 670 |
+
SET error_count = error_count + 1,
|
| 671 |
+
today_error_count = 1,
|
| 672 |
+
today_date = ?,
|
| 673 |
+
last_error_at = CURRENT_TIMESTAMP
|
| 674 |
+
WHERE token_id = ?
|
| 675 |
+
""", (today, token_id))
|
| 676 |
+
else:
|
| 677 |
+
# Same day, just increment both
|
| 678 |
+
await db.execute("""
|
| 679 |
+
UPDATE token_stats
|
| 680 |
+
SET error_count = error_count + 1,
|
| 681 |
+
today_error_count = today_error_count + 1,
|
| 682 |
+
today_date = ?,
|
| 683 |
+
last_error_at = CURRENT_TIMESTAMP
|
| 684 |
+
WHERE token_id = ?
|
| 685 |
+
""", (today, token_id))
|
| 686 |
+
await db.commit()
|
| 687 |
+
|
| 688 |
+
# Config operations
|
| 689 |
+
async def get_admin_config(self) -> Optional[AdminConfig]:
|
| 690 |
+
"""Get admin configuration"""
|
| 691 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 692 |
+
db.row_factory = aiosqlite.Row
|
| 693 |
+
cursor = await db.execute("SELECT * FROM admin_config WHERE id = 1")
|
| 694 |
+
row = await cursor.fetchone()
|
| 695 |
+
if row:
|
| 696 |
+
return AdminConfig(**dict(row))
|
| 697 |
+
return None
|
| 698 |
+
|
| 699 |
+
async def update_admin_config(self, **kwargs):
|
| 700 |
+
"""Update admin configuration"""
|
| 701 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 702 |
+
updates = []
|
| 703 |
+
params = []
|
| 704 |
+
|
| 705 |
+
for key, value in kwargs.items():
|
| 706 |
+
if value is not None:
|
| 707 |
+
updates.append(f"{key} = ?")
|
| 708 |
+
params.append(value)
|
| 709 |
+
|
| 710 |
+
if updates:
|
| 711 |
+
updates.append("updated_at = CURRENT_TIMESTAMP")
|
| 712 |
+
query = f"UPDATE admin_config SET {', '.join(updates)} WHERE id = 1"
|
| 713 |
+
await db.execute(query, params)
|
| 714 |
+
await db.commit()
|
| 715 |
+
|
| 716 |
+
async def get_proxy_config(self) -> Optional[ProxyConfig]:
|
| 717 |
+
"""Get proxy configuration"""
|
| 718 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 719 |
+
db.row_factory = aiosqlite.Row
|
| 720 |
+
cursor = await db.execute("SELECT * FROM proxy_config WHERE id = 1")
|
| 721 |
+
row = await cursor.fetchone()
|
| 722 |
+
if row:
|
| 723 |
+
return ProxyConfig(**dict(row))
|
| 724 |
+
return None
|
| 725 |
+
|
| 726 |
+
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str] = None):
|
| 727 |
+
"""Update proxy configuration"""
|
| 728 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 729 |
+
await db.execute("""
|
| 730 |
+
UPDATE proxy_config
|
| 731 |
+
SET enabled = ?, proxy_url = ?, updated_at = CURRENT_TIMESTAMP
|
| 732 |
+
WHERE id = 1
|
| 733 |
+
""", (enabled, proxy_url))
|
| 734 |
+
await db.commit()
|
| 735 |
+
|
| 736 |
+
async def get_generation_config(self) -> Optional[GenerationConfig]:
|
| 737 |
+
"""Get generation configuration"""
|
| 738 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 739 |
+
db.row_factory = aiosqlite.Row
|
| 740 |
+
cursor = await db.execute("SELECT * FROM generation_config WHERE id = 1")
|
| 741 |
+
row = await cursor.fetchone()
|
| 742 |
+
if row:
|
| 743 |
+
return GenerationConfig(**dict(row))
|
| 744 |
+
return None
|
| 745 |
+
|
| 746 |
+
async def update_generation_config(self, image_timeout: int, video_timeout: int):
|
| 747 |
+
"""Update generation configuration"""
|
| 748 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 749 |
+
await db.execute("""
|
| 750 |
+
UPDATE generation_config
|
| 751 |
+
SET image_timeout = ?, video_timeout = ?, updated_at = CURRENT_TIMESTAMP
|
| 752 |
+
WHERE id = 1
|
| 753 |
+
""", (image_timeout, video_timeout))
|
| 754 |
+
await db.commit()
|
| 755 |
+
|
| 756 |
+
# Request log operations
|
| 757 |
+
async def add_request_log(self, log: RequestLog):
|
| 758 |
+
"""Add request log"""
|
| 759 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 760 |
+
await db.execute("""
|
| 761 |
+
INSERT INTO request_logs (token_id, operation, request_body, response_body, status_code, duration)
|
| 762 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 763 |
+
""", (log.token_id, log.operation, log.request_body, log.response_body,
|
| 764 |
+
log.status_code, log.duration))
|
| 765 |
+
await db.commit()
|
| 766 |
+
|
| 767 |
+
async def get_logs(self, limit: int = 100, token_id: Optional[int] = None):
|
| 768 |
+
"""Get request logs with token email"""
|
| 769 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 770 |
+
db.row_factory = aiosqlite.Row
|
| 771 |
+
|
| 772 |
+
if token_id:
|
| 773 |
+
cursor = await db.execute("""
|
| 774 |
+
SELECT
|
| 775 |
+
rl.id,
|
| 776 |
+
rl.token_id,
|
| 777 |
+
rl.operation,
|
| 778 |
+
rl.request_body,
|
| 779 |
+
rl.response_body,
|
| 780 |
+
rl.status_code,
|
| 781 |
+
rl.duration,
|
| 782 |
+
rl.created_at,
|
| 783 |
+
t.email as token_email,
|
| 784 |
+
t.name as token_username
|
| 785 |
+
FROM request_logs rl
|
| 786 |
+
LEFT JOIN tokens t ON rl.token_id = t.id
|
| 787 |
+
WHERE rl.token_id = ?
|
| 788 |
+
ORDER BY rl.created_at DESC
|
| 789 |
+
LIMIT ?
|
| 790 |
+
""", (token_id, limit))
|
| 791 |
+
else:
|
| 792 |
+
cursor = await db.execute("""
|
| 793 |
+
SELECT
|
| 794 |
+
rl.id,
|
| 795 |
+
rl.token_id,
|
| 796 |
+
rl.operation,
|
| 797 |
+
rl.request_body,
|
| 798 |
+
rl.response_body,
|
| 799 |
+
rl.status_code,
|
| 800 |
+
rl.duration,
|
| 801 |
+
rl.created_at,
|
| 802 |
+
t.email as token_email,
|
| 803 |
+
t.name as token_username
|
| 804 |
+
FROM request_logs rl
|
| 805 |
+
LEFT JOIN tokens t ON rl.token_id = t.id
|
| 806 |
+
ORDER BY rl.created_at DESC
|
| 807 |
+
LIMIT ?
|
| 808 |
+
""", (limit,))
|
| 809 |
+
|
| 810 |
+
rows = await cursor.fetchall()
|
| 811 |
+
return [dict(row) for row in rows]
|
| 812 |
+
|
| 813 |
+
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
|
| 814 |
+
"""
|
| 815 |
+
Initialize database configuration from setting.toml
|
| 816 |
+
|
| 817 |
+
Args:
|
| 818 |
+
config_dict: Configuration dictionary from setting.toml
|
| 819 |
+
is_first_startup: If True, initialize all config rows from setting.toml.
|
| 820 |
+
If False (upgrade mode), only ensure missing config rows exist with default values.
|
| 821 |
+
"""
|
| 822 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 823 |
+
if is_first_startup:
|
| 824 |
+
# First startup: Initialize all config tables with values from setting.toml
|
| 825 |
+
await self._ensure_config_rows(db, config_dict)
|
| 826 |
+
else:
|
| 827 |
+
# Upgrade mode: Only ensure missing config rows exist (with default values, not from TOML)
|
| 828 |
+
await self._ensure_config_rows(db, config_dict=None)
|
| 829 |
+
|
| 830 |
+
await db.commit()
|
| 831 |
+
|
| 832 |
+
# Cache config operations
|
| 833 |
+
async def get_cache_config(self) -> CacheConfig:
|
| 834 |
+
"""Get cache configuration"""
|
| 835 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 836 |
+
db.row_factory = aiosqlite.Row
|
| 837 |
+
cursor = await db.execute("SELECT * FROM cache_config WHERE id = 1")
|
| 838 |
+
row = await cursor.fetchone()
|
| 839 |
+
if row:
|
| 840 |
+
return CacheConfig(**dict(row))
|
| 841 |
+
# Return default if not found
|
| 842 |
+
return CacheConfig(cache_enabled=False, cache_timeout=7200)
|
| 843 |
+
|
| 844 |
+
async def update_cache_config(self, enabled: bool = None, timeout: int = None, base_url: Optional[str] = None):
|
| 845 |
+
"""Update cache configuration"""
|
| 846 |
+
async with aiosqlite.connect(self.db_path) as db:
|
| 847 |
+
db.row_factory = aiosqlite.Row
|
| 848 |
+
# Get current values
|
| 849 |
+
cursor = await db.execute("SELECT * FROM cache_config WHERE id = 1")
|
| 850 |
+
row = await cursor.fetchone()
|
| 851 |
+
|
| 852 |
+
if row:
|
| 853 |
+
current = dict(row)
|
| 854 |
+
# Use new values if provided, otherwise keep existing
|
| 855 |
+
new_enabled = enabled if enabled is not None else current.get("cache_enabled", False)
|
| 856 |
+
new_timeout = timeout if timeout is not None else current.get("cache_timeout", 7200)
|
| 857 |
+
new_base_url = base_url if base_url is not None else current.get("cache_base_url")
|
| 858 |
+
|
| 859 |
+
# If base_url is explicitly set to empty string, treat as None
|
| 860 |
+
if base_url == "":
|
| 861 |
+
new_base_url = None
|
| 862 |
+
|
| 863 |
+
await db.execute("""
|
| 864 |
+
UPDATE cache_config
|
| 865 |
+
SET cache_enabled = ?, cache_timeout = ?, cache_base_url = ?, updated_at = CURRENT_TIMESTAMP
|
| 866 |
+
WHERE id = 1
|
| 867 |
+
""", (new_enabled, new_timeout, new_base_url))
|
| 868 |
+
else:
|
| 869 |
+
# Insert default row if not exists
|
| 870 |
+
new_enabled = enabled if enabled is not None else False
|
| 871 |
+
new_timeout = timeout if timeout is not None else 7200
|
| 872 |
+
new_base_url = base_url if base_url is not None else None
|
| 873 |
+
|
| 874 |
+
await db.execute("""
|
| 875 |
+
INSERT INTO cache_config (id, cache_enabled, cache_timeout, cache_base_url)
|
| 876 |
+
VALUES (1, ?, ?, ?)
|
| 877 |
+
""", (new_enabled, new_timeout, new_base_url))
|
| 878 |
+
|
| 879 |
+
await db.commit()
|
src/core/logger.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Debug logger module for detailed API request/response logging"""
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict, Any, Optional
|
| 7 |
+
from .config import config
|
| 8 |
+
|
| 9 |
+
class DebugLogger:
|
| 10 |
+
"""Debug logger for API requests and responses"""
|
| 11 |
+
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.log_file = Path("logs.txt")
|
| 14 |
+
self._setup_logger()
|
| 15 |
+
|
| 16 |
+
def _setup_logger(self):
|
| 17 |
+
"""Setup file logger"""
|
| 18 |
+
# Create logger
|
| 19 |
+
self.logger = logging.getLogger("debug_logger")
|
| 20 |
+
self.logger.setLevel(logging.DEBUG)
|
| 21 |
+
|
| 22 |
+
# Remove existing handlers
|
| 23 |
+
self.logger.handlers.clear()
|
| 24 |
+
|
| 25 |
+
# Create file handler
|
| 26 |
+
file_handler = logging.FileHandler(
|
| 27 |
+
self.log_file,
|
| 28 |
+
mode='a',
|
| 29 |
+
encoding='utf-8'
|
| 30 |
+
)
|
| 31 |
+
file_handler.setLevel(logging.DEBUG)
|
| 32 |
+
|
| 33 |
+
# Create formatter
|
| 34 |
+
formatter = logging.Formatter(
|
| 35 |
+
'%(message)s',
|
| 36 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 37 |
+
)
|
| 38 |
+
file_handler.setFormatter(formatter)
|
| 39 |
+
|
| 40 |
+
# Add handler
|
| 41 |
+
self.logger.addHandler(file_handler)
|
| 42 |
+
|
| 43 |
+
# Prevent propagation to root logger
|
| 44 |
+
self.logger.propagate = False
|
| 45 |
+
|
| 46 |
+
def _mask_token(self, token: str) -> str:
|
| 47 |
+
"""Mask token for logging (show first 6 and last 6 characters)"""
|
| 48 |
+
if not config.debug_mask_token or len(token) <= 12:
|
| 49 |
+
return token
|
| 50 |
+
return f"{token[:6]}...{token[-6:]}"
|
| 51 |
+
|
| 52 |
+
def _format_timestamp(self) -> str:
|
| 53 |
+
"""Format current timestamp"""
|
| 54 |
+
return datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
| 55 |
+
|
| 56 |
+
def _write_separator(self, char: str = "=", length: int = 100):
|
| 57 |
+
"""Write separator line"""
|
| 58 |
+
self.logger.info(char * length)
|
| 59 |
+
|
| 60 |
+
def log_request(
|
| 61 |
+
self,
|
| 62 |
+
method: str,
|
| 63 |
+
url: str,
|
| 64 |
+
headers: Dict[str, str],
|
| 65 |
+
body: Optional[Any] = None,
|
| 66 |
+
files: Optional[Dict] = None,
|
| 67 |
+
proxy: Optional[str] = None
|
| 68 |
+
):
|
| 69 |
+
"""Log API request details to log.txt"""
|
| 70 |
+
|
| 71 |
+
if not config.debug_enabled or not config.debug_log_requests:
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
self._write_separator()
|
| 76 |
+
self.logger.info(f"🔵 [REQUEST] {self._format_timestamp()}")
|
| 77 |
+
self._write_separator("-")
|
| 78 |
+
|
| 79 |
+
# Basic info
|
| 80 |
+
self.logger.info(f"Method: {method}")
|
| 81 |
+
self.logger.info(f"URL: {url}")
|
| 82 |
+
|
| 83 |
+
# Headers
|
| 84 |
+
self.logger.info("\n📋 Headers:")
|
| 85 |
+
masked_headers = dict(headers)
|
| 86 |
+
if "Authorization" in masked_headers or "authorization" in masked_headers:
|
| 87 |
+
auth_key = "Authorization" if "Authorization" in masked_headers else "authorization"
|
| 88 |
+
auth_value = masked_headers[auth_key]
|
| 89 |
+
if auth_value.startswith("Bearer "):
|
| 90 |
+
token = auth_value[7:]
|
| 91 |
+
masked_headers[auth_key] = f"Bearer {self._mask_token(token)}"
|
| 92 |
+
|
| 93 |
+
# Mask Cookie header (ST token)
|
| 94 |
+
if "Cookie" in masked_headers:
|
| 95 |
+
cookie_value = masked_headers["Cookie"]
|
| 96 |
+
if "__Secure-next-auth.session-token=" in cookie_value:
|
| 97 |
+
parts = cookie_value.split("=", 1)
|
| 98 |
+
if len(parts) == 2:
|
| 99 |
+
st_token = parts[1].split(";")[0]
|
| 100 |
+
masked_headers["Cookie"] = f"__Secure-next-auth.session-token={self._mask_token(st_token)}"
|
| 101 |
+
|
| 102 |
+
for key, value in masked_headers.items():
|
| 103 |
+
self.logger.info(f" {key}: {value}")
|
| 104 |
+
|
| 105 |
+
# Body
|
| 106 |
+
if body is not None:
|
| 107 |
+
self.logger.info("\n📦 Request Body:")
|
| 108 |
+
if isinstance(body, (dict, list)):
|
| 109 |
+
body_str = json.dumps(body, indent=2, ensure_ascii=False)
|
| 110 |
+
self.logger.info(body_str)
|
| 111 |
+
else:
|
| 112 |
+
self.logger.info(str(body))
|
| 113 |
+
|
| 114 |
+
# Files
|
| 115 |
+
if files:
|
| 116 |
+
self.logger.info("\n📎 Files:")
|
| 117 |
+
try:
|
| 118 |
+
if hasattr(files, 'keys') and callable(getattr(files, 'keys', None)):
|
| 119 |
+
for key in files.keys():
|
| 120 |
+
self.logger.info(f" {key}: <file data>")
|
| 121 |
+
else:
|
| 122 |
+
self.logger.info(" <multipart form data>")
|
| 123 |
+
except (AttributeError, TypeError):
|
| 124 |
+
self.logger.info(" <binary file data>")
|
| 125 |
+
|
| 126 |
+
# Proxy
|
| 127 |
+
if proxy:
|
| 128 |
+
self.logger.info(f"\n🌐 Proxy: {proxy}")
|
| 129 |
+
|
| 130 |
+
self._write_separator()
|
| 131 |
+
self.logger.info("") # Empty line
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
self.logger.error(f"Error logging request: {e}")
|
| 135 |
+
|
| 136 |
+
def log_response(
|
| 137 |
+
self,
|
| 138 |
+
status_code: int,
|
| 139 |
+
headers: Dict[str, str],
|
| 140 |
+
body: Any,
|
| 141 |
+
duration_ms: Optional[float] = None
|
| 142 |
+
):
|
| 143 |
+
"""Log API response details to log.txt"""
|
| 144 |
+
|
| 145 |
+
if not config.debug_enabled or not config.debug_log_responses:
|
| 146 |
+
return
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
self._write_separator()
|
| 150 |
+
self.logger.info(f"🟢 [RESPONSE] {self._format_timestamp()}")
|
| 151 |
+
self._write_separator("-")
|
| 152 |
+
|
| 153 |
+
# Status
|
| 154 |
+
status_emoji = "✅" if 200 <= status_code < 300 else "❌"
|
| 155 |
+
self.logger.info(f"Status: {status_code} {status_emoji}")
|
| 156 |
+
|
| 157 |
+
# Duration
|
| 158 |
+
if duration_ms is not None:
|
| 159 |
+
self.logger.info(f"Duration: {duration_ms:.2f}ms")
|
| 160 |
+
|
| 161 |
+
# Headers
|
| 162 |
+
self.logger.info("\n📋 Response Headers:")
|
| 163 |
+
for key, value in headers.items():
|
| 164 |
+
self.logger.info(f" {key}: {value}")
|
| 165 |
+
|
| 166 |
+
# Body
|
| 167 |
+
self.logger.info("\n📦 Response Body:")
|
| 168 |
+
if isinstance(body, (dict, list)):
|
| 169 |
+
body_str = json.dumps(body, indent=2, ensure_ascii=False)
|
| 170 |
+
self.logger.info(body_str)
|
| 171 |
+
elif isinstance(body, str):
|
| 172 |
+
# Try to parse as JSON
|
| 173 |
+
try:
|
| 174 |
+
parsed = json.loads(body)
|
| 175 |
+
body_str = json.dumps(parsed, indent=2, ensure_ascii=False)
|
| 176 |
+
self.logger.info(body_str)
|
| 177 |
+
except:
|
| 178 |
+
# Not JSON, log as text (limit length)
|
| 179 |
+
if len(body) > 2000:
|
| 180 |
+
self.logger.info(f"{body[:2000]}... (truncated)")
|
| 181 |
+
else:
|
| 182 |
+
self.logger.info(body)
|
| 183 |
+
else:
|
| 184 |
+
self.logger.info(str(body))
|
| 185 |
+
|
| 186 |
+
self._write_separator()
|
| 187 |
+
self.logger.info("") # Empty line
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
self.logger.error(f"Error logging response: {e}")
|
| 191 |
+
|
| 192 |
+
def log_error(
|
| 193 |
+
self,
|
| 194 |
+
error_message: str,
|
| 195 |
+
status_code: Optional[int] = None,
|
| 196 |
+
response_text: Optional[str] = None
|
| 197 |
+
):
|
| 198 |
+
"""Log API error details to log.txt"""
|
| 199 |
+
|
| 200 |
+
if not config.debug_enabled:
|
| 201 |
+
return
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
self._write_separator()
|
| 205 |
+
self.logger.info(f"🔴 [ERROR] {self._format_timestamp()}")
|
| 206 |
+
self._write_separator("-")
|
| 207 |
+
|
| 208 |
+
if status_code:
|
| 209 |
+
self.logger.info(f"Status Code: {status_code}")
|
| 210 |
+
|
| 211 |
+
self.logger.info(f"Error Message: {error_message}")
|
| 212 |
+
|
| 213 |
+
if response_text:
|
| 214 |
+
self.logger.info("\n📦 Error Response:")
|
| 215 |
+
# Try to parse as JSON
|
| 216 |
+
try:
|
| 217 |
+
parsed = json.loads(response_text)
|
| 218 |
+
body_str = json.dumps(parsed, indent=2, ensure_ascii=False)
|
| 219 |
+
self.logger.info(body_str)
|
| 220 |
+
except:
|
| 221 |
+
# Not JSON, log as text
|
| 222 |
+
if len(response_text) > 2000:
|
| 223 |
+
self.logger.info(f"{response_text[:2000]}... (truncated)")
|
| 224 |
+
else:
|
| 225 |
+
self.logger.info(response_text)
|
| 226 |
+
|
| 227 |
+
self._write_separator()
|
| 228 |
+
self.logger.info("") # Empty line
|
| 229 |
+
|
| 230 |
+
except Exception as e:
|
| 231 |
+
self.logger.error(f"Error logging error: {e}")
|
| 232 |
+
|
| 233 |
+
def log_info(self, message: str):
|
| 234 |
+
"""Log general info message to log.txt"""
|
| 235 |
+
if not config.debug_enabled:
|
| 236 |
+
return
|
| 237 |
+
try:
|
| 238 |
+
self.logger.info(f"ℹ️ [{self._format_timestamp()}] {message}")
|
| 239 |
+
except Exception as e:
|
| 240 |
+
self.logger.error(f"Error logging info: {e}")
|
| 241 |
+
|
| 242 |
+
# Global debug logger instance
|
| 243 |
+
debug_logger = DebugLogger()
|
src/core/models.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models for Flow2API"""
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from typing import Optional, List, Union, Any
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Token(BaseModel):
|
| 8 |
+
"""Token model for Flow2API"""
|
| 9 |
+
id: Optional[int] = None
|
| 10 |
+
|
| 11 |
+
# 认证信息 (核心)
|
| 12 |
+
st: str # Session Token (__Secure-next-auth.session-token)
|
| 13 |
+
at: Optional[str] = None # Access Token (从ST转换而来)
|
| 14 |
+
at_expires: Optional[datetime] = None # AT过期时间
|
| 15 |
+
|
| 16 |
+
# 基础信息
|
| 17 |
+
email: str
|
| 18 |
+
name: Optional[str] = ""
|
| 19 |
+
remark: Optional[str] = None
|
| 20 |
+
is_active: bool = True
|
| 21 |
+
created_at: Optional[datetime] = None
|
| 22 |
+
last_used_at: Optional[datetime] = None
|
| 23 |
+
use_count: int = 0
|
| 24 |
+
|
| 25 |
+
# VideoFX特有字段
|
| 26 |
+
credits: int = 0 # 剩余credits
|
| 27 |
+
user_paygate_tier: Optional[str] = None # PAYGATE_TIER_ONE
|
| 28 |
+
|
| 29 |
+
# 项目管理
|
| 30 |
+
current_project_id: Optional[str] = None # 当前使用的项目UUID
|
| 31 |
+
current_project_name: Optional[str] = None # 项目名称
|
| 32 |
+
|
| 33 |
+
# 功能开关
|
| 34 |
+
image_enabled: bool = True
|
| 35 |
+
video_enabled: bool = True
|
| 36 |
+
|
| 37 |
+
# 并发限制
|
| 38 |
+
image_concurrency: int = -1 # -1表示无限制
|
| 39 |
+
video_concurrency: int = -1 # -1表示无限制
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Project(BaseModel):
|
| 43 |
+
"""Project model for VideoFX"""
|
| 44 |
+
id: Optional[int] = None
|
| 45 |
+
project_id: str # VideoFX项目UUID
|
| 46 |
+
token_id: int # 关联的Token ID
|
| 47 |
+
project_name: str # 项目名称
|
| 48 |
+
tool_name: str = "PINHOLE" # 工具名称,固定为PINHOLE
|
| 49 |
+
is_active: bool = True
|
| 50 |
+
created_at: Optional[datetime] = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TokenStats(BaseModel):
|
| 54 |
+
"""Token statistics"""
|
| 55 |
+
token_id: int
|
| 56 |
+
image_count: int = 0
|
| 57 |
+
video_count: int = 0
|
| 58 |
+
success_count: int = 0
|
| 59 |
+
error_count: int = 0
|
| 60 |
+
last_success_at: Optional[datetime] = None
|
| 61 |
+
last_error_at: Optional[datetime] = None
|
| 62 |
+
# 今日统计
|
| 63 |
+
today_image_count: int = 0
|
| 64 |
+
today_video_count: int = 0
|
| 65 |
+
today_error_count: int = 0
|
| 66 |
+
today_date: Optional[str] = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class Task(BaseModel):
|
| 70 |
+
"""Generation task"""
|
| 71 |
+
id: Optional[int] = None
|
| 72 |
+
task_id: str # Flow API返回的operation name
|
| 73 |
+
token_id: int
|
| 74 |
+
model: str
|
| 75 |
+
prompt: str
|
| 76 |
+
status: str # processing, completed, failed
|
| 77 |
+
progress: int = 0 # 0-100
|
| 78 |
+
result_urls: Optional[List[str]] = None
|
| 79 |
+
error_message: Optional[str] = None
|
| 80 |
+
scene_id: Optional[str] = None # Flow API的sceneId
|
| 81 |
+
created_at: Optional[datetime] = None
|
| 82 |
+
completed_at: Optional[datetime] = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class RequestLog(BaseModel):
|
| 86 |
+
"""API request log"""
|
| 87 |
+
id: Optional[int] = None
|
| 88 |
+
token_id: Optional[int] = None
|
| 89 |
+
operation: str
|
| 90 |
+
request_body: Optional[str] = None
|
| 91 |
+
response_body: Optional[str] = None
|
| 92 |
+
status_code: int
|
| 93 |
+
duration: float
|
| 94 |
+
created_at: Optional[datetime] = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class AdminConfig(BaseModel):
|
| 98 |
+
"""Admin configuration"""
|
| 99 |
+
id: int = 1
|
| 100 |
+
username: str
|
| 101 |
+
password: str
|
| 102 |
+
api_key: str
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class ProxyConfig(BaseModel):
|
| 106 |
+
"""Proxy configuration"""
|
| 107 |
+
id: int = 1
|
| 108 |
+
enabled: bool = False
|
| 109 |
+
proxy_url: Optional[str] = None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class GenerationConfig(BaseModel):
|
| 113 |
+
"""Generation timeout configuration"""
|
| 114 |
+
id: int = 1
|
| 115 |
+
image_timeout: int = 300 # seconds
|
| 116 |
+
video_timeout: int = 1500 # seconds
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class CacheConfig(BaseModel):
|
| 120 |
+
"""Cache configuration"""
|
| 121 |
+
id: int = 1
|
| 122 |
+
cache_enabled: bool = False
|
| 123 |
+
cache_timeout: int = 7200 # seconds (2 hours)
|
| 124 |
+
cache_base_url: Optional[str] = None
|
| 125 |
+
created_at: Optional[datetime] = None
|
| 126 |
+
updated_at: Optional[datetime] = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# OpenAI Compatible Request Models
|
| 130 |
+
class ChatMessage(BaseModel):
|
| 131 |
+
"""Chat message"""
|
| 132 |
+
role: str
|
| 133 |
+
content: Union[str, List[dict]] # string or multimodal array
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class ChatCompletionRequest(BaseModel):
|
| 137 |
+
"""Chat completion request (OpenAI compatible)"""
|
| 138 |
+
model: str
|
| 139 |
+
messages: List[ChatMessage]
|
| 140 |
+
stream: bool = False
|
| 141 |
+
temperature: Optional[float] = None
|
| 142 |
+
max_tokens: Optional[int] = None
|
| 143 |
+
# Flow2API specific parameters
|
| 144 |
+
image: Optional[str] = None # Base64 encoded image (deprecated, use messages)
|
| 145 |
+
video: Optional[str] = None # Base64 encoded video (deprecated)
|
src/main.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application initialization"""
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
from fastapi.responses import HTMLResponse, FileResponse
|
| 4 |
+
from fastapi.staticfiles import StaticFiles
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from .core.config import config
|
| 10 |
+
from .core.database import Database
|
| 11 |
+
from .services.flow_client import FlowClient
|
| 12 |
+
from .services.proxy_manager import ProxyManager
|
| 13 |
+
from .services.token_manager import TokenManager
|
| 14 |
+
from .services.load_balancer import LoadBalancer
|
| 15 |
+
from .services.concurrency_manager import ConcurrencyManager
|
| 16 |
+
from .services.generation_handler import GenerationHandler
|
| 17 |
+
from .api import routes, admin
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@asynccontextmanager
|
| 21 |
+
async def lifespan(app: FastAPI):
|
| 22 |
+
"""Application lifespan manager"""
|
| 23 |
+
# Startup
|
| 24 |
+
print("=" * 60)
|
| 25 |
+
print("Flow2API Starting...")
|
| 26 |
+
print("=" * 60)
|
| 27 |
+
|
| 28 |
+
# Get config from setting.toml
|
| 29 |
+
config_dict = config.get_raw_config()
|
| 30 |
+
|
| 31 |
+
# Check if database exists (determine if first startup)
|
| 32 |
+
is_first_startup = not db.db_exists()
|
| 33 |
+
|
| 34 |
+
# Initialize database tables structure
|
| 35 |
+
await db.init_db()
|
| 36 |
+
|
| 37 |
+
# Handle database initialization based on startup type
|
| 38 |
+
if is_first_startup:
|
| 39 |
+
print("🎉 First startup detected. Initializing database and configuration from setting.toml...")
|
| 40 |
+
await db.init_config_from_toml(config_dict, is_first_startup=True)
|
| 41 |
+
print("✓ Database and configuration initialized successfully.")
|
| 42 |
+
else:
|
| 43 |
+
print("🔄 Existing database detected. Checking for missing tables and columns...")
|
| 44 |
+
await db.check_and_migrate_db(config_dict)
|
| 45 |
+
print("✓ Database migration check completed.")
|
| 46 |
+
|
| 47 |
+
# Load admin config from database
|
| 48 |
+
admin_config = await db.get_admin_config()
|
| 49 |
+
if admin_config:
|
| 50 |
+
config.set_admin_username_from_db(admin_config.username)
|
| 51 |
+
config.set_admin_password_from_db(admin_config.password)
|
| 52 |
+
config.api_key = admin_config.api_key
|
| 53 |
+
|
| 54 |
+
# Load cache configuration from database
|
| 55 |
+
cache_config = await db.get_cache_config()
|
| 56 |
+
config.set_cache_enabled(cache_config.cache_enabled)
|
| 57 |
+
config.set_cache_timeout(cache_config.cache_timeout)
|
| 58 |
+
config.set_cache_base_url(cache_config.cache_base_url or "")
|
| 59 |
+
|
| 60 |
+
# Load generation configuration from database
|
| 61 |
+
generation_config = await db.get_generation_config()
|
| 62 |
+
config.set_image_timeout(generation_config.image_timeout)
|
| 63 |
+
config.set_video_timeout(generation_config.video_timeout)
|
| 64 |
+
|
| 65 |
+
# Initialize concurrency manager
|
| 66 |
+
tokens = await token_manager.get_all_tokens()
|
| 67 |
+
await concurrency_manager.initialize(tokens)
|
| 68 |
+
|
| 69 |
+
# Start file cache cleanup task
|
| 70 |
+
await generation_handler.file_cache.start_cleanup_task()
|
| 71 |
+
|
| 72 |
+
print(f"✓ Database initialized")
|
| 73 |
+
print(f"✓ Total tokens: {len(tokens)}")
|
| 74 |
+
print(f"✓ Cache: {'Enabled' if config.cache_enabled else 'Disabled'} (timeout: {config.cache_timeout}s)")
|
| 75 |
+
print(f"✓ File cache cleanup task started")
|
| 76 |
+
print(f"✓ Server running on http://{config.server_host}:{config.server_port}")
|
| 77 |
+
print("=" * 60)
|
| 78 |
+
|
| 79 |
+
yield
|
| 80 |
+
|
| 81 |
+
# Shutdown
|
| 82 |
+
print("Flow2API Shutting down...")
|
| 83 |
+
# Stop file cache cleanup task
|
| 84 |
+
await generation_handler.file_cache.stop_cleanup_task()
|
| 85 |
+
print("✓ File cache cleanup task stopped")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Initialize components
|
| 89 |
+
db = Database()
|
| 90 |
+
proxy_manager = ProxyManager(db)
|
| 91 |
+
flow_client = FlowClient(proxy_manager)
|
| 92 |
+
token_manager = TokenManager(db, flow_client)
|
| 93 |
+
concurrency_manager = ConcurrencyManager()
|
| 94 |
+
load_balancer = LoadBalancer(token_manager, concurrency_manager)
|
| 95 |
+
generation_handler = GenerationHandler(
|
| 96 |
+
flow_client,
|
| 97 |
+
token_manager,
|
| 98 |
+
load_balancer,
|
| 99 |
+
db,
|
| 100 |
+
concurrency_manager,
|
| 101 |
+
proxy_manager # 添加 proxy_manager 参数
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Set dependencies
|
| 105 |
+
routes.set_generation_handler(generation_handler)
|
| 106 |
+
admin.set_dependencies(token_manager, proxy_manager, db)
|
| 107 |
+
|
| 108 |
+
# Create FastAPI app
|
| 109 |
+
app = FastAPI(
|
| 110 |
+
title="Flow2API",
|
| 111 |
+
description="OpenAI-compatible API for Google VideoFX (Veo)",
|
| 112 |
+
version="1.0.0",
|
| 113 |
+
lifespan=lifespan
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# CORS middleware
|
| 117 |
+
app.add_middleware(
|
| 118 |
+
CORSMiddleware,
|
| 119 |
+
allow_origins=["*"],
|
| 120 |
+
allow_credentials=True,
|
| 121 |
+
allow_methods=["*"],
|
| 122 |
+
allow_headers=["*"],
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Include routers
|
| 126 |
+
app.include_router(routes.router)
|
| 127 |
+
app.include_router(admin.router)
|
| 128 |
+
|
| 129 |
+
# Static files - serve tmp directory for cached files
|
| 130 |
+
tmp_dir = Path(__file__).parent.parent / "tmp"
|
| 131 |
+
tmp_dir.mkdir(exist_ok=True)
|
| 132 |
+
app.mount("/tmp", StaticFiles(directory=str(tmp_dir)), name="tmp")
|
| 133 |
+
|
| 134 |
+
# HTML routes for frontend
|
| 135 |
+
static_path = Path(__file__).parent.parent / "static"
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.get("/", response_class=HTMLResponse)
|
| 139 |
+
async def index():
|
| 140 |
+
"""Redirect to login page"""
|
| 141 |
+
login_file = static_path / "login.html"
|
| 142 |
+
if login_file.exists():
|
| 143 |
+
return FileResponse(str(login_file))
|
| 144 |
+
return HTMLResponse(content="<h1>Flow2API</h1><p>Frontend not found</p>", status_code=404)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@app.get("/login", response_class=HTMLResponse)
|
| 148 |
+
async def login_page():
|
| 149 |
+
"""Login page"""
|
| 150 |
+
login_file = static_path / "login.html"
|
| 151 |
+
if login_file.exists():
|
| 152 |
+
return FileResponse(str(login_file))
|
| 153 |
+
return HTMLResponse(content="<h1>Login Page Not Found</h1>", status_code=404)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@app.get("/manage", response_class=HTMLResponse)
|
| 157 |
+
async def manage_page():
|
| 158 |
+
"""Management console page"""
|
| 159 |
+
manage_file = static_path / "manage.html"
|
| 160 |
+
if manage_file.exists():
|
| 161 |
+
return FileResponse(str(manage_file))
|
| 162 |
+
return HTMLResponse(content="<h1>Management Page Not Found</h1>", status_code=404)
|
src/services/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Services modules"""
|
| 2 |
+
|
| 3 |
+
from .flow_client import FlowClient
|
| 4 |
+
from .proxy_manager import ProxyManager
|
| 5 |
+
from .load_balancer import LoadBalancer
|
| 6 |
+
from .concurrency_manager import ConcurrencyManager
|
| 7 |
+
from .token_manager import TokenManager
|
| 8 |
+
from .generation_handler import GenerationHandler
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"FlowClient",
|
| 12 |
+
"ProxyManager",
|
| 13 |
+
"LoadBalancer",
|
| 14 |
+
"ConcurrencyManager",
|
| 15 |
+
"TokenManager",
|
| 16 |
+
"GenerationHandler"
|
| 17 |
+
]
|
src/services/concurrency_manager.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Concurrency manager for token-based rate limiting"""
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import Dict, Optional
|
| 4 |
+
from ..core.logger import debug_logger
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ConcurrencyManager:
|
| 8 |
+
"""Manages concurrent request limits for each token"""
|
| 9 |
+
|
| 10 |
+
def __init__(self):
|
| 11 |
+
"""Initialize concurrency manager"""
|
| 12 |
+
self._image_concurrency: Dict[int, int] = {} # token_id -> remaining image concurrency
|
| 13 |
+
self._video_concurrency: Dict[int, int] = {} # token_id -> remaining video concurrency
|
| 14 |
+
self._lock = asyncio.Lock() # Protect concurrent access
|
| 15 |
+
|
| 16 |
+
async def initialize(self, tokens: list):
|
| 17 |
+
"""
|
| 18 |
+
Initialize concurrency counters from token list
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
tokens: List of Token objects with image_concurrency and video_concurrency fields
|
| 22 |
+
"""
|
| 23 |
+
async with self._lock:
|
| 24 |
+
for token in tokens:
|
| 25 |
+
if token.image_concurrency and token.image_concurrency > 0:
|
| 26 |
+
self._image_concurrency[token.id] = token.image_concurrency
|
| 27 |
+
if token.video_concurrency and token.video_concurrency > 0:
|
| 28 |
+
self._video_concurrency[token.id] = token.video_concurrency
|
| 29 |
+
|
| 30 |
+
debug_logger.log_info(f"Concurrency manager initialized with {len(tokens)} tokens")
|
| 31 |
+
|
| 32 |
+
async def can_use_image(self, token_id: int) -> bool:
|
| 33 |
+
"""
|
| 34 |
+
Check if token can be used for image generation
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
token_id: Token ID
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
True if token has available image concurrency, False if concurrency is 0
|
| 41 |
+
"""
|
| 42 |
+
async with self._lock:
|
| 43 |
+
# If not in dict, it means no limit (-1)
|
| 44 |
+
if token_id not in self._image_concurrency:
|
| 45 |
+
return True
|
| 46 |
+
|
| 47 |
+
remaining = self._image_concurrency[token_id]
|
| 48 |
+
if remaining <= 0:
|
| 49 |
+
debug_logger.log_info(f"Token {token_id} image concurrency exhausted (remaining: {remaining})")
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
return True
|
| 53 |
+
|
| 54 |
+
async def can_use_video(self, token_id: int) -> bool:
|
| 55 |
+
"""
|
| 56 |
+
Check if token can be used for video generation
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
token_id: Token ID
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
True if token has available video concurrency, False if concurrency is 0
|
| 63 |
+
"""
|
| 64 |
+
async with self._lock:
|
| 65 |
+
# If not in dict, it means no limit (-1)
|
| 66 |
+
if token_id not in self._video_concurrency:
|
| 67 |
+
return True
|
| 68 |
+
|
| 69 |
+
remaining = self._video_concurrency[token_id]
|
| 70 |
+
if remaining <= 0:
|
| 71 |
+
debug_logger.log_info(f"Token {token_id} video concurrency exhausted (remaining: {remaining})")
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
return True
|
| 75 |
+
|
| 76 |
+
async def acquire_image(self, token_id: int) -> bool:
|
| 77 |
+
"""
|
| 78 |
+
Acquire image concurrency slot
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
token_id: Token ID
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
True if acquired, False if not available
|
| 85 |
+
"""
|
| 86 |
+
async with self._lock:
|
| 87 |
+
if token_id not in self._image_concurrency:
|
| 88 |
+
# No limit
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
if self._image_concurrency[token_id] <= 0:
|
| 92 |
+
return False
|
| 93 |
+
|
| 94 |
+
self._image_concurrency[token_id] -= 1
|
| 95 |
+
debug_logger.log_info(f"Token {token_id} acquired image slot (remaining: {self._image_concurrency[token_id]})")
|
| 96 |
+
return True
|
| 97 |
+
|
| 98 |
+
async def acquire_video(self, token_id: int) -> bool:
|
| 99 |
+
"""
|
| 100 |
+
Acquire video concurrency slot
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
token_id: Token ID
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
True if acquired, False if not available
|
| 107 |
+
"""
|
| 108 |
+
async with self._lock:
|
| 109 |
+
if token_id not in self._video_concurrency:
|
| 110 |
+
# No limit
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
if self._video_concurrency[token_id] <= 0:
|
| 114 |
+
return False
|
| 115 |
+
|
| 116 |
+
self._video_concurrency[token_id] -= 1
|
| 117 |
+
debug_logger.log_info(f"Token {token_id} acquired video slot (remaining: {self._video_concurrency[token_id]})")
|
| 118 |
+
return True
|
| 119 |
+
|
| 120 |
+
async def release_image(self, token_id: int):
|
| 121 |
+
"""
|
| 122 |
+
Release image concurrency slot
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
token_id: Token ID
|
| 126 |
+
"""
|
| 127 |
+
async with self._lock:
|
| 128 |
+
if token_id in self._image_concurrency:
|
| 129 |
+
self._image_concurrency[token_id] += 1
|
| 130 |
+
debug_logger.log_info(f"Token {token_id} released image slot (remaining: {self._image_concurrency[token_id]})")
|
| 131 |
+
|
| 132 |
+
async def release_video(self, token_id: int):
|
| 133 |
+
"""
|
| 134 |
+
Release video concurrency slot
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
token_id: Token ID
|
| 138 |
+
"""
|
| 139 |
+
async with self._lock:
|
| 140 |
+
if token_id in self._video_concurrency:
|
| 141 |
+
self._video_concurrency[token_id] += 1
|
| 142 |
+
debug_logger.log_info(f"Token {token_id} released video slot (remaining: {self._video_concurrency[token_id]})")
|
| 143 |
+
|
| 144 |
+
async def get_image_remaining(self, token_id: int) -> Optional[int]:
|
| 145 |
+
"""
|
| 146 |
+
Get remaining image concurrency for token
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
token_id: Token ID
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Remaining count or None if no limit
|
| 153 |
+
"""
|
| 154 |
+
async with self._lock:
|
| 155 |
+
return self._image_concurrency.get(token_id)
|
| 156 |
+
|
| 157 |
+
async def get_video_remaining(self, token_id: int) -> Optional[int]:
|
| 158 |
+
"""
|
| 159 |
+
Get remaining video concurrency for token
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
token_id: Token ID
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
Remaining count or None if no limit
|
| 166 |
+
"""
|
| 167 |
+
async with self._lock:
|
| 168 |
+
return self._video_concurrency.get(token_id)
|
| 169 |
+
|
| 170 |
+
async def reset_token(self, token_id: int, image_concurrency: int = -1, video_concurrency: int = -1):
|
| 171 |
+
"""
|
| 172 |
+
Reset concurrency counters for a token
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
token_id: Token ID
|
| 176 |
+
image_concurrency: New image concurrency limit (-1 for no limit)
|
| 177 |
+
video_concurrency: New video concurrency limit (-1 for no limit)
|
| 178 |
+
"""
|
| 179 |
+
async with self._lock:
|
| 180 |
+
if image_concurrency > 0:
|
| 181 |
+
self._image_concurrency[token_id] = image_concurrency
|
| 182 |
+
elif token_id in self._image_concurrency:
|
| 183 |
+
del self._image_concurrency[token_id]
|
| 184 |
+
|
| 185 |
+
if video_concurrency > 0:
|
| 186 |
+
self._video_concurrency[token_id] = video_concurrency
|
| 187 |
+
elif token_id in self._video_concurrency:
|
| 188 |
+
del self._video_concurrency[token_id]
|
| 189 |
+
|
| 190 |
+
debug_logger.log_info(f"Token {token_id} concurrency reset (image: {image_concurrency}, video: {video_concurrency})")
|
src/services/file_cache.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""File caching service"""
|
| 2 |
+
import os
|
| 3 |
+
import asyncio
|
| 4 |
+
import hashlib
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from curl_cffi.requests import AsyncSession
|
| 10 |
+
from ..core.config import config
|
| 11 |
+
from ..core.logger import debug_logger
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FileCache:
|
| 15 |
+
"""File caching service for videos"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, cache_dir: str = "tmp", default_timeout: int = 7200, proxy_manager=None):
|
| 18 |
+
"""
|
| 19 |
+
Initialize file cache
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
cache_dir: Cache directory path
|
| 23 |
+
default_timeout: Default cache timeout in seconds (default: 2 hours)
|
| 24 |
+
proxy_manager: ProxyManager instance for downloading files
|
| 25 |
+
"""
|
| 26 |
+
self.cache_dir = Path(cache_dir)
|
| 27 |
+
self.cache_dir.mkdir(exist_ok=True)
|
| 28 |
+
self.default_timeout = default_timeout
|
| 29 |
+
self.proxy_manager = proxy_manager
|
| 30 |
+
self._cleanup_task = None
|
| 31 |
+
|
| 32 |
+
async def start_cleanup_task(self):
|
| 33 |
+
"""Start background cleanup task"""
|
| 34 |
+
if self._cleanup_task is None:
|
| 35 |
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
| 36 |
+
|
| 37 |
+
async def stop_cleanup_task(self):
|
| 38 |
+
"""Stop background cleanup task"""
|
| 39 |
+
if self._cleanup_task:
|
| 40 |
+
self._cleanup_task.cancel()
|
| 41 |
+
try:
|
| 42 |
+
await self._cleanup_task
|
| 43 |
+
except asyncio.CancelledError:
|
| 44 |
+
pass
|
| 45 |
+
self._cleanup_task = None
|
| 46 |
+
|
| 47 |
+
async def _cleanup_loop(self):
|
| 48 |
+
"""Background task to clean up expired files"""
|
| 49 |
+
while True:
|
| 50 |
+
try:
|
| 51 |
+
await asyncio.sleep(300) # Check every 5 minutes
|
| 52 |
+
await self._cleanup_expired_files()
|
| 53 |
+
except asyncio.CancelledError:
|
| 54 |
+
break
|
| 55 |
+
except Exception as e:
|
| 56 |
+
debug_logger.log_error(
|
| 57 |
+
error_message=f"Cleanup task error: {str(e)}",
|
| 58 |
+
status_code=0,
|
| 59 |
+
response_text=""
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
async def _cleanup_expired_files(self):
|
| 63 |
+
"""Remove expired cache files"""
|
| 64 |
+
try:
|
| 65 |
+
current_time = time.time()
|
| 66 |
+
removed_count = 0
|
| 67 |
+
|
| 68 |
+
for file_path in self.cache_dir.iterdir():
|
| 69 |
+
if file_path.is_file():
|
| 70 |
+
# Check file age
|
| 71 |
+
file_age = current_time - file_path.stat().st_mtime
|
| 72 |
+
if file_age > self.default_timeout:
|
| 73 |
+
try:
|
| 74 |
+
file_path.unlink()
|
| 75 |
+
removed_count += 1
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
|
| 79 |
+
if removed_count > 0:
|
| 80 |
+
debug_logger.log_info(f"Cleanup: removed {removed_count} expired cache files")
|
| 81 |
+
|
| 82 |
+
except Exception as e:
|
| 83 |
+
debug_logger.log_error(
|
| 84 |
+
error_message=f"Failed to cleanup expired files: {str(e)}",
|
| 85 |
+
status_code=0,
|
| 86 |
+
response_text=""
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def _generate_cache_filename(self, url: str, media_type: str) -> str:
|
| 90 |
+
"""Generate unique filename for cached file"""
|
| 91 |
+
# Use URL hash as filename
|
| 92 |
+
url_hash = hashlib.md5(url.encode()).hexdigest()
|
| 93 |
+
|
| 94 |
+
# Determine file extension
|
| 95 |
+
if media_type == "video":
|
| 96 |
+
ext = ".mp4"
|
| 97 |
+
elif media_type == "image":
|
| 98 |
+
ext = ".jpg"
|
| 99 |
+
else:
|
| 100 |
+
ext = ""
|
| 101 |
+
|
| 102 |
+
return f"{url_hash}{ext}"
|
| 103 |
+
|
| 104 |
+
async def download_and_cache(self, url: str, media_type: str) -> str:
|
| 105 |
+
"""
|
| 106 |
+
Download file from URL and cache it locally
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
url: File URL to download
|
| 110 |
+
media_type: 'image' or 'video'
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
Local cache filename
|
| 114 |
+
"""
|
| 115 |
+
filename = self._generate_cache_filename(url, media_type)
|
| 116 |
+
file_path = self.cache_dir / filename
|
| 117 |
+
|
| 118 |
+
# Check if already cached and not expired
|
| 119 |
+
if file_path.exists():
|
| 120 |
+
file_age = time.time() - file_path.stat().st_mtime
|
| 121 |
+
if file_age < self.default_timeout:
|
| 122 |
+
debug_logger.log_info(f"Cache hit: {filename}")
|
| 123 |
+
return filename
|
| 124 |
+
else:
|
| 125 |
+
# Remove expired file
|
| 126 |
+
try:
|
| 127 |
+
file_path.unlink()
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
|
| 131 |
+
# Download file
|
| 132 |
+
debug_logger.log_info(f"Downloading file from: {url}")
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
# Get proxy if available
|
| 136 |
+
proxy_url = None
|
| 137 |
+
if self.proxy_manager:
|
| 138 |
+
proxy_config = await self.proxy_manager.get_proxy_config()
|
| 139 |
+
if proxy_config and proxy_config.enabled and proxy_config.proxy_url:
|
| 140 |
+
proxy_url = proxy_config.proxy_url
|
| 141 |
+
|
| 142 |
+
# Download with proxy support
|
| 143 |
+
async with AsyncSession() as session:
|
| 144 |
+
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
| 145 |
+
response = await session.get(url, timeout=60, proxies=proxies)
|
| 146 |
+
|
| 147 |
+
if response.status_code != 200:
|
| 148 |
+
raise Exception(f"Download failed: HTTP {response.status_code}")
|
| 149 |
+
|
| 150 |
+
# Save to cache
|
| 151 |
+
with open(file_path, 'wb') as f:
|
| 152 |
+
f.write(response.content)
|
| 153 |
+
|
| 154 |
+
debug_logger.log_info(f"File cached: {filename} ({len(response.content)} bytes)")
|
| 155 |
+
return filename
|
| 156 |
+
|
| 157 |
+
except Exception as e:
|
| 158 |
+
debug_logger.log_error(
|
| 159 |
+
error_message=f"Failed to download file: {str(e)}",
|
| 160 |
+
status_code=0,
|
| 161 |
+
response_text=str(e)
|
| 162 |
+
)
|
| 163 |
+
raise Exception(f"Failed to cache file: {str(e)}")
|
| 164 |
+
|
| 165 |
+
def get_cache_path(self, filename: str) -> Path:
|
| 166 |
+
"""Get full path to cached file"""
|
| 167 |
+
return self.cache_dir / filename
|
| 168 |
+
|
| 169 |
+
def set_timeout(self, timeout: int):
|
| 170 |
+
"""Set cache timeout in seconds"""
|
| 171 |
+
self.default_timeout = timeout
|
| 172 |
+
debug_logger.log_info(f"Cache timeout updated to {timeout} seconds")
|
| 173 |
+
|
| 174 |
+
def get_timeout(self) -> int:
|
| 175 |
+
"""Get current cache timeout"""
|
| 176 |
+
return self.default_timeout
|
| 177 |
+
|
| 178 |
+
async def clear_all(self):
|
| 179 |
+
"""Clear all cached files"""
|
| 180 |
+
try:
|
| 181 |
+
removed_count = 0
|
| 182 |
+
for file_path in self.cache_dir.iterdir():
|
| 183 |
+
if file_path.is_file():
|
| 184 |
+
try:
|
| 185 |
+
file_path.unlink()
|
| 186 |
+
removed_count += 1
|
| 187 |
+
except Exception:
|
| 188 |
+
pass
|
| 189 |
+
|
| 190 |
+
debug_logger.log_info(f"Cache cleared: removed {removed_count} files")
|
| 191 |
+
return removed_count
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
debug_logger.log_error(
|
| 195 |
+
error_message=f"Failed to clear cache: {str(e)}",
|
| 196 |
+
status_code=0,
|
| 197 |
+
response_text=""
|
| 198 |
+
)
|
| 199 |
+
raise
|
src/services/flow_client.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flow API Client for VideoFX (Veo)"""
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
import random
|
| 5 |
+
import base64
|
| 6 |
+
from typing import Dict, Any, Optional, List
|
| 7 |
+
from curl_cffi.requests import AsyncSession
|
| 8 |
+
from ..core.logger import debug_logger
|
| 9 |
+
from ..core.config import config
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class FlowClient:
|
| 13 |
+
"""VideoFX API客户端"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, proxy_manager):
|
| 16 |
+
self.proxy_manager = proxy_manager
|
| 17 |
+
self.labs_base_url = config.flow_labs_base_url # https://labs.google/fx/api
|
| 18 |
+
self.api_base_url = config.flow_api_base_url # https://aisandbox-pa.googleapis.com/v1
|
| 19 |
+
self.timeout = config.flow_timeout
|
| 20 |
+
|
| 21 |
+
async def _make_request(
|
| 22 |
+
self,
|
| 23 |
+
method: str,
|
| 24 |
+
url: str,
|
| 25 |
+
headers: Optional[Dict] = None,
|
| 26 |
+
json_data: Optional[Dict] = None,
|
| 27 |
+
use_st: bool = False,
|
| 28 |
+
st_token: Optional[str] = None,
|
| 29 |
+
use_at: bool = False,
|
| 30 |
+
at_token: Optional[str] = None
|
| 31 |
+
) -> Dict[str, Any]:
|
| 32 |
+
"""统一HTTP请求处理
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
method: HTTP方法 (GET/POST)
|
| 36 |
+
url: 完整URL
|
| 37 |
+
headers: 请求头
|
| 38 |
+
json_data: JSON请求体
|
| 39 |
+
use_st: 是否使用ST认证 (Cookie方式)
|
| 40 |
+
st_token: Session Token
|
| 41 |
+
use_at: 是否使用AT认证 (Bearer方式)
|
| 42 |
+
at_token: Access Token
|
| 43 |
+
"""
|
| 44 |
+
proxy_url = await self.proxy_manager.get_proxy_url()
|
| 45 |
+
|
| 46 |
+
if headers is None:
|
| 47 |
+
headers = {}
|
| 48 |
+
|
| 49 |
+
# ST认证 - 使用Cookie
|
| 50 |
+
if use_st and st_token:
|
| 51 |
+
headers["Cookie"] = f"__Secure-next-auth.session-token={st_token}"
|
| 52 |
+
|
| 53 |
+
# AT认证 - 使用Bearer
|
| 54 |
+
if use_at and at_token:
|
| 55 |
+
headers["authorization"] = f"Bearer {at_token}"
|
| 56 |
+
|
| 57 |
+
# 通用请求头
|
| 58 |
+
headers.update({
|
| 59 |
+
"Content-Type": "application/json",
|
| 60 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
# Log request
|
| 64 |
+
if config.debug_enabled:
|
| 65 |
+
debug_logger.log_request(
|
| 66 |
+
method=method,
|
| 67 |
+
url=url,
|
| 68 |
+
headers=headers,
|
| 69 |
+
body=json_data,
|
| 70 |
+
proxy=proxy_url
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
start_time = time.time()
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
async with AsyncSession() as session:
|
| 77 |
+
if method.upper() == "GET":
|
| 78 |
+
response = await session.get(
|
| 79 |
+
url,
|
| 80 |
+
headers=headers,
|
| 81 |
+
proxy=proxy_url,
|
| 82 |
+
timeout=self.timeout,
|
| 83 |
+
impersonate="chrome110"
|
| 84 |
+
)
|
| 85 |
+
else: # POST
|
| 86 |
+
response = await session.post(
|
| 87 |
+
url,
|
| 88 |
+
headers=headers,
|
| 89 |
+
json=json_data,
|
| 90 |
+
proxy=proxy_url,
|
| 91 |
+
timeout=self.timeout,
|
| 92 |
+
impersonate="chrome110"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 96 |
+
|
| 97 |
+
# Log response
|
| 98 |
+
if config.debug_enabled:
|
| 99 |
+
debug_logger.log_response(
|
| 100 |
+
status_code=response.status_code,
|
| 101 |
+
headers=dict(response.headers),
|
| 102 |
+
body=response.text,
|
| 103 |
+
duration_ms=duration_ms
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
response.raise_for_status()
|
| 107 |
+
return response.json()
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 111 |
+
error_msg = str(e)
|
| 112 |
+
|
| 113 |
+
if config.debug_enabled:
|
| 114 |
+
debug_logger.log_error(
|
| 115 |
+
error_message=error_msg,
|
| 116 |
+
status_code=getattr(e, 'status_code', None),
|
| 117 |
+
response_text=getattr(e, 'response_text', None)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
raise Exception(f"Flow API request failed: {error_msg}")
|
| 121 |
+
|
| 122 |
+
# ========== 认证相关 (使用ST) ==========
|
| 123 |
+
|
| 124 |
+
async def st_to_at(self, st: str) -> dict:
|
| 125 |
+
"""ST转AT
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
st: Session Token
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
{
|
| 132 |
+
"access_token": "AT",
|
| 133 |
+
"expires": "2025-11-15T04:46:04.000Z",
|
| 134 |
+
"user": {...}
|
| 135 |
+
}
|
| 136 |
+
"""
|
| 137 |
+
url = f"{self.labs_base_url}/auth/session"
|
| 138 |
+
result = await self._make_request(
|
| 139 |
+
method="GET",
|
| 140 |
+
url=url,
|
| 141 |
+
use_st=True,
|
| 142 |
+
st_token=st
|
| 143 |
+
)
|
| 144 |
+
return result
|
| 145 |
+
|
| 146 |
+
# ========== 项目管理 (使用ST) ==========
|
| 147 |
+
|
| 148 |
+
async def create_project(self, st: str, title: str) -> str:
|
| 149 |
+
"""创建项目,返回project_id
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
st: Session Token
|
| 153 |
+
title: 项目标题
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
project_id (UUID)
|
| 157 |
+
"""
|
| 158 |
+
url = f"{self.labs_base_url}/trpc/project.createProject"
|
| 159 |
+
json_data = {
|
| 160 |
+
"json": {
|
| 161 |
+
"projectTitle": title,
|
| 162 |
+
"toolName": "PINHOLE"
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
result = await self._make_request(
|
| 167 |
+
method="POST",
|
| 168 |
+
url=url,
|
| 169 |
+
json_data=json_data,
|
| 170 |
+
use_st=True,
|
| 171 |
+
st_token=st
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# 解析返回的project_id
|
| 175 |
+
project_id = result["result"]["data"]["json"]["result"]["projectId"]
|
| 176 |
+
return project_id
|
| 177 |
+
|
| 178 |
+
async def delete_project(self, st: str, project_id: str):
|
| 179 |
+
"""删除项目
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
st: Session Token
|
| 183 |
+
project_id: 项目ID
|
| 184 |
+
"""
|
| 185 |
+
url = f"{self.labs_base_url}/trpc/project.deleteProject"
|
| 186 |
+
json_data = {
|
| 187 |
+
"json": {
|
| 188 |
+
"projectToDeleteId": project_id
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
await self._make_request(
|
| 193 |
+
method="POST",
|
| 194 |
+
url=url,
|
| 195 |
+
json_data=json_data,
|
| 196 |
+
use_st=True,
|
| 197 |
+
st_token=st
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# ========== 余额查询 (使用AT) ==========
|
| 201 |
+
|
| 202 |
+
async def get_credits(self, at: str) -> dict:
|
| 203 |
+
"""查询余额
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
at: Access Token
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
{
|
| 210 |
+
"credits": 920,
|
| 211 |
+
"userPaygateTier": "PAYGATE_TIER_ONE"
|
| 212 |
+
}
|
| 213 |
+
"""
|
| 214 |
+
url = f"{self.api_base_url}/credits"
|
| 215 |
+
result = await self._make_request(
|
| 216 |
+
method="GET",
|
| 217 |
+
url=url,
|
| 218 |
+
use_at=True,
|
| 219 |
+
at_token=at
|
| 220 |
+
)
|
| 221 |
+
return result
|
| 222 |
+
|
| 223 |
+
# ========== 图片上传 (使用AT) ==========
|
| 224 |
+
|
| 225 |
+
async def upload_image(
|
| 226 |
+
self,
|
| 227 |
+
at: str,
|
| 228 |
+
image_bytes: bytes,
|
| 229 |
+
aspect_ratio: str = "IMAGE_ASPECT_RATIO_LANDSCAPE"
|
| 230 |
+
) -> str:
|
| 231 |
+
"""上传图片,返回mediaGenerationId
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
at: Access Token
|
| 235 |
+
image_bytes: 图片字节数据
|
| 236 |
+
aspect_ratio: 图片或视频宽高比(会自动转换为图片格式)
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
mediaGenerationId (CAM...)
|
| 240 |
+
"""
|
| 241 |
+
# 转换视频aspect_ratio为图片aspect_ratio
|
| 242 |
+
# VIDEO_ASPECT_RATIO_LANDSCAPE -> IMAGE_ASPECT_RATIO_LANDSCAPE
|
| 243 |
+
# VIDEO_ASPECT_RATIO_PORTRAIT -> IMAGE_ASPECT_RATIO_PORTRAIT
|
| 244 |
+
if aspect_ratio.startswith("VIDEO_"):
|
| 245 |
+
aspect_ratio = aspect_ratio.replace("VIDEO_", "IMAGE_")
|
| 246 |
+
|
| 247 |
+
# 编码为base64 (去掉前缀)
|
| 248 |
+
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
| 249 |
+
|
| 250 |
+
url = f"{self.api_base_url}:uploadUserImage"
|
| 251 |
+
json_data = {
|
| 252 |
+
"imageInput": {
|
| 253 |
+
"rawImageBytes": image_base64,
|
| 254 |
+
"mimeType": "image/jpeg",
|
| 255 |
+
"isUserUploaded": True,
|
| 256 |
+
"aspectRatio": aspect_ratio
|
| 257 |
+
},
|
| 258 |
+
"clientContext": {
|
| 259 |
+
"sessionId": self._generate_session_id(),
|
| 260 |
+
"tool": "ASSET_MANAGER"
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
result = await self._make_request(
|
| 265 |
+
method="POST",
|
| 266 |
+
url=url,
|
| 267 |
+
json_data=json_data,
|
| 268 |
+
use_at=True,
|
| 269 |
+
at_token=at
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# 返回mediaGenerationId
|
| 273 |
+
media_id = result["mediaGenerationId"]["mediaGenerationId"]
|
| 274 |
+
return media_id
|
| 275 |
+
|
| 276 |
+
# ========== 图片生成 (使用AT) - 同步返回 ==========
|
| 277 |
+
|
| 278 |
+
async def generate_image(
|
| 279 |
+
self,
|
| 280 |
+
at: str,
|
| 281 |
+
project_id: str,
|
| 282 |
+
prompt: str,
|
| 283 |
+
model_name: str,
|
| 284 |
+
aspect_ratio: str,
|
| 285 |
+
image_inputs: Optional[List[Dict]] = None
|
| 286 |
+
) -> dict:
|
| 287 |
+
"""生成图片(同步返回)
|
| 288 |
+
|
| 289 |
+
Args:
|
| 290 |
+
at: Access Token
|
| 291 |
+
project_id: 项目ID
|
| 292 |
+
prompt: 提示词
|
| 293 |
+
model_name: GEM_PIX, GEM_PIX_2 或 IMAGEN_3_5
|
| 294 |
+
aspect_ratio: 图片宽高比
|
| 295 |
+
image_inputs: 参考图片列表(图生图时使用)
|
| 296 |
+
|
| 297 |
+
Returns:
|
| 298 |
+
{
|
| 299 |
+
"media": [{
|
| 300 |
+
"image": {
|
| 301 |
+
"generatedImage": {
|
| 302 |
+
"fifeUrl": "图片URL",
|
| 303 |
+
...
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
}]
|
| 307 |
+
}
|
| 308 |
+
"""
|
| 309 |
+
url = f"{self.api_base_url}/projects/{project_id}/flowMedia:batchGenerateImages"
|
| 310 |
+
|
| 311 |
+
# 构建请求
|
| 312 |
+
request_data = {
|
| 313 |
+
"clientContext": {
|
| 314 |
+
"sessionId": self._generate_session_id()
|
| 315 |
+
},
|
| 316 |
+
"seed": random.randint(1, 99999),
|
| 317 |
+
"imageModelName": model_name,
|
| 318 |
+
"imageAspectRatio": aspect_ratio,
|
| 319 |
+
"prompt": prompt,
|
| 320 |
+
"imageInputs": image_inputs or []
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
json_data = {
|
| 324 |
+
"requests": [request_data]
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
result = await self._make_request(
|
| 328 |
+
method="POST",
|
| 329 |
+
url=url,
|
| 330 |
+
json_data=json_data,
|
| 331 |
+
use_at=True,
|
| 332 |
+
at_token=at
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
return result
|
| 336 |
+
|
| 337 |
+
# ========== 视频生成 (使用AT) - 异步返回 ==========
|
| 338 |
+
|
| 339 |
+
async def generate_video_text(
|
| 340 |
+
self,
|
| 341 |
+
at: str,
|
| 342 |
+
project_id: str,
|
| 343 |
+
prompt: str,
|
| 344 |
+
model_key: str,
|
| 345 |
+
aspect_ratio: str,
|
| 346 |
+
user_paygate_tier: str = "PAYGATE_TIER_ONE"
|
| 347 |
+
) -> dict:
|
| 348 |
+
"""文生视频,返回task_id
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
at: Access Token
|
| 352 |
+
project_id: 项目ID
|
| 353 |
+
prompt: 提示词
|
| 354 |
+
model_key: veo_3_1_t2v_fast 等
|
| 355 |
+
aspect_ratio: 视频宽高比
|
| 356 |
+
user_paygate_tier: 用户等级
|
| 357 |
+
|
| 358 |
+
Returns:
|
| 359 |
+
{
|
| 360 |
+
"operations": [{
|
| 361 |
+
"operation": {"name": "task_id"},
|
| 362 |
+
"sceneId": "uuid",
|
| 363 |
+
"status": "MEDIA_GENERATION_STATUS_PENDING"
|
| 364 |
+
}],
|
| 365 |
+
"remainingCredits": 900
|
| 366 |
+
}
|
| 367 |
+
"""
|
| 368 |
+
url = f"{self.api_base_url}/video:batchAsyncGenerateVideoText"
|
| 369 |
+
|
| 370 |
+
scene_id = str(uuid.uuid4())
|
| 371 |
+
|
| 372 |
+
json_data = {
|
| 373 |
+
"clientContext": {
|
| 374 |
+
"sessionId": self._generate_session_id(),
|
| 375 |
+
"projectId": project_id,
|
| 376 |
+
"tool": "PINHOLE",
|
| 377 |
+
"userPaygateTier": user_paygate_tier
|
| 378 |
+
},
|
| 379 |
+
"requests": [{
|
| 380 |
+
"aspectRatio": aspect_ratio,
|
| 381 |
+
"seed": random.randint(1, 99999),
|
| 382 |
+
"textInput": {
|
| 383 |
+
"prompt": prompt
|
| 384 |
+
},
|
| 385 |
+
"videoModelKey": model_key,
|
| 386 |
+
"metadata": {
|
| 387 |
+
"sceneId": scene_id
|
| 388 |
+
}
|
| 389 |
+
}]
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
result = await self._make_request(
|
| 393 |
+
method="POST",
|
| 394 |
+
url=url,
|
| 395 |
+
json_data=json_data,
|
| 396 |
+
use_at=True,
|
| 397 |
+
at_token=at
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
return result
|
| 401 |
+
|
| 402 |
+
async def generate_video_reference_images(
|
| 403 |
+
self,
|
| 404 |
+
at: str,
|
| 405 |
+
project_id: str,
|
| 406 |
+
prompt: str,
|
| 407 |
+
model_key: str,
|
| 408 |
+
aspect_ratio: str,
|
| 409 |
+
reference_images: List[Dict],
|
| 410 |
+
user_paygate_tier: str = "PAYGATE_TIER_ONE"
|
| 411 |
+
) -> dict:
|
| 412 |
+
"""图生视频,返回task_id
|
| 413 |
+
|
| 414 |
+
Args:
|
| 415 |
+
at: Access Token
|
| 416 |
+
project_id: 项目ID
|
| 417 |
+
prompt: 提示词
|
| 418 |
+
model_key: veo_3_0_r2v_fast
|
| 419 |
+
aspect_ratio: 视频宽高比
|
| 420 |
+
reference_images: 参考图片列表 [{"imageUsageType": "IMAGE_USAGE_TYPE_ASSET", "mediaId": "..."}]
|
| 421 |
+
user_paygate_tier: 用户等级
|
| 422 |
+
|
| 423 |
+
Returns:
|
| 424 |
+
同 generate_video_text
|
| 425 |
+
"""
|
| 426 |
+
url = f"{self.api_base_url}/video:batchAsyncGenerateVideoReferenceImages"
|
| 427 |
+
|
| 428 |
+
scene_id = str(uuid.uuid4())
|
| 429 |
+
|
| 430 |
+
json_data = {
|
| 431 |
+
"clientContext": {
|
| 432 |
+
"sessionId": self._generate_session_id(),
|
| 433 |
+
"projectId": project_id,
|
| 434 |
+
"tool": "PINHOLE",
|
| 435 |
+
"userPaygateTier": user_paygate_tier
|
| 436 |
+
},
|
| 437 |
+
"requests": [{
|
| 438 |
+
"aspectRatio": aspect_ratio,
|
| 439 |
+
"seed": random.randint(1, 99999),
|
| 440 |
+
"textInput": {
|
| 441 |
+
"prompt": prompt
|
| 442 |
+
},
|
| 443 |
+
"videoModelKey": model_key,
|
| 444 |
+
"referenceImages": reference_images,
|
| 445 |
+
"metadata": {
|
| 446 |
+
"sceneId": scene_id
|
| 447 |
+
}
|
| 448 |
+
}]
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
result = await self._make_request(
|
| 452 |
+
method="POST",
|
| 453 |
+
url=url,
|
| 454 |
+
json_data=json_data,
|
| 455 |
+
use_at=True,
|
| 456 |
+
at_token=at
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
return result
|
| 460 |
+
|
| 461 |
+
async def generate_video_start_end(
|
| 462 |
+
self,
|
| 463 |
+
at: str,
|
| 464 |
+
project_id: str,
|
| 465 |
+
prompt: str,
|
| 466 |
+
model_key: str,
|
| 467 |
+
aspect_ratio: str,
|
| 468 |
+
start_media_id: str,
|
| 469 |
+
end_media_id: str,
|
| 470 |
+
user_paygate_tier: str = "PAYGATE_TIER_ONE"
|
| 471 |
+
) -> dict:
|
| 472 |
+
"""收尾帧生成视频,返回task_id
|
| 473 |
+
|
| 474 |
+
Args:
|
| 475 |
+
at: Access Token
|
| 476 |
+
project_id: 项目ID
|
| 477 |
+
prompt: 提示词
|
| 478 |
+
model_key: veo_3_1_i2v_s_fast_fl
|
| 479 |
+
aspect_ratio: 视频宽高比
|
| 480 |
+
start_media_id: 起始帧mediaId
|
| 481 |
+
end_media_id: 结束帧mediaId
|
| 482 |
+
user_paygate_tier: 用户等级
|
| 483 |
+
|
| 484 |
+
Returns:
|
| 485 |
+
同 generate_video_text
|
| 486 |
+
"""
|
| 487 |
+
url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartAndEndImage"
|
| 488 |
+
|
| 489 |
+
scene_id = str(uuid.uuid4())
|
| 490 |
+
|
| 491 |
+
json_data = {
|
| 492 |
+
"clientContext": {
|
| 493 |
+
"sessionId": self._generate_session_id(),
|
| 494 |
+
"projectId": project_id,
|
| 495 |
+
"tool": "PINHOLE",
|
| 496 |
+
"userPaygateTier": user_paygate_tier
|
| 497 |
+
},
|
| 498 |
+
"requests": [{
|
| 499 |
+
"aspectRatio": aspect_ratio,
|
| 500 |
+
"seed": random.randint(1, 99999),
|
| 501 |
+
"textInput": {
|
| 502 |
+
"prompt": prompt
|
| 503 |
+
},
|
| 504 |
+
"videoModelKey": model_key,
|
| 505 |
+
"startImage": {
|
| 506 |
+
"mediaId": start_media_id
|
| 507 |
+
},
|
| 508 |
+
"endImage": {
|
| 509 |
+
"mediaId": end_media_id
|
| 510 |
+
},
|
| 511 |
+
"metadata": {
|
| 512 |
+
"sceneId": scene_id
|
| 513 |
+
}
|
| 514 |
+
}]
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
result = await self._make_request(
|
| 518 |
+
method="POST",
|
| 519 |
+
url=url,
|
| 520 |
+
json_data=json_data,
|
| 521 |
+
use_at=True,
|
| 522 |
+
at_token=at
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
return result
|
| 526 |
+
|
| 527 |
+
async def generate_video_start_image(
|
| 528 |
+
self,
|
| 529 |
+
at: str,
|
| 530 |
+
project_id: str,
|
| 531 |
+
prompt: str,
|
| 532 |
+
model_key: str,
|
| 533 |
+
aspect_ratio: str,
|
| 534 |
+
start_media_id: str,
|
| 535 |
+
user_paygate_tier: str = "PAYGATE_TIER_ONE"
|
| 536 |
+
) -> dict:
|
| 537 |
+
"""仅首帧生成视频,返回task_id
|
| 538 |
+
|
| 539 |
+
Args:
|
| 540 |
+
at: Access Token
|
| 541 |
+
project_id: 项目ID
|
| 542 |
+
prompt: 提示词
|
| 543 |
+
model_key: veo_3_1_i2v_s_fast_fl等
|
| 544 |
+
aspect_ratio: 视频宽高比
|
| 545 |
+
start_media_id: 起始帧mediaId
|
| 546 |
+
user_paygate_tier: 用户等级
|
| 547 |
+
|
| 548 |
+
Returns:
|
| 549 |
+
同 generate_video_text
|
| 550 |
+
"""
|
| 551 |
+
url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartAndEndImage"
|
| 552 |
+
|
| 553 |
+
scene_id = str(uuid.uuid4())
|
| 554 |
+
|
| 555 |
+
json_data = {
|
| 556 |
+
"clientContext": {
|
| 557 |
+
"sessionId": self._generate_session_id(),
|
| 558 |
+
"projectId": project_id,
|
| 559 |
+
"tool": "PINHOLE",
|
| 560 |
+
"userPaygateTier": user_paygate_tier
|
| 561 |
+
},
|
| 562 |
+
"requests": [{
|
| 563 |
+
"aspectRatio": aspect_ratio,
|
| 564 |
+
"seed": random.randint(1, 99999),
|
| 565 |
+
"textInput": {
|
| 566 |
+
"prompt": prompt
|
| 567 |
+
},
|
| 568 |
+
"videoModelKey": model_key,
|
| 569 |
+
"startImage": {
|
| 570 |
+
"mediaId": start_media_id
|
| 571 |
+
},
|
| 572 |
+
# 注意: 没有endImage字段,只用首帧
|
| 573 |
+
"metadata": {
|
| 574 |
+
"sceneId": scene_id
|
| 575 |
+
}
|
| 576 |
+
}]
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
result = await self._make_request(
|
| 580 |
+
method="POST",
|
| 581 |
+
url=url,
|
| 582 |
+
json_data=json_data,
|
| 583 |
+
use_at=True,
|
| 584 |
+
at_token=at
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
return result
|
| 588 |
+
|
| 589 |
+
# ========== 任务轮询 (使用AT) ==========
|
| 590 |
+
|
| 591 |
+
async def check_video_status(self, at: str, operations: List[Dict]) -> dict:
|
| 592 |
+
"""查询视频生成状态
|
| 593 |
+
|
| 594 |
+
Args:
|
| 595 |
+
at: Access Token
|
| 596 |
+
operations: 操作列表 [{"operation": {"name": "task_id"}, "sceneId": "...", "status": "..."}]
|
| 597 |
+
|
| 598 |
+
Returns:
|
| 599 |
+
{
|
| 600 |
+
"operations": [{
|
| 601 |
+
"operation": {
|
| 602 |
+
"name": "task_id",
|
| 603 |
+
"metadata": {...} # 完成时包含视频信息
|
| 604 |
+
},
|
| 605 |
+
"status": "MEDIA_GENERATION_STATUS_SUCCESSFUL"
|
| 606 |
+
}]
|
| 607 |
+
}
|
| 608 |
+
"""
|
| 609 |
+
url = f"{self.api_base_url}/video:batchCheckAsyncVideoGenerationStatus"
|
| 610 |
+
|
| 611 |
+
json_data = {
|
| 612 |
+
"operations": operations
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
result = await self._make_request(
|
| 616 |
+
method="POST",
|
| 617 |
+
url=url,
|
| 618 |
+
json_data=json_data,
|
| 619 |
+
use_at=True,
|
| 620 |
+
at_token=at
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
return result
|
| 624 |
+
|
| 625 |
+
# ========== 媒体删除 (使用ST) ==========
|
| 626 |
+
|
| 627 |
+
async def delete_media(self, st: str, media_names: List[str]):
|
| 628 |
+
"""删除媒体
|
| 629 |
+
|
| 630 |
+
Args:
|
| 631 |
+
st: Session Token
|
| 632 |
+
media_names: 媒体ID列表
|
| 633 |
+
"""
|
| 634 |
+
url = f"{self.labs_base_url}/trpc/media.deleteMedia"
|
| 635 |
+
json_data = {
|
| 636 |
+
"json": {
|
| 637 |
+
"names": media_names
|
| 638 |
+
}
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
await self._make_request(
|
| 642 |
+
method="POST",
|
| 643 |
+
url=url,
|
| 644 |
+
json_data=json_data,
|
| 645 |
+
use_st=True,
|
| 646 |
+
st_token=st
|
| 647 |
+
)
|
| 648 |
+
|
| 649 |
+
# ========== 辅助方法 ==========
|
| 650 |
+
|
| 651 |
+
def _generate_session_id(self) -> str:
|
| 652 |
+
"""生成sessionId: ;timestamp"""
|
| 653 |
+
return f";{int(time.time() * 1000)}"
|
| 654 |
+
|
| 655 |
+
def _generate_scene_id(self) -> str:
|
| 656 |
+
"""生成sceneId: UUID"""
|
| 657 |
+
return str(uuid.uuid4())
|
src/services/generation_handler.py
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generation handler for Flow2API"""
|
| 2 |
+
import asyncio
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from typing import Optional, AsyncGenerator, List, Dict, Any
|
| 7 |
+
from ..core.logger import debug_logger
|
| 8 |
+
from ..core.config import config
|
| 9 |
+
from ..core.models import Task, RequestLog
|
| 10 |
+
from .file_cache import FileCache
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Model configuration
|
| 14 |
+
MODEL_CONFIG = {
|
| 15 |
+
# 图片生成 - GEM_PIX (Gemini 2.5 Flash)
|
| 16 |
+
"gemini-2.5-flash-image-landscape": {
|
| 17 |
+
"type": "image",
|
| 18 |
+
"model_name": "GEM_PIX",
|
| 19 |
+
"aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
|
| 20 |
+
},
|
| 21 |
+
"gemini-2.5-flash-image-portrait": {
|
| 22 |
+
"type": "image",
|
| 23 |
+
"model_name": "GEM_PIX",
|
| 24 |
+
"aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
|
| 25 |
+
},
|
| 26 |
+
|
| 27 |
+
# 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro)
|
| 28 |
+
"gemini-3.0-pro-image-landscape": {
|
| 29 |
+
"type": "image",
|
| 30 |
+
"model_name": "GEM_PIX_2",
|
| 31 |
+
"aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
|
| 32 |
+
},
|
| 33 |
+
"gemini-3.0-pro-image-portrait": {
|
| 34 |
+
"type": "image",
|
| 35 |
+
"model_name": "GEM_PIX_2",
|
| 36 |
+
"aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
# 图片生成 - IMAGEN_3_5 (Imagen 4.0)
|
| 40 |
+
"imagen-4.0-generate-preview-landscape": {
|
| 41 |
+
"type": "image",
|
| 42 |
+
"model_name": "IMAGEN_3_5",
|
| 43 |
+
"aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
|
| 44 |
+
},
|
| 45 |
+
"imagen-4.0-generate-preview-portrait": {
|
| 46 |
+
"type": "image",
|
| 47 |
+
"model_name": "IMAGEN_3_5",
|
| 48 |
+
"aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
|
| 49 |
+
},
|
| 50 |
+
|
| 51 |
+
# ========== 文生视频 (T2V - Text to Video) ==========
|
| 52 |
+
# 不支持上传图片,只使用文本提示词生成
|
| 53 |
+
|
| 54 |
+
# veo_3_1_t2v_fast_portrait (竖屏)
|
| 55 |
+
# 上游模型名: veo_3_1_t2v_fast_portrait
|
| 56 |
+
"veo_3_1_t2v_fast_portrait": {
|
| 57 |
+
"type": "video",
|
| 58 |
+
"video_type": "t2v",
|
| 59 |
+
"model_key": "veo_3_1_t2v_fast_portrait",
|
| 60 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
|
| 61 |
+
"supports_images": False
|
| 62 |
+
},
|
| 63 |
+
# veo_3_1_t2v_fast_landscape (横屏)
|
| 64 |
+
# 上游模型名: veo_3_1_t2v_fast
|
| 65 |
+
"veo_3_1_t2v_fast_landscape": {
|
| 66 |
+
"type": "video",
|
| 67 |
+
"video_type": "t2v",
|
| 68 |
+
"model_key": "veo_3_1_t2v_fast",
|
| 69 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
| 70 |
+
"supports_images": False
|
| 71 |
+
},
|
| 72 |
+
|
| 73 |
+
# veo_2_1_fast_d_15_t2v (需要新增横竖屏)
|
| 74 |
+
"veo_2_1_fast_d_15_t2v_portrait": {
|
| 75 |
+
"type": "video",
|
| 76 |
+
"video_type": "t2v",
|
| 77 |
+
"model_key": "veo_2_1_fast_d_15_t2v",
|
| 78 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
|
| 79 |
+
"supports_images": False
|
| 80 |
+
},
|
| 81 |
+
"veo_2_1_fast_d_15_t2v_landscape": {
|
| 82 |
+
"type": "video",
|
| 83 |
+
"video_type": "t2v",
|
| 84 |
+
"model_key": "veo_2_1_fast_d_15_t2v",
|
| 85 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
| 86 |
+
"supports_images": False
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
# veo_2_0_t2v (需要新增横竖屏)
|
| 90 |
+
"veo_2_0_t2v_portrait": {
|
| 91 |
+
"type": "video",
|
| 92 |
+
"video_type": "t2v",
|
| 93 |
+
"model_key": "veo_2_0_t2v",
|
| 94 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
|
| 95 |
+
"supports_images": False
|
| 96 |
+
},
|
| 97 |
+
"veo_2_0_t2v_landscape": {
|
| 98 |
+
"type": "video",
|
| 99 |
+
"video_type": "t2v",
|
| 100 |
+
"model_key": "veo_2_0_t2v",
|
| 101 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
| 102 |
+
"supports_images": False
|
| 103 |
+
},
|
| 104 |
+
|
| 105 |
+
# ========== 首尾帧模型 (I2V - Image to Video) ==========
|
| 106 |
+
# 支持1-2张图片:1张作为首帧,2张作为首尾帧
|
| 107 |
+
|
| 108 |
+
# veo_3_1_i2v_s_fast_fl (需要新增横竖屏)
|
| 109 |
+
"veo_3_1_i2v_s_fast_fl_portrait": {
|
| 110 |
+
"type": "video",
|
| 111 |
+
"video_type": "i2v",
|
| 112 |
+
"model_key": "veo_3_1_i2v_s_fast_fl",
|
| 113 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
|
| 114 |
+
"supports_images": True,
|
| 115 |
+
"min_images": 1,
|
| 116 |
+
"max_images": 2
|
| 117 |
+
},
|
| 118 |
+
"veo_3_1_i2v_s_fast_fl_landscape": {
|
| 119 |
+
"type": "video",
|
| 120 |
+
"video_type": "i2v",
|
| 121 |
+
"model_key": "veo_3_1_i2v_s_fast_fl",
|
| 122 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
| 123 |
+
"supports_images": True,
|
| 124 |
+
"min_images": 1,
|
| 125 |
+
"max_images": 2
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
# veo_2_1_fast_d_15_i2v (需要新增横竖屏)
|
| 129 |
+
"veo_2_1_fast_d_15_i2v_portrait": {
|
| 130 |
+
"type": "video",
|
| 131 |
+
"video_type": "i2v",
|
| 132 |
+
"model_key": "veo_2_1_fast_d_15_i2v",
|
| 133 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
|
| 134 |
+
"supports_images": True,
|
| 135 |
+
"min_images": 1,
|
| 136 |
+
"max_images": 2
|
| 137 |
+
},
|
| 138 |
+
"veo_2_1_fast_d_15_i2v_landscape": {
|
| 139 |
+
"type": "video",
|
| 140 |
+
"video_type": "i2v",
|
| 141 |
+
"model_key": "veo_2_1_fast_d_15_i2v",
|
| 142 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
| 143 |
+
"supports_images": True,
|
| 144 |
+
"min_images": 1,
|
| 145 |
+
"max_images": 2
|
| 146 |
+
},
|
| 147 |
+
|
| 148 |
+
# veo_2_0_i2v (需要新增横竖屏)
|
| 149 |
+
"veo_2_0_i2v_portrait": {
|
| 150 |
+
"type": "video",
|
| 151 |
+
"video_type": "i2v",
|
| 152 |
+
"model_key": "veo_2_0_i2v",
|
| 153 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
|
| 154 |
+
"supports_images": True,
|
| 155 |
+
"min_images": 1,
|
| 156 |
+
"max_images": 2
|
| 157 |
+
},
|
| 158 |
+
"veo_2_0_i2v_landscape": {
|
| 159 |
+
"type": "video",
|
| 160 |
+
"video_type": "i2v",
|
| 161 |
+
"model_key": "veo_2_0_i2v",
|
| 162 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
| 163 |
+
"supports_images": True,
|
| 164 |
+
"min_images": 1,
|
| 165 |
+
"max_images": 2
|
| 166 |
+
},
|
| 167 |
+
|
| 168 |
+
# ========== 多图生成 (R2V - Reference Images to Video) ==========
|
| 169 |
+
# 支持多张图片,不限制数量
|
| 170 |
+
|
| 171 |
+
# veo_3_0_r2v_fast (需要新增横竖屏)
|
| 172 |
+
"veo_3_0_r2v_fast_portrait": {
|
| 173 |
+
"type": "video",
|
| 174 |
+
"video_type": "r2v",
|
| 175 |
+
"model_key": "veo_3_0_r2v_fast",
|
| 176 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
|
| 177 |
+
"supports_images": True,
|
| 178 |
+
"min_images": 0,
|
| 179 |
+
"max_images": None # 不限制
|
| 180 |
+
},
|
| 181 |
+
"veo_3_0_r2v_fast_landscape": {
|
| 182 |
+
"type": "video",
|
| 183 |
+
"video_type": "r2v",
|
| 184 |
+
"model_key": "veo_3_0_r2v_fast",
|
| 185 |
+
"aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
|
| 186 |
+
"supports_images": True,
|
| 187 |
+
"min_images": 0,
|
| 188 |
+
"max_images": None # 不限制
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class GenerationHandler:
|
| 194 |
+
"""统一生成处理器"""
|
| 195 |
+
|
| 196 |
+
def __init__(self, flow_client, token_manager, load_balancer, db, concurrency_manager, proxy_manager):
|
| 197 |
+
self.flow_client = flow_client
|
| 198 |
+
self.token_manager = token_manager
|
| 199 |
+
self.load_balancer = load_balancer
|
| 200 |
+
self.db = db
|
| 201 |
+
self.concurrency_manager = concurrency_manager
|
| 202 |
+
self.file_cache = FileCache(
|
| 203 |
+
cache_dir="tmp",
|
| 204 |
+
default_timeout=config.cache_timeout,
|
| 205 |
+
proxy_manager=proxy_manager
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
async def check_token_availability(self, is_image: bool, is_video: bool) -> bool:
|
| 209 |
+
"""检查Token可用性
|
| 210 |
+
|
| 211 |
+
Args:
|
| 212 |
+
is_image: 是否检查图片生成Token
|
| 213 |
+
is_video: 是否检查视频生成Token
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
True表示有可用Token, False表示无可用Token
|
| 217 |
+
"""
|
| 218 |
+
token_obj = await self.load_balancer.select_token(
|
| 219 |
+
for_image_generation=is_image,
|
| 220 |
+
for_video_generation=is_video
|
| 221 |
+
)
|
| 222 |
+
return token_obj is not None
|
| 223 |
+
|
| 224 |
+
async def handle_generation(
|
| 225 |
+
self,
|
| 226 |
+
model: str,
|
| 227 |
+
prompt: str,
|
| 228 |
+
images: Optional[List[bytes]] = None,
|
| 229 |
+
stream: bool = False
|
| 230 |
+
) -> AsyncGenerator:
|
| 231 |
+
"""统一生成入口
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
model: 模型名称
|
| 235 |
+
prompt: 提示词
|
| 236 |
+
images: 图片列表 (bytes格式)
|
| 237 |
+
stream: 是否流式输出
|
| 238 |
+
"""
|
| 239 |
+
start_time = time.time()
|
| 240 |
+
token = None
|
| 241 |
+
|
| 242 |
+
# 1. 验证模型
|
| 243 |
+
if model not in MODEL_CONFIG:
|
| 244 |
+
error_msg = f"不支持的模型: {model}"
|
| 245 |
+
debug_logger.log_error(error_msg)
|
| 246 |
+
yield self._create_error_response(error_msg)
|
| 247 |
+
return
|
| 248 |
+
|
| 249 |
+
model_config = MODEL_CONFIG[model]
|
| 250 |
+
generation_type = model_config["type"]
|
| 251 |
+
debug_logger.log_info(f"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...")
|
| 252 |
+
|
| 253 |
+
# 非流式模式: 只检查可用性
|
| 254 |
+
if not stream:
|
| 255 |
+
is_image = (generation_type == "image")
|
| 256 |
+
is_video = (generation_type == "video")
|
| 257 |
+
available = await self.check_token_availability(is_image, is_video)
|
| 258 |
+
|
| 259 |
+
if available:
|
| 260 |
+
if is_image:
|
| 261 |
+
message = "所有Token可用于图片生成。请启用流式模式使用生成功能。"
|
| 262 |
+
else:
|
| 263 |
+
message = "所有Token可用于视频生成。请启用流式模式使用生成功能。"
|
| 264 |
+
else:
|
| 265 |
+
if is_image:
|
| 266 |
+
message = "没有可用的Token进行图片生成"
|
| 267 |
+
else:
|
| 268 |
+
message = "没有可用的Token进行视频生成"
|
| 269 |
+
|
| 270 |
+
yield self._create_completion_response(message, is_availability_check=True)
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
+
# 向用户展示开始信息
|
| 274 |
+
if stream:
|
| 275 |
+
yield self._create_stream_chunk(
|
| 276 |
+
f"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\n",
|
| 277 |
+
role="assistant"
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
# 2. 选择Token
|
| 281 |
+
debug_logger.log_info(f"[GENERATION] 正在选择可用Token...")
|
| 282 |
+
|
| 283 |
+
if generation_type == "image":
|
| 284 |
+
token = await self.load_balancer.select_token(for_image_generation=True)
|
| 285 |
+
else:
|
| 286 |
+
token = await self.load_balancer.select_token(for_video_generation=True)
|
| 287 |
+
|
| 288 |
+
if not token:
|
| 289 |
+
error_msg = self._get_no_token_error_message(generation_type)
|
| 290 |
+
debug_logger.log_error(f"[GENERATION] {error_msg}")
|
| 291 |
+
if stream:
|
| 292 |
+
yield self._create_stream_chunk(f"❌ {error_msg}\n")
|
| 293 |
+
yield self._create_error_response(error_msg)
|
| 294 |
+
return
|
| 295 |
+
|
| 296 |
+
debug_logger.log_info(f"[GENERATION] 已选择Token: {token.id} ({token.email})")
|
| 297 |
+
|
| 298 |
+
try:
|
| 299 |
+
# 3. 确保AT有效
|
| 300 |
+
debug_logger.log_info(f"[GENERATION] 检查Token AT有效性...")
|
| 301 |
+
if stream:
|
| 302 |
+
yield self._create_stream_chunk("初始化生成环境...\n")
|
| 303 |
+
|
| 304 |
+
if not await self.token_manager.is_at_valid(token.id):
|
| 305 |
+
error_msg = "Token AT无效或刷新失败"
|
| 306 |
+
debug_logger.log_error(f"[GENERATION] {error_msg}")
|
| 307 |
+
if stream:
|
| 308 |
+
yield self._create_stream_chunk(f"❌ {error_msg}\n")
|
| 309 |
+
yield self._create_error_response(error_msg)
|
| 310 |
+
return
|
| 311 |
+
|
| 312 |
+
# 重新获取token (AT可能已刷新)
|
| 313 |
+
token = await self.token_manager.get_token(token.id)
|
| 314 |
+
|
| 315 |
+
# 4. 确保Project存在
|
| 316 |
+
debug_logger.log_info(f"[GENERATION] 检查/创建Project...")
|
| 317 |
+
|
| 318 |
+
project_id = await self.token_manager.ensure_project_exists(token.id)
|
| 319 |
+
debug_logger.log_info(f"[GENERATION] Project ID: {project_id}")
|
| 320 |
+
|
| 321 |
+
# 5. 根据类型处理
|
| 322 |
+
if generation_type == "image":
|
| 323 |
+
debug_logger.log_info(f"[GENERATION] 开始图片生成流程...")
|
| 324 |
+
async for chunk in self._handle_image_generation(
|
| 325 |
+
token, project_id, model_config, prompt, images, stream
|
| 326 |
+
):
|
| 327 |
+
yield chunk
|
| 328 |
+
else: # video
|
| 329 |
+
debug_logger.log_info(f"[GENERATION] 开始视频生成流程...")
|
| 330 |
+
async for chunk in self._handle_video_generation(
|
| 331 |
+
token, project_id, model_config, prompt, images, stream
|
| 332 |
+
):
|
| 333 |
+
yield chunk
|
| 334 |
+
|
| 335 |
+
# 6. 记录使用
|
| 336 |
+
is_video = (generation_type == "video")
|
| 337 |
+
await self.token_manager.record_usage(token.id, is_video=is_video)
|
| 338 |
+
debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成")
|
| 339 |
+
|
| 340 |
+
# 7. 记录成功日志
|
| 341 |
+
duration = time.time() - start_time
|
| 342 |
+
await self._log_request(
|
| 343 |
+
token.id,
|
| 344 |
+
f"generate_{generation_type}",
|
| 345 |
+
{"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0},
|
| 346 |
+
{"status": "success"},
|
| 347 |
+
200,
|
| 348 |
+
duration
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
except Exception as e:
|
| 352 |
+
error_msg = f"生成失败: {str(e)}"
|
| 353 |
+
debug_logger.log_error(f"[GENERATION] ❌ {error_msg}")
|
| 354 |
+
if stream:
|
| 355 |
+
yield self._create_stream_chunk(f"❌ {error_msg}\n")
|
| 356 |
+
if token:
|
| 357 |
+
await self.token_manager.record_error(token.id)
|
| 358 |
+
yield self._create_error_response(error_msg)
|
| 359 |
+
|
| 360 |
+
# 记录失败日志
|
| 361 |
+
duration = time.time() - start_time
|
| 362 |
+
await self._log_request(
|
| 363 |
+
token.id if token else None,
|
| 364 |
+
f"generate_{generation_type if model_config else 'unknown'}",
|
| 365 |
+
{"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0},
|
| 366 |
+
{"error": error_msg},
|
| 367 |
+
500,
|
| 368 |
+
duration
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
def _get_no_token_error_message(self, generation_type: str) -> str:
|
| 372 |
+
"""获取无可用Token时的详细错误信息"""
|
| 373 |
+
if generation_type == "image":
|
| 374 |
+
return "没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。"
|
| 375 |
+
else:
|
| 376 |
+
return "没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。"
|
| 377 |
+
|
| 378 |
+
async def _handle_image_generation(
|
| 379 |
+
self,
|
| 380 |
+
token,
|
| 381 |
+
project_id: str,
|
| 382 |
+
model_config: dict,
|
| 383 |
+
prompt: str,
|
| 384 |
+
images: Optional[List[bytes]],
|
| 385 |
+
stream: bool
|
| 386 |
+
) -> AsyncGenerator:
|
| 387 |
+
"""处理图片生成 (同步返回)"""
|
| 388 |
+
|
| 389 |
+
# 获取并发槽位
|
| 390 |
+
if self.concurrency_manager:
|
| 391 |
+
if not await self.concurrency_manager.acquire_image(token.id):
|
| 392 |
+
yield self._create_error_response("图片并发限制已达上限")
|
| 393 |
+
return
|
| 394 |
+
|
| 395 |
+
try:
|
| 396 |
+
# 上传图片 (如果有)
|
| 397 |
+
image_inputs = []
|
| 398 |
+
if images and len(images) > 0:
|
| 399 |
+
if stream:
|
| 400 |
+
yield self._create_stream_chunk("上传参考图片...\n")
|
| 401 |
+
|
| 402 |
+
image_bytes = images[0] # 图生图只需要一张
|
| 403 |
+
media_id = await self.flow_client.upload_image(
|
| 404 |
+
token.at,
|
| 405 |
+
image_bytes,
|
| 406 |
+
model_config["aspect_ratio"]
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
image_inputs = [{
|
| 410 |
+
"name": media_id,
|
| 411 |
+
"imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"
|
| 412 |
+
}]
|
| 413 |
+
|
| 414 |
+
# 调用生成API
|
| 415 |
+
if stream:
|
| 416 |
+
yield self._create_stream_chunk("正在生成图片...\n")
|
| 417 |
+
|
| 418 |
+
result = await self.flow_client.generate_image(
|
| 419 |
+
at=token.at,
|
| 420 |
+
project_id=project_id,
|
| 421 |
+
prompt=prompt,
|
| 422 |
+
model_name=model_config["model_name"],
|
| 423 |
+
aspect_ratio=model_config["aspect_ratio"],
|
| 424 |
+
image_inputs=image_inputs
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
# 提取URL
|
| 428 |
+
media = result.get("media", [])
|
| 429 |
+
if not media:
|
| 430 |
+
yield self._create_error_response("生成结果为空")
|
| 431 |
+
return
|
| 432 |
+
|
| 433 |
+
image_url = media[0]["image"]["generatedImage"]["fifeUrl"]
|
| 434 |
+
|
| 435 |
+
# 缓存图片 (如果启用)
|
| 436 |
+
local_url = image_url
|
| 437 |
+
if config.cache_enabled:
|
| 438 |
+
try:
|
| 439 |
+
if stream:
|
| 440 |
+
yield self._create_stream_chunk("缓存图片中...\n")
|
| 441 |
+
cached_filename = await self.file_cache.download_and_cache(image_url, "image")
|
| 442 |
+
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
|
| 443 |
+
except Exception as e:
|
| 444 |
+
debug_logger.log_error(f"Failed to cache image: {str(e)}")
|
| 445 |
+
# 缓存失败不影响结果返回,使用原始URL
|
| 446 |
+
local_url = image_url
|
| 447 |
+
|
| 448 |
+
# 返回结果
|
| 449 |
+
if stream:
|
| 450 |
+
yield self._create_stream_chunk(
|
| 451 |
+
f"<img src='{local_url}' style='max-width:100%' />",
|
| 452 |
+
finish_reason="stop"
|
| 453 |
+
)
|
| 454 |
+
else:
|
| 455 |
+
yield self._create_completion_response(
|
| 456 |
+
local_url, # 直接传URL,让方法内部格式化
|
| 457 |
+
media_type="image"
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
finally:
|
| 461 |
+
# 释放并发槽位
|
| 462 |
+
if self.concurrency_manager:
|
| 463 |
+
await self.concurrency_manager.release_image(token.id)
|
| 464 |
+
|
| 465 |
+
async def _handle_video_generation(
|
| 466 |
+
self,
|
| 467 |
+
token,
|
| 468 |
+
project_id: str,
|
| 469 |
+
model_config: dict,
|
| 470 |
+
prompt: str,
|
| 471 |
+
images: Optional[List[bytes]],
|
| 472 |
+
stream: bool
|
| 473 |
+
) -> AsyncGenerator:
|
| 474 |
+
"""处理视频生成 (异步轮询)"""
|
| 475 |
+
|
| 476 |
+
# 获取并发槽位
|
| 477 |
+
if self.concurrency_manager:
|
| 478 |
+
if not await self.concurrency_manager.acquire_video(token.id):
|
| 479 |
+
yield self._create_error_response("视频并发限制已达上限")
|
| 480 |
+
return
|
| 481 |
+
|
| 482 |
+
try:
|
| 483 |
+
# 获取模型类型和配置
|
| 484 |
+
video_type = model_config.get("video_type")
|
| 485 |
+
supports_images = model_config.get("supports_images", False)
|
| 486 |
+
min_images = model_config.get("min_images", 0)
|
| 487 |
+
max_images = model_config.get("max_images", 0)
|
| 488 |
+
|
| 489 |
+
# 图片数量
|
| 490 |
+
image_count = len(images) if images else 0
|
| 491 |
+
|
| 492 |
+
# ========== 验证和处理图片 ==========
|
| 493 |
+
|
| 494 |
+
# T2V: 文生视频 - 不支持图片
|
| 495 |
+
if video_type == "t2v":
|
| 496 |
+
if image_count > 0:
|
| 497 |
+
if stream:
|
| 498 |
+
yield self._create_stream_chunk("⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\n")
|
| 499 |
+
debug_logger.log_warning(f"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片")
|
| 500 |
+
images = None # 清空图片
|
| 501 |
+
image_count = 0
|
| 502 |
+
|
| 503 |
+
# I2V: 首尾帧模型 - 需要1-2张图片
|
| 504 |
+
elif video_type == "i2v":
|
| 505 |
+
if image_count < min_images or image_count > max_images:
|
| 506 |
+
error_msg = f"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张"
|
| 507 |
+
if stream:
|
| 508 |
+
yield self._create_stream_chunk(f"{error_msg}\n")
|
| 509 |
+
yield self._create_error_response(error_msg)
|
| 510 |
+
return
|
| 511 |
+
|
| 512 |
+
# R2V: 多图生成 - 支持多张图片,不限制数量
|
| 513 |
+
elif video_type == "r2v":
|
| 514 |
+
# 不再限制最大图片数量
|
| 515 |
+
pass
|
| 516 |
+
|
| 517 |
+
# ========== 上传图片 ==========
|
| 518 |
+
start_media_id = None
|
| 519 |
+
end_media_id = None
|
| 520 |
+
reference_images = []
|
| 521 |
+
|
| 522 |
+
# I2V: 首尾帧处理
|
| 523 |
+
if video_type == "i2v" and images:
|
| 524 |
+
if image_count == 1:
|
| 525 |
+
# 只有1张图: 仅作为首帧
|
| 526 |
+
if stream:
|
| 527 |
+
yield self._create_stream_chunk("上传首帧图片...\n")
|
| 528 |
+
start_media_id = await self.flow_client.upload_image(
|
| 529 |
+
token.at, images[0], model_config["aspect_ratio"]
|
| 530 |
+
)
|
| 531 |
+
debug_logger.log_info(f"[I2V] 仅上传首帧: {start_media_id}")
|
| 532 |
+
|
| 533 |
+
elif image_count == 2:
|
| 534 |
+
# 2张图: 首帧+尾帧
|
| 535 |
+
if stream:
|
| 536 |
+
yield self._create_stream_chunk("上传首帧和尾帧图片...\n")
|
| 537 |
+
start_media_id = await self.flow_client.upload_image(
|
| 538 |
+
token.at, images[0], model_config["aspect_ratio"]
|
| 539 |
+
)
|
| 540 |
+
end_media_id = await self.flow_client.upload_image(
|
| 541 |
+
token.at, images[1], model_config["aspect_ratio"]
|
| 542 |
+
)
|
| 543 |
+
debug_logger.log_info(f"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}")
|
| 544 |
+
|
| 545 |
+
# R2V: 多图处理
|
| 546 |
+
elif video_type == "r2v" and images:
|
| 547 |
+
if stream:
|
| 548 |
+
yield self._create_stream_chunk(f"上传 {image_count} 张参考图片...\n")
|
| 549 |
+
|
| 550 |
+
for idx, img in enumerate(images): # 上传所有图片,不限制数量
|
| 551 |
+
media_id = await self.flow_client.upload_image(
|
| 552 |
+
token.at, img, model_config["aspect_ratio"]
|
| 553 |
+
)
|
| 554 |
+
reference_images.append({
|
| 555 |
+
"imageUsageType": "IMAGE_USAGE_TYPE_ASSET",
|
| 556 |
+
"mediaId": media_id
|
| 557 |
+
})
|
| 558 |
+
debug_logger.log_info(f"[R2V] 上传了 {len(reference_images)} 张参考图片")
|
| 559 |
+
|
| 560 |
+
# ========== 调用生成API ==========
|
| 561 |
+
if stream:
|
| 562 |
+
yield self._create_stream_chunk("提交视频生成任务...\n")
|
| 563 |
+
|
| 564 |
+
# I2V: 首尾帧生成
|
| 565 |
+
if video_type == "i2v" and start_media_id:
|
| 566 |
+
if end_media_id:
|
| 567 |
+
# 有首尾帧
|
| 568 |
+
result = await self.flow_client.generate_video_start_end(
|
| 569 |
+
at=token.at,
|
| 570 |
+
project_id=project_id,
|
| 571 |
+
prompt=prompt,
|
| 572 |
+
model_key=model_config["model_key"],
|
| 573 |
+
aspect_ratio=model_config["aspect_ratio"],
|
| 574 |
+
start_media_id=start_media_id,
|
| 575 |
+
end_media_id=end_media_id,
|
| 576 |
+
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
|
| 577 |
+
)
|
| 578 |
+
else:
|
| 579 |
+
# 只有首帧
|
| 580 |
+
result = await self.flow_client.generate_video_start_image(
|
| 581 |
+
at=token.at,
|
| 582 |
+
project_id=project_id,
|
| 583 |
+
prompt=prompt,
|
| 584 |
+
model_key=model_config["model_key"],
|
| 585 |
+
aspect_ratio=model_config["aspect_ratio"],
|
| 586 |
+
start_media_id=start_media_id,
|
| 587 |
+
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
# R2V: 多图生成
|
| 591 |
+
elif video_type == "r2v" and reference_images:
|
| 592 |
+
result = await self.flow_client.generate_video_reference_images(
|
| 593 |
+
at=token.at,
|
| 594 |
+
project_id=project_id,
|
| 595 |
+
prompt=prompt,
|
| 596 |
+
model_key=model_config["model_key"],
|
| 597 |
+
aspect_ratio=model_config["aspect_ratio"],
|
| 598 |
+
reference_images=reference_images,
|
| 599 |
+
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
# T2V 或 R2V无图: 纯文本生成
|
| 603 |
+
else:
|
| 604 |
+
result = await self.flow_client.generate_video_text(
|
| 605 |
+
at=token.at,
|
| 606 |
+
project_id=project_id,
|
| 607 |
+
prompt=prompt,
|
| 608 |
+
model_key=model_config["model_key"],
|
| 609 |
+
aspect_ratio=model_config["aspect_ratio"],
|
| 610 |
+
user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
|
| 611 |
+
)
|
| 612 |
+
|
| 613 |
+
# 获取task_id和operations
|
| 614 |
+
operations = result.get("operations", [])
|
| 615 |
+
if not operations:
|
| 616 |
+
yield self._create_error_response("生成任务创建失败")
|
| 617 |
+
return
|
| 618 |
+
|
| 619 |
+
operation = operations[0]
|
| 620 |
+
task_id = operation["operation"]["name"]
|
| 621 |
+
scene_id = operation.get("sceneId")
|
| 622 |
+
|
| 623 |
+
# 保存Task到数据库
|
| 624 |
+
task = Task(
|
| 625 |
+
task_id=task_id,
|
| 626 |
+
token_id=token.id,
|
| 627 |
+
model=model_config["model_key"],
|
| 628 |
+
prompt=prompt,
|
| 629 |
+
status="processing",
|
| 630 |
+
scene_id=scene_id
|
| 631 |
+
)
|
| 632 |
+
await self.db.create_task(task)
|
| 633 |
+
|
| 634 |
+
# 轮询结果
|
| 635 |
+
if stream:
|
| 636 |
+
yield self._create_stream_chunk(f"视频生成中...\n")
|
| 637 |
+
|
| 638 |
+
async for chunk in self._poll_video_result(token, operations, stream):
|
| 639 |
+
yield chunk
|
| 640 |
+
|
| 641 |
+
finally:
|
| 642 |
+
# 释放并发槽位
|
| 643 |
+
if self.concurrency_manager:
|
| 644 |
+
await self.concurrency_manager.release_video(token.id)
|
| 645 |
+
|
| 646 |
+
async def _poll_video_result(
|
| 647 |
+
self,
|
| 648 |
+
token,
|
| 649 |
+
operations: List[Dict],
|
| 650 |
+
stream: bool
|
| 651 |
+
) -> AsyncGenerator:
|
| 652 |
+
"""轮询视频生成结果"""
|
| 653 |
+
|
| 654 |
+
max_attempts = config.max_poll_attempts
|
| 655 |
+
poll_interval = config.poll_interval
|
| 656 |
+
|
| 657 |
+
for attempt in range(max_attempts):
|
| 658 |
+
await asyncio.sleep(poll_interval)
|
| 659 |
+
|
| 660 |
+
try:
|
| 661 |
+
result = await self.flow_client.check_video_status(token.at, operations)
|
| 662 |
+
checked_operations = result.get("operations", [])
|
| 663 |
+
|
| 664 |
+
if not checked_operations:
|
| 665 |
+
continue
|
| 666 |
+
|
| 667 |
+
operation = checked_operations[0]
|
| 668 |
+
status = operation.get("status")
|
| 669 |
+
|
| 670 |
+
# 状态更新 - 每20秒报告一次 (poll_interval=3秒, 20秒约7次轮询)
|
| 671 |
+
progress_update_interval = 7 # 每7次轮询 = 21秒
|
| 672 |
+
if stream and attempt % progress_update_interval == 0: # 每20秒报告一次
|
| 673 |
+
progress = min(int((attempt / max_attempts) * 100), 95)
|
| 674 |
+
yield self._create_stream_chunk(f"生成进度: {progress}%\n")
|
| 675 |
+
|
| 676 |
+
# 检查状态
|
| 677 |
+
if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL":
|
| 678 |
+
# 成功
|
| 679 |
+
metadata = operation["operation"].get("metadata", {})
|
| 680 |
+
video_info = metadata.get("video", {})
|
| 681 |
+
video_url = video_info.get("fifeUrl")
|
| 682 |
+
|
| 683 |
+
if not video_url:
|
| 684 |
+
yield self._create_error_response("视频URL为空")
|
| 685 |
+
return
|
| 686 |
+
|
| 687 |
+
# 缓存视频 (如果启用)
|
| 688 |
+
local_url = video_url
|
| 689 |
+
if config.cache_enabled:
|
| 690 |
+
try:
|
| 691 |
+
if stream:
|
| 692 |
+
yield self._create_stream_chunk("缓存视频中...\n")
|
| 693 |
+
cached_filename = await self.file_cache.download_and_cache(video_url, "video")
|
| 694 |
+
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
|
| 695 |
+
except Exception as e:
|
| 696 |
+
debug_logger.log_error(f"Failed to cache video: {str(e)}")
|
| 697 |
+
# 缓存失败不影响结果返回,使用原始URL
|
| 698 |
+
local_url = video_url
|
| 699 |
+
|
| 700 |
+
# 更新数据库
|
| 701 |
+
task_id = operation["operation"]["name"]
|
| 702 |
+
await self.db.update_task(
|
| 703 |
+
task_id,
|
| 704 |
+
status="completed",
|
| 705 |
+
progress=100,
|
| 706 |
+
result_urls=[local_url],
|
| 707 |
+
completed_at=time.time()
|
| 708 |
+
)
|
| 709 |
+
|
| 710 |
+
# 返回结果
|
| 711 |
+
if stream:
|
| 712 |
+
yield self._create_stream_chunk(
|
| 713 |
+
f"<video src='{local_url}' controls style='max-width:100%'></video>",
|
| 714 |
+
finish_reason="stop"
|
| 715 |
+
)
|
| 716 |
+
else:
|
| 717 |
+
yield self._create_completion_response(
|
| 718 |
+
local_url, # 直接传URL,让方法内部格式化
|
| 719 |
+
media_type="video"
|
| 720 |
+
)
|
| 721 |
+
return
|
| 722 |
+
|
| 723 |
+
elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"):
|
| 724 |
+
# 失败
|
| 725 |
+
yield self._create_error_response(f"视频生成失败: {status}")
|
| 726 |
+
return
|
| 727 |
+
|
| 728 |
+
except Exception as e:
|
| 729 |
+
debug_logger.log_error(f"Poll error: {str(e)}")
|
| 730 |
+
continue
|
| 731 |
+
|
| 732 |
+
# 超时
|
| 733 |
+
yield self._create_error_response(f"视频生成超时 (已轮询{max_attempts}次)")
|
| 734 |
+
|
| 735 |
+
# ========== 响应格式化 ==========
|
| 736 |
+
|
| 737 |
+
def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str:
|
| 738 |
+
"""创建流式响应chunk"""
|
| 739 |
+
import json
|
| 740 |
+
import time
|
| 741 |
+
|
| 742 |
+
chunk = {
|
| 743 |
+
"id": f"chatcmpl-{int(time.time())}",
|
| 744 |
+
"object": "chat.completion.chunk",
|
| 745 |
+
"created": int(time.time()),
|
| 746 |
+
"model": "flow2api",
|
| 747 |
+
"choices": [{
|
| 748 |
+
"index": 0,
|
| 749 |
+
"delta": {},
|
| 750 |
+
"finish_reason": finish_reason
|
| 751 |
+
}]
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
if role:
|
| 755 |
+
chunk["choices"][0]["delta"]["role"] = role
|
| 756 |
+
|
| 757 |
+
if finish_reason:
|
| 758 |
+
chunk["choices"][0]["delta"]["content"] = content
|
| 759 |
+
else:
|
| 760 |
+
chunk["choices"][0]["delta"]["reasoning_content"] = content
|
| 761 |
+
|
| 762 |
+
return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
| 763 |
+
|
| 764 |
+
def _create_completion_response(self, content: str, media_type: str = "image", is_availability_check: bool = False) -> str:
|
| 765 |
+
"""创建非流式响应
|
| 766 |
+
|
| 767 |
+
Args:
|
| 768 |
+
content: 媒体URL或纯文本消息
|
| 769 |
+
media_type: 媒体类型 ("image" 或 "video")
|
| 770 |
+
is_availability_check: 是否为可用性检查响应 (纯文本消息)
|
| 771 |
+
|
| 772 |
+
Returns:
|
| 773 |
+
JSON格式的响应
|
| 774 |
+
"""
|
| 775 |
+
import json
|
| 776 |
+
import time
|
| 777 |
+
|
| 778 |
+
# 可用性检查: 返回纯文本消息
|
| 779 |
+
if is_availability_check:
|
| 780 |
+
formatted_content = content
|
| 781 |
+
else:
|
| 782 |
+
# 媒体生成: 根据媒体类型格式化内容为Markdown
|
| 783 |
+
if media_type == "video":
|
| 784 |
+
formatted_content = f"```html\n<video src='{content}' controls></video>\n```"
|
| 785 |
+
else: # image
|
| 786 |
+
formatted_content = f""
|
| 787 |
+
|
| 788 |
+
response = {
|
| 789 |
+
"id": f"chatcmpl-{int(time.time())}",
|
| 790 |
+
"object": "chat.completion",
|
| 791 |
+
"created": int(time.time()),
|
| 792 |
+
"model": "flow2api",
|
| 793 |
+
"choices": [{
|
| 794 |
+
"index": 0,
|
| 795 |
+
"message": {
|
| 796 |
+
"role": "assistant",
|
| 797 |
+
"content": formatted_content
|
| 798 |
+
},
|
| 799 |
+
"finish_reason": "stop"
|
| 800 |
+
}]
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
return json.dumps(response, ensure_ascii=False)
|
| 804 |
+
|
| 805 |
+
def _create_error_response(self, error_message: str) -> str:
|
| 806 |
+
"""创建错误响应"""
|
| 807 |
+
import json
|
| 808 |
+
|
| 809 |
+
error = {
|
| 810 |
+
"error": {
|
| 811 |
+
"message": error_message,
|
| 812 |
+
"type": "invalid_request_error",
|
| 813 |
+
"code": "generation_failed"
|
| 814 |
+
}
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
return json.dumps(error, ensure_ascii=False)
|
| 818 |
+
|
| 819 |
+
def _get_base_url(self) -> str:
|
| 820 |
+
"""获取基础URL用于缓存文件访问"""
|
| 821 |
+
# 优先使用配置的cache_base_url
|
| 822 |
+
if config.cache_base_url:
|
| 823 |
+
return config.cache_base_url
|
| 824 |
+
# 否则使用服务器地址
|
| 825 |
+
return f"http://{config.server_host}:{config.server_port}"
|
| 826 |
+
|
| 827 |
+
async def _log_request(
|
| 828 |
+
self,
|
| 829 |
+
token_id: Optional[int],
|
| 830 |
+
operation: str,
|
| 831 |
+
request_data: Dict[str, Any],
|
| 832 |
+
response_data: Dict[str, Any],
|
| 833 |
+
status_code: int,
|
| 834 |
+
duration: float
|
| 835 |
+
):
|
| 836 |
+
"""记录请求到数据库"""
|
| 837 |
+
try:
|
| 838 |
+
log = RequestLog(
|
| 839 |
+
token_id=token_id,
|
| 840 |
+
operation=operation,
|
| 841 |
+
request_body=json.dumps(request_data, ensure_ascii=False),
|
| 842 |
+
response_body=json.dumps(response_data, ensure_ascii=False),
|
| 843 |
+
status_code=status_code,
|
| 844 |
+
duration=duration
|
| 845 |
+
)
|
| 846 |
+
await self.db.add_request_log(log)
|
| 847 |
+
except Exception as e:
|
| 848 |
+
# 日志记录失败不影响主流程
|
| 849 |
+
debug_logger.log_error(f"Failed to log request: {e}")
|
| 850 |
+
|
src/services/load_balancer.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load balancing module for Flow2API"""
|
| 2 |
+
import random
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from ..core.models import Token
|
| 5 |
+
from .concurrency_manager import ConcurrencyManager
|
| 6 |
+
from ..core.logger import debug_logger
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class LoadBalancer:
|
| 10 |
+
"""Token load balancer with random selection"""
|
| 11 |
+
|
| 12 |
+
def __init__(self, token_manager, concurrency_manager: Optional[ConcurrencyManager] = None):
|
| 13 |
+
self.token_manager = token_manager
|
| 14 |
+
self.concurrency_manager = concurrency_manager
|
| 15 |
+
|
| 16 |
+
async def select_token(
|
| 17 |
+
self,
|
| 18 |
+
for_image_generation: bool = False,
|
| 19 |
+
for_video_generation: bool = False
|
| 20 |
+
) -> Optional[Token]:
|
| 21 |
+
"""
|
| 22 |
+
Select a token using random load balancing
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
for_image_generation: If True, only select tokens with image_enabled=True
|
| 26 |
+
for_video_generation: If True, only select tokens with video_enabled=True
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
Selected token or None if no available tokens
|
| 30 |
+
"""
|
| 31 |
+
debug_logger.log_info(f"[LOAD_BALANCER] 开始选择Token (图片生成={for_image_generation}, 视频生成={for_video_generation})")
|
| 32 |
+
|
| 33 |
+
active_tokens = await self.token_manager.get_active_tokens()
|
| 34 |
+
debug_logger.log_info(f"[LOAD_BALANCER] 获取到 {len(active_tokens)} 个活跃Token")
|
| 35 |
+
|
| 36 |
+
if not active_tokens:
|
| 37 |
+
debug_logger.log_info(f"[LOAD_BALANCER] ❌ 没有活跃的Token")
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
# Filter tokens based on generation type
|
| 41 |
+
available_tokens = []
|
| 42 |
+
filtered_reasons = {} # 记录过滤原因
|
| 43 |
+
|
| 44 |
+
for token in active_tokens:
|
| 45 |
+
# Check if token has valid AT (not expired)
|
| 46 |
+
if not await self.token_manager.is_at_valid(token.id):
|
| 47 |
+
filtered_reasons[token.id] = "AT无效或已过期"
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
# Filter for image generation
|
| 51 |
+
if for_image_generation:
|
| 52 |
+
if not token.image_enabled:
|
| 53 |
+
filtered_reasons[token.id] = "图片生成已禁用"
|
| 54 |
+
continue
|
| 55 |
+
|
| 56 |
+
# Check concurrency limit
|
| 57 |
+
if self.concurrency_manager and not await self.concurrency_manager.can_use_image(token.id):
|
| 58 |
+
filtered_reasons[token.id] = "图片并发已满"
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
# Filter for video generation
|
| 62 |
+
if for_video_generation:
|
| 63 |
+
if not token.video_enabled:
|
| 64 |
+
filtered_reasons[token.id] = "视频生成已禁用"
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
# Check concurrency limit
|
| 68 |
+
if self.concurrency_manager and not await self.concurrency_manager.can_use_video(token.id):
|
| 69 |
+
filtered_reasons[token.id] = "视频并发已满"
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
available_tokens.append(token)
|
| 73 |
+
|
| 74 |
+
# 输出过滤信息
|
| 75 |
+
if filtered_reasons:
|
| 76 |
+
debug_logger.log_info(f"[LOAD_BALANCER] 已过滤Token:")
|
| 77 |
+
for token_id, reason in filtered_reasons.items():
|
| 78 |
+
debug_logger.log_info(f"[LOAD_BALANCER] - Token {token_id}: {reason}")
|
| 79 |
+
|
| 80 |
+
if not available_tokens:
|
| 81 |
+
debug_logger.log_info(f"[LOAD_BALANCER] ❌ 没有可用的Token (图片生成={for_image_generation}, 视频生成={for_video_generation})")
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
# Random selection
|
| 85 |
+
selected = random.choice(available_tokens)
|
| 86 |
+
debug_logger.log_info(f"[LOAD_BALANCER] ✅ 已选择Token {selected.id} ({selected.email}) - 余额: {selected.credits}")
|
| 87 |
+
return selected
|
src/services/proxy_manager.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Proxy management module"""
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from ..core.database import Database
|
| 4 |
+
from ..core.models import ProxyConfig
|
| 5 |
+
|
| 6 |
+
class ProxyManager:
|
| 7 |
+
"""Proxy configuration manager"""
|
| 8 |
+
|
| 9 |
+
def __init__(self, db: Database):
|
| 10 |
+
self.db = db
|
| 11 |
+
|
| 12 |
+
async def get_proxy_url(self) -> Optional[str]:
|
| 13 |
+
"""Get proxy URL if enabled, otherwise return None"""
|
| 14 |
+
config = await self.db.get_proxy_config()
|
| 15 |
+
if config and config.enabled and config.proxy_url:
|
| 16 |
+
return config.proxy_url
|
| 17 |
+
return None
|
| 18 |
+
|
| 19 |
+
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
|
| 20 |
+
"""Update proxy configuration"""
|
| 21 |
+
await self.db.update_proxy_config(enabled, proxy_url)
|
| 22 |
+
|
| 23 |
+
async def get_proxy_config(self) -> ProxyConfig:
|
| 24 |
+
"""Get proxy configuration"""
|
| 25 |
+
return await self.db.get_proxy_config()
|
src/services/token_manager.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token manager for Flow2API with AT auto-refresh"""
|
| 2 |
+
import asyncio
|
| 3 |
+
from datetime import datetime, timedelta, timezone
|
| 4 |
+
from typing import Optional, List
|
| 5 |
+
from ..core.database import Database
|
| 6 |
+
from ..core.models import Token, Project
|
| 7 |
+
from ..core.logger import debug_logger
|
| 8 |
+
from .flow_client import FlowClient
|
| 9 |
+
from .proxy_manager import ProxyManager
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TokenManager:
|
| 13 |
+
"""Token lifecycle manager with AT auto-refresh"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, db: Database, flow_client: FlowClient):
|
| 16 |
+
self.db = db
|
| 17 |
+
self.flow_client = flow_client
|
| 18 |
+
self._lock = asyncio.Lock()
|
| 19 |
+
|
| 20 |
+
# ========== Token CRUD ==========
|
| 21 |
+
|
| 22 |
+
async def get_all_tokens(self) -> List[Token]:
|
| 23 |
+
"""Get all tokens"""
|
| 24 |
+
return await self.db.get_all_tokens()
|
| 25 |
+
|
| 26 |
+
async def get_active_tokens(self) -> List[Token]:
|
| 27 |
+
"""Get all active tokens"""
|
| 28 |
+
return await self.db.get_active_tokens()
|
| 29 |
+
|
| 30 |
+
async def get_token(self, token_id: int) -> Optional[Token]:
|
| 31 |
+
"""Get token by ID"""
|
| 32 |
+
return await self.db.get_token(token_id)
|
| 33 |
+
|
| 34 |
+
async def delete_token(self, token_id: int):
|
| 35 |
+
"""Delete token"""
|
| 36 |
+
await self.db.delete_token(token_id)
|
| 37 |
+
|
| 38 |
+
async def enable_token(self, token_id: int):
|
| 39 |
+
"""Enable a token"""
|
| 40 |
+
await self.db.update_token(token_id, is_active=True)
|
| 41 |
+
|
| 42 |
+
async def disable_token(self, token_id: int):
|
| 43 |
+
"""Disable a token"""
|
| 44 |
+
await self.db.update_token(token_id, is_active=False)
|
| 45 |
+
|
| 46 |
+
# ========== Token添加 (支持Project创建) ==========
|
| 47 |
+
|
| 48 |
+
async def add_token(
|
| 49 |
+
self,
|
| 50 |
+
st: str,
|
| 51 |
+
project_id: Optional[str] = None,
|
| 52 |
+
project_name: Optional[str] = None,
|
| 53 |
+
remark: Optional[str] = None,
|
| 54 |
+
image_enabled: bool = True,
|
| 55 |
+
video_enabled: bool = True,
|
| 56 |
+
image_concurrency: int = -1,
|
| 57 |
+
video_concurrency: int = -1
|
| 58 |
+
) -> Token:
|
| 59 |
+
"""Add a new token
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
st: Session Token (必需)
|
| 63 |
+
project_id: 项目ID (可选,如果提供则直接使用,不创建新项目)
|
| 64 |
+
project_name: 项目名称 (可选,如果不提供则自动生成)
|
| 65 |
+
remark: 备注
|
| 66 |
+
image_enabled: 是否启用图片生成
|
| 67 |
+
video_enabled: 是否启用视频生成
|
| 68 |
+
image_concurrency: 图片并发限制
|
| 69 |
+
video_concurrency: 视频并发限制
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Token object
|
| 73 |
+
"""
|
| 74 |
+
# Step 1: 检查ST是否已存在
|
| 75 |
+
existing_token = await self.db.get_token_by_st(st)
|
| 76 |
+
if existing_token:
|
| 77 |
+
raise ValueError(f"Token 已存在(邮箱: {existing_token.email})")
|
| 78 |
+
|
| 79 |
+
# Step 2: 使用ST转换AT
|
| 80 |
+
debug_logger.log_info(f"[ADD_TOKEN] Converting ST to AT...")
|
| 81 |
+
try:
|
| 82 |
+
result = await self.flow_client.st_to_at(st)
|
| 83 |
+
at = result["access_token"]
|
| 84 |
+
expires = result.get("expires")
|
| 85 |
+
user_info = result.get("user", {})
|
| 86 |
+
email = user_info.get("email", "")
|
| 87 |
+
name = user_info.get("name", email.split("@")[0] if email else "")
|
| 88 |
+
|
| 89 |
+
# 解析过期时间
|
| 90 |
+
at_expires = None
|
| 91 |
+
if expires:
|
| 92 |
+
try:
|
| 93 |
+
at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
|
| 94 |
+
except:
|
| 95 |
+
pass
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
raise ValueError(f"ST转AT失败: {str(e)}")
|
| 99 |
+
|
| 100 |
+
# Step 3: 查询余额
|
| 101 |
+
try:
|
| 102 |
+
credits_result = await self.flow_client.get_credits(at)
|
| 103 |
+
credits = credits_result.get("credits", 0)
|
| 104 |
+
user_paygate_tier = credits_result.get("userPaygateTier")
|
| 105 |
+
except:
|
| 106 |
+
credits = 0
|
| 107 |
+
user_paygate_tier = None
|
| 108 |
+
|
| 109 |
+
# Step 4: 处理Project ID和名称
|
| 110 |
+
if project_id:
|
| 111 |
+
# 用户提供了project_id,直接使用
|
| 112 |
+
debug_logger.log_info(f"[ADD_TOKEN] Using provided project_id: {project_id}")
|
| 113 |
+
if not project_name:
|
| 114 |
+
# 如果没有提供project_name,生成一个
|
| 115 |
+
now = datetime.now()
|
| 116 |
+
project_name = now.strftime("%b %d - %H:%M")
|
| 117 |
+
else:
|
| 118 |
+
# 用户没有提供project_id,需要创建新项目
|
| 119 |
+
if not project_name:
|
| 120 |
+
# 自动生成项目名称
|
| 121 |
+
now = datetime.now()
|
| 122 |
+
project_name = now.strftime("%b %d - %H:%M")
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
project_id = await self.flow_client.create_project(st, project_name)
|
| 126 |
+
debug_logger.log_info(f"[ADD_TOKEN] Created new project: {project_name} (ID: {project_id})")
|
| 127 |
+
except Exception as e:
|
| 128 |
+
raise ValueError(f"创建项目失败: {str(e)}")
|
| 129 |
+
|
| 130 |
+
# Step 5: 创建Token对象
|
| 131 |
+
token = Token(
|
| 132 |
+
st=st,
|
| 133 |
+
at=at,
|
| 134 |
+
at_expires=at_expires,
|
| 135 |
+
email=email,
|
| 136 |
+
name=name,
|
| 137 |
+
remark=remark,
|
| 138 |
+
is_active=True,
|
| 139 |
+
credits=credits,
|
| 140 |
+
user_paygate_tier=user_paygate_tier,
|
| 141 |
+
current_project_id=project_id,
|
| 142 |
+
current_project_name=project_name,
|
| 143 |
+
image_enabled=image_enabled,
|
| 144 |
+
video_enabled=video_enabled,
|
| 145 |
+
image_concurrency=image_concurrency,
|
| 146 |
+
video_concurrency=video_concurrency
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# Step 6: 保存到数据库
|
| 150 |
+
token_id = await self.db.add_token(token)
|
| 151 |
+
token.id = token_id
|
| 152 |
+
|
| 153 |
+
# Step 7: 保存Project到数据库
|
| 154 |
+
project = Project(
|
| 155 |
+
project_id=project_id,
|
| 156 |
+
token_id=token_id,
|
| 157 |
+
project_name=project_name,
|
| 158 |
+
tool_name="PINHOLE"
|
| 159 |
+
)
|
| 160 |
+
await self.db.add_project(project)
|
| 161 |
+
|
| 162 |
+
debug_logger.log_info(f"[ADD_TOKEN] Token added successfully (ID: {token_id}, Email: {email})")
|
| 163 |
+
return token
|
| 164 |
+
|
| 165 |
+
async def update_token(
|
| 166 |
+
self,
|
| 167 |
+
token_id: int,
|
| 168 |
+
st: Optional[str] = None,
|
| 169 |
+
at: Optional[str] = None,
|
| 170 |
+
project_id: Optional[str] = None,
|
| 171 |
+
project_name: Optional[str] = None,
|
| 172 |
+
remark: Optional[str] = None,
|
| 173 |
+
image_enabled: Optional[bool] = None,
|
| 174 |
+
video_enabled: Optional[bool] = None,
|
| 175 |
+
image_concurrency: Optional[int] = None,
|
| 176 |
+
video_concurrency: Optional[int] = None
|
| 177 |
+
):
|
| 178 |
+
"""Update token (支持修改project_id和project_name)"""
|
| 179 |
+
update_fields = {}
|
| 180 |
+
|
| 181 |
+
if st is not None:
|
| 182 |
+
update_fields["st"] = st
|
| 183 |
+
if at is not None:
|
| 184 |
+
update_fields["at"] = at
|
| 185 |
+
if project_id is not None:
|
| 186 |
+
update_fields["current_project_id"] = project_id
|
| 187 |
+
if project_name is not None:
|
| 188 |
+
update_fields["current_project_name"] = project_name
|
| 189 |
+
if remark is not None:
|
| 190 |
+
update_fields["remark"] = remark
|
| 191 |
+
if image_enabled is not None:
|
| 192 |
+
update_fields["image_enabled"] = image_enabled
|
| 193 |
+
if video_enabled is not None:
|
| 194 |
+
update_fields["video_enabled"] = video_enabled
|
| 195 |
+
if image_concurrency is not None:
|
| 196 |
+
update_fields["image_concurrency"] = image_concurrency
|
| 197 |
+
if video_concurrency is not None:
|
| 198 |
+
update_fields["video_concurrency"] = video_concurrency
|
| 199 |
+
|
| 200 |
+
if update_fields:
|
| 201 |
+
await self.db.update_token(token_id, **update_fields)
|
| 202 |
+
|
| 203 |
+
# ========== AT自动刷新逻辑 (核心) ==========
|
| 204 |
+
|
| 205 |
+
async def is_at_valid(self, token_id: int) -> bool:
|
| 206 |
+
"""检查AT是否有效,如果无效或即将过期则自动刷新
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
True if AT is valid or refreshed successfully
|
| 210 |
+
False if AT cannot be refreshed
|
| 211 |
+
"""
|
| 212 |
+
token = await self.db.get_token(token_id)
|
| 213 |
+
if not token:
|
| 214 |
+
return False
|
| 215 |
+
|
| 216 |
+
# 如果AT不存在,需要刷新
|
| 217 |
+
if not token.at:
|
| 218 |
+
debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT不存在,需要刷新")
|
| 219 |
+
return await self._refresh_at(token_id)
|
| 220 |
+
|
| 221 |
+
# 如果没有过期时间,假设需要刷新
|
| 222 |
+
if not token.at_expires:
|
| 223 |
+
debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT过期时间未知,尝试刷新")
|
| 224 |
+
return await self._refresh_at(token_id)
|
| 225 |
+
|
| 226 |
+
# 检查是否即将过期 (提前1小时刷新)
|
| 227 |
+
now = datetime.now(timezone.utc)
|
| 228 |
+
# 确保at_expires也是timezone-aware
|
| 229 |
+
if token.at_expires.tzinfo is None:
|
| 230 |
+
at_expires_aware = token.at_expires.replace(tzinfo=timezone.utc)
|
| 231 |
+
else:
|
| 232 |
+
at_expires_aware = token.at_expires
|
| 233 |
+
|
| 234 |
+
time_until_expiry = at_expires_aware - now
|
| 235 |
+
|
| 236 |
+
if time_until_expiry.total_seconds() < 3600: # 1 hour (3600 seconds)
|
| 237 |
+
debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT即将过期 (剩余 {time_until_expiry.total_seconds():.0f} 秒),需要刷新")
|
| 238 |
+
return await self._refresh_at(token_id)
|
| 239 |
+
|
| 240 |
+
# AT有效
|
| 241 |
+
return True
|
| 242 |
+
|
| 243 |
+
async def _refresh_at(self, token_id: int) -> bool:
|
| 244 |
+
"""内部方法: 刷新AT
|
| 245 |
+
|
| 246 |
+
Returns:
|
| 247 |
+
True if refresh successful, False otherwise
|
| 248 |
+
"""
|
| 249 |
+
async with self._lock:
|
| 250 |
+
token = await self.db.get_token(token_id)
|
| 251 |
+
if not token:
|
| 252 |
+
return False
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: 开始刷新AT...")
|
| 256 |
+
|
| 257 |
+
# 使用ST转AT
|
| 258 |
+
result = await self.flow_client.st_to_at(token.st)
|
| 259 |
+
new_at = result["access_token"]
|
| 260 |
+
expires = result.get("expires")
|
| 261 |
+
|
| 262 |
+
# 解析过期时间
|
| 263 |
+
new_at_expires = None
|
| 264 |
+
if expires:
|
| 265 |
+
try:
|
| 266 |
+
new_at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
|
| 267 |
+
except:
|
| 268 |
+
pass
|
| 269 |
+
|
| 270 |
+
# 更新数据库
|
| 271 |
+
await self.db.update_token(
|
| 272 |
+
token_id,
|
| 273 |
+
at=new_at,
|
| 274 |
+
at_expires=new_at_expires
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: AT刷新成功")
|
| 278 |
+
debug_logger.log_info(f" - 新过期时间: {new_at_expires}")
|
| 279 |
+
|
| 280 |
+
# 同时刷新credits
|
| 281 |
+
try:
|
| 282 |
+
credits_result = await self.flow_client.get_credits(new_at)
|
| 283 |
+
await self.db.update_token(
|
| 284 |
+
token_id,
|
| 285 |
+
credits=credits_result.get("credits", 0)
|
| 286 |
+
)
|
| 287 |
+
except:
|
| 288 |
+
pass
|
| 289 |
+
|
| 290 |
+
return True
|
| 291 |
+
|
| 292 |
+
except Exception as e:
|
| 293 |
+
debug_logger.log_error(f"[AT_REFRESH] Token {token_id}: AT刷新失败 - {str(e)}")
|
| 294 |
+
# 刷新失败,禁用Token
|
| 295 |
+
await self.disable_token(token_id)
|
| 296 |
+
return False
|
| 297 |
+
|
| 298 |
+
async def ensure_project_exists(self, token_id: int) -> str:
|
| 299 |
+
"""确保Token有可用的Project
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
project_id
|
| 303 |
+
"""
|
| 304 |
+
token = await self.db.get_token(token_id)
|
| 305 |
+
if not token:
|
| 306 |
+
raise ValueError("Token not found")
|
| 307 |
+
|
| 308 |
+
# 如果已有project_id,直接返回
|
| 309 |
+
if token.current_project_id:
|
| 310 |
+
return token.current_project_id
|
| 311 |
+
|
| 312 |
+
# 创建新Project
|
| 313 |
+
now = datetime.now()
|
| 314 |
+
project_name = now.strftime("%b %d - %H:%M")
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
project_id = await self.flow_client.create_project(token.st, project_name)
|
| 318 |
+
debug_logger.log_info(f"[PROJECT] Created project for token {token_id}: {project_name}")
|
| 319 |
+
|
| 320 |
+
# 更新Token
|
| 321 |
+
await self.db.update_token(
|
| 322 |
+
token_id,
|
| 323 |
+
current_project_id=project_id,
|
| 324 |
+
current_project_name=project_name
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# 保存Project到数据库
|
| 328 |
+
project = Project(
|
| 329 |
+
project_id=project_id,
|
| 330 |
+
token_id=token_id,
|
| 331 |
+
project_name=project_name
|
| 332 |
+
)
|
| 333 |
+
await self.db.add_project(project)
|
| 334 |
+
|
| 335 |
+
return project_id
|
| 336 |
+
|
| 337 |
+
except Exception as e:
|
| 338 |
+
raise ValueError(f"Failed to create project: {str(e)}")
|
| 339 |
+
|
| 340 |
+
# ========== Token使用统计 ==========
|
| 341 |
+
|
| 342 |
+
async def record_usage(self, token_id: int, is_video: bool = False):
|
| 343 |
+
"""Record token usage"""
|
| 344 |
+
await self.db.update_token(token_id, use_count=1, last_used_at=datetime.now())
|
| 345 |
+
|
| 346 |
+
if is_video:
|
| 347 |
+
await self.db.increment_token_stats(token_id, "video")
|
| 348 |
+
else:
|
| 349 |
+
await self.db.increment_token_stats(token_id, "image")
|
| 350 |
+
|
| 351 |
+
async def record_error(self, token_id: int):
|
| 352 |
+
"""Record token error"""
|
| 353 |
+
await self.db.increment_token_stats(token_id, "error")
|
| 354 |
+
|
| 355 |
+
# ========== 余额刷新 ==========
|
| 356 |
+
|
| 357 |
+
async def refresh_credits(self, token_id: int) -> int:
|
| 358 |
+
"""刷新Token余额
|
| 359 |
+
|
| 360 |
+
Returns:
|
| 361 |
+
credits
|
| 362 |
+
"""
|
| 363 |
+
token = await self.db.get_token(token_id)
|
| 364 |
+
if not token:
|
| 365 |
+
return 0
|
| 366 |
+
|
| 367 |
+
# 确保AT有效
|
| 368 |
+
if not await self.is_at_valid(token_id):
|
| 369 |
+
return 0
|
| 370 |
+
|
| 371 |
+
# 重新获取token (AT可能已刷新)
|
| 372 |
+
token = await self.db.get_token(token_id)
|
| 373 |
+
|
| 374 |
+
try:
|
| 375 |
+
result = await self.flow_client.get_credits(token.at)
|
| 376 |
+
credits = result.get("credits", 0)
|
| 377 |
+
|
| 378 |
+
# 更新数据库
|
| 379 |
+
await self.db.update_token(token_id, credits=credits)
|
| 380 |
+
|
| 381 |
+
return credits
|
| 382 |
+
except Exception as e:
|
| 383 |
+
debug_logger.log_error(f"Failed to refresh credits for token {token_id}: {str(e)}")
|
| 384 |
+
return 0
|
static/login.html
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>登录 - Flow2API</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 10 |
+
.animate-slide-up{animation:slide-up .3s ease-out}
|
| 11 |
+
</style>
|
| 12 |
+
<script>
|
| 13 |
+
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
| 14 |
+
</script>
|
| 15 |
+
</head>
|
| 16 |
+
<body class="h-full bg-background text-foreground antialiased">
|
| 17 |
+
<div class="flex min-h-full flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
|
| 18 |
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 19 |
+
<div class="text-center">
|
| 20 |
+
<h1 class="text-4xl font-bold">Flow2API</h1>
|
| 21 |
+
<p class="mt-2 text-sm text-muted-foreground">管理员控制台</p>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 26 |
+
<div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
|
| 27 |
+
<form id="loginForm" class="space-y-6">
|
| 28 |
+
<div class="space-y-2">
|
| 29 |
+
<label for="username" class="text-sm font-medium">账户</label>
|
| 30 |
+
<input type="text" id="username" name="username" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="请输入账户">
|
| 31 |
+
</div>
|
| 32 |
+
<div class="space-y-2">
|
| 33 |
+
<label for="password" class="text-sm font-medium">密码</label>
|
| 34 |
+
<input type="password" id="password" name="password" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="请输入密码">
|
| 35 |
+
</div>
|
| 36 |
+
<button type="submit" id="loginButton" class="inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-10 w-full disabled:opacity-50">登录</button>
|
| 37 |
+
</form>
|
| 38 |
+
|
| 39 |
+
<div class="mt-6 text-center text-xs text-muted-foreground">
|
| 40 |
+
<p>Flow2API © 2025</p>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<script>
|
| 47 |
+
const form=document.getElementById('loginForm'),btn=document.getElementById('loginButton');
|
| 48 |
+
form.addEventListener('submit',async(e)=>{e.preventDefault();btn.disabled=true;btn.textContent='登录中...';try{const fd=new FormData(form),r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:fd.get('username'),password:fd.get('password')})});const d=await r.json();d.success?(localStorage.setItem('adminToken',d.token),location.href='/manage'):showToast(d.message||'登录失败','error')}catch(e){showToast('网络错误,请稍后重试','error')}finally{btn.disabled=false;btn.textContent='登录'}});
|
| 49 |
+
function showToast(m,t='error'){const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.error} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)}
|
| 50 |
+
window.addEventListener('DOMContentLoaded',()=>{const t=localStorage.getItem('adminToken');t&&fetch('/api/stats',{headers:{Authorization:`Bearer ${t}`}}).then(r=>{if(r.ok)location.href='/manage'})});
|
| 51 |
+
</script>
|
| 52 |
+
</body>
|
| 53 |
+
</html>
|
static/manage.html
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>管理控制台 - Flow2API</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 10 |
+
.animate-slide-up{animation:slide-up .3s ease-out}
|
| 11 |
+
.tab-btn{transition:all .2s ease}
|
| 12 |
+
</style>
|
| 13 |
+
<script>
|
| 14 |
+
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
| 15 |
+
</script>
|
| 16 |
+
</head>
|
| 17 |
+
<body class="h-full bg-background text-foreground antialiased">
|
| 18 |
+
<!-- 导航栏 -->
|
| 19 |
+
<header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur">
|
| 20 |
+
<div class="mx-auto flex h-14 max-w-7xl items-center px-6">
|
| 21 |
+
<div class="mr-4 flex items-baseline gap-3">
|
| 22 |
+
<span class="font-bold text-xl">Flow2API</span>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="flex flex-1 items-center justify-end gap-3">
|
| 25 |
+
<a href="https://github.com/TheSmallHanCat/flow2api" target="_blank" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5" title="GitHub 仓库">
|
| 26 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
| 27 |
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
| 28 |
+
</svg>
|
| 29 |
+
</a>
|
| 30 |
+
<button onclick="logout()" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5 gap-1">
|
| 31 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 32 |
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
| 33 |
+
<polyline points="16 17 21 12 16 7"/>
|
| 34 |
+
<line x1="21" y1="12" x2="9" y2="12"/>
|
| 35 |
+
</svg>
|
| 36 |
+
退出
|
| 37 |
+
</button>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</header>
|
| 41 |
+
|
| 42 |
+
<main class="mx-auto max-w-7xl px-6 py-6">
|
| 43 |
+
<!-- Tab 导航 -->
|
| 44 |
+
<div class="border-b border-border mb-6">
|
| 45 |
+
<nav class="flex space-x-8">
|
| 46 |
+
<button onclick="switchTab('tokens')" id="tabTokens" class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">Token 管理</button>
|
| 47 |
+
<button onclick="switchTab('settings')" id="tabSettings" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">系统配置</button>
|
| 48 |
+
<button onclick="switchTab('logs')" id="tabLogs" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">请求日志</button>
|
| 49 |
+
</nav>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- Token 管理面板 -->
|
| 53 |
+
<div id="panelTokens">
|
| 54 |
+
<!-- 统计卡片 -->
|
| 55 |
+
<div class="grid gap-4 grid-cols-2 md:grid-cols-5 mb-6">
|
| 56 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 57 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">Token 总数</p>
|
| 58 |
+
<h3 class="text-xl font-bold" id="statTotal">-</h3>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 61 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">活跃 Token</p>
|
| 62 |
+
<h3 class="text-xl font-bold text-green-600" id="statActive">-</h3>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 65 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">今日图片/总图片</p>
|
| 66 |
+
<h3 class="text-xl font-bold text-blue-600" id="statImages">-</h3>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 69 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">今日视频/总视频</p>
|
| 70 |
+
<h3 class="text-xl font-bold text-purple-600" id="statVideos">-</h3>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 73 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">今日错误/总错误</p>
|
| 74 |
+
<h3 class="text-xl font-bold text-destructive" id="statErrors">-</h3>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<!-- Token 列表 -->
|
| 79 |
+
<div class="rounded-lg border border-border bg-background">
|
| 80 |
+
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
|
| 81 |
+
<h3 class="text-lg font-semibold">Token 列表</h3>
|
| 82 |
+
<div class="flex items-center gap-3">
|
| 83 |
+
<!-- 自动刷新AT标签和开关 -->
|
| 84 |
+
<div class="flex items-center gap-2">
|
| 85 |
+
<span class="text-xs text-muted-foreground">自动刷新AT</span>
|
| 86 |
+
<div class="relative inline-flex items-center group">
|
| 87 |
+
<label class="inline-flex items-center cursor-pointer">
|
| 88 |
+
<input type="checkbox" id="atAutoRefreshToggle" onchange="toggleATAutoRefresh()" class="sr-only peer">
|
| 89 |
+
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
| 90 |
+
</label>
|
| 91 |
+
<!-- 悬浮提示 -->
|
| 92 |
+
<div class="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
|
| 93 |
+
Token距离过期<1h时自动使用ST刷新AT
|
| 94 |
+
<div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
<button onclick="refreshTokens()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
|
| 99 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 100 |
+
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
| 101 |
+
</svg>
|
| 102 |
+
</button>
|
| 103 |
+
<button onclick="exportTokens()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 h-8 px-3" title="导出所有Token">
|
| 104 |
+
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 105 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 106 |
+
<polyline points="7 10 12 15 17 10"/>
|
| 107 |
+
<line x1="12" y1="15" x2="12" y2="3"/>
|
| 108 |
+
</svg>
|
| 109 |
+
<span class="text-sm font-medium">导出</span>
|
| 110 |
+
</button>
|
| 111 |
+
<button onclick="openImportModal()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 h-8 px-3" title="导入Token">
|
| 112 |
+
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 113 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 114 |
+
<polyline points="17 8 12 3 7 8"/>
|
| 115 |
+
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 116 |
+
</svg>
|
| 117 |
+
<span class="text-sm font-medium">导入</span>
|
| 118 |
+
</button>
|
| 119 |
+
<button onclick="openAddModal()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3">
|
| 120 |
+
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 121 |
+
<line x1="12" y1="5" x2="12" y2="19"/>
|
| 122 |
+
<line x1="5" y1="12" x2="19" y2="12"/>
|
| 123 |
+
</svg>
|
| 124 |
+
<span class="text-sm font-medium">新增</span>
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div class="relative w-full overflow-auto">
|
| 130 |
+
<table class="w-full text-sm">
|
| 131 |
+
<thead>
|
| 132 |
+
<tr class="border-b border-border">
|
| 133 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">邮箱</th>
|
| 134 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态</th>
|
| 135 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">过期时间</th>
|
| 136 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">余额</th>
|
| 137 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目名称</th>
|
| 138 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目ID</th>
|
| 139 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">图片</th>
|
| 140 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">视频</th>
|
| 141 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">错误</th>
|
| 142 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">备注</th>
|
| 143 |
+
<th class="h-10 px-3 text-right align-middle font-medium text-muted-foreground">操作</th>
|
| 144 |
+
</tr>
|
| 145 |
+
</thead>
|
| 146 |
+
<tbody id="tokenTableBody" class="divide-y divide-border">
|
| 147 |
+
<!-- 动态填充 -->
|
| 148 |
+
</tbody>
|
| 149 |
+
</table>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<!-- 系统配置面板 -->
|
| 155 |
+
<div id="panelSettings" class="hidden">
|
| 156 |
+
<div class="grid gap-6 lg:grid-cols-2">
|
| 157 |
+
<!-- 安全配置 -->
|
| 158 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 159 |
+
<h3 class="text-lg font-semibold mb-4">安全配置</h3>
|
| 160 |
+
<div class="space-y-4">
|
| 161 |
+
<div>
|
| 162 |
+
<label class="text-sm font-medium mb-2 block">管理员用户名</label>
|
| 163 |
+
<input id="cfgAdminUsername" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
| 164 |
+
<p class="text-xs text-muted-foreground mt-1">管理员用户名</p>
|
| 165 |
+
</div>
|
| 166 |
+
<div>
|
| 167 |
+
<label class="text-sm font-medium mb-2 block">旧密码</label>
|
| 168 |
+
<input id="cfgOldPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入旧密码">
|
| 169 |
+
</div>
|
| 170 |
+
<div>
|
| 171 |
+
<label class="text-sm font-medium mb-2 block">新密码</label>
|
| 172 |
+
<input id="cfgNewPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新密码">
|
| 173 |
+
</div>
|
| 174 |
+
<button onclick="updateAdminPassword()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">修改密码</button>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
<!-- API 密钥配置 -->
|
| 179 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 180 |
+
<h3 class="text-lg font-semibold mb-4">API 密钥配置</h3>
|
| 181 |
+
<div class="space-y-4">
|
| 182 |
+
<div>
|
| 183 |
+
<label class="text-sm font-medium mb-2 block">当前 API Key</label>
|
| 184 |
+
<input id="cfgCurrentAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" readonly disabled>
|
| 185 |
+
<p class="text-xs text-muted-foreground mt-1">当前使用的 API Key(只读)</p>
|
| 186 |
+
</div>
|
| 187 |
+
<div>
|
| 188 |
+
<label class="text-sm font-medium mb-2 block">新 API Key</label>
|
| 189 |
+
<input id="cfgNewAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新的 API Key">
|
| 190 |
+
<p class="text-xs text-muted-foreground mt-1">用于客户端调用 API 的密钥</p>
|
| 191 |
+
</div>
|
| 192 |
+
<button onclick="updateAPIKey()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">更新 API Key</button>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<!-- 代理配置 -->
|
| 197 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 198 |
+
<h3 class="text-lg font-semibold mb-4">代理配置</h3>
|
| 199 |
+
<div class="space-y-4">
|
| 200 |
+
<div>
|
| 201 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 202 |
+
<input type="checkbox" id="cfgProxyEnabled" class="h-4 w-4 rounded border-input">
|
| 203 |
+
<span class="text-sm font-medium">启用代理</span>
|
| 204 |
+
</label>
|
| 205 |
+
</div>
|
| 206 |
+
<div>
|
| 207 |
+
<label class="text-sm font-medium mb-2 block">代理地址</label>
|
| 208 |
+
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
| 209 |
+
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
|
| 210 |
+
</div>
|
| 211 |
+
<button onclick="saveProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<!-- 错误处理配置 -->
|
| 216 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 217 |
+
<h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
|
| 218 |
+
<div class="space-y-4">
|
| 219 |
+
<div>
|
| 220 |
+
<label class="text-sm font-medium mb-2 block">错误封禁阈值</label>
|
| 221 |
+
<input id="cfgErrorBan" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3">
|
| 222 |
+
<p class="text-xs text-muted-foreground mt-1">Token 连续错误达到此次数后自动禁用</p>
|
| 223 |
+
</div>
|
| 224 |
+
<button onclick="saveAdminConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<!-- 缓存配置 -->
|
| 229 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 230 |
+
<h3 class="text-lg font-semibold mb-4">缓存配置</h3>
|
| 231 |
+
<div class="space-y-4">
|
| 232 |
+
<div>
|
| 233 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 234 |
+
<input type="checkbox" id="cfgCacheEnabled" class="h-4 w-4 rounded border-input" onchange="toggleCacheOptions()">
|
| 235 |
+
<span class="text-sm font-medium">启用缓存</span>
|
| 236 |
+
</label>
|
| 237 |
+
<p class="text-xs text-muted-foreground mt-1">关闭后,生成的图片和视频将直接输出原始链接,不会缓存到本地</p>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<!-- 缓存配置选项 -->
|
| 241 |
+
<div id="cacheOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border">
|
| 242 |
+
<div>
|
| 243 |
+
<label class="text-sm font-medium mb-2 block">缓存超时时间(秒)</label>
|
| 244 |
+
<input id="cfgCacheTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="7200" min="60" max="86400">
|
| 245 |
+
<p class="text-xs text-muted-foreground mt-1">文件缓存超时时间,范围:60-86400 秒(1分钟-24小时)</p>
|
| 246 |
+
</div>
|
| 247 |
+
<div>
|
| 248 |
+
<label class="text-sm font-medium mb-2 block">缓存文件访问域名</label>
|
| 249 |
+
<input id="cfgCacheBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://yourdomain.com">
|
| 250 |
+
<p class="text-xs text-muted-foreground mt-1">留空则使用服务器地址,例如:https://yourdomain.com</p>
|
| 251 |
+
</div>
|
| 252 |
+
<div id="cacheEffectiveUrl" class="rounded-md bg-muted p-3 hidden">
|
| 253 |
+
<p class="text-xs text-muted-foreground">
|
| 254 |
+
<strong>🌐 当前生效的访问地址:</strong><code id="cacheEffectiveUrlValue" class="bg-background px-1 py-0.5 rounded"></code>
|
| 255 |
+
</p>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<button onclick="saveCacheConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<!-- 生成超时配置 -->
|
| 264 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 265 |
+
<h3 class="text-lg font-semibold mb-4">生成超时配置</h3>
|
| 266 |
+
<div class="space-y-4">
|
| 267 |
+
<div>
|
| 268 |
+
<label class="text-sm font-medium mb-2 block">图片生成超时时间(秒)</label>
|
| 269 |
+
<input id="cfgImageTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="300" min="60" max="3600">
|
| 270 |
+
<p class="text-xs text-muted-foreground mt-1">图片生成超时时间,范围:60-3600 秒(1分钟-1小时),超时后自动释放Token锁</p>
|
| 271 |
+
</div>
|
| 272 |
+
<div>
|
| 273 |
+
<label class="text-sm font-medium mb-2 block">视频生成超时时间(秒)</label>
|
| 274 |
+
<input id="cfgVideoTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="1500" min="60" max="7200">
|
| 275 |
+
<p class="text-xs text-muted-foreground mt-1">视频生成超时时间,范围:60-7200 秒(1分钟-2小时),超时后返回上游API超时错误</p>
|
| 276 |
+
</div>
|
| 277 |
+
<button onclick="saveGenerationTimeout()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<!-- 调试配置 -->
|
| 282 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 283 |
+
<h3 class="text-lg font-semibold mb-4">调试配置</h3>
|
| 284 |
+
<div class="space-y-4">
|
| 285 |
+
<div>
|
| 286 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 287 |
+
<input type="checkbox" id="cfgDebugEnabled" class="h-4 w-4 rounded border-input" onchange="toggleDebugMode()">
|
| 288 |
+
<span class="text-sm font-medium">启用调试模式</span>
|
| 289 |
+
</label>
|
| 290 |
+
<p class="text-xs text-muted-foreground mt-2">开启后,详细的上游API请求和响应日志将写入 <code class="bg-muted px-1 py-0.5 rounded">logs.txt</code> 文件,重启生效</p>
|
| 291 |
+
</div>
|
| 292 |
+
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 border border-yellow-200 dark:border-yellow-800">
|
| 293 |
+
<p class="text-xs text-yellow-800 dark:text-yellow-200">
|
| 294 |
+
⚠️ <strong>注意:</strong>调试模式会产生非常非常大量的日志,仅限Debug时候开启,否则磁盘boom
|
| 295 |
+
</p>
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<!-- 请求日志面板 -->
|
| 303 |
+
<div id="panelLogs" class="hidden">
|
| 304 |
+
<div class="rounded-lg border border-border bg-background">
|
| 305 |
+
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
|
| 306 |
+
<h3 class="text-lg font-semibold">请求日志</h3>
|
| 307 |
+
<button onclick="refreshLogs()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
|
| 308 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 309 |
+
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
| 310 |
+
</svg>
|
| 311 |
+
</button>
|
| 312 |
+
</div>
|
| 313 |
+
<div class="relative w-full overflow-auto max-h-[600px]">
|
| 314 |
+
<table class="w-full text-sm">
|
| 315 |
+
<thead class="sticky top-0 bg-background">
|
| 316 |
+
<tr class="border-b border-border">
|
| 317 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">操作</th>
|
| 318 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Token邮箱</th>
|
| 319 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态码</th>
|
| 320 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">耗时(秒)</th>
|
| 321 |
+
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">时间</th>
|
| 322 |
+
</tr>
|
| 323 |
+
</thead>
|
| 324 |
+
<tbody id="logsTableBody" class="divide-y divide-border">
|
| 325 |
+
<!-- 动态填充 -->
|
| 326 |
+
</tbody>
|
| 327 |
+
</table>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<!-- 页脚 -->
|
| 333 |
+
<footer class="mt-12 pt-6 border-t border-border text-center text-xs text-muted-foreground">
|
| 334 |
+
<p>© 2025 <a href="https://linux.do/u/thesmallhancat/summary" target="_blank" class="no-underline hover:underline" style="color: inherit;">TheSmallHanCat</a> && <a href="https://linux.do/u/tibbar/summary" target="_blank" class="no-underline hover:underline" style="color: inherit;">Tibbar</a>. All rights reserved.</p>
|
| 335 |
+
</footer>
|
| 336 |
+
</main>
|
| 337 |
+
|
| 338 |
+
<!-- 添加 Token 模态框 -->
|
| 339 |
+
<div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
|
| 340 |
+
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
|
| 341 |
+
<div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
|
| 342 |
+
<h3 class="text-lg font-semibold">添加 Token</h3>
|
| 343 |
+
<button onclick="closeAddModal()" class="text-muted-foreground hover:text-foreground">
|
| 344 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 345 |
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
| 346 |
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
| 347 |
+
</svg>
|
| 348 |
+
</button>
|
| 349 |
+
</div>
|
| 350 |
+
<div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
| 351 |
+
<!-- Session Token -->
|
| 352 |
+
<div class="space-y-2">
|
| 353 |
+
<label class="text-sm font-medium">Session Token (ST) <span class="text-red-500">*</span></label>
|
| 354 |
+
<textarea id="addTokenST" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Session Token" required></textarea>
|
| 355 |
+
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token,保存时将自动转换为 Access Token</p>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<!-- Remark -->
|
| 359 |
+
<div class="space-y-2">
|
| 360 |
+
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 361 |
+
<input id="addTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
<!-- Project ID -->
|
| 365 |
+
<div class="space-y-2">
|
| 366 |
+
<label class="text-sm font-medium">Project ID <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 367 |
+
<input id="addTokenProjectId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="若不填写则系统自动生成">
|
| 368 |
+
<p class="text-xs text-muted-foreground">如果已有Project ID可直接输入,留空则创建新项目</p>
|
| 369 |
+
</div>
|
| 370 |
+
|
| 371 |
+
<!-- Project Name -->
|
| 372 |
+
<div class="space-y-2">
|
| 373 |
+
<label class="text-sm font-medium">Project Name <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 374 |
+
<input id="addTokenProjectName" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="若不填写则自动生成 (如: Jan 01 - 12:00)">
|
| 375 |
+
</div>
|
| 376 |
+
|
| 377 |
+
<!-- 功能开关 -->
|
| 378 |
+
<div class="space-y-3 pt-2 border-t border-border">
|
| 379 |
+
<label class="text-sm font-medium">功能开关</label>
|
| 380 |
+
<div class="space-y-2">
|
| 381 |
+
<div class="flex items-center gap-3">
|
| 382 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 383 |
+
<input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input">
|
| 384 |
+
<span class="text-sm font-medium">启用图片生成</span>
|
| 385 |
+
</label>
|
| 386 |
+
<input type="number" id="addTokenImageConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
<div class="space-y-2">
|
| 390 |
+
<div class="flex items-center gap-3">
|
| 391 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 392 |
+
<input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input">
|
| 393 |
+
<span class="text-sm font-medium">启用视频生成</span>
|
| 394 |
+
</label>
|
| 395 |
+
<input type="number" id="addTokenVideoConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
</div>
|
| 399 |
+
</div>
|
| 400 |
+
<div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
|
| 401 |
+
<button onclick="closeAddModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
| 402 |
+
<button id="addTokenBtn" onclick="submitAddToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
| 403 |
+
<span id="addTokenBtnText">添加</span>
|
| 404 |
+
<svg id="addTokenBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 405 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 406 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 407 |
+
</svg>
|
| 408 |
+
</button>
|
| 409 |
+
</div>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
<!-- 编辑 Token 模态框 -->
|
| 414 |
+
<div id="editModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
|
| 415 |
+
<div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
|
| 416 |
+
<div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
|
| 417 |
+
<h3 class="text-lg font-semibold">编辑 Token</h3>
|
| 418 |
+
<button onclick="closeEditModal()" class="text-muted-foreground hover:text-foreground">
|
| 419 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 420 |
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
| 421 |
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
| 422 |
+
</svg>
|
| 423 |
+
</button>
|
| 424 |
+
</div>
|
| 425 |
+
<div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
| 426 |
+
<input type="hidden" id="editTokenId">
|
| 427 |
+
|
| 428 |
+
<!-- Session Token -->
|
| 429 |
+
<div class="space-y-2">
|
| 430 |
+
<label class="text-sm font-medium">Session Token (ST) <span class="text-red-500">*</span></label>
|
| 431 |
+
<textarea id="editTokenST" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Session Token" required></textarea>
|
| 432 |
+
<p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token,保存时将自动转换为 Access Token</p>
|
| 433 |
+
</div>
|
| 434 |
+
|
| 435 |
+
<!-- Remark -->
|
| 436 |
+
<div class="space-y-2">
|
| 437 |
+
<label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 438 |
+
<input id="editTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
|
| 439 |
+
</div>
|
| 440 |
+
|
| 441 |
+
<!-- Project ID -->
|
| 442 |
+
<div class="space-y-2">
|
| 443 |
+
<label class="text-sm font-medium">Project ID <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 444 |
+
<input id="editTokenProjectId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="若不填写则保持原有值">
|
| 445 |
+
<p class="text-xs text-muted-foreground">修改Project ID会更新Token使用的项目</p>
|
| 446 |
+
</div>
|
| 447 |
+
|
| 448 |
+
<!-- Project Name -->
|
| 449 |
+
<div class="space-y-2">
|
| 450 |
+
<label class="text-sm font-medium">Project Name <span class="text-muted-foreground text-xs">- 可选</span></label>
|
| 451 |
+
<input id="editTokenProjectName" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="若不填写则保持原有值">
|
| 452 |
+
</div>
|
| 453 |
+
|
| 454 |
+
<!-- 功能开关 -->
|
| 455 |
+
<div class="space-y-3 pt-2 border-t border-border">
|
| 456 |
+
<label class="text-sm font-medium">功能开关</label>
|
| 457 |
+
<div class="space-y-2">
|
| 458 |
+
<div class="flex items-center gap-3">
|
| 459 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 460 |
+
<input type="checkbox" id="editTokenImageEnabled" class="h-4 w-4 rounded border-input">
|
| 461 |
+
<span class="text-sm font-medium">启用图片生成</span>
|
| 462 |
+
</label>
|
| 463 |
+
<input type="number" id="editTokenImageConcurrency" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
|
| 464 |
+
</div>
|
| 465 |
+
</div>
|
| 466 |
+
<div class="space-y-2">
|
| 467 |
+
<div class="flex items-center gap-3">
|
| 468 |
+
<label class="inline-flex items-center gap-2 cursor-pointer">
|
| 469 |
+
<input type="checkbox" id="editTokenVideoEnabled" class="h-4 w-4 rounded border-input">
|
| 470 |
+
<span class="text-sm font-medium">启用视频生成</span>
|
| 471 |
+
</label>
|
| 472 |
+
<input type="number" id="editTokenVideoConcurrency" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
|
| 473 |
+
</div>
|
| 474 |
+
</div>
|
| 475 |
+
</div>
|
| 476 |
+
</div>
|
| 477 |
+
<div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
|
| 478 |
+
<button onclick="closeEditModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
| 479 |
+
<button id="editTokenBtn" onclick="submitEditToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
| 480 |
+
<span id="editTokenBtnText">保存</span>
|
| 481 |
+
<svg id="editTokenBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 482 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 483 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 484 |
+
</svg>
|
| 485 |
+
</button>
|
| 486 |
+
</div>
|
| 487 |
+
</div>
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<!-- Token 导入模态框 -->
|
| 491 |
+
<div id="importModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
|
| 492 |
+
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
|
| 493 |
+
<div class="flex items-center justify-between p-5 border-b border-border">
|
| 494 |
+
<h3 class="text-lg font-semibold">导入 Token</h3>
|
| 495 |
+
<button onclick="closeImportModal()" class="text-muted-foreground hover:text-foreground">
|
| 496 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 497 |
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
| 498 |
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
| 499 |
+
</svg>
|
| 500 |
+
</button>
|
| 501 |
+
</div>
|
| 502 |
+
<div class="p-5 space-y-4">
|
| 503 |
+
<div>
|
| 504 |
+
<label class="text-sm font-medium mb-2 block">选择 JSON 文件</label>
|
| 505 |
+
<input type="file" id="importFile" accept=".json" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
| 506 |
+
<p class="text-xs text-muted-foreground mt-1">选择导出的 Token JSON 文件进行导入</p>
|
| 507 |
+
</div>
|
| 508 |
+
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
|
| 509 |
+
<p class="text-xs text-blue-800 dark:text-blue-200">
|
| 510 |
+
<strong>说明:</strong>如果邮箱存在则会覆盖更新,不存在则会新增
|
| 511 |
+
</p>
|
| 512 |
+
</div>
|
| 513 |
+
</div>
|
| 514 |
+
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
|
| 515 |
+
<button onclick="closeImportModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
| 516 |
+
<button id="importBtn" onclick="submitImportTokens()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
| 517 |
+
<span id="importBtnText">导入</span>
|
| 518 |
+
<svg id="importBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 519 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 520 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 521 |
+
</svg>
|
| 522 |
+
</button>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
</div>
|
| 526 |
+
|
| 527 |
+
<script>
|
| 528 |
+
let allTokens=[];
|
| 529 |
+
const $=(id)=>document.getElementById(id),
|
| 530 |
+
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
|
| 531 |
+
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
|
| 532 |
+
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
|
| 533 |
+
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
| 534 |
+
formatExpiry=exp=>{if(!exp)return'<span class="text-muted-foreground">-</span>';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});const hours=Math.floor(diff/36e5);if(diff<0)return`<span class="text-red-600 font-medium" title="已过期">已过期</span>`;if(hours<1)return`<span class="text-red-600 font-medium" title="${dateStr} ${timeStr}">${Math.floor(diff/6e4)}分钟</span>`;if(hours<24)return`<span class="text-orange-600 font-medium" title="${dateStr} ${timeStr}">${hours}小时</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600" title="${dateStr} ${timeStr}">${days}天</span>`;return`<span class="text-muted-foreground" title="${dateStr} ${timeStr}">${days}天</span>`},
|
| 535 |
+
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
| 536 |
+
formatSora2=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_total_count-t.sora2_redeemed_count;const tooltipText=`邀请码: ${t.sora2_invite_code||'无'}\n可用次数: ${remaining}/${t.sora2_total_count}\n已用次数: ${t.sora2_redeemed_count}`;return`<div class="inline-flex items-center gap-1"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-green-50 text-green-700 cursor-pointer" title="${tooltipText}" onclick="copySora2Code('${t.sora2_invite_code||''}')">支持</span><span class="text-xs text-muted-foreground" title="${tooltipText}">${remaining}/${t.sora2_total_count}</span></div>`}else if(t.sora2_supported===false){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700 cursor-pointer" title="点击使用邀请码激活" onclick="openSora2Modal(${t.id})">不支持</span>`}else{return'-'}},
|
| 537 |
+
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
|
| 538 |
+
formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}},
|
| 539 |
+
renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const creditsDisplay=t.credits!==undefined?`${t.credits}`:'-';const projectDisplay=t.current_project_name||'-';const projectIdDisplay=t.current_project_id?(t.current_project_id.length>5?`<span class="cursor-pointer text-blue-600 hover:text-blue-700" onclick="copyProjectId('${t.current_project_id}')" title="${t.current_project_id}">${t.current_project_id.substring(0,5)}...</span>`:`<span class="cursor-pointer text-blue-600 hover:text-blue-700" onclick="copyProjectId('${t.current_project_id}')" title="${t.current_project_id}">${t.current_project_id}</span>`):'-';const expiryDisplay=formatExpiry(t.at_expires);return`<tr><td class="py-2.5 px-3">${t.email}</td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700'}">${t.is_active?'活跃':'禁用'}</span></td><td class="py-2.5 px-3 text-xs">${expiryDisplay}</td><td class="py-2.5 px-3"><button onclick="refreshTokenCredits(${t.id})" class="inline-flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm" title="点击刷新余额"><span>${creditsDisplay}</span><svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.219-8.56"/><path d="M15 4.5l3.5 3.5L22 4.5"/></svg></button></td><td class="py-2.5 px-3 text-xs">${projectDisplay}</td><td class="py-2.5 px-3 text-xs">${projectIdDisplay}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="refreshTokenAT(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1" title="刷新AT">更新</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('')},
|
| 540 |
+
refreshTokenCredits=async(id)=>{try{showToast('正在刷新余额...','info');const r=await apiRequest(`/api/tokens/${id}/refresh-credits`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast(`余额刷新成功: ${d.credits}`,'success');await refreshTokens()}else{showToast('刷新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('刷新失败: '+e.message,'error')}},
|
| 541 |
+
refreshTokenAT=async(id)=>{try{showToast('正在更新AT...','info');const r=await apiRequest(`/api/tokens/${id}/refresh-at`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){const expiresDate=d.token.at_expires?new Date(d.token.at_expires):null;const expiresStr=expiresDate?expiresDate.toLocaleString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).replace(/\//g,'-'):'未知';showToast(`AT更新成功! 新过期时间: ${expiresStr}`,'success');await refreshTokens()}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
|
| 542 |
+
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
| 543 |
+
openAddModal=()=>$('addModal').classList.remove('hidden'),
|
| 544 |
+
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenST').value='';$('addTokenRemark').value='';$('addTokenProjectId').value='';$('addTokenProjectName').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1'},
|
| 545 |
+
openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenST').value=token.st||'';$('editTokenRemark').value=token.remark||'';$('editTokenProjectId').value=token.current_project_id||'';$('editTokenProjectName').value=token.current_project_name||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')},
|
| 546 |
+
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenST').value='';$('editTokenRemark').value='';$('editTokenProjectId').value='';$('editTokenProjectName').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value=''},
|
| 547 |
+
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),st=$('editTokenST').value.trim(),remark=$('editTokenRemark').value.trim(),projectId=$('editTokenProjectId').value.trim(),projectName=$('editTokenProjectName').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!st)return showToast('请输入 Session Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}},
|
| 548 |
+
convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
| 549 |
+
convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
| 550 |
+
submitAddToken=async()=>{const st=$('addTokenST').value.trim(),remark=$('addTokenRemark').value.trim(),projectId=$('addTokenProjectId').value.trim(),projectName=$('addTokenProjectName').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!st)return showToast('请输入 Session Token','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}},
|
| 551 |
+
testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}},
|
| 552 |
+
toggleToken=async(id,isActive)=>{const action=isActive?'disable':'enable';try{const r=await apiRequest(`/api/tokens/${id}/${action}`,{method:'POST'});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast(isActive?'Token已禁用':'Token已启用','success')):showToast('操作失败','error')}catch(e){showToast('操作失败: '+e.message,'error')}},
|
| 553 |
+
toggleTokenStatus=async(id,active)=>{try{const r=await apiRequest(`/api/tokens/${id}/status`,{method:'PUT',body:JSON.stringify({is_active:active})});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast('状态更新成功','success')):showToast('更新失败','error')}catch(e){showToast('更新失败: '+e.message,'error')}},
|
| 554 |
+
deleteToken=async(id,skipConfirm=false)=>{if(!skipConfirm&&!confirm('确定要删除这个Token吗?'))return;try{const r=await apiRequest(`/api/tokens/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();if(!skipConfirm)showToast('删除成功','success');return true}else{if(!skipConfirm)showToast('删除失败','error');return false}}catch(e){if(!skipConfirm)showToast('删除失败: '+e.message,'error');return false}},
|
| 555 |
+
copySora2Code=async(code)=>{if(!code){showToast('没有可复制的邀请码','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(code);showToast(`邀请码已复制: ${code}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=code;textarea.style.position='fixed';textarea.style.opacity='0';document.body.appendChild(textarea);textarea.select();const success=document.execCommand('copy');document.body.removeChild(textarea);if(success){showToast(`邀请码已复制: ${code}`,'success')}else{showToast('复制失败: 浏览器不支持','error')}}}catch(e){showToast('复制失败: '+e.message,'error')}},
|
| 556 |
+
copyProjectId=async(projectId)=>{if(!projectId){showToast('没有可复制的Project ID','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(projectId);showToast(`Project ID已复制: ${projectId}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=projectId;textarea.style.position='fixed';textarea.style.opacity='0';document.body.appendChild(textarea);textarea.select();const success=document.execCommand('copy');document.body.removeChild(textarea);if(success){showToast(`Project ID已复制: ${projectId}`,'success')}else{showToast('复制失败: 浏览器不支持','error')}}}catch(e){showToast('复制失败: '+e.message,'error')}},
|
| 557 |
+
openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')},
|
| 558 |
+
closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''},
|
| 559 |
+
openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''},
|
| 560 |
+
closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''},
|
| 561 |
+
exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')},
|
| 562 |
+
submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误:应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();const msg=`导入成功!新增: ${d.added||0}, 更新: ${d.updated||0}`;showToast(msg,'success')}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
|
| 563 |
+
submitSora2Activate=async()=>{const tokenId=parseInt($('sora2TokenId').value),inviteCode=$('sora2InviteCode').value.trim();if(!tokenId)return showToast('Token ID无效','error');if(!inviteCode)return showToast('请输入邀请码','error');if(inviteCode.length!==6)return showToast('邀请码必须是6位','error');const btn=$('sora2ActivateBtn'),btnText=$('sora2ActivateBtnText'),btnSpinner=$('sora2ActivateBtnSpinner');btn.disabled=true;btnText.textContent='激活中...';btnSpinner.classList.remove('hidden');try{showToast('正在激活Sora2...','info');const r=await apiRequest(`/api/tokens/${tokenId}/sora2/activate?invite_code=${inviteCode}`,{method:'POST'});if(!r){btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeSora2Modal();await refreshTokens();if(d.already_accepted){showToast('Sora2已激活���之前已接受)','success')}else{showToast(`Sora2激活成功!邀请码: ${d.invite_code||'无'}`,'success')}}else{showToast('激活失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('激活失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden')}},
|
| 564 |
+
loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}},
|
| 565 |
+
saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
| 566 |
+
updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}},
|
| 567 |
+
updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
|
| 568 |
+
toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}},
|
| 569 |
+
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
|
| 570 |
+
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
| 571 |
+
toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
|
| 572 |
+
loadCacheConfig=async()=>{try{console.log('开始加载缓存配置...');const r=await apiRequest('/api/cache/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('缓存配置数据:',d);if(d.success&&d.config){const enabled=d.config.enabled!==false;const timeout=d.config.timeout||7200;const baseUrl=d.config.base_url||'';const effectiveUrl=d.config.effective_base_url||'';console.log('设置缓存启用:',enabled);console.log('设置超时时间:',timeout);console.log('设置域名:',baseUrl);console.log('生效URL:',effectiveUrl);$('cfgCacheEnabled').checked=enabled;$('cfgCacheTimeout').value=timeout;$('cfgCacheBaseUrl').value=baseUrl;if(effectiveUrl){$('cacheEffectiveUrlValue').textContent=effectiveUrl;$('cacheEffectiveUrl').classList.remove('hidden')}else{$('cacheEffectiveUrl').classList.add('hidden')}toggleCacheOptions();console.log('缓存配置加载成功')}else{console.error('缓存配置数据格式错误:',d)}}catch(e){console.error('加载缓存配置失败:',e);showToast('加载缓存配置失败: '+e.message,'error')}},
|
| 573 |
+
loadGenerationTimeout=async()=>{try{console.log('开始加载生成超时配置...');const r=await apiRequest('/api/generation/timeout');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('生成超时配置数据:',d);if(d.success&&d.config){const imageTimeout=d.config.image_timeout||300;const videoTimeout=d.config.video_timeout||1500;console.log('设置图片超时:',imageTimeout);console.log('设置视频超时:',videoTimeout);$('cfgImageTimeout').value=imageTimeout;$('cfgVideoTimeout').value=videoTimeout;console.log('生成超时配置加载成功')}else{console.error('生成超时配置数据格式错误:',d)}}catch(e){console.error('加载生成超时配置失败:',e);showToast('加载生成超时配置失败: '+e.message,'error')}},
|
| 574 |
+
saveCacheConfig=async()=>{const enabled=$('cfgCacheEnabled').checked,timeout=parseInt($('cfgCacheTimeout').value)||7200,baseUrl=$('cfgCacheBaseUrl').value.trim();console.log('保存缓存配置:',{enabled,timeout,baseUrl});if(timeout<60||timeout>86400)return showToast('缓存超时时间必须在 60-86400 秒之间','error');if(baseUrl&&!baseUrl.startsWith('http://')&&!baseUrl.startsWith('https://'))return showToast('域名必须以 http:// 或 https:// 开头','error');try{console.log('保存缓存启用状态...');const r0=await apiRequest('/api/cache/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r0){console.error('保存缓存启用状态请求失败');return}const d0=await r0.json();console.log('缓存启用状态保存结果:',d0);if(!d0.success){console.error('保存缓存启用状态失败:',d0);return showToast('保存缓存启用状态失败','error')}console.log('保存超时时间...');const r1=await apiRequest('/api/cache/config',{method:'POST',body:JSON.stringify({timeout:timeout})});if(!r1){console.error('保存超时时间请求失败');return}const d1=await r1.json();console.log('超时时间保存结果:',d1);if(!d1.success){console.error('保存超时时间失败:',d1);return showToast('保存超时时间失败','error')}console.log('保存域名...');const r2=await apiRequest('/api/cache/base-url',{method:'POST',body:JSON.stringify({base_url:baseUrl})});if(!r2){console.error('保存域名请求失败');return}const d2=await r2.json();console.log('域名保存结果:',d2);if(d2.success){showToast('缓存配置保存成功','success');console.log('等待配置文件写入完成...');await new Promise(r=>setTimeout(r,200));console.log('重新加载配置...');await loadCacheConfig()}else{console.error('保存域名失败:',d2);showToast('保存域名失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
|
| 575 |
+
saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
|
| 576 |
+
toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}},
|
| 577 |
+
loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}},
|
| 578 |
+
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>`<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${l.status_code}</span></td><td class="py-2.5 px-3">${l.duration.toFixed(2)}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}},
|
| 579 |
+
refreshLogs=async()=>{await loadLogs()},
|
| 580 |
+
showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)},
|
| 581 |
+
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
|
| 582 |
+
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}};
|
| 583 |
+
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
|
| 584 |
+
</script>
|
| 585 |
+
</body>
|
| 586 |
+
</html>
|