Upload 1108 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +77 -0
- .gitattributes +8 -0
- .gitignore +56 -0
- Dockerfile +35 -0
- LICENSE +21 -0
- README.md +295 -8
- app.py +18 -0
- config/setting.toml +42 -0
- config/setting_warp.toml +42 -0
- docker-compose.proxy.yml +36 -0
- docker-compose.yml +14 -0
- main.py +13 -0
- request.py +150 -0
- requirements.txt +10 -0
- src/__pycache__/main.cpython-310.pyc +0 -0
- src/api/__init__.py +6 -0
- src/api/admin.py +1047 -0
- src/api/routes.py +213 -0
- src/core/__init__.py +7 -0
- src/core/auth.py +39 -0
- src/core/config.py +218 -0
- src/core/database.py +1269 -0
- src/core/logger.py +252 -0
- src/core/models.py +186 -0
- src/main.py +209 -0
- src/services/__init__.py +17 -0
- src/services/browser_captcha.py +317 -0
- src/services/browser_captcha_personal.py +197 -0
- src/services/concurrency_manager.py +190 -0
- src/services/file_cache.py +301 -0
- src/services/flow_client.py +765 -0
- src/services/generation_handler.py +1018 -0
- src/services/load_balancer.py +89 -0
- src/services/proxy_manager.py +25 -0
- src/services/token_manager.py +504 -0
- static/login.html +53 -0
- static/manage.html +722 -0
- venv/Lib/site-packages/_distutils_hack/__init__.py +128 -0
- venv/Lib/site-packages/_distutils_hack/__pycache__/__init__.cpython-310.pyc +0 -0
- venv/Lib/site-packages/_distutils_hack/__pycache__/override.cpython-310.pyc +0 -0
- venv/Lib/site-packages/_distutils_hack/override.py +1 -0
- venv/Lib/site-packages/distutils-precedence.pth +3 -0
- venv/Lib/site-packages/pip-21.2.3.dist-info/INSTALLER +1 -0
- venv/Lib/site-packages/pip-21.2.3.dist-info/LICENSE.txt +20 -0
- venv/Lib/site-packages/pip-21.2.3.dist-info/METADATA +92 -0
- venv/Lib/site-packages/pip-21.2.3.dist-info/RECORD +795 -0
- venv/Lib/site-packages/pip-21.2.3.dist-info/REQUESTED +0 -0
- venv/Lib/site-packages/pip-21.2.3.dist-info/WHEEL +5 -0
- venv/Lib/site-packages/pip-21.2.3.dist-info/entry_points.txt +5 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
pinned: false
|
| 8 |
-
short_description: FLOW2API
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Flow2API
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+
[](LICENSE)
|
| 6 |
+
[](https://www.python.org/)
|
| 7 |
+
[](https://fastapi.tiangolo.com/)
|
| 8 |
+
[](https://www.docker.com/)
|
| 9 |
+
|
| 10 |
+
**一个功能完整的 OpenAI 兼容 API 服务,为 Flow 提供统一的接口**
|
| 11 |
+
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
## ✨ 核心特性
|
| 15 |
+
|
| 16 |
+
- 🎨 **文生图** / **图生图**
|
| 17 |
+
- 🎬 **文生视频** / **图生视频**
|
| 18 |
+
- 🎞️ **首尾帧视频**
|
| 19 |
+
- 🔄 **AT自动刷新**
|
| 20 |
+
- 📊 **余额显示** - 实时查询和显示 VideoFX Credits
|
| 21 |
+
- 🚀 **负载均衡** - 多 Token 轮询和并发控制
|
| 22 |
+
- 🌐 **代理支持** - 支持 HTTP/SOCKS5 代理
|
| 23 |
+
- 📱 **Web 管理界面** - 直观的 Token 和配置管理
|
| 24 |
+
- 🎨 **图片生成连续对话**
|
| 25 |
+
|
| 26 |
+
## 🚀 快速开始
|
| 27 |
+
|
| 28 |
+
### 前置要求
|
| 29 |
+
|
| 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 |
+
[](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 图片格式: 
|
| 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"",
|
| 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""
|
| 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
|