xiaoh2018 commited on
Commit
33cfa2a
·
verified ·
1 Parent(s): 582be00

Upload 1108 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +77 -0
  2. .gitattributes +8 -0
  3. .gitignore +56 -0
  4. Dockerfile +35 -0
  5. LICENSE +21 -0
  6. README.md +295 -8
  7. app.py +18 -0
  8. config/setting.toml +42 -0
  9. config/setting_warp.toml +42 -0
  10. docker-compose.proxy.yml +36 -0
  11. docker-compose.yml +14 -0
  12. main.py +13 -0
  13. request.py +150 -0
  14. requirements.txt +10 -0
  15. src/__pycache__/main.cpython-310.pyc +0 -0
  16. src/api/__init__.py +6 -0
  17. src/api/admin.py +1047 -0
  18. src/api/routes.py +213 -0
  19. src/core/__init__.py +7 -0
  20. src/core/auth.py +39 -0
  21. src/core/config.py +218 -0
  22. src/core/database.py +1269 -0
  23. src/core/logger.py +252 -0
  24. src/core/models.py +186 -0
  25. src/main.py +209 -0
  26. src/services/__init__.py +17 -0
  27. src/services/browser_captcha.py +317 -0
  28. src/services/browser_captcha_personal.py +197 -0
  29. src/services/concurrency_manager.py +190 -0
  30. src/services/file_cache.py +301 -0
  31. src/services/flow_client.py +765 -0
  32. src/services/generation_handler.py +1018 -0
  33. src/services/load_balancer.py +89 -0
  34. src/services/proxy_manager.py +25 -0
  35. src/services/token_manager.py +504 -0
  36. static/login.html +53 -0
  37. static/manage.html +722 -0
  38. venv/Lib/site-packages/_distutils_hack/__init__.py +128 -0
  39. venv/Lib/site-packages/_distutils_hack/__pycache__/__init__.cpython-310.pyc +0 -0
  40. venv/Lib/site-packages/_distutils_hack/__pycache__/override.cpython-310.pyc +0 -0
  41. venv/Lib/site-packages/_distutils_hack/override.py +1 -0
  42. venv/Lib/site-packages/distutils-precedence.pth +3 -0
  43. venv/Lib/site-packages/pip-21.2.3.dist-info/INSTALLER +1 -0
  44. venv/Lib/site-packages/pip-21.2.3.dist-info/LICENSE.txt +20 -0
  45. venv/Lib/site-packages/pip-21.2.3.dist-info/METADATA +92 -0
  46. venv/Lib/site-packages/pip-21.2.3.dist-info/RECORD +795 -0
  47. venv/Lib/site-packages/pip-21.2.3.dist-info/REQUESTED +0 -0
  48. venv/Lib/site-packages/pip-21.2.3.dist-info/WHEEL +5 -0
  49. venv/Lib/site-packages/pip-21.2.3.dist-info/entry_points.txt +5 -0
  50. venv/Lib/site-packages/pip-21.2.3.dist-info/top_level.txt +1 -0
.dockerignore ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git
2
+ .git
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+ *.manifest
29
+ *.spec
30
+ pip-log.txt
31
+ pip-delete-this-directory.txt
32
+
33
+ # Virtual Environment
34
+ venv/
35
+ env/
36
+ ENV/
37
+ .venv
38
+
39
+ # IDE
40
+ .vscode/
41
+ .idea/
42
+ *.swp
43
+ *.swo
44
+ *~
45
+ .DS_Store
46
+
47
+ # Project specific
48
+ data/*.db
49
+ data/*.db-journal
50
+ tmp/*
51
+ logs/*
52
+ *.log
53
+
54
+ # Docker
55
+ Dockerfile
56
+ docker-compose*.yml
57
+ .dockerignore
58
+
59
+ # Documentation
60
+ README.md
61
+ DEPLOYMENT.md
62
+ LICENSE
63
+ *.md
64
+
65
+ # Test files
66
+ tests/
67
+ test_*.py
68
+ *_test.py
69
+
70
+ # CI/CD
71
+ .github/
72
+ .gitlab-ci.yml
73
+ .travis.yml
74
+
75
+ # Environment files
76
+ .env
77
+ .env.*
.gitattributes CHANGED
@@ -33,3 +33,11 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ venv/Lib/site-packages/pip/_vendor/__pycache__/pyparsing.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
37
+ venv/Lib/site-packages/pip/_vendor/distlib/t64.exe filter=lfs diff=lfs merge=lfs -text
38
+ venv/Lib/site-packages/pip/_vendor/html5lib/__pycache__/constants.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
39
+ venv/Lib/site-packages/pip/_vendor/idna/__pycache__/uts46data.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
40
+ venv/Lib/site-packages/pkg_resources/__pycache__/__init__.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
41
+ venv/Lib/site-packages/pkg_resources/_vendor/__pycache__/pyparsing.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
42
+ venv/Lib/site-packages/setuptools/_vendor/__pycache__/pyparsing.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
43
+ venv/Lib/site-packages/setuptools/_vendor/more_itertools/__pycache__/more.cpython-310.pyc filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
55
+
56
+ browser_data
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # 安装 Playwright 所需的系统依赖
6
+ RUN apt-get update && apt-get install -y \
7
+ libnss3 \
8
+ libnspr4 \
9
+ libatk1.0-0 \
10
+ libatk-bridge2.0-0 \
11
+ libcups2 \
12
+ libdrm2 \
13
+ libxkbcommon0 \
14
+ libxcomposite1 \
15
+ libxdamage1 \
16
+ libxfixes3 \
17
+ libxrandr2 \
18
+ libgbm1 \
19
+ libasound2 \
20
+ libpango-1.0-0 \
21
+ libcairo2 \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ # 安装 Python 依赖
25
+ COPY requirements.txt .
26
+ RUN pip install --no-cache-dir -r requirements.txt
27
+
28
+ # 安装 Playwright 浏览器
29
+ RUN playwright install chromium
30
+
31
+ COPY . .
32
+
33
+ EXPOSE 8000
34
+
35
+ CMD ["python", "main.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 TheSmallHanCat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,11 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: FLOW2API
3
- emoji: 🚀
4
- colorFrom: gray
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- short_description: FLOW2API
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
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
+
30
+ - Docker 和 Docker Compose(推荐)
31
+ - 或 Python 3.8+
32
+
33
+ - 由于Flow增加了额外的验证码,你可以自行选择使用浏览器打码或第三发打码:
34
+ 注册[YesCaptcha](https://yescaptcha.com/i/13Xd8K)并获取api key,将其填入系统配置页面```YesCaptcha API密钥```区域
35
+
36
+ - 自动更新st浏览器拓展:[Flow2API-Token-Updater](https://github.com/TheSmallHanCat/Flow2API-Token-Updater)
37
+
38
+ ### 方式一:Docker 部署(推荐)
39
+
40
+ #### 标准模式(不使用代理)
41
+
42
+ ```bash
43
+ # 克隆项目
44
+ git clone https://github.com/TheSmallHanCat/flow2api.git
45
+ cd flow2api
46
+
47
+ # 启动服务
48
+ docker-compose up -d
49
+
50
+ # 查看日志
51
+ docker-compose logs -f
52
+ ```
53
+
54
+ #### WARP 模式(使用代理)
55
+
56
+ ```bash
57
+ # 使用 WARP 代理启动
58
+ docker-compose -f docker-compose.warp.yml up -d
59
+
60
+ # 查看日志
61
+ docker-compose -f docker-compose.warp.yml logs -f
62
+ ```
63
+
64
+ ### 方式二:本地部署
65
+
66
+ ```bash
67
+ # 克隆项目
68
+ git clone https://github.com/TheSmallHanCat/flow2api.git
69
+ cd flow2api
70
+
71
+ # 创建虚拟环境
72
+ python -m venv venv
73
+
74
+ # 激活虚拟环境
75
+ # Windows
76
+ venv\Scripts\activate
77
+ # Linux/Mac
78
+ source venv/bin/activate
79
+
80
+ # 安装依赖
81
+ pip install -r requirements.txt
82
+
83
+ # 启动服务
84
+ python main.py
85
+ ```
86
+
87
+ ### 方式三:Hugging Face 部署
88
+
89
+ 1. **准备工作**
90
+ - 注册并登录 [Hugging Face](https://huggingface.co/)
91
+ - 创建一个新的 Space
92
+ - 选择 "Docker" 作为 Space 类型
93
+
94
+ 2. **配置 Space**
95
+ - **Repository name**: 输入一个名称(例如 `flow2api`)
96
+ - **Visibility**: 选择 "Public" 或 "Private"
97
+ - **Hardware**: 选择适当的硬件配置(最低推荐 2GB RAM)
98
+
99
+ 3. **部署步骤**
100
+ - 在 Space 的 "Files" 标签页中,上传项目的所有文件
101
+ - 确保 `app.py` 文件存在(Hugging Face 的部署入口)
102
+ - 确保 `requirements.txt` 文件包含所有必要的依赖
103
+ - 点击 "Build" 按钮开始部署
104
+
105
+ 4. **访问服务**
106
+ - 部署完成后,通过 Space 提供的 URL 访问服务
107
+ - 默认登录地址: `https://<your-space-name>.hf.space/`
108
+ - 首次登录用户名: `admin`,密码: `admin`
109
+
110
+ 5. **注意事项**
111
+ - Hugging Face Spaces 使用端口 7860,配置已自动适配
112
+ - 由于 Hugging Face 的网络环境,可能需要配置代理
113
+ - 建议在管理界面中修改默认的管理员密码
114
+ - 对于视频生成等耗时操作,可能会受到 Hugging Face 的资源限制
115
+
116
+ ### 首次访问
117
+
118
+ 服务启动后,访问管理后台: **http://localhost:8000**,首次登录后请立即修改密码!
119
+
120
+ - **用户名**: `admin`
121
+ - **密码**: `admin`
122
+
123
+ ## 📋 支持的模型
124
+
125
+ ### 图片生成
126
+
127
+ | 模型名称 | 说明| 尺寸 |
128
+ |---------|--------|--------|
129
+ | `gemini-2.5-flash-image-landscape` | 图/文生图 | 横屏 |
130
+ | `gemini-2.5-flash-image-portrait` | 图/文生图 | 竖屏 |
131
+ | `gemini-3.0-pro-image-landscape` | 图/文生图 | 横屏 |
132
+ | `gemini-3.0-pro-image-portrait` | 图/文生图 | 竖屏 |
133
+ | `imagen-4.0-generate-preview-landscape` | 图/文生图 | 横屏 |
134
+ | `imagen-4.0-generate-preview-portrait` | 图/文生图 | 竖屏 |
135
+
136
+ ### 视频生成
137
+
138
+ #### 文生视频 (T2V - Text to Video)
139
+ ⚠️ **不支持上传图片**
140
+
141
+ | 模型名称 | 说明| 尺寸 |
142
+ |---------|---------|--------|
143
+ | `veo_3_1_t2v_fast_portrait` | 文生视频 | 竖屏 |
144
+ | `veo_3_1_t2v_fast_landscape` | 文生视频 | 横屏 |
145
+ | `veo_2_1_fast_d_15_t2v_portrait` | 文生视频 | 竖屏 |
146
+ | `veo_2_1_fast_d_15_t2v_landscape` | 文生视频 | 横屏 |
147
+ | `veo_2_0_t2v_portrait` | 文生视频 | 竖屏 |
148
+ | `veo_2_0_t2v_landscape` | 文生视频 | 横屏 |
149
+
150
+ #### 首尾帧模型 (I2V - Image to Video)
151
+ 📸 **支持1-2张图片:首尾帧**
152
+
153
+ | 模型名称 | 说明| 尺寸 |
154
+ |---------|---------|--------|
155
+ | `veo_3_1_i2v_s_fast_fl_portrait` | 图生视频 | 竖屏 |
156
+ | `veo_3_1_i2v_s_fast_fl_landscape` | 图生视频 | 横屏 |
157
+ | `veo_2_1_fast_d_15_i2v_portrait` | 图生视频 | 竖屏 |
158
+ | `veo_2_1_fast_d_15_i2v_landscape` | 图生视频 | 横屏 |
159
+ | `veo_2_0_i2v_portrait` | 图生视频 | 竖屏 |
160
+ | `veo_2_0_i2v_landscape` | 图生视频 | 横屏 |
161
+
162
+ #### 多图生成 (R2V - Reference Images to Video)
163
+ 🖼️ **支持多张图片**
164
+
165
+ | 模型名称 | 说明| 尺寸 |
166
+ |---------|---------|--------|
167
+ | `veo_3_0_r2v_fast_portrait` | 图生视频 | 竖屏 |
168
+ | `veo_3_0_r2v_fast_landscape` | 图生视频 | 横屏 |
169
+
170
+ ## 📡 API 使用示例(需要使用流式)
171
+
172
+ ### 文生图
173
+
174
+ ```bash
175
+ curl -X POST "http://localhost:8000/v1/chat/completions" \
176
+ -H "Authorization: Bearer han1234" \
177
+ -H "Content-Type: application/json" \
178
+ -d '{
179
+ "model": "gemini-2.5-flash-image-landscape",
180
+ "messages": [
181
+ {
182
+ "role": "user",
183
+ "content": "一只可爱的猫咪在花园里玩耍"
184
+ }
185
+ ],
186
+ "stream": true
187
+ }'
188
+ ```
189
+
190
+ ### 图生图
191
+
192
+ ```bash
193
+ curl -X POST "http://localhost:8000/v1/chat/completions" \
194
+ -H "Authorization: Bearer han1234" \
195
+ -H "Content-Type: application/json" \
196
+ -d '{
197
+ "model": "imagen-4.0-generate-preview-landscape",
198
+ "messages": [
199
+ {
200
+ "role": "user",
201
+ "content": [
202
+ {
203
+ "type": "text",
204
+ "text": "将这张图片变成水彩画风格"
205
+ },
206
+ {
207
+ "type": "image_url",
208
+ "image_url": {
209
+ "url": "data:image/jpeg;base64,<base64_encoded_image>"
210
+ }
211
+ }
212
+ ]
213
+ }
214
+ ],
215
+ "stream": true
216
+ }'
217
+ ```
218
+
219
+ ### 文生视频
220
+
221
+ ```bash
222
+ curl -X POST "http://localhost:8000/v1/chat/completions" \
223
+ -H "Authorization: Bearer han1234" \
224
+ -H "Content-Type: application/json" \
225
+ -d '{
226
+ "model": "veo_3_1_t2v_fast_landscape",
227
+ "messages": [
228
+ {
229
+ "role": "user",
230
+ "content": "一只小猫在草地上追逐蝴蝶"
231
+ }
232
+ ],
233
+ "stream": true
234
+ }'
235
+ ```
236
+
237
+ ### 首尾帧生成视频
238
+
239
+ ```bash
240
+ curl -X POST "http://localhost:8000/v1/chat/completions" \
241
+ -H "Authorization: Bearer han1234" \
242
+ -H "Content-Type: application/json" \
243
+ -d '{
244
+ "model": "veo_3_1_i2v_s_fast_fl_landscape",
245
+ "messages": [
246
+ {
247
+ "role": "user",
248
+ "content": [
249
+ {
250
+ "type": "text",
251
+ "text": "从第一张图过渡到第二张图"
252
+ },
253
+ {
254
+ "type": "image_url",
255
+ "image_url": {
256
+ "url": "data:image/jpeg;base64,<首帧base64>"
257
+ }
258
+ },
259
+ {
260
+ "type": "image_url",
261
+ "image_url": {
262
+ "url": "data:image/jpeg;base64,<尾帧base64>"
263
+ }
264
+ }
265
+ ]
266
+ }
267
+ ],
268
+ "stream": true
269
+ }'
270
+ ```
271
+
272
+ ---
273
+
274
+ ## 📄 许可证
275
+
276
+ 本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
277
+
278
+ ---
279
+
280
+ ## 🙏 致谢
281
+
282
+ - [PearNoDec](https://github.com/PearNoDec) 提供的YesCaptcha打码方案
283
+ - [raomaiping](https://github.com/raomaiping) 提供的无头打码方案
284
+ 感谢所有贡献者和使用者的支持!
285
+
286
  ---
287
+
288
+ ## 📞 联系方式
289
+
290
+ - 提交 Issue:[GitHub Issues](https://github.com/TheSmallHanCat/flow2api/issues)
291
+
 
 
292
  ---
293
 
294
+ **⭐ 如果这个项目对你有帮助,请给个 Star!**
295
+
296
+ ## Star History
297
+
298
+ [![Star History Chart](https://api.star-history.com/svg?repos=TheSmallHanCat/flow2api&type=date&legend=top-left)](https://www.star-history.com/#TheSmallHanCat/flow2api&type=date&legend=top-left)
app.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow2API - Hugging Face Deployment Entry Point"""
2
+ from src.main import app
3
+ import os
4
+
5
+ # Hugging Face 要求使用 7860 端口
6
+ if __name__ == "__main__":
7
+ import uvicorn
8
+ from src.core.config import config
9
+
10
+ # 使用环境变量中的端口(Hugging Face 会设置),默认 7860
11
+ port = int(os.getenv("PORT", "7860"))
12
+
13
+ uvicorn.run(
14
+ "src.main:app",
15
+ host="0.0.0.0",
16
+ port=port,
17
+ reload=False
18
+ )
config/setting.toml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ poll_interval = 3.0
11
+ max_poll_attempts = 200
12
+
13
+ [server]
14
+ host = "0.0.0.0"
15
+ port = 7860
16
+
17
+ [debug]
18
+ enabled = false
19
+ log_requests = true
20
+ log_responses = true
21
+ mask_token = true
22
+
23
+ [proxy]
24
+ proxy_enabled = false
25
+ proxy_url = ""
26
+
27
+ [generation]
28
+ image_timeout = 300
29
+ video_timeout = 1500
30
+
31
+ [admin]
32
+ error_ban_threshold = 3
33
+
34
+ [cache]
35
+ enabled = false
36
+ timeout = 7200 # 缓存超时时间(秒), 默认2小时
37
+ base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
38
+
39
+ [captcha]
40
+ captcha_method = "browser" # 打码方式: yescaptcha 或 browser
41
+ yescaptcha_api_key = "" # YesCaptcha API密钥
42
+ yescaptcha_base_url = "https://api.yescaptcha.com"
config/setting_warp.toml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ poll_interval = 3.0
11
+ max_poll_attempts = 200
12
+
13
+ [server]
14
+ host = "0.0.0.0"
15
+ port = 8000
16
+
17
+ [debug]
18
+ enabled = false
19
+ log_requests = true
20
+ log_responses = true
21
+ mask_token = true
22
+
23
+ [proxy]
24
+ proxy_enabled = true
25
+ proxy_url = "socks5://warp:1080"
26
+
27
+ [generation]
28
+ image_timeout = 300
29
+ video_timeout = 1500
30
+
31
+ [admin]
32
+ error_ban_threshold = 3
33
+
34
+ [cache]
35
+ enabled = false
36
+ timeout = 7200 # 缓存超时时间(秒), 默认2小时
37
+ base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
38
+
39
+ [captcha]
40
+ captcha_method = "browser" # 打码方式: yescaptcha 或 browser
41
+ yescaptcha_api_key = "" # YesCaptcha API密钥
42
+ yescaptcha_base_url = "https://api.yescaptcha.com"
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
+ )
request.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import re
4
+ import base64
5
+ import aiohttp # Async test. Need to install
6
+ import asyncio
7
+
8
+
9
+ # --- 配置区域 ---
10
+ BASE_URL = os.getenv('GEMINI_FLOW2API_URL', 'http://127.0.0.1:8000')
11
+ BACKEND_URL = BASE_URL + "/v1/chat/completions"
12
+ API_KEY = os.getenv('GEMINI_FLOW2API_APIKEY', 'Bearer han1234')
13
+ if API_KEY is None:
14
+ raise ValueError('[gemini flow2api] api key not set')
15
+ MODEL_LANDSCAPE = "gemini-3.0-pro-image-landscape"
16
+ MODEL_PORTRAIT = "gemini-3.0-pro-image-portrait"
17
+
18
+ # 修改: 增加 model 参数,默认为 None
19
+ async def request_backend_generation(
20
+ prompt: str,
21
+ images: list[bytes] = None,
22
+ model: str = None) -> bytes | None:
23
+ """
24
+ 请求后端生成图片。
25
+ :param prompt: 提示词
26
+ :param images: 图片二进制列表
27
+ :param model: 指定模型名称 (可选)
28
+ :return: 成功返回图片bytes,失败返回None
29
+ """
30
+ # 更新token
31
+ images = images or []
32
+
33
+ # 逻辑: 如果未指定 model,默认使用 Landscape
34
+ use_model = model if model else MODEL_LANDSCAPE
35
+
36
+ # 1. 构造 Payload
37
+ if images:
38
+ content_payload = [{"type": "text", "text": prompt}]
39
+ print(f"[Backend] 正在处理 {len(images)} 张图片输入...")
40
+ for img_bytes in images:
41
+ b64_str = base64.b64encode(img_bytes).decode('utf-8')
42
+ content_payload.append({
43
+ "type": "image_url",
44
+ "image_url": {"url": f"data:image/jpeg;base64,{b64_str}"}
45
+ })
46
+ else:
47
+ content_payload = prompt
48
+
49
+ payload = {
50
+ "model": use_model, # 使用选定的模型
51
+ "messages": [{"role": "user", "content": content_payload}],
52
+ "stream": True
53
+ }
54
+
55
+ headers = {
56
+ "Authorization": API_KEY,
57
+ "Content-Type": "application/json"
58
+ }
59
+
60
+ image_url = None
61
+ print(f"[Backend] Model: {use_model} | 发起请求: {prompt[:20]}...")
62
+
63
+ try:
64
+ async with aiohttp.ClientSession() as session:
65
+ async with session.post(BACKEND_URL, json=payload, headers=headers, timeout=120) as response:
66
+ if response.status != 200:
67
+ err_text = await response.text()
68
+ content = response.content
69
+ print(f"[Backend Error] Status {response.status}: {err_text} {content}")
70
+ raise Exception(f"API Error: {response.status}: {err_text}")
71
+
72
+ async for line in response.content:
73
+ line_str = line.decode('utf-8').strip()
74
+ if line_str.startswith('{"error'):
75
+ chunk = json.loads(data_str)
76
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
77
+ msg = delta['reasoning_content']
78
+ if '401' in msg:
79
+ msg += '\nAccess Token 已失效,需重新配置。'
80
+ elif '400' in msg:
81
+ msg += '\n返回内容被拦截。'
82
+ raise Exception(msg)
83
+
84
+ if not line_str or not line_str.startswith('data: '):
85
+ continue
86
+
87
+ data_str = line_str[6:]
88
+ if data_str == '[DONE]':
89
+ break
90
+
91
+ try:
92
+ chunk = json.loads(data_str)
93
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
94
+
95
+ # 打印思考过程
96
+ if "reasoning_content" in delta:
97
+ print(delta['reasoning_content'], end="", flush=True)
98
+
99
+ # 提取内容中的图片链接
100
+ if "content" in delta:
101
+ content_text = delta["content"]
102
+ img_match = re.search(r'!\[.*?\]\((.*?)\)', content_text)
103
+ if img_match:
104
+ image_url = img_match.group(1)
105
+ print(f"\n[Backend] 捕获图片链接: {image_url}")
106
+ except json.JSONDecodeError:
107
+ continue
108
+
109
+ # 3. 下载生成的图片
110
+ if image_url:
111
+ async with session.get(image_url) as img_resp:
112
+ if img_resp.status == 200:
113
+ image_bytes = await img_resp.read()
114
+ return image_bytes
115
+ else:
116
+ print(f"[Backend Error] 图片下载失败: {img_resp.status}")
117
+ except Exception as e:
118
+ print(f"[Backend Exception] {e}")
119
+ raise e
120
+
121
+ return None
122
+
123
+ if __name__ == '__main__':
124
+ async def main():
125
+ print("=== AI 绘图接口测试 ===")
126
+ user_prompt = input("请输入提示词 (例如 '一只猫'): ").strip()
127
+ if not user_prompt:
128
+ user_prompt = "A cute cat in the garden"
129
+
130
+ print(f"正在请求: {user_prompt}")
131
+
132
+ # 这里的 images 传空列表用于测试文生图
133
+ # 如果想测试图生图,你需要手动读取本地文件:
134
+ # with open("output_test.jpg", "rb") as f: img_data = f.read()
135
+ # result = await request_backend_generation(user_prompt, [img_data])
136
+
137
+ result = await request_backend_generation(user_prompt)
138
+
139
+ if result:
140
+ filename = "output_test.jpg"
141
+ with open(filename, "wb") as f:
142
+ f.write(result)
143
+ print(f"\n[Success] 图片已保存为 {filename},大小: {len(result)} bytes")
144
+ else:
145
+ print("\n[Failed] 生成失败")
146
+
147
+ # 运行测试
148
+ if os.name == 'nt': # Windows 兼容性
149
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
150
+ asyncio.run(main())
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
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
10
+ playwright==1.53.0
src/__pycache__/main.cpython-310.pyc ADDED
Binary file (5.96 kB). View file
 
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,1047 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin API routes"""
2
+ from fastapi import APIRouter, Depends, HTTPException, Header, Request
3
+ from fastapi.responses import JSONResponse
4
+ from pydantic import BaseModel
5
+ from typing import Optional, List
6
+ import secrets
7
+ from ..core.auth import AuthManager
8
+ from ..core.database import Database
9
+ from ..core.config import config
10
+ from ..services.token_manager import TokenManager
11
+ from ..services.proxy_manager import ProxyManager
12
+
13
+ router = APIRouter()
14
+
15
+ # Dependency injection
16
+ token_manager: TokenManager = None
17
+ proxy_manager: ProxyManager = None
18
+ db: Database = None
19
+
20
+ # Store active admin session tokens (in production, use Redis or database)
21
+ active_admin_tokens = set()
22
+
23
+
24
+ def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database):
25
+ """Set service instances"""
26
+ global token_manager, proxy_manager, db
27
+ token_manager = tm
28
+ proxy_manager = pm
29
+ db = database
30
+
31
+
32
+ # ========== Request Models ==========
33
+
34
+ class LoginRequest(BaseModel):
35
+ username: str
36
+ password: str
37
+
38
+
39
+ class AddTokenRequest(BaseModel):
40
+ st: str
41
+ project_id: Optional[str] = None # 用户可选输入project_id
42
+ project_name: Optional[str] = None
43
+ remark: Optional[str] = None
44
+ image_enabled: bool = True
45
+ video_enabled: bool = True
46
+ image_concurrency: int = -1
47
+ video_concurrency: int = -1
48
+
49
+
50
+ class UpdateTokenRequest(BaseModel):
51
+ st: str # Session Token (必填,用于刷新AT)
52
+ project_id: Optional[str] = None # 用户可选输入project_id
53
+ project_name: Optional[str] = None
54
+ remark: Optional[str] = None
55
+ image_enabled: Optional[bool] = None
56
+ video_enabled: Optional[bool] = None
57
+ image_concurrency: Optional[int] = None
58
+ video_concurrency: Optional[int] = None
59
+
60
+
61
+ class ProxyConfigRequest(BaseModel):
62
+ proxy_enabled: bool
63
+ proxy_url: Optional[str] = None
64
+
65
+
66
+ class GenerationConfigRequest(BaseModel):
67
+ image_timeout: int
68
+ video_timeout: int
69
+
70
+
71
+ class ChangePasswordRequest(BaseModel):
72
+ username: Optional[str] = None
73
+ old_password: str
74
+ new_password: str
75
+
76
+
77
+ class UpdateAPIKeyRequest(BaseModel):
78
+ new_api_key: str
79
+
80
+
81
+ class UpdateDebugConfigRequest(BaseModel):
82
+ enabled: bool
83
+
84
+
85
+ class UpdateAdminConfigRequest(BaseModel):
86
+ error_ban_threshold: int
87
+
88
+
89
+ class ST2ATRequest(BaseModel):
90
+ """ST转AT请求"""
91
+ st: str
92
+
93
+
94
+ class ImportTokenItem(BaseModel):
95
+ """导入Token项"""
96
+ email: Optional[str] = None
97
+ access_token: Optional[str] = None
98
+ session_token: Optional[str] = None
99
+ is_active: bool = True
100
+ image_enabled: bool = True
101
+ video_enabled: bool = True
102
+ image_concurrency: int = -1
103
+ video_concurrency: int = -1
104
+
105
+
106
+ class ImportTokensRequest(BaseModel):
107
+ """导入Token请求"""
108
+ tokens: List[ImportTokenItem]
109
+
110
+
111
+ # ========== Auth Middleware ==========
112
+
113
+ async def verify_admin_token(authorization: str = Header(None)):
114
+ """Verify admin session token (NOT API key)"""
115
+ if not authorization or not authorization.startswith("Bearer "):
116
+ raise HTTPException(status_code=401, detail="Missing authorization")
117
+
118
+ token = authorization[7:]
119
+
120
+ # Check if token is in active session tokens
121
+ if token not in active_admin_tokens:
122
+ raise HTTPException(status_code=401, detail="Invalid or expired admin token")
123
+
124
+ return token
125
+
126
+
127
+ # ========== Auth Endpoints ==========
128
+
129
+ @router.post("/api/admin/login")
130
+ async def admin_login(request: LoginRequest):
131
+ """Admin login - returns session token (NOT API key)"""
132
+ admin_config = await db.get_admin_config()
133
+
134
+ if not AuthManager.verify_admin(request.username, request.password):
135
+ raise HTTPException(status_code=401, detail="Invalid credentials")
136
+
137
+ # Generate independent session token
138
+ session_token = f"admin-{secrets.token_urlsafe(32)}"
139
+
140
+ # Store in active tokens
141
+ active_admin_tokens.add(session_token)
142
+
143
+ return {
144
+ "success": True,
145
+ "token": session_token, # Session token (NOT API key)
146
+ "username": admin_config.username
147
+ }
148
+
149
+
150
+ @router.post("/api/admin/logout")
151
+ async def admin_logout(token: str = Depends(verify_admin_token)):
152
+ """Admin logout - invalidate session token"""
153
+ active_admin_tokens.discard(token)
154
+ return {"success": True, "message": "退出登录成功"}
155
+
156
+
157
+ @router.post("/api/admin/change-password")
158
+ async def change_password(
159
+ request: ChangePasswordRequest,
160
+ token: str = Depends(verify_admin_token)
161
+ ):
162
+ """Change admin password"""
163
+ admin_config = await db.get_admin_config()
164
+
165
+ # Verify old password
166
+ if not AuthManager.verify_admin(admin_config.username, request.old_password):
167
+ raise HTTPException(status_code=400, detail="旧密码错误")
168
+
169
+ # Update password and username in database
170
+ update_params = {"password": request.new_password}
171
+ if request.username:
172
+ update_params["username"] = request.username
173
+
174
+ await db.update_admin_config(**update_params)
175
+
176
+ # 🔥 Hot reload: sync database config to memory
177
+ await db.reload_config_to_memory()
178
+
179
+ # 🔑 Invalidate all admin session tokens (force re-login for security)
180
+ active_admin_tokens.clear()
181
+
182
+ return {"success": True, "message": "密码修改成功,请重新登录"}
183
+
184
+
185
+ # ========== Token Management ==========
186
+
187
+ @router.get("/api/tokens")
188
+ async def get_tokens(token: str = Depends(verify_admin_token)):
189
+ """Get all tokens with statistics"""
190
+ tokens = await token_manager.get_all_tokens()
191
+ result = []
192
+
193
+ for t in tokens:
194
+ stats = await db.get_token_stats(t.id)
195
+
196
+ result.append({
197
+ "id": t.id,
198
+ "st": t.st, # Session Token for editing
199
+ "at": t.at, # Access Token for editing (从ST转换而来)
200
+ "at_expires": t.at_expires.isoformat() if t.at_expires else None, # 🆕 AT过期时间
201
+ "token": t.at, # 兼容前端 token.token 的访问方式
202
+ "email": t.email,
203
+ "name": t.name,
204
+ "remark": t.remark,
205
+ "is_active": t.is_active,
206
+ "created_at": t.created_at.isoformat() if t.created_at else None,
207
+ "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
208
+ "use_count": t.use_count,
209
+ "credits": t.credits, # 🆕 余额
210
+ "user_paygate_tier": t.user_paygate_tier,
211
+ "current_project_id": t.current_project_id, # 🆕 项目ID
212
+ "current_project_name": t.current_project_name, # 🆕 项目名称
213
+ "image_enabled": t.image_enabled,
214
+ "video_enabled": t.video_enabled,
215
+ "image_concurrency": t.image_concurrency,
216
+ "video_concurrency": t.video_concurrency,
217
+ "image_count": stats.image_count if stats else 0,
218
+ "video_count": stats.video_count if stats else 0,
219
+ "error_count": stats.error_count if stats else 0
220
+ })
221
+
222
+ return result # 直接返回数组,兼容前端
223
+
224
+
225
+ @router.post("/api/tokens")
226
+ async def add_token(
227
+ request: AddTokenRequest,
228
+ token: str = Depends(verify_admin_token)
229
+ ):
230
+ """Add a new token"""
231
+ try:
232
+ new_token = await token_manager.add_token(
233
+ st=request.st,
234
+ project_id=request.project_id, # 🆕 支持用户指定project_id
235
+ project_name=request.project_name,
236
+ remark=request.remark,
237
+ image_enabled=request.image_enabled,
238
+ video_enabled=request.video_enabled,
239
+ image_concurrency=request.image_concurrency,
240
+ video_concurrency=request.video_concurrency
241
+ )
242
+
243
+ return {
244
+ "success": True,
245
+ "message": "Token添加成功",
246
+ "token": {
247
+ "id": new_token.id,
248
+ "email": new_token.email,
249
+ "credits": new_token.credits,
250
+ "project_id": new_token.current_project_id,
251
+ "project_name": new_token.current_project_name
252
+ }
253
+ }
254
+ except ValueError as e:
255
+ raise HTTPException(status_code=400, detail=str(e))
256
+ except Exception as e:
257
+ raise HTTPException(status_code=500, detail=f"添加Token失败: {str(e)}")
258
+
259
+
260
+ @router.put("/api/tokens/{token_id}")
261
+ async def update_token(
262
+ token_id: int,
263
+ request: UpdateTokenRequest,
264
+ token: str = Depends(verify_admin_token)
265
+ ):
266
+ """Update token - 使用ST自动刷新AT"""
267
+ try:
268
+ # 先ST转AT
269
+ result = await token_manager.flow_client.st_to_at(request.st)
270
+ at = result["access_token"]
271
+ expires = result.get("expires")
272
+
273
+ # 解析过期时间
274
+ from datetime import datetime
275
+ at_expires = None
276
+ if expires:
277
+ try:
278
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
279
+ except:
280
+ pass
281
+
282
+ # 更新token (包含AT、ST、AT过期时间、project_id和project_name)
283
+ await token_manager.update_token(
284
+ token_id=token_id,
285
+ st=request.st,
286
+ at=at,
287
+ at_expires=at_expires, # 🆕 更新AT过期时间
288
+ project_id=request.project_id,
289
+ project_name=request.project_name,
290
+ remark=request.remark,
291
+ image_enabled=request.image_enabled,
292
+ video_enabled=request.video_enabled,
293
+ image_concurrency=request.image_concurrency,
294
+ video_concurrency=request.video_concurrency
295
+ )
296
+
297
+ return {"success": True, "message": "Token更新成功"}
298
+ except Exception as e:
299
+ raise HTTPException(status_code=500, detail=str(e))
300
+
301
+
302
+ @router.delete("/api/tokens/{token_id}")
303
+ async def delete_token(
304
+ token_id: int,
305
+ token: str = Depends(verify_admin_token)
306
+ ):
307
+ """Delete token"""
308
+ try:
309
+ await token_manager.delete_token(token_id)
310
+ return {"success": True, "message": "Token删除成功"}
311
+ except Exception as e:
312
+ raise HTTPException(status_code=500, detail=str(e))
313
+
314
+
315
+ @router.post("/api/tokens/{token_id}/enable")
316
+ async def enable_token(
317
+ token_id: int,
318
+ token: str = Depends(verify_admin_token)
319
+ ):
320
+ """Enable token"""
321
+ await token_manager.enable_token(token_id)
322
+ return {"success": True, "message": "Token已启用"}
323
+
324
+
325
+ @router.post("/api/tokens/{token_id}/disable")
326
+ async def disable_token(
327
+ token_id: int,
328
+ token: str = Depends(verify_admin_token)
329
+ ):
330
+ """Disable token"""
331
+ await token_manager.disable_token(token_id)
332
+ return {"success": True, "message": "Token已禁用"}
333
+
334
+
335
+ @router.post("/api/tokens/{token_id}/refresh-credits")
336
+ async def refresh_credits(
337
+ token_id: int,
338
+ token: str = Depends(verify_admin_token)
339
+ ):
340
+ """刷新Token余额 🆕"""
341
+ try:
342
+ credits = await token_manager.refresh_credits(token_id)
343
+ return {
344
+ "success": True,
345
+ "message": "余额刷新成功",
346
+ "credits": credits
347
+ }
348
+ except Exception as e:
349
+ raise HTTPException(status_code=500, detail=f"刷新余额失败: {str(e)}")
350
+
351
+
352
+ @router.post("/api/tokens/{token_id}/refresh-at")
353
+ async def refresh_at(
354
+ token_id: int,
355
+ token: str = Depends(verify_admin_token)
356
+ ):
357
+ """手动刷新Token的AT (使用ST转换) 🆕"""
358
+ try:
359
+ # 调用token_manager的内部刷新方法
360
+ success = await token_manager._refresh_at(token_id)
361
+
362
+ if success:
363
+ # 获取更新后的token信息
364
+ updated_token = await token_manager.get_token(token_id)
365
+ return {
366
+ "success": True,
367
+ "message": "AT刷新成功",
368
+ "token": {
369
+ "id": updated_token.id,
370
+ "email": updated_token.email,
371
+ "at_expires": updated_token.at_expires.isoformat() if updated_token.at_expires else None
372
+ }
373
+ }
374
+ else:
375
+ raise HTTPException(status_code=500, detail="AT刷新失败")
376
+ except Exception as e:
377
+ raise HTTPException(status_code=500, detail=f"刷新AT失败: {str(e)}")
378
+
379
+
380
+ @router.post("/api/tokens/st2at")
381
+ async def st_to_at(
382
+ request: ST2ATRequest,
383
+ token: str = Depends(verify_admin_token)
384
+ ):
385
+ """Convert Session Token to Access Token (仅转换,不添加到数据库)"""
386
+ try:
387
+ result = await token_manager.flow_client.st_to_at(request.st)
388
+ return {
389
+ "success": True,
390
+ "message": "ST converted to AT successfully",
391
+ "access_token": result["access_token"],
392
+ "email": result.get("user", {}).get("email"),
393
+ "expires": result.get("expires")
394
+ }
395
+ except Exception as e:
396
+ raise HTTPException(status_code=400, detail=str(e))
397
+
398
+
399
+ @router.post("/api/tokens/import")
400
+ async def import_tokens(
401
+ request: ImportTokensRequest,
402
+ token: str = Depends(verify_admin_token)
403
+ ):
404
+ """批量导入Token"""
405
+ from datetime import datetime, timezone
406
+
407
+ added = 0
408
+ updated = 0
409
+ errors = []
410
+
411
+ for idx, item in enumerate(request.tokens):
412
+ try:
413
+ st = item.session_token
414
+
415
+ if not st:
416
+ errors.append(f"第{idx+1}项: 缺少 session_token")
417
+ continue
418
+
419
+ # 使用 ST 转 AT 获取用户信息
420
+ try:
421
+ result = await token_manager.flow_client.st_to_at(st)
422
+ at = result["access_token"]
423
+ email = result.get("user", {}).get("email")
424
+ expires = result.get("expires")
425
+
426
+ if not email:
427
+ errors.append(f"第{idx+1}项: 无法获取邮箱信息")
428
+ continue
429
+
430
+ # 解析过期时间
431
+ at_expires = None
432
+ is_expired = False
433
+ if expires:
434
+ try:
435
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
436
+ # 判断是否过期
437
+ now = datetime.now(timezone.utc)
438
+ is_expired = at_expires <= now
439
+ except:
440
+ pass
441
+
442
+ # 使用邮箱检查是否已存在
443
+ existing_tokens = await token_manager.get_all_tokens()
444
+ existing = next((t for t in existing_tokens if t.email == email), None)
445
+
446
+ if existing:
447
+ # 更新现有Token
448
+ await token_manager.update_token(
449
+ token_id=existing.id,
450
+ st=st,
451
+ at=at,
452
+ at_expires=at_expires,
453
+ image_enabled=item.image_enabled,
454
+ video_enabled=item.video_enabled,
455
+ image_concurrency=item.image_concurrency,
456
+ video_concurrency=item.video_concurrency
457
+ )
458
+ # 如果过期则禁用
459
+ if is_expired:
460
+ await token_manager.disable_token(existing.id)
461
+ updated += 1
462
+ else:
463
+ # 添加新Token
464
+ new_token = await token_manager.add_token(
465
+ st=st,
466
+ image_enabled=item.image_enabled,
467
+ video_enabled=item.video_enabled,
468
+ image_concurrency=item.image_concurrency,
469
+ video_concurrency=item.video_concurrency
470
+ )
471
+ # 如果过期则禁用
472
+ if is_expired:
473
+ await token_manager.disable_token(new_token.id)
474
+ added += 1
475
+
476
+ except Exception as e:
477
+ errors.append(f"第{idx+1}项: {str(e)}")
478
+
479
+ except Exception as e:
480
+ errors.append(f"第{idx+1}项: {str(e)}")
481
+
482
+ return {
483
+ "success": True,
484
+ "added": added,
485
+ "updated": updated,
486
+ "errors": errors if errors else None,
487
+ "message": f"导入完成: 新增 {added} 个, 更新 {updated} 个" + (f", {len(errors)} 个失败" if errors else "")
488
+ }
489
+
490
+
491
+ # ========== Config Management ==========
492
+
493
+ @router.get("/api/config/proxy")
494
+ async def get_proxy_config(token: str = Depends(verify_admin_token)):
495
+ """Get proxy configuration"""
496
+ config = await proxy_manager.get_proxy_config()
497
+ return {
498
+ "success": True,
499
+ "config": {
500
+ "enabled": config.enabled,
501
+ "proxy_url": config.proxy_url
502
+ }
503
+ }
504
+
505
+
506
+ @router.get("/api/proxy/config")
507
+ async def get_proxy_config_alias(token: str = Depends(verify_admin_token)):
508
+ """Get proxy configuration (alias for frontend compatibility)"""
509
+ config = await proxy_manager.get_proxy_config()
510
+ return {
511
+ "proxy_enabled": config.enabled, # Frontend expects proxy_enabled
512
+ "proxy_url": config.proxy_url
513
+ }
514
+
515
+
516
+ @router.post("/api/proxy/config")
517
+ async def update_proxy_config_alias(
518
+ request: ProxyConfigRequest,
519
+ token: str = Depends(verify_admin_token)
520
+ ):
521
+ """Update proxy configuration (alias for frontend compatibility)"""
522
+ await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
523
+ return {"success": True, "message": "代理配置更新成功"}
524
+
525
+
526
+ @router.post("/api/config/proxy")
527
+ async def update_proxy_config(
528
+ request: ProxyConfigRequest,
529
+ token: str = Depends(verify_admin_token)
530
+ ):
531
+ """Update proxy configuration"""
532
+ await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
533
+ return {"success": True, "message": "代理配置更新成功"}
534
+
535
+
536
+ @router.get("/api/config/generation")
537
+ async def get_generation_config(token: str = Depends(verify_admin_token)):
538
+ """Get generation timeout configuration"""
539
+ config = await db.get_generation_config()
540
+ return {
541
+ "success": True,
542
+ "config": {
543
+ "image_timeout": config.image_timeout,
544
+ "video_timeout": config.video_timeout
545
+ }
546
+ }
547
+
548
+
549
+ @router.post("/api/config/generation")
550
+ async def update_generation_config(
551
+ request: GenerationConfigRequest,
552
+ token: str = Depends(verify_admin_token)
553
+ ):
554
+ """Update generation timeout configuration"""
555
+ await db.update_generation_config(request.image_timeout, request.video_timeout)
556
+
557
+ # 🔥 Hot reload: sync database config to memory
558
+ await db.reload_config_to_memory()
559
+
560
+ return {"success": True, "message": "生成配置更新成功"}
561
+
562
+
563
+ # ========== System Info ==========
564
+
565
+ @router.get("/api/system/info")
566
+ async def get_system_info(token: str = Depends(verify_admin_token)):
567
+ """Get system information"""
568
+ tokens = await token_manager.get_all_tokens()
569
+ active_tokens = [t for t in tokens if t.is_active]
570
+
571
+ total_credits = sum(t.credits for t in active_tokens)
572
+
573
+ return {
574
+ "success": True,
575
+ "info": {
576
+ "total_tokens": len(tokens),
577
+ "active_tokens": len(active_tokens),
578
+ "total_credits": total_credits,
579
+ "version": "1.0.0"
580
+ }
581
+ }
582
+
583
+
584
+ # ========== Additional Routes for Frontend Compatibility ==========
585
+
586
+ @router.post("/api/login")
587
+ async def login(request: LoginRequest):
588
+ """Login endpoint (alias for /api/admin/login)"""
589
+ return await admin_login(request)
590
+
591
+
592
+ @router.post("/api/logout")
593
+ async def logout(token: str = Depends(verify_admin_token)):
594
+ """Logout endpoint (alias for /api/admin/logout)"""
595
+ return await admin_logout(token)
596
+
597
+
598
+ @router.get("/api/stats")
599
+ async def get_stats(token: str = Depends(verify_admin_token)):
600
+ """Get statistics for dashboard"""
601
+ tokens = await token_manager.get_all_tokens()
602
+ active_tokens = [t for t in tokens if t.is_active]
603
+
604
+ # Calculate totals
605
+ total_images = 0
606
+ total_videos = 0
607
+ total_errors = 0
608
+ today_images = 0
609
+ today_videos = 0
610
+ today_errors = 0
611
+
612
+ for t in tokens:
613
+ stats = await db.get_token_stats(t.id)
614
+ if stats:
615
+ total_images += stats.image_count
616
+ total_videos += stats.video_count
617
+ total_errors += stats.error_count # Historical total errors
618
+ today_images += stats.today_image_count
619
+ today_videos += stats.today_video_count
620
+ today_errors += stats.today_error_count
621
+
622
+ return {
623
+ "total_tokens": len(tokens),
624
+ "active_tokens": len(active_tokens),
625
+ "total_images": total_images,
626
+ "total_videos": total_videos,
627
+ "total_errors": total_errors,
628
+ "today_images": today_images,
629
+ "today_videos": today_videos,
630
+ "today_errors": today_errors
631
+ }
632
+
633
+
634
+ @router.get("/api/logs")
635
+ async def get_logs(
636
+ limit: int = 100,
637
+ token: str = Depends(verify_admin_token)
638
+ ):
639
+ """Get request logs with token email"""
640
+ logs = await db.get_logs(limit=limit)
641
+
642
+ return [{
643
+ "id": log.get("id"),
644
+ "token_id": log.get("token_id"),
645
+ "token_email": log.get("token_email"),
646
+ "token_username": log.get("token_username"),
647
+ "operation": log.get("operation"),
648
+ "status_code": log.get("status_code"),
649
+ "duration": log.get("duration"),
650
+ "created_at": log.get("created_at"),
651
+ "request_body": log.get("request_body"),
652
+ "response_body": log.get("response_body")
653
+ } for log in logs]
654
+
655
+
656
+ @router.delete("/api/logs")
657
+ async def clear_logs(token: str = Depends(verify_admin_token)):
658
+ """Clear all logs"""
659
+ try:
660
+ await db.clear_all_logs()
661
+ return {"success": True, "message": "所有日志已清空"}
662
+ except Exception as e:
663
+ raise HTTPException(status_code=500, detail=str(e))
664
+
665
+
666
+ @router.get("/api/admin/config")
667
+ async def get_admin_config(token: str = Depends(verify_admin_token)):
668
+ """Get admin configuration"""
669
+ admin_config = await db.get_admin_config()
670
+
671
+ return {
672
+ "admin_username": admin_config.username,
673
+ "api_key": admin_config.api_key,
674
+ "error_ban_threshold": admin_config.error_ban_threshold,
675
+ "debug_enabled": config.debug_enabled # Return actual debug status
676
+ }
677
+
678
+
679
+ @router.post("/api/admin/config")
680
+ async def update_admin_config(
681
+ request: UpdateAdminConfigRequest,
682
+ token: str = Depends(verify_admin_token)
683
+ ):
684
+ """Update admin configuration (error_ban_threshold)"""
685
+ # Update error_ban_threshold in database
686
+ await db.update_admin_config(error_ban_threshold=request.error_ban_threshold)
687
+
688
+ return {"success": True, "message": "配置更新成功"}
689
+
690
+
691
+ @router.post("/api/admin/password")
692
+ async def update_admin_password(
693
+ request: ChangePasswordRequest,
694
+ token: str = Depends(verify_admin_token)
695
+ ):
696
+ """Update admin password"""
697
+ return await change_password(request, token)
698
+
699
+
700
+ @router.post("/api/admin/apikey")
701
+ async def update_api_key(
702
+ request: UpdateAPIKeyRequest,
703
+ token: str = Depends(verify_admin_token)
704
+ ):
705
+ """Update API key (for external API calls, NOT for admin login)"""
706
+ # Update API key in database
707
+ await db.update_admin_config(api_key=request.new_api_key)
708
+
709
+ # 🔥 Hot reload: sync database config to memory
710
+ await db.reload_config_to_memory()
711
+
712
+ return {"success": True, "message": "API Key更新成功"}
713
+
714
+
715
+ @router.post("/api/admin/debug")
716
+ async def update_debug_config(
717
+ request: UpdateDebugConfigRequest,
718
+ token: str = Depends(verify_admin_token)
719
+ ):
720
+ """Update debug configuration"""
721
+ try:
722
+ # Update in-memory config only (not database)
723
+ # This ensures debug mode is automatically disabled on restart
724
+ config.set_debug_enabled(request.enabled)
725
+
726
+ status = "enabled" if request.enabled else "disabled"
727
+ return {"success": True, "message": f"Debug mode {status}", "enabled": request.enabled}
728
+ except Exception as e:
729
+ raise HTTPException(status_code=500, detail=f"Failed to update debug config: {str(e)}")
730
+
731
+
732
+ @router.get("/api/generation/timeout")
733
+ async def get_generation_timeout(token: str = Depends(verify_admin_token)):
734
+ """Get generation timeout configuration"""
735
+ return await get_generation_config(token)
736
+
737
+
738
+ @router.post("/api/generation/timeout")
739
+ async def update_generation_timeout(
740
+ request: GenerationConfigRequest,
741
+ token: str = Depends(verify_admin_token)
742
+ ):
743
+ """Update generation timeout configuration"""
744
+ await db.update_generation_config(request.image_timeout, request.video_timeout)
745
+
746
+ # 🔥 Hot reload: sync database config to memory
747
+ await db.reload_config_to_memory()
748
+
749
+ return {"success": True, "message": "生成配置更新成功"}
750
+
751
+
752
+ # ========== AT Auto Refresh Config ==========
753
+
754
+ @router.get("/api/token-refresh/config")
755
+ async def get_token_refresh_config(token: str = Depends(verify_admin_token)):
756
+ """Get AT auto refresh configuration (默认启用)"""
757
+ return {
758
+ "success": True,
759
+ "config": {
760
+ "at_auto_refresh_enabled": True # Flow2API默认启用AT自动刷新
761
+ }
762
+ }
763
+
764
+
765
+ @router.post("/api/token-refresh/enabled")
766
+ async def update_token_refresh_enabled(
767
+ token: str = Depends(verify_admin_token)
768
+ ):
769
+ """Update AT auto refresh enabled (Flow2API固定启用,此接口仅用于前端兼容)"""
770
+ return {
771
+ "success": True,
772
+ "message": "Flow2API的AT自动刷新默认启用且无法关闭"
773
+ }
774
+
775
+
776
+ # ========== Cache Configuration Endpoints ==========
777
+
778
+ @router.get("/api/cache/config")
779
+ async def get_cache_config(token: str = Depends(verify_admin_token)):
780
+ """Get cache configuration"""
781
+ cache_config = await db.get_cache_config()
782
+
783
+ # Calculate effective base URL
784
+ effective_base_url = cache_config.cache_base_url if cache_config.cache_base_url else f"http://127.0.0.1:8000"
785
+
786
+ return {
787
+ "success": True,
788
+ "config": {
789
+ "enabled": cache_config.cache_enabled,
790
+ "timeout": cache_config.cache_timeout,
791
+ "base_url": cache_config.cache_base_url or "",
792
+ "effective_base_url": effective_base_url
793
+ }
794
+ }
795
+
796
+
797
+ @router.post("/api/cache/enabled")
798
+ async def update_cache_enabled(
799
+ request: dict,
800
+ token: str = Depends(verify_admin_token)
801
+ ):
802
+ """Update cache enabled status"""
803
+ enabled = request.get("enabled", False)
804
+ await db.update_cache_config(enabled=enabled)
805
+
806
+ # 🔥 Hot reload: sync database config to memory
807
+ await db.reload_config_to_memory()
808
+
809
+ return {"success": True, "message": f"缓存已{'启用' if enabled else '禁用'}"}
810
+
811
+
812
+ @router.post("/api/cache/config")
813
+ async def update_cache_config_full(
814
+ request: dict,
815
+ token: str = Depends(verify_admin_token)
816
+ ):
817
+ """Update complete cache configuration"""
818
+ enabled = request.get("enabled")
819
+ timeout = request.get("timeout")
820
+ base_url = request.get("base_url")
821
+
822
+ await db.update_cache_config(enabled=enabled, timeout=timeout, base_url=base_url)
823
+
824
+ # 🔥 Hot reload: sync database config to memory
825
+ await db.reload_config_to_memory()
826
+
827
+ return {"success": True, "message": "缓存配置更新成功"}
828
+
829
+
830
+ @router.post("/api/cache/base-url")
831
+ async def update_cache_base_url(
832
+ request: dict,
833
+ token: str = Depends(verify_admin_token)
834
+ ):
835
+ """Update cache base URL"""
836
+ base_url = request.get("base_url", "")
837
+ await db.update_cache_config(base_url=base_url)
838
+
839
+ # 🔥 Hot reload: sync database config to memory
840
+ await db.reload_config_to_memory()
841
+
842
+ return {"success": True, "message": "缓存Base URL更新成功"}
843
+
844
+
845
+ @router.post("/api/captcha/config")
846
+ async def update_captcha_config(
847
+ request: dict,
848
+ token: str = Depends(verify_admin_token)
849
+ ):
850
+ """Update captcha configuration"""
851
+ from ..services.browser_captcha import validate_browser_proxy_url
852
+
853
+ captcha_method = request.get("captcha_method")
854
+ yescaptcha_api_key = request.get("yescaptcha_api_key")
855
+ yescaptcha_base_url = request.get("yescaptcha_base_url")
856
+ browser_proxy_enabled = request.get("browser_proxy_enabled", False)
857
+ browser_proxy_url = request.get("browser_proxy_url", "")
858
+
859
+ # 验证浏览器代理URL格式
860
+ if browser_proxy_enabled and browser_proxy_url:
861
+ is_valid, error_msg = validate_browser_proxy_url(browser_proxy_url)
862
+ if not is_valid:
863
+ return {"success": False, "message": error_msg}
864
+
865
+ await db.update_captcha_config(
866
+ captcha_method=captcha_method,
867
+ yescaptcha_api_key=yescaptcha_api_key,
868
+ yescaptcha_base_url=yescaptcha_base_url,
869
+ browser_proxy_enabled=browser_proxy_enabled,
870
+ browser_proxy_url=browser_proxy_url if browser_proxy_enabled else None
871
+ )
872
+
873
+ # 🔥 Hot reload: sync database config to memory
874
+ await db.reload_config_to_memory()
875
+
876
+ return {"success": True, "message": "验证码配置更新成功"}
877
+
878
+
879
+ @router.get("/api/captcha/config")
880
+ async def get_captcha_config(token: str = Depends(verify_admin_token)):
881
+ """Get captcha configuration"""
882
+ captcha_config = await db.get_captcha_config()
883
+ return {
884
+ "captcha_method": captcha_config.captcha_method,
885
+ "yescaptcha_api_key": captcha_config.yescaptcha_api_key,
886
+ "yescaptcha_base_url": captcha_config.yescaptcha_base_url,
887
+ "browser_proxy_enabled": captcha_config.browser_proxy_enabled,
888
+ "browser_proxy_url": captcha_config.browser_proxy_url or ""
889
+ }
890
+
891
+
892
+ # ========== Plugin Configuration Endpoints ==========
893
+
894
+ @router.get("/api/plugin/config")
895
+ async def get_plugin_config(request: Request, token: str = Depends(verify_admin_token)):
896
+ """Get plugin configuration"""
897
+ plugin_config = await db.get_plugin_config()
898
+
899
+ # Get the actual domain and port from the request
900
+ # This allows the connection URL to reflect the user's actual access path
901
+ host_header = request.headers.get("host", "")
902
+
903
+ # Generate connection URL based on actual request
904
+ if host_header:
905
+ # Use the actual domain/IP and port from the request
906
+ connection_url = f"http://{host_header}/api/plugin/update-token"
907
+ else:
908
+ # Fallback to config-based URL
909
+ from ..core.config import config
910
+ server_host = config.server_host
911
+ server_port = config.server_port
912
+
913
+ if server_host == "0.0.0.0":
914
+ connection_url = f"http://127.0.0.1:{server_port}/api/plugin/update-token"
915
+ else:
916
+ connection_url = f"http://{server_host}:{server_port}/api/plugin/update-token"
917
+
918
+ return {
919
+ "success": True,
920
+ "config": {
921
+ "connection_token": plugin_config.connection_token,
922
+ "connection_url": connection_url,
923
+ "auto_enable_on_update": plugin_config.auto_enable_on_update
924
+ }
925
+ }
926
+
927
+
928
+ @router.post("/api/plugin/config")
929
+ async def update_plugin_config(
930
+ request: dict,
931
+ token: str = Depends(verify_admin_token)
932
+ ):
933
+ """Update plugin configuration"""
934
+ connection_token = request.get("connection_token", "")
935
+ auto_enable_on_update = request.get("auto_enable_on_update", True) # 默认开启
936
+
937
+ # Generate random token if empty
938
+ if not connection_token:
939
+ connection_token = secrets.token_urlsafe(32)
940
+
941
+ await db.update_plugin_config(
942
+ connection_token=connection_token,
943
+ auto_enable_on_update=auto_enable_on_update
944
+ )
945
+
946
+ return {
947
+ "success": True,
948
+ "message": "插件配置更新成功",
949
+ "connection_token": connection_token,
950
+ "auto_enable_on_update": auto_enable_on_update
951
+ }
952
+
953
+
954
+ @router.post("/api/plugin/update-token")
955
+ async def plugin_update_token(request: dict, authorization: Optional[str] = Header(None)):
956
+ """Receive token update from Chrome extension (no admin auth required, uses connection_token)"""
957
+ # Verify connection token
958
+ plugin_config = await db.get_plugin_config()
959
+
960
+ # Extract token from Authorization header
961
+ provided_token = None
962
+ if authorization:
963
+ if authorization.startswith("Bearer "):
964
+ provided_token = authorization[7:]
965
+ else:
966
+ provided_token = authorization
967
+
968
+ # Check if token matches
969
+ if not plugin_config.connection_token or provided_token != plugin_config.connection_token:
970
+ raise HTTPException(status_code=401, detail="Invalid connection token")
971
+
972
+ # Extract session token from request
973
+ session_token = request.get("session_token")
974
+
975
+ if not session_token:
976
+ raise HTTPException(status_code=400, detail="Missing session_token")
977
+
978
+ # Step 1: Convert ST to AT to get user info (including email)
979
+ try:
980
+ result = await token_manager.flow_client.st_to_at(session_token)
981
+ at = result["access_token"]
982
+ expires = result.get("expires")
983
+ user_info = result.get("user", {})
984
+ email = user_info.get("email", "")
985
+
986
+ if not email:
987
+ raise HTTPException(status_code=400, detail="Failed to get email from session token")
988
+
989
+ # Parse expiration time
990
+ from datetime import datetime
991
+ at_expires = None
992
+ if expires:
993
+ try:
994
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
995
+ except:
996
+ pass
997
+
998
+ except Exception as e:
999
+ raise HTTPException(status_code=400, detail=f"Invalid session token: {str(e)}")
1000
+
1001
+ # Step 2: Check if token with this email exists
1002
+ existing_token = await db.get_token_by_email(email)
1003
+
1004
+ if existing_token:
1005
+ # Update existing token
1006
+ try:
1007
+ # Update token
1008
+ await token_manager.update_token(
1009
+ token_id=existing_token.id,
1010
+ st=session_token,
1011
+ at=at,
1012
+ at_expires=at_expires
1013
+ )
1014
+
1015
+ # Check if auto-enable is enabled and token is disabled
1016
+ if plugin_config.auto_enable_on_update and not existing_token.is_active:
1017
+ await token_manager.enable_token(existing_token.id)
1018
+ return {
1019
+ "success": True,
1020
+ "message": f"Token updated and auto-enabled for {email}",
1021
+ "action": "updated",
1022
+ "auto_enabled": True
1023
+ }
1024
+
1025
+ return {
1026
+ "success": True,
1027
+ "message": f"Token updated for {email}",
1028
+ "action": "updated"
1029
+ }
1030
+ except Exception as e:
1031
+ raise HTTPException(status_code=500, detail=f"Failed to update token: {str(e)}")
1032
+ else:
1033
+ # Add new token
1034
+ try:
1035
+ new_token = await token_manager.add_token(
1036
+ st=session_token,
1037
+ remark="Added by Chrome Extension"
1038
+ )
1039
+
1040
+ return {
1041
+ "success": True,
1042
+ "message": f"Token added for {new_token.email}",
1043
+ "action": "added",
1044
+ "token_id": new_token.id
1045
+ }
1046
+ except Exception as e:
1047
+ raise HTTPException(status_code=500, detail=f"Failed to add token: {str(e)}")
src/api/routes.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, Optional
5
+ import base64
6
+ import re
7
+ import json
8
+ import time
9
+ from urllib.parse import urlparse
10
+ from curl_cffi.requests import AsyncSession
11
+ from ..core.auth import verify_api_key_header
12
+ from ..core.models import ChatCompletionRequest
13
+ from ..services.generation_handler import GenerationHandler, MODEL_CONFIG
14
+ from ..core.logger import debug_logger
15
+
16
+ router = APIRouter()
17
+
18
+ # Dependency injection will be set up in main.py
19
+ generation_handler: GenerationHandler = None
20
+
21
+
22
+ def set_generation_handler(handler: GenerationHandler):
23
+ """Set generation handler instance"""
24
+ global generation_handler
25
+ generation_handler = handler
26
+
27
+
28
+ async def retrieve_image_data(url: str) -> Optional[bytes]:
29
+ """
30
+ 智能获取图片数据:
31
+ 1. 优先检查是否为本地 /tmp/ 缓存文件,如果是则直接读取磁盘
32
+ 2. 如果本地不存在或是外部链接,则进行网络下载
33
+ """
34
+ # 优先尝试本地读取
35
+ try:
36
+ if "/tmp/" in url and generation_handler and generation_handler.file_cache:
37
+ path = urlparse(url).path
38
+ filename = path.split("/tmp/")[-1]
39
+ local_file_path = generation_handler.file_cache.cache_dir / filename
40
+
41
+ if local_file_path.exists() and local_file_path.is_file():
42
+ data = local_file_path.read_bytes()
43
+ if data:
44
+ return data
45
+ except Exception as e:
46
+ debug_logger.log_warning(f"[CONTEXT] 本地缓存读取失败: {str(e)}")
47
+
48
+ # 回退逻辑:网络下载
49
+ try:
50
+ async with AsyncSession() as session:
51
+ response = await session.get(url, timeout=30, impersonate="chrome110", verify=False)
52
+ if response.status_code == 200:
53
+ return response.content
54
+ else:
55
+ debug_logger.log_warning(f"[CONTEXT] 图片下载失败,状态码: {response.status_code}")
56
+ except Exception as e:
57
+ debug_logger.log_error(f"[CONTEXT] 图片下载异常: {str(e)}")
58
+
59
+ return None
60
+
61
+
62
+ @router.get("/v1/models")
63
+ async def list_models(api_key: str = Depends(verify_api_key_header)):
64
+ """List available models"""
65
+ models = []
66
+
67
+ for model_id, config in MODEL_CONFIG.items():
68
+ description = f"{config['type'].capitalize()} generation"
69
+ if config['type'] == 'image':
70
+ description += f" - {config['model_name']}"
71
+ else:
72
+ description += f" - {config['model_key']}"
73
+
74
+ models.append({
75
+ "id": model_id,
76
+ "object": "model",
77
+ "owned_by": "flow2api",
78
+ "description": description
79
+ })
80
+
81
+ return {
82
+ "object": "list",
83
+ "data": models
84
+ }
85
+
86
+
87
+ @router.post("/v1/chat/completions")
88
+ async def create_chat_completion(
89
+ request: ChatCompletionRequest,
90
+ api_key: str = Depends(verify_api_key_header)
91
+ ):
92
+ """Create chat completion (unified endpoint for image and video generation)"""
93
+ try:
94
+ # Extract prompt from messages
95
+ if not request.messages:
96
+ raise HTTPException(status_code=400, detail="Messages cannot be empty")
97
+
98
+ last_message = request.messages[-1]
99
+ content = last_message.content
100
+
101
+ # Handle both string and array format (OpenAI multimodal)
102
+ prompt = ""
103
+ images: List[bytes] = []
104
+
105
+ if isinstance(content, str):
106
+ # Simple text format
107
+ prompt = content
108
+ elif isinstance(content, list):
109
+ # Multimodal format
110
+ for item in content:
111
+ if item.get("type") == "text":
112
+ prompt = item.get("text", "")
113
+ elif item.get("type") == "image_url":
114
+ # Extract base64 image
115
+ image_url = item.get("image_url", {}).get("url", "")
116
+ if image_url.startswith("data:image"):
117
+ # Parse base64
118
+ match = re.search(r"base64,(.+)", image_url)
119
+ if match:
120
+ image_base64 = match.group(1)
121
+ image_bytes = base64.b64decode(image_base64)
122
+ images.append(image_bytes)
123
+
124
+ # Fallback to deprecated image parameter
125
+ if request.image and not images:
126
+ if request.image.startswith("data:image"):
127
+ match = re.search(r"base64,(.+)", request.image)
128
+ if match:
129
+ image_base64 = match.group(1)
130
+ image_bytes = base64.b64decode(image_base64)
131
+ images.append(image_bytes)
132
+
133
+ # 自动参考图:仅对图片模型生效
134
+ model_config = MODEL_CONFIG.get(request.model)
135
+
136
+ if model_config and model_config["type"] == "image" and len(request.messages) > 1:
137
+ debug_logger.log_info(f"[CONTEXT] 开始查找历史参考图,消息数量: {len(request.messages)}")
138
+
139
+ # 查找上一次 assistant 回复的图片
140
+ for msg in reversed(request.messages[:-1]):
141
+ if msg.role == "assistant" and isinstance(msg.content, str):
142
+ # 匹配 Markdown 图片格式: ![...](http...)
143
+ matches = re.findall(r"!\[.*?\]\((.*?)\)", msg.content)
144
+ if matches:
145
+ last_image_url = matches[-1]
146
+
147
+ if last_image_url.startswith("http"):
148
+ try:
149
+ downloaded_bytes = await retrieve_image_data(last_image_url)
150
+ if downloaded_bytes and len(downloaded_bytes) > 0:
151
+ # 将历史图片插入到最前面
152
+ images.insert(0, downloaded_bytes)
153
+ debug_logger.log_info(f"[CONTEXT] ✅ 添加历史参考图: {last_image_url}")
154
+ break
155
+ else:
156
+ debug_logger.log_warning(f"[CONTEXT] 图片下载失败或为空,尝试下一个: {last_image_url}")
157
+ except Exception as e:
158
+ debug_logger.log_error(f"[CONTEXT] 处理参考图时出错: {str(e)}")
159
+ # 继续尝试下一个图片
160
+
161
+ if not prompt:
162
+ raise HTTPException(status_code=400, detail="Prompt cannot be empty")
163
+
164
+ # Call generation handler
165
+ if request.stream:
166
+ # Streaming response
167
+ async def generate():
168
+ async for chunk in generation_handler.handle_generation(
169
+ model=request.model,
170
+ prompt=prompt,
171
+ images=images if images else None,
172
+ stream=True
173
+ ):
174
+ yield chunk
175
+
176
+ # Send [DONE] signal
177
+ yield "data: [DONE]\n\n"
178
+
179
+ return StreamingResponse(
180
+ generate(),
181
+ media_type="text/event-stream",
182
+ headers={
183
+ "Cache-Control": "no-cache",
184
+ "Connection": "keep-alive",
185
+ "X-Accel-Buffering": "no"
186
+ }
187
+ )
188
+ else:
189
+ # Non-streaming response
190
+ result = None
191
+ async for chunk in generation_handler.handle_generation(
192
+ model=request.model,
193
+ prompt=prompt,
194
+ images=images if images else None,
195
+ stream=False
196
+ ):
197
+ result = chunk
198
+
199
+ if result:
200
+ # Parse the result JSON string
201
+ try:
202
+ result_json = json.loads(result)
203
+ return JSONResponse(content=result_json)
204
+ except json.JSONDecodeError:
205
+ # If not JSON, return as-is
206
+ return JSONResponse(content={"result": result})
207
+ else:
208
+ raise HTTPException(status_code=500, detail="Generation failed: No response from handler")
209
+
210
+ except HTTPException:
211
+ raise
212
+ except Exception as e:
213
+ 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,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Captcha configuration
183
+ @property
184
+ def captcha_method(self) -> str:
185
+ """Get captcha method"""
186
+ return self._config.get("captcha", {}).get("captcha_method", "yescaptcha")
187
+
188
+ def set_captcha_method(self, method: str):
189
+ """Set captcha method"""
190
+ if "captcha" not in self._config:
191
+ self._config["captcha"] = {}
192
+ self._config["captcha"]["captcha_method"] = method
193
+
194
+ @property
195
+ def yescaptcha_api_key(self) -> str:
196
+ """Get YesCaptcha API key"""
197
+ return self._config.get("captcha", {}).get("yescaptcha_api_key", "")
198
+
199
+ def set_yescaptcha_api_key(self, api_key: str):
200
+ """Set YesCaptcha API key"""
201
+ if "captcha" not in self._config:
202
+ self._config["captcha"] = {}
203
+ self._config["captcha"]["yescaptcha_api_key"] = api_key
204
+
205
+ @property
206
+ def yescaptcha_base_url(self) -> str:
207
+ """Get YesCaptcha base URL"""
208
+ return self._config.get("captcha", {}).get("yescaptcha_base_url", "https://api.yescaptcha.com")
209
+
210
+ def set_yescaptcha_base_url(self, base_url: str):
211
+ """Set YesCaptcha base URL"""
212
+ if "captcha" not in self._config:
213
+ self._config["captcha"] = {}
214
+ self._config["captcha"]["yescaptcha_base_url"] = base_url
215
+
216
+
217
+ # Global config instance
218
+ config = Config()
src/core/database.py ADDED
@@ -0,0 +1,1269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, CaptchaConfig, PluginConfig
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
+ error_ban_threshold = 3
59
+
60
+ if config_dict:
61
+ global_config = config_dict.get("global", {})
62
+ admin_username = global_config.get("admin_username", "admin")
63
+ admin_password = global_config.get("admin_password", "admin")
64
+ api_key = global_config.get("api_key", "han1234")
65
+
66
+ admin_config = config_dict.get("admin", {})
67
+ error_ban_threshold = admin_config.get("error_ban_threshold", 3)
68
+
69
+ await db.execute("""
70
+ INSERT INTO admin_config (id, username, password, api_key, error_ban_threshold)
71
+ VALUES (1, ?, ?, ?, ?)
72
+ """, (admin_username, admin_password, api_key, error_ban_threshold))
73
+
74
+ # Ensure proxy_config has a row
75
+ cursor = await db.execute("SELECT COUNT(*) FROM proxy_config")
76
+ count = await cursor.fetchone()
77
+ if count[0] == 0:
78
+ proxy_enabled = False
79
+ proxy_url = None
80
+
81
+ if config_dict:
82
+ proxy_config = config_dict.get("proxy", {})
83
+ proxy_enabled = proxy_config.get("proxy_enabled", False)
84
+ proxy_url = proxy_config.get("proxy_url", "")
85
+ proxy_url = proxy_url if proxy_url else None
86
+
87
+ await db.execute("""
88
+ INSERT INTO proxy_config (id, enabled, proxy_url)
89
+ VALUES (1, ?, ?)
90
+ """, (proxy_enabled, proxy_url))
91
+
92
+ # Ensure generation_config has a row
93
+ cursor = await db.execute("SELECT COUNT(*) FROM generation_config")
94
+ count = await cursor.fetchone()
95
+ if count[0] == 0:
96
+ image_timeout = 300
97
+ video_timeout = 1500
98
+
99
+ if config_dict:
100
+ generation_config = config_dict.get("generation", {})
101
+ image_timeout = generation_config.get("image_timeout", 300)
102
+ video_timeout = generation_config.get("video_timeout", 1500)
103
+
104
+ await db.execute("""
105
+ INSERT INTO generation_config (id, image_timeout, video_timeout)
106
+ VALUES (1, ?, ?)
107
+ """, (image_timeout, video_timeout))
108
+
109
+ # Ensure cache_config has a row
110
+ cursor = await db.execute("SELECT COUNT(*) FROM cache_config")
111
+ count = await cursor.fetchone()
112
+ if count[0] == 0:
113
+ cache_enabled = False
114
+ cache_timeout = 7200
115
+ cache_base_url = None
116
+
117
+ if config_dict:
118
+ cache_config = config_dict.get("cache", {})
119
+ cache_enabled = cache_config.get("enabled", False)
120
+ cache_timeout = cache_config.get("timeout", 7200)
121
+ cache_base_url = cache_config.get("base_url", "")
122
+ # Convert empty string to None
123
+ cache_base_url = cache_base_url if cache_base_url else None
124
+
125
+ await db.execute("""
126
+ INSERT INTO cache_config (id, cache_enabled, cache_timeout, cache_base_url)
127
+ VALUES (1, ?, ?, ?)
128
+ """, (cache_enabled, cache_timeout, cache_base_url))
129
+
130
+ # Ensure debug_config has a row
131
+ cursor = await db.execute("SELECT COUNT(*) FROM debug_config")
132
+ count = await cursor.fetchone()
133
+ if count[0] == 0:
134
+ debug_enabled = False
135
+ log_requests = True
136
+ log_responses = True
137
+ mask_token = True
138
+
139
+ if config_dict:
140
+ debug_config = config_dict.get("debug", {})
141
+ debug_enabled = debug_config.get("enabled", False)
142
+ log_requests = debug_config.get("log_requests", True)
143
+ log_responses = debug_config.get("log_responses", True)
144
+ mask_token = debug_config.get("mask_token", True)
145
+
146
+ await db.execute("""
147
+ INSERT INTO debug_config (id, enabled, log_requests, log_responses, mask_token)
148
+ VALUES (1, ?, ?, ?, ?)
149
+ """, (debug_enabled, log_requests, log_responses, mask_token))
150
+
151
+ # Ensure captcha_config has a row
152
+ cursor = await db.execute("SELECT COUNT(*) FROM captcha_config")
153
+ count = await cursor.fetchone()
154
+ if count[0] == 0:
155
+ captcha_method = "browser"
156
+ yescaptcha_api_key = ""
157
+ yescaptcha_base_url = "https://api.yescaptcha.com"
158
+
159
+ if config_dict:
160
+ captcha_config = config_dict.get("captcha", {})
161
+ captcha_method = captcha_config.get("captcha_method", "browser")
162
+ yescaptcha_api_key = captcha_config.get("yescaptcha_api_key", "")
163
+ yescaptcha_base_url = captcha_config.get("yescaptcha_base_url", "https://api.yescaptcha.com")
164
+
165
+ await db.execute("""
166
+ INSERT INTO captcha_config (id, captcha_method, yescaptcha_api_key, yescaptcha_base_url)
167
+ VALUES (1, ?, ?, ?)
168
+ """, (captcha_method, yescaptcha_api_key, yescaptcha_base_url))
169
+
170
+ # Ensure plugin_config has a row
171
+ cursor = await db.execute("SELECT COUNT(*) FROM plugin_config")
172
+ count = await cursor.fetchone()
173
+ if count[0] == 0:
174
+ await db.execute("""
175
+ INSERT INTO plugin_config (id, connection_token)
176
+ VALUES (1, '')
177
+ """)
178
+
179
+ async def check_and_migrate_db(self, config_dict: dict = None):
180
+ """Check database integrity and perform migrations if needed
181
+
182
+ This method is called during upgrade mode to:
183
+ 1. Create missing tables (if they don't exist)
184
+ 2. Add missing columns to existing tables
185
+ 3. Ensure all config tables have default rows
186
+
187
+ Args:
188
+ config_dict: Configuration dictionary from setting.toml (optional)
189
+ Used only to initialize missing config rows with default values.
190
+ Existing config rows will NOT be overwritten.
191
+ """
192
+ async with aiosqlite.connect(self.db_path) as db:
193
+ print("Checking database integrity and performing migrations...")
194
+
195
+ # ========== Step 1: Create missing tables ==========
196
+ # Check and create cache_config table if missing
197
+ if not await self._table_exists(db, "cache_config"):
198
+ print(" ✓ Creating missing table: cache_config")
199
+ await db.execute("""
200
+ CREATE TABLE cache_config (
201
+ id INTEGER PRIMARY KEY DEFAULT 1,
202
+ cache_enabled BOOLEAN DEFAULT 0,
203
+ cache_timeout INTEGER DEFAULT 7200,
204
+ cache_base_url TEXT,
205
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
206
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
207
+ )
208
+ """)
209
+
210
+ # Check and create captcha_config table if missing
211
+ if not await self._table_exists(db, "captcha_config"):
212
+ print(" ✓ Creating missing table: captcha_config")
213
+ await db.execute("""
214
+ CREATE TABLE captcha_config (
215
+ id INTEGER PRIMARY KEY DEFAULT 1,
216
+ captcha_method TEXT DEFAULT 'browser',
217
+ yescaptcha_api_key TEXT DEFAULT '',
218
+ yescaptcha_base_url TEXT DEFAULT 'https://api.yescaptcha.com',
219
+ website_key TEXT DEFAULT '6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV',
220
+ page_action TEXT DEFAULT 'FLOW_GENERATION',
221
+ browser_proxy_enabled BOOLEAN DEFAULT 0,
222
+ browser_proxy_url TEXT,
223
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
224
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
225
+ )
226
+ """)
227
+
228
+ # Check and create plugin_config table if missing
229
+ if not await self._table_exists(db, "plugin_config"):
230
+ print(" ✓ Creating missing table: plugin_config")
231
+ await db.execute("""
232
+ CREATE TABLE plugin_config (
233
+ id INTEGER PRIMARY KEY DEFAULT 1,
234
+ connection_token TEXT DEFAULT '',
235
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
236
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
237
+ )
238
+ """)
239
+
240
+ # ========== Step 2: Add missing columns to existing tables ==========
241
+ # Check and add missing columns to tokens table
242
+ if await self._table_exists(db, "tokens"):
243
+ columns_to_add = [
244
+ ("at", "TEXT"), # Access Token
245
+ ("at_expires", "TIMESTAMP"), # AT expiration time
246
+ ("credits", "INTEGER DEFAULT 0"), # Balance
247
+ ("user_paygate_tier", "TEXT"), # User tier
248
+ ("current_project_id", "TEXT"), # Current project UUID
249
+ ("current_project_name", "TEXT"), # Project name
250
+ ("image_enabled", "BOOLEAN DEFAULT 1"),
251
+ ("video_enabled", "BOOLEAN DEFAULT 1"),
252
+ ("image_concurrency", "INTEGER DEFAULT -1"),
253
+ ("video_concurrency", "INTEGER DEFAULT -1"),
254
+ ("ban_reason", "TEXT"), # 禁用原因
255
+ ("banned_at", "TIMESTAMP"), # 禁用时间
256
+ ]
257
+
258
+ for col_name, col_type in columns_to_add:
259
+ if not await self._column_exists(db, "tokens", col_name):
260
+ try:
261
+ await db.execute(f"ALTER TABLE tokens ADD COLUMN {col_name} {col_type}")
262
+ print(f" ✓ Added column '{col_name}' to tokens table")
263
+ except Exception as e:
264
+ print(f" ✗ Failed to add column '{col_name}': {e}")
265
+
266
+ # Check and add missing columns to admin_config table
267
+ if await self._table_exists(db, "admin_config"):
268
+ if not await self._column_exists(db, "admin_config", "error_ban_threshold"):
269
+ try:
270
+ await db.execute("ALTER TABLE admin_config ADD COLUMN error_ban_threshold INTEGER DEFAULT 3")
271
+ print(" ✓ Added column 'error_ban_threshold' to admin_config table")
272
+ except Exception as e:
273
+ print(f" ✗ Failed to add column 'error_ban_threshold': {e}")
274
+
275
+ # Check and add missing columns to captcha_config table
276
+ if await self._table_exists(db, "captcha_config"):
277
+ captcha_columns_to_add = [
278
+ ("browser_proxy_enabled", "BOOLEAN DEFAULT 0"),
279
+ ("browser_proxy_url", "TEXT"),
280
+ ]
281
+
282
+ for col_name, col_type in captcha_columns_to_add:
283
+ if not await self._column_exists(db, "captcha_config", col_name):
284
+ try:
285
+ await db.execute(f"ALTER TABLE captcha_config ADD COLUMN {col_name} {col_type}")
286
+ print(f" ✓ Added column '{col_name}' to captcha_config table")
287
+ except Exception as e:
288
+ print(f" ✗ Failed to add column '{col_name}': {e}")
289
+
290
+ # Check and add missing columns to token_stats table
291
+ if await self._table_exists(db, "token_stats"):
292
+ stats_columns_to_add = [
293
+ ("today_image_count", "INTEGER DEFAULT 0"),
294
+ ("today_video_count", "INTEGER DEFAULT 0"),
295
+ ("today_error_count", "INTEGER DEFAULT 0"),
296
+ ("today_date", "DATE"),
297
+ ("consecutive_error_count", "INTEGER DEFAULT 0"), # 🆕 连续错误计数
298
+ ]
299
+
300
+ for col_name, col_type in stats_columns_to_add:
301
+ if not await self._column_exists(db, "token_stats", col_name):
302
+ try:
303
+ await db.execute(f"ALTER TABLE token_stats ADD COLUMN {col_name} {col_type}")
304
+ print(f" ✓ Added column '{col_name}' to token_stats table")
305
+ except Exception as e:
306
+ print(f" ✗ Failed to add column '{col_name}': {e}")
307
+
308
+ # Check and add missing columns to plugin_config table
309
+ if await self._table_exists(db, "plugin_config"):
310
+ plugin_columns_to_add = [
311
+ ("auto_enable_on_update", "BOOLEAN DEFAULT 1"), # 默认开启
312
+ ]
313
+
314
+ for col_name, col_type in plugin_columns_to_add:
315
+ if not await self._column_exists(db, "plugin_config", col_name):
316
+ try:
317
+ await db.execute(f"ALTER TABLE plugin_config ADD COLUMN {col_name} {col_type}")
318
+ print(f" ✓ Added column '{col_name}' to plugin_config table")
319
+ except Exception as e:
320
+ print(f" ✗ Failed to add column '{col_name}': {e}")
321
+
322
+ # ========== Step 3: Ensure all config tables have default rows ==========
323
+ # Note: This will NOT overwrite existing config rows
324
+ # It only ensures missing rows are created with default values from setting.toml
325
+ await self._ensure_config_rows(db, config_dict=config_dict)
326
+
327
+ await db.commit()
328
+ print("Database migration check completed.")
329
+
330
+ async def init_db(self):
331
+ """Initialize database tables"""
332
+ async with aiosqlite.connect(self.db_path) as db:
333
+ # Tokens table (Flow2API版本)
334
+ await db.execute("""
335
+ CREATE TABLE IF NOT EXISTS tokens (
336
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
337
+ st TEXT UNIQUE NOT NULL,
338
+ at TEXT,
339
+ at_expires TIMESTAMP,
340
+ email TEXT NOT NULL,
341
+ name TEXT,
342
+ remark TEXT,
343
+ is_active BOOLEAN DEFAULT 1,
344
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
345
+ last_used_at TIMESTAMP,
346
+ use_count INTEGER DEFAULT 0,
347
+ credits INTEGER DEFAULT 0,
348
+ user_paygate_tier TEXT,
349
+ current_project_id TEXT,
350
+ current_project_name TEXT,
351
+ image_enabled BOOLEAN DEFAULT 1,
352
+ video_enabled BOOLEAN DEFAULT 1,
353
+ image_concurrency INTEGER DEFAULT -1,
354
+ video_concurrency INTEGER DEFAULT -1,
355
+ ban_reason TEXT,
356
+ banned_at TIMESTAMP
357
+ )
358
+ """)
359
+
360
+ # Projects table (新增)
361
+ await db.execute("""
362
+ CREATE TABLE IF NOT EXISTS projects (
363
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
364
+ project_id TEXT UNIQUE NOT NULL,
365
+ token_id INTEGER NOT NULL,
366
+ project_name TEXT NOT NULL,
367
+ tool_name TEXT DEFAULT 'PINHOLE',
368
+ is_active BOOLEAN DEFAULT 1,
369
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
370
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
371
+ )
372
+ """)
373
+
374
+ # Token stats table
375
+ await db.execute("""
376
+ CREATE TABLE IF NOT EXISTS token_stats (
377
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
378
+ token_id INTEGER NOT NULL,
379
+ image_count INTEGER DEFAULT 0,
380
+ video_count INTEGER DEFAULT 0,
381
+ success_count INTEGER DEFAULT 0,
382
+ error_count INTEGER DEFAULT 0,
383
+ last_success_at TIMESTAMP,
384
+ last_error_at TIMESTAMP,
385
+ today_image_count INTEGER DEFAULT 0,
386
+ today_video_count INTEGER DEFAULT 0,
387
+ today_error_count INTEGER DEFAULT 0,
388
+ today_date DATE,
389
+ consecutive_error_count INTEGER DEFAULT 0,
390
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
391
+ )
392
+ """)
393
+
394
+ # Tasks table
395
+ await db.execute("""
396
+ CREATE TABLE IF NOT EXISTS tasks (
397
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
398
+ task_id TEXT UNIQUE NOT NULL,
399
+ token_id INTEGER NOT NULL,
400
+ model TEXT NOT NULL,
401
+ prompt TEXT NOT NULL,
402
+ status TEXT NOT NULL DEFAULT 'processing',
403
+ progress INTEGER DEFAULT 0,
404
+ result_urls TEXT,
405
+ error_message TEXT,
406
+ scene_id TEXT,
407
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
408
+ completed_at TIMESTAMP,
409
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
410
+ )
411
+ """)
412
+
413
+ # Request logs table
414
+ await db.execute("""
415
+ CREATE TABLE IF NOT EXISTS request_logs (
416
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
417
+ token_id INTEGER,
418
+ operation TEXT NOT NULL,
419
+ request_body TEXT,
420
+ response_body TEXT,
421
+ status_code INTEGER NOT NULL,
422
+ duration FLOAT NOT NULL,
423
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
424
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
425
+ )
426
+ """)
427
+
428
+ # Admin config table
429
+ await db.execute("""
430
+ CREATE TABLE IF NOT EXISTS admin_config (
431
+ id INTEGER PRIMARY KEY DEFAULT 1,
432
+ username TEXT DEFAULT 'admin',
433
+ password TEXT DEFAULT 'admin',
434
+ api_key TEXT DEFAULT 'han1234',
435
+ error_ban_threshold INTEGER DEFAULT 3,
436
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
437
+ )
438
+ """)
439
+
440
+ # Proxy config table
441
+ await db.execute("""
442
+ CREATE TABLE IF NOT EXISTS proxy_config (
443
+ id INTEGER PRIMARY KEY DEFAULT 1,
444
+ enabled BOOLEAN DEFAULT 0,
445
+ proxy_url TEXT,
446
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
447
+ )
448
+ """)
449
+
450
+ # Generation config table
451
+ await db.execute("""
452
+ CREATE TABLE IF NOT EXISTS generation_config (
453
+ id INTEGER PRIMARY KEY DEFAULT 1,
454
+ image_timeout INTEGER DEFAULT 300,
455
+ video_timeout INTEGER DEFAULT 1500,
456
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
457
+ )
458
+ """)
459
+
460
+ # Cache config table
461
+ await db.execute("""
462
+ CREATE TABLE IF NOT EXISTS cache_config (
463
+ id INTEGER PRIMARY KEY DEFAULT 1,
464
+ cache_enabled BOOLEAN DEFAULT 0,
465
+ cache_timeout INTEGER DEFAULT 7200,
466
+ cache_base_url TEXT,
467
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
468
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
469
+ )
470
+ """)
471
+
472
+ # Debug config table
473
+ await db.execute("""
474
+ CREATE TABLE IF NOT EXISTS debug_config (
475
+ id INTEGER PRIMARY KEY DEFAULT 1,
476
+ enabled BOOLEAN DEFAULT 0,
477
+ log_requests BOOLEAN DEFAULT 1,
478
+ log_responses BOOLEAN DEFAULT 1,
479
+ mask_token BOOLEAN DEFAULT 1,
480
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
481
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
482
+ )
483
+ """)
484
+
485
+ # Captcha config table
486
+ await db.execute("""
487
+ CREATE TABLE IF NOT EXISTS captcha_config (
488
+ id INTEGER PRIMARY KEY DEFAULT 1,
489
+ captcha_method TEXT DEFAULT 'browser',
490
+ yescaptcha_api_key TEXT DEFAULT '',
491
+ yescaptcha_base_url TEXT DEFAULT 'https://api.yescaptcha.com',
492
+ website_key TEXT DEFAULT '6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV',
493
+ page_action TEXT DEFAULT 'FLOW_GENERATION',
494
+ browser_proxy_enabled BOOLEAN DEFAULT 0,
495
+ browser_proxy_url TEXT,
496
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
497
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
498
+ )
499
+ """)
500
+
501
+ # Plugin config table
502
+ await db.execute("""
503
+ CREATE TABLE IF NOT EXISTS plugin_config (
504
+ id INTEGER PRIMARY KEY DEFAULT 1,
505
+ connection_token TEXT DEFAULT '',
506
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
507
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
508
+ )
509
+ """)
510
+
511
+ # Create indexes
512
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
513
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_token_st ON tokens(st)")
514
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_project_id ON projects(project_id)")
515
+
516
+ # Migrate request_logs table if needed
517
+ await self._migrate_request_logs(db)
518
+
519
+ await db.commit()
520
+
521
+ async def _migrate_request_logs(self, db):
522
+ """Migrate request_logs table from old schema to new schema"""
523
+ try:
524
+ # Check if old columns exist
525
+ has_model = await self._column_exists(db, "request_logs", "model")
526
+ has_operation = await self._column_exists(db, "request_logs", "operation")
527
+
528
+ if has_model and not has_operation:
529
+ # Old schema detected, need migration
530
+ print("🔄 检测到旧的request_logs表结构,开始迁移...")
531
+
532
+ # Rename old table
533
+ await db.execute("ALTER TABLE request_logs RENAME TO request_logs_old")
534
+
535
+ # Create new table with new schema
536
+ await db.execute("""
537
+ CREATE TABLE request_logs (
538
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
539
+ token_id INTEGER,
540
+ operation TEXT NOT NULL,
541
+ request_body TEXT,
542
+ response_body TEXT,
543
+ status_code INTEGER NOT NULL,
544
+ duration FLOAT NOT NULL,
545
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
546
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
547
+ )
548
+ """)
549
+
550
+ # Migrate data from old table (basic migration)
551
+ await db.execute("""
552
+ INSERT INTO request_logs (token_id, operation, request_body, status_code, duration, created_at)
553
+ SELECT
554
+ token_id,
555
+ model as operation,
556
+ json_object('model', model, 'prompt', substr(prompt, 1, 100)) as request_body,
557
+ CASE
558
+ WHEN status = 'completed' THEN 200
559
+ WHEN status = 'failed' THEN 500
560
+ ELSE 0
561
+ END as status_code,
562
+ response_time as duration,
563
+ created_at
564
+ FROM request_logs_old
565
+ """)
566
+
567
+ # Drop old table
568
+ await db.execute("DROP TABLE request_logs_old")
569
+
570
+ print("✅ request_logs表迁移完成")
571
+ except Exception as e:
572
+ print(f"⚠️ request_logs表迁移失败: {e}")
573
+ # Continue even if migration fails
574
+
575
+ # Token operations
576
+ async def add_token(self, token: Token) -> int:
577
+ """Add a new token"""
578
+ async with aiosqlite.connect(self.db_path) as db:
579
+ cursor = await db.execute("""
580
+ INSERT INTO tokens (st, at, at_expires, email, name, remark, is_active,
581
+ credits, user_paygate_tier, current_project_id, current_project_name,
582
+ image_enabled, video_enabled, image_concurrency, video_concurrency)
583
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
584
+ """, (token.st, token.at, token.at_expires, token.email, token.name, token.remark,
585
+ token.is_active, token.credits, token.user_paygate_tier,
586
+ token.current_project_id, token.current_project_name,
587
+ token.image_enabled, token.video_enabled,
588
+ token.image_concurrency, token.video_concurrency))
589
+ await db.commit()
590
+ token_id = cursor.lastrowid
591
+
592
+ # Create stats entry
593
+ await db.execute("""
594
+ INSERT INTO token_stats (token_id) VALUES (?)
595
+ """, (token_id,))
596
+ await db.commit()
597
+
598
+ return token_id
599
+
600
+ async def get_token(self, token_id: int) -> Optional[Token]:
601
+ """Get token by ID"""
602
+ async with aiosqlite.connect(self.db_path) as db:
603
+ db.row_factory = aiosqlite.Row
604
+ cursor = await db.execute("SELECT * FROM tokens WHERE id = ?", (token_id,))
605
+ row = await cursor.fetchone()
606
+ if row:
607
+ return Token(**dict(row))
608
+ return None
609
+
610
+ async def get_token_by_st(self, st: str) -> Optional[Token]:
611
+ """Get token by ST"""
612
+ async with aiosqlite.connect(self.db_path) as db:
613
+ db.row_factory = aiosqlite.Row
614
+ cursor = await db.execute("SELECT * FROM tokens WHERE st = ?", (st,))
615
+ row = await cursor.fetchone()
616
+ if row:
617
+ return Token(**dict(row))
618
+ return None
619
+
620
+ async def get_token_by_email(self, email: str) -> Optional[Token]:
621
+ """Get token by email"""
622
+ async with aiosqlite.connect(self.db_path) as db:
623
+ db.row_factory = aiosqlite.Row
624
+ cursor = await db.execute("SELECT * FROM tokens WHERE email = ?", (email,))
625
+ row = await cursor.fetchone()
626
+ if row:
627
+ return Token(**dict(row))
628
+ return None
629
+
630
+ async def get_all_tokens(self) -> List[Token]:
631
+ """Get all tokens"""
632
+ async with aiosqlite.connect(self.db_path) as db:
633
+ db.row_factory = aiosqlite.Row
634
+ cursor = await db.execute("SELECT * FROM tokens ORDER BY created_at DESC")
635
+ rows = await cursor.fetchall()
636
+ return [Token(**dict(row)) for row in rows]
637
+
638
+ async def get_active_tokens(self) -> List[Token]:
639
+ """Get all active tokens"""
640
+ async with aiosqlite.connect(self.db_path) as db:
641
+ db.row_factory = aiosqlite.Row
642
+ cursor = await db.execute("SELECT * FROM tokens WHERE is_active = 1 ORDER BY last_used_at ASC")
643
+ rows = await cursor.fetchall()
644
+ return [Token(**dict(row)) for row in rows]
645
+
646
+ async def update_token(self, token_id: int, **kwargs):
647
+ """Update token fields"""
648
+ async with aiosqlite.connect(self.db_path) as db:
649
+ updates = []
650
+ params = []
651
+
652
+ for key, value in kwargs.items():
653
+ if value is not None:
654
+ updates.append(f"{key} = ?")
655
+ params.append(value)
656
+
657
+ if updates:
658
+ params.append(token_id)
659
+ query = f"UPDATE tokens SET {', '.join(updates)} WHERE id = ?"
660
+ await db.execute(query, params)
661
+ await db.commit()
662
+
663
+ async def delete_token(self, token_id: int):
664
+ """Delete token and related data"""
665
+ async with aiosqlite.connect(self.db_path) as db:
666
+ await db.execute("DELETE FROM token_stats WHERE token_id = ?", (token_id,))
667
+ await db.execute("DELETE FROM projects WHERE token_id = ?", (token_id,))
668
+ await db.execute("DELETE FROM tokens WHERE id = ?", (token_id,))
669
+ await db.commit()
670
+
671
+ # Project operations
672
+ async def add_project(self, project: Project) -> int:
673
+ """Add a new project"""
674
+ async with aiosqlite.connect(self.db_path) as db:
675
+ cursor = await db.execute("""
676
+ INSERT INTO projects (project_id, token_id, project_name, tool_name, is_active)
677
+ VALUES (?, ?, ?, ?, ?)
678
+ """, (project.project_id, project.token_id, project.project_name,
679
+ project.tool_name, project.is_active))
680
+ await db.commit()
681
+ return cursor.lastrowid
682
+
683
+ async def get_project_by_id(self, project_id: str) -> Optional[Project]:
684
+ """Get project by UUID"""
685
+ async with aiosqlite.connect(self.db_path) as db:
686
+ db.row_factory = aiosqlite.Row
687
+ cursor = await db.execute("SELECT * FROM projects WHERE project_id = ?", (project_id,))
688
+ row = await cursor.fetchone()
689
+ if row:
690
+ return Project(**dict(row))
691
+ return None
692
+
693
+ async def get_projects_by_token(self, token_id: int) -> List[Project]:
694
+ """Get all projects for a token"""
695
+ async with aiosqlite.connect(self.db_path) as db:
696
+ db.row_factory = aiosqlite.Row
697
+ cursor = await db.execute(
698
+ "SELECT * FROM projects WHERE token_id = ? ORDER BY created_at DESC",
699
+ (token_id,)
700
+ )
701
+ rows = await cursor.fetchall()
702
+ return [Project(**dict(row)) for row in rows]
703
+
704
+ async def delete_project(self, project_id: str):
705
+ """Delete project"""
706
+ async with aiosqlite.connect(self.db_path) as db:
707
+ await db.execute("DELETE FROM projects WHERE project_id = ?", (project_id,))
708
+ await db.commit()
709
+
710
+ # Task operations
711
+ async def create_task(self, task: Task) -> int:
712
+ """Create a new task"""
713
+ async with aiosqlite.connect(self.db_path) as db:
714
+ cursor = await db.execute("""
715
+ INSERT INTO tasks (task_id, token_id, model, prompt, status, progress, scene_id)
716
+ VALUES (?, ?, ?, ?, ?, ?, ?)
717
+ """, (task.task_id, task.token_id, task.model, task.prompt,
718
+ task.status, task.progress, task.scene_id))
719
+ await db.commit()
720
+ return cursor.lastrowid
721
+
722
+ async def get_task(self, task_id: str) -> Optional[Task]:
723
+ """Get task by ID"""
724
+ async with aiosqlite.connect(self.db_path) as db:
725
+ db.row_factory = aiosqlite.Row
726
+ cursor = await db.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,))
727
+ row = await cursor.fetchone()
728
+ if row:
729
+ task_dict = dict(row)
730
+ # Parse result_urls from JSON
731
+ if task_dict.get("result_urls"):
732
+ task_dict["result_urls"] = json.loads(task_dict["result_urls"])
733
+ return Task(**task_dict)
734
+ return None
735
+
736
+ async def update_task(self, task_id: str, **kwargs):
737
+ """Update task"""
738
+ async with aiosqlite.connect(self.db_path) as db:
739
+ updates = []
740
+ params = []
741
+
742
+ for key, value in kwargs.items():
743
+ if value is not None:
744
+ # Convert list to JSON string for result_urls
745
+ if key == "result_urls" and isinstance(value, list):
746
+ value = json.dumps(value)
747
+ updates.append(f"{key} = ?")
748
+ params.append(value)
749
+
750
+ if updates:
751
+ params.append(task_id)
752
+ query = f"UPDATE tasks SET {', '.join(updates)} WHERE task_id = ?"
753
+ await db.execute(query, params)
754
+ await db.commit()
755
+
756
+ # Token stats operations (kept for compatibility, now delegates to specific methods)
757
+ async def increment_token_stats(self, token_id: int, stat_type: str):
758
+ """Increment token statistics (delegates to specific methods)"""
759
+ if stat_type == "image":
760
+ await self.increment_image_count(token_id)
761
+ elif stat_type == "video":
762
+ await self.increment_video_count(token_id)
763
+ elif stat_type == "error":
764
+ await self.increment_error_count(token_id)
765
+
766
+ async def get_token_stats(self, token_id: int) -> Optional[TokenStats]:
767
+ """Get token statistics"""
768
+ async with aiosqlite.connect(self.db_path) as db:
769
+ db.row_factory = aiosqlite.Row
770
+ cursor = await db.execute("SELECT * FROM token_stats WHERE token_id = ?", (token_id,))
771
+ row = await cursor.fetchone()
772
+ if row:
773
+ return TokenStats(**dict(row))
774
+ return None
775
+
776
+ async def increment_image_count(self, token_id: int):
777
+ """Increment image generation count with daily reset"""
778
+ from datetime import date
779
+ async with aiosqlite.connect(self.db_path) as db:
780
+ today = str(date.today())
781
+ # Get current stats
782
+ cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
783
+ row = await cursor.fetchone()
784
+
785
+ # If date changed, reset today's count
786
+ if row and row[0] != today:
787
+ await db.execute("""
788
+ UPDATE token_stats
789
+ SET image_count = image_count + 1,
790
+ today_image_count = 1,
791
+ today_date = ?
792
+ WHERE token_id = ?
793
+ """, (today, token_id))
794
+ else:
795
+ # Same day, just increment both
796
+ await db.execute("""
797
+ UPDATE token_stats
798
+ SET image_count = image_count + 1,
799
+ today_image_count = today_image_count + 1,
800
+ today_date = ?
801
+ WHERE token_id = ?
802
+ """, (today, token_id))
803
+ await db.commit()
804
+
805
+ async def increment_video_count(self, token_id: int):
806
+ """Increment video generation count with daily reset"""
807
+ from datetime import date
808
+ async with aiosqlite.connect(self.db_path) as db:
809
+ today = str(date.today())
810
+ # Get current stats
811
+ cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
812
+ row = await cursor.fetchone()
813
+
814
+ # If date changed, reset today's count
815
+ if row and row[0] != today:
816
+ await db.execute("""
817
+ UPDATE token_stats
818
+ SET video_count = video_count + 1,
819
+ today_video_count = 1,
820
+ today_date = ?
821
+ WHERE token_id = ?
822
+ """, (today, token_id))
823
+ else:
824
+ # Same day, just increment both
825
+ await db.execute("""
826
+ UPDATE token_stats
827
+ SET video_count = video_count + 1,
828
+ today_video_count = today_video_count + 1,
829
+ today_date = ?
830
+ WHERE token_id = ?
831
+ """, (today, token_id))
832
+ await db.commit()
833
+
834
+ async def increment_error_count(self, token_id: int):
835
+ """Increment error count with daily reset
836
+
837
+ Updates two counters:
838
+ - error_count: Historical total errors (never reset)
839
+ - consecutive_error_count: Consecutive errors (reset on success/enable)
840
+ - today_error_count: Today's errors (reset on date change)
841
+ """
842
+ from datetime import date
843
+ async with aiosqlite.connect(self.db_path) as db:
844
+ today = str(date.today())
845
+ # Get current stats
846
+ cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
847
+ row = await cursor.fetchone()
848
+
849
+ # If date changed, reset today's error count
850
+ if row and row[0] != today:
851
+ await db.execute("""
852
+ UPDATE token_stats
853
+ SET error_count = error_count + 1,
854
+ consecutive_error_count = consecutive_error_count + 1,
855
+ today_error_count = 1,
856
+ today_date = ?,
857
+ last_error_at = CURRENT_TIMESTAMP
858
+ WHERE token_id = ?
859
+ """, (today, token_id))
860
+ else:
861
+ # Same day, just increment all counters
862
+ await db.execute("""
863
+ UPDATE token_stats
864
+ SET error_count = error_count + 1,
865
+ consecutive_error_count = consecutive_error_count + 1,
866
+ today_error_count = today_error_count + 1,
867
+ today_date = ?,
868
+ last_error_at = CURRENT_TIMESTAMP
869
+ WHERE token_id = ?
870
+ """, (today, token_id))
871
+ await db.commit()
872
+
873
+ async def reset_error_count(self, token_id: int):
874
+ """Reset consecutive error count (only reset consecutive_error_count, keep error_count and today_error_count)
875
+
876
+ This is called when:
877
+ - Token is manually enabled by admin
878
+ - Request succeeds (resets consecutive error counter)
879
+
880
+ Note: error_count (total historical errors) is NEVER reset
881
+ """
882
+ async with aiosqlite.connect(self.db_path) as db:
883
+ await db.execute("""
884
+ UPDATE token_stats SET consecutive_error_count = 0 WHERE token_id = ?
885
+ """, (token_id,))
886
+ await db.commit()
887
+
888
+ # Config operations
889
+ async def get_admin_config(self) -> Optional[AdminConfig]:
890
+ """Get admin configuration"""
891
+ async with aiosqlite.connect(self.db_path) as db:
892
+ db.row_factory = aiosqlite.Row
893
+ cursor = await db.execute("SELECT * FROM admin_config WHERE id = 1")
894
+ row = await cursor.fetchone()
895
+ if row:
896
+ return AdminConfig(**dict(row))
897
+ return None
898
+
899
+ async def update_admin_config(self, **kwargs):
900
+ """Update admin configuration"""
901
+ async with aiosqlite.connect(self.db_path) as db:
902
+ updates = []
903
+ params = []
904
+
905
+ for key, value in kwargs.items():
906
+ if value is not None:
907
+ updates.append(f"{key} = ?")
908
+ params.append(value)
909
+
910
+ if updates:
911
+ updates.append("updated_at = CURRENT_TIMESTAMP")
912
+ query = f"UPDATE admin_config SET {', '.join(updates)} WHERE id = 1"
913
+ await db.execute(query, params)
914
+ await db.commit()
915
+
916
+ async def get_proxy_config(self) -> Optional[ProxyConfig]:
917
+ """Get proxy configuration"""
918
+ async with aiosqlite.connect(self.db_path) as db:
919
+ db.row_factory = aiosqlite.Row
920
+ cursor = await db.execute("SELECT * FROM proxy_config WHERE id = 1")
921
+ row = await cursor.fetchone()
922
+ if row:
923
+ return ProxyConfig(**dict(row))
924
+ return None
925
+
926
+ async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str] = None):
927
+ """Update proxy configuration"""
928
+ async with aiosqlite.connect(self.db_path) as db:
929
+ await db.execute("""
930
+ UPDATE proxy_config
931
+ SET enabled = ?, proxy_url = ?, updated_at = CURRENT_TIMESTAMP
932
+ WHERE id = 1
933
+ """, (enabled, proxy_url))
934
+ await db.commit()
935
+
936
+ async def get_generation_config(self) -> Optional[GenerationConfig]:
937
+ """Get generation configuration"""
938
+ async with aiosqlite.connect(self.db_path) as db:
939
+ db.row_factory = aiosqlite.Row
940
+ cursor = await db.execute("SELECT * FROM generation_config WHERE id = 1")
941
+ row = await cursor.fetchone()
942
+ if row:
943
+ return GenerationConfig(**dict(row))
944
+ return None
945
+
946
+ async def update_generation_config(self, image_timeout: int, video_timeout: int):
947
+ """Update generation configuration"""
948
+ async with aiosqlite.connect(self.db_path) as db:
949
+ await db.execute("""
950
+ UPDATE generation_config
951
+ SET image_timeout = ?, video_timeout = ?, updated_at = CURRENT_TIMESTAMP
952
+ WHERE id = 1
953
+ """, (image_timeout, video_timeout))
954
+ await db.commit()
955
+
956
+ # Request log operations
957
+ async def add_request_log(self, log: RequestLog):
958
+ """Add request log"""
959
+ async with aiosqlite.connect(self.db_path) as db:
960
+ await db.execute("""
961
+ INSERT INTO request_logs (token_id, operation, request_body, response_body, status_code, duration)
962
+ VALUES (?, ?, ?, ?, ?, ?)
963
+ """, (log.token_id, log.operation, log.request_body, log.response_body,
964
+ log.status_code, log.duration))
965
+ await db.commit()
966
+
967
+ async def get_logs(self, limit: int = 100, token_id: Optional[int] = None):
968
+ """Get request logs with token email"""
969
+ async with aiosqlite.connect(self.db_path) as db:
970
+ db.row_factory = aiosqlite.Row
971
+
972
+ if token_id:
973
+ cursor = await db.execute("""
974
+ SELECT
975
+ rl.id,
976
+ rl.token_id,
977
+ rl.operation,
978
+ rl.request_body,
979
+ rl.response_body,
980
+ rl.status_code,
981
+ rl.duration,
982
+ rl.created_at,
983
+ t.email as token_email,
984
+ t.name as token_username
985
+ FROM request_logs rl
986
+ LEFT JOIN tokens t ON rl.token_id = t.id
987
+ WHERE rl.token_id = ?
988
+ ORDER BY rl.created_at DESC
989
+ LIMIT ?
990
+ """, (token_id, limit))
991
+ else:
992
+ cursor = await db.execute("""
993
+ SELECT
994
+ rl.id,
995
+ rl.token_id,
996
+ rl.operation,
997
+ rl.request_body,
998
+ rl.response_body,
999
+ rl.status_code,
1000
+ rl.duration,
1001
+ rl.created_at,
1002
+ t.email as token_email,
1003
+ t.name as token_username
1004
+ FROM request_logs rl
1005
+ LEFT JOIN tokens t ON rl.token_id = t.id
1006
+ ORDER BY rl.created_at DESC
1007
+ LIMIT ?
1008
+ """, (limit,))
1009
+
1010
+ rows = await cursor.fetchall()
1011
+ return [dict(row) for row in rows]
1012
+
1013
+ async def clear_all_logs(self):
1014
+ """Clear all request logs"""
1015
+ async with aiosqlite.connect(self.db_path) as db:
1016
+ await db.execute("DELETE FROM request_logs")
1017
+ await db.commit()
1018
+
1019
+ async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
1020
+ """
1021
+ Initialize database configuration from setting.toml
1022
+
1023
+ Args:
1024
+ config_dict: Configuration dictionary from setting.toml
1025
+ is_first_startup: If True, initialize all config rows from setting.toml.
1026
+ If False (upgrade mode), only ensure missing config rows exist with default values.
1027
+ """
1028
+ async with aiosqlite.connect(self.db_path) as db:
1029
+ if is_first_startup:
1030
+ # First startup: Initialize all config tables with values from setting.toml
1031
+ await self._ensure_config_rows(db, config_dict)
1032
+ else:
1033
+ # Upgrade mode: Only ensure missing config rows exist (with default values, not from TOML)
1034
+ await self._ensure_config_rows(db, config_dict=None)
1035
+
1036
+ await db.commit()
1037
+
1038
+ async def reload_config_to_memory(self):
1039
+ """
1040
+ Reload all configuration from database to in-memory Config instance.
1041
+ This should be called after any configuration update to ensure hot-reload.
1042
+
1043
+ Includes:
1044
+ - Admin config (username, password, api_key)
1045
+ - Cache config (enabled, timeout, base_url)
1046
+ - Generation config (image_timeout, video_timeout)
1047
+ - Proxy config will be handled by ProxyManager
1048
+ """
1049
+ from .config import config
1050
+
1051
+ # Reload admin config
1052
+ admin_config = await self.get_admin_config()
1053
+ if admin_config:
1054
+ config.set_admin_username_from_db(admin_config.username)
1055
+ config.set_admin_password_from_db(admin_config.password)
1056
+ config.api_key = admin_config.api_key
1057
+
1058
+ # Reload cache config
1059
+ cache_config = await self.get_cache_config()
1060
+ if cache_config:
1061
+ config.set_cache_enabled(cache_config.cache_enabled)
1062
+ config.set_cache_timeout(cache_config.cache_timeout)
1063
+ config.set_cache_base_url(cache_config.cache_base_url or "")
1064
+
1065
+ # Reload generation config
1066
+ generation_config = await self.get_generation_config()
1067
+ if generation_config:
1068
+ config.set_image_timeout(generation_config.image_timeout)
1069
+ config.set_video_timeout(generation_config.video_timeout)
1070
+
1071
+ # Reload debug config
1072
+ debug_config = await self.get_debug_config()
1073
+ if debug_config:
1074
+ config.set_debug_enabled(debug_config.enabled)
1075
+
1076
+ # Reload captcha config
1077
+ captcha_config = await self.get_captcha_config()
1078
+ if captcha_config:
1079
+ config.set_captcha_method(captcha_config.captcha_method)
1080
+ config.set_yescaptcha_api_key(captcha_config.yescaptcha_api_key)
1081
+ config.set_yescaptcha_base_url(captcha_config.yescaptcha_base_url)
1082
+
1083
+ # Cache config operations
1084
+ async def get_cache_config(self) -> CacheConfig:
1085
+ """Get cache configuration"""
1086
+ async with aiosqlite.connect(self.db_path) as db:
1087
+ db.row_factory = aiosqlite.Row
1088
+ cursor = await db.execute("SELECT * FROM cache_config WHERE id = 1")
1089
+ row = await cursor.fetchone()
1090
+ if row:
1091
+ return CacheConfig(**dict(row))
1092
+ # Return default if not found
1093
+ return CacheConfig(cache_enabled=False, cache_timeout=7200)
1094
+
1095
+ async def update_cache_config(self, enabled: bool = None, timeout: int = None, base_url: Optional[str] = None):
1096
+ """Update cache configuration"""
1097
+ async with aiosqlite.connect(self.db_path) as db:
1098
+ db.row_factory = aiosqlite.Row
1099
+ # Get current values
1100
+ cursor = await db.execute("SELECT * FROM cache_config WHERE id = 1")
1101
+ row = await cursor.fetchone()
1102
+
1103
+ if row:
1104
+ current = dict(row)
1105
+ # Use new values if provided, otherwise keep existing
1106
+ new_enabled = enabled if enabled is not None else current.get("cache_enabled", False)
1107
+ new_timeout = timeout if timeout is not None else current.get("cache_timeout", 7200)
1108
+ new_base_url = base_url if base_url is not None else current.get("cache_base_url")
1109
+
1110
+ # If base_url is explicitly set to empty string, treat as None
1111
+ if base_url == "":
1112
+ new_base_url = None
1113
+
1114
+ await db.execute("""
1115
+ UPDATE cache_config
1116
+ SET cache_enabled = ?, cache_timeout = ?, cache_base_url = ?, updated_at = CURRENT_TIMESTAMP
1117
+ WHERE id = 1
1118
+ """, (new_enabled, new_timeout, new_base_url))
1119
+ else:
1120
+ # Insert default row if not exists
1121
+ new_enabled = enabled if enabled is not None else False
1122
+ new_timeout = timeout if timeout is not None else 7200
1123
+ new_base_url = base_url if base_url is not None else None
1124
+
1125
+ await db.execute("""
1126
+ INSERT INTO cache_config (id, cache_enabled, cache_timeout, cache_base_url)
1127
+ VALUES (1, ?, ?, ?)
1128
+ """, (new_enabled, new_timeout, new_base_url))
1129
+
1130
+ await db.commit()
1131
+
1132
+ # Debug config operations
1133
+ async def get_debug_config(self) -> 'DebugConfig':
1134
+ """Get debug configuration"""
1135
+ from .models import DebugConfig
1136
+ async with aiosqlite.connect(self.db_path) as db:
1137
+ db.row_factory = aiosqlite.Row
1138
+ cursor = await db.execute("SELECT * FROM debug_config WHERE id = 1")
1139
+ row = await cursor.fetchone()
1140
+ if row:
1141
+ return DebugConfig(**dict(row))
1142
+ # Return default if not found
1143
+ return DebugConfig(enabled=False, log_requests=True, log_responses=True, mask_token=True)
1144
+
1145
+ async def update_debug_config(
1146
+ self,
1147
+ enabled: bool = None,
1148
+ log_requests: bool = None,
1149
+ log_responses: bool = None,
1150
+ mask_token: bool = None
1151
+ ):
1152
+ """Update debug configuration"""
1153
+ async with aiosqlite.connect(self.db_path) as db:
1154
+ db.row_factory = aiosqlite.Row
1155
+ # Get current values
1156
+ cursor = await db.execute("SELECT * FROM debug_config WHERE id = 1")
1157
+ row = await cursor.fetchone()
1158
+
1159
+ if row:
1160
+ current = dict(row)
1161
+ # Use new values if provided, otherwise keep existing
1162
+ new_enabled = enabled if enabled is not None else current.get("enabled", False)
1163
+ new_log_requests = log_requests if log_requests is not None else current.get("log_requests", True)
1164
+ new_log_responses = log_responses if log_responses is not None else current.get("log_responses", True)
1165
+ new_mask_token = mask_token if mask_token is not None else current.get("mask_token", True)
1166
+
1167
+ await db.execute("""
1168
+ UPDATE debug_config
1169
+ SET enabled = ?, log_requests = ?, log_responses = ?, mask_token = ?, updated_at = CURRENT_TIMESTAMP
1170
+ WHERE id = 1
1171
+ """, (new_enabled, new_log_requests, new_log_responses, new_mask_token))
1172
+ else:
1173
+ # Insert default row if not exists
1174
+ new_enabled = enabled if enabled is not None else False
1175
+ new_log_requests = log_requests if log_requests is not None else True
1176
+ new_log_responses = log_responses if log_responses is not None else True
1177
+ new_mask_token = mask_token if mask_token is not None else True
1178
+
1179
+ await db.execute("""
1180
+ INSERT INTO debug_config (id, enabled, log_requests, log_responses, mask_token)
1181
+ VALUES (1, ?, ?, ?, ?)
1182
+ """, (new_enabled, new_log_requests, new_log_responses, new_mask_token))
1183
+
1184
+ await db.commit()
1185
+
1186
+ # Captcha config operations
1187
+ async def get_captcha_config(self) -> CaptchaConfig:
1188
+ """Get captcha configuration"""
1189
+ async with aiosqlite.connect(self.db_path) as db:
1190
+ db.row_factory = aiosqlite.Row
1191
+ cursor = await db.execute("SELECT * FROM captcha_config WHERE id = 1")
1192
+ row = await cursor.fetchone()
1193
+ if row:
1194
+ return CaptchaConfig(**dict(row))
1195
+ return CaptchaConfig()
1196
+
1197
+ async def update_captcha_config(
1198
+ self,
1199
+ captcha_method: str = None,
1200
+ yescaptcha_api_key: str = None,
1201
+ yescaptcha_base_url: str = None,
1202
+ browser_proxy_enabled: bool = None,
1203
+ browser_proxy_url: str = None
1204
+ ):
1205
+ """Update captcha configuration"""
1206
+ async with aiosqlite.connect(self.db_path) as db:
1207
+ db.row_factory = aiosqlite.Row
1208
+ cursor = await db.execute("SELECT * FROM captcha_config WHERE id = 1")
1209
+ row = await cursor.fetchone()
1210
+
1211
+ if row:
1212
+ current = dict(row)
1213
+ new_method = captcha_method if captcha_method is not None else current.get("captcha_method", "yescaptcha")
1214
+ new_api_key = yescaptcha_api_key if yescaptcha_api_key is not None else current.get("yescaptcha_api_key", "")
1215
+ new_base_url = yescaptcha_base_url if yescaptcha_base_url is not None else current.get("yescaptcha_base_url", "https://api.yescaptcha.com")
1216
+ new_proxy_enabled = browser_proxy_enabled if browser_proxy_enabled is not None else current.get("browser_proxy_enabled", False)
1217
+ new_proxy_url = browser_proxy_url if browser_proxy_url is not None else current.get("browser_proxy_url")
1218
+
1219
+ await db.execute("""
1220
+ UPDATE captcha_config
1221
+ SET captcha_method = ?, yescaptcha_api_key = ?, yescaptcha_base_url = ?,
1222
+ browser_proxy_enabled = ?, browser_proxy_url = ?, updated_at = CURRENT_TIMESTAMP
1223
+ WHERE id = 1
1224
+ """, (new_method, new_api_key, new_base_url, new_proxy_enabled, new_proxy_url))
1225
+ else:
1226
+ new_method = captcha_method if captcha_method is not None else "yescaptcha"
1227
+ new_api_key = yescaptcha_api_key if yescaptcha_api_key is not None else ""
1228
+ new_base_url = yescaptcha_base_url if yescaptcha_base_url is not None else "https://api.yescaptcha.com"
1229
+ new_proxy_enabled = browser_proxy_enabled if browser_proxy_enabled is not None else False
1230
+ new_proxy_url = browser_proxy_url
1231
+
1232
+ await db.execute("""
1233
+ INSERT INTO captcha_config (id, captcha_method, yescaptcha_api_key, yescaptcha_base_url, browser_proxy_enabled, browser_proxy_url)
1234
+ VALUES (1, ?, ?, ?, ?, ?)
1235
+ """, (new_method, new_api_key, new_base_url, new_proxy_enabled, new_proxy_url))
1236
+
1237
+ await db.commit()
1238
+
1239
+ # Plugin config operations
1240
+ async def get_plugin_config(self) -> PluginConfig:
1241
+ """Get plugin configuration"""
1242
+ async with aiosqlite.connect(self.db_path) as db:
1243
+ db.row_factory = aiosqlite.Row
1244
+ cursor = await db.execute("SELECT * FROM plugin_config WHERE id = 1")
1245
+ row = await cursor.fetchone()
1246
+ if row:
1247
+ return PluginConfig(**dict(row))
1248
+ return PluginConfig()
1249
+
1250
+ async def update_plugin_config(self, connection_token: str, auto_enable_on_update: bool = True):
1251
+ """Update plugin configuration"""
1252
+ async with aiosqlite.connect(self.db_path) as db:
1253
+ db.row_factory = aiosqlite.Row
1254
+ cursor = await db.execute("SELECT * FROM plugin_config WHERE id = 1")
1255
+ row = await cursor.fetchone()
1256
+
1257
+ if row:
1258
+ await db.execute("""
1259
+ UPDATE plugin_config
1260
+ SET connection_token = ?, auto_enable_on_update = ?, updated_at = CURRENT_TIMESTAMP
1261
+ WHERE id = 1
1262
+ """, (connection_token, auto_enable_on_update))
1263
+ else:
1264
+ await db.execute("""
1265
+ INSERT INTO plugin_config (id, connection_token, auto_enable_on_update)
1266
+ VALUES (1, ?, ?)
1267
+ """, (connection_token, auto_enable_on_update))
1268
+
1269
+ await db.commit()
src/core/logger.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ def log_warning(self, message: str):
243
+ """Log warning message to log.txt"""
244
+ if not config.debug_enabled:
245
+ return
246
+ try:
247
+ self.logger.warning(f"⚠️ [{self._format_timestamp()}] {message}")
248
+ except Exception as e:
249
+ self.logger.error(f"Error logging warning: {e}")
250
+
251
+ # Global debug logger instance
252
+ debug_logger = DebugLogger()
src/core/models.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # 429禁用相关
42
+ ban_reason: Optional[str] = None # 禁用原因: "429_rate_limit" 或 None
43
+ banned_at: Optional[datetime] = None # 禁用时间
44
+
45
+
46
+ class Project(BaseModel):
47
+ """Project model for VideoFX"""
48
+ id: Optional[int] = None
49
+ project_id: str # VideoFX项目UUID
50
+ token_id: int # 关联的Token ID
51
+ project_name: str # 项目名称
52
+ tool_name: str = "PINHOLE" # 工具名称,固定为PINHOLE
53
+ is_active: bool = True
54
+ created_at: Optional[datetime] = None
55
+
56
+
57
+ class TokenStats(BaseModel):
58
+ """Token statistics"""
59
+ token_id: int
60
+ image_count: int = 0
61
+ video_count: int = 0
62
+ success_count: int = 0
63
+ error_count: int = 0 # Historical total errors (never reset)
64
+ last_success_at: Optional[datetime] = None
65
+ last_error_at: Optional[datetime] = None
66
+ # 今日统计
67
+ today_image_count: int = 0
68
+ today_video_count: int = 0
69
+ today_error_count: int = 0
70
+ today_date: Optional[str] = None
71
+ # 连续错误计数 (用于自动禁用判断)
72
+ consecutive_error_count: int = 0
73
+
74
+
75
+ class Task(BaseModel):
76
+ """Generation task"""
77
+ id: Optional[int] = None
78
+ task_id: str # Flow API返回的operation name
79
+ token_id: int
80
+ model: str
81
+ prompt: str
82
+ status: str # processing, completed, failed
83
+ progress: int = 0 # 0-100
84
+ result_urls: Optional[List[str]] = None
85
+ error_message: Optional[str] = None
86
+ scene_id: Optional[str] = None # Flow API的sceneId
87
+ created_at: Optional[datetime] = None
88
+ completed_at: Optional[datetime] = None
89
+
90
+
91
+ class RequestLog(BaseModel):
92
+ """API request log"""
93
+ id: Optional[int] = None
94
+ token_id: Optional[int] = None
95
+ operation: str
96
+ request_body: Optional[str] = None
97
+ response_body: Optional[str] = None
98
+ status_code: int
99
+ duration: float
100
+ created_at: Optional[datetime] = None
101
+
102
+
103
+ class AdminConfig(BaseModel):
104
+ """Admin configuration"""
105
+ id: int = 1
106
+ username: str
107
+ password: str
108
+ api_key: str
109
+ error_ban_threshold: int = 3 # Auto-disable token after N consecutive errors
110
+
111
+
112
+ class ProxyConfig(BaseModel):
113
+ """Proxy configuration"""
114
+ id: int = 1
115
+ enabled: bool = False
116
+ proxy_url: Optional[str] = None
117
+
118
+
119
+ class GenerationConfig(BaseModel):
120
+ """Generation timeout configuration"""
121
+ id: int = 1
122
+ image_timeout: int = 300 # seconds
123
+ video_timeout: int = 1500 # seconds
124
+
125
+
126
+ class CacheConfig(BaseModel):
127
+ """Cache configuration"""
128
+ id: int = 1
129
+ cache_enabled: bool = False
130
+ cache_timeout: int = 7200 # seconds (2 hours)
131
+ cache_base_url: Optional[str] = None
132
+ created_at: Optional[datetime] = None
133
+ updated_at: Optional[datetime] = None
134
+
135
+
136
+ class DebugConfig(BaseModel):
137
+ """Debug configuration"""
138
+ id: int = 1
139
+ enabled: bool = False
140
+ log_requests: bool = True
141
+ log_responses: bool = True
142
+ mask_token: bool = True
143
+ created_at: Optional[datetime] = None
144
+ updated_at: Optional[datetime] = None
145
+
146
+
147
+ class CaptchaConfig(BaseModel):
148
+ """Captcha configuration"""
149
+ id: int = 1
150
+ captcha_method: str = "browser" # yescaptcha 或 browser
151
+ yescaptcha_api_key: str = ""
152
+ yescaptcha_base_url: str = "https://api.yescaptcha.com"
153
+ website_key: str = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV"
154
+ page_action: str = "FLOW_GENERATION"
155
+ browser_proxy_enabled: bool = False # 浏览器打码是否启用代理
156
+ browser_proxy_url: Optional[str] = None # 浏览器打码代理URL
157
+ created_at: Optional[datetime] = None
158
+ updated_at: Optional[datetime] = None
159
+
160
+
161
+ class PluginConfig(BaseModel):
162
+ """Plugin connection configuration"""
163
+ id: int = 1
164
+ connection_token: str = "" # 插件连接token
165
+ auto_enable_on_update: bool = True # 更新token时自动启用(默认开启)
166
+ created_at: Optional[datetime] = None
167
+ updated_at: Optional[datetime] = None
168
+
169
+
170
+ # OpenAI Compatible Request Models
171
+ class ChatMessage(BaseModel):
172
+ """Chat message"""
173
+ role: str
174
+ content: Union[str, List[dict]] # string or multimodal array
175
+
176
+
177
+ class ChatCompletionRequest(BaseModel):
178
+ """Chat completion request (OpenAI compatible)"""
179
+ model: str
180
+ messages: List[ChatMessage]
181
+ stream: bool = False
182
+ temperature: Optional[float] = None
183
+ max_tokens: Optional[int] = None
184
+ # Flow2API specific parameters
185
+ image: Optional[str] = None # Base64 encoded image (deprecated, use messages)
186
+ video: Optional[str] = None # Base64 encoded video (deprecated)
src/main.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Load debug configuration from database
66
+ debug_config = await db.get_debug_config()
67
+ config.set_debug_enabled(debug_config.enabled)
68
+
69
+ # Load captcha configuration from database
70
+ captcha_config = await db.get_captcha_config()
71
+ config.set_captcha_method(captcha_config.captcha_method)
72
+ config.set_yescaptcha_api_key(captcha_config.yescaptcha_api_key)
73
+ config.set_yescaptcha_base_url(captcha_config.yescaptcha_base_url)
74
+
75
+ # Initialize browser captcha service if needed
76
+ browser_service = None
77
+ if captcha_config.captcha_method == "personal":
78
+ from .services.browser_captcha_personal import BrowserCaptchaService
79
+ browser_service = await BrowserCaptchaService.get_instance(db)
80
+ await browser_service.open_login_window()
81
+ print("✓ Browser captcha service initialized (webui mode)")
82
+ elif captcha_config.captcha_method == "browser":
83
+ from .services.browser_captcha import BrowserCaptchaService
84
+ browser_service = await BrowserCaptchaService.get_instance(db)
85
+ print("✓ Browser captcha service initialized (headless mode)")
86
+
87
+ # Initialize concurrency manager
88
+ tokens = await token_manager.get_all_tokens()
89
+ await concurrency_manager.initialize(tokens)
90
+
91
+ # Start file cache cleanup task
92
+ await generation_handler.file_cache.start_cleanup_task()
93
+
94
+ # Start 429 auto-unban task
95
+ import asyncio
96
+ async def auto_unban_task():
97
+ """定时任务:每小时检查并解禁429被禁用的token"""
98
+ while True:
99
+ try:
100
+ await asyncio.sleep(3600) # 每小时执行一次
101
+ await token_manager.auto_unban_429_tokens()
102
+ except Exception as e:
103
+ print(f"❌ Auto-unban task error: {e}")
104
+
105
+ auto_unban_task_handle = asyncio.create_task(auto_unban_task())
106
+
107
+ print(f"✓ Database initialized")
108
+ print(f"✓ Total tokens: {len(tokens)}")
109
+ print(f"✓ Cache: {'Enabled' if config.cache_enabled else 'Disabled'} (timeout: {config.cache_timeout}s)")
110
+ print(f"✓ File cache cleanup task started")
111
+ print(f"✓ 429 auto-unban task started (runs every hour)")
112
+ print(f"✓ Server running on http://{config.server_host}:{config.server_port}")
113
+ print("=" * 60)
114
+
115
+ yield
116
+
117
+ # Shutdown
118
+ print("Flow2API Shutting down...")
119
+ # Stop file cache cleanup task
120
+ await generation_handler.file_cache.stop_cleanup_task()
121
+ # Stop auto-unban task
122
+ auto_unban_task_handle.cancel()
123
+ try:
124
+ await auto_unban_task_handle
125
+ except asyncio.CancelledError:
126
+ pass
127
+ # Close browser if initialized
128
+ if browser_service:
129
+ await browser_service.close()
130
+ print("✓ Browser captcha service closed")
131
+ print("✓ File cache cleanup task stopped")
132
+ print("✓ 429 auto-unban task stopped")
133
+
134
+
135
+ # Initialize components
136
+ db = Database()
137
+ proxy_manager = ProxyManager(db)
138
+ flow_client = FlowClient(proxy_manager)
139
+ token_manager = TokenManager(db, flow_client)
140
+ concurrency_manager = ConcurrencyManager()
141
+ load_balancer = LoadBalancer(token_manager, concurrency_manager)
142
+ generation_handler = GenerationHandler(
143
+ flow_client,
144
+ token_manager,
145
+ load_balancer,
146
+ db,
147
+ concurrency_manager,
148
+ proxy_manager # 添加 proxy_manager 参数
149
+ )
150
+
151
+ # Set dependencies
152
+ routes.set_generation_handler(generation_handler)
153
+ admin.set_dependencies(token_manager, proxy_manager, db)
154
+
155
+ # Create FastAPI app
156
+ app = FastAPI(
157
+ title="Flow2API",
158
+ description="OpenAI-compatible API for Google VideoFX (Veo)",
159
+ version="1.0.0",
160
+ lifespan=lifespan
161
+ )
162
+
163
+ # CORS middleware
164
+ app.add_middleware(
165
+ CORSMiddleware,
166
+ allow_origins=["*"],
167
+ allow_credentials=True,
168
+ allow_methods=["*"],
169
+ allow_headers=["*"],
170
+ )
171
+
172
+ # Include routers
173
+ app.include_router(routes.router)
174
+ app.include_router(admin.router)
175
+
176
+ # Static files - serve tmp directory for cached files
177
+ tmp_dir = Path(__file__).parent.parent / "tmp"
178
+ tmp_dir.mkdir(exist_ok=True)
179
+ app.mount("/tmp", StaticFiles(directory=str(tmp_dir)), name="tmp")
180
+
181
+ # HTML routes for frontend
182
+ static_path = Path(__file__).parent.parent / "static"
183
+
184
+
185
+ @app.get("/", response_class=HTMLResponse)
186
+ async def index():
187
+ """Redirect to login page"""
188
+ login_file = static_path / "login.html"
189
+ if login_file.exists():
190
+ return FileResponse(str(login_file))
191
+ return HTMLResponse(content="<h1>Flow2API</h1><p>Frontend not found</p>", status_code=404)
192
+
193
+
194
+ @app.get("/login", response_class=HTMLResponse)
195
+ async def login_page():
196
+ """Login page"""
197
+ login_file = static_path / "login.html"
198
+ if login_file.exists():
199
+ return FileResponse(str(login_file))
200
+ return HTMLResponse(content="<h1>Login Page Not Found</h1>", status_code=404)
201
+
202
+
203
+ @app.get("/manage", response_class=HTMLResponse)
204
+ async def manage_page():
205
+ """Management console page"""
206
+ manage_file = static_path / "manage.html"
207
+ if manage_file.exists():
208
+ return FileResponse(str(manage_file))
209
+ 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/browser_captcha.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 浏览器自动化获取 reCAPTCHA token
3
+ 使用 Playwright 访问页面并执行 reCAPTCHA 验证
4
+ """
5
+ import asyncio
6
+ import time
7
+ import re
8
+ from typing import Optional, Dict
9
+ from playwright.async_api import async_playwright, Browser, BrowserContext
10
+
11
+ from ..core.logger import debug_logger
12
+
13
+
14
+ def parse_proxy_url(proxy_url: str) -> Optional[Dict[str, str]]:
15
+ """解析代理URL,分离协议、主机、端口、认证信息
16
+
17
+ Args:
18
+ proxy_url: 代理URL,格式:protocol://[username:password@]host:port
19
+
20
+ Returns:
21
+ 代理配置字典,包含server、username、password(如果有认证)
22
+ """
23
+ proxy_pattern = r'^(socks5|http|https)://(?:([^:]+):([^@]+)@)?([^:]+):(\d+)$'
24
+ match = re.match(proxy_pattern, proxy_url)
25
+
26
+ if match:
27
+ protocol, username, password, host, port = match.groups()
28
+ proxy_config = {'server': f'{protocol}://{host}:{port}'}
29
+
30
+ if username and password:
31
+ proxy_config['username'] = username
32
+ proxy_config['password'] = password
33
+
34
+ return proxy_config
35
+ return None
36
+
37
+
38
+ def validate_browser_proxy_url(proxy_url: str) -> tuple[bool, str]:
39
+ """验证浏览器代理URL格式(仅支持HTTP和无认证SOCKS5)
40
+
41
+ Args:
42
+ proxy_url: 代理URL
43
+
44
+ Returns:
45
+ (是否有效, 错误信息)
46
+ """
47
+ if not proxy_url or not proxy_url.strip():
48
+ return True, "" # 空URL视为有效(不使用代理)
49
+
50
+ proxy_url = proxy_url.strip()
51
+ parsed = parse_proxy_url(proxy_url)
52
+
53
+ if not parsed:
54
+ return False, "代理URL格式错误,正确格式:http://host:port 或 socks5://host:port"
55
+
56
+ # 检查是否有认证信息
57
+ has_auth = 'username' in parsed
58
+
59
+ # 获取协议
60
+ protocol = parsed['server'].split('://')[0]
61
+
62
+ # SOCKS5不支持认证
63
+ if protocol == 'socks5' and has_auth:
64
+ return False, "浏览器不支持带认证的SOCKS5代理,请使用HTTP代理或移除SOCKS5认证"
65
+
66
+ # HTTP/HTTPS支持认证
67
+ if protocol in ['http', 'https']:
68
+ return True, ""
69
+
70
+ # SOCKS5无认证支持
71
+ if protocol == 'socks5' and not has_auth:
72
+ return True, ""
73
+
74
+ return False, f"不支持的代理协议:{protocol}"
75
+
76
+
77
+ class BrowserCaptchaService:
78
+ """浏览器自动化获取 reCAPTCHA token(单例模式)"""
79
+
80
+ _instance: Optional['BrowserCaptchaService'] = None
81
+ _lock = asyncio.Lock()
82
+
83
+ def __init__(self, db=None):
84
+ """初始化服务(始终使用无头模式)"""
85
+ self.headless = True # 始终无头
86
+ self.playwright = None
87
+ self.browser: Optional[Browser] = None
88
+ self._initialized = False
89
+ self.website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV"
90
+ self.db = db
91
+
92
+ @classmethod
93
+ async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
94
+ """获取单例实例"""
95
+ if cls._instance is None:
96
+ async with cls._lock:
97
+ if cls._instance is None:
98
+ cls._instance = cls(db)
99
+ await cls._instance.initialize()
100
+ return cls._instance
101
+
102
+ async def initialize(self):
103
+ """初始化浏览器(启动一次)"""
104
+ if self._initialized:
105
+ return
106
+
107
+ try:
108
+ # 获取浏览器专用代理配置
109
+ proxy_url = None
110
+ if self.db:
111
+ captcha_config = await self.db.get_captcha_config()
112
+ if captcha_config.browser_proxy_enabled and captcha_config.browser_proxy_url:
113
+ proxy_url = captcha_config.browser_proxy_url
114
+
115
+ debug_logger.log_info(f"[BrowserCaptcha] 正在启动浏览器... (proxy={proxy_url or 'None'})")
116
+ self.playwright = await async_playwright().start()
117
+
118
+ # 配置浏览器启动参数
119
+ launch_options = {
120
+ 'headless': self.headless,
121
+ 'args': [
122
+ '--disable-blink-features=AutomationControlled',
123
+ '--disable-dev-shm-usage',
124
+ '--no-sandbox',
125
+ '--disable-setuid-sandbox'
126
+ ]
127
+ }
128
+
129
+ # 如果有代理,解析并添加代理配置
130
+ if proxy_url:
131
+ proxy_config = parse_proxy_url(proxy_url)
132
+ if proxy_config:
133
+ launch_options['proxy'] = proxy_config
134
+ auth_info = "auth=yes" if 'username' in proxy_config else "auth=no"
135
+ debug_logger.log_info(f"[BrowserCaptcha] 代理配置: {proxy_config['server']} ({auth_info})")
136
+ else:
137
+ debug_logger.log_warning(f"[BrowserCaptcha] 代理URL格式错误: {proxy_url}")
138
+
139
+ self.browser = await self.playwright.chromium.launch(**launch_options)
140
+ self._initialized = True
141
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 浏览器已启动 (headless={self.headless}, proxy={proxy_url or 'None'})")
142
+ except Exception as e:
143
+ debug_logger.log_error(f"[BrowserCaptcha] ❌ 浏览器启动失败: {str(e)}")
144
+ raise
145
+
146
+ async def get_token(self, project_id: str) -> Optional[str]:
147
+ """获取 reCAPTCHA token
148
+
149
+ Args:
150
+ project_id: Flow项目ID
151
+
152
+ Returns:
153
+ reCAPTCHA token字符串,如果获取失败返回None
154
+ """
155
+ if not self._initialized:
156
+ await self.initialize()
157
+
158
+ start_time = time.time()
159
+ context = None
160
+
161
+ try:
162
+ # 创建新的上下文
163
+ context = await self.browser.new_context(
164
+ viewport={'width': 1920, 'height': 1080},
165
+ user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
166
+ locale='en-US',
167
+ timezone_id='America/New_York'
168
+ )
169
+ page = await context.new_page()
170
+
171
+ website_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
172
+
173
+ debug_logger.log_info(f"[BrowserCaptcha] 访问页面: {website_url}")
174
+
175
+ # 访问页面
176
+ try:
177
+ await page.goto(website_url, wait_until="domcontentloaded", timeout=30000)
178
+ except Exception as e:
179
+ debug_logger.log_warning(f"[BrowserCaptcha] 页面加载超时或失败: {str(e)}")
180
+
181
+ # 检查并注入 reCAPTCHA v3 脚本
182
+ debug_logger.log_info("[BrowserCaptcha] 检查并加载 reCAPTCHA v3 脚本...")
183
+ script_loaded = await page.evaluate("""
184
+ () => {
185
+ if (window.grecaptcha && typeof window.grecaptcha.execute === 'function') {
186
+ return true;
187
+ }
188
+ return false;
189
+ }
190
+ """)
191
+
192
+ if not script_loaded:
193
+ # 注入脚本
194
+ debug_logger.log_info("[BrowserCaptcha] 注入 reCAPTCHA v3 脚本...")
195
+ await page.evaluate(f"""
196
+ () => {{
197
+ return new Promise((resolve) => {{
198
+ const script = document.createElement('script');
199
+ script.src = 'https://www.google.com/recaptcha/api.js?render={self.website_key}';
200
+ script.async = true;
201
+ script.defer = true;
202
+ script.onload = () => resolve(true);
203
+ script.onerror = () => resolve(false);
204
+ document.head.appendChild(script);
205
+ }});
206
+ }}
207
+ """)
208
+
209
+ # 等待reCAPTCHA加载和初始化
210
+ debug_logger.log_info("[BrowserCaptcha] 等待reCAPTCHA初始化...")
211
+ for i in range(20):
212
+ grecaptcha_ready = await page.evaluate("""
213
+ () => {
214
+ return window.grecaptcha &&
215
+ typeof window.grecaptcha.execute === 'function';
216
+ }
217
+ """)
218
+ if grecaptcha_ready:
219
+ debug_logger.log_info(f"[BrowserCaptcha] reCAPTCHA 已准备好(等待了 {i*0.5} 秒)")
220
+ break
221
+ await asyncio.sleep(0.5)
222
+ else:
223
+ debug_logger.log_warning("[BrowserCaptcha] reCAPTCHA 初始化超时,继续尝试执行...")
224
+
225
+ # 额外等待确保完全初始化
226
+ await page.wait_for_timeout(1000)
227
+
228
+ # 执行reCAPTCHA并获取token
229
+ debug_logger.log_info("[BrowserCaptcha] 执行reCAPTCHA验证...")
230
+ token = await page.evaluate("""
231
+ async (websiteKey) => {
232
+ try {
233
+ if (!window.grecaptcha) {
234
+ console.error('[BrowserCaptcha] window.grecaptcha 不存在');
235
+ return null;
236
+ }
237
+
238
+ if (typeof window.grecaptcha.execute !== 'function') {
239
+ console.error('[BrowserCaptcha] window.grecaptcha.execute 不是函数');
240
+ return null;
241
+ }
242
+
243
+ // 确保grecaptcha已准备好
244
+ await new Promise((resolve, reject) => {
245
+ const timeout = setTimeout(() => {
246
+ reject(new Error('reCAPTCHA加载超时'));
247
+ }, 15000);
248
+
249
+ if (window.grecaptcha && window.grecaptcha.ready) {
250
+ window.grecaptcha.ready(() => {
251
+ clearTimeout(timeout);
252
+ resolve();
253
+ });
254
+ } else {
255
+ clearTimeout(timeout);
256
+ resolve();
257
+ }
258
+ });
259
+
260
+ // 执行reCAPTCHA v3
261
+ const token = await window.grecaptcha.execute(websiteKey, {
262
+ action: 'FLOW_GENERATION'
263
+ });
264
+
265
+ return token;
266
+ } catch (error) {
267
+ console.error('[BrowserCaptcha] reCAPTCHA执行错误:', error);
268
+ return null;
269
+ }
270
+ }
271
+ """, self.website_key)
272
+
273
+ duration_ms = (time.time() - start_time) * 1000
274
+
275
+ if token:
276
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ Token获取成功(耗时 {duration_ms:.0f}ms)")
277
+ return token
278
+ else:
279
+ debug_logger.log_error("[BrowserCaptcha] Token获取失败(返回null)")
280
+ return None
281
+
282
+ except Exception as e:
283
+ debug_logger.log_error(f"[BrowserCaptcha] 获取token异常: {str(e)}")
284
+ return None
285
+ finally:
286
+ # 关闭上下文
287
+ if context:
288
+ try:
289
+ await context.close()
290
+ except:
291
+ pass
292
+
293
+ async def close(self):
294
+ """关闭浏览器"""
295
+ try:
296
+ if self.browser:
297
+ try:
298
+ await self.browser.close()
299
+ except Exception as e:
300
+ # 忽略连接关闭错误(正常关闭场景)
301
+ if "Connection closed" not in str(e):
302
+ debug_logger.log_warning(f"[BrowserCaptcha] 关闭浏览器时出现异常: {str(e)}")
303
+ finally:
304
+ self.browser = None
305
+
306
+ if self.playwright:
307
+ try:
308
+ await self.playwright.stop()
309
+ except Exception:
310
+ pass # 静默处理 playwright 停止异常
311
+ finally:
312
+ self.playwright = None
313
+
314
+ self._initialized = False
315
+ debug_logger.log_info("[BrowserCaptcha] 浏览器已关闭")
316
+ except Exception as e:
317
+ debug_logger.log_error(f"[BrowserCaptcha] 关闭浏览器异常: {str(e)}")
src/services/browser_captcha_personal.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+ import re
4
+ import os
5
+ from typing import Optional, Dict
6
+ from playwright.async_api import async_playwright, BrowserContext, Page
7
+
8
+ from ..core.logger import debug_logger
9
+
10
+ # ... (保持原来的 parse_proxy_url 和 validate_browser_proxy_url 函数不变) ...
11
+ def parse_proxy_url(proxy_url: str) -> Optional[Dict[str, str]]:
12
+ """解析代理URL,分离协议、主机、端口、认证信息"""
13
+ proxy_pattern = r'^(socks5|http|https)://(?:([^:]+):([^@]+)@)?([^:]+):(\d+)$'
14
+ match = re.match(proxy_pattern, proxy_url)
15
+ if match:
16
+ protocol, username, password, host, port = match.groups()
17
+ proxy_config = {'server': f'{protocol}://{host}:{port}'}
18
+ if username and password:
19
+ proxy_config['username'] = username
20
+ proxy_config['password'] = password
21
+ return proxy_config
22
+ return None
23
+
24
+ class BrowserCaptchaService:
25
+ """浏览器自动化获取 reCAPTCHA token(持久化有头模式)"""
26
+
27
+ _instance: Optional['BrowserCaptchaService'] = None
28
+ _lock = asyncio.Lock()
29
+
30
+ def __init__(self, db=None):
31
+ """初始化服务"""
32
+ # === 修改点 1: 设置为有头模式 ===
33
+ self.headless = False
34
+ self.playwright = None
35
+ # 注意: 持久化模式下,我们操作的是 context 而不是 browser
36
+ self.context: Optional[BrowserContext] = None
37
+ self._initialized = False
38
+ self.website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV"
39
+ self.db = db
40
+
41
+ # === 修改点 2: 指定本地数据存储目录 ===
42
+ # 这会在脚本运行目录下生成 browser_data 文件夹,用于保存你的登录状态
43
+ self.user_data_dir = os.path.join(os.getcwd(), "browser_data")
44
+
45
+ @classmethod
46
+ async def get_instance(cls, db=None) -> 'BrowserCaptchaService':
47
+ if cls._instance is None:
48
+ async with cls._lock:
49
+ if cls._instance is None:
50
+ cls._instance = cls(db)
51
+ # 首次调用不强制初始化,等待 get_token 时懒加载,或者可以在这里await
52
+ return cls._instance
53
+
54
+ async def initialize(self):
55
+ """初始化持久化浏览器上下文"""
56
+ if self._initialized and self.context:
57
+ return
58
+
59
+ try:
60
+ proxy_url = None
61
+ if self.db:
62
+ captcha_config = await self.db.get_captcha_config()
63
+ if captcha_config.browser_proxy_enabled and captcha_config.browser_proxy_url:
64
+ proxy_url = captcha_config.browser_proxy_url
65
+
66
+ debug_logger.log_info(f"[BrowserCaptcha] 正在启动浏览器 (用户数据目录: {self.user_data_dir})...")
67
+ self.playwright = await async_playwright().start()
68
+
69
+ # 配置启动参数
70
+ launch_options = {
71
+ 'headless': self.headless,
72
+ 'user_data_dir': self.user_data_dir, # 指定数据目录
73
+ 'viewport': {'width': 1280, 'height': 720}, # 设置默认窗口大小
74
+ 'args': [
75
+ '--disable-blink-features=AutomationControlled',
76
+ '--disable-infobars',
77
+ '--no-sandbox',
78
+ '--disable-setuid-sandbox',
79
+ ]
80
+ }
81
+
82
+ # 代理配置
83
+ if proxy_url:
84
+ proxy_config = parse_proxy_url(proxy_url)
85
+ if proxy_config:
86
+ launch_options['proxy'] = proxy_config
87
+ debug_logger.log_info(f"[BrowserCaptcha] 使用代理: {proxy_config['server']}")
88
+
89
+ # === 修改点 3: 使用 launch_persistent_context ===
90
+ # 这会启动一个带有状态的浏览器窗口
91
+ self.context = await self.playwright.chromium.launch_persistent_context(**launch_options)
92
+
93
+ # 设置默认超时
94
+ self.context.set_default_timeout(30000)
95
+
96
+ self._initialized = True
97
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ 浏览器已启动 (Profile: {self.user_data_dir})")
98
+
99
+ except Exception as e:
100
+ debug_logger.log_error(f"[BrowserCaptcha] ❌ 浏览器启动失败: {str(e)}")
101
+ raise
102
+
103
+ async def get_token(self, project_id: str) -> Optional[str]:
104
+ """获取 reCAPTCHA token"""
105
+ # 确保浏览器已启动
106
+ if not self._initialized or not self.context:
107
+ await self.initialize()
108
+
109
+ start_time = time.time()
110
+ page: Optional[Page] = None
111
+
112
+ try:
113
+ # === 修改点 4: 在现有上下文中新建标签页,而不是新建上下文 ===
114
+ # 这样可以复用该上下文中已保存的 Cookie (你的登录状态)
115
+ page = await self.context.new_page()
116
+
117
+ website_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
118
+ debug_logger.log_info(f"[BrowserCaptcha] 访问页面: {website_url}")
119
+
120
+ # 访问页面
121
+ try:
122
+ await page.goto(website_url, wait_until="domcontentloaded")
123
+ except Exception as e:
124
+ debug_logger.log_warning(f"[BrowserCaptcha] 页面加载警告: {str(e)}")
125
+
126
+ # --- 关键点:如果需要人工介入 ---
127
+ # 你可以在这里加入一段逻辑,如果是第一次运行,或者检测到未登录,
128
+ # 可以暂停脚本,等你手动操作完再继续。
129
+ # 例如: await asyncio.sleep(30)
130
+
131
+ # ... (中间注入脚本和执行 reCAPTCHA 的代码逻辑与原版完全一致,此处省略以节省篇幅) ...
132
+ # ... 请将原代码中从 "检查并注入 reCAPTCHA v3 脚本" 到 token 获取部分的代码复制到这里 ...
133
+
134
+ # 这里为了演示,简写注入逻辑(请保留你原有的完整注入逻辑):
135
+ script_loaded = await page.evaluate("() => { return !!(window.grecaptcha && window.grecaptcha.execute); }")
136
+ if not script_loaded:
137
+ await page.evaluate(f"""
138
+ () => {{
139
+ const script = document.createElement('script');
140
+ script.src = 'https://www.google.com/recaptcha/api.js?render={self.website_key}';
141
+ script.async = true; script.defer = true;
142
+ document.head.appendChild(script);
143
+ }}
144
+ """)
145
+ # 等待加载... (保留你原有的等待循环)
146
+ await page.wait_for_timeout(2000)
147
+
148
+ # 执行获取 Token (保留你原有的 execute 逻辑)
149
+ token = await page.evaluate(f"""
150
+ async () => {{
151
+ try {{
152
+ return await window.grecaptcha.execute('{self.website_key}', {{ action: 'FLOW_GENERATION' }});
153
+ }} catch (e) {{ return null; }}
154
+ }}
155
+ """)
156
+
157
+ if token:
158
+ debug_logger.log_info(f"[BrowserCaptcha] ✅ Token获取成功")
159
+ return token
160
+ else:
161
+ debug_logger.log_error("[BrowserCaptcha] Token获取失败")
162
+ return None
163
+
164
+ except Exception as e:
165
+ debug_logger.log_error(f"[BrowserCaptcha] 异常: {str(e)}")
166
+ return None
167
+ finally:
168
+ # === 修改点 5: 只关闭 Page (标签页),不关闭 Context (浏览器窗口) ===
169
+ if page:
170
+ try:
171
+ await page.close()
172
+ except:
173
+ pass
174
+
175
+ async def close(self):
176
+ """完全关闭浏览器(清理资源时调用)"""
177
+ try:
178
+ if self.context:
179
+ await self.context.close() # 这会关闭整个浏览器窗口
180
+ self.context = None
181
+
182
+ if self.playwright:
183
+ await self.playwright.stop()
184
+ self.playwright = None
185
+
186
+ self._initialized = False
187
+ debug_logger.log_info("[BrowserCaptcha] 浏览器服务已关闭")
188
+ except Exception as e:
189
+ debug_logger.log_error(f"[BrowserCaptcha] 关闭异常: {str(e)}")
190
+
191
+ # 增加一个辅助方法,用于手动登录
192
+ async def open_login_window(self):
193
+ """调用此方法打开一个永久窗口供你登录Google"""
194
+ await self.initialize()
195
+ page = await self.context.new_page()
196
+ await page.goto("https://accounts.google.com/")
197
+ print("请在打开的浏览器中登录账号。登录完成后,无需关闭浏览器,脚本下次运行时会自动使用此状态。")
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,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Get proxy if available
135
+ proxy_url = None
136
+ if self.proxy_manager:
137
+ proxy_config = await self.proxy_manager.get_proxy_config()
138
+ if proxy_config and proxy_config.enabled and proxy_config.proxy_url:
139
+ proxy_url = proxy_config.proxy_url
140
+
141
+ # Try method 1: curl_cffi with browser impersonation
142
+ try:
143
+ async with AsyncSession() as session:
144
+ proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
145
+ headers = {
146
+ "Accept": "*/*",
147
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
148
+ "Accept-Encoding": "gzip, deflate, br",
149
+ "Connection": "keep-alive",
150
+ "Sec-Fetch-Dest": "document",
151
+ "Sec-Fetch-Mode": "navigate",
152
+ "Sec-Fetch-Site": "none",
153
+ "Upgrade-Insecure-Requests": "1"
154
+ }
155
+ response = await session.get(
156
+ url,
157
+ timeout=60,
158
+ proxies=proxies,
159
+ headers=headers,
160
+ impersonate="chrome120",
161
+ verify=False
162
+ )
163
+
164
+ if response.status_code == 200:
165
+ with open(file_path, 'wb') as f:
166
+ f.write(response.content)
167
+ debug_logger.log_info(f"File cached (curl_cffi): {filename} ({len(response.content)} bytes)")
168
+ return filename
169
+ else:
170
+ debug_logger.log_warning(f"curl_cffi failed with HTTP {response.status_code}, trying wget...")
171
+
172
+ except Exception as e:
173
+ debug_logger.log_warning(f"curl_cffi failed: {str(e)}, trying wget...")
174
+
175
+ # Try method 2: wget command
176
+ try:
177
+ import subprocess
178
+
179
+ wget_cmd = [
180
+ "wget",
181
+ "-q", # Quiet mode
182
+ "-O", str(file_path), # Output file
183
+ "--timeout=60",
184
+ "--tries=3",
185
+ "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
186
+ "--header=Accept: */*",
187
+ "--header=Accept-Language: zh-CN,zh;q=0.9,en;q=0.8",
188
+ "--header=Connection: keep-alive"
189
+ ]
190
+
191
+ # Add proxy if configured
192
+ if proxy_url:
193
+ # wget uses environment variables for proxy
194
+ env = os.environ.copy()
195
+ env['http_proxy'] = proxy_url
196
+ env['https_proxy'] = proxy_url
197
+ else:
198
+ env = None
199
+
200
+ # Add URL
201
+ wget_cmd.append(url)
202
+
203
+ # Execute wget
204
+ result = subprocess.run(wget_cmd, capture_output=True, timeout=90, env=env)
205
+
206
+ if result.returncode == 0 and file_path.exists():
207
+ file_size = file_path.stat().st_size
208
+ if file_size > 0:
209
+ debug_logger.log_info(f"File cached (wget): {filename} ({file_size} bytes)")
210
+ return filename
211
+ else:
212
+ raise Exception("Downloaded file is empty")
213
+ else:
214
+ error_msg = result.stderr.decode('utf-8', errors='ignore') if result.stderr else "Unknown error"
215
+ debug_logger.log_warning(f"wget failed: {error_msg}, trying curl...")
216
+
217
+ except FileNotFoundError:
218
+ debug_logger.log_warning("wget not found, trying curl...")
219
+ except Exception as e:
220
+ debug_logger.log_warning(f"wget failed: {str(e)}, trying curl...")
221
+
222
+ # Try method 3: system curl command
223
+ try:
224
+ import subprocess
225
+
226
+ curl_cmd = [
227
+ "curl",
228
+ "-L", # Follow redirects
229
+ "-s", # Silent mode
230
+ "-o", str(file_path), # Output file
231
+ "--max-time", "60",
232
+ "-H", "Accept: */*",
233
+ "-H", "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8",
234
+ "-H", "Connection: keep-alive",
235
+ "-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
236
+ ]
237
+
238
+ # Add proxy if configured
239
+ if proxy_url:
240
+ curl_cmd.extend(["-x", proxy_url])
241
+
242
+ # Add URL
243
+ curl_cmd.append(url)
244
+
245
+ # Execute curl
246
+ result = subprocess.run(curl_cmd, capture_output=True, timeout=90)
247
+
248
+ if result.returncode == 0 and file_path.exists():
249
+ file_size = file_path.stat().st_size
250
+ if file_size > 0:
251
+ debug_logger.log_info(f"File cached (curl): {filename} ({file_size} bytes)")
252
+ return filename
253
+ else:
254
+ raise Exception("Downloaded file is empty")
255
+ else:
256
+ error_msg = result.stderr.decode('utf-8', errors='ignore') if result.stderr else "Unknown error"
257
+ raise Exception(f"curl command failed: {error_msg}")
258
+
259
+ except Exception as e:
260
+ debug_logger.log_error(
261
+ error_message=f"Failed to download file: {str(e)}",
262
+ status_code=0,
263
+ response_text=str(e)
264
+ )
265
+ raise Exception(f"Failed to cache file: {str(e)}")
266
+
267
+ def get_cache_path(self, filename: str) -> Path:
268
+ """Get full path to cached file"""
269
+ return self.cache_dir / filename
270
+
271
+ def set_timeout(self, timeout: int):
272
+ """Set cache timeout in seconds"""
273
+ self.default_timeout = timeout
274
+ debug_logger.log_info(f"Cache timeout updated to {timeout} seconds")
275
+
276
+ def get_timeout(self) -> int:
277
+ """Get current cache timeout"""
278
+ return self.default_timeout
279
+
280
+ async def clear_all(self):
281
+ """Clear all cached files"""
282
+ try:
283
+ removed_count = 0
284
+ for file_path in self.cache_dir.iterdir():
285
+ if file_path.is_file():
286
+ try:
287
+ file_path.unlink()
288
+ removed_count += 1
289
+ except Exception:
290
+ pass
291
+
292
+ debug_logger.log_info(f"Cache cleared: removed {removed_count} files")
293
+ return removed_count
294
+
295
+ except Exception as e:
296
+ debug_logger.log_error(
297
+ error_message=f"Failed to clear cache: {str(e)}",
298
+ status_code=0,
299
+ response_text=""
300
+ )
301
+ raise
src/services/flow_client.py ADDED
@@ -0,0 +1,765 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # 获取 reCAPTCHA token
312
+ recaptcha_token = await self._get_recaptcha_token(project_id) or ""
313
+ session_id = self._generate_session_id()
314
+
315
+ # 构建请求
316
+ request_data = {
317
+ "clientContext": {
318
+ "recaptchaToken": recaptcha_token,
319
+ "projectId": project_id,
320
+ "sessionId": session_id,
321
+ "tool": "PINHOLE"
322
+ },
323
+ "seed": random.randint(1, 99999),
324
+ "imageModelName": model_name,
325
+ "imageAspectRatio": aspect_ratio,
326
+ "prompt": prompt,
327
+ "imageInputs": image_inputs or []
328
+ }
329
+
330
+ json_data = {
331
+ "clientContext": {
332
+ "recaptchaToken": recaptcha_token,
333
+ "sessionId": session_id
334
+ },
335
+ "requests": [request_data]
336
+ }
337
+
338
+ result = await self._make_request(
339
+ method="POST",
340
+ url=url,
341
+ json_data=json_data,
342
+ use_at=True,
343
+ at_token=at
344
+ )
345
+
346
+ return result
347
+
348
+ # ========== 视频生成 (使用AT) - 异步返回 ==========
349
+
350
+ async def generate_video_text(
351
+ self,
352
+ at: str,
353
+ project_id: str,
354
+ prompt: str,
355
+ model_key: str,
356
+ aspect_ratio: str,
357
+ user_paygate_tier: str = "PAYGATE_TIER_ONE"
358
+ ) -> dict:
359
+ """文生视频,返回task_id
360
+
361
+ Args:
362
+ at: Access Token
363
+ project_id: 项目ID
364
+ prompt: 提示词
365
+ model_key: veo_3_1_t2v_fast 等
366
+ aspect_ratio: 视频宽高比
367
+ user_paygate_tier: 用户等级
368
+
369
+ Returns:
370
+ {
371
+ "operations": [{
372
+ "operation": {"name": "task_id"},
373
+ "sceneId": "uuid",
374
+ "status": "MEDIA_GENERATION_STATUS_PENDING"
375
+ }],
376
+ "remainingCredits": 900
377
+ }
378
+ """
379
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoText"
380
+
381
+ # 获取 reCAPTCHA token
382
+ recaptcha_token = await self._get_recaptcha_token(project_id) or ""
383
+ session_id = self._generate_session_id()
384
+ scene_id = str(uuid.uuid4())
385
+
386
+ json_data = {
387
+ "clientContext": {
388
+ "recaptchaToken": recaptcha_token,
389
+ "sessionId": session_id,
390
+ "projectId": project_id,
391
+ "tool": "PINHOLE",
392
+ "userPaygateTier": user_paygate_tier
393
+ },
394
+ "requests": [{
395
+ "aspectRatio": aspect_ratio,
396
+ "seed": random.randint(1, 99999),
397
+ "textInput": {
398
+ "prompt": prompt
399
+ },
400
+ "videoModelKey": model_key,
401
+ "metadata": {
402
+ "sceneId": scene_id
403
+ }
404
+ }]
405
+ }
406
+
407
+ result = await self._make_request(
408
+ method="POST",
409
+ url=url,
410
+ json_data=json_data,
411
+ use_at=True,
412
+ at_token=at
413
+ )
414
+
415
+ return result
416
+
417
+ async def generate_video_reference_images(
418
+ self,
419
+ at: str,
420
+ project_id: str,
421
+ prompt: str,
422
+ model_key: str,
423
+ aspect_ratio: str,
424
+ reference_images: List[Dict],
425
+ user_paygate_tier: str = "PAYGATE_TIER_ONE"
426
+ ) -> dict:
427
+ """图生视频,返回task_id
428
+
429
+ Args:
430
+ at: Access Token
431
+ project_id: 项目ID
432
+ prompt: 提示词
433
+ model_key: veo_3_0_r2v_fast
434
+ aspect_ratio: 视频宽高比
435
+ reference_images: 参考图片列表 [{"imageUsageType": "IMAGE_USAGE_TYPE_ASSET", "mediaId": "..."}]
436
+ user_paygate_tier: 用户等级
437
+
438
+ Returns:
439
+ 同 generate_video_text
440
+ """
441
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoReferenceImages"
442
+
443
+ # 获取 reCAPTCHA token
444
+ recaptcha_token = await self._get_recaptcha_token(project_id) or ""
445
+ session_id = self._generate_session_id()
446
+ scene_id = str(uuid.uuid4())
447
+
448
+ json_data = {
449
+ "clientContext": {
450
+ "recaptchaToken": recaptcha_token,
451
+ "sessionId": session_id,
452
+ "projectId": project_id,
453
+ "tool": "PINHOLE",
454
+ "userPaygateTier": user_paygate_tier
455
+ },
456
+ "requests": [{
457
+ "aspectRatio": aspect_ratio,
458
+ "seed": random.randint(1, 99999),
459
+ "textInput": {
460
+ "prompt": prompt
461
+ },
462
+ "videoModelKey": model_key,
463
+ "referenceImages": reference_images,
464
+ "metadata": {
465
+ "sceneId": scene_id
466
+ }
467
+ }]
468
+ }
469
+
470
+ result = await self._make_request(
471
+ method="POST",
472
+ url=url,
473
+ json_data=json_data,
474
+ use_at=True,
475
+ at_token=at
476
+ )
477
+
478
+ return result
479
+
480
+ async def generate_video_start_end(
481
+ self,
482
+ at: str,
483
+ project_id: str,
484
+ prompt: str,
485
+ model_key: str,
486
+ aspect_ratio: str,
487
+ start_media_id: str,
488
+ end_media_id: str,
489
+ user_paygate_tier: str = "PAYGATE_TIER_ONE"
490
+ ) -> dict:
491
+ """收尾帧生成视频,返回task_id
492
+
493
+ Args:
494
+ at: Access Token
495
+ project_id: 项目ID
496
+ prompt: 提示词
497
+ model_key: veo_3_1_i2v_s_fast_fl
498
+ aspect_ratio: 视频宽高比
499
+ start_media_id: 起始帧mediaId
500
+ end_media_id: 结束帧mediaId
501
+ user_paygate_tier: 用户等级
502
+
503
+ Returns:
504
+ 同 generate_video_text
505
+ """
506
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartAndEndImage"
507
+
508
+ # 获取 reCAPTCHA token
509
+ recaptcha_token = await self._get_recaptcha_token(project_id) or ""
510
+ session_id = self._generate_session_id()
511
+ scene_id = str(uuid.uuid4())
512
+
513
+ json_data = {
514
+ "clientContext": {
515
+ "recaptchaToken": recaptcha_token,
516
+ "sessionId": session_id,
517
+ "projectId": project_id,
518
+ "tool": "PINHOLE",
519
+ "userPaygateTier": user_paygate_tier
520
+ },
521
+ "requests": [{
522
+ "aspectRatio": aspect_ratio,
523
+ "seed": random.randint(1, 99999),
524
+ "textInput": {
525
+ "prompt": prompt
526
+ },
527
+ "videoModelKey": model_key,
528
+ "startImage": {
529
+ "mediaId": start_media_id
530
+ },
531
+ "endImage": {
532
+ "mediaId": end_media_id
533
+ },
534
+ "metadata": {
535
+ "sceneId": scene_id
536
+ }
537
+ }]
538
+ }
539
+
540
+ result = await self._make_request(
541
+ method="POST",
542
+ url=url,
543
+ json_data=json_data,
544
+ use_at=True,
545
+ at_token=at
546
+ )
547
+
548
+ return result
549
+
550
+ async def generate_video_start_image(
551
+ self,
552
+ at: str,
553
+ project_id: str,
554
+ prompt: str,
555
+ model_key: str,
556
+ aspect_ratio: str,
557
+ start_media_id: str,
558
+ user_paygate_tier: str = "PAYGATE_TIER_ONE"
559
+ ) -> dict:
560
+ """仅首帧生成视频,返回task_id
561
+
562
+ Args:
563
+ at: Access Token
564
+ project_id: 项目ID
565
+ prompt: 提示词
566
+ model_key: veo_3_1_i2v_s_fast_fl等
567
+ aspect_ratio: 视频宽高比
568
+ start_media_id: 起始帧mediaId
569
+ user_paygate_tier: 用户等级
570
+
571
+ Returns:
572
+ 同 generate_video_text
573
+ """
574
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartAndEndImage"
575
+
576
+ # 获取 reCAPTCHA token
577
+ recaptcha_token = await self._get_recaptcha_token(project_id) or ""
578
+ session_id = self._generate_session_id()
579
+ scene_id = str(uuid.uuid4())
580
+
581
+ json_data = {
582
+ "clientContext": {
583
+ "recaptchaToken": recaptcha_token,
584
+ "sessionId": session_id,
585
+ "projectId": project_id,
586
+ "tool": "PINHOLE",
587
+ "userPaygateTier": user_paygate_tier
588
+ },
589
+ "requests": [{
590
+ "aspectRatio": aspect_ratio,
591
+ "seed": random.randint(1, 99999),
592
+ "textInput": {
593
+ "prompt": prompt
594
+ },
595
+ "videoModelKey": model_key,
596
+ "startImage": {
597
+ "mediaId": start_media_id
598
+ },
599
+ # 注意: 没有endImage字段,只用首帧
600
+ "metadata": {
601
+ "sceneId": scene_id
602
+ }
603
+ }]
604
+ }
605
+
606
+ result = await self._make_request(
607
+ method="POST",
608
+ url=url,
609
+ json_data=json_data,
610
+ use_at=True,
611
+ at_token=at
612
+ )
613
+
614
+ return result
615
+
616
+ # ========== 任务轮询 (使用AT) ==========
617
+
618
+ async def check_video_status(self, at: str, operations: List[Dict]) -> dict:
619
+ """查询视频生成状态
620
+
621
+ Args:
622
+ at: Access Token
623
+ operations: 操作列表 [{"operation": {"name": "task_id"}, "sceneId": "...", "status": "..."}]
624
+
625
+ Returns:
626
+ {
627
+ "operations": [{
628
+ "operation": {
629
+ "name": "task_id",
630
+ "metadata": {...} # 完成时包含视频信息
631
+ },
632
+ "status": "MEDIA_GENERATION_STATUS_SUCCESSFUL"
633
+ }]
634
+ }
635
+ """
636
+ url = f"{self.api_base_url}/video:batchCheckAsyncVideoGenerationStatus"
637
+
638
+ json_data = {
639
+ "operations": operations
640
+ }
641
+
642
+ result = await self._make_request(
643
+ method="POST",
644
+ url=url,
645
+ json_data=json_data,
646
+ use_at=True,
647
+ at_token=at
648
+ )
649
+
650
+ return result
651
+
652
+ # ========== 媒体删除 (使用ST) ==========
653
+
654
+ async def delete_media(self, st: str, media_names: List[str]):
655
+ """删除媒体
656
+
657
+ Args:
658
+ st: Session Token
659
+ media_names: 媒体ID列表
660
+ """
661
+ url = f"{self.labs_base_url}/trpc/media.deleteMedia"
662
+ json_data = {
663
+ "json": {
664
+ "names": media_names
665
+ }
666
+ }
667
+
668
+ await self._make_request(
669
+ method="POST",
670
+ url=url,
671
+ json_data=json_data,
672
+ use_st=True,
673
+ st_token=st
674
+ )
675
+
676
+ # ========== 辅助方法 ==========
677
+
678
+ def _generate_session_id(self) -> str:
679
+ """生成sessionId: ;timestamp"""
680
+ return f";{int(time.time() * 1000)}"
681
+
682
+ def _generate_scene_id(self) -> str:
683
+ """生成sceneId: UUID"""
684
+ return str(uuid.uuid4())
685
+
686
+ async def _get_recaptcha_token(self, project_id: str) -> Optional[str]:
687
+ """获取reCAPTCHA token - 支持两种方式"""
688
+ captcha_method = config.captcha_method
689
+
690
+ # 恒定浏览器打码
691
+ if captcha_method == "personal":
692
+ try:
693
+ from .browser_captcha_personal import BrowserCaptchaService
694
+ service = await BrowserCaptchaService.get_instance(self.proxy_manager)
695
+ return await service.get_token(project_id)
696
+ except Exception as e:
697
+ debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
698
+ return None
699
+ # 无头浏览器打码
700
+ elif captcha_method == "browser":
701
+ try:
702
+ from .browser_captcha import BrowserCaptchaService
703
+ service = await BrowserCaptchaService.get_instance(self.proxy_manager)
704
+ return await service.get_token(project_id)
705
+ except Exception as e:
706
+ debug_logger.log_error(f"[reCAPTCHA Browser] error: {str(e)}")
707
+ return None
708
+ else:
709
+ # YesCaptcha打码
710
+ client_key = config.yescaptcha_api_key
711
+ if not client_key:
712
+ debug_logger.log_info("[reCAPTCHA] API key not configured, skipping")
713
+ return None
714
+
715
+ website_key = "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV"
716
+ website_url = f"https://labs.google/fx/tools/flow/project/{project_id}"
717
+ base_url = config.yescaptcha_base_url
718
+ page_action = "FLOW_GENERATION"
719
+
720
+ try:
721
+ async with AsyncSession() as session:
722
+ create_url = f"{base_url}/createTask"
723
+ create_data = {
724
+ "clientKey": client_key,
725
+ "task": {
726
+ "websiteURL": website_url,
727
+ "websiteKey": website_key,
728
+ "type": "RecaptchaV3TaskProxylessM1",
729
+ "pageAction": page_action
730
+ }
731
+ }
732
+
733
+ result = await session.post(create_url, json=create_data, impersonate="chrome110")
734
+ result_json = result.json()
735
+ task_id = result_json.get('taskId')
736
+
737
+ debug_logger.log_info(f"[reCAPTCHA] created task_id: {task_id}")
738
+
739
+ if not task_id:
740
+ return None
741
+
742
+ get_url = f"{base_url}/getTaskResult"
743
+ for i in range(40):
744
+ get_data = {
745
+ "clientKey": client_key,
746
+ "taskId": task_id
747
+ }
748
+ result = await session.post(get_url, json=get_data, impersonate="chrome110")
749
+ result_json = result.json()
750
+
751
+ debug_logger.log_info(f"[reCAPTCHA] polling #{i+1}: {result_json}")
752
+
753
+ solution = result_json.get('solution', {})
754
+ response = solution.get('gRecaptchaResponse')
755
+
756
+ if response:
757
+ return response
758
+
759
+ time.sleep(3)
760
+
761
+ return None
762
+
763
+ except Exception as e:
764
+ debug_logger.log_error(f"[reCAPTCHA] error: {str(e)}")
765
+ return None
src/services/generation_handler.py ADDED
@@ -0,0 +1,1018 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # veo_3_1_t2v_fast_portrait_ultra (竖屏)
106
+ "veo_3_1_t2v_fast_portrait_ultra": {
107
+ "type": "video",
108
+ "video_type": "t2v",
109
+ "model_key": "veo_3_1_t2v_fast_portrait_ultra",
110
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
111
+ "supports_images": False
112
+ },
113
+
114
+ # veo_3_1_t2v_fast_portrait_ultra_relaxed (竖屏)
115
+ "veo_3_1_t2v_fast_portrait_ultra_relaxed": {
116
+ "type": "video",
117
+ "video_type": "t2v",
118
+ "model_key": "veo_3_1_t2v_fast_portrait_ultra_relaxed",
119
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
120
+ "supports_images": False
121
+ },
122
+
123
+ # veo_3_1_t2v_portrait (竖屏)
124
+ "veo_3_1_t2v_portrait": {
125
+ "type": "video",
126
+ "video_type": "t2v",
127
+ "model_key": "veo_3_1_t2v_portrait",
128
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
129
+ "supports_images": False
130
+ },
131
+
132
+ # ========== 首尾帧模型 (I2V - Image to Video) ==========
133
+ # 支持1-2张图片:1张作为首帧,2张作为首尾帧
134
+
135
+ # veo_3_1_i2v_s_fast_fl (需要新增横竖屏)
136
+ "veo_3_1_i2v_s_fast_fl_portrait": {
137
+ "type": "video",
138
+ "video_type": "i2v",
139
+ "model_key": "veo_3_1_i2v_s_fast_fl",
140
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
141
+ "supports_images": True,
142
+ "min_images": 1,
143
+ "max_images": 2
144
+ },
145
+ "veo_3_1_i2v_s_fast_fl_landscape": {
146
+ "type": "video",
147
+ "video_type": "i2v",
148
+ "model_key": "veo_3_1_i2v_s_fast_fl",
149
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
150
+ "supports_images": True,
151
+ "min_images": 1,
152
+ "max_images": 2
153
+ },
154
+
155
+ # veo_2_1_fast_d_15_i2v (需要新增横竖屏)
156
+ "veo_2_1_fast_d_15_i2v_portrait": {
157
+ "type": "video",
158
+ "video_type": "i2v",
159
+ "model_key": "veo_2_1_fast_d_15_i2v",
160
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
161
+ "supports_images": True,
162
+ "min_images": 1,
163
+ "max_images": 2
164
+ },
165
+ "veo_2_1_fast_d_15_i2v_landscape": {
166
+ "type": "video",
167
+ "video_type": "i2v",
168
+ "model_key": "veo_2_1_fast_d_15_i2v",
169
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
170
+ "supports_images": True,
171
+ "min_images": 1,
172
+ "max_images": 2
173
+ },
174
+
175
+ # veo_2_0_i2v (需要新增横竖屏)
176
+ "veo_2_0_i2v_portrait": {
177
+ "type": "video",
178
+ "video_type": "i2v",
179
+ "model_key": "veo_2_0_i2v",
180
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
181
+ "supports_images": True,
182
+ "min_images": 1,
183
+ "max_images": 2
184
+ },
185
+ "veo_2_0_i2v_landscape": {
186
+ "type": "video",
187
+ "video_type": "i2v",
188
+ "model_key": "veo_2_0_i2v",
189
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
190
+ "supports_images": True,
191
+ "min_images": 1,
192
+ "max_images": 2
193
+ },
194
+
195
+ # veo_3_1_i2v_s_fast_ultra (需要新增横竖屏)
196
+ "veo_3_1_i2v_s_fast_ultra_portrait": {
197
+ "type": "video",
198
+ "video_type": "i2v",
199
+ "model_key": "veo_3_1_i2v_s_fast_ultra",
200
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
201
+ "supports_images": True,
202
+ "min_images": 1,
203
+ "max_images": 2
204
+ },
205
+ "veo_3_1_i2v_s_fast_ultra_landscape": {
206
+ "type": "video",
207
+ "video_type": "i2v",
208
+ "model_key": "veo_3_1_i2v_s_fast_ultra",
209
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
210
+ "supports_images": True,
211
+ "min_images": 1,
212
+ "max_images": 2
213
+ },
214
+
215
+ # veo_3_1_i2v_s_fast_ultra_relaxed (需要新增横竖屏)
216
+ "veo_3_1_i2v_s_fast_ultra_relaxed_portrait": {
217
+ "type": "video",
218
+ "video_type": "i2v",
219
+ "model_key": "veo_3_1_i2v_s_fast_ultra_relaxed",
220
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
221
+ "supports_images": True,
222
+ "min_images": 1,
223
+ "max_images": 2
224
+ },
225
+ "veo_3_1_i2v_s_fast_ultra_relaxed_landscape": {
226
+ "type": "video",
227
+ "video_type": "i2v",
228
+ "model_key": "veo_3_1_i2v_s_fast_ultra_relaxed",
229
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
230
+ "supports_images": True,
231
+ "min_images": 1,
232
+ "max_images": 2
233
+ },
234
+
235
+ # veo_3_1_i2v_s (需要新增横竖屏)
236
+ "veo_3_1_i2v_s_portrait": {
237
+ "type": "video",
238
+ "video_type": "i2v",
239
+ "model_key": "veo_3_1_i2v_s",
240
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
241
+ "supports_images": True,
242
+ "min_images": 1,
243
+ "max_images": 2
244
+ },
245
+ "veo_3_1_i2v_s_landscape": {
246
+ "type": "video",
247
+ "video_type": "i2v",
248
+ "model_key": "veo_3_1_i2v_s",
249
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
250
+ "supports_images": True,
251
+ "min_images": 1,
252
+ "max_images": 2
253
+ },
254
+
255
+ # ========== 多图生成 (R2V - Reference Images to Video) ==========
256
+ # 支持多张图片,不限制数量
257
+
258
+ # veo_3_0_r2v_fast (需要新增横竖屏)
259
+ "veo_3_0_r2v_fast_portrait": {
260
+ "type": "video",
261
+ "video_type": "r2v",
262
+ "model_key": "veo_3_0_r2v_fast",
263
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
264
+ "supports_images": True,
265
+ "min_images": 0,
266
+ "max_images": None # 不限制
267
+ },
268
+ "veo_3_0_r2v_fast_landscape": {
269
+ "type": "video",
270
+ "video_type": "r2v",
271
+ "model_key": "veo_3_0_r2v_fast",
272
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
273
+ "supports_images": True,
274
+ "min_images": 0,
275
+ "max_images": None # 不限制
276
+ },
277
+
278
+ # veo_3_0_r2v_fast_ultra (需要新增横竖屏)
279
+ "veo_3_0_r2v_fast_ultra_portrait": {
280
+ "type": "video",
281
+ "video_type": "r2v",
282
+ "model_key": "veo_3_0_r2v_fast_ultra",
283
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
284
+ "supports_images": True,
285
+ "min_images": 0,
286
+ "max_images": None # 不限制
287
+ },
288
+ "veo_3_0_r2v_fast_ultra_landscape": {
289
+ "type": "video",
290
+ "video_type": "r2v",
291
+ "model_key": "veo_3_0_r2v_fast_ultra",
292
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
293
+ "supports_images": True,
294
+ "min_images": 0,
295
+ "max_images": None # 不限制
296
+ },
297
+
298
+ # veo_3_0_r2v_fast_ultra_relaxed (需要新增横竖屏)
299
+ "veo_3_0_r2v_fast_ultra_relaxed_portrait": {
300
+ "type": "video",
301
+ "video_type": "r2v",
302
+ "model_key": "veo_3_0_r2v_fast_ultra_relaxed",
303
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
304
+ "supports_images": True,
305
+ "min_images": 0,
306
+ "max_images": None # 不限制
307
+ },
308
+ "veo_3_0_r2v_fast_ultra_relaxed_landscape": {
309
+ "type": "video",
310
+ "video_type": "r2v",
311
+ "model_key": "veo_3_0_r2v_fast_ultra_relaxed",
312
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
313
+ "supports_images": True,
314
+ "min_images": 0,
315
+ "max_images": None # 不限制
316
+ }
317
+ }
318
+
319
+
320
+ class GenerationHandler:
321
+ """统一生成处理器"""
322
+
323
+ def __init__(self, flow_client, token_manager, load_balancer, db, concurrency_manager, proxy_manager):
324
+ self.flow_client = flow_client
325
+ self.token_manager = token_manager
326
+ self.load_balancer = load_balancer
327
+ self.db = db
328
+ self.concurrency_manager = concurrency_manager
329
+ self.file_cache = FileCache(
330
+ cache_dir="tmp",
331
+ default_timeout=config.cache_timeout,
332
+ proxy_manager=proxy_manager
333
+ )
334
+
335
+ async def check_token_availability(self, is_image: bool, is_video: bool) -> bool:
336
+ """检查Token可用性
337
+
338
+ Args:
339
+ is_image: 是否检查图片生成Token
340
+ is_video: 是否检查视频生成Token
341
+
342
+ Returns:
343
+ True表示有可用Token, False表示无可用Token
344
+ """
345
+ token_obj = await self.load_balancer.select_token(
346
+ for_image_generation=is_image,
347
+ for_video_generation=is_video
348
+ )
349
+ return token_obj is not None
350
+
351
+ async def handle_generation(
352
+ self,
353
+ model: str,
354
+ prompt: str,
355
+ images: Optional[List[bytes]] = None,
356
+ stream: bool = False
357
+ ) -> AsyncGenerator:
358
+ """统一生成入口
359
+
360
+ Args:
361
+ model: 模型名称
362
+ prompt: 提示词
363
+ images: 图片列表 (bytes格式)
364
+ stream: 是否流式输出
365
+ """
366
+ start_time = time.time()
367
+ token = None
368
+
369
+ # 1. 验证模型
370
+ if model not in MODEL_CONFIG:
371
+ error_msg = f"不支持的模型: {model}"
372
+ debug_logger.log_error(error_msg)
373
+ yield self._create_error_response(error_msg)
374
+ return
375
+
376
+ model_config = MODEL_CONFIG[model]
377
+ generation_type = model_config["type"]
378
+ debug_logger.log_info(f"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...")
379
+
380
+ # 非流式模式: 只检查可用性
381
+ if not stream:
382
+ is_image = (generation_type == "image")
383
+ is_video = (generation_type == "video")
384
+ available = await self.check_token_availability(is_image, is_video)
385
+
386
+ if available:
387
+ if is_image:
388
+ message = "所有Token可用于图片生成。请启用流式模式使用生成功能。"
389
+ else:
390
+ message = "所有Token可用于视频生成。请启用流式模式使用生成功能。"
391
+ else:
392
+ if is_image:
393
+ message = "没有可用的Token进行图片生成"
394
+ else:
395
+ message = "没有可用的Token进行视频生成"
396
+
397
+ yield self._create_completion_response(message, is_availability_check=True)
398
+ return
399
+
400
+ # 向用户展示开始信息
401
+ if stream:
402
+ yield self._create_stream_chunk(
403
+ f"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\n",
404
+ role="assistant"
405
+ )
406
+
407
+ # 2. 选择Token
408
+ debug_logger.log_info(f"[GENERATION] 正在选择可用Token...")
409
+
410
+ if generation_type == "image":
411
+ token = await self.load_balancer.select_token(for_image_generation=True, model=model)
412
+ else:
413
+ token = await self.load_balancer.select_token(for_video_generation=True, model=model)
414
+
415
+ if not token:
416
+ error_msg = self._get_no_token_error_message(generation_type)
417
+ debug_logger.log_error(f"[GENERATION] {error_msg}")
418
+ if stream:
419
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
420
+ yield self._create_error_response(error_msg)
421
+ return
422
+
423
+ debug_logger.log_info(f"[GENERATION] 已选择Token: {token.id} ({token.email})")
424
+
425
+ try:
426
+ # 3. 确保AT有效
427
+ debug_logger.log_info(f"[GENERATION] 检查Token AT有效性...")
428
+ if stream:
429
+ yield self._create_stream_chunk("初始化生成环境...\n")
430
+
431
+ if not await self.token_manager.is_at_valid(token.id):
432
+ error_msg = "Token AT无效或刷新失败"
433
+ debug_logger.log_error(f"[GENERATION] {error_msg}")
434
+ if stream:
435
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
436
+ yield self._create_error_response(error_msg)
437
+ return
438
+
439
+ # 重新获取token (AT可能已刷新)
440
+ token = await self.token_manager.get_token(token.id)
441
+
442
+ # 4. 确保Project存在
443
+ debug_logger.log_info(f"[GENERATION] 检查/创建Project...")
444
+
445
+ project_id = await self.token_manager.ensure_project_exists(token.id)
446
+ debug_logger.log_info(f"[GENERATION] Project ID: {project_id}")
447
+
448
+ # 5. 根据类型处理
449
+ if generation_type == "image":
450
+ debug_logger.log_info(f"[GENERATION] 开始图片生成流程...")
451
+ async for chunk in self._handle_image_generation(
452
+ token, project_id, model_config, prompt, images, stream
453
+ ):
454
+ yield chunk
455
+ else: # video
456
+ debug_logger.log_info(f"[GENERATION] 开始视频生成流程...")
457
+ async for chunk in self._handle_video_generation(
458
+ token, project_id, model_config, prompt, images, stream
459
+ ):
460
+ yield chunk
461
+
462
+ # 6. 记录使用
463
+ is_video = (generation_type == "video")
464
+ await self.token_manager.record_usage(token.id, is_video=is_video)
465
+
466
+ # 重置错误计数 (请求成功时清空连续错误计数)
467
+ await self.token_manager.record_success(token.id)
468
+
469
+ debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成")
470
+
471
+ # 7. 记录成功日志
472
+ duration = time.time() - start_time
473
+
474
+ # 构建响应数据,包含生成的URL
475
+ response_data = {
476
+ "status": "success",
477
+ "model": model,
478
+ "prompt": prompt[:100]
479
+ }
480
+
481
+ # 添加生成的URL(如果有)
482
+ if hasattr(self, '_last_generated_url') and self._last_generated_url:
483
+ response_data["url"] = self._last_generated_url
484
+ # 清除临时存储
485
+ self._last_generated_url = None
486
+
487
+ await self._log_request(
488
+ token.id,
489
+ f"generate_{generation_type}",
490
+ {"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0},
491
+ response_data,
492
+ 200,
493
+ duration
494
+ )
495
+
496
+ except Exception as e:
497
+ error_msg = f"生成失败: {str(e)}"
498
+ debug_logger.log_error(f"[GENERATION] ❌ {error_msg}")
499
+ if stream:
500
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
501
+ if token:
502
+ # 记录错误(所有错误统一处理,不再特殊处理429)
503
+ await self.token_manager.record_error(token.id)
504
+ yield self._create_error_response(error_msg)
505
+
506
+ # 记录失败日志
507
+ duration = time.time() - start_time
508
+ await self._log_request(
509
+ token.id if token else None,
510
+ f"generate_{generation_type if model_config else 'unknown'}",
511
+ {"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0},
512
+ {"error": error_msg},
513
+ 500,
514
+ duration
515
+ )
516
+
517
+ def _get_no_token_error_message(self, generation_type: str) -> str:
518
+ """获取无可用Token时的详细错误信息"""
519
+ if generation_type == "image":
520
+ return "没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。"
521
+ else:
522
+ return "没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。"
523
+
524
+ async def _handle_image_generation(
525
+ self,
526
+ token,
527
+ project_id: str,
528
+ model_config: dict,
529
+ prompt: str,
530
+ images: Optional[List[bytes]],
531
+ stream: bool
532
+ ) -> AsyncGenerator:
533
+ """处理图片生成 (同步返回)"""
534
+
535
+ # 获取并发槽位
536
+ if self.concurrency_manager:
537
+ if not await self.concurrency_manager.acquire_image(token.id):
538
+ yield self._create_error_response("图片并发限制已达上限")
539
+ return
540
+
541
+ try:
542
+ # 上传图片 (如果有)
543
+ image_inputs = []
544
+ if images and len(images) > 0:
545
+ if stream:
546
+ yield self._create_stream_chunk(f"上传 {len(images)} 张参考图片...\n")
547
+
548
+ # 支持多图输入
549
+ for idx, image_bytes in enumerate(images):
550
+ media_id = await self.flow_client.upload_image(
551
+ token.at,
552
+ image_bytes,
553
+ model_config["aspect_ratio"]
554
+ )
555
+ image_inputs.append({
556
+ "name": media_id,
557
+ "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"
558
+ })
559
+ if stream:
560
+ yield self._create_stream_chunk(f"已上传第 {idx + 1}/{len(images)} 张图片\n")
561
+
562
+ # 调用生成API
563
+ if stream:
564
+ yield self._create_stream_chunk("正在生成图片...\n")
565
+
566
+ result = await self.flow_client.generate_image(
567
+ at=token.at,
568
+ project_id=project_id,
569
+ prompt=prompt,
570
+ model_name=model_config["model_name"],
571
+ aspect_ratio=model_config["aspect_ratio"],
572
+ image_inputs=image_inputs
573
+ )
574
+
575
+ # 提取URL
576
+ media = result.get("media", [])
577
+ if not media:
578
+ yield self._create_error_response("生成结果为空")
579
+ return
580
+
581
+ image_url = media[0]["image"]["generatedImage"]["fifeUrl"]
582
+
583
+ # 缓存图片 (如果启用)
584
+ local_url = image_url
585
+ if config.cache_enabled:
586
+ try:
587
+ if stream:
588
+ yield self._create_stream_chunk("缓存图片中...\n")
589
+ cached_filename = await self.file_cache.download_and_cache(image_url, "image")
590
+ local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
591
+ if stream:
592
+ yield self._create_stream_chunk("✅ 图片缓存成功,准备返回缓存地址...\n")
593
+ except Exception as e:
594
+ debug_logger.log_error(f"Failed to cache image: {str(e)}")
595
+ # 缓存失败不影响结果返回,使用原始URL
596
+ local_url = image_url
597
+ if stream:
598
+ yield self._create_stream_chunk(f"⚠️ 缓存失败: {str(e)}\n正在返回源链接...\n")
599
+ else:
600
+ if stream:
601
+ yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n")
602
+
603
+ # 返回结果
604
+ # 存储URL用于日志记录
605
+ self._last_generated_url = local_url
606
+
607
+ if stream:
608
+ yield self._create_stream_chunk(
609
+ f"![Generated Image]({local_url})",
610
+ finish_reason="stop"
611
+ )
612
+ else:
613
+ yield self._create_completion_response(
614
+ local_url, # 直接传URL,让方法内部格式化
615
+ media_type="image"
616
+ )
617
+
618
+ finally:
619
+ # 释放并发槽位
620
+ if self.concurrency_manager:
621
+ await self.concurrency_manager.release_image(token.id)
622
+
623
+ async def _handle_video_generation(
624
+ self,
625
+ token,
626
+ project_id: str,
627
+ model_config: dict,
628
+ prompt: str,
629
+ images: Optional[List[bytes]],
630
+ stream: bool
631
+ ) -> AsyncGenerator:
632
+ """处理视频生成 (异步轮询)"""
633
+
634
+ # 获取并发槽位
635
+ if self.concurrency_manager:
636
+ if not await self.concurrency_manager.acquire_video(token.id):
637
+ yield self._create_error_response("视频并发限制已达上限")
638
+ return
639
+
640
+ try:
641
+ # 获取模型类型和配置
642
+ video_type = model_config.get("video_type")
643
+ supports_images = model_config.get("supports_images", False)
644
+ min_images = model_config.get("min_images", 0)
645
+ max_images = model_config.get("max_images", 0)
646
+
647
+ # 图片数量
648
+ image_count = len(images) if images else 0
649
+
650
+ # ========== 验证和处理图片 ==========
651
+
652
+ # T2V: 文生视频 - 不支持图片
653
+ if video_type == "t2v":
654
+ if image_count > 0:
655
+ if stream:
656
+ yield self._create_stream_chunk("⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\n")
657
+ debug_logger.log_warning(f"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片")
658
+ images = None # 清空图片
659
+ image_count = 0
660
+
661
+ # I2V: 首尾帧模型 - 需要1-2张图片
662
+ elif video_type == "i2v":
663
+ if image_count < min_images or image_count > max_images:
664
+ error_msg = f"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张"
665
+ if stream:
666
+ yield self._create_stream_chunk(f"{error_msg}\n")
667
+ yield self._create_error_response(error_msg)
668
+ return
669
+
670
+ # R2V: 多图生成 - 支持多张图片,不限制数量
671
+ elif video_type == "r2v":
672
+ # 不再限制最大图片数量
673
+ pass
674
+
675
+ # ========== 上传图片 ==========
676
+ start_media_id = None
677
+ end_media_id = None
678
+ reference_images = []
679
+
680
+ # I2V: 首尾帧处理
681
+ if video_type == "i2v" and images:
682
+ if image_count == 1:
683
+ # 只有1张图: 仅作为首帧
684
+ if stream:
685
+ yield self._create_stream_chunk("上传首帧图片...\n")
686
+ start_media_id = await self.flow_client.upload_image(
687
+ token.at, images[0], model_config["aspect_ratio"]
688
+ )
689
+ debug_logger.log_info(f"[I2V] 仅上传首帧: {start_media_id}")
690
+
691
+ elif image_count == 2:
692
+ # 2张图: 首帧+尾帧
693
+ if stream:
694
+ yield self._create_stream_chunk("上传首帧和尾帧图片...\n")
695
+ start_media_id = await self.flow_client.upload_image(
696
+ token.at, images[0], model_config["aspect_ratio"]
697
+ )
698
+ end_media_id = await self.flow_client.upload_image(
699
+ token.at, images[1], model_config["aspect_ratio"]
700
+ )
701
+ debug_logger.log_info(f"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}")
702
+
703
+ # R2V: 多图处理
704
+ elif video_type == "r2v" and images:
705
+ if stream:
706
+ yield self._create_stream_chunk(f"上传 {image_count} 张参考图片...\n")
707
+
708
+ for idx, img in enumerate(images): # 上传所有图片,不限制数量
709
+ media_id = await self.flow_client.upload_image(
710
+ token.at, img, model_config["aspect_ratio"]
711
+ )
712
+ reference_images.append({
713
+ "imageUsageType": "IMAGE_USAGE_TYPE_ASSET",
714
+ "mediaId": media_id
715
+ })
716
+ debug_logger.log_info(f"[R2V] 上传了 {len(reference_images)} 张参考图片")
717
+
718
+ # ========== 调用生成API ==========
719
+ if stream:
720
+ yield self._create_stream_chunk("提交视频生成任务...\n")
721
+
722
+ # I2V: 首尾帧生成
723
+ if video_type == "i2v" and start_media_id:
724
+ if end_media_id:
725
+ # 有首尾帧
726
+ result = await self.flow_client.generate_video_start_end(
727
+ at=token.at,
728
+ project_id=project_id,
729
+ prompt=prompt,
730
+ model_key=model_config["model_key"],
731
+ aspect_ratio=model_config["aspect_ratio"],
732
+ start_media_id=start_media_id,
733
+ end_media_id=end_media_id,
734
+ user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
735
+ )
736
+ else:
737
+ # 只有首帧
738
+ result = await self.flow_client.generate_video_start_image(
739
+ at=token.at,
740
+ project_id=project_id,
741
+ prompt=prompt,
742
+ model_key=model_config["model_key"],
743
+ aspect_ratio=model_config["aspect_ratio"],
744
+ start_media_id=start_media_id,
745
+ user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
746
+ )
747
+
748
+ # R2V: 多图生成
749
+ elif video_type == "r2v" and reference_images:
750
+ result = await self.flow_client.generate_video_reference_images(
751
+ at=token.at,
752
+ project_id=project_id,
753
+ prompt=prompt,
754
+ model_key=model_config["model_key"],
755
+ aspect_ratio=model_config["aspect_ratio"],
756
+ reference_images=reference_images,
757
+ user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
758
+ )
759
+
760
+ # T2V 或 R2V无图: 纯文本生成
761
+ else:
762
+ result = await self.flow_client.generate_video_text(
763
+ at=token.at,
764
+ project_id=project_id,
765
+ prompt=prompt,
766
+ model_key=model_config["model_key"],
767
+ aspect_ratio=model_config["aspect_ratio"],
768
+ user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
769
+ )
770
+
771
+ # 获取task_id和operations
772
+ operations = result.get("operations", [])
773
+ if not operations:
774
+ yield self._create_error_response("生成任务创建失败")
775
+ return
776
+
777
+ operation = operations[0]
778
+ task_id = operation["operation"]["name"]
779
+ scene_id = operation.get("sceneId")
780
+
781
+ # 保存Task到数据库
782
+ task = Task(
783
+ task_id=task_id,
784
+ token_id=token.id,
785
+ model=model_config["model_key"],
786
+ prompt=prompt,
787
+ status="processing",
788
+ scene_id=scene_id
789
+ )
790
+ await self.db.create_task(task)
791
+
792
+ # 轮询结果
793
+ if stream:
794
+ yield self._create_stream_chunk(f"视频生成中...\n")
795
+
796
+ async for chunk in self._poll_video_result(token, operations, stream):
797
+ yield chunk
798
+
799
+ finally:
800
+ # 释放并发槽位
801
+ if self.concurrency_manager:
802
+ await self.concurrency_manager.release_video(token.id)
803
+
804
+ async def _poll_video_result(
805
+ self,
806
+ token,
807
+ operations: List[Dict],
808
+ stream: bool
809
+ ) -> AsyncGenerator:
810
+ """轮询视频生成结果"""
811
+
812
+ max_attempts = config.max_poll_attempts
813
+ poll_interval = config.poll_interval
814
+
815
+ for attempt in range(max_attempts):
816
+ await asyncio.sleep(poll_interval)
817
+
818
+ try:
819
+ result = await self.flow_client.check_video_status(token.at, operations)
820
+ checked_operations = result.get("operations", [])
821
+
822
+ if not checked_operations:
823
+ continue
824
+
825
+ operation = checked_operations[0]
826
+ status = operation.get("status")
827
+
828
+ # 状态更新 - 每20秒报告一次 (poll_interval=3秒, 20秒约7次轮询)
829
+ progress_update_interval = 7 # 每7次轮询 = 21秒
830
+ if stream and attempt % progress_update_interval == 0: # 每20秒报告一次
831
+ progress = min(int((attempt / max_attempts) * 100), 95)
832
+ yield self._create_stream_chunk(f"生成进度: {progress}%\n")
833
+
834
+ # 检查状态
835
+ if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL":
836
+ # 成功
837
+ metadata = operation["operation"].get("metadata", {})
838
+ video_info = metadata.get("video", {})
839
+ video_url = video_info.get("fifeUrl")
840
+
841
+ if not video_url:
842
+ yield self._create_error_response("视频URL为空")
843
+ return
844
+
845
+ # 缓存视频 (如果启用)
846
+ local_url = video_url
847
+ if config.cache_enabled:
848
+ try:
849
+ if stream:
850
+ yield self._create_stream_chunk("正在缓存视频文件...\n")
851
+ cached_filename = await self.file_cache.download_and_cache(video_url, "video")
852
+ local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
853
+ if stream:
854
+ yield self._create_stream_chunk("✅ 视频缓存成功,准备返回缓存地址...\n")
855
+ except Exception as e:
856
+ debug_logger.log_error(f"Failed to cache video: {str(e)}")
857
+ # 缓存失败不影响结果返回,使用原始URL
858
+ local_url = video_url
859
+ if stream:
860
+ yield self._create_stream_chunk(f"⚠️ 缓存失败: {str(e)}\n正在返回源链接...\n")
861
+ else:
862
+ if stream:
863
+ yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n")
864
+
865
+ # 更新数据库
866
+ task_id = operation["operation"]["name"]
867
+ await self.db.update_task(
868
+ task_id,
869
+ status="completed",
870
+ progress=100,
871
+ result_urls=[local_url],
872
+ completed_at=time.time()
873
+ )
874
+
875
+ # 存储URL用于日志记录
876
+ self._last_generated_url = local_url
877
+
878
+ # 返回结果
879
+ if stream:
880
+ yield self._create_stream_chunk(
881
+ f"<video src='{local_url}' controls style='max-width:100%'></video>",
882
+ finish_reason="stop"
883
+ )
884
+ else:
885
+ yield self._create_completion_response(
886
+ local_url, # 直接传URL,让方法内部格式化
887
+ media_type="video"
888
+ )
889
+ return
890
+
891
+ elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"):
892
+ # 失败
893
+ yield self._create_error_response(f"视频生成失败: {status}")
894
+ return
895
+
896
+ except Exception as e:
897
+ debug_logger.log_error(f"Poll error: {str(e)}")
898
+ continue
899
+
900
+ # 超时
901
+ yield self._create_error_response(f"视频生成超时 (已轮询{max_attempts}次)")
902
+
903
+ # ========== 响应格式化 ==========
904
+
905
+ def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str:
906
+ """创建流式响应chunk"""
907
+ import json
908
+ import time
909
+
910
+ chunk = {
911
+ "id": f"chatcmpl-{int(time.time())}",
912
+ "object": "chat.completion.chunk",
913
+ "created": int(time.time()),
914
+ "model": "flow2api",
915
+ "choices": [{
916
+ "index": 0,
917
+ "delta": {},
918
+ "finish_reason": finish_reason
919
+ }]
920
+ }
921
+
922
+ if role:
923
+ chunk["choices"][0]["delta"]["role"] = role
924
+
925
+ if finish_reason:
926
+ chunk["choices"][0]["delta"]["content"] = content
927
+ else:
928
+ chunk["choices"][0]["delta"]["reasoning_content"] = content
929
+
930
+ return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
931
+
932
+ def _create_completion_response(self, content: str, media_type: str = "image", is_availability_check: bool = False) -> str:
933
+ """创建非流式响应
934
+
935
+ Args:
936
+ content: 媒体URL或纯文本消息
937
+ media_type: 媒体类型 ("image" 或 "video")
938
+ is_availability_check: 是否为可用性检查响应 (纯文本消息)
939
+
940
+ Returns:
941
+ JSON格式的响应
942
+ """
943
+ import json
944
+ import time
945
+
946
+ # 可用性检查: 返回纯文本消息
947
+ if is_availability_check:
948
+ formatted_content = content
949
+ else:
950
+ # 媒体生成: 根据媒体类型格式化内容为Markdown
951
+ if media_type == "video":
952
+ formatted_content = f"```html\n<video src='{content}' controls></video>\n```"
953
+ else: # image
954
+ formatted_content = f"![Generated Image]({content})"
955
+
956
+ response = {
957
+ "id": f"chatcmpl-{int(time.time())}",
958
+ "object": "chat.completion",
959
+ "created": int(time.time()),
960
+ "model": "flow2api",
961
+ "choices": [{
962
+ "index": 0,
963
+ "message": {
964
+ "role": "assistant",
965
+ "content": formatted_content
966
+ },
967
+ "finish_reason": "stop"
968
+ }]
969
+ }
970
+
971
+ return json.dumps(response, ensure_ascii=False)
972
+
973
+ def _create_error_response(self, error_message: str) -> str:
974
+ """创建错误响应"""
975
+ import json
976
+
977
+ error = {
978
+ "error": {
979
+ "message": error_message,
980
+ "type": "invalid_request_error",
981
+ "code": "generation_failed"
982
+ }
983
+ }
984
+
985
+ return json.dumps(error, ensure_ascii=False)
986
+
987
+ def _get_base_url(self) -> str:
988
+ """获取基础URL用于缓存文件访问"""
989
+ # 优先使用配置的cache_base_url
990
+ if config.cache_base_url:
991
+ return config.cache_base_url
992
+ # 否则使用服务器地址
993
+ return f"http://{config.server_host}:{config.server_port}"
994
+
995
+ async def _log_request(
996
+ self,
997
+ token_id: Optional[int],
998
+ operation: str,
999
+ request_data: Dict[str, Any],
1000
+ response_data: Dict[str, Any],
1001
+ status_code: int,
1002
+ duration: float
1003
+ ):
1004
+ """记录请求到数据库"""
1005
+ try:
1006
+ log = RequestLog(
1007
+ token_id=token_id,
1008
+ operation=operation,
1009
+ request_body=json.dumps(request_data, ensure_ascii=False),
1010
+ response_body=json.dumps(response_data, ensure_ascii=False),
1011
+ status_code=status_code,
1012
+ duration=duration
1013
+ )
1014
+ await self.db.add_request_log(log)
1015
+ except Exception as e:
1016
+ # 日志记录失败不影响主流程
1017
+ debug_logger.log_error(f"Failed to log request: {e}")
1018
+
src/services/load_balancer.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ model: Optional[str] = None
21
+ ) -> Optional[Token]:
22
+ """
23
+ Select a token using random load balancing
24
+
25
+ Args:
26
+ for_image_generation: If True, only select tokens with image_enabled=True
27
+ for_video_generation: If True, only select tokens with video_enabled=True
28
+ model: Model name (used to filter tokens for specific models)
29
+
30
+ Returns:
31
+ Selected token or None if no available tokens
32
+ """
33
+ debug_logger.log_info(f"[LOAD_BALANCER] 开始选择Token (图片生成={for_image_generation}, 视频生成={for_video_generation}, 模型={model})")
34
+
35
+ active_tokens = await self.token_manager.get_active_tokens()
36
+ debug_logger.log_info(f"[LOAD_BALANCER] 获取到 {len(active_tokens)} 个活跃Token")
37
+
38
+ if not active_tokens:
39
+ debug_logger.log_info(f"[LOAD_BALANCER] ❌ 没有活跃的Token")
40
+ return None
41
+
42
+ # Filter tokens based on generation type
43
+ available_tokens = []
44
+ filtered_reasons = {} # 记录过滤原因
45
+
46
+ for token in active_tokens:
47
+ # Check if token has valid AT (not expired)
48
+ if not await self.token_manager.is_at_valid(token.id):
49
+ filtered_reasons[token.id] = "AT无效或已过期"
50
+ continue
51
+
52
+ # Filter for image generation
53
+ if for_image_generation:
54
+ if not token.image_enabled:
55
+ filtered_reasons[token.id] = "图片生成已禁用"
56
+ continue
57
+
58
+ # Check concurrency limit
59
+ if self.concurrency_manager and not await self.concurrency_manager.can_use_image(token.id):
60
+ filtered_reasons[token.id] = "图片并发已满"
61
+ continue
62
+
63
+ # Filter for video generation
64
+ if for_video_generation:
65
+ if not token.video_enabled:
66
+ filtered_reasons[token.id] = "视频生成已禁用"
67
+ continue
68
+
69
+ # Check concurrency limit
70
+ if self.concurrency_manager and not await self.concurrency_manager.can_use_video(token.id):
71
+ filtered_reasons[token.id] = "视频并发已满"
72
+ continue
73
+
74
+ available_tokens.append(token)
75
+
76
+ # 输出过滤信息
77
+ if filtered_reasons:
78
+ debug_logger.log_info(f"[LOAD_BALANCER] 已过滤Token:")
79
+ for token_id, reason in filtered_reasons.items():
80
+ debug_logger.log_info(f"[LOAD_BALANCER] - Token {token_id}: {reason}")
81
+
82
+ if not available_tokens:
83
+ debug_logger.log_info(f"[LOAD_BALANCER] ❌ 没有可用的Token (图片生成={for_image_generation}, 视频生成={for_video_generation})")
84
+ return None
85
+
86
+ # Random selection
87
+ selected = random.choice(available_tokens)
88
+ debug_logger.log_info(f"[LOAD_BALANCER] ✅ 已选择Token {selected.id} ({selected.email}) - 余额: {selected.credits}")
89
+ 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,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 and reset error count"""
40
+ # Enable the token
41
+ await self.db.update_token(token_id, is_active=True)
42
+ # Reset error count when enabling (only reset total error_count, keep today_error_count)
43
+ await self.db.reset_error_count(token_id)
44
+
45
+ async def disable_token(self, token_id: int):
46
+ """Disable a token"""
47
+ await self.db.update_token(token_id, is_active=False)
48
+
49
+ # ========== Token添加 (支持Project创建) ==========
50
+
51
+ async def add_token(
52
+ self,
53
+ st: str,
54
+ project_id: Optional[str] = None,
55
+ project_name: Optional[str] = None,
56
+ remark: Optional[str] = None,
57
+ image_enabled: bool = True,
58
+ video_enabled: bool = True,
59
+ image_concurrency: int = -1,
60
+ video_concurrency: int = -1
61
+ ) -> Token:
62
+ """Add a new token
63
+
64
+ Args:
65
+ st: Session Token (必需)
66
+ project_id: 项目ID (可选,如果提供则直接使用,不创建新项目)
67
+ project_name: 项目名称 (可选,如果不提供则自动生成)
68
+ remark: 备注
69
+ image_enabled: 是否启用图片生成
70
+ video_enabled: 是否启用视频生成
71
+ image_concurrency: 图片并发限制
72
+ video_concurrency: 视频并发限制
73
+
74
+ Returns:
75
+ Token object
76
+ """
77
+ # Step 1: 检查ST是否已存在
78
+ existing_token = await self.db.get_token_by_st(st)
79
+ if existing_token:
80
+ raise ValueError(f"Token 已存在(邮箱: {existing_token.email})")
81
+
82
+ # Step 2: 使用ST转换AT
83
+ debug_logger.log_info(f"[ADD_TOKEN] Converting ST to AT...")
84
+ try:
85
+ result = await self.flow_client.st_to_at(st)
86
+ at = result["access_token"]
87
+ expires = result.get("expires")
88
+ user_info = result.get("user", {})
89
+ email = user_info.get("email", "")
90
+ name = user_info.get("name", email.split("@")[0] if email else "")
91
+
92
+ # 解析过期时间
93
+ at_expires = None
94
+ if expires:
95
+ try:
96
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
97
+ except:
98
+ pass
99
+
100
+ except Exception as e:
101
+ raise ValueError(f"ST转AT失败: {str(e)}")
102
+
103
+ # Step 3: 查询余额
104
+ try:
105
+ credits_result = await self.flow_client.get_credits(at)
106
+ credits = credits_result.get("credits", 0)
107
+ user_paygate_tier = credits_result.get("userPaygateTier")
108
+ except:
109
+ credits = 0
110
+ user_paygate_tier = None
111
+
112
+ # Step 4: 处理Project ID和名称
113
+ if project_id:
114
+ # 用户提供了project_id,直接使用
115
+ debug_logger.log_info(f"[ADD_TOKEN] Using provided project_id: {project_id}")
116
+ if not project_name:
117
+ # 如果没有提供project_name,生成一个
118
+ now = datetime.now()
119
+ project_name = now.strftime("%b %d - %H:%M")
120
+ else:
121
+ # 用户没有提供project_id,需要创建新项目
122
+ if not project_name:
123
+ # 自动生成项目名称
124
+ now = datetime.now()
125
+ project_name = now.strftime("%b %d - %H:%M")
126
+
127
+ try:
128
+ project_id = await self.flow_client.create_project(st, project_name)
129
+ debug_logger.log_info(f"[ADD_TOKEN] Created new project: {project_name} (ID: {project_id})")
130
+ except Exception as e:
131
+ raise ValueError(f"创建项目失败: {str(e)}")
132
+
133
+ # Step 5: 创建Token对象
134
+ token = Token(
135
+ st=st,
136
+ at=at,
137
+ at_expires=at_expires,
138
+ email=email,
139
+ name=name,
140
+ remark=remark,
141
+ is_active=True,
142
+ credits=credits,
143
+ user_paygate_tier=user_paygate_tier,
144
+ current_project_id=project_id,
145
+ current_project_name=project_name,
146
+ image_enabled=image_enabled,
147
+ video_enabled=video_enabled,
148
+ image_concurrency=image_concurrency,
149
+ video_concurrency=video_concurrency
150
+ )
151
+
152
+ # Step 6: 保存到数据库
153
+ token_id = await self.db.add_token(token)
154
+ token.id = token_id
155
+
156
+ # Step 7: 保存Project到数据库
157
+ project = Project(
158
+ project_id=project_id,
159
+ token_id=token_id,
160
+ project_name=project_name,
161
+ tool_name="PINHOLE"
162
+ )
163
+ await self.db.add_project(project)
164
+
165
+ debug_logger.log_info(f"[ADD_TOKEN] Token added successfully (ID: {token_id}, Email: {email})")
166
+ return token
167
+
168
+ async def update_token(
169
+ self,
170
+ token_id: int,
171
+ st: Optional[str] = None,
172
+ at: Optional[str] = None,
173
+ at_expires: Optional[datetime] = None,
174
+ project_id: Optional[str] = None,
175
+ project_name: Optional[str] = None,
176
+ remark: Optional[str] = None,
177
+ image_enabled: Optional[bool] = None,
178
+ video_enabled: Optional[bool] = None,
179
+ image_concurrency: Optional[int] = None,
180
+ video_concurrency: Optional[int] = None
181
+ ):
182
+ """Update token (支持修改project_id和project_name)
183
+
184
+ 当用户编辑保存token时,如果token未过期,自动清空429禁用状态
185
+ """
186
+ update_fields = {}
187
+
188
+ if st is not None:
189
+ update_fields["st"] = st
190
+ if at is not None:
191
+ update_fields["at"] = at
192
+ if at_expires is not None:
193
+ update_fields["at_expires"] = at_expires
194
+ if project_id is not None:
195
+ update_fields["current_project_id"] = project_id
196
+ if project_name is not None:
197
+ update_fields["current_project_name"] = project_name
198
+ if remark is not None:
199
+ update_fields["remark"] = remark
200
+ if image_enabled is not None:
201
+ update_fields["image_enabled"] = image_enabled
202
+ if video_enabled is not None:
203
+ update_fields["video_enabled"] = video_enabled
204
+ if image_concurrency is not None:
205
+ update_fields["image_concurrency"] = image_concurrency
206
+ if video_concurrency is not None:
207
+ update_fields["video_concurrency"] = video_concurrency
208
+
209
+ # 检查token是否因429被禁用,如果是且未过期,则清空429状态
210
+ token = await self.db.get_token(token_id)
211
+ if token and token.ban_reason == "429_rate_limit":
212
+ # 检查token是否过期
213
+ is_expired = False
214
+ if token.at_expires:
215
+ now = datetime.now(timezone.utc)
216
+ if token.at_expires.tzinfo is None:
217
+ at_expires_aware = token.at_expires.replace(tzinfo=timezone.utc)
218
+ else:
219
+ at_expires_aware = token.at_expires
220
+ is_expired = at_expires_aware <= now
221
+
222
+ # 如果未过期,清空429禁用状态
223
+ if not is_expired:
224
+ debug_logger.log_info(f"[UPDATE_TOKEN] Token {token_id} 编辑保存,清空429禁用状态")
225
+ update_fields["ban_reason"] = None
226
+ update_fields["banned_at"] = None
227
+
228
+ if update_fields:
229
+ await self.db.update_token(token_id, **update_fields)
230
+
231
+ # ========== AT自动刷新逻辑 (核心) ==========
232
+
233
+ async def is_at_valid(self, token_id: int) -> bool:
234
+ """检查AT是否有效,如果无效或即将过期则自动刷新
235
+
236
+ Returns:
237
+ True if AT is valid or refreshed successfully
238
+ False if AT cannot be refreshed
239
+ """
240
+ token = await self.db.get_token(token_id)
241
+ if not token:
242
+ return False
243
+
244
+ # 如果AT不存在,需要刷新
245
+ if not token.at:
246
+ debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT不存在,需要刷新")
247
+ return await self._refresh_at(token_id)
248
+
249
+ # 如果没有过期时间,假设需要刷新
250
+ if not token.at_expires:
251
+ debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT过期时间未知,尝试刷新")
252
+ return await self._refresh_at(token_id)
253
+
254
+ # 检查是否即将过期 (提前1小时刷新)
255
+ now = datetime.now(timezone.utc)
256
+ # 确保at_expires也是timezone-aware
257
+ if token.at_expires.tzinfo is None:
258
+ at_expires_aware = token.at_expires.replace(tzinfo=timezone.utc)
259
+ else:
260
+ at_expires_aware = token.at_expires
261
+
262
+ time_until_expiry = at_expires_aware - now
263
+
264
+ if time_until_expiry.total_seconds() < 3600: # 1 hour (3600 seconds)
265
+ debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT即将过期 (剩余 {time_until_expiry.total_seconds():.0f} 秒),需要刷新")
266
+ return await self._refresh_at(token_id)
267
+
268
+ # AT有效
269
+ return True
270
+
271
+ async def _refresh_at(self, token_id: int) -> bool:
272
+ """内部方法: 刷新AT
273
+
274
+ Returns:
275
+ True if refresh successful, False otherwise
276
+ """
277
+ async with self._lock:
278
+ token = await self.db.get_token(token_id)
279
+ if not token:
280
+ return False
281
+
282
+ try:
283
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: 开始刷新AT...")
284
+
285
+ # 使用ST转AT
286
+ result = await self.flow_client.st_to_at(token.st)
287
+ new_at = result["access_token"]
288
+ expires = result.get("expires")
289
+
290
+ # 解析过期时间
291
+ new_at_expires = None
292
+ if expires:
293
+ try:
294
+ new_at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
295
+ except:
296
+ pass
297
+
298
+ # 更新数据库
299
+ await self.db.update_token(
300
+ token_id,
301
+ at=new_at,
302
+ at_expires=new_at_expires
303
+ )
304
+
305
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: AT刷新成功")
306
+ debug_logger.log_info(f" - 新过期时间: {new_at_expires}")
307
+
308
+ # 同时刷新credits
309
+ try:
310
+ credits_result = await self.flow_client.get_credits(new_at)
311
+ await self.db.update_token(
312
+ token_id,
313
+ credits=credits_result.get("credits", 0)
314
+ )
315
+ except:
316
+ pass
317
+
318
+ return True
319
+
320
+ except Exception as e:
321
+ debug_logger.log_error(f"[AT_REFRESH] Token {token_id}: AT刷新失败 - {str(e)}")
322
+ # 刷新失败,禁用Token
323
+ await self.disable_token(token_id)
324
+ return False
325
+
326
+ async def ensure_project_exists(self, token_id: int) -> str:
327
+ """确保Token有可用的Project
328
+
329
+ Returns:
330
+ project_id
331
+ """
332
+ token = await self.db.get_token(token_id)
333
+ if not token:
334
+ raise ValueError("Token not found")
335
+
336
+ # 如果已有project_id,直接返回
337
+ if token.current_project_id:
338
+ return token.current_project_id
339
+
340
+ # 创建新Project
341
+ now = datetime.now()
342
+ project_name = now.strftime("%b %d - %H:%M")
343
+
344
+ try:
345
+ project_id = await self.flow_client.create_project(token.st, project_name)
346
+ debug_logger.log_info(f"[PROJECT] Created project for token {token_id}: {project_name}")
347
+
348
+ # 更新Token
349
+ await self.db.update_token(
350
+ token_id,
351
+ current_project_id=project_id,
352
+ current_project_name=project_name
353
+ )
354
+
355
+ # 保存Project到数据库
356
+ project = Project(
357
+ project_id=project_id,
358
+ token_id=token_id,
359
+ project_name=project_name
360
+ )
361
+ await self.db.add_project(project)
362
+
363
+ return project_id
364
+
365
+ except Exception as e:
366
+ raise ValueError(f"Failed to create project: {str(e)}")
367
+
368
+ # ========== Token使用统计 ==========
369
+
370
+ async def record_usage(self, token_id: int, is_video: bool = False):
371
+ """Record token usage"""
372
+ await self.db.update_token(token_id, use_count=1, last_used_at=datetime.now())
373
+
374
+ if is_video:
375
+ await self.db.increment_token_stats(token_id, "video")
376
+ else:
377
+ await self.db.increment_token_stats(token_id, "image")
378
+
379
+ async def record_error(self, token_id: int):
380
+ """Record token error and auto-disable if threshold reached"""
381
+ await self.db.increment_token_stats(token_id, "error")
382
+
383
+ # Check if should auto-disable token (based on consecutive errors)
384
+ stats = await self.db.get_token_stats(token_id)
385
+ admin_config = await self.db.get_admin_config()
386
+
387
+ if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
388
+ debug_logger.log_warning(
389
+ f"[TOKEN_BAN] Token {token_id} consecutive error count ({stats.consecutive_error_count}) "
390
+ f"reached threshold ({admin_config.error_ban_threshold}), auto-disabling"
391
+ )
392
+ await self.disable_token(token_id)
393
+
394
+ async def record_success(self, token_id: int):
395
+ """Record successful request (reset consecutive error count)
396
+
397
+ This method resets error_count to 0, which is used for auto-disable threshold checking.
398
+ Note: today_error_count and historical statistics are NOT reset.
399
+ """
400
+ await self.db.reset_error_count(token_id)
401
+
402
+ async def ban_token_for_429(self, token_id: int):
403
+ """因429错误立即禁用token
404
+
405
+ Args:
406
+ token_id: Token ID
407
+ """
408
+ debug_logger.log_warning(f"[429_BAN] 禁用Token {token_id} (原因: 429 Rate Limit)")
409
+ await self.db.update_token(
410
+ token_id,
411
+ is_active=False,
412
+ ban_reason="429_rate_limit",
413
+ banned_at=datetime.now(timezone.utc)
414
+ )
415
+
416
+ async def auto_unban_429_tokens(self):
417
+ """自动解禁因429被禁用的token
418
+
419
+ 规则:
420
+ - 距离禁用时间12小时后自动解禁
421
+ - 仅解禁未过期的token
422
+ - 仅解禁因429被禁用的token
423
+ """
424
+ all_tokens = await self.db.get_all_tokens()
425
+ now = datetime.now(timezone.utc)
426
+
427
+ for token in all_tokens:
428
+ # 跳过非429禁用的token
429
+ if token.ban_reason != "429_rate_limit":
430
+ continue
431
+
432
+ # 跳过未禁用的token
433
+ if token.is_active:
434
+ continue
435
+
436
+ # 跳过没有禁用时间的token
437
+ if not token.banned_at:
438
+ continue
439
+
440
+ # 检查token是否已过期
441
+ if token.at_expires:
442
+ # 确保时区一致
443
+ if token.at_expires.tzinfo is None:
444
+ at_expires_aware = token.at_expires.replace(tzinfo=timezone.utc)
445
+ else:
446
+ at_expires_aware = token.at_expires
447
+
448
+ # 如果已过期,跳过
449
+ if at_expires_aware <= now:
450
+ debug_logger.log_info(f"[AUTO_UNBAN] Token {token.id} 已过期,跳过解禁")
451
+ continue
452
+
453
+ # 确保banned_at时区一致
454
+ if token.banned_at.tzinfo is None:
455
+ banned_at_aware = token.banned_at.replace(tzinfo=timezone.utc)
456
+ else:
457
+ banned_at_aware = token.banned_at
458
+
459
+ # 检查是否已过12小时
460
+ time_since_ban = now - banned_at_aware
461
+ if time_since_ban.total_seconds() >= 12 * 3600: # 12小时
462
+ debug_logger.log_info(
463
+ f"[AUTO_UNBAN] 解禁Token {token.id} (禁用时间: {banned_at_aware}, "
464
+ f"已过 {time_since_ban.total_seconds() / 3600:.1f} 小时)"
465
+ )
466
+ await self.db.update_token(
467
+ token.id,
468
+ is_active=True,
469
+ ban_reason=None,
470
+ banned_at=None
471
+ )
472
+ # 重置错误计数
473
+ await self.db.reset_error_count(token.id)
474
+
475
+ # ========== 余额刷新 ==========
476
+
477
+ async def refresh_credits(self, token_id: int) -> int:
478
+ """刷新Token余额
479
+
480
+ Returns:
481
+ credits
482
+ """
483
+ token = await self.db.get_token(token_id)
484
+ if not token:
485
+ return 0
486
+
487
+ # 确保AT有效
488
+ if not await self.is_at_valid(token_id):
489
+ return 0
490
+
491
+ # 重新获取token (AT可能已刷新)
492
+ token = await self.db.get_token(token_id)
493
+
494
+ try:
495
+ result = await self.flow_client.get_credits(token.at)
496
+ credits = result.get("credits", 0)
497
+
498
+ # 更新数据库
499
+ await self.db.update_token(token_id, credits=credits)
500
+
501
+ return credits
502
+ except Exception as e:
503
+ debug_logger.log_error(f"Failed to refresh credits for token {token_id}: {str(e)}")
504
+ 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,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">项目名称</th>
139
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目ID</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-left align-middle font-medium text-muted-foreground">备注</th>
144
+ <th class="h-10 px-3 text-right align-middle font-medium text-muted-foreground">操作</th>
145
+ </tr>
146
+ </thead>
147
+ <tbody id="tokenTableBody" class="divide-y divide-border">
148
+ <!-- 动态填充 -->
149
+ </tbody>
150
+ </table>
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- 系统配置面板 -->
156
+ <div id="panelSettings" class="hidden">
157
+ <div class="grid gap-6 lg:grid-cols-2">
158
+ <!-- 安全配置 -->
159
+ <div class="rounded-lg border border-border bg-background p-6">
160
+ <h3 class="text-lg font-semibold mb-4">安全配置</h3>
161
+ <div class="space-y-4">
162
+ <div>
163
+ <label class="text-sm font-medium mb-2 block">管理员用户名</label>
164
+ <input id="cfgAdminUsername" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
165
+ <p class="text-xs text-muted-foreground mt-1">管理员用户名</p>
166
+ </div>
167
+ <div>
168
+ <label class="text-sm font-medium mb-2 block">旧密码</label>
169
+ <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="输入旧密码">
170
+ </div>
171
+ <div>
172
+ <label class="text-sm font-medium mb-2 block">新密码</label>
173
+ <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="输入新密码">
174
+ </div>
175
+ <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>
176
+ </div>
177
+ </div>
178
+
179
+ <!-- API 密钥配置 -->
180
+ <div class="rounded-lg border border-border bg-background p-6">
181
+ <h3 class="text-lg font-semibold mb-4">API 密钥配置</h3>
182
+ <div class="space-y-4">
183
+ <div>
184
+ <label class="text-sm font-medium mb-2 block">当前 API Key</label>
185
+ <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>
186
+ <p class="text-xs text-muted-foreground mt-1">当前使用的 API Key(只读)</p>
187
+ </div>
188
+ <div>
189
+ <label class="text-sm font-medium mb-2 block">新 API Key</label>
190
+ <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">
191
+ <p class="text-xs text-muted-foreground mt-1">用于客���端调用 API 的密钥</p>
192
+ </div>
193
+ <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>
194
+ </div>
195
+ </div>
196
+
197
+ <!-- 代理配置 -->
198
+ <div class="rounded-lg border border-border bg-background p-6">
199
+ <h3 class="text-lg font-semibold mb-4">代理配置</h3>
200
+ <div class="space-y-4">
201
+ <div>
202
+ <label class="inline-flex items-center gap-2 cursor-pointer">
203
+ <input type="checkbox" id="cfgProxyEnabled" class="h-4 w-4 rounded border-input">
204
+ <span class="text-sm font-medium">启用代理</span>
205
+ </label>
206
+ </div>
207
+ <div>
208
+ <label class="text-sm font-medium mb-2 block">代理地址</label>
209
+ <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">
210
+ <p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
211
+ </div>
212
+ <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>
213
+ </div>
214
+ </div>
215
+
216
+ <!-- 错误处理配置 -->
217
+ <div class="rounded-lg border border-border bg-background p-6">
218
+ <h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
219
+ <div class="space-y-4">
220
+ <div>
221
+ <label class="text-sm font-medium mb-2 block">错误封禁阈值</label>
222
+ <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">
223
+ <p class="text-xs text-muted-foreground mt-1">Token 连续错误达到此次数后自动禁用</p>
224
+ </div>
225
+ <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>
226
+ </div>
227
+ </div>
228
+
229
+ <!-- 缓存配置 -->
230
+ <div class="rounded-lg border border-border bg-background p-6">
231
+ <h3 class="text-lg font-semibold mb-4">缓存配置</h3>
232
+ <div class="space-y-4">
233
+ <div>
234
+ <label class="inline-flex items-center gap-2 cursor-pointer">
235
+ <input type="checkbox" id="cfgCacheEnabled" class="h-4 w-4 rounded border-input" onchange="toggleCacheOptions()">
236
+ <span class="text-sm font-medium">启用缓存</span>
237
+ </label>
238
+ <p class="text-xs text-muted-foreground mt-1">关闭后,生成的图片和视频将直接输出原始链接,不会缓存到本地</p>
239
+ </div>
240
+
241
+ <!-- 缓存配置选项 -->
242
+ <div id="cacheOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border">
243
+ <div>
244
+ <label class="text-sm font-medium mb-2 block">缓存超时时间(秒)</label>
245
+ <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">
246
+ <p class="text-xs text-muted-foreground mt-1">文件缓存超时时间,范围:60-86400 秒(1分钟-24小时)</p>
247
+ </div>
248
+ <div>
249
+ <label class="text-sm font-medium mb-2 block">缓存文件访问域名</label>
250
+ <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">
251
+ <p class="text-xs text-muted-foreground mt-1">留空则使用服务器地址,例如:https://yourdomain.com</p>
252
+ </div>
253
+ <div id="cacheEffectiveUrl" class="rounded-md bg-muted p-3 hidden">
254
+ <p class="text-xs text-muted-foreground">
255
+ <strong>🌐 当前生效的访问地址:</strong><code id="cacheEffectiveUrlValue" class="bg-background px-1 py-0.5 rounded"></code>
256
+ </p>
257
+ </div>
258
+ </div>
259
+
260
+ <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>
261
+ </div>
262
+ </div>
263
+
264
+ <!-- 验证码配置 -->
265
+ <div class="rounded-lg border border-border bg-background p-6">
266
+ <h3 class="text-lg font-semibold mb-4">验证码配置</h3>
267
+ <div class="space-y-4">
268
+ <div>
269
+ <label class="text-sm font-medium mb-2 block">打码方式</label>
270
+ <select id="cfgCaptchaMethod" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" onchange="toggleCaptchaOptions()">
271
+ <option value="yescaptcha">YesCaptcha打码</option>
272
+ <option value="browser">无头浏览器打码</option>
273
+ <option value="personal">内置浏览器打码</option>
274
+ </select>
275
+ <p class="text-xs text-muted-foreground mt-1">选择验证码获取方式</p>
276
+ </div>
277
+
278
+ <!-- YesCaptcha配置选项 -->
279
+ <div id="yescaptchaOptions" class="space-y-4">
280
+ <div>
281
+ <label class="text-sm font-medium mb-2 block">YesCaptcha API密钥</label>
282
+ <input id="cfgYescaptchaApiKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="请输入YesCaptcha API密钥">
283
+ <p class="text-xs text-muted-foreground mt-1">用于自动获取reCAPTCHA验证码,留空则不使用验证码</p>
284
+ </div>
285
+ <div>
286
+ <label class="text-sm font-medium mb-2 block">YesCaptcha API地址</label>
287
+ <input id="cfgYescaptchaBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://api.yescaptcha.com">
288
+ <p class="text-xs text-muted-foreground mt-1">YesCaptcha服务地址,默认:https://api.yescaptcha.com</p>
289
+ </div>
290
+ </div>
291
+
292
+ <!-- 浏览器打码配置选项 -->
293
+ <div id="browserCaptchaOptions" class="hidden space-y-4">
294
+ <div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
295
+ <p class="text-xs text-blue-800 dark:text-blue-200">
296
+ ℹ️ <strong>浏览器打码:</strong>使用Playwright自动化浏览器获取验证码,无需额外配置,但会占用更多系统资源
297
+ </p>
298
+ </div>
299
+
300
+ <div>
301
+ <label class="inline-flex items-center gap-2 cursor-pointer">
302
+ <input type="checkbox" id="cfgBrowserProxyEnabled" class="h-4 w-4 rounded border-input" onchange="toggleBrowserProxyInput()">
303
+ <span class="text-sm font-medium">启用代理</span>
304
+ </label>
305
+ <p class="text-xs text-muted-foreground mt-2">为无头浏览器配置独立代理</p>
306
+ </div>
307
+
308
+ <div id="browserProxyUrlInput" class="hidden">
309
+ <label class="text-sm font-medium mb-2 block">代理地址</label>
310
+ <input id="cfgBrowserProxyUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://host:port 或 socks5://host:port">
311
+ <p class="text-xs text-muted-foreground mt-1">
312
+ ⚠️ <strong>仅支持:</strong>HTTP代理(可带认证)或 SOCKS5代理(不可带认证)<br>
313
+ 示例:<code class="bg-muted px-1 py-0.5 rounded">http://user:pass@proxy.com:8080</code> 或 <code class="bg-muted px-1 py-0.5 rounded">socks5://proxy.com:1080</code>
314
+ </p>
315
+ </div>
316
+ </div>
317
+
318
+ <button onclick="saveCaptchaConfig()" 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>
319
+ </div>
320
+ </div>
321
+
322
+ <!-- 插件连接配置 -->
323
+ <div class="rounded-lg border border-border bg-background p-6">
324
+ <h3 class="text-lg font-semibold mb-4">插件连接配置</h3>
325
+ <div class="space-y-4">
326
+ <div>
327
+ <label class="text-sm font-semibold mb-2 block">连接接口</label>
328
+ <div class="flex gap-2">
329
+ <input id="cfgPluginConnectionUrl" type="text" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" placeholder="加载中...">
330
+ <button onclick="copyConnectionUrl()" class="inline-flex items-center justify-center rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-4">复制</button>
331
+ </div>
332
+ <p class="text-xs text-muted-foreground mt-1">Chrome扩展插件需要配置此接口地址</p>
333
+ </div>
334
+ <div>
335
+ <label class="text-sm font-semibold mb-2 block">连接Token</label>
336
+ <div class="flex gap-2">
337
+ <input id="cfgPluginConnectionToken" type="text" class="flex h-9 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="留空自动生成">
338
+ <button onclick="copyConnectionToken()" class="inline-flex items-center justify-center rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-4">复制</button>
339
+ </div>
340
+ <p class="text-xs text-muted-foreground mt-1">用于验证Chrome扩展插件的身份,留空将自动生成随机token</p>
341
+ </div>
342
+ <div>
343
+ <label class="inline-flex items-center gap-2 cursor-pointer">
344
+ <input type="checkbox" id="cfgAutoEnableOnUpdate" class="h-4 w-4 rounded border-input">
345
+ <span class="text-sm font-medium">更新token时自动启用</span>
346
+ </label>
347
+ <p class="text-xs text-muted-foreground mt-2">当插件更新token时,如果该token被禁用,则自动启用它</p>
348
+ </div>
349
+ <div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
350
+ <p class="text-xs text-blue-800 dark:text-blue-200">
351
+ ℹ️ <strong>使用说明:</strong>安装Chrome扩展后,将连接接口和Token配置到插件中,插件会自动提取Google Labs的cookie并更新到系统
352
+ </p>
353
+ </div>
354
+ <button onclick="savePluginConfig()" 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>
355
+ </div>
356
+ </div>
357
+
358
+ <!-- 生成超时配置 -->
359
+ <div class="rounded-lg border border-border bg-background p-6">
360
+ <h3 class="text-lg font-semibold mb-4">生成超时配置</h3>
361
+ <div class="space-y-4">
362
+ <div>
363
+ <label class="text-sm font-medium mb-2 block">图片生成超时时间(秒)</label>
364
+ <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">
365
+ <p class="text-xs text-muted-foreground mt-1">图片生成超时时间,范围:60-3600 秒(1分钟-1小时),超时后自动释放Token锁</p>
366
+ </div>
367
+ <div>
368
+ <label class="text-sm font-medium mb-2 block">视频生成超时时间(秒)</label>
369
+ <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">
370
+ <p class="text-xs text-muted-foreground mt-1">视频生成超时时间,范围:60-7200 秒(1分钟-2小时),超时后返回上游API超时错误</p>
371
+ </div>
372
+ <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>
373
+ </div>
374
+ </div>
375
+
376
+ <!-- 调试配置 -->
377
+ <div class="rounded-lg border border-border bg-background p-6">
378
+ <h3 class="text-lg font-semibold mb-4">调试配置</h3>
379
+ <div class="space-y-4">
380
+ <div>
381
+ <label class="inline-flex items-center gap-2 cursor-pointer">
382
+ <input type="checkbox" id="cfgDebugEnabled" class="h-4 w-4 rounded border-input" onchange="toggleDebugMode()">
383
+ <span class="text-sm font-medium">启用调试模式</span>
384
+ </label>
385
+ <p class="text-xs text-muted-foreground mt-2">开启后,详细的上游API请求和响应日志将写入 <code class="bg-muted px-1 py-0.5 rounded">logs.txt</code> 文件</p>
386
+ </div>
387
+ <div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 border border-yellow-200 dark:border-yellow-800">
388
+ <p class="text-xs text-yellow-800 dark:text-yellow-200">
389
+ ⚠️ <strong>注意:</strong>调试模式会产生非常非常大量的日志,仅限Debug时候开启,否则磁盘boom
390
+ </p>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ </div>
395
+ </div>
396
+
397
+ <!-- 请求日志面板 -->
398
+ <div id="panelLogs" class="hidden">
399
+ <div class="rounded-lg border border-border bg-background">
400
+ <div class="flex items-center justify-between gap-4 p-4 border-b border-border">
401
+ <h3 class="text-lg font-semibold">请求日志</h3>
402
+ <div class="flex gap-2">
403
+ <button onclick="clearAllLogs()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-red-50 hover:text-red-700 h-8 px-3 text-sm" title="清空日志">
404
+ <svg class="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
405
+ <polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
406
+ </svg>
407
+ 清空
408
+ </button>
409
+ <button onclick="refreshLogs()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
410
+ <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">
411
+ <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"/>
412
+ </svg>
413
+ </button>
414
+ </div>
415
+ </div>
416
+ <div class="relative w-full overflow-auto max-h-[600px]">
417
+ <table class="w-full text-sm">
418
+ <thead class="sticky top-0 bg-background">
419
+ <tr class="border-b border-border">
420
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">操作</th>
421
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Token邮箱</th>
422
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态码</th>
423
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">耗时(秒)</th>
424
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">时间</th>
425
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">详情</th>
426
+ </tr>
427
+ </thead>
428
+ <tbody id="logsTableBody" class="divide-y divide-border">
429
+ <!-- 动态填充 -->
430
+ </tbody>
431
+ </table>
432
+ </div>
433
+ </div>
434
+ </div>
435
+
436
+ <!-- 日志详情模态框 -->
437
+ <div id="logDetailModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
438
+ <div class="bg-background rounded-lg border border-border w-full max-w-3xl shadow-xl max-h-[80vh] flex flex-col">
439
+ <div class="flex items-center justify-between p-5 border-b border-border">
440
+ <h3 class="text-lg font-semibold">日志详情</h3>
441
+ <button onclick="closeLogDetailModal()" class="text-muted-foreground hover:text-foreground">
442
+ <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">
443
+ <line x1="18" y1="6" x2="6" y2="18"/>
444
+ <line x1="6" y1="6" x2="18" y2="18"/>
445
+ </svg>
446
+ </button>
447
+ </div>
448
+ <div class="p-5 overflow-y-auto">
449
+ <div id="logDetailContent" class="space-y-4">
450
+ <!-- 动态填充 -->
451
+ </div>
452
+ </div>
453
+ </div>
454
+ </div>
455
+
456
+ <!-- 页脚 -->
457
+ <footer class="mt-12 pt-6 border-t border-border text-center text-xs text-muted-foreground">
458
+ <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>
459
+ </footer>
460
+ </main>
461
+
462
+ <!-- 添加 Token 模态框 -->
463
+ <div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
464
+ <div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
465
+ <div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
466
+ <h3 class="text-lg font-semibold">添加 Token</h3>
467
+ <button onclick="closeAddModal()" class="text-muted-foreground hover:text-foreground">
468
+ <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">
469
+ <line x1="18" y1="6" x2="6" y2="18"/>
470
+ <line x1="6" y1="6" x2="18" y2="18"/>
471
+ </svg>
472
+ </button>
473
+ </div>
474
+ <div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
475
+ <!-- Session Token -->
476
+ <div class="space-y-2">
477
+ <label class="text-sm font-medium">Session Token (ST) <span class="text-red-500">*</span></label>
478
+ <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>
479
+ <p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token,保存时将自动转换为 Access Token</p>
480
+ </div>
481
+
482
+ <!-- Remark -->
483
+ <div class="space-y-2">
484
+ <label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
485
+ <input id="addTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
486
+ </div>
487
+
488
+ <!-- Project ID -->
489
+ <div class="space-y-2">
490
+ <label class="text-sm font-medium">Project ID <span class="text-muted-foreground text-xs">- 可选</span></label>
491
+ <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="若不填写则系统自动生成">
492
+ <p class="text-xs text-muted-foreground">如果已有Project ID可直接输入,留空则创建新项目</p>
493
+ </div>
494
+
495
+ <!-- Project Name -->
496
+ <div class="space-y-2">
497
+ <label class="text-sm font-medium">Project Name <span class="text-muted-foreground text-xs">- 可选</span></label>
498
+ <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)">
499
+ </div>
500
+
501
+ <!-- 功能开关 -->
502
+ <div class="space-y-3 pt-2 border-t border-border">
503
+ <label class="text-sm font-medium">功能开关</label>
504
+ <div class="space-y-2">
505
+ <div class="flex items-center gap-3">
506
+ <label class="inline-flex items-center gap-2 cursor-pointer">
507
+ <input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input">
508
+ <span class="text-sm font-medium">启用图片生成</span>
509
+ </label>
510
+ <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表示不限制">
511
+ </div>
512
+ </div>
513
+ <div class="space-y-2">
514
+ <div class="flex items-center gap-3">
515
+ <label class="inline-flex items-center gap-2 cursor-pointer">
516
+ <input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input">
517
+ <span class="text-sm font-medium">启用视频生成</span>
518
+ </label>
519
+ <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表示不限制">
520
+ </div>
521
+ </div>
522
+ </div>
523
+ </div>
524
+ <div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
525
+ <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>
526
+ <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">
527
+ <span id="addTokenBtnText">添加</span>
528
+ <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">
529
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
530
+ <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>
531
+ </svg>
532
+ </button>
533
+ </div>
534
+ </div>
535
+ </div>
536
+
537
+ <!-- 编辑 Token 模态框 -->
538
+ <div id="editModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
539
+ <div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
540
+ <div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
541
+ <h3 class="text-lg font-semibold">编辑 Token</h3>
542
+ <button onclick="closeEditModal()" class="text-muted-foreground hover:text-foreground">
543
+ <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">
544
+ <line x1="18" y1="6" x2="6" y2="18"/>
545
+ <line x1="6" y1="6" x2="18" y2="18"/>
546
+ </svg>
547
+ </button>
548
+ </div>
549
+ <div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
550
+ <input type="hidden" id="editTokenId">
551
+
552
+ <!-- Session Token -->
553
+ <div class="space-y-2">
554
+ <label class="text-sm font-medium">Session Token (ST) <span class="text-red-500">*</span></label>
555
+ <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>
556
+ <p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token,保存时将自动转换为 Access Token</p>
557
+ </div>
558
+
559
+ <!-- Remark -->
560
+ <div class="space-y-2">
561
+ <label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
562
+ <input id="editTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
563
+ </div>
564
+
565
+ <!-- Project ID -->
566
+ <div class="space-y-2">
567
+ <label class="text-sm font-medium">Project ID <span class="text-muted-foreground text-xs">- 可选</span></label>
568
+ <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="若不填写则保持原有值">
569
+ <p class="text-xs text-muted-foreground">修改Project ID会更新Token使用的项目</p>
570
+ </div>
571
+
572
+ <!-- Project Name -->
573
+ <div class="space-y-2">
574
+ <label class="text-sm font-medium">Project Name <span class="text-muted-foreground text-xs">- 可选</span></label>
575
+ <input id="editTokenProjectName" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="若不填写则保持原有值">
576
+ </div>
577
+
578
+ <!-- 功能开关 -->
579
+ <div class="space-y-3 pt-2 border-t border-border">
580
+ <label class="text-sm font-medium">功能开关</label>
581
+ <div class="space-y-2">
582
+ <div class="flex items-center gap-3">
583
+ <label class="inline-flex items-center gap-2 cursor-pointer">
584
+ <input type="checkbox" id="editTokenImageEnabled" class="h-4 w-4 rounded border-input">
585
+ <span class="text-sm font-medium">启用图片生成</span>
586
+ </label>
587
+ <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表示不限制">
588
+ </div>
589
+ </div>
590
+ <div class="space-y-2">
591
+ <div class="flex items-center gap-3">
592
+ <label class="inline-flex items-center gap-2 cursor-pointer">
593
+ <input type="checkbox" id="editTokenVideoEnabled" class="h-4 w-4 rounded border-input">
594
+ <span class="text-sm font-medium">启用视频生成</span>
595
+ </label>
596
+ <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表示不限制">
597
+ </div>
598
+ </div>
599
+ </div>
600
+ </div>
601
+ <div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
602
+ <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>
603
+ <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">
604
+ <span id="editTokenBtnText">保存</span>
605
+ <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">
606
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
607
+ <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>
608
+ </svg>
609
+ </button>
610
+ </div>
611
+ </div>
612
+ </div>
613
+
614
+ <!-- Token 导入模态框 -->
615
+ <div id="importModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
616
+ <div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
617
+ <div class="flex items-center justify-between p-5 border-b border-border">
618
+ <h3 class="text-lg font-semibold">导入 Token</h3>
619
+ <button onclick="closeImportModal()" class="text-muted-foreground hover:text-foreground">
620
+ <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">
621
+ <line x1="18" y1="6" x2="6" y2="18"/>
622
+ <line x1="6" y1="6" x2="18" y2="18"/>
623
+ </svg>
624
+ </button>
625
+ </div>
626
+ <div class="p-5 space-y-4">
627
+ <div>
628
+ <label class="text-sm font-medium mb-2 block">选择 JSON 文件</label>
629
+ <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">
630
+ <p class="text-xs text-muted-foreground mt-1">选择导出的 Token JSON 文件进行导入</p>
631
+ </div>
632
+ <div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
633
+ <p class="text-xs text-blue-800 dark:text-blue-200">
634
+ <strong>说明:</strong>如果邮箱存在则会覆盖更新,不存在则会新增
635
+ </p>
636
+ </div>
637
+ </div>
638
+ <div class="flex items-center justify-end gap-3 p-5 border-t border-border">
639
+ <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>
640
+ <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">
641
+ <span id="importBtnText">导入</span>
642
+ <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">
643
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
644
+ <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>
645
+ </svg>
646
+ </button>
647
+ </div>
648
+ </div>
649
+ </div>
650
+
651
+ <script>
652
+ let allTokens=[];
653
+ const $=(id)=>document.getElementById(id),
654
+ checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
655
+ 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},
656
+ 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)}},
657
+ loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
658
+ 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>`},
659
+ formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
660
+ 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'-'}},
661
+ 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>`},
662
+ formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}},
663
+ formatAccountType=(tier)=>{if(tier==='PAYGATE_TIER_NOT_PAID'){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700">普通</span>`}else{return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-purple-50 text-purple-700">会员</span>`}},
664
+ 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 accountTypeDisplay=formatAccountType(t.user_paygate_tier);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">${accountTypeDisplay}</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('')},
665
+ 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')}},
666
+ 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')}},
667
+ refreshTokens=async()=>{await loadTokens();await loadStats()},
668
+ openAddModal=()=>$('addModal').classList.remove('hidden'),
669
+ 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'},
670
+ 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')},
671
+ 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=''},
672
+ 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')}},
673
+ 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')}},
674
+ 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')}},
675
+ 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')}},
676
+ 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')}},
677
+ 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')}},
678
+ 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')}},
679
+ 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}},
680
+ 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')}},
681
+ 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')}},
682
+ openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')},
683
+ closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''},
684
+ openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''},
685
+ closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''},
686
+ 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')},
687
+ 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')}},
688
+ 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')}},
689
+ 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)}},
690
+ 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')}},
691
+ 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')}},
692
+ 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')}},
693
+ 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}},
694
+ 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)}},
695
+ 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')}},
696
+ toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
697
+ 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')}},
698
+ 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')}},
699
+ 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')}},
700
+ 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')}},
701
+ toggleCaptchaOptions=()=>{const method=$('cfgCaptchaMethod').value;$('yescaptchaOptions').style.display=method==='yescaptcha'?'block':'none';$('browserCaptchaOptions').classList.toggle('hidden',method!=='browser')},
702
+ toggleBrowserProxyInput=()=>{const enabled=$('cfgBrowserProxyEnabled').checked;$('browserProxyUrlInput').classList.toggle('hidden',!enabled)},
703
+ loadCaptchaConfig=async()=>{try{console.log('开始加载验证码配置...');const r=await apiRequest('/api/captcha/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('验证码配置数据:',d);$('cfgCaptchaMethod').value=d.captcha_method||'yescaptcha';$('cfgYescaptchaApiKey').value=d.yescaptcha_api_key||'';$('cfgYescaptchaBaseUrl').value=d.yescaptcha_base_url||'https://api.yescaptcha.com';$('cfgBrowserProxyEnabled').checked=d.browser_proxy_enabled||false;$('cfgBrowserProxyUrl').value=d.browser_proxy_url||'';toggleCaptchaOptions();toggleBrowserProxyInput();console.log('验证码配置加载成功')}catch(e){console.error('加载验证码配置失败:',e);showToast('加载验证码配置失败: '+e.message,'error')}},
704
+ saveCaptchaConfig=async()=>{const method=$('cfgCaptchaMethod').value,apiKey=$('cfgYescaptchaApiKey').value.trim(),baseUrl=$('cfgYescaptchaBaseUrl').value.trim(),browserProxyEnabled=$('cfgBrowserProxyEnabled').checked,browserProxyUrl=$('cfgBrowserProxyUrl').value.trim();console.log('保存验证码配置:',{method,apiKey,baseUrl,browserProxyEnabled,browserProxyUrl});try{const r=await apiRequest('/api/captcha/config',{method:'POST',body:JSON.stringify({captcha_method:method,yescaptcha_api_key:apiKey,yescaptcha_base_url:baseUrl,browser_proxy_enabled:browserProxyEnabled,browser_proxy_url:browserProxyUrl})});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 loadCaptchaConfig()}else{console.error('保存失败:',d);showToast(d.message||'保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
705
+ loadPluginConfig=async()=>{try{const r=await apiRequest('/api/plugin/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPluginConnectionUrl').value=d.config.connection_url||'';$('cfgPluginConnectionToken').value=d.config.connection_token||'';$('cfgAutoEnableOnUpdate').checked=d.config.auto_enable_on_update||false}}catch(e){console.error('加载插件配置失败:',e);showToast('加载插件配置失败: '+e.message,'error')}},
706
+ savePluginConfig=async()=>{const token=$('cfgPluginConnectionToken').value.trim();const autoEnable=$('cfgAutoEnableOnUpdate').checked;try{const r=await apiRequest('/api/plugin/config',{method:'POST',body:JSON.stringify({connection_token:token,auto_enable_on_update:autoEnable})});if(!r)return;const d=await r.json();if(d.success){showToast('插件配置保存成功','success');await loadPluginConfig()}else{showToast(d.message||'保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
707
+ copyConnectionUrl=()=>{const url=$('cfgPluginConnectionUrl').value;if(!url){showToast('连接接口为空','error');return}navigator.clipboard.writeText(url).then(()=>showToast('连接接口已复制','success')).catch(()=>showToast('复制失败','error'))},
708
+ copyConnectionToken=()=>{const token=$('cfgPluginConnectionToken').value;if(!token){showToast('连接Token为空','error');return}navigator.clipboard.writeText(token).then(()=>showToast('连接Token已复制','success')).catch(()=>showToast('复制失败','error'))},
709
+ 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}},
710
+ 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)}},
711
+ loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;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><td class="py-2.5 px-3"><button onclick="showLogDetail(${l.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button></td></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}},
712
+ refreshLogs=async()=>{await loadLogs()},
713
+ clearAllLogs=async()=>{if(!confirm('确定要清空所有日志吗?此操作不可恢复!'))return;try{const r=await apiRequest('/api/logs',{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
714
+ showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${responseBody.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${responseBody.url}</a></div></div>`}else if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.error){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}}detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${log.operation}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration.toFixed(2)}秒</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
715
+ closeLogDetailModal=()=>{$('logDetailModal').classList.add('hidden')},
716
+ 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)},
717
+ logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
718
+ 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();loadCaptchaConfig();loadPluginConfig();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}};
719
+ window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
720
+ </script>
721
+ </body>
722
+ </html>
venv/Lib/site-packages/_distutils_hack/__init__.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ import re
4
+ import importlib
5
+ import warnings
6
+
7
+
8
+ is_pypy = '__pypy__' in sys.builtin_module_names
9
+
10
+
11
+ warnings.filterwarnings('ignore',
12
+ r'.+ distutils\b.+ deprecated',
13
+ DeprecationWarning)
14
+
15
+
16
+ def warn_distutils_present():
17
+ if 'distutils' not in sys.modules:
18
+ return
19
+ if is_pypy and sys.version_info < (3, 7):
20
+ # PyPy for 3.6 unconditionally imports distutils, so bypass the warning
21
+ # https://foss.heptapod.net/pypy/pypy/-/blob/be829135bc0d758997b3566062999ee8b23872b4/lib-python/3/site.py#L250
22
+ return
23
+ warnings.warn(
24
+ "Distutils was imported before Setuptools, but importing Setuptools "
25
+ "also replaces the `distutils` module in `sys.modules`. This may lead "
26
+ "to undesirable behaviors or errors. To avoid these issues, avoid "
27
+ "using distutils directly, ensure that setuptools is installed in the "
28
+ "traditional way (e.g. not an editable install), and/or make sure "
29
+ "that setuptools is always imported before distutils.")
30
+
31
+
32
+ def clear_distutils():
33
+ if 'distutils' not in sys.modules:
34
+ return
35
+ warnings.warn("Setuptools is replacing distutils.")
36
+ mods = [name for name in sys.modules if re.match(r'distutils\b', name)]
37
+ for name in mods:
38
+ del sys.modules[name]
39
+
40
+
41
+ def enabled():
42
+ """
43
+ Allow selection of distutils by environment variable.
44
+ """
45
+ which = os.environ.get('SETUPTOOLS_USE_DISTUTILS', 'stdlib')
46
+ return which == 'local'
47
+
48
+
49
+ def ensure_local_distutils():
50
+ clear_distutils()
51
+ distutils = importlib.import_module('setuptools._distutils')
52
+ distutils.__name__ = 'distutils'
53
+ sys.modules['distutils'] = distutils
54
+
55
+ # sanity check that submodules load as expected
56
+ core = importlib.import_module('distutils.core')
57
+ assert '_distutils' in core.__file__, core.__file__
58
+
59
+
60
+ def do_override():
61
+ """
62
+ Ensure that the local copy of distutils is preferred over stdlib.
63
+
64
+ See https://github.com/pypa/setuptools/issues/417#issuecomment-392298401
65
+ for more motivation.
66
+ """
67
+ if enabled():
68
+ warn_distutils_present()
69
+ ensure_local_distutils()
70
+
71
+
72
+ class DistutilsMetaFinder:
73
+ def find_spec(self, fullname, path, target=None):
74
+ if path is not None:
75
+ return
76
+
77
+ method_name = 'spec_for_{fullname}'.format(**locals())
78
+ method = getattr(self, method_name, lambda: None)
79
+ return method()
80
+
81
+ def spec_for_distutils(self):
82
+ import importlib.abc
83
+ import importlib.util
84
+
85
+ class DistutilsLoader(importlib.abc.Loader):
86
+
87
+ def create_module(self, spec):
88
+ return importlib.import_module('setuptools._distutils')
89
+
90
+ def exec_module(self, module):
91
+ pass
92
+
93
+ return importlib.util.spec_from_loader('distutils', DistutilsLoader())
94
+
95
+ def spec_for_pip(self):
96
+ """
97
+ Ensure stdlib distutils when running under pip.
98
+ See pypa/pip#8761 for rationale.
99
+ """
100
+ if self.pip_imported_during_build():
101
+ return
102
+ clear_distutils()
103
+ self.spec_for_distutils = lambda: None
104
+
105
+ @staticmethod
106
+ def pip_imported_during_build():
107
+ """
108
+ Detect if pip is being imported in a build script. Ref #2355.
109
+ """
110
+ import traceback
111
+ return any(
112
+ frame.f_globals['__file__'].endswith('setup.py')
113
+ for frame, line in traceback.walk_stack(None)
114
+ )
115
+
116
+
117
+ DISTUTILS_FINDER = DistutilsMetaFinder()
118
+
119
+
120
+ def add_shim():
121
+ sys.meta_path.insert(0, DISTUTILS_FINDER)
122
+
123
+
124
+ def remove_shim():
125
+ try:
126
+ sys.meta_path.remove(DISTUTILS_FINDER)
127
+ except ValueError:
128
+ pass
venv/Lib/site-packages/_distutils_hack/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (5.15 kB). View file
 
venv/Lib/site-packages/_distutils_hack/__pycache__/override.cpython-310.pyc ADDED
Binary file (250 Bytes). View file
 
venv/Lib/site-packages/_distutils_hack/override.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __import__('_distutils_hack').do_override()
venv/Lib/site-packages/distutils-precedence.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7ea7ffef3fe2a117ee12c68ed6553617f0d7fd2f0590257c25c484959a3b7373
3
+ size 152
venv/Lib/site-packages/pip-21.2.3.dist-info/INSTALLER ADDED
@@ -0,0 +1 @@
 
 
1
+ pip
venv/Lib/site-packages/pip-21.2.3.dist-info/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright (c) 2008-2021 The pip developers (see AUTHORS.txt file)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
venv/Lib/site-packages/pip-21.2.3.dist-info/METADATA ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.1
2
+ Name: pip
3
+ Version: 21.2.3
4
+ Summary: The PyPA recommended tool for installing Python packages.
5
+ Home-page: https://pip.pypa.io/
6
+ Author: The pip developers
7
+ Author-email: distutils-sig@python.org
8
+ License: MIT
9
+ Project-URL: Documentation, https://pip.pypa.io
10
+ Project-URL: Source, https://github.com/pypa/pip
11
+ Project-URL: Changelog, https://pip.pypa.io/en/stable/news/
12
+ Platform: UNKNOWN
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Topic :: Software Development :: Build Tools
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.6
21
+ Classifier: Programming Language :: Python :: 3.7
22
+ Classifier: Programming Language :: Python :: 3.8
23
+ Classifier: Programming Language :: Python :: 3.9
24
+ Classifier: Programming Language :: Python :: Implementation :: CPython
25
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
26
+ Requires-Python: >=3.6
27
+ License-File: LICENSE.txt
28
+
29
+ pip - The Python Package Installer
30
+ ==================================
31
+
32
+ .. image:: https://img.shields.io/pypi/v/pip.svg
33
+ :target: https://pypi.org/project/pip/
34
+
35
+ .. image:: https://readthedocs.org/projects/pip/badge/?version=latest
36
+ :target: https://pip.pypa.io/en/latest
37
+
38
+ pip is the `package installer`_ for Python. You can use pip to install packages from the `Python Package Index`_ and other indexes.
39
+
40
+ Please take a look at our documentation for how to install and use pip:
41
+
42
+ * `Installation`_
43
+ * `Usage`_
44
+
45
+ We release updates regularly, with a new version every 3 months. Find more details in our documentation:
46
+
47
+ * `Release notes`_
48
+ * `Release process`_
49
+
50
+ In pip 20.3, we've `made a big improvement to the heart of pip`_; `learn more`_. We want your input, so `sign up for our user experience research studies`_ to help us do it right.
51
+
52
+ **Note**: pip 21.0, in January 2021, removed Python 2 support, per pip's `Python 2 support policy`_. Please migrate to Python 3.
53
+
54
+ If you find bugs, need help, or want to talk to the developers, please use our mailing lists or chat rooms:
55
+
56
+ * `Issue tracking`_
57
+ * `Discourse channel`_
58
+ * `User IRC`_
59
+
60
+ If you want to get involved head over to GitHub to get the source code, look at our development documentation and feel free to jump on the developer mailing lists and chat rooms:
61
+
62
+ * `GitHub page`_
63
+ * `Development documentation`_
64
+ * `Development mailing list`_
65
+ * `Development IRC`_
66
+
67
+ Code of Conduct
68
+ ---------------
69
+
70
+ Everyone interacting in the pip project's codebases, issue trackers, chat
71
+ rooms, and mailing lists is expected to follow the `PSF Code of Conduct`_.
72
+
73
+ .. _package installer: https://packaging.python.org/guides/tool-recommendations/
74
+ .. _Python Package Index: https://pypi.org
75
+ .. _Installation: https://pip.pypa.io/en/stable/installation/
76
+ .. _Usage: https://pip.pypa.io/en/stable/
77
+ .. _Release notes: https://pip.pypa.io/en/stable/news.html
78
+ .. _Release process: https://pip.pypa.io/en/latest/development/release-process/
79
+ .. _GitHub page: https://github.com/pypa/pip
80
+ .. _Development documentation: https://pip.pypa.io/en/latest/development
81
+ .. _made a big improvement to the heart of pip: https://pyfound.blogspot.com/2020/11/pip-20-3-new-resolver.html
82
+ .. _learn more: https://pip.pypa.io/en/latest/user_guide/#changes-to-the-pip-dependency-resolver-in-20-3-2020
83
+ .. _sign up for our user experience research studies: https://pyfound.blogspot.com/2020/03/new-pip-resolver-to-roll-out-this-year.html
84
+ .. _Python 2 support policy: https://pip.pypa.io/en/latest/development/release-process/#python-2-support
85
+ .. _Issue tracking: https://github.com/pypa/pip/issues
86
+ .. _Discourse channel: https://discuss.python.org/c/packaging
87
+ .. _Development mailing list: https://mail.python.org/mailman3/lists/distutils-sig.python.org/
88
+ .. _User IRC: https://kiwiirc.com/nextclient/#ircs://irc.libera.chat:+6697/pypa
89
+ .. _Development IRC: https://kiwiirc.com/nextclient/#ircs://irc.libera.chat:+6697/pypa-dev
90
+ .. _PSF Code of Conduct: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md
91
+
92
+
venv/Lib/site-packages/pip-21.2.3.dist-info/RECORD ADDED
@@ -0,0 +1,795 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ../../Scripts/pip.exe,sha256=U22BM6itznlzrP8HMUlQoiy_IuZMJJqDXU2Wgja2WNs,106390
2
+ ../../Scripts/pip3.10.exe,sha256=U22BM6itznlzrP8HMUlQoiy_IuZMJJqDXU2Wgja2WNs,106390
3
+ ../../Scripts/pip3.exe,sha256=U22BM6itznlzrP8HMUlQoiy_IuZMJJqDXU2Wgja2WNs,106390
4
+ pip-21.2.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
5
+ pip-21.2.3.dist-info/LICENSE.txt,sha256=gJDJthjxG8mDPXZg96yUjnIt4bce2hULfec5mrfNnmI,1110
6
+ pip-21.2.3.dist-info/METADATA,sha256=BA4M-MqDkwwNoSPm1cVEoleu3sEr_iN4HbpM3cjr6rI,4165
7
+ pip-21.2.3.dist-info/RECORD,,
8
+ pip-21.2.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ pip-21.2.3.dist-info/WHEEL,sha256=OqRkF0eY5GHssMorFjlbTIq072vpHpF60fIQA6lS9xA,92
10
+ pip-21.2.3.dist-info/entry_points.txt,sha256=HtfDOwpUlr9s73jqLQ6wF9V0_0qvUXJwCBz7Vwx0Ue0,125
11
+ pip-21.2.3.dist-info/top_level.txt,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
12
+ pip/__init__.py,sha256=qXU49Q5cd1uydhWo2AnReaOxEtM0o8t5EP9EvhtRCN8,370
13
+ pip/__main__.py,sha256=8mTMucDffyV05KR_fXWM2p1JQEnHPu1-CWjbthYHYis,1229
14
+ pip/__pycache__/__init__.cpython-310.pyc,,
15
+ pip/__pycache__/__main__.cpython-310.pyc,,
16
+ pip/_internal/__init__.py,sha256=OLxitHt9NAlSaObDjAlRSYUkAftIjV9sUpv1yIZDO4E,592
17
+ pip/_internal/__pycache__/__init__.cpython-310.pyc,,
18
+ pip/_internal/__pycache__/build_env.cpython-310.pyc,,
19
+ pip/_internal/__pycache__/cache.cpython-310.pyc,,
20
+ pip/_internal/__pycache__/configuration.cpython-310.pyc,,
21
+ pip/_internal/__pycache__/exceptions.cpython-310.pyc,,
22
+ pip/_internal/__pycache__/main.cpython-310.pyc,,
23
+ pip/_internal/__pycache__/pyproject.cpython-310.pyc,,
24
+ pip/_internal/__pycache__/self_outdated_check.cpython-310.pyc,,
25
+ pip/_internal/__pycache__/wheel_builder.cpython-310.pyc,,
26
+ pip/_internal/build_env.py,sha256=YMIinJTDdKm_x5JC8IjJZp-_Zw42kBm6fkiWprBi-Hk,10415
27
+ pip/_internal/cache.py,sha256=bjyh33eOAq6kX1kPk84Oxb_JXazpP6izVZipJLq0eJI,10245
28
+ pip/_internal/cli/__init__.py,sha256=9gMw_A_StJXzDh2Rhxil6bd8tFP-ZR719Q1pINHAw5I,136
29
+ pip/_internal/cli/__pycache__/__init__.cpython-310.pyc,,
30
+ pip/_internal/cli/__pycache__/autocompletion.cpython-310.pyc,,
31
+ pip/_internal/cli/__pycache__/base_command.cpython-310.pyc,,
32
+ pip/_internal/cli/__pycache__/cmdoptions.cpython-310.pyc,,
33
+ pip/_internal/cli/__pycache__/command_context.cpython-310.pyc,,
34
+ pip/_internal/cli/__pycache__/main.cpython-310.pyc,,
35
+ pip/_internal/cli/__pycache__/main_parser.cpython-310.pyc,,
36
+ pip/_internal/cli/__pycache__/parser.cpython-310.pyc,,
37
+ pip/_internal/cli/__pycache__/progress_bars.cpython-310.pyc,,
38
+ pip/_internal/cli/__pycache__/req_command.cpython-310.pyc,,
39
+ pip/_internal/cli/__pycache__/spinners.cpython-310.pyc,,
40
+ pip/_internal/cli/__pycache__/status_codes.cpython-310.pyc,,
41
+ pip/_internal/cli/autocompletion.py,sha256=_d6Ugrj-KQOCoDgDscSr_238-RcwedXzpLmW8F_L_uc,6562
42
+ pip/_internal/cli/base_command.py,sha256=mZH7AMA9tBoiYEysFx9l3Pe_h23mnNZy8K8M_ZhsRSs,7810
43
+ pip/_internal/cli/cmdoptions.py,sha256=Kalu7Z2ZAvySgOOIo4e_Ah3Vv8HDmGhLQlkDysI-ihw,29292
44
+ pip/_internal/cli/command_context.py,sha256=ocjEqsuP6pFF98N_ljRyLToYLm5fBgMaZicVjMy_f0Y,787
45
+ pip/_internal/cli/main.py,sha256=D6DAuHgfr4na5SuEbtLyx1FlZhcGHIB9BltXsjrrKsA,2542
46
+ pip/_internal/cli/main_parser.py,sha256=rusU-JYOLpcJ8cFXWiPvuDoNpTptRlndY3WyBh2UXq4,2701
47
+ pip/_internal/cli/parser.py,sha256=0eTD4_7ucvjv_VnZajerOhzHUAIOju5KDlQM0rv5VP4,11080
48
+ pip/_internal/cli/progress_bars.py,sha256=-bhkKHcjsZ0tWdf-DH9eGHsJuGedVPih8E0vV2_rJXg,8550
49
+ pip/_internal/cli/req_command.py,sha256=_9w-fMnI0j8bey7mryr-iLxjCoUT_jIU3lkNp2PQJVU,17001
50
+ pip/_internal/cli/spinners.py,sha256=rQJtl8c9JiMpOCsOk-YRFYtWdhiFoFectJuryMM-jn4,5233
51
+ pip/_internal/cli/status_codes.py,sha256=1xaB32lG8Nf1nMl_6e0yy5z2Iyvv81OTUpuHwXgGsfU,122
52
+ pip/_internal/commands/__init__.py,sha256=LoscneenHTO4OCqGIah4KtHyPNURU6F7bUeGRjbcr_k,3888
53
+ pip/_internal/commands/__pycache__/__init__.cpython-310.pyc,,
54
+ pip/_internal/commands/__pycache__/cache.cpython-310.pyc,,
55
+ pip/_internal/commands/__pycache__/check.cpython-310.pyc,,
56
+ pip/_internal/commands/__pycache__/completion.cpython-310.pyc,,
57
+ pip/_internal/commands/__pycache__/configuration.cpython-310.pyc,,
58
+ pip/_internal/commands/__pycache__/debug.cpython-310.pyc,,
59
+ pip/_internal/commands/__pycache__/download.cpython-310.pyc,,
60
+ pip/_internal/commands/__pycache__/freeze.cpython-310.pyc,,
61
+ pip/_internal/commands/__pycache__/hash.cpython-310.pyc,,
62
+ pip/_internal/commands/__pycache__/help.cpython-310.pyc,,
63
+ pip/_internal/commands/__pycache__/index.cpython-310.pyc,,
64
+ pip/_internal/commands/__pycache__/install.cpython-310.pyc,,
65
+ pip/_internal/commands/__pycache__/list.cpython-310.pyc,,
66
+ pip/_internal/commands/__pycache__/search.cpython-310.pyc,,
67
+ pip/_internal/commands/__pycache__/show.cpython-310.pyc,,
68
+ pip/_internal/commands/__pycache__/uninstall.cpython-310.pyc,,
69
+ pip/_internal/commands/__pycache__/wheel.cpython-310.pyc,,
70
+ pip/_internal/commands/cache.py,sha256=XqHqGkiyZ6A0Vf0oIGJhWiNY4k9JIC0nlNmooiaCtuo,7453
71
+ pip/_internal/commands/check.py,sha256=FZ7IHeRkwnWkwgdaMI1AjFPcI4xrPK4_4jODp8aDyiA,1617
72
+ pip/_internal/commands/completion.py,sha256=RXEru4uYaBfUUtZJ2bLE7s6FJUe9t1BAVSUQ8wPzW0o,3005
73
+ pip/_internal/commands/configuration.py,sha256=1Fr4QLwdu1OcBL_FZQh6uY3i7i38qI70OJGG-JhoUuI,9228
74
+ pip/_internal/commands/debug.py,sha256=Bb2Lxzn7XEQRYgBYOZbuK3EYeLphKHo8Gubg_EZ6JnU,6851
75
+ pip/_internal/commands/download.py,sha256=sT3gInhPvMyhuZ-rVBf1OlpLzh52J0hStM57_CPdvCA,5088
76
+ pip/_internal/commands/freeze.py,sha256=xzVjiTJT5DBJceJPNnAYyCF4oGITyuSpfsfkSKR70Fg,2869
77
+ pip/_internal/commands/hash.py,sha256=XoLZ3Hx_b72asRLGOe1WXSvJ6ClwSFhn-aFPgbcB9Zk,1719
78
+ pip/_internal/commands/help.py,sha256=zV8LmdIbJOnVOIZTz1TwlNWQZQLEWZnJGwXSSh9SSEw,1173
79
+ pip/_internal/commands/index.py,sha256=qTchSIS-zCEiJCrZ9X-o5_93kMYCSVZDRTmeY_fhOOE,4920
80
+ pip/_internal/commands/install.py,sha256=oj_4a73RMGXgmFQV1jUZEZTnvnZw5kkT6pA3naYj3PA,28243
81
+ pip/_internal/commands/list.py,sha256=1K-dhUMEs8akjPE16n5Eq9GsalONKkS7bvWwSCNzQe8,12090
82
+ pip/_internal/commands/search.py,sha256=Dtebo30wNJJ0Blr5MkmiET1wIuNioxzoH8vR82ma7Tc,5707
83
+ pip/_internal/commands/show.py,sha256=G-ak6SZS-H2MJqQI89D2x4TLd8l9amVYhtaa5dhCFeE,8208
84
+ pip/_internal/commands/uninstall.py,sha256=sTUTiJSTp0NKeFHUBJHD3f-fSVPGFjBY6go_DhpvC0Q,3580
85
+ pip/_internal/commands/wheel.py,sha256=dIRE7v6g9HTpTw0nsOG2HHoG0uvwrWV5-xyp0kuJdhU,6365
86
+ pip/_internal/configuration.py,sha256=g_C1ByxxCGrYfY26B-X8v-3mbUwzlXdj9EKwDs-0kH0,14128
87
+ pip/_internal/distributions/__init__.py,sha256=99Rzk077wS5wGire_mchcAjtJG9vbwTLPXZKLWrh_D4,879
88
+ pip/_internal/distributions/__pycache__/__init__.cpython-310.pyc,,
89
+ pip/_internal/distributions/__pycache__/base.cpython-310.pyc,,
90
+ pip/_internal/distributions/__pycache__/installed.cpython-310.pyc,,
91
+ pip/_internal/distributions/__pycache__/sdist.cpython-310.pyc,,
92
+ pip/_internal/distributions/__pycache__/wheel.cpython-310.pyc,,
93
+ pip/_internal/distributions/base.py,sha256=VCEqCFm36o5SSpu3esHmM055Wv5TOxGFLXtxpY8m-L8,1244
94
+ pip/_internal/distributions/installed.py,sha256=UZU2Q4-jcAaPexNZzqk5YXhols1jTjyh4xAA0ZQ_A8g,667
95
+ pip/_internal/distributions/sdist.py,sha256=5OpYaS51ll8tqsZC-h86ij62RQkid3YZEOqUXCRq8TA,3957
96
+ pip/_internal/distributions/wheel.py,sha256=8lzZqqpA5oUUHANOZlf0qcKLIunsJ3K821VP29hLYdg,1217
97
+ pip/_internal/exceptions.py,sha256=yiMEmKFvLIPNGaLCQxxtnacfNhUisQayQ4XiqPuRLZU,13567
98
+ pip/_internal/index/__init__.py,sha256=x0ifDyFChwwQC4V_eHBFF1fvzLwbXRYG3nq15-Axy24,32
99
+ pip/_internal/index/__pycache__/__init__.cpython-310.pyc,,
100
+ pip/_internal/index/__pycache__/collector.cpython-310.pyc,,
101
+ pip/_internal/index/__pycache__/package_finder.cpython-310.pyc,,
102
+ pip/_internal/index/__pycache__/sources.cpython-310.pyc,,
103
+ pip/_internal/index/collector.py,sha256=pB-NFZkI-KjQnhTBo7JzArphtu6OVJ52WVQlFQrSbFU,18179
104
+ pip/_internal/index/package_finder.py,sha256=k7qar-7Lw778ODq0iB-ETXjof-MUhRh4YHXsVNjl6eo,37120
105
+ pip/_internal/index/sources.py,sha256=SW2LlD5eAF2Rj74TbpEUNjADU9UH-K76oqgJFNtDlWc,6781
106
+ pip/_internal/locations/__init__.py,sha256=bReITFXFSX786ngLRJhXhsb1npYOw8YbpOuOngrQUik,11584
107
+ pip/_internal/locations/__pycache__/__init__.cpython-310.pyc,,
108
+ pip/_internal/locations/__pycache__/_distutils.cpython-310.pyc,,
109
+ pip/_internal/locations/__pycache__/_sysconfig.cpython-310.pyc,,
110
+ pip/_internal/locations/__pycache__/base.cpython-310.pyc,,
111
+ pip/_internal/locations/_distutils.py,sha256=OUPuNiGJP2vmiYgwdAR-ZQryj_2yANs9DebSm4afDZU,6040
112
+ pip/_internal/locations/_sysconfig.py,sha256=sJJD4PQujUcLAaKiOeXYsILmQb0-hjznP79PzKPiA4Q,8137
113
+ pip/_internal/locations/base.py,sha256=KMpxtATY6iOshQ3AimpymGTy81zK-pM1CWPemJQ0mj4,1631
114
+ pip/_internal/main.py,sha256=yXF5_lVS3QQTxrbbvNk31juR2E1_16H1iu-xuXAod9o,364
115
+ pip/_internal/metadata/__init__.py,sha256=p3NvOGG_3quhV9bOlDPvWSAEByPsdn63cO4FrsFubM0,1624
116
+ pip/_internal/metadata/__pycache__/__init__.cpython-310.pyc,,
117
+ pip/_internal/metadata/__pycache__/base.cpython-310.pyc,,
118
+ pip/_internal/metadata/__pycache__/pkg_resources.cpython-310.pyc,,
119
+ pip/_internal/metadata/base.py,sha256=dCfs9XIcicyYSoFtVtzq6rFvduUYNdV6U_EDDlG4yJ0,8170
120
+ pip/_internal/metadata/pkg_resources.py,sha256=azUoAkvyF-h80pORd0czY4-fWUIRe_z3i2qNHuKiSjE,5353
121
+ pip/_internal/models/__init__.py,sha256=j2kiRfNTH6h5JVP5yO_N2Yn0DqiNzJUtaPjHe2xMcgg,65
122
+ pip/_internal/models/__pycache__/__init__.cpython-310.pyc,,
123
+ pip/_internal/models/__pycache__/candidate.cpython-310.pyc,,
124
+ pip/_internal/models/__pycache__/direct_url.cpython-310.pyc,,
125
+ pip/_internal/models/__pycache__/format_control.cpython-310.pyc,,
126
+ pip/_internal/models/__pycache__/index.cpython-310.pyc,,
127
+ pip/_internal/models/__pycache__/link.cpython-310.pyc,,
128
+ pip/_internal/models/__pycache__/scheme.cpython-310.pyc,,
129
+ pip/_internal/models/__pycache__/search_scope.cpython-310.pyc,,
130
+ pip/_internal/models/__pycache__/selection_prefs.cpython-310.pyc,,
131
+ pip/_internal/models/__pycache__/target_python.cpython-310.pyc,,
132
+ pip/_internal/models/__pycache__/wheel.cpython-310.pyc,,
133
+ pip/_internal/models/candidate.py,sha256=L4nKS2HIgdOfbylU-2MIO2APVWEWueJpHu_gcwO0iH4,977
134
+ pip/_internal/models/direct_url.py,sha256=36n2ed5kddmPLhNLh4b1GzGr36dUdSgl23Xrbcxd_ck,6482
135
+ pip/_internal/models/format_control.py,sha256=PZ5PExPrxArZWzzPFATSj6W07UqvxjtrMBgFNAUxae4,2641
136
+ pip/_internal/models/index.py,sha256=vqgOrXoZqQW4AA928wBmEGjKbxjeb-_NALLGZH0kMXY,1090
137
+ pip/_internal/models/link.py,sha256=SrRPbWmpzWh1LfQd-eurbYAuMvOvpD5fzMvNDlRFpQ8,10227
138
+ pip/_internal/models/scheme.py,sha256=moDcYk85amZUkf6Je5TDvXh4xMHxsSgf8rkD1ykpQhU,769
139
+ pip/_internal/models/search_scope.py,sha256=D_Q6xSo1dTjSRljshHz_xVe4vXXivl2H7_rtj0dSHpU,4600
140
+ pip/_internal/models/selection_prefs.py,sha256=0DZIWNPT8q2EiuqZCAQxczCoVnbs9_C4zugNfhNnvaw,1923
141
+ pip/_internal/models/target_python.py,sha256=NUm2Ua7qbJHFK0UzKAlfHixudx4-FgmAFKPKf3B2RyU,3981
142
+ pip/_internal/models/wheel.py,sha256=NLmn_y8dVc5vfTcAYw3EfS692hAF9MPPMDr5wTBu_4E,3633
143
+ pip/_internal/network/__init__.py,sha256=IEtuAPVGqBTS0C7M0KJ95xqGcA76coOc2AsDcgIBP-8,52
144
+ pip/_internal/network/__pycache__/__init__.cpython-310.pyc,,
145
+ pip/_internal/network/__pycache__/auth.cpython-310.pyc,,
146
+ pip/_internal/network/__pycache__/cache.cpython-310.pyc,,
147
+ pip/_internal/network/__pycache__/download.cpython-310.pyc,,
148
+ pip/_internal/network/__pycache__/lazy_wheel.cpython-310.pyc,,
149
+ pip/_internal/network/__pycache__/session.cpython-310.pyc,,
150
+ pip/_internal/network/__pycache__/utils.cpython-310.pyc,,
151
+ pip/_internal/network/__pycache__/xmlrpc.cpython-310.pyc,,
152
+ pip/_internal/network/auth.py,sha256=820jI8SAsVfNnfoOfxfH7HaGVFtYX_M8vsHEtiHCS14,11961
153
+ pip/_internal/network/cache.py,sha256=KfaWDbALIGsUnP4qi1MfvKSpCJ0vlC5lviELSTNKLtQ,2169
154
+ pip/_internal/network/download.py,sha256=-EnS83XhpNVRsfB107nYU-OPZsHd29QQdG6lNokemAk,6200
155
+ pip/_internal/network/lazy_wheel.py,sha256=LFVwrv2nHpwr0pl17i-3sbEAedJjraaLdRTFRfS23vo,7825
156
+ pip/_internal/network/session.py,sha256=9KBZpMGrtEmek4iPgpZiLEBgyxQ6bPH3yUUxNbEEQ54,17036
157
+ pip/_internal/network/utils.py,sha256=K4d2srYGzR_tz5vVBbKFC7rBWA-G_DqqX9io2flIrI0,4155
158
+ pip/_internal/network/xmlrpc.py,sha256=oFCngChTOtIVcc55ExwdX1HJcPvL-6PKMNl5ok6nMQk,1851
159
+ pip/_internal/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
160
+ pip/_internal/operations/__pycache__/__init__.cpython-310.pyc,,
161
+ pip/_internal/operations/__pycache__/check.cpython-310.pyc,,
162
+ pip/_internal/operations/__pycache__/freeze.cpython-310.pyc,,
163
+ pip/_internal/operations/__pycache__/prepare.cpython-310.pyc,,
164
+ pip/_internal/operations/build/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
165
+ pip/_internal/operations/build/__pycache__/__init__.cpython-310.pyc,,
166
+ pip/_internal/operations/build/__pycache__/metadata.cpython-310.pyc,,
167
+ pip/_internal/operations/build/__pycache__/metadata_legacy.cpython-310.pyc,,
168
+ pip/_internal/operations/build/__pycache__/wheel.cpython-310.pyc,,
169
+ pip/_internal/operations/build/__pycache__/wheel_legacy.cpython-310.pyc,,
170
+ pip/_internal/operations/build/metadata.py,sha256=-06VLFRgwxsIuqHL_56NxXdn7TQuZHeaL4RYXDnjB18,1200
171
+ pip/_internal/operations/build/metadata_legacy.py,sha256=1V8i8VVGUF7GauyS_KPEhcl7dIwrocD6lyT5kWvXkBA,1991
172
+ pip/_internal/operations/build/wheel.py,sha256=r2IKZgek1REUjP4xA0RLEG5O1-HsPZtzfy7q7W3_nhQ,1144
173
+ pip/_internal/operations/build/wheel_legacy.py,sha256=4ddx179dnTTHJertW50M6869gchuyrNEdYcyWe4w5Yg,3337
174
+ pip/_internal/operations/check.py,sha256=eCLrRScb_ZJDxTK9rMjU5kbLuu1xRngdoLxATNPuPQE,5448
175
+ pip/_internal/operations/freeze.py,sha256=WEglEwdf9rD9M6jGzMyRVhiN-WF8p-hhiHGnwsWlDDc,10833
176
+ pip/_internal/operations/install/__init__.py,sha256=Zug2xxRJjeI2LdVd45iwmeavUBYXA4ltbhFFwc4BEOg,53
177
+ pip/_internal/operations/install/__pycache__/__init__.cpython-310.pyc,,
178
+ pip/_internal/operations/install/__pycache__/editable_legacy.cpython-310.pyc,,
179
+ pip/_internal/operations/install/__pycache__/legacy.cpython-310.pyc,,
180
+ pip/_internal/operations/install/__pycache__/wheel.cpython-310.pyc,,
181
+ pip/_internal/operations/install/editable_legacy.py,sha256=FhvBds_MF_Flabnylh6EVwCD3mFDxa-RmdKq3jl5JGM,1443
182
+ pip/_internal/operations/install/legacy.py,sha256=xyGakzR-Xnfa9H37-OzT6cKH-AQMsctBkFxfUr3jlvw,4537
183
+ pip/_internal/operations/install/wheel.py,sha256=oV6IB77355YCaI_ta4mqWXyHlkQF8OqBRZAdq8jpQYs,30269
184
+ pip/_internal/operations/prepare.py,sha256=QB3-cmVD1agyO6_B9oiRs9HCQyyDMHF9pLTboLtufxo,25503
185
+ pip/_internal/pyproject.py,sha256=DQoqc7F3HyFBO7fG82HOPsdP-GfEzfuqTUGOG0v2E1c,7246
186
+ pip/_internal/req/__init__.py,sha256=5eiQnk8IiwtrWHU4r1ttcLy545MCrUdg5eRRq7_ZNz0,2925
187
+ pip/_internal/req/__pycache__/__init__.cpython-310.pyc,,
188
+ pip/_internal/req/__pycache__/constructors.cpython-310.pyc,,
189
+ pip/_internal/req/__pycache__/req_file.cpython-310.pyc,,
190
+ pip/_internal/req/__pycache__/req_install.cpython-310.pyc,,
191
+ pip/_internal/req/__pycache__/req_set.cpython-310.pyc,,
192
+ pip/_internal/req/__pycache__/req_tracker.cpython-310.pyc,,
193
+ pip/_internal/req/__pycache__/req_uninstall.cpython-310.pyc,,
194
+ pip/_internal/req/constructors.py,sha256=Izde_5pbxkMzDWN-khLufzKKpC0uaGNTPRO9ShWrud8,16300
195
+ pip/_internal/req/req_file.py,sha256=RN04-oHRNn5xECciUcs0Gh3LxA1RdlVw1X6wbVs0r7k,17936
196
+ pip/_internal/req/req_install.py,sha256=gIGZ3t9QzqCBoeL04my4DIuqM86j0i5nId6iwZIGYzQ,32517
197
+ pip/_internal/req/req_set.py,sha256=yeKmy9-oH5tW2Oe7SHobrp9btcDBZ3DxdFJ3HqAKQ44,7762
198
+ pip/_internal/req/req_tracker.py,sha256=tFN50kuTTWwpY0WJkPK6QCPUZePmUhaBbuKVSgkWl1A,4312
199
+ pip/_internal/req/req_uninstall.py,sha256=T6n_Pgpljq30LLQ81Qs4kpPoxSuJUkUvbvG9b697rGs,24450
200
+ pip/_internal/resolution/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
201
+ pip/_internal/resolution/__pycache__/__init__.cpython-310.pyc,,
202
+ pip/_internal/resolution/__pycache__/base.cpython-310.pyc,,
203
+ pip/_internal/resolution/base.py,sha256=qMuP4GV1NJU-81E3gUKzczQxzS-f_SWF0Dk59YVrH9A,575
204
+ pip/_internal/resolution/legacy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
205
+ pip/_internal/resolution/legacy/__pycache__/__init__.cpython-310.pyc,,
206
+ pip/_internal/resolution/legacy/__pycache__/resolver.cpython-310.pyc,,
207
+ pip/_internal/resolution/legacy/resolver.py,sha256=6nUm1wcBGRT58gUOu0-B0TE354XrCx_dexslaFtlDis,18005
208
+ pip/_internal/resolution/resolvelib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
209
+ pip/_internal/resolution/resolvelib/__pycache__/__init__.cpython-310.pyc,,
210
+ pip/_internal/resolution/resolvelib/__pycache__/base.cpython-310.pyc,,
211
+ pip/_internal/resolution/resolvelib/__pycache__/candidates.cpython-310.pyc,,
212
+ pip/_internal/resolution/resolvelib/__pycache__/factory.cpython-310.pyc,,
213
+ pip/_internal/resolution/resolvelib/__pycache__/found_candidates.cpython-310.pyc,,
214
+ pip/_internal/resolution/resolvelib/__pycache__/provider.cpython-310.pyc,,
215
+ pip/_internal/resolution/resolvelib/__pycache__/reporter.cpython-310.pyc,,
216
+ pip/_internal/resolution/resolvelib/__pycache__/requirements.cpython-310.pyc,,
217
+ pip/_internal/resolution/resolvelib/__pycache__/resolver.cpython-310.pyc,,
218
+ pip/_internal/resolution/resolvelib/base.py,sha256=IWvd3KNMJtIqDLcqeUuGV4MbimM83JOofBKwggVYxzI,5434
219
+ pip/_internal/resolution/resolvelib/candidates.py,sha256=a8-csKtuj_Bz8UeYgk2cz8mP1EOObTyXsZrCO8wq2Tc,19397
220
+ pip/_internal/resolution/resolvelib/factory.py,sha256=clwGzM3_u3SReBwOOG9sAvcdd2pNqk03T5qrCyryeB4,27559
221
+ pip/_internal/resolution/resolvelib/found_candidates.py,sha256=9kFfU_ZFugtCMaCIez6UYrG4hyfqwDaQYxP9db9XQvE,5427
222
+ pip/_internal/resolution/resolvelib/provider.py,sha256=qXB9l3kjEca0orqTS2e6TGaWHJIn537kEqJYClBIShQ,8617
223
+ pip/_internal/resolution/resolvelib/reporter.py,sha256=AwjzTX3LytVk9wuVS1LdJAk1V2kPhEdn1dE7MfrPwLM,2669
224
+ pip/_internal/resolution/resolvelib/requirements.py,sha256=FKE-HcZmsljRrcLpr545XprOOqDd09149GjjF8AfTiw,5621
225
+ pip/_internal/resolution/resolvelib/resolver.py,sha256=Bmwfy5pO8cLzI-13YMgWEAWwFlpjLKdKeCBHMrdWyWM,10795
226
+ pip/_internal/self_outdated_check.py,sha256=cNOjZkFVtwJmftBZt34cH2j9SJ5Gd4vSbJY8FBYM6tg,6671
227
+ pip/_internal/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
228
+ pip/_internal/utils/__pycache__/__init__.cpython-310.pyc,,
229
+ pip/_internal/utils/__pycache__/_log.cpython-310.pyc,,
230
+ pip/_internal/utils/__pycache__/appdirs.cpython-310.pyc,,
231
+ pip/_internal/utils/__pycache__/compat.cpython-310.pyc,,
232
+ pip/_internal/utils/__pycache__/compatibility_tags.cpython-310.pyc,,
233
+ pip/_internal/utils/__pycache__/datetime.cpython-310.pyc,,
234
+ pip/_internal/utils/__pycache__/deprecation.cpython-310.pyc,,
235
+ pip/_internal/utils/__pycache__/direct_url_helpers.cpython-310.pyc,,
236
+ pip/_internal/utils/__pycache__/distutils_args.cpython-310.pyc,,
237
+ pip/_internal/utils/__pycache__/encoding.cpython-310.pyc,,
238
+ pip/_internal/utils/__pycache__/entrypoints.cpython-310.pyc,,
239
+ pip/_internal/utils/__pycache__/filesystem.cpython-310.pyc,,
240
+ pip/_internal/utils/__pycache__/filetypes.cpython-310.pyc,,
241
+ pip/_internal/utils/__pycache__/glibc.cpython-310.pyc,,
242
+ pip/_internal/utils/__pycache__/hashes.cpython-310.pyc,,
243
+ pip/_internal/utils/__pycache__/inject_securetransport.cpython-310.pyc,,
244
+ pip/_internal/utils/__pycache__/logging.cpython-310.pyc,,
245
+ pip/_internal/utils/__pycache__/misc.cpython-310.pyc,,
246
+ pip/_internal/utils/__pycache__/models.cpython-310.pyc,,
247
+ pip/_internal/utils/__pycache__/packaging.cpython-310.pyc,,
248
+ pip/_internal/utils/__pycache__/parallel.cpython-310.pyc,,
249
+ pip/_internal/utils/__pycache__/pkg_resources.cpython-310.pyc,,
250
+ pip/_internal/utils/__pycache__/setuptools_build.cpython-310.pyc,,
251
+ pip/_internal/utils/__pycache__/subprocess.cpython-310.pyc,,
252
+ pip/_internal/utils/__pycache__/temp_dir.cpython-310.pyc,,
253
+ pip/_internal/utils/__pycache__/unpacking.cpython-310.pyc,,
254
+ pip/_internal/utils/__pycache__/urls.cpython-310.pyc,,
255
+ pip/_internal/utils/__pycache__/virtualenv.cpython-310.pyc,,
256
+ pip/_internal/utils/__pycache__/wheel.cpython-310.pyc,,
257
+ pip/_internal/utils/_log.py,sha256=t0zjUNjMLPYna4ZbL0MJB8wrxGdFvC8JsPs7f3UUdz0,1053
258
+ pip/_internal/utils/appdirs.py,sha256=JKsp6Dlfk0BjBfni2Geq_uzpSycc8pV0Ap5A5qAwhag,1220
259
+ pip/_internal/utils/compat.py,sha256=U1ofuQpRkcmWNtCsZccTK_24YsauhWl0KPDMnC69adM,1947
260
+ pip/_internal/utils/compatibility_tags.py,sha256=f0RJ5Wb3Jj2T5z57i0RPLyoWNtqYvymealy3aUucrVU,5622
261
+ pip/_internal/utils/datetime.py,sha256=NhzGBwpDdc5W1hX7-ynGHSFH5T8srUUPpFw6zOORiMM,253
262
+ pip/_internal/utils/deprecation.py,sha256=onEDau8zZyztsQf5HjhHTckTChbXIIdOWRh_Ugz1PSQ,3304
263
+ pip/_internal/utils/direct_url_helpers.py,sha256=7xVyieLJtD1SmB1xsNhIPsYBGIDK7AZGQUtfZyRi0tQ,3073
264
+ pip/_internal/utils/distutils_args.py,sha256=fw5ZrUBHRy2IWgLciAKUl7RA4Ryu6jWL-M9LNv90V2k,1291
265
+ pip/_internal/utils/encoding.py,sha256=3Hr_T_shHHq5EJmQg7sUCeNY37pvmxEmQay6aPsK_4o,1205
266
+ pip/_internal/utils/entrypoints.py,sha256=Iq8laJFPEcErDlGcPIshG_-VrUmfwLB3_nLWlT1EIKI,1082
267
+ pip/_internal/utils/filesystem.py,sha256=8UhURyTKl2R1l9TAnYXuULsGNNMkH9IBPHAoRy4QEaU,6075
268
+ pip/_internal/utils/filetypes.py,sha256=0VA-FQmevfgw7_9OAe7YXjxB3DcYUxpUypVKERWlU_I,789
269
+ pip/_internal/utils/glibc.py,sha256=6i1vPIDuLfV3vKQeFe2oUa9QjSHGVlOkvysujEldOEE,3262
270
+ pip/_internal/utils/hashes.py,sha256=dV10-3U4kcopR-XrArkX6vFSiLS9uNOhPbrv8olu8Qw,5332
271
+ pip/_internal/utils/inject_securetransport.py,sha256=Wa89Vhq5SdlBGreg8wdE40iDJGTouYEyFuugMrOVUI0,846
272
+ pip/_internal/utils/logging.py,sha256=cRHg2gUYXEUknl_WzZ2Zdxxah485Gr69wl8TpymaAHc,12735
273
+ pip/_internal/utils/misc.py,sha256=Zt509grPCYtfQEI16CHULTX9nW8eRXbBucPQQasuOYk,24472
274
+ pip/_internal/utils/models.py,sha256=89bZ42cL0YJUmV9GCaFLfs4ZiG_cDW1udAHc6UTCI2c,1376
275
+ pip/_internal/utils/packaging.py,sha256=xTyBldqCDRn0_v9CQizadbHd3ZUTgjXeElTxrJW_JKo,2989
276
+ pip/_internal/utils/parallel.py,sha256=7IFve8sBfphARNXprnhhuSyzlxfdDWaMP3LTFwCUL9c,3325
277
+ pip/_internal/utils/pkg_resources.py,sha256=bkYDe05U5fph4t8TO-q06fpHQwuzookJqP0MokucHWg,1146
278
+ pip/_internal/utils/setuptools_build.py,sha256=tO48Mgcb28GJf3zmkQlzBvsRR-2hdrpm1dipXFBkyaE,5220
279
+ pip/_internal/utils/subprocess.py,sha256=0GB0g9r2muHAnljX2s_sX-x805eWWoDtdN_9JcCwKu8,10324
280
+ pip/_internal/utils/temp_dir.py,sha256=gFwfsfEtM-y5Lb9ZqpbP1wy_K3o-vUPsMouAFQCy7mg,8210
281
+ pip/_internal/utils/unpacking.py,sha256=klIaodkk3_rIg-VjN2s-PQFPfGXaVhOWpOwd46T7bHo,9317
282
+ pip/_internal/utils/urls.py,sha256=pJQSXBv1vKtBFr9KPbSMYA5lY7-G9rjLHq0A8plI7yo,1863
283
+ pip/_internal/utils/virtualenv.py,sha256=3JeTdc_O-2uZbfn7EmQKLyXurWKD3nKSEi856ZZWSC4,3675
284
+ pip/_internal/utils/wheel.py,sha256=xeIImmT3FJfVBPv3J4jxsHJy2wLinnJXAsZOcrrMSAM,6479
285
+ pip/_internal/vcs/__init__.py,sha256=6ZAbu6NoqDWJjYPWCtn8PaZjulAEgs-R0Xngq2Z174Q,611
286
+ pip/_internal/vcs/__pycache__/__init__.cpython-310.pyc,,
287
+ pip/_internal/vcs/__pycache__/bazaar.cpython-310.pyc,,
288
+ pip/_internal/vcs/__pycache__/git.cpython-310.pyc,,
289
+ pip/_internal/vcs/__pycache__/mercurial.cpython-310.pyc,,
290
+ pip/_internal/vcs/__pycache__/subversion.cpython-310.pyc,,
291
+ pip/_internal/vcs/__pycache__/versioncontrol.cpython-310.pyc,,
292
+ pip/_internal/vcs/bazaar.py,sha256=6_3dygJ3x0TrnlsMx2-sokvNKpk-6EfH2zsMgAmLj4o,3058
293
+ pip/_internal/vcs/git.py,sha256=vYxXxg4M6YIUxNQCswMJJ2ZiBkb4capqTziRrATcp0E,17853
294
+ pip/_internal/vcs/mercurial.py,sha256=jrfkmZF7SLOwxrJroz-NjvTTMCvaWl8BnZATPdu1CsE,5234
295
+ pip/_internal/vcs/subversion.py,sha256=Oy4kcrNGmimnatKWHGKtED-0Da6bjfuc2DtsnitUam0,12195
296
+ pip/_internal/vcs/versioncontrol.py,sha256=7w9WNAlFXQj5pRlWA-3jA8XUwn1VSxbNfmWvI-aY2p0,23998
297
+ pip/_internal/wheel_builder.py,sha256=3bibo47mjXw7rRChRqudDQObp5JynLQ5uZ8x81PtBq8,12100
298
+ pip/_vendor/__init__.py,sha256=3sjRJDFysoLez0JAFlt8jJA0FUzWVPkt9AlJlcuNsC0,4814
299
+ pip/_vendor/__pycache__/__init__.cpython-310.pyc,,
300
+ pip/_vendor/__pycache__/appdirs.cpython-310.pyc,,
301
+ pip/_vendor/__pycache__/distro.cpython-310.pyc,,
302
+ pip/_vendor/__pycache__/pyparsing.cpython-310.pyc,,
303
+ pip/_vendor/__pycache__/six.cpython-310.pyc,,
304
+ pip/_vendor/appdirs.py,sha256=Od1rs7d0yMmHLUc0FQn2DleIUbC--EEmM-UtXvFqAjM,26540
305
+ pip/_vendor/cachecontrol/__init__.py,sha256=SR74BEsga7Z2I6-CH8doh2Oq_vH0GG7RCwjJg7TntdI,313
306
+ pip/_vendor/cachecontrol/__pycache__/__init__.cpython-310.pyc,,
307
+ pip/_vendor/cachecontrol/__pycache__/_cmd.cpython-310.pyc,,
308
+ pip/_vendor/cachecontrol/__pycache__/adapter.cpython-310.pyc,,
309
+ pip/_vendor/cachecontrol/__pycache__/cache.cpython-310.pyc,,
310
+ pip/_vendor/cachecontrol/__pycache__/compat.cpython-310.pyc,,
311
+ pip/_vendor/cachecontrol/__pycache__/controller.cpython-310.pyc,,
312
+ pip/_vendor/cachecontrol/__pycache__/filewrapper.cpython-310.pyc,,
313
+ pip/_vendor/cachecontrol/__pycache__/heuristics.cpython-310.pyc,,
314
+ pip/_vendor/cachecontrol/__pycache__/serialize.cpython-310.pyc,,
315
+ pip/_vendor/cachecontrol/__pycache__/wrapper.cpython-310.pyc,,
316
+ pip/_vendor/cachecontrol/_cmd.py,sha256=KIO6PIJoXmNr5RGS2pZjDum1-40oR4fw5kE0LguxrY4,1352
317
+ pip/_vendor/cachecontrol/adapter.py,sha256=FBRrYfpkXaH8hKogEgw6wYCScnL2SJFDZlHBNF0EvLE,5015
318
+ pip/_vendor/cachecontrol/cache.py,sha256=gCo5R0D__iptJ49dUfxwWfu2Lc2OjpDs-MERy2hTpK8,844
319
+ pip/_vendor/cachecontrol/caches/__init__.py,sha256=rN8Ox5dd2ucPtgkybgz77XfTTUL4HFTO2-n2ACK2q3E,88
320
+ pip/_vendor/cachecontrol/caches/__pycache__/__init__.cpython-310.pyc,,
321
+ pip/_vendor/cachecontrol/caches/__pycache__/file_cache.cpython-310.pyc,,
322
+ pip/_vendor/cachecontrol/caches/__pycache__/redis_cache.cpython-310.pyc,,
323
+ pip/_vendor/cachecontrol/caches/file_cache.py,sha256=tw35e4ZnOsxqrlZ2fQ2VYz2FlUlCbFMerNu2tPwtRHY,4299
324
+ pip/_vendor/cachecontrol/caches/redis_cache.py,sha256=hFJ_J9MCUTjblJCBT_cV_glP--2toqHDCKLRGUIHSOQ,889
325
+ pip/_vendor/cachecontrol/compat.py,sha256=3BisP29GBHAo0QxUrbpBsMeXSp8YzKQcGHwEW7VYU2U,724
326
+ pip/_vendor/cachecontrol/controller.py,sha256=fTDK1V7NjpnU1hwfMboX4Vyh73-uWgL6QkghtvvyTrY,14525
327
+ pip/_vendor/cachecontrol/filewrapper.py,sha256=YsK9ISeZg26n-rS0z7MdEcMTyQ9gW_fLb6zIRJvE2rg,2613
328
+ pip/_vendor/cachecontrol/heuristics.py,sha256=yndlfXHJZ5u_TC1ECrV4fVl68OuWiXnDS0HPyscK1MM,4205
329
+ pip/_vendor/cachecontrol/serialize.py,sha256=7Jq5PcVBH6RVI-qkKkQsV5yAiZCFQa7yFhvITw_DYsc,7279
330
+ pip/_vendor/cachecontrol/wrapper.py,sha256=tKJnzRvbl7uJRxOChwlNLdJf9NR0QlnknQxgNzQW2kM,719
331
+ pip/_vendor/certifi/__init__.py,sha256=mRf2Fl2WmJxc7O-Zob068lpqa3nlsU4215CXzbkoBBU,65
332
+ pip/_vendor/certifi/__main__.py,sha256=4JJNpOgznsXzgISGReUBrJGB6Q4zJOlIV99WFE185fM,267
333
+ pip/_vendor/certifi/__pycache__/__init__.cpython-310.pyc,,
334
+ pip/_vendor/certifi/__pycache__/__main__.cpython-310.pyc,,
335
+ pip/_vendor/certifi/__pycache__/core.cpython-310.pyc,,
336
+ pip/_vendor/certifi/cacert.pem,sha256=3i-hfE2K5o3CBKG2tYt6ehJWk2fP64o6Th83fHPoPp4,259465
337
+ pip/_vendor/certifi/core.py,sha256=u1ccq2BcSYX_ZtX61r6UFpwbKCKxNavjrzse_QVQ_PI,2916
338
+ pip/_vendor/chardet/__init__.py,sha256=yxky3TQpsr5YTFEf5XYv0O4wq2e1WSilELYZ9e2AEes,3354
339
+ pip/_vendor/chardet/__pycache__/__init__.cpython-310.pyc,,
340
+ pip/_vendor/chardet/__pycache__/big5freq.cpython-310.pyc,,
341
+ pip/_vendor/chardet/__pycache__/big5prober.cpython-310.pyc,,
342
+ pip/_vendor/chardet/__pycache__/chardistribution.cpython-310.pyc,,
343
+ pip/_vendor/chardet/__pycache__/charsetgroupprober.cpython-310.pyc,,
344
+ pip/_vendor/chardet/__pycache__/charsetprober.cpython-310.pyc,,
345
+ pip/_vendor/chardet/__pycache__/codingstatemachine.cpython-310.pyc,,
346
+ pip/_vendor/chardet/__pycache__/compat.cpython-310.pyc,,
347
+ pip/_vendor/chardet/__pycache__/cp949prober.cpython-310.pyc,,
348
+ pip/_vendor/chardet/__pycache__/enums.cpython-310.pyc,,
349
+ pip/_vendor/chardet/__pycache__/escprober.cpython-310.pyc,,
350
+ pip/_vendor/chardet/__pycache__/escsm.cpython-310.pyc,,
351
+ pip/_vendor/chardet/__pycache__/eucjpprober.cpython-310.pyc,,
352
+ pip/_vendor/chardet/__pycache__/euckrfreq.cpython-310.pyc,,
353
+ pip/_vendor/chardet/__pycache__/euckrprober.cpython-310.pyc,,
354
+ pip/_vendor/chardet/__pycache__/euctwfreq.cpython-310.pyc,,
355
+ pip/_vendor/chardet/__pycache__/euctwprober.cpython-310.pyc,,
356
+ pip/_vendor/chardet/__pycache__/gb2312freq.cpython-310.pyc,,
357
+ pip/_vendor/chardet/__pycache__/gb2312prober.cpython-310.pyc,,
358
+ pip/_vendor/chardet/__pycache__/hebrewprober.cpython-310.pyc,,
359
+ pip/_vendor/chardet/__pycache__/jisfreq.cpython-310.pyc,,
360
+ pip/_vendor/chardet/__pycache__/jpcntx.cpython-310.pyc,,
361
+ pip/_vendor/chardet/__pycache__/langbulgarianmodel.cpython-310.pyc,,
362
+ pip/_vendor/chardet/__pycache__/langgreekmodel.cpython-310.pyc,,
363
+ pip/_vendor/chardet/__pycache__/langhebrewmodel.cpython-310.pyc,,
364
+ pip/_vendor/chardet/__pycache__/langhungarianmodel.cpython-310.pyc,,
365
+ pip/_vendor/chardet/__pycache__/langrussianmodel.cpython-310.pyc,,
366
+ pip/_vendor/chardet/__pycache__/langthaimodel.cpython-310.pyc,,
367
+ pip/_vendor/chardet/__pycache__/langturkishmodel.cpython-310.pyc,,
368
+ pip/_vendor/chardet/__pycache__/latin1prober.cpython-310.pyc,,
369
+ pip/_vendor/chardet/__pycache__/mbcharsetprober.cpython-310.pyc,,
370
+ pip/_vendor/chardet/__pycache__/mbcsgroupprober.cpython-310.pyc,,
371
+ pip/_vendor/chardet/__pycache__/mbcssm.cpython-310.pyc,,
372
+ pip/_vendor/chardet/__pycache__/sbcharsetprober.cpython-310.pyc,,
373
+ pip/_vendor/chardet/__pycache__/sbcsgroupprober.cpython-310.pyc,,
374
+ pip/_vendor/chardet/__pycache__/sjisprober.cpython-310.pyc,,
375
+ pip/_vendor/chardet/__pycache__/universaldetector.cpython-310.pyc,,
376
+ pip/_vendor/chardet/__pycache__/utf8prober.cpython-310.pyc,,
377
+ pip/_vendor/chardet/__pycache__/version.cpython-310.pyc,,
378
+ pip/_vendor/chardet/big5freq.py,sha256=dwRzRlsGp3Zgr1JQSSSwnxvyaZ7_q-5kuPfCVMuy4to,31640
379
+ pip/_vendor/chardet/big5prober.py,sha256=TpmdoNfRtnQ7x9Q_p-a1CHaG-ok2mbisN5e9UHAtOiY,1804
380
+ pip/_vendor/chardet/chardistribution.py,sha256=NzboAhfS6GODy_Tp6BkmUOL4NuxwTVfdVFcKA9bdUAo,9644
381
+ pip/_vendor/chardet/charsetgroupprober.py,sha256=NPYh0Agp8UnrfqIls_qdbwszQ1mv9imGawGUCErFT6M,3946
382
+ pip/_vendor/chardet/charsetprober.py,sha256=kk5-m0VdjqzbEhPRkBZ386R3fBQo3DxsBrdL-WFyk1o,5255
383
+ pip/_vendor/chardet/cli/__init__.py,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
384
+ pip/_vendor/chardet/cli/__pycache__/__init__.cpython-310.pyc,,
385
+ pip/_vendor/chardet/cli/__pycache__/chardetect.cpython-310.pyc,,
386
+ pip/_vendor/chardet/cli/chardetect.py,sha256=535zsG4tA_x-_xPtEeDvn46QLib2nvF-5NT_nJdGgVs,2831
387
+ pip/_vendor/chardet/codingstatemachine.py,sha256=qz9ZwK1q4mZ4s4zDRbyXu5KaGunYbk7g1Z7fqfb4mA4,3678
388
+ pip/_vendor/chardet/compat.py,sha256=3j2eGvEAakISaIanHZ4wZutzfttNdRSdlo6RSjpyxsM,1236
389
+ pip/_vendor/chardet/cp949prober.py,sha256=5NnMVUcel3jDY3w8ljD0cXyj2lcrvdagxOVE1jxl7xc,1904
390
+ pip/_vendor/chardet/enums.py,sha256=3H_EIVP-VUYOdKqe2xmYdyooEDSLqS8sACMbn_3oejU,1737
391
+ pip/_vendor/chardet/escprober.py,sha256=5MrTnVtZGEt3ssnY-lOXmOo3JY-CIqz9ruG3KjDpkbY,4051
392
+ pip/_vendor/chardet/escsm.py,sha256=xQbwmM3Ieuskg-Aohyc6-bSfg3vsY0tx2TEKLDoVZGg,10756
393
+ pip/_vendor/chardet/eucjpprober.py,sha256=PHumemJS19xMhDR4xPrsvxMfyBfsb297kVWmYz6zgy8,3841
394
+ pip/_vendor/chardet/euckrfreq.py,sha256=MrLrIWMtlaDI0LYt-MM3MougBbLtSWHs6kvZx0VasIM,13741
395
+ pip/_vendor/chardet/euckrprober.py,sha256=VbiOn7_id7mL9Q5GdeV0Ze3w5fG0nRCpUkEzeR-bnnY,1795
396
+ pip/_vendor/chardet/euctwfreq.py,sha256=ZPBIHZDwNknGf7m6r4xGH8bX0W38qBpnTwVVv1QHw_M,32008
397
+ pip/_vendor/chardet/euctwprober.py,sha256=hlUyGKUxzOPfBxCcyUcvRZSxgkLuvRoDU9wejp6YMiM,1793
398
+ pip/_vendor/chardet/gb2312freq.py,sha256=aLHs-2GS8vmSM2ljyoWWgeVq_xRRcS_gN7ykpIiV43A,20998
399
+ pip/_vendor/chardet/gb2312prober.py,sha256=msVbrDFcrJRE_XvsyETiqbTGfvdFhVIEZ2zBd-OENaE,1800
400
+ pip/_vendor/chardet/hebrewprober.py,sha256=r81LqgKb24ZbvOmfi95MzItUxx7bkrjJR1ppkj5rvZw,14130
401
+ pip/_vendor/chardet/jisfreq.py,sha256=vrqCR4CmwownBVXJ3Hh_gsfiDnIHOELbcNmTyC6Jx3w,26102
402
+ pip/_vendor/chardet/jpcntx.py,sha256=Cn4cypo2y8CpqCan-zsdfYdEgXkRCnsqQoYaCu6FRjI,19876
403
+ pip/_vendor/chardet/langbulgarianmodel.py,sha256=IuDOQ4uAe5spaYXt1F-2_496DFYd3J5lyLKKbVg-Nkw,110347
404
+ pip/_vendor/chardet/langgreekmodel.py,sha256=cZRowhYjEUNYCevhuD5ZMHMiOIf3Pk1IpRixjTpRPB0,103969
405
+ pip/_vendor/chardet/langhebrewmodel.py,sha256=p-xw_b2XvGVSIQFgQL91cVpS7u3vPpGJZ0udYxD07Do,103159
406
+ pip/_vendor/chardet/langhungarianmodel.py,sha256=EKIZs5Z8Y-l6ORDcBzE9htOMMnAnr2j6Wb1PFRBMVxM,107148
407
+ pip/_vendor/chardet/langrussianmodel.py,sha256=TFH-3rTFzbCBF15oasmoqf92FKBnwWY_HaN2ptl5WVo,136898
408
+ pip/_vendor/chardet/langthaimodel.py,sha256=rTzLQ2x_RjQEzZfIksCR--SCFQyuP5eCtQpqxyl5-x8,107695
409
+ pip/_vendor/chardet/langturkishmodel.py,sha256=fWI_tafe_UQ24gdOGqOWy1tnEY2jxKHoi4ueoT3rrrc,100329
410
+ pip/_vendor/chardet/latin1prober.py,sha256=s1SFkEFY2NGe2_9bgX2MhOmyM_U_qSd_jVSdkdSgZxs,5515
411
+ pip/_vendor/chardet/mbcharsetprober.py,sha256=hzFVD-brxTAVLnTAkDqa1ztd6RwGGwb5oAdvhj1-lE8,3504
412
+ pip/_vendor/chardet/mbcsgroupprober.py,sha256=DlT-X7KRUl5y3SWJNqF1NXqvkjVc47jPKjJ2j4KVs3A,2066
413
+ pip/_vendor/chardet/mbcssm.py,sha256=LGUDh1VB61rWsZB4QlJBzaCjI2PUEUgbBc91gPlX4DQ,26053
414
+ pip/_vendor/chardet/metadata/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
415
+ pip/_vendor/chardet/metadata/__pycache__/__init__.cpython-310.pyc,,
416
+ pip/_vendor/chardet/metadata/__pycache__/languages.cpython-310.pyc,,
417
+ pip/_vendor/chardet/metadata/languages.py,sha256=pGf_EnapgynSUCViRjUcwEi7AWw_bYPJFHCqerAFSbQ,19784
418
+ pip/_vendor/chardet/sbcharsetprober.py,sha256=VPAZ5z-o8ixIIfEGTScLVXeQxkd3Zqi1eceerr0rb78,6281
419
+ pip/_vendor/chardet/sbcsgroupprober.py,sha256=p8XICsXYXOF78Anypfvdne8K_0p8qFC-SUF5nwD1fo4,4392
420
+ pip/_vendor/chardet/sjisprober.py,sha256=1WGev_SSHpa7AVXmM0DIONl1OvyKc8mdydUNaKtGGNI,3866
421
+ pip/_vendor/chardet/universaldetector.py,sha256=C3ryFrDZ9JuroNMdYwgDa2_zAYJlWuPHyHLX5WtCY-g,12789
422
+ pip/_vendor/chardet/utf8prober.py,sha256=rGwn69WfIvmibp0sWvYuH_TPoXs7zzwKHTX79Ojbr9o,2848
423
+ pip/_vendor/chardet/version.py,sha256=LCY3oiBIflXJGeBYm7ly2aw6P9n272rhp3t7qz3oOHo,251
424
+ pip/_vendor/colorama/__init__.py,sha256=besK61Glmusp-wZ1wjjSlsPKEY_6zndaeulh1FkVStw,245
425
+ pip/_vendor/colorama/__pycache__/__init__.cpython-310.pyc,,
426
+ pip/_vendor/colorama/__pycache__/ansi.cpython-310.pyc,,
427
+ pip/_vendor/colorama/__pycache__/ansitowin32.cpython-310.pyc,,
428
+ pip/_vendor/colorama/__pycache__/initialise.cpython-310.pyc,,
429
+ pip/_vendor/colorama/__pycache__/win32.cpython-310.pyc,,
430
+ pip/_vendor/colorama/__pycache__/winterm.cpython-310.pyc,,
431
+ pip/_vendor/colorama/ansi.py,sha256=121ZIWJSdXR76TcqKXusVZQRgyb0AIlRnf5EW6oSGlQ,2624
432
+ pip/_vendor/colorama/ansitowin32.py,sha256=bZByVMjpiUp-LSAK21KNvCh63UN9CPkXdHFPUsq20kA,10775
433
+ pip/_vendor/colorama/initialise.py,sha256=J92wwYPAAEgdlAyw-ady4JJxl1j9UmXPodi0HicWDwg,1995
434
+ pip/_vendor/colorama/win32.py,sha256=fI0Ani_DO_cYDAbHz_a0BsMbDKHCA1-P3PGcj0eDCmA,5556
435
+ pip/_vendor/colorama/winterm.py,sha256=Zurpa5AEwarU62JTuERX53gGelEWH5SBUiAXN4CxMtA,6607
436
+ pip/_vendor/distlib/__init__.py,sha256=iP0jP2IxDeV5bLyzuna9JsdxOw2AO-VqAMXslthb-oQ,604
437
+ pip/_vendor/distlib/__pycache__/__init__.cpython-310.pyc,,
438
+ pip/_vendor/distlib/__pycache__/compat.cpython-310.pyc,,
439
+ pip/_vendor/distlib/__pycache__/database.cpython-310.pyc,,
440
+ pip/_vendor/distlib/__pycache__/index.cpython-310.pyc,,
441
+ pip/_vendor/distlib/__pycache__/locators.cpython-310.pyc,,
442
+ pip/_vendor/distlib/__pycache__/manifest.cpython-310.pyc,,
443
+ pip/_vendor/distlib/__pycache__/markers.cpython-310.pyc,,
444
+ pip/_vendor/distlib/__pycache__/metadata.cpython-310.pyc,,
445
+ pip/_vendor/distlib/__pycache__/resources.cpython-310.pyc,,
446
+ pip/_vendor/distlib/__pycache__/scripts.cpython-310.pyc,,
447
+ pip/_vendor/distlib/__pycache__/util.cpython-310.pyc,,
448
+ pip/_vendor/distlib/__pycache__/version.cpython-310.pyc,,
449
+ pip/_vendor/distlib/__pycache__/wheel.cpython-310.pyc,,
450
+ pip/_vendor/distlib/_backport/__init__.py,sha256=XkACqtjaFfFn1QQBFDNxSqhMva0LqXeeh6H3fVwwLQ4,280
451
+ pip/_vendor/distlib/_backport/__pycache__/__init__.cpython-310.pyc,,
452
+ pip/_vendor/distlib/_backport/__pycache__/misc.cpython-310.pyc,,
453
+ pip/_vendor/distlib/_backport/__pycache__/shutil.cpython-310.pyc,,
454
+ pip/_vendor/distlib/_backport/__pycache__/sysconfig.cpython-310.pyc,,
455
+ pip/_vendor/distlib/_backport/__pycache__/tarfile.cpython-310.pyc,,
456
+ pip/_vendor/distlib/_backport/misc.py,sha256=focjmI7975W3LgEtiNC99lvxohfZdsNSLTakOcPNShs,1012
457
+ pip/_vendor/distlib/_backport/shutil.py,sha256=h-yIttFtLq-_LKn5lLn4beHXzRwcmo2wEg4UKU7hX6E,26471
458
+ pip/_vendor/distlib/_backport/sysconfig.cfg,sha256=LoipPkR2PfCKC7JUQBGxp6OFVlWIiWBXT-rNuzv8acU,2701
459
+ pip/_vendor/distlib/_backport/sysconfig.py,sha256=qV5ZK6YVkHS-gUFacIT2TpFBw7bZJFH3DYa8PbT6O54,27640
460
+ pip/_vendor/distlib/_backport/tarfile.py,sha256=fzwGLsCdTmO8uzoHjyjSgu4-srrDQEAcw4jNKUfvQH0,95235
461
+ pip/_vendor/distlib/compat.py,sha256=Z8PBQ-ZPCJuRvzs5rtHuzceFOB8iYV8HHjAGrW3SQ8s,42528
462
+ pip/_vendor/distlib/database.py,sha256=m_LtL3siDUdcSvftoTnXcjhUJA-WZhDwTvHO7rg72SA,52398
463
+ pip/_vendor/distlib/index.py,sha256=LMZK2uX_oH2SNgPQ_lnnoJFFx6X5ByY-LBP8qgUTuC0,21248
464
+ pip/_vendor/distlib/locators.py,sha256=mefGpRbPPG1Bl-UhNUquwBQR50J8uL0x2CSG-c5mbJs,53265
465
+ pip/_vendor/distlib/manifest.py,sha256=0TlGw5ZyFp8wxr_GJz7tAAXGYwUJvceMIOsh9ydAXpM,15204
466
+ pip/_vendor/distlib/markers.py,sha256=lTFISO7AcGHoYk2AQx_VFrjDltOFAg5YnPTvBGnOZtE,4474
467
+ pip/_vendor/distlib/metadata.py,sha256=tCLNLfWfC-lQacX4bY-mBTKPgJZTiowKnLX2HWUcQeE,40167
468
+ pip/_vendor/distlib/resources.py,sha256=DMriFf8j-5IXduPHW0YPnx50jQIbaOlvTQkPcdN5r88,11178
469
+ pip/_vendor/distlib/scripts.py,sha256=-jtzATPNKOj8VpnxJUh1aXdUUkNpXiop-bOwsojbwWA,17671
470
+ pip/_vendor/distlib/t32.exe,sha256=NS3xBCVAld35JVFNmb-1QRyVtThukMrwZVeXn4LhaEQ,96768
471
+ pip/_vendor/distlib/t64.exe,sha256=oAqHes78rUWVM0OtVqIhUvequl_PKhAhXYQWnUf7zR0,105984
472
+ pip/_vendor/distlib/util.py,sha256=w5nS2W71eWhilj69cZbERp6NR5rV1p_yIUbkwpvtqu4,69523
473
+ pip/_vendor/distlib/version.py,sha256=cI1oZGIqY11EQn5P-jI_OqQml7jIxLFS52syBFIpXNU,24247
474
+ pip/_vendor/distlib/w32.exe,sha256=lJtnZdeUxTZWya_EW5DZos_K5rswRECGspIl8ZJCIXs,90112
475
+ pip/_vendor/distlib/w64.exe,sha256=0aRzoN2BO9NWW4ENy4_4vHkHR4qZTFZNVSAJJYlODTI,99840
476
+ pip/_vendor/distlib/wheel.py,sha256=NNoICc3pe4uSF-gHpmqgGwQs3Q-0dnn5418JHS1ZI6c,44118
477
+ pip/_vendor/distro.py,sha256=ni3ahks9qSr3P1FMur9zTPEF_xcAdaHW8iWZWqwB5mU,44858
478
+ pip/_vendor/html5lib/__init__.py,sha256=Bmlpvs5dN2GoaWRAvN2UZ1yF_p7xb2zROelA0QxBKis,1195
479
+ pip/_vendor/html5lib/__pycache__/__init__.cpython-310.pyc,,
480
+ pip/_vendor/html5lib/__pycache__/_ihatexml.cpython-310.pyc,,
481
+ pip/_vendor/html5lib/__pycache__/_inputstream.cpython-310.pyc,,
482
+ pip/_vendor/html5lib/__pycache__/_tokenizer.cpython-310.pyc,,
483
+ pip/_vendor/html5lib/__pycache__/_utils.cpython-310.pyc,,
484
+ pip/_vendor/html5lib/__pycache__/constants.cpython-310.pyc,,
485
+ pip/_vendor/html5lib/__pycache__/html5parser.cpython-310.pyc,,
486
+ pip/_vendor/html5lib/__pycache__/serializer.cpython-310.pyc,,
487
+ pip/_vendor/html5lib/_ihatexml.py,sha256=IyMKE35pNPCYYGs290_oSUhWXF1BQZsbVcXBzGuFvl4,17017
488
+ pip/_vendor/html5lib/_inputstream.py,sha256=EA6Wj46jxuK6544Vnk9YOjIpFwGbfJW0Ar2cMH1H0VU,33271
489
+ pip/_vendor/html5lib/_tokenizer.py,sha256=BUDNWZENVB0oFBiKR49sZsqQU4rzLLa13-byISlYRfA,78775
490
+ pip/_vendor/html5lib/_trie/__init__.py,sha256=kfSo27BaU64El8U7bg4ugLmI3Ksywu54xE6BlhVgggA,114
491
+ pip/_vendor/html5lib/_trie/__pycache__/__init__.cpython-310.pyc,,
492
+ pip/_vendor/html5lib/_trie/__pycache__/_base.cpython-310.pyc,,
493
+ pip/_vendor/html5lib/_trie/__pycache__/py.cpython-310.pyc,,
494
+ pip/_vendor/html5lib/_trie/_base.py,sha256=LTpLNz1pn7LAcfn2TFvRp4moVPbFTkkbhzjPKUrvGes,1053
495
+ pip/_vendor/html5lib/_trie/py.py,sha256=LmuYcbypKw-aMLcT0-IY6WewATGzg1QRkmyd8hTBQeY,1842
496
+ pip/_vendor/html5lib/_utils.py,sha256=dLFxoZDTv5r38HOIHy45uxWwUY7VhLgbEFWNQw6Wppo,5090
497
+ pip/_vendor/html5lib/constants.py,sha256=P9n6_ScDgAFkst0YfKaB-yaAlxVtUS9uMn5Lh8ywbQo,86410
498
+ pip/_vendor/html5lib/filters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
499
+ pip/_vendor/html5lib/filters/__pycache__/__init__.cpython-310.pyc,,
500
+ pip/_vendor/html5lib/filters/__pycache__/alphabeticalattributes.cpython-310.pyc,,
501
+ pip/_vendor/html5lib/filters/__pycache__/base.cpython-310.pyc,,
502
+ pip/_vendor/html5lib/filters/__pycache__/inject_meta_charset.cpython-310.pyc,,
503
+ pip/_vendor/html5lib/filters/__pycache__/lint.cpython-310.pyc,,
504
+ pip/_vendor/html5lib/filters/__pycache__/optionaltags.cpython-310.pyc,,
505
+ pip/_vendor/html5lib/filters/__pycache__/sanitizer.cpython-310.pyc,,
506
+ pip/_vendor/html5lib/filters/__pycache__/whitespace.cpython-310.pyc,,
507
+ pip/_vendor/html5lib/filters/alphabeticalattributes.py,sha256=0TV6VWJzhNkcLFiR7BNZUJsTJgAEEyZ02in6-PuL2gU,948
508
+ pip/_vendor/html5lib/filters/base.py,sha256=6D2t423hbOLtjnvAAOrs1mWX1vsabMLBrWQF67ITPho,298
509
+ pip/_vendor/html5lib/filters/inject_meta_charset.py,sha256=J-W5X3LyosH1sUipiHU1x-2ocd7g9JSudpIek_QlCUU,3018
510
+ pip/_vendor/html5lib/filters/lint.py,sha256=O6sK29HXXW02Nv-EIEOfGvdQMuXxWvBePu2sQ2ecbJc,3736
511
+ pip/_vendor/html5lib/filters/optionaltags.py,sha256=IVHcJ35kr6_MYBqahFMIK-Gel-ALLUk6Wk9X-or_yXk,10795
512
+ pip/_vendor/html5lib/filters/sanitizer.py,sha256=uwT0HNJHjnw3Omf2LpmvfoVdIgAWb9_3VrMcWD1es_M,27813
513
+ pip/_vendor/html5lib/filters/whitespace.py,sha256=bCC0mMQZicbq8HCg67pip_oScN5Fz_KkkvldfE137Kw,1252
514
+ pip/_vendor/html5lib/html5parser.py,sha256=2xGZMaUvdkuuswAmpkazK1CXHT_y3-XTy4lS71PYUuU,119981
515
+ pip/_vendor/html5lib/serializer.py,sha256=vMivcnRcQxjCSTrbMFdevLMhJ2HbF0cfv_CkroTODZM,16168
516
+ pip/_vendor/html5lib/treeadapters/__init__.py,sha256=76InX2oJAx-C4rGAJziZsoE_CHI8_3thl6TeMgP-ypk,709
517
+ pip/_vendor/html5lib/treeadapters/__pycache__/__init__.cpython-310.pyc,,
518
+ pip/_vendor/html5lib/treeadapters/__pycache__/genshi.cpython-310.pyc,,
519
+ pip/_vendor/html5lib/treeadapters/__pycache__/sax.cpython-310.pyc,,
520
+ pip/_vendor/html5lib/treeadapters/genshi.py,sha256=nQHNa4Hu0IMpu4bqHbJJS3_Cd1pKXgDO1pgMZ6gADDg,1769
521
+ pip/_vendor/html5lib/treeadapters/sax.py,sha256=PAmV6NG9BSpfMHUY72bDbXwAe6Q2tPn1BC2yAD-K1G0,1826
522
+ pip/_vendor/html5lib/treebuilders/__init__.py,sha256=zfrXDjeqDo2M7cJFax6hRJs70Az4pfHFiZbuLOZ9YE4,3680
523
+ pip/_vendor/html5lib/treebuilders/__pycache__/__init__.cpython-310.pyc,,
524
+ pip/_vendor/html5lib/treebuilders/__pycache__/base.cpython-310.pyc,,
525
+ pip/_vendor/html5lib/treebuilders/__pycache__/dom.cpython-310.pyc,,
526
+ pip/_vendor/html5lib/treebuilders/__pycache__/etree.cpython-310.pyc,,
527
+ pip/_vendor/html5lib/treebuilders/__pycache__/etree_lxml.cpython-310.pyc,,
528
+ pip/_vendor/html5lib/treebuilders/base.py,sha256=Yao9LOJd-4KaLEx-3ysqRkAkhv1YaDqhTksvX6nuQyY,14982
529
+ pip/_vendor/html5lib/treebuilders/dom.py,sha256=QWkBtUprtDosTiTFlIY6QpgKwk2-pD0AV84qVTNgiLo,9164
530
+ pip/_vendor/html5lib/treebuilders/etree.py,sha256=k-LHrme562Hd5GmIi87r1_vfF25MtURGPurT3mAp8sY,13179
531
+ pip/_vendor/html5lib/treebuilders/etree_lxml.py,sha256=CviyyGjvv2TwN-m47DC8DFWdx0Gt-atRw9jMTv4v8-Q,15158
532
+ pip/_vendor/html5lib/treewalkers/__init__.py,sha256=buyxCJb9LFfJ_1ZIMdc-Do1zV93Uw-7L942o2H-Swy0,5873
533
+ pip/_vendor/html5lib/treewalkers/__pycache__/__init__.cpython-310.pyc,,
534
+ pip/_vendor/html5lib/treewalkers/__pycache__/base.cpython-310.pyc,,
535
+ pip/_vendor/html5lib/treewalkers/__pycache__/dom.cpython-310.pyc,,
536
+ pip/_vendor/html5lib/treewalkers/__pycache__/etree.cpython-310.pyc,,
537
+ pip/_vendor/html5lib/treewalkers/__pycache__/etree_lxml.cpython-310.pyc,,
538
+ pip/_vendor/html5lib/treewalkers/__pycache__/genshi.cpython-310.pyc,,
539
+ pip/_vendor/html5lib/treewalkers/base.py,sha256=g-cLq7VStBtpZZZ1v_Tbwp3GhJjQ2oG5njgeHVhAaXE,7728
540
+ pip/_vendor/html5lib/treewalkers/dom.py,sha256=fBJht3gn5a6y1WN2KE9gsUru158yTQ0KikT3vOM7Xc4,1456
541
+ pip/_vendor/html5lib/treewalkers/etree.py,sha256=VtcKOS13qy9nv2PAaYoB1j9V1Z8n9o0AEA9KoGAgYOg,4682
542
+ pip/_vendor/html5lib/treewalkers/etree_lxml.py,sha256=u9X06RqSrHanDb0qGI-v8I99-PqzOzmnpZOspHHz_Io,6572
543
+ pip/_vendor/html5lib/treewalkers/genshi.py,sha256=P_2Tnc2GkbWJfuodXN9oYIg6kN9E26aWXXe9iL0_eX4,2378
544
+ pip/_vendor/idna/__init__.py,sha256=aHTBHXXun6n0ecdus8ToBvhs-4Ziin24HuNbJ9ZXE3o,893
545
+ pip/_vendor/idna/__pycache__/__init__.cpython-310.pyc,,
546
+ pip/_vendor/idna/__pycache__/codec.cpython-310.pyc,,
547
+ pip/_vendor/idna/__pycache__/compat.cpython-310.pyc,,
548
+ pip/_vendor/idna/__pycache__/core.cpython-310.pyc,,
549
+ pip/_vendor/idna/__pycache__/idnadata.cpython-310.pyc,,
550
+ pip/_vendor/idna/__pycache__/intranges.cpython-310.pyc,,
551
+ pip/_vendor/idna/__pycache__/package_data.cpython-310.pyc,,
552
+ pip/_vendor/idna/__pycache__/uts46data.cpython-310.pyc,,
553
+ pip/_vendor/idna/codec.py,sha256=LtpT6KflQ-NkZZXgLc0_ADLiShhKuC__nz_JmYyLnXs,3570
554
+ pip/_vendor/idna/compat.py,sha256=Y-t409G3-dxunv_cSx0zrDjJkOKtC60ALLuqSknryXc,376
555
+ pip/_vendor/idna/core.py,sha256=PWa20xVmQMBN8zgaBNXGNKCbQHOdnqKEpdcWCxXTidw,13235
556
+ pip/_vendor/idna/idnadata.py,sha256=z3JZKnxitScqic6U-cO3rM_SmC2P0-UjUHEDnGv1xsQ,44400
557
+ pip/_vendor/idna/intranges.py,sha256=Ipf6IPZDhD56FppDz_tjl_1YWzzh_viPiEBBp_nSQ8k,1991
558
+ pip/_vendor/idna/package_data.py,sha256=MMuW8HkL_d3g6tkyzl7kIEN-fg9uyv6YDmk_v_jjL3U,23
559
+ pip/_vendor/idna/uts46data.py,sha256=l_0BwSDthDPKq_AX35avb7jnXUgPEKgjf816traOf8s,210287
560
+ pip/_vendor/msgpack/__init__.py,sha256=OhoFouHD7wOYMP2PN-Hlyk9RAZw39V-iPTDRsmkoIns,1172
561
+ pip/_vendor/msgpack/__pycache__/__init__.cpython-310.pyc,,
562
+ pip/_vendor/msgpack/__pycache__/_version.cpython-310.pyc,,
563
+ pip/_vendor/msgpack/__pycache__/exceptions.cpython-310.pyc,,
564
+ pip/_vendor/msgpack/__pycache__/ext.cpython-310.pyc,,
565
+ pip/_vendor/msgpack/__pycache__/fallback.cpython-310.pyc,,
566
+ pip/_vendor/msgpack/_version.py,sha256=qcv5IclQy1PSvtCYDvZyjaUSFWdHPIRzdGjv3YwkKCs,21
567
+ pip/_vendor/msgpack/exceptions.py,sha256=2fCtczricqQgdT3NtW6cTqmZn3WA7GQtmlPuT-NhLyM,1129
568
+ pip/_vendor/msgpack/ext.py,sha256=3Xznjz11nxxfQJe50uLzKDznWOvxOBxWSZ833DL_DDs,6281
569
+ pip/_vendor/msgpack/fallback.py,sha256=ZaNwBMO2hh9WrqHnYqdHJaCv8zzPMnva9YhD5yInTpM,39113
570
+ pip/_vendor/packaging/__about__.py,sha256=eoW72tGZd0YfLOf_tDScx_kjG1SFtdXMg79yNoJrxg4,687
571
+ pip/_vendor/packaging/__init__.py,sha256=Rtl7XZgdQyDFurOf4u9TWH8UM8-Y6pNC9mfN1QP5NZY,522
572
+ pip/_vendor/packaging/__pycache__/__about__.cpython-310.pyc,,
573
+ pip/_vendor/packaging/__pycache__/__init__.cpython-310.pyc,,
574
+ pip/_vendor/packaging/__pycache__/_manylinux.cpython-310.pyc,,
575
+ pip/_vendor/packaging/__pycache__/_musllinux.cpython-310.pyc,,
576
+ pip/_vendor/packaging/__pycache__/_structures.cpython-310.pyc,,
577
+ pip/_vendor/packaging/__pycache__/markers.cpython-310.pyc,,
578
+ pip/_vendor/packaging/__pycache__/requirements.cpython-310.pyc,,
579
+ pip/_vendor/packaging/__pycache__/specifiers.cpython-310.pyc,,
580
+ pip/_vendor/packaging/__pycache__/tags.cpython-310.pyc,,
581
+ pip/_vendor/packaging/__pycache__/utils.cpython-310.pyc,,
582
+ pip/_vendor/packaging/__pycache__/version.cpython-310.pyc,,
583
+ pip/_vendor/packaging/_manylinux.py,sha256=qLGjZQVH7CwWE2jjOIyE2f4hektTsbtFc7UxfRCQzMI,11789
584
+ pip/_vendor/packaging/_musllinux.py,sha256=Eq-8bd_ZnxcxPEZ-N4TTl7_HATTosVSmxMhZidXvg5g,4514
585
+ pip/_vendor/packaging/_structures.py,sha256=9-rULC3_oZTgVRl9Furm3bFZe7xVsZeGiway_piR3c0,1696
586
+ pip/_vendor/packaging/markers.py,sha256=nTk-tDQYPWjSMZSUIwGVaIvY3NbYmtQQLSXvAgBBxL8,8791
587
+ pip/_vendor/packaging/requirements.py,sha256=PKbV9AWRNnqd0jGBMi83GPyS3hAUmx8IMxjQSJEBXsY,4822
588
+ pip/_vendor/packaging/specifiers.py,sha256=HeDND7swHCqUzD9mKLuvXwBp8Rl-sBdWoA9okxul8cA,31792
589
+ pip/_vendor/packaging/tags.py,sha256=aUZfcmH14rf5DAwYCHHjx5Z9zWnPAYqm9QKJf0yEkRk,16198
590
+ pip/_vendor/packaging/utils.py,sha256=wl4SX90PE-_rF8s24QsN30N9afxY8BX42yKBmwI3wtU,4336
591
+ pip/_vendor/packaging/version.py,sha256=CfhEcnRcBUAgA2viaZV9i5f84V0goapf5vSGgg3Yxl0,15169
592
+ pip/_vendor/pep517/__init__.py,sha256=_xFPzQ0RNMbFs0cvD091C2aMOneiq-cTNfGj9uSTc3k,136
593
+ pip/_vendor/pep517/__pycache__/__init__.cpython-310.pyc,,
594
+ pip/_vendor/pep517/__pycache__/build.cpython-310.pyc,,
595
+ pip/_vendor/pep517/__pycache__/check.cpython-310.pyc,,
596
+ pip/_vendor/pep517/__pycache__/colorlog.cpython-310.pyc,,
597
+ pip/_vendor/pep517/__pycache__/compat.cpython-310.pyc,,
598
+ pip/_vendor/pep517/__pycache__/dirtools.cpython-310.pyc,,
599
+ pip/_vendor/pep517/__pycache__/envbuild.cpython-310.pyc,,
600
+ pip/_vendor/pep517/__pycache__/meta.cpython-310.pyc,,
601
+ pip/_vendor/pep517/__pycache__/wrappers.cpython-310.pyc,,
602
+ pip/_vendor/pep517/build.py,sha256=MDBFdwDA7ygdOFkHRZDA2vmbLuM5vIg3PYnHAszhMz8,3596
603
+ pip/_vendor/pep517/check.py,sha256=FwTS6MXy67e7MCC3QPsN4g7sou0Pg1FkXxMBLimFJQ4,6303
604
+ pip/_vendor/pep517/colorlog.py,sha256=uOdcoDYZ0ocKGRPPs5JgvpLYVDIfoEVvoMpc43ICQFU,4213
605
+ pip/_vendor/pep517/compat.py,sha256=BmdEmQQW3XQqBjgDov0EWT-q1V-ECANNzDp692gUTdg,1113
606
+ pip/_vendor/pep517/dirtools.py,sha256=hrSzAJOGDo3tXdtCbgJ6LqoLhPVJn6JGmekz1ofLi6o,1173
607
+ pip/_vendor/pep517/envbuild.py,sha256=D8XnGq1CZKAuIAMZQxpJQrvDwkjKRzKotHAOpr_AF1M,6283
608
+ pip/_vendor/pep517/in_process/__init__.py,sha256=IZ3Qr3CBsGM77dePRWUxnF9FADyAi0imlDLX5MWGPz8,580
609
+ pip/_vendor/pep517/in_process/__pycache__/__init__.cpython-310.pyc,,
610
+ pip/_vendor/pep517/in_process/__pycache__/_in_process.cpython-310.pyc,,
611
+ pip/_vendor/pep517/in_process/_in_process.py,sha256=MW1-Z-5WuMGVy0cdY3-kgSr2rEO7bIYDlhgMuMHciEw,11182
612
+ pip/_vendor/pep517/meta.py,sha256=ZkHYB0YHt4FDuSE5NdFuVsat3xfZ6LgW6VS6d4D6vIQ,2555
613
+ pip/_vendor/pep517/wrappers.py,sha256=mgBTFXDKJtKluWpQ7VMZda49ZUzFGSNu5d_PYJLoor0,13629
614
+ pip/_vendor/pkg_resources/__init__.py,sha256=zeMvnKzGEcWISjTwy6YtFKWamTFJdwBwYjBAFUoyf7A,111573
615
+ pip/_vendor/pkg_resources/__pycache__/__init__.cpython-310.pyc,,
616
+ pip/_vendor/pkg_resources/__pycache__/py31compat.cpython-310.pyc,,
617
+ pip/_vendor/pkg_resources/py31compat.py,sha256=tzQGe-w8g7GEXb6Ozw2-v8ZHaIygADmw0LAgriYzPAc,585
618
+ pip/_vendor/progress/__init__.py,sha256=YTntFxK5CZDfVAa1b77EbNkWljGqvyM72YKRTHaHap8,5034
619
+ pip/_vendor/progress/__pycache__/__init__.cpython-310.pyc,,
620
+ pip/_vendor/progress/__pycache__/bar.cpython-310.pyc,,
621
+ pip/_vendor/progress/__pycache__/counter.cpython-310.pyc,,
622
+ pip/_vendor/progress/__pycache__/spinner.cpython-310.pyc,,
623
+ pip/_vendor/progress/bar.py,sha256=evFQod41y2bMU60teK16uV-A5F4yVUehab8dtCiXj1E,2945
624
+ pip/_vendor/progress/counter.py,sha256=c8AdstUGrFQvIQbvtHjjXxZx6LCflrY-a7DVM6IYTBs,1413
625
+ pip/_vendor/progress/spinner.py,sha256=zLcx2RFinPfM6UwveJJrcJ8YABE3aLCAKqQFVD3pHow,1423
626
+ pip/_vendor/pyparsing.py,sha256=lD3A8iEK1JYvnNDP00Cgve4vZjwEFonCvrpo7mEl3Q8,280501
627
+ pip/_vendor/requests/__init__.py,sha256=IPdrlLH8zOpx45fFKwMSH9HT_d9wfwdjjoka0HEY9ps,5267
628
+ pip/_vendor/requests/__pycache__/__init__.cpython-310.pyc,,
629
+ pip/_vendor/requests/__pycache__/__version__.cpython-310.pyc,,
630
+ pip/_vendor/requests/__pycache__/_internal_utils.cpython-310.pyc,,
631
+ pip/_vendor/requests/__pycache__/adapters.cpython-310.pyc,,
632
+ pip/_vendor/requests/__pycache__/api.cpython-310.pyc,,
633
+ pip/_vendor/requests/__pycache__/auth.cpython-310.pyc,,
634
+ pip/_vendor/requests/__pycache__/certs.cpython-310.pyc,,
635
+ pip/_vendor/requests/__pycache__/compat.cpython-310.pyc,,
636
+ pip/_vendor/requests/__pycache__/cookies.cpython-310.pyc,,
637
+ pip/_vendor/requests/__pycache__/exceptions.cpython-310.pyc,,
638
+ pip/_vendor/requests/__pycache__/help.cpython-310.pyc,,
639
+ pip/_vendor/requests/__pycache__/hooks.cpython-310.pyc,,
640
+ pip/_vendor/requests/__pycache__/models.cpython-310.pyc,,
641
+ pip/_vendor/requests/__pycache__/packages.cpython-310.pyc,,
642
+ pip/_vendor/requests/__pycache__/sessions.cpython-310.pyc,,
643
+ pip/_vendor/requests/__pycache__/status_codes.cpython-310.pyc,,
644
+ pip/_vendor/requests/__pycache__/structures.cpython-310.pyc,,
645
+ pip/_vendor/requests/__pycache__/utils.cpython-310.pyc,,
646
+ pip/_vendor/requests/__version__.py,sha256=SKjnGRNIWoxDQR1pAkRW3fen6mAXw7MruEBPlqvEjuE,455
647
+ pip/_vendor/requests/_internal_utils.py,sha256=zDALdxFfs4pAp4CME_TTw2rGyYR2EGBpSehYHgfn8o0,1138
648
+ pip/_vendor/requests/adapters.py,sha256=v-nXh1zlxNzGQWQicaDrnsMmus75p2c99GPOtPl-6uw,22081
649
+ pip/_vendor/requests/api.py,sha256=IPHU2zrGr6hURdheImh9lqurXPhQlv4PFOkjik6dmSQ,6561
650
+ pip/_vendor/requests/auth.py,sha256=xe7s91xl3ENjQgRlZP3-2KsACnXYHAiLOuHLDw6nyyQ,10512
651
+ pip/_vendor/requests/certs.py,sha256=fFBPJjnP90gWELetFYPbzrsfZgSZopej7XhlkrnVVec,483
652
+ pip/_vendor/requests/compat.py,sha256=xfkhI1O0M1RPT9n92GEeoalPuBOYMsdApi7TONmwWD8,2121
653
+ pip/_vendor/requests/cookies.py,sha256=PIxSKntoUn6l2irwR-CBMgm0scK8s-6yUZzwoCVIAdo,18979
654
+ pip/_vendor/requests/exceptions.py,sha256=-_Uvqm89mT79nPWoxlf_NF7JawnsfHcJdAeIipOiAQg,3377
655
+ pip/_vendor/requests/help.py,sha256=HQ9KRPaFWwmEYS46TnLTyaGYXGNv-G3RQyO9yUfa7Gg,4104
656
+ pip/_vendor/requests/hooks.py,sha256=LAWGUHI8SB52PkhFYbwyPcT6mWsjuVJeeZpM0RUTADc,791
657
+ pip/_vendor/requests/models.py,sha256=eBTofFVVYXXqpuqkMrPzRNYKJfuICHhw5bPn6bi-lEI,35890
658
+ pip/_vendor/requests/packages.py,sha256=ry2VlKGoCDdr8ZJyNCXxDcAF1HfENfmoylCK-_VzXh0,711
659
+ pip/_vendor/requests/sessions.py,sha256=xn0NHgjfsCdvZoi11vUDP8zS0LPM0sVuZP_6qYNC7kw,30949
660
+ pip/_vendor/requests/status_codes.py,sha256=ef_TQlJHa44J6_nrl_hTw6h7I-oZS8qg2MHCu9YyzYY,4311
661
+ pip/_vendor/requests/structures.py,sha256=ssrNLrH8XELX89hk4yRQYEVeHnbopq1HAJBfgu38bi8,3110
662
+ pip/_vendor/requests/utils.py,sha256=dZh-kGRO-A8sLHdHp_hcS_5u4syIDRRE2T-un0s4HwM,32407
663
+ pip/_vendor/resolvelib/__init__.py,sha256=g_NlynQjt3xlCb_JRtRIrV6Y-zBYSkQMxH4NtoC4Axc,563
664
+ pip/_vendor/resolvelib/__pycache__/__init__.cpython-310.pyc,,
665
+ pip/_vendor/resolvelib/__pycache__/providers.cpython-310.pyc,,
666
+ pip/_vendor/resolvelib/__pycache__/reporters.cpython-310.pyc,,
667
+ pip/_vendor/resolvelib/__pycache__/resolvers.cpython-310.pyc,,
668
+ pip/_vendor/resolvelib/__pycache__/structs.cpython-310.pyc,,
669
+ pip/_vendor/resolvelib/compat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
670
+ pip/_vendor/resolvelib/compat/__pycache__/__init__.cpython-310.pyc,,
671
+ pip/_vendor/resolvelib/compat/__pycache__/collections_abc.cpython-310.pyc,,
672
+ pip/_vendor/resolvelib/compat/collections_abc.py,sha256=RCp_gXYFZUg2DF2iUPTTD-XSpBTia4xEuJhjbXNzQyM,162
673
+ pip/_vendor/resolvelib/providers.py,sha256=4rEMGvu_xM5qx42oiIeMwgfwsrd1vCUZusmbfvMknWg,5762
674
+ pip/_vendor/resolvelib/reporters.py,sha256=Yi7l5VMEKyhL20KIEglPukQHWJHkweV4e4amcJs-4yk,1401
675
+ pip/_vendor/resolvelib/resolvers.py,sha256=nlrz0Zql6opJgYgnBoFg3FwG_Z9lGp8rAi7hooQ33E8,17698
676
+ pip/_vendor/resolvelib/structs.py,sha256=8HTLTjfJCdDs2FBt3EqaNiFbn0oelLG9OQK9htCvBpE,4959
677
+ pip/_vendor/six.py,sha256=MB08ff_RApHF8XMk2vSx581-xTyX6sf_pQOtz6j1BZU,35547
678
+ pip/_vendor/tenacity/__init__.py,sha256=YfrArVagvtYH5gcjBOlOQ2u6dx2C2N4X1x0DOUBXv5Y,18774
679
+ pip/_vendor/tenacity/__pycache__/__init__.cpython-310.pyc,,
680
+ pip/_vendor/tenacity/__pycache__/_asyncio.cpython-310.pyc,,
681
+ pip/_vendor/tenacity/__pycache__/_utils.cpython-310.pyc,,
682
+ pip/_vendor/tenacity/__pycache__/after.cpython-310.pyc,,
683
+ pip/_vendor/tenacity/__pycache__/before.cpython-310.pyc,,
684
+ pip/_vendor/tenacity/__pycache__/before_sleep.cpython-310.pyc,,
685
+ pip/_vendor/tenacity/__pycache__/nap.cpython-310.pyc,,
686
+ pip/_vendor/tenacity/__pycache__/retry.cpython-310.pyc,,
687
+ pip/_vendor/tenacity/__pycache__/stop.cpython-310.pyc,,
688
+ pip/_vendor/tenacity/__pycache__/tornadoweb.cpython-310.pyc,,
689
+ pip/_vendor/tenacity/__pycache__/wait.cpython-310.pyc,,
690
+ pip/_vendor/tenacity/_asyncio.py,sha256=jZcrONVfckAiLERCKTxI9DJ_Aeq4fquZj9G7Vr1_I7s,3406
691
+ pip/_vendor/tenacity/_utils.py,sha256=J2-zhV7bDTTuye-U8jtimO7lN1x70_a23qAdKniuzSE,2012
692
+ pip/_vendor/tenacity/after.py,sha256=mJq5ygM_NzL69Bx63RbI24IU5KgGHqmwl3AbQXiTCTc,1542
693
+ pip/_vendor/tenacity/before.py,sha256=RmWTv09-aU_3kFVRJw6PX3dp74ZZqnRD1UZUoCWw5XY,1417
694
+ pip/_vendor/tenacity/before_sleep.py,sha256=gSQPoPHevxpluUv0ASzd2Z4AOfq8Q8Y7XOns32cL4n8,1966
695
+ pip/_vendor/tenacity/nap.py,sha256=5DH1ui6-d3X4ZsQCRKJhY5jaQ0NdYZ2AEqt7Svt5VFM,1426
696
+ pip/_vendor/tenacity/retry.py,sha256=VsJQ9DcYTMr0ZeV43yhQfBDDJD1y5av52TJ0OSwvqLw,6858
697
+ pip/_vendor/tenacity/stop.py,sha256=lVnfBu2fzvqVtsL-HH8o1m_HborN7zLnlXBWa2UzM-A,2886
698
+ pip/_vendor/tenacity/tornadoweb.py,sha256=2wGpfDdTztK9Sk74ARv8prohTReTQzst9a1m1qo5bBs,2204
699
+ pip/_vendor/tenacity/wait.py,sha256=t16nLHioncGDdKA6ETwTNzABC-RrZiR-7HS1pXikVsg,6882
700
+ pip/_vendor/tomli/__init__.py,sha256=fUFJngh-LwLWqbgw4Vc7wvxnfTdrIK8Nc7wvydyxOjw,236
701
+ pip/_vendor/tomli/__pycache__/__init__.cpython-310.pyc,,
702
+ pip/_vendor/tomli/__pycache__/_parser.cpython-310.pyc,,
703
+ pip/_vendor/tomli/__pycache__/_re.cpython-310.pyc,,
704
+ pip/_vendor/tomli/_parser.py,sha256=946C00VAWqHjKSueGK44YSAU_Ufwg4LQmvd8yqta2dg,23118
705
+ pip/_vendor/tomli/_re.py,sha256=tGGogP0HJnQDBSITAPldXRB5TLQeaC069FBzxVOOy5k,2764
706
+ pip/_vendor/urllib3/__init__.py,sha256=FzLqycdKhCzSxjYOPTX50D3qf0lTCe6UgfZdwT-Li7o,2848
707
+ pip/_vendor/urllib3/__pycache__/__init__.cpython-310.pyc,,
708
+ pip/_vendor/urllib3/__pycache__/_collections.cpython-310.pyc,,
709
+ pip/_vendor/urllib3/__pycache__/_version.cpython-310.pyc,,
710
+ pip/_vendor/urllib3/__pycache__/connection.cpython-310.pyc,,
711
+ pip/_vendor/urllib3/__pycache__/connectionpool.cpython-310.pyc,,
712
+ pip/_vendor/urllib3/__pycache__/exceptions.cpython-310.pyc,,
713
+ pip/_vendor/urllib3/__pycache__/fields.cpython-310.pyc,,
714
+ pip/_vendor/urllib3/__pycache__/filepost.cpython-310.pyc,,
715
+ pip/_vendor/urllib3/__pycache__/poolmanager.cpython-310.pyc,,
716
+ pip/_vendor/urllib3/__pycache__/request.cpython-310.pyc,,
717
+ pip/_vendor/urllib3/__pycache__/response.cpython-310.pyc,,
718
+ pip/_vendor/urllib3/_collections.py,sha256=RQtWWhudTDETvb2BCVqih1QTpXS2Q5HSf77UJY5ditA,11148
719
+ pip/_vendor/urllib3/_version.py,sha256=Cs83ZyfrMWqd18tvuyiZ-SW91-r7-fsCZ-S3EnE-7Vk,65
720
+ pip/_vendor/urllib3/connection.py,sha256=ml6ucQ9HfP5eR3t_x7Qpgkke62PF92i-b_XYrIUdZeI,19293
721
+ pip/_vendor/urllib3/connectionpool.py,sha256=itteZaSObupTC6p-6YYcI3KTQdLwgQxlS01w_hOoQqA,38198
722
+ pip/_vendor/urllib3/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
723
+ pip/_vendor/urllib3/contrib/__pycache__/__init__.cpython-310.pyc,,
724
+ pip/_vendor/urllib3/contrib/__pycache__/_appengine_environ.cpython-310.pyc,,
725
+ pip/_vendor/urllib3/contrib/__pycache__/appengine.cpython-310.pyc,,
726
+ pip/_vendor/urllib3/contrib/__pycache__/ntlmpool.cpython-310.pyc,,
727
+ pip/_vendor/urllib3/contrib/__pycache__/pyopenssl.cpython-310.pyc,,
728
+ pip/_vendor/urllib3/contrib/__pycache__/securetransport.cpython-310.pyc,,
729
+ pip/_vendor/urllib3/contrib/__pycache__/socks.cpython-310.pyc,,
730
+ pip/_vendor/urllib3/contrib/_appengine_environ.py,sha256=POYJSeNWacJYwXQdv0If3v56lcoiHuL6MQE8pwG1Yoc,993
731
+ pip/_vendor/urllib3/contrib/_securetransport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
732
+ pip/_vendor/urllib3/contrib/_securetransport/__pycache__/__init__.cpython-310.pyc,,
733
+ pip/_vendor/urllib3/contrib/_securetransport/__pycache__/bindings.cpython-310.pyc,,
734
+ pip/_vendor/urllib3/contrib/_securetransport/__pycache__/low_level.cpython-310.pyc,,
735
+ pip/_vendor/urllib3/contrib/_securetransport/bindings.py,sha256=jreOmmwBW-Cio0m7I_OjmP028nqgrGuo_oB2f7Gir3s,18168
736
+ pip/_vendor/urllib3/contrib/_securetransport/low_level.py,sha256=0KKeznz3h0z-SBDbCtGorDfgCgiZ30VQOqkX5ZgaPBY,14304
737
+ pip/_vendor/urllib3/contrib/appengine.py,sha256=nlIR6iWZ0A1cV9X4dYwdx7H1F1tqGE7ai9QCfJ7ntFo,11348
738
+ pip/_vendor/urllib3/contrib/ntlmpool.py,sha256=JCCrvNQpXdL3eb_D4AoviNnl71nYhSX2r-hG9N-dPf4,4668
739
+ pip/_vendor/urllib3/contrib/pyopenssl.py,sha256=093d4DdfHcz0h8KcEg-D9fQEUtXaVLENmOkzOqNQ5eU,17402
740
+ pip/_vendor/urllib3/contrib/securetransport.py,sha256=ozB5tLGYiVgN2NLx9vvzVxjdtY9nNj9mBxGsCYTwEGI,35356
741
+ pip/_vendor/urllib3/contrib/socks.py,sha256=RqYih4HGeICnKzmYnG3MMo2xMlCdwb1bAdEuo6zCA_Y,7313
742
+ pip/_vendor/urllib3/exceptions.py,sha256=QDT9xy1fNxui5aS7dMabeb1gokQOQ-zOT7XiDp-O5Qo,8540
743
+ pip/_vendor/urllib3/fields.py,sha256=0KSfpuXxzXUMLkI2npM9siWOqCJO1H4wCiJN6neVmlA,8853
744
+ pip/_vendor/urllib3/filepost.py,sha256=BVkEES0YAO9tFwXGBj1mD9yO92pRwk4pX5Q6cO5IRb8,2538
745
+ pip/_vendor/urllib3/packages/__init__.py,sha256=FsOIVHqBFBlT3XxZnaD5y2yq0mvtVwmY4kut3GEfcBI,113
746
+ pip/_vendor/urllib3/packages/__pycache__/__init__.cpython-310.pyc,,
747
+ pip/_vendor/urllib3/packages/__pycache__/six.cpython-310.pyc,,
748
+ pip/_vendor/urllib3/packages/backports/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
749
+ pip/_vendor/urllib3/packages/backports/__pycache__/__init__.cpython-310.pyc,,
750
+ pip/_vendor/urllib3/packages/backports/__pycache__/makefile.cpython-310.pyc,,
751
+ pip/_vendor/urllib3/packages/backports/makefile.py,sha256=DREmQjGcs2LoVH_Q3hrggClhTNdcI5Y3GJglsuihjAM,1468
752
+ pip/_vendor/urllib3/packages/six.py,sha256=dUImuFN7dZON9oeQQqHjXdwH3n2Oxr9148Ns4K4Y2xg,35743
753
+ pip/_vendor/urllib3/packages/ssl_match_hostname/__init__.py,sha256=d-Tyw37qbvVdcjcVF4WLYiM7AXaOpIg386C2P2uB5Ks,951
754
+ pip/_vendor/urllib3/packages/ssl_match_hostname/__pycache__/__init__.cpython-310.pyc,,
755
+ pip/_vendor/urllib3/packages/ssl_match_hostname/__pycache__/_implementation.cpython-310.pyc,,
756
+ pip/_vendor/urllib3/packages/ssl_match_hostname/_implementation.py,sha256=WXs1yNtk9PsYVmeTJQAcqeAm81zbzeabEWWf-xRJSAo,5839
757
+ pip/_vendor/urllib3/poolmanager.py,sha256=blNTYqVqFg9zUGncVtyXk1HQsTxKO1Cy9hfGVLAGvhM,20299
758
+ pip/_vendor/urllib3/request.py,sha256=Fe4bQCUhum8qh3t1dihpSsQwdyfd5nB44cNX8566DmM,6155
759
+ pip/_vendor/urllib3/response.py,sha256=LjfUJBUhwPrJTrGgNI3WoySUizNEPd1Xiv71YxE2J7Y,29024
760
+ pip/_vendor/urllib3/util/__init__.py,sha256=UV_J7p9b29cJXXQ6wTvBZppJDLUeKQ6mcv0v1ptl13c,1204
761
+ pip/_vendor/urllib3/util/__pycache__/__init__.cpython-310.pyc,,
762
+ pip/_vendor/urllib3/util/__pycache__/connection.cpython-310.pyc,,
763
+ pip/_vendor/urllib3/util/__pycache__/proxy.cpython-310.pyc,,
764
+ pip/_vendor/urllib3/util/__pycache__/queue.cpython-310.pyc,,
765
+ pip/_vendor/urllib3/util/__pycache__/request.cpython-310.pyc,,
766
+ pip/_vendor/urllib3/util/__pycache__/response.cpython-310.pyc,,
767
+ pip/_vendor/urllib3/util/__pycache__/retry.cpython-310.pyc,,
768
+ pip/_vendor/urllib3/util/__pycache__/ssl_.cpython-310.pyc,,
769
+ pip/_vendor/urllib3/util/__pycache__/ssltransport.cpython-310.pyc,,
770
+ pip/_vendor/urllib3/util/__pycache__/timeout.cpython-310.pyc,,
771
+ pip/_vendor/urllib3/util/__pycache__/url.cpython-310.pyc,,
772
+ pip/_vendor/urllib3/util/__pycache__/wait.cpython-310.pyc,,
773
+ pip/_vendor/urllib3/util/connection.py,sha256=hoiT3Z-vROOSOB-4JrFyRKzF67xYt-ZTFMTiYj1yd6Q,5070
774
+ pip/_vendor/urllib3/util/proxy.py,sha256=xMGYpCWlY1Obf1nod_fhOG3rk3NTUM2q_BJmj6B_NmU,1660
775
+ pip/_vendor/urllib3/util/queue.py,sha256=mY2d0cfoJG51UEKZwk_sJMwYraofNfQWq7Larj9xh_o,520
776
+ pip/_vendor/urllib3/util/request.py,sha256=O-NJTFysuN_wgY33pne8xA1P35qv3R7uh67ER9zwqYM,4266
777
+ pip/_vendor/urllib3/util/response.py,sha256=685vBStgxTo8u3KoOilR6Kuh7IGPZr7TmzrP9awEtqU,3617
778
+ pip/_vendor/urllib3/util/retry.py,sha256=6xS4OYGWN2gz8kX34RH6ly7Uc6YcfCb2Z3V0QMdHpys,21993
779
+ pip/_vendor/urllib3/util/ssl_.py,sha256=WILvlnNl3FxrO1-AuSJzGBuFTLCNIfkzRbhNvIuxseU,17672
780
+ pip/_vendor/urllib3/util/ssltransport.py,sha256=r8zXGD19jRdpYPlAt9wR-mBg9aVRIB3UkV1GiT5uENQ,7152
781
+ pip/_vendor/urllib3/util/timeout.py,sha256=Ym2WjTspeYp4fzcCYGTQ5aSU1neVSMHXBAgDp1rcETw,10271
782
+ pip/_vendor/urllib3/util/url.py,sha256=3MdcqSYaGz4A2FIwYwe4KclwnrTn6ZdEG1-sUoCniUo,14479
783
+ pip/_vendor/urllib3/util/wait.py,sha256=qk2qJQNb3FjhOm9lKJtpByhnsLWRVapWdhW_NLr7Eog,5557
784
+ pip/_vendor/vendor.txt,sha256=gryMWlplkGEQkjoFzG1xYAfARSAghGWVWX-fGObCxOw,386
785
+ pip/_vendor/webencodings/__init__.py,sha256=kG5cBDbIrAtrrdU-h1iSPVYq10ayTFldU1CTRcb1ym4,10921
786
+ pip/_vendor/webencodings/__pycache__/__init__.cpython-310.pyc,,
787
+ pip/_vendor/webencodings/__pycache__/labels.cpython-310.pyc,,
788
+ pip/_vendor/webencodings/__pycache__/mklabels.cpython-310.pyc,,
789
+ pip/_vendor/webencodings/__pycache__/tests.cpython-310.pyc,,
790
+ pip/_vendor/webencodings/__pycache__/x_user_defined.cpython-310.pyc,,
791
+ pip/_vendor/webencodings/labels.py,sha256=e9gPVTA1XNYalJMVVX7lXvb52Kurc4UdnXFJDPcBXFE,9210
792
+ pip/_vendor/webencodings/mklabels.py,sha256=tyhoDDc-TC6kjJY25Qn5TlsyMs2_IyPf_rfh4L6nSrg,1364
793
+ pip/_vendor/webencodings/tests.py,sha256=7J6TdufKEml8sQSWcYEsl-e73QXtPmsIHF6pPT0sq08,6716
794
+ pip/_vendor/webencodings/x_user_defined.py,sha256=CMn5bx2ccJ4y3Bsqf3xC24bYO4ONC3ZY_lv5vrqhKAY,4632
795
+ pip/py.typed,sha256=9_aEgAx4lyfhJKT_8nv7mk-FpX3Mdtn8cV5Fw11xicg,290
venv/Lib/site-packages/pip-21.2.3.dist-info/REQUESTED ADDED
File without changes
venv/Lib/site-packages/pip-21.2.3.dist-info/WHEEL ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.36.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
venv/Lib/site-packages/pip-21.2.3.dist-info/entry_points.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [console_scripts]
2
+ pip = pip._internal.cli.main:main
3
+ pip3 = pip._internal.cli.main:main
4
+ pip3.8 = pip._internal.cli.main:main
5
+
venv/Lib/site-packages/pip-21.2.3.dist-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ pip