TheSmallHanCat commited on
Commit
36cbcc0
·
1 Parent(s): 23fd521

feat:flow2api初版

Browse files
.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
- # flow2api
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Flow2API
2
+
3
+ <div align="center">
4
+
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/)
7
+ [![FastAPI](https://img.shields.io/badge/fastapi-0.119.0-green.svg)](https://fastapi.tiangolo.com/)
8
+ [![Docker](https://img.shields.io/badge/docker-supported-blue.svg)](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"![Generated Image]({content})"
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>