├── src ├── api │ ├── __init__.py │ ├── routes.py │ └── admin.py ├── core │ ├── __init__.py │ ├── auth.py │ ├── models.py │ ├── config.py │ └── logger.py ├── services │ ├── __init__.py │ ├── proxy_manager.py │ ├── load_balancer.py │ ├── concurrency_manager.py │ ├── file_cache.py │ ├── browser_captcha_personal.py │ ├── browser_captcha.py │ ├── token_manager.py │ ├── flow_client.py │ └── generation_handler.py └── main.py ├── requirements.txt ├── main.py ├── docker-compose.yml ├── .gitignore ├── Dockerfile ├── docker-compose.proxy.yml ├── config ├── setting.toml └── setting_warp.toml ├── .dockerignore ├── LICENSE ├── static └── login.html ├── request.py └── README.md /src/api/__init__.py: -------------------------------------------------------------------------------- 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"] 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 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.48.0 11 | -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- 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"] 8 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 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 | ) 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 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 15 | -------------------------------------------------------------------------------- /src/services/__init__.py: -------------------------------------------------------------------------------- 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 | ] 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 57 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 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"] 36 | -------------------------------------------------------------------------------- /docker-compose.proxy.yml: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /config/setting.toml: -------------------------------------------------------------------------------- 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 = 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" 43 | -------------------------------------------------------------------------------- /src/services/proxy_manager.py: -------------------------------------------------------------------------------- 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() 26 | -------------------------------------------------------------------------------- /config/setting_warp.toml: -------------------------------------------------------------------------------- 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" 43 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 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.* 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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. 22 | -------------------------------------------------------------------------------- /src/core/auth.py: -------------------------------------------------------------------------------- 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 40 | -------------------------------------------------------------------------------- /src/services/load_balancer.py: -------------------------------------------------------------------------------- 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 gemini-3.0 models (skip free tier tokens) 53 | if model and model in ["gemini-3.0-pro-image-landscape", "gemini-3.0-pro-image-portrait"]: 54 | if token.user_paygate_tier == "PAYGATE_TIER_NOT_PAID": 55 | filtered_reasons[token.id] = "gemini-3.0模型不支持普通账号" 56 | continue 57 | 58 | # Filter for image generation 59 | if for_image_generation: 60 | if not token.image_enabled: 61 | filtered_reasons[token.id] = "图片生成已禁用" 62 | continue 63 | 64 | # Check concurrency limit 65 | if self.concurrency_manager and not await self.concurrency_manager.can_use_image(token.id): 66 | filtered_reasons[token.id] = "图片并发已满" 67 | continue 68 | 69 | # Filter for video generation 70 | if for_video_generation: 71 | if not token.video_enabled: 72 | filtered_reasons[token.id] = "视频生成已禁用" 73 | continue 74 | 75 | # Check concurrency limit 76 | if self.concurrency_manager and not await self.concurrency_manager.can_use_video(token.id): 77 | filtered_reasons[token.id] = "视频并发已满" 78 | continue 79 | 80 | available_tokens.append(token) 81 | 82 | # 输出过滤信息 83 | if filtered_reasons: 84 | debug_logger.log_info(f"[LOAD_BALANCER] 已过滤Token:") 85 | for token_id, reason in filtered_reasons.items(): 86 | debug_logger.log_info(f"[LOAD_BALANCER] - Token {token_id}: {reason}") 87 | 88 | if not available_tokens: 89 | debug_logger.log_info(f"[LOAD_BALANCER] ❌ 没有可用的Token (图片生成={for_image_generation}, 视频生成={for_video_generation})") 90 | return None 91 | 92 | # Random selection 93 | selected = random.choice(available_tokens) 94 | debug_logger.log_info(f"[LOAD_BALANCER] ✅ 已选择Token {selected.id} ({selected.email}) - 余额: {selected.credits}") 95 | return selected 96 | -------------------------------------------------------------------------------- /static/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 登录 - Flow2API 7 | 8 | 12 | 15 | 16 | 17 |
18 |
19 |
20 |

Flow2API

21 |

管理员控制台

22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 | 37 |
38 | 39 |
40 |

Flow2API © 2025

41 |
42 |
43 |
44 |
45 | 46 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/core/models.py: -------------------------------------------------------------------------------- 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 | # OpenAI Compatible Request Models 162 | class ChatMessage(BaseModel): 163 | """Chat message""" 164 | role: str 165 | content: Union[str, List[dict]] # string or multimodal array 166 | 167 | 168 | class ChatCompletionRequest(BaseModel): 169 | """Chat completion request (OpenAI compatible)""" 170 | model: str 171 | messages: List[ChatMessage] 172 | stream: bool = False 173 | temperature: Optional[float] = None 174 | max_tokens: Optional[int] = None 175 | # Flow2API specific parameters 176 | image: Optional[str] = None # Base64 encoded image (deprecated, use messages) 177 | video: Optional[str] = None # Base64 encoded video (deprecated) 178 | -------------------------------------------------------------------------------- /request.py: -------------------------------------------------------------------------------- 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()) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flow2API 2 | 3 |
4 | 5 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 6 | [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/) 7 | [![FastAPI](https://img.shields.io/badge/fastapi-0.119.0-green.svg)](https://fastapi.tiangolo.com/) 8 | [![Docker](https://img.shields.io/badge/docker-supported-blue.svg)](https://www.docker.com/) 9 | 10 | **一个功能完整的 OpenAI 兼容 API 服务,为 Flow 提供统一的接口** 11 | 12 |
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 | ### 方式一:Docker 部署(推荐) 37 | 38 | #### 标准模式(不使用代理) 39 | 40 | ```bash 41 | # 克隆项目 42 | git clone https://github.com/TheSmallHanCat/flow2api.git 43 | cd flow2api 44 | 45 | # 启动服务 46 | docker-compose up -d 47 | 48 | # 查看日志 49 | docker-compose logs -f 50 | ``` 51 | 52 | #### WARP 模式(使用代理) 53 | 54 | ```bash 55 | # 使用 WARP 代理启动 56 | docker-compose -f docker-compose.warp.yml up -d 57 | 58 | # 查看日志 59 | docker-compose -f docker-compose.warp.yml logs -f 60 | ``` 61 | 62 | ### 方式二:本地部署 63 | 64 | ```bash 65 | # 克隆项目 66 | git clone https://github.com/TheSmallHanCat/flow2api.git 67 | cd sora2api 68 | 69 | # 创建虚拟环境 70 | python -m venv venv 71 | 72 | # 激活虚拟环境 73 | # Windows 74 | venv\Scripts\activate 75 | # Linux/Mac 76 | source venv/bin/activate 77 | 78 | # 安装依赖 79 | pip install -r requirements.txt 80 | 81 | # 启动服务 82 | python main.py 83 | ``` 84 | 85 | ### 首次访问 86 | 87 | 服务启动后,访问管理后台: **http://localhost:8000**,首次登录后请立即修改密码! 88 | 89 | - **用户名**: `admin` 90 | - **密码**: `admin` 91 | 92 | ## 📋 支持的模型 93 | 94 | ### 图片生成 95 | 96 | | 模型名称 | 说明| 尺寸 | 97 | |---------|--------|--------| 98 | | `gemini-2.5-flash-image-landscape` | 图/文生图 | 横屏 | 99 | | `gemini-2.5-flash-image-portrait` | 图/文生图 | 竖屏 | 100 | | `gemini-3.0-pro-image-landscape` | 图/文生图 | 横屏 | 101 | | `gemini-3.0-pro-image-portrait` | 图/文生图 | 竖屏 | 102 | | `imagen-4.0-generate-preview-landscape` | 图/文生图 | 横屏 | 103 | | `imagen-4.0-generate-preview-portrait` | 图/文生图 | 竖屏 | 104 | 105 | ### 视频生成 106 | 107 | #### 文生视频 (T2V - Text to Video) 108 | ⚠️ **不支持上传图片** 109 | 110 | | 模型名称 | 说明| 尺寸 | 111 | |---------|---------|--------| 112 | | `veo_3_1_t2v_fast_portrait` | 文生视频 | 竖屏 | 113 | | `veo_3_1_t2v_fast_landscape` | 文生视频 | 横屏 | 114 | | `veo_2_1_fast_d_15_t2v_portrait` | 文生视频 | 竖屏 | 115 | | `veo_2_1_fast_d_15_t2v_landscape` | 文生视频 | 横屏 | 116 | | `veo_2_0_t2v_portrait` | 文生视频 | 竖屏 | 117 | | `veo_2_0_t2v_landscape` | 文生视频 | 横屏 | 118 | 119 | #### 首尾帧模型 (I2V - Image to Video) 120 | 📸 **支持1-2张图片:首尾帧** 121 | 122 | | 模型名称 | 说明| 尺寸 | 123 | |---------|---------|--------| 124 | | `veo_3_1_i2v_s_fast_fl_portrait` | 图生视频 | 竖屏 | 125 | | `veo_3_1_i2v_s_fast_fl_landscape` | 图生视频 | 横屏 | 126 | | `veo_2_1_fast_d_15_i2v_portrait` | 图生视频 | 竖屏 | 127 | | `veo_2_1_fast_d_15_i2v_landscape` | 图生视频 | 横屏 | 128 | | `veo_2_0_i2v_portrait` | 图生视频 | 竖屏 | 129 | | `veo_2_0_i2v_landscape` | 图生视频 | 横屏 | 130 | 131 | #### 多图生成 (R2V - Reference Images to Video) 132 | 🖼️ **支持多张图片** 133 | 134 | | 模型名称 | 说明| 尺寸 | 135 | |---------|---------|--------| 136 | | `veo_3_0_r2v_fast_portrait` | 图生视频 | 竖屏 | 137 | | `veo_3_0_r2v_fast_landscape` | 图生视频 | 横屏 | 138 | 139 | ## 📡 API 使用示例(需要使用流式) 140 | 141 | ### 文生图 142 | 143 | ```bash 144 | curl -X POST "http://localhost:8000/v1/chat/completions" \ 145 | -H "Authorization: Bearer han1234" \ 146 | -H "Content-Type: application/json" \ 147 | -d '{ 148 | "model": "gemini-2.5-flash-image-landscape", 149 | "messages": [ 150 | { 151 | "role": "user", 152 | "content": "一只可爱的猫咪在花园里玩耍" 153 | } 154 | ], 155 | "stream": true 156 | }' 157 | ``` 158 | 159 | ### 图生图 160 | 161 | ```bash 162 | curl -X POST "http://localhost:8000/v1/chat/completions" \ 163 | -H "Authorization: Bearer han1234" \ 164 | -H "Content-Type: application/json" \ 165 | -d '{ 166 | "model": "imagen-4.0-generate-preview-landscape", 167 | "messages": [ 168 | { 169 | "role": "user", 170 | "content": [ 171 | { 172 | "type": "text", 173 | "text": "将这张图片变成水彩画风格" 174 | }, 175 | { 176 | "type": "image_url", 177 | "image_url": { 178 | "url": "data:image/jpeg;base64," 179 | } 180 | } 181 | ] 182 | } 183 | ], 184 | "stream": true 185 | }' 186 | ``` 187 | 188 | ### 文生视频 189 | 190 | ```bash 191 | curl -X POST "http://localhost:8000/v1/chat/completions" \ 192 | -H "Authorization: Bearer han1234" \ 193 | -H "Content-Type: application/json" \ 194 | -d '{ 195 | "model": "veo_3_1_t2v_fast_landscape", 196 | "messages": [ 197 | { 198 | "role": "user", 199 | "content": "一只小猫在草地上追逐蝴蝶" 200 | } 201 | ], 202 | "stream": true 203 | }' 204 | ``` 205 | 206 | ### 首尾帧生成视频 207 | 208 | ```bash 209 | curl -X POST "http://localhost:8000/v1/chat/completions" \ 210 | -H "Authorization: Bearer han1234" \ 211 | -H "Content-Type: application/json" \ 212 | -d '{ 213 | "model": "veo_3_1_i2v_s_fast_fl_landscape", 214 | "messages": [ 215 | { 216 | "role": "user", 217 | "content": [ 218 | { 219 | "type": "text", 220 | "text": "从第一张图过渡到第二张图" 221 | }, 222 | { 223 | "type": "image_url", 224 | "image_url": { 225 | "url": "data:image/jpeg;base64,<首帧base64>" 226 | } 227 | }, 228 | { 229 | "type": "image_url", 230 | "image_url": { 231 | "url": "data:image/jpeg;base64,<尾帧base64>" 232 | } 233 | } 234 | ] 235 | } 236 | ], 237 | "stream": true 238 | }' 239 | ``` 240 | 241 | --- 242 | 243 | ## 📄 许可证 244 | 245 | 本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。 246 | 247 | --- 248 | 249 | ## 🙏 致谢 250 | 251 | - [PearNoDec](https://github.com/PearNoDec) 提供的YesCaptcha打码方案 252 | - [raomaiping](https://github.com/raomaiping) 提供的无头打码方案 253 | 感谢所有贡献者和使用者的支持! 254 | 255 | --- 256 | 257 | ## 📞 联系方式 258 | 259 | - 提交 Issue:[GitHub Issues](https://github.com/TheSmallHanCat/flow2api/issues) 260 | 261 | --- 262 | 263 | **⭐ 如果这个项目对你有帮助,请给个 Star!** 264 | 265 | ## Star History 266 | 267 | [![Star History Chart](https://api.star-history.com/svg?repos=TheSmallHanCat/flow2api&type=date&legend=top-left)](https://www.star-history.com/#TheSmallHanCat/flow2api&type=date&legend=top-left) 268 | -------------------------------------------------------------------------------- /src/services/concurrency_manager.py: -------------------------------------------------------------------------------- 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})") 191 | -------------------------------------------------------------------------------- /src/services/file_cache.py: -------------------------------------------------------------------------------- 1 | """File caching service""" 2 | import os 3 | import asyncio 4 | import hashlib 5 | import time 6 | from pathlib import Path 7 | from typing import Optional 8 | from datetime import datetime, timedelta 9 | from curl_cffi.requests import AsyncSession 10 | from ..core.config import config 11 | from ..core.logger import debug_logger 12 | 13 | 14 | class FileCache: 15 | """File caching service for videos""" 16 | 17 | def __init__(self, cache_dir: str = "tmp", default_timeout: int = 7200, proxy_manager=None): 18 | """ 19 | Initialize file cache 20 | 21 | Args: 22 | cache_dir: Cache directory path 23 | default_timeout: Default cache timeout in seconds (default: 2 hours) 24 | proxy_manager: ProxyManager instance for downloading files 25 | """ 26 | self.cache_dir = Path(cache_dir) 27 | self.cache_dir.mkdir(exist_ok=True) 28 | self.default_timeout = default_timeout 29 | self.proxy_manager = proxy_manager 30 | self._cleanup_task = None 31 | 32 | async def start_cleanup_task(self): 33 | """Start background cleanup task""" 34 | if self._cleanup_task is None: 35 | self._cleanup_task = asyncio.create_task(self._cleanup_loop()) 36 | 37 | async def stop_cleanup_task(self): 38 | """Stop background cleanup task""" 39 | if self._cleanup_task: 40 | self._cleanup_task.cancel() 41 | try: 42 | await self._cleanup_task 43 | except asyncio.CancelledError: 44 | pass 45 | self._cleanup_task = None 46 | 47 | async def _cleanup_loop(self): 48 | """Background task to clean up expired files""" 49 | while True: 50 | try: 51 | await asyncio.sleep(300) # Check every 5 minutes 52 | await self._cleanup_expired_files() 53 | except asyncio.CancelledError: 54 | break 55 | except Exception as e: 56 | debug_logger.log_error( 57 | error_message=f"Cleanup task error: {str(e)}", 58 | status_code=0, 59 | response_text="" 60 | ) 61 | 62 | async def _cleanup_expired_files(self): 63 | """Remove expired cache files""" 64 | try: 65 | current_time = time.time() 66 | removed_count = 0 67 | 68 | for file_path in self.cache_dir.iterdir(): 69 | if file_path.is_file(): 70 | # Check file age 71 | file_age = current_time - file_path.stat().st_mtime 72 | if file_age > self.default_timeout: 73 | try: 74 | file_path.unlink() 75 | removed_count += 1 76 | except Exception: 77 | pass 78 | 79 | if removed_count > 0: 80 | debug_logger.log_info(f"Cleanup: removed {removed_count} expired cache files") 81 | 82 | except Exception as e: 83 | debug_logger.log_error( 84 | error_message=f"Failed to cleanup expired files: {str(e)}", 85 | status_code=0, 86 | response_text="" 87 | ) 88 | 89 | def _generate_cache_filename(self, url: str, media_type: str) -> str: 90 | """Generate unique filename for cached file""" 91 | # Use URL hash as filename 92 | url_hash = hashlib.md5(url.encode()).hexdigest() 93 | 94 | # Determine file extension 95 | if media_type == "video": 96 | ext = ".mp4" 97 | elif media_type == "image": 98 | ext = ".jpg" 99 | else: 100 | ext = "" 101 | 102 | return f"{url_hash}{ext}" 103 | 104 | async def download_and_cache(self, url: str, media_type: str) -> str: 105 | """ 106 | Download file from URL and cache it locally 107 | 108 | Args: 109 | url: File URL to download 110 | media_type: 'image' or 'video' 111 | 112 | Returns: 113 | Local cache filename 114 | """ 115 | filename = self._generate_cache_filename(url, media_type) 116 | file_path = self.cache_dir / filename 117 | 118 | # Check if already cached and not expired 119 | if file_path.exists(): 120 | file_age = time.time() - file_path.stat().st_mtime 121 | if file_age < self.default_timeout: 122 | debug_logger.log_info(f"Cache hit: {filename}") 123 | return filename 124 | else: 125 | # Remove expired file 126 | try: 127 | file_path.unlink() 128 | except Exception: 129 | pass 130 | 131 | # Download file 132 | debug_logger.log_info(f"Downloading file from: {url}") 133 | 134 | try: 135 | # Get proxy if available 136 | proxy_url = None 137 | if self.proxy_manager: 138 | proxy_config = await self.proxy_manager.get_proxy_config() 139 | if proxy_config and proxy_config.enabled and proxy_config.proxy_url: 140 | proxy_url = proxy_config.proxy_url 141 | 142 | # Download with proxy support 143 | async with AsyncSession() as session: 144 | proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None 145 | response = await session.get(url, timeout=60, proxies=proxies) 146 | 147 | if response.status_code != 200: 148 | raise Exception(f"Download failed: HTTP {response.status_code}") 149 | 150 | # Save to cache 151 | with open(file_path, 'wb') as f: 152 | f.write(response.content) 153 | 154 | debug_logger.log_info(f"File cached: {filename} ({len(response.content)} bytes)") 155 | return filename 156 | 157 | except Exception as e: 158 | debug_logger.log_error( 159 | error_message=f"Failed to download file: {str(e)}", 160 | status_code=0, 161 | response_text=str(e) 162 | ) 163 | raise Exception(f"Failed to cache file: {str(e)}") 164 | 165 | def get_cache_path(self, filename: str) -> Path: 166 | """Get full path to cached file""" 167 | return self.cache_dir / filename 168 | 169 | def set_timeout(self, timeout: int): 170 | """Set cache timeout in seconds""" 171 | self.default_timeout = timeout 172 | debug_logger.log_info(f"Cache timeout updated to {timeout} seconds") 173 | 174 | def get_timeout(self) -> int: 175 | """Get current cache timeout""" 176 | return self.default_timeout 177 | 178 | async def clear_all(self): 179 | """Clear all cached files""" 180 | try: 181 | removed_count = 0 182 | for file_path in self.cache_dir.iterdir(): 183 | if file_path.is_file(): 184 | try: 185 | file_path.unlink() 186 | removed_count += 1 187 | except Exception: 188 | pass 189 | 190 | debug_logger.log_info(f"Cache cleared: removed {removed_count} files") 191 | return removed_count 192 | 193 | except Exception as e: 194 | debug_logger.log_error( 195 | error_message=f"Failed to clear cache: {str(e)}", 196 | status_code=0, 197 | response_text="" 198 | ) 199 | raise 200 | -------------------------------------------------------------------------------- /src/services/browser_captcha_personal.py: -------------------------------------------------------------------------------- 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/core/config.py: -------------------------------------------------------------------------------- 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() 219 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 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="

Flow2API

Frontend not found

", 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="

Login Page Not Found

", 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="

Management Page Not Found

", status_code=404) 210 | -------------------------------------------------------------------------------- /src/api/routes.py: -------------------------------------------------------------------------------- 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 not images and len(request.messages) > 1: 137 | debug_logger.log_info(f"[CONTEXT] 开始查找历史参考图,消息数量: {len(request.messages)}") 138 | 139 | # 如果当前请求没有上传图片,则尝试从历史记录中寻找最近的一张生成图 140 | for msg in reversed(request.messages[:-1]): 141 | if msg.role == "assistant" and isinstance(msg.content, str): 142 | # 匹配 Markdown 图片格式: ![...](http...) 143 | matches = re.findall(r"!\[.*?\]\((.*?)\)", msg.content) 144 | if matches: 145 | last_image_url = matches[-1] 146 | 147 | if last_image_url.startswith("http"): 148 | try: 149 | downloaded_bytes = await retrieve_image_data(last_image_url) 150 | if downloaded_bytes and len(downloaded_bytes) > 0: 151 | images.append(downloaded_bytes) 152 | debug_logger.log_info(f"[CONTEXT] ✅ 自动使用历史参考图: {last_image_url}") 153 | break 154 | else: 155 | debug_logger.log_warning(f"[CONTEXT] 图片下载失败或为空,尝试下一个: {last_image_url}") 156 | except Exception as e: 157 | debug_logger.log_error(f"[CONTEXT] 处理参考图时出错: {str(e)}") 158 | # 继续尝试下一个图片 159 | 160 | if not prompt: 161 | raise HTTPException(status_code=400, detail="Prompt cannot be empty") 162 | 163 | # Call generation handler 164 | if request.stream: 165 | # Streaming response 166 | async def generate(): 167 | async for chunk in generation_handler.handle_generation( 168 | model=request.model, 169 | prompt=prompt, 170 | images=images if images else None, 171 | stream=True 172 | ): 173 | yield chunk 174 | 175 | # Send [DONE] signal 176 | yield "data: [DONE]\n\n" 177 | 178 | return StreamingResponse( 179 | generate(), 180 | media_type="text/event-stream", 181 | headers={ 182 | "Cache-Control": "no-cache", 183 | "Connection": "keep-alive", 184 | "X-Accel-Buffering": "no" 185 | } 186 | ) 187 | else: 188 | # Non-streaming response 189 | result = None 190 | async for chunk in generation_handler.handle_generation( 191 | model=request.model, 192 | prompt=prompt, 193 | images=images if images else None, 194 | stream=False 195 | ): 196 | result = chunk 197 | 198 | if result: 199 | # Parse the result JSON string 200 | try: 201 | result_json = json.loads(result) 202 | return JSONResponse(content=result_json) 203 | except json.JSONDecodeError: 204 | # If not JSON, return as-is 205 | return JSONResponse(content={"result": result}) 206 | else: 207 | raise HTTPException(status_code=500, detail="Generation failed: No response from handler") 208 | 209 | except HTTPException: 210 | raise 211 | except Exception as e: 212 | raise HTTPException(status_code=500, detail=str(e)) 213 | -------------------------------------------------------------------------------- /src/core/logger.py: -------------------------------------------------------------------------------- 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}: ") 121 | else: 122 | self.logger.info(" ") 123 | except (AttributeError, TypeError): 124 | self.logger.info(" ") 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() 253 | -------------------------------------------------------------------------------- /src/services/browser_captcha.py: -------------------------------------------------------------------------------- 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)}") 318 | -------------------------------------------------------------------------------- /src/services/token_manager.py: -------------------------------------------------------------------------------- 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 505 | -------------------------------------------------------------------------------- /src/services/flow_client.py: -------------------------------------------------------------------------------- 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 766 | -------------------------------------------------------------------------------- /src/api/admin.py: -------------------------------------------------------------------------------- 1 | """Admin API routes""" 2 | from fastapi import APIRouter, Depends, HTTPException, Header 3 | from fastapi.responses import JSONResponse 4 | from pydantic import BaseModel 5 | from typing import Optional, List 6 | import secrets 7 | from ..core.auth import AuthManager 8 | from ..core.database import Database 9 | from ..services.token_manager import TokenManager 10 | from ..services.proxy_manager import ProxyManager 11 | 12 | router = APIRouter() 13 | 14 | # Dependency injection 15 | token_manager: TokenManager = None 16 | proxy_manager: ProxyManager = None 17 | db: Database = None 18 | 19 | # Store active admin session tokens (in production, use Redis or database) 20 | active_admin_tokens = set() 21 | 22 | 23 | def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database): 24 | """Set service instances""" 25 | global token_manager, proxy_manager, db 26 | token_manager = tm 27 | proxy_manager = pm 28 | db = database 29 | 30 | 31 | # ========== Request Models ========== 32 | 33 | class LoginRequest(BaseModel): 34 | username: str 35 | password: str 36 | 37 | 38 | class AddTokenRequest(BaseModel): 39 | st: str 40 | project_id: Optional[str] = None # 用户可选输入project_id 41 | project_name: Optional[str] = None 42 | remark: Optional[str] = None 43 | image_enabled: bool = True 44 | video_enabled: bool = True 45 | image_concurrency: int = -1 46 | video_concurrency: int = -1 47 | 48 | 49 | class UpdateTokenRequest(BaseModel): 50 | st: str # Session Token (必填,用于刷新AT) 51 | project_id: Optional[str] = None # 用户可选输入project_id 52 | project_name: Optional[str] = None 53 | remark: Optional[str] = None 54 | image_enabled: Optional[bool] = None 55 | video_enabled: Optional[bool] = None 56 | image_concurrency: Optional[int] = None 57 | video_concurrency: Optional[int] = None 58 | 59 | 60 | class ProxyConfigRequest(BaseModel): 61 | proxy_enabled: bool 62 | proxy_url: Optional[str] = None 63 | 64 | 65 | class GenerationConfigRequest(BaseModel): 66 | image_timeout: int 67 | video_timeout: int 68 | 69 | 70 | class ChangePasswordRequest(BaseModel): 71 | username: Optional[str] = None 72 | old_password: str 73 | new_password: str 74 | 75 | 76 | class UpdateAPIKeyRequest(BaseModel): 77 | new_api_key: str 78 | 79 | 80 | class UpdateDebugConfigRequest(BaseModel): 81 | enabled: bool 82 | 83 | 84 | class UpdateAdminConfigRequest(BaseModel): 85 | error_ban_threshold: int 86 | 87 | 88 | class ST2ATRequest(BaseModel): 89 | """ST转AT请求""" 90 | st: str 91 | 92 | 93 | class ImportTokenItem(BaseModel): 94 | """导入Token项""" 95 | email: Optional[str] = None 96 | access_token: Optional[str] = None 97 | session_token: Optional[str] = None 98 | is_active: bool = True 99 | image_enabled: bool = True 100 | video_enabled: bool = True 101 | image_concurrency: int = -1 102 | video_concurrency: int = -1 103 | 104 | 105 | class ImportTokensRequest(BaseModel): 106 | """导入Token请求""" 107 | tokens: List[ImportTokenItem] 108 | 109 | 110 | # ========== Auth Middleware ========== 111 | 112 | async def verify_admin_token(authorization: str = Header(None)): 113 | """Verify admin session token (NOT API key)""" 114 | if not authorization or not authorization.startswith("Bearer "): 115 | raise HTTPException(status_code=401, detail="Missing authorization") 116 | 117 | token = authorization[7:] 118 | 119 | # Check if token is in active session tokens 120 | if token not in active_admin_tokens: 121 | raise HTTPException(status_code=401, detail="Invalid or expired admin token") 122 | 123 | return token 124 | 125 | 126 | # ========== Auth Endpoints ========== 127 | 128 | @router.post("/api/admin/login") 129 | async def admin_login(request: LoginRequest): 130 | """Admin login - returns session token (NOT API key)""" 131 | admin_config = await db.get_admin_config() 132 | 133 | if not AuthManager.verify_admin(request.username, request.password): 134 | raise HTTPException(status_code=401, detail="Invalid credentials") 135 | 136 | # Generate independent session token 137 | session_token = f"admin-{secrets.token_urlsafe(32)}" 138 | 139 | # Store in active tokens 140 | active_admin_tokens.add(session_token) 141 | 142 | return { 143 | "success": True, 144 | "token": session_token, # Session token (NOT API key) 145 | "username": admin_config.username 146 | } 147 | 148 | 149 | @router.post("/api/admin/logout") 150 | async def admin_logout(token: str = Depends(verify_admin_token)): 151 | """Admin logout - invalidate session token""" 152 | active_admin_tokens.discard(token) 153 | return {"success": True, "message": "退出登录成功"} 154 | 155 | 156 | @router.post("/api/admin/change-password") 157 | async def change_password( 158 | request: ChangePasswordRequest, 159 | token: str = Depends(verify_admin_token) 160 | ): 161 | """Change admin password""" 162 | admin_config = await db.get_admin_config() 163 | 164 | # Verify old password 165 | if not AuthManager.verify_admin(admin_config.username, request.old_password): 166 | raise HTTPException(status_code=400, detail="旧密码错误") 167 | 168 | # Update password and username in database 169 | update_params = {"password": request.new_password} 170 | if request.username: 171 | update_params["username"] = request.username 172 | 173 | await db.update_admin_config(**update_params) 174 | 175 | # 🔥 Hot reload: sync database config to memory 176 | await db.reload_config_to_memory() 177 | 178 | # 🔑 Invalidate all admin session tokens (force re-login for security) 179 | active_admin_tokens.clear() 180 | 181 | return {"success": True, "message": "密码修改成功,请重新登录"} 182 | 183 | 184 | # ========== Token Management ========== 185 | 186 | @router.get("/api/tokens") 187 | async def get_tokens(token: str = Depends(verify_admin_token)): 188 | """Get all tokens with statistics""" 189 | tokens = await token_manager.get_all_tokens() 190 | result = [] 191 | 192 | for t in tokens: 193 | stats = await db.get_token_stats(t.id) 194 | 195 | result.append({ 196 | "id": t.id, 197 | "st": t.st, # Session Token for editing 198 | "at": t.at, # Access Token for editing (从ST转换而来) 199 | "at_expires": t.at_expires.isoformat() if t.at_expires else None, # 🆕 AT过期时间 200 | "token": t.at, # 兼容前端 token.token 的访问方式 201 | "email": t.email, 202 | "name": t.name, 203 | "remark": t.remark, 204 | "is_active": t.is_active, 205 | "created_at": t.created_at.isoformat() if t.created_at else None, 206 | "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None, 207 | "use_count": t.use_count, 208 | "credits": t.credits, # 🆕 余额 209 | "user_paygate_tier": t.user_paygate_tier, 210 | "current_project_id": t.current_project_id, # 🆕 项目ID 211 | "current_project_name": t.current_project_name, # 🆕 项目名称 212 | "image_enabled": t.image_enabled, 213 | "video_enabled": t.video_enabled, 214 | "image_concurrency": t.image_concurrency, 215 | "video_concurrency": t.video_concurrency, 216 | "image_count": stats.image_count if stats else 0, 217 | "video_count": stats.video_count if stats else 0, 218 | "error_count": stats.error_count if stats else 0 219 | }) 220 | 221 | return result # 直接返回数组,兼容前端 222 | 223 | 224 | @router.post("/api/tokens") 225 | async def add_token( 226 | request: AddTokenRequest, 227 | token: str = Depends(verify_admin_token) 228 | ): 229 | """Add a new token""" 230 | try: 231 | new_token = await token_manager.add_token( 232 | st=request.st, 233 | project_id=request.project_id, # 🆕 支持用户指定project_id 234 | project_name=request.project_name, 235 | remark=request.remark, 236 | image_enabled=request.image_enabled, 237 | video_enabled=request.video_enabled, 238 | image_concurrency=request.image_concurrency, 239 | video_concurrency=request.video_concurrency 240 | ) 241 | 242 | return { 243 | "success": True, 244 | "message": "Token添加成功", 245 | "token": { 246 | "id": new_token.id, 247 | "email": new_token.email, 248 | "credits": new_token.credits, 249 | "project_id": new_token.current_project_id, 250 | "project_name": new_token.current_project_name 251 | } 252 | } 253 | except ValueError as e: 254 | raise HTTPException(status_code=400, detail=str(e)) 255 | except Exception as e: 256 | raise HTTPException(status_code=500, detail=f"添加Token失败: {str(e)}") 257 | 258 | 259 | @router.put("/api/tokens/{token_id}") 260 | async def update_token( 261 | token_id: int, 262 | request: UpdateTokenRequest, 263 | token: str = Depends(verify_admin_token) 264 | ): 265 | """Update token - 使用ST自动刷新AT""" 266 | try: 267 | # 先ST转AT 268 | result = await token_manager.flow_client.st_to_at(request.st) 269 | at = result["access_token"] 270 | expires = result.get("expires") 271 | 272 | # 解析过期时间 273 | from datetime import datetime 274 | at_expires = None 275 | if expires: 276 | try: 277 | at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00')) 278 | except: 279 | pass 280 | 281 | # 更新token (包含AT、ST、AT过期时间、project_id和project_name) 282 | await token_manager.update_token( 283 | token_id=token_id, 284 | st=request.st, 285 | at=at, 286 | at_expires=at_expires, # 🆕 更新AT过期时间 287 | project_id=request.project_id, 288 | project_name=request.project_name, 289 | remark=request.remark, 290 | image_enabled=request.image_enabled, 291 | video_enabled=request.video_enabled, 292 | image_concurrency=request.image_concurrency, 293 | video_concurrency=request.video_concurrency 294 | ) 295 | 296 | return {"success": True, "message": "Token更新成功"} 297 | except Exception as e: 298 | raise HTTPException(status_code=500, detail=str(e)) 299 | 300 | 301 | @router.delete("/api/tokens/{token_id}") 302 | async def delete_token( 303 | token_id: int, 304 | token: str = Depends(verify_admin_token) 305 | ): 306 | """Delete token""" 307 | try: 308 | await token_manager.delete_token(token_id) 309 | return {"success": True, "message": "Token删除成功"} 310 | except Exception as e: 311 | raise HTTPException(status_code=500, detail=str(e)) 312 | 313 | 314 | @router.post("/api/tokens/{token_id}/enable") 315 | async def enable_token( 316 | token_id: int, 317 | token: str = Depends(verify_admin_token) 318 | ): 319 | """Enable token""" 320 | await token_manager.enable_token(token_id) 321 | return {"success": True, "message": "Token已启用"} 322 | 323 | 324 | @router.post("/api/tokens/{token_id}/disable") 325 | async def disable_token( 326 | token_id: int, 327 | token: str = Depends(verify_admin_token) 328 | ): 329 | """Disable token""" 330 | await token_manager.disable_token(token_id) 331 | return {"success": True, "message": "Token已禁用"} 332 | 333 | 334 | @router.post("/api/tokens/{token_id}/refresh-credits") 335 | async def refresh_credits( 336 | token_id: int, 337 | token: str = Depends(verify_admin_token) 338 | ): 339 | """刷新Token余额 🆕""" 340 | try: 341 | credits = await token_manager.refresh_credits(token_id) 342 | return { 343 | "success": True, 344 | "message": "余额刷新成功", 345 | "credits": credits 346 | } 347 | except Exception as e: 348 | raise HTTPException(status_code=500, detail=f"刷新余额失败: {str(e)}") 349 | 350 | 351 | @router.post("/api/tokens/{token_id}/refresh-at") 352 | async def refresh_at( 353 | token_id: int, 354 | token: str = Depends(verify_admin_token) 355 | ): 356 | """手动刷新Token的AT (使用ST转换) 🆕""" 357 | try: 358 | # 调用token_manager的内部刷新方法 359 | success = await token_manager._refresh_at(token_id) 360 | 361 | if success: 362 | # 获取更新后的token信息 363 | updated_token = await token_manager.get_token(token_id) 364 | return { 365 | "success": True, 366 | "message": "AT刷新成功", 367 | "token": { 368 | "id": updated_token.id, 369 | "email": updated_token.email, 370 | "at_expires": updated_token.at_expires.isoformat() if updated_token.at_expires else None 371 | } 372 | } 373 | else: 374 | raise HTTPException(status_code=500, detail="AT刷新失败") 375 | except Exception as e: 376 | raise HTTPException(status_code=500, detail=f"刷新AT失败: {str(e)}") 377 | 378 | 379 | @router.post("/api/tokens/st2at") 380 | async def st_to_at( 381 | request: ST2ATRequest, 382 | token: str = Depends(verify_admin_token) 383 | ): 384 | """Convert Session Token to Access Token (仅转换,不添加到数据库)""" 385 | try: 386 | result = await token_manager.flow_client.st_to_at(request.st) 387 | return { 388 | "success": True, 389 | "message": "ST converted to AT successfully", 390 | "access_token": result["access_token"], 391 | "email": result.get("user", {}).get("email"), 392 | "expires": result.get("expires") 393 | } 394 | except Exception as e: 395 | raise HTTPException(status_code=400, detail=str(e)) 396 | 397 | 398 | @router.post("/api/tokens/import") 399 | async def import_tokens( 400 | request: ImportTokensRequest, 401 | token: str = Depends(verify_admin_token) 402 | ): 403 | """批量导入Token""" 404 | from datetime import datetime, timezone 405 | 406 | added = 0 407 | updated = 0 408 | errors = [] 409 | 410 | for idx, item in enumerate(request.tokens): 411 | try: 412 | st = item.session_token 413 | 414 | if not st: 415 | errors.append(f"第{idx+1}项: 缺少 session_token") 416 | continue 417 | 418 | # 使用 ST 转 AT 获取用户信息 419 | try: 420 | result = await token_manager.flow_client.st_to_at(st) 421 | at = result["access_token"] 422 | email = result.get("user", {}).get("email") 423 | expires = result.get("expires") 424 | 425 | if not email: 426 | errors.append(f"第{idx+1}项: 无法获取邮箱信息") 427 | continue 428 | 429 | # 解析过期时间 430 | at_expires = None 431 | is_expired = False 432 | if expires: 433 | try: 434 | at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00')) 435 | # 判断是否过期 436 | now = datetime.now(timezone.utc) 437 | is_expired = at_expires <= now 438 | except: 439 | pass 440 | 441 | # 使用邮箱检查是否已存在 442 | existing_tokens = await token_manager.get_all_tokens() 443 | existing = next((t for t in existing_tokens if t.email == email), None) 444 | 445 | if existing: 446 | # 更新现有Token 447 | await token_manager.update_token( 448 | token_id=existing.id, 449 | st=st, 450 | at=at, 451 | at_expires=at_expires, 452 | image_enabled=item.image_enabled, 453 | video_enabled=item.video_enabled, 454 | image_concurrency=item.image_concurrency, 455 | video_concurrency=item.video_concurrency 456 | ) 457 | # 如果过期则禁用 458 | if is_expired: 459 | await token_manager.disable_token(existing.id) 460 | updated += 1 461 | else: 462 | # 添加新Token 463 | new_token = await token_manager.add_token( 464 | st=st, 465 | image_enabled=item.image_enabled, 466 | video_enabled=item.video_enabled, 467 | image_concurrency=item.image_concurrency, 468 | video_concurrency=item.video_concurrency 469 | ) 470 | # 如果过期则禁用 471 | if is_expired: 472 | await token_manager.disable_token(new_token.id) 473 | added += 1 474 | 475 | except Exception as e: 476 | errors.append(f"第{idx+1}项: {str(e)}") 477 | 478 | except Exception as e: 479 | errors.append(f"第{idx+1}项: {str(e)}") 480 | 481 | return { 482 | "success": True, 483 | "added": added, 484 | "updated": updated, 485 | "errors": errors if errors else None, 486 | "message": f"导入完成: 新增 {added} 个, 更新 {updated} 个" + (f", {len(errors)} 个失败" if errors else "") 487 | } 488 | 489 | 490 | # ========== Config Management ========== 491 | 492 | @router.get("/api/config/proxy") 493 | async def get_proxy_config(token: str = Depends(verify_admin_token)): 494 | """Get proxy configuration""" 495 | config = await proxy_manager.get_proxy_config() 496 | return { 497 | "success": True, 498 | "config": { 499 | "enabled": config.enabled, 500 | "proxy_url": config.proxy_url 501 | } 502 | } 503 | 504 | 505 | @router.get("/api/proxy/config") 506 | async def get_proxy_config_alias(token: str = Depends(verify_admin_token)): 507 | """Get proxy configuration (alias for frontend compatibility)""" 508 | config = await proxy_manager.get_proxy_config() 509 | return { 510 | "proxy_enabled": config.enabled, # Frontend expects proxy_enabled 511 | "proxy_url": config.proxy_url 512 | } 513 | 514 | 515 | @router.post("/api/proxy/config") 516 | async def update_proxy_config_alias( 517 | request: ProxyConfigRequest, 518 | token: str = Depends(verify_admin_token) 519 | ): 520 | """Update proxy configuration (alias for frontend compatibility)""" 521 | await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url) 522 | return {"success": True, "message": "代理配置更新成功"} 523 | 524 | 525 | @router.post("/api/config/proxy") 526 | async def update_proxy_config( 527 | request: ProxyConfigRequest, 528 | token: str = Depends(verify_admin_token) 529 | ): 530 | """Update proxy configuration""" 531 | await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url) 532 | return {"success": True, "message": "代理配置更新成功"} 533 | 534 | 535 | @router.get("/api/config/generation") 536 | async def get_generation_config(token: str = Depends(verify_admin_token)): 537 | """Get generation timeout configuration""" 538 | config = await db.get_generation_config() 539 | return { 540 | "success": True, 541 | "config": { 542 | "image_timeout": config.image_timeout, 543 | "video_timeout": config.video_timeout 544 | } 545 | } 546 | 547 | 548 | @router.post("/api/config/generation") 549 | async def update_generation_config( 550 | request: GenerationConfigRequest, 551 | token: str = Depends(verify_admin_token) 552 | ): 553 | """Update generation timeout configuration""" 554 | await db.update_generation_config(request.image_timeout, request.video_timeout) 555 | 556 | # 🔥 Hot reload: sync database config to memory 557 | await db.reload_config_to_memory() 558 | 559 | return {"success": True, "message": "生成配置更新成功"} 560 | 561 | 562 | # ========== System Info ========== 563 | 564 | @router.get("/api/system/info") 565 | async def get_system_info(token: str = Depends(verify_admin_token)): 566 | """Get system information""" 567 | tokens = await token_manager.get_all_tokens() 568 | active_tokens = [t for t in tokens if t.is_active] 569 | 570 | total_credits = sum(t.credits for t in active_tokens) 571 | 572 | return { 573 | "success": True, 574 | "info": { 575 | "total_tokens": len(tokens), 576 | "active_tokens": len(active_tokens), 577 | "total_credits": total_credits, 578 | "version": "1.0.0" 579 | } 580 | } 581 | 582 | 583 | # ========== Additional Routes for Frontend Compatibility ========== 584 | 585 | @router.post("/api/login") 586 | async def login(request: LoginRequest): 587 | """Login endpoint (alias for /api/admin/login)""" 588 | return await admin_login(request) 589 | 590 | 591 | @router.post("/api/logout") 592 | async def logout(token: str = Depends(verify_admin_token)): 593 | """Logout endpoint (alias for /api/admin/logout)""" 594 | return await admin_logout(token) 595 | 596 | 597 | @router.get("/api/stats") 598 | async def get_stats(token: str = Depends(verify_admin_token)): 599 | """Get statistics for dashboard""" 600 | tokens = await token_manager.get_all_tokens() 601 | active_tokens = [t for t in tokens if t.is_active] 602 | 603 | # Calculate totals 604 | total_images = 0 605 | total_videos = 0 606 | total_errors = 0 607 | today_images = 0 608 | today_videos = 0 609 | today_errors = 0 610 | 611 | for t in tokens: 612 | stats = await db.get_token_stats(t.id) 613 | if stats: 614 | total_images += stats.image_count 615 | total_videos += stats.video_count 616 | total_errors += stats.error_count # Historical total errors 617 | today_images += stats.today_image_count 618 | today_videos += stats.today_video_count 619 | today_errors += stats.today_error_count 620 | 621 | return { 622 | "total_tokens": len(tokens), 623 | "active_tokens": len(active_tokens), 624 | "total_images": total_images, 625 | "total_videos": total_videos, 626 | "total_errors": total_errors, 627 | "today_images": today_images, 628 | "today_videos": today_videos, 629 | "today_errors": today_errors 630 | } 631 | 632 | 633 | @router.get("/api/logs") 634 | async def get_logs( 635 | limit: int = 100, 636 | token: str = Depends(verify_admin_token) 637 | ): 638 | """Get request logs with token email""" 639 | logs = await db.get_logs(limit=limit) 640 | 641 | return [{ 642 | "id": log.get("id"), 643 | "token_id": log.get("token_id"), 644 | "token_email": log.get("token_email"), 645 | "token_username": log.get("token_username"), 646 | "operation": log.get("operation"), 647 | "status_code": log.get("status_code"), 648 | "duration": log.get("duration"), 649 | "created_at": log.get("created_at") 650 | } for log in logs] 651 | 652 | 653 | @router.get("/api/admin/config") 654 | async def get_admin_config(token: str = Depends(verify_admin_token)): 655 | """Get admin configuration""" 656 | from ..core.config import config 657 | 658 | admin_config = await db.get_admin_config() 659 | 660 | return { 661 | "admin_username": admin_config.username, 662 | "api_key": admin_config.api_key, 663 | "error_ban_threshold": admin_config.error_ban_threshold, 664 | "debug_enabled": config.debug_enabled # Return actual debug status 665 | } 666 | 667 | 668 | @router.post("/api/admin/config") 669 | async def update_admin_config( 670 | request: UpdateAdminConfigRequest, 671 | token: str = Depends(verify_admin_token) 672 | ): 673 | """Update admin configuration (error_ban_threshold)""" 674 | # Update error_ban_threshold in database 675 | await db.update_admin_config(error_ban_threshold=request.error_ban_threshold) 676 | 677 | return {"success": True, "message": "配置更新成功"} 678 | 679 | 680 | @router.post("/api/admin/password") 681 | async def update_admin_password( 682 | request: ChangePasswordRequest, 683 | token: str = Depends(verify_admin_token) 684 | ): 685 | """Update admin password""" 686 | return await change_password(request, token) 687 | 688 | 689 | @router.post("/api/admin/apikey") 690 | async def update_api_key( 691 | request: UpdateAPIKeyRequest, 692 | token: str = Depends(verify_admin_token) 693 | ): 694 | """Update API key (for external API calls, NOT for admin login)""" 695 | # Update API key in database 696 | await db.update_admin_config(api_key=request.new_api_key) 697 | 698 | # 🔥 Hot reload: sync database config to memory 699 | await db.reload_config_to_memory() 700 | 701 | return {"success": True, "message": "API Key更新成功"} 702 | 703 | 704 | @router.post("/api/admin/debug") 705 | async def update_debug_config( 706 | request: UpdateDebugConfigRequest, 707 | token: str = Depends(verify_admin_token) 708 | ): 709 | """Update debug configuration""" 710 | try: 711 | # Update debug config in database 712 | await db.update_debug_config(enabled=request.enabled) 713 | 714 | # 🔥 Hot reload: sync database config to memory 715 | await db.reload_config_to_memory() 716 | 717 | status = "enabled" if request.enabled else "disabled" 718 | return {"success": True, "message": f"Debug mode {status}", "enabled": request.enabled} 719 | except Exception as e: 720 | raise HTTPException(status_code=500, detail=f"Failed to update debug config: {str(e)}") 721 | 722 | 723 | @router.get("/api/generation/timeout") 724 | async def get_generation_timeout(token: str = Depends(verify_admin_token)): 725 | """Get generation timeout configuration""" 726 | return await get_generation_config(token) 727 | 728 | 729 | @router.post("/api/generation/timeout") 730 | async def update_generation_timeout( 731 | request: GenerationConfigRequest, 732 | token: str = Depends(verify_admin_token) 733 | ): 734 | """Update generation timeout configuration""" 735 | await db.update_generation_config(request.image_timeout, request.video_timeout) 736 | 737 | # 🔥 Hot reload: sync database config to memory 738 | await db.reload_config_to_memory() 739 | 740 | return {"success": True, "message": "生成配置更新成功"} 741 | 742 | 743 | # ========== AT Auto Refresh Config ========== 744 | 745 | @router.get("/api/token-refresh/config") 746 | async def get_token_refresh_config(token: str = Depends(verify_admin_token)): 747 | """Get AT auto refresh configuration (默认启用)""" 748 | return { 749 | "success": True, 750 | "config": { 751 | "at_auto_refresh_enabled": True # Flow2API默认启用AT自动刷新 752 | } 753 | } 754 | 755 | 756 | @router.post("/api/token-refresh/enabled") 757 | async def update_token_refresh_enabled( 758 | token: str = Depends(verify_admin_token) 759 | ): 760 | """Update AT auto refresh enabled (Flow2API固定启用,此接口仅用于前端兼容)""" 761 | return { 762 | "success": True, 763 | "message": "Flow2API的AT自动刷新默认启用且无法关闭" 764 | } 765 | 766 | 767 | # ========== Cache Configuration Endpoints ========== 768 | 769 | @router.get("/api/cache/config") 770 | async def get_cache_config(token: str = Depends(verify_admin_token)): 771 | """Get cache configuration""" 772 | cache_config = await db.get_cache_config() 773 | 774 | # Calculate effective base URL 775 | effective_base_url = cache_config.cache_base_url if cache_config.cache_base_url else f"http://127.0.0.1:8000" 776 | 777 | return { 778 | "success": True, 779 | "config": { 780 | "enabled": cache_config.cache_enabled, 781 | "timeout": cache_config.cache_timeout, 782 | "base_url": cache_config.cache_base_url or "", 783 | "effective_base_url": effective_base_url 784 | } 785 | } 786 | 787 | 788 | @router.post("/api/cache/enabled") 789 | async def update_cache_enabled( 790 | request: dict, 791 | token: str = Depends(verify_admin_token) 792 | ): 793 | """Update cache enabled status""" 794 | enabled = request.get("enabled", False) 795 | await db.update_cache_config(enabled=enabled) 796 | 797 | # 🔥 Hot reload: sync database config to memory 798 | await db.reload_config_to_memory() 799 | 800 | return {"success": True, "message": f"缓存已{'启用' if enabled else '禁用'}"} 801 | 802 | 803 | @router.post("/api/cache/config") 804 | async def update_cache_config_full( 805 | request: dict, 806 | token: str = Depends(verify_admin_token) 807 | ): 808 | """Update complete cache configuration""" 809 | enabled = request.get("enabled") 810 | timeout = request.get("timeout") 811 | base_url = request.get("base_url") 812 | 813 | await db.update_cache_config(enabled=enabled, timeout=timeout, base_url=base_url) 814 | 815 | # 🔥 Hot reload: sync database config to memory 816 | await db.reload_config_to_memory() 817 | 818 | return {"success": True, "message": "缓存配置更新成功"} 819 | 820 | 821 | @router.post("/api/cache/base-url") 822 | async def update_cache_base_url( 823 | request: dict, 824 | token: str = Depends(verify_admin_token) 825 | ): 826 | """Update cache base URL""" 827 | base_url = request.get("base_url", "") 828 | await db.update_cache_config(base_url=base_url) 829 | 830 | # 🔥 Hot reload: sync database config to memory 831 | await db.reload_config_to_memory() 832 | 833 | return {"success": True, "message": "缓存Base URL更新成功"} 834 | 835 | 836 | @router.post("/api/captcha/config") 837 | async def update_captcha_config( 838 | request: dict, 839 | token: str = Depends(verify_admin_token) 840 | ): 841 | """Update captcha configuration""" 842 | from ..services.browser_captcha import validate_browser_proxy_url 843 | 844 | captcha_method = request.get("captcha_method") 845 | yescaptcha_api_key = request.get("yescaptcha_api_key") 846 | yescaptcha_base_url = request.get("yescaptcha_base_url") 847 | browser_proxy_enabled = request.get("browser_proxy_enabled", False) 848 | browser_proxy_url = request.get("browser_proxy_url", "") 849 | 850 | # 验证浏览器代理URL格式 851 | if browser_proxy_enabled and browser_proxy_url: 852 | is_valid, error_msg = validate_browser_proxy_url(browser_proxy_url) 853 | if not is_valid: 854 | return {"success": False, "message": error_msg} 855 | 856 | await db.update_captcha_config( 857 | captcha_method=captcha_method, 858 | yescaptcha_api_key=yescaptcha_api_key, 859 | yescaptcha_base_url=yescaptcha_base_url, 860 | browser_proxy_enabled=browser_proxy_enabled, 861 | browser_proxy_url=browser_proxy_url if browser_proxy_enabled else None 862 | ) 863 | 864 | # 🔥 Hot reload: sync database config to memory 865 | await db.reload_config_to_memory() 866 | 867 | return {"success": True, "message": "验证码配置更新成功"} 868 | 869 | 870 | @router.get("/api/captcha/config") 871 | async def get_captcha_config(token: str = Depends(verify_admin_token)): 872 | """Get captcha configuration""" 873 | captcha_config = await db.get_captcha_config() 874 | return { 875 | "captcha_method": captcha_config.captcha_method, 876 | "yescaptcha_api_key": captcha_config.yescaptcha_api_key, 877 | "yescaptcha_base_url": captcha_config.yescaptcha_base_url, 878 | "browser_proxy_enabled": captcha_config.browser_proxy_enabled, 879 | "browser_proxy_url": captcha_config.browser_proxy_url or "" 880 | } 881 | -------------------------------------------------------------------------------- /src/services/generation_handler.py: -------------------------------------------------------------------------------- 1 | """Generation handler for Flow2API""" 2 | import asyncio 3 | import base64 4 | import json 5 | import time 6 | from typing import Optional, AsyncGenerator, List, Dict, Any 7 | from ..core.logger import debug_logger 8 | from ..core.config import config 9 | from ..core.models import Task, RequestLog 10 | from .file_cache import FileCache 11 | 12 | 13 | # Model configuration 14 | MODEL_CONFIG = { 15 | # 图片生成 - GEM_PIX (Gemini 2.5 Flash) 16 | "gemini-2.5-flash-image-landscape": { 17 | "type": "image", 18 | "model_name": "GEM_PIX", 19 | "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE" 20 | }, 21 | "gemini-2.5-flash-image-portrait": { 22 | "type": "image", 23 | "model_name": "GEM_PIX", 24 | "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT" 25 | }, 26 | 27 | # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro) 28 | "gemini-3.0-pro-image-landscape": { 29 | "type": "image", 30 | "model_name": "GEM_PIX_2", 31 | "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE" 32 | }, 33 | "gemini-3.0-pro-image-portrait": { 34 | "type": "image", 35 | "model_name": "GEM_PIX_2", 36 | "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT" 37 | }, 38 | 39 | # 图片生成 - IMAGEN_3_5 (Imagen 4.0) 40 | "imagen-4.0-generate-preview-landscape": { 41 | "type": "image", 42 | "model_name": "IMAGEN_3_5", 43 | "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE" 44 | }, 45 | "imagen-4.0-generate-preview-portrait": { 46 | "type": "image", 47 | "model_name": "IMAGEN_3_5", 48 | "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT" 49 | }, 50 | 51 | # ========== 文生视频 (T2V - Text to Video) ========== 52 | # 不支持上传图片,只使用文本提示词生成 53 | 54 | # veo_3_1_t2v_fast_portrait (竖屏) 55 | # 上游模型名: veo_3_1_t2v_fast_portrait 56 | "veo_3_1_t2v_fast_portrait": { 57 | "type": "video", 58 | "video_type": "t2v", 59 | "model_key": "veo_3_1_t2v_fast_portrait", 60 | "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", 61 | "supports_images": False 62 | }, 63 | # veo_3_1_t2v_fast_landscape (横屏) 64 | # 上游模型名: veo_3_1_t2v_fast 65 | "veo_3_1_t2v_fast_landscape": { 66 | "type": "video", 67 | "video_type": "t2v", 68 | "model_key": "veo_3_1_t2v_fast", 69 | "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", 70 | "supports_images": False 71 | }, 72 | 73 | # veo_2_1_fast_d_15_t2v (需要新增横竖屏) 74 | "veo_2_1_fast_d_15_t2v_portrait": { 75 | "type": "video", 76 | "video_type": "t2v", 77 | "model_key": "veo_2_1_fast_d_15_t2v", 78 | "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", 79 | "supports_images": False 80 | }, 81 | "veo_2_1_fast_d_15_t2v_landscape": { 82 | "type": "video", 83 | "video_type": "t2v", 84 | "model_key": "veo_2_1_fast_d_15_t2v", 85 | "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", 86 | "supports_images": False 87 | }, 88 | 89 | # veo_2_0_t2v (需要新增横竖屏) 90 | "veo_2_0_t2v_portrait": { 91 | "type": "video", 92 | "video_type": "t2v", 93 | "model_key": "veo_2_0_t2v", 94 | "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", 95 | "supports_images": False 96 | }, 97 | "veo_2_0_t2v_landscape": { 98 | "type": "video", 99 | "video_type": "t2v", 100 | "model_key": "veo_2_0_t2v", 101 | "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", 102 | "supports_images": False 103 | }, 104 | 105 | # ========== 首尾帧模型 (I2V - Image to Video) ========== 106 | # 支持1-2张图片:1张作为首帧,2张作为首尾帧 107 | 108 | # veo_3_1_i2v_s_fast_fl (需要新增横竖屏) 109 | "veo_3_1_i2v_s_fast_fl_portrait": { 110 | "type": "video", 111 | "video_type": "i2v", 112 | "model_key": "veo_3_1_i2v_s_fast_fl", 113 | "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", 114 | "supports_images": True, 115 | "min_images": 1, 116 | "max_images": 2 117 | }, 118 | "veo_3_1_i2v_s_fast_fl_landscape": { 119 | "type": "video", 120 | "video_type": "i2v", 121 | "model_key": "veo_3_1_i2v_s_fast_fl", 122 | "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", 123 | "supports_images": True, 124 | "min_images": 1, 125 | "max_images": 2 126 | }, 127 | 128 | # veo_2_1_fast_d_15_i2v (需要新增横竖屏) 129 | "veo_2_1_fast_d_15_i2v_portrait": { 130 | "type": "video", 131 | "video_type": "i2v", 132 | "model_key": "veo_2_1_fast_d_15_i2v", 133 | "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", 134 | "supports_images": True, 135 | "min_images": 1, 136 | "max_images": 2 137 | }, 138 | "veo_2_1_fast_d_15_i2v_landscape": { 139 | "type": "video", 140 | "video_type": "i2v", 141 | "model_key": "veo_2_1_fast_d_15_i2v", 142 | "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", 143 | "supports_images": True, 144 | "min_images": 1, 145 | "max_images": 2 146 | }, 147 | 148 | # veo_2_0_i2v (需要新增横竖屏) 149 | "veo_2_0_i2v_portrait": { 150 | "type": "video", 151 | "video_type": "i2v", 152 | "model_key": "veo_2_0_i2v", 153 | "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", 154 | "supports_images": True, 155 | "min_images": 1, 156 | "max_images": 2 157 | }, 158 | "veo_2_0_i2v_landscape": { 159 | "type": "video", 160 | "video_type": "i2v", 161 | "model_key": "veo_2_0_i2v", 162 | "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", 163 | "supports_images": True, 164 | "min_images": 1, 165 | "max_images": 2 166 | }, 167 | 168 | # ========== 多图生成 (R2V - Reference Images to Video) ========== 169 | # 支持多张图片,不限制数量 170 | 171 | # veo_3_0_r2v_fast (需要新增横竖屏) 172 | "veo_3_0_r2v_fast_portrait": { 173 | "type": "video", 174 | "video_type": "r2v", 175 | "model_key": "veo_3_0_r2v_fast", 176 | "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT", 177 | "supports_images": True, 178 | "min_images": 0, 179 | "max_images": None # 不限制 180 | }, 181 | "veo_3_0_r2v_fast_landscape": { 182 | "type": "video", 183 | "video_type": "r2v", 184 | "model_key": "veo_3_0_r2v_fast", 185 | "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE", 186 | "supports_images": True, 187 | "min_images": 0, 188 | "max_images": None # 不限制 189 | } 190 | } 191 | 192 | 193 | class GenerationHandler: 194 | """统一生成处理器""" 195 | 196 | def __init__(self, flow_client, token_manager, load_balancer, db, concurrency_manager, proxy_manager): 197 | self.flow_client = flow_client 198 | self.token_manager = token_manager 199 | self.load_balancer = load_balancer 200 | self.db = db 201 | self.concurrency_manager = concurrency_manager 202 | self.file_cache = FileCache( 203 | cache_dir="tmp", 204 | default_timeout=config.cache_timeout, 205 | proxy_manager=proxy_manager 206 | ) 207 | 208 | async def check_token_availability(self, is_image: bool, is_video: bool) -> bool: 209 | """检查Token可用性 210 | 211 | Args: 212 | is_image: 是否检查图片生成Token 213 | is_video: 是否检查视频生成Token 214 | 215 | Returns: 216 | True表示有可用Token, False表示无可用Token 217 | """ 218 | token_obj = await self.load_balancer.select_token( 219 | for_image_generation=is_image, 220 | for_video_generation=is_video 221 | ) 222 | return token_obj is not None 223 | 224 | async def handle_generation( 225 | self, 226 | model: str, 227 | prompt: str, 228 | images: Optional[List[bytes]] = None, 229 | stream: bool = False 230 | ) -> AsyncGenerator: 231 | """统一生成入口 232 | 233 | Args: 234 | model: 模型名称 235 | prompt: 提示词 236 | images: 图片列表 (bytes格式) 237 | stream: 是否流式输出 238 | """ 239 | start_time = time.time() 240 | token = None 241 | 242 | # 1. 验证模型 243 | if model not in MODEL_CONFIG: 244 | error_msg = f"不支持的模型: {model}" 245 | debug_logger.log_error(error_msg) 246 | yield self._create_error_response(error_msg) 247 | return 248 | 249 | model_config = MODEL_CONFIG[model] 250 | generation_type = model_config["type"] 251 | debug_logger.log_info(f"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...") 252 | 253 | # 非流式模式: 只检查可用性 254 | if not stream: 255 | is_image = (generation_type == "image") 256 | is_video = (generation_type == "video") 257 | available = await self.check_token_availability(is_image, is_video) 258 | 259 | if available: 260 | if is_image: 261 | message = "所有Token可用于图片生成。请启用流式模式使用生成功能。" 262 | else: 263 | message = "所有Token可用于视频生成。请启用流式模式使用生成功能。" 264 | else: 265 | if is_image: 266 | message = "没有可用的Token进行图片生成" 267 | else: 268 | message = "没有可用的Token进行视频生成" 269 | 270 | yield self._create_completion_response(message, is_availability_check=True) 271 | return 272 | 273 | # 向用户展示开始信息 274 | if stream: 275 | yield self._create_stream_chunk( 276 | f"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\n", 277 | role="assistant" 278 | ) 279 | 280 | # 2. 选择Token 281 | debug_logger.log_info(f"[GENERATION] 正在选择可用Token...") 282 | 283 | if generation_type == "image": 284 | token = await self.load_balancer.select_token(for_image_generation=True, model=model) 285 | else: 286 | token = await self.load_balancer.select_token(for_video_generation=True, model=model) 287 | 288 | if not token: 289 | error_msg = self._get_no_token_error_message(generation_type) 290 | debug_logger.log_error(f"[GENERATION] {error_msg}") 291 | if stream: 292 | yield self._create_stream_chunk(f"❌ {error_msg}\n") 293 | yield self._create_error_response(error_msg) 294 | return 295 | 296 | debug_logger.log_info(f"[GENERATION] 已选择Token: {token.id} ({token.email})") 297 | 298 | try: 299 | # 3. 确保AT有效 300 | debug_logger.log_info(f"[GENERATION] 检查Token AT有效性...") 301 | if stream: 302 | yield self._create_stream_chunk("初始化生成环境...\n") 303 | 304 | if not await self.token_manager.is_at_valid(token.id): 305 | error_msg = "Token AT无效或刷新失败" 306 | debug_logger.log_error(f"[GENERATION] {error_msg}") 307 | if stream: 308 | yield self._create_stream_chunk(f"❌ {error_msg}\n") 309 | yield self._create_error_response(error_msg) 310 | return 311 | 312 | # 重新获取token (AT可能已刷新) 313 | token = await self.token_manager.get_token(token.id) 314 | 315 | # 4. 确保Project存在 316 | debug_logger.log_info(f"[GENERATION] 检查/创建Project...") 317 | 318 | project_id = await self.token_manager.ensure_project_exists(token.id) 319 | debug_logger.log_info(f"[GENERATION] Project ID: {project_id}") 320 | 321 | # 5. 根据类型处理 322 | if generation_type == "image": 323 | debug_logger.log_info(f"[GENERATION] 开始图片生成流程...") 324 | async for chunk in self._handle_image_generation( 325 | token, project_id, model_config, prompt, images, stream 326 | ): 327 | yield chunk 328 | else: # video 329 | debug_logger.log_info(f"[GENERATION] 开始视频生成流程...") 330 | async for chunk in self._handle_video_generation( 331 | token, project_id, model_config, prompt, images, stream 332 | ): 333 | yield chunk 334 | 335 | # 6. 记录使用 336 | is_video = (generation_type == "video") 337 | await self.token_manager.record_usage(token.id, is_video=is_video) 338 | 339 | # 重置错误计数 (请求成功时清空连续错误计数) 340 | await self.token_manager.record_success(token.id) 341 | 342 | debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成") 343 | 344 | # 7. 记录成功日志 345 | duration = time.time() - start_time 346 | await self._log_request( 347 | token.id, 348 | f"generate_{generation_type}", 349 | {"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0}, 350 | {"status": "success"}, 351 | 200, 352 | duration 353 | ) 354 | 355 | except Exception as e: 356 | error_msg = f"生成失败: {str(e)}" 357 | debug_logger.log_error(f"[GENERATION] ❌ {error_msg}") 358 | if stream: 359 | yield self._create_stream_chunk(f"❌ {error_msg}\n") 360 | if token: 361 | # 检测429错误,立即禁用token 362 | if "429" in str(e) or "HTTP Error 429" in str(e): 363 | debug_logger.log_warning(f"[429_BAN] Token {token.id} 遇到429错误,立即禁用") 364 | await self.token_manager.ban_token_for_429(token.id) 365 | else: 366 | await self.token_manager.record_error(token.id) 367 | yield self._create_error_response(error_msg) 368 | 369 | # 记录失败日志 370 | duration = time.time() - start_time 371 | await self._log_request( 372 | token.id if token else None, 373 | f"generate_{generation_type if model_config else 'unknown'}", 374 | {"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0}, 375 | {"error": error_msg}, 376 | 500, 377 | duration 378 | ) 379 | 380 | def _get_no_token_error_message(self, generation_type: str) -> str: 381 | """获取无可用Token时的详细错误信息""" 382 | if generation_type == "image": 383 | return "没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。" 384 | else: 385 | return "没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。" 386 | 387 | async def _handle_image_generation( 388 | self, 389 | token, 390 | project_id: str, 391 | model_config: dict, 392 | prompt: str, 393 | images: Optional[List[bytes]], 394 | stream: bool 395 | ) -> AsyncGenerator: 396 | """处理图片生成 (同步返回)""" 397 | 398 | # 获取并发槽位 399 | if self.concurrency_manager: 400 | if not await self.concurrency_manager.acquire_image(token.id): 401 | yield self._create_error_response("图片并发限制已达上限") 402 | return 403 | 404 | try: 405 | # 上传图片 (如果有) 406 | image_inputs = [] 407 | if images and len(images) > 0: 408 | if stream: 409 | yield self._create_stream_chunk(f"上传 {len(images)} 张参考图片...\n") 410 | 411 | # 支持多图输入 412 | for idx, image_bytes in enumerate(images): 413 | media_id = await self.flow_client.upload_image( 414 | token.at, 415 | image_bytes, 416 | model_config["aspect_ratio"] 417 | ) 418 | image_inputs.append({ 419 | "name": media_id, 420 | "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE" 421 | }) 422 | if stream: 423 | yield self._create_stream_chunk(f"已上传第 {idx + 1}/{len(images)} 张图片\n") 424 | 425 | # 调用生成API 426 | if stream: 427 | yield self._create_stream_chunk("正在生成图片...\n") 428 | 429 | result = await self.flow_client.generate_image( 430 | at=token.at, 431 | project_id=project_id, 432 | prompt=prompt, 433 | model_name=model_config["model_name"], 434 | aspect_ratio=model_config["aspect_ratio"], 435 | image_inputs=image_inputs 436 | ) 437 | 438 | # 提取URL 439 | media = result.get("media", []) 440 | if not media: 441 | yield self._create_error_response("生成结果为空") 442 | return 443 | 444 | image_url = media[0]["image"]["generatedImage"]["fifeUrl"] 445 | 446 | # 缓存图片 (如果启用) 447 | local_url = image_url 448 | if config.cache_enabled: 449 | try: 450 | if stream: 451 | yield self._create_stream_chunk("缓存图片中...\n") 452 | cached_filename = await self.file_cache.download_and_cache(image_url, "image") 453 | local_url = f"{self._get_base_url()}/tmp/{cached_filename}" 454 | if stream: 455 | yield self._create_stream_chunk("✅ 图片缓存成功,准备返回缓存地址...\n") 456 | except Exception as e: 457 | debug_logger.log_error(f"Failed to cache image: {str(e)}") 458 | # 缓存失败不影响结果返回,使用原始URL 459 | local_url = image_url 460 | if stream: 461 | yield self._create_stream_chunk(f"⚠️ 缓存失败: {str(e)}\n正在返回源链接...\n") 462 | else: 463 | if stream: 464 | yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n") 465 | 466 | # 返回结果 467 | if stream: 468 | yield self._create_stream_chunk( 469 | f"![Generated Image]({local_url})", 470 | finish_reason="stop" 471 | ) 472 | else: 473 | yield self._create_completion_response( 474 | local_url, # 直接传URL,让方法内部格式化 475 | media_type="image" 476 | ) 477 | 478 | finally: 479 | # 释放并发槽位 480 | if self.concurrency_manager: 481 | await self.concurrency_manager.release_image(token.id) 482 | 483 | async def _handle_video_generation( 484 | self, 485 | token, 486 | project_id: str, 487 | model_config: dict, 488 | prompt: str, 489 | images: Optional[List[bytes]], 490 | stream: bool 491 | ) -> AsyncGenerator: 492 | """处理视频生成 (异步轮询)""" 493 | 494 | # 获取并发槽位 495 | if self.concurrency_manager: 496 | if not await self.concurrency_manager.acquire_video(token.id): 497 | yield self._create_error_response("视频并发限制已达上限") 498 | return 499 | 500 | try: 501 | # 获取模型类型和配置 502 | video_type = model_config.get("video_type") 503 | supports_images = model_config.get("supports_images", False) 504 | min_images = model_config.get("min_images", 0) 505 | max_images = model_config.get("max_images", 0) 506 | 507 | # 图片数量 508 | image_count = len(images) if images else 0 509 | 510 | # ========== 验证和处理图片 ========== 511 | 512 | # T2V: 文生视频 - 不支持图片 513 | if video_type == "t2v": 514 | if image_count > 0: 515 | if stream: 516 | yield self._create_stream_chunk("⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\n") 517 | debug_logger.log_warning(f"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片") 518 | images = None # 清空图片 519 | image_count = 0 520 | 521 | # I2V: 首尾帧模型 - 需要1-2张图片 522 | elif video_type == "i2v": 523 | if image_count < min_images or image_count > max_images: 524 | error_msg = f"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张" 525 | if stream: 526 | yield self._create_stream_chunk(f"{error_msg}\n") 527 | yield self._create_error_response(error_msg) 528 | return 529 | 530 | # R2V: 多图生成 - 支持多张图片,不限制数量 531 | elif video_type == "r2v": 532 | # 不再限制最大图片数量 533 | pass 534 | 535 | # ========== 上传图片 ========== 536 | start_media_id = None 537 | end_media_id = None 538 | reference_images = [] 539 | 540 | # I2V: 首尾帧处理 541 | if video_type == "i2v" and images: 542 | if image_count == 1: 543 | # 只有1张图: 仅作为首帧 544 | if stream: 545 | yield self._create_stream_chunk("上传首帧图片...\n") 546 | start_media_id = await self.flow_client.upload_image( 547 | token.at, images[0], model_config["aspect_ratio"] 548 | ) 549 | debug_logger.log_info(f"[I2V] 仅上传首帧: {start_media_id}") 550 | 551 | elif image_count == 2: 552 | # 2张图: 首帧+尾帧 553 | if stream: 554 | yield self._create_stream_chunk("上传首帧和尾帧图片...\n") 555 | start_media_id = await self.flow_client.upload_image( 556 | token.at, images[0], model_config["aspect_ratio"] 557 | ) 558 | end_media_id = await self.flow_client.upload_image( 559 | token.at, images[1], model_config["aspect_ratio"] 560 | ) 561 | debug_logger.log_info(f"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}") 562 | 563 | # R2V: 多图处理 564 | elif video_type == "r2v" and images: 565 | if stream: 566 | yield self._create_stream_chunk(f"上传 {image_count} 张参考图片...\n") 567 | 568 | for idx, img in enumerate(images): # 上传所有图片,不限制数量 569 | media_id = await self.flow_client.upload_image( 570 | token.at, img, model_config["aspect_ratio"] 571 | ) 572 | reference_images.append({ 573 | "imageUsageType": "IMAGE_USAGE_TYPE_ASSET", 574 | "mediaId": media_id 575 | }) 576 | debug_logger.log_info(f"[R2V] 上传了 {len(reference_images)} 张参考图片") 577 | 578 | # ========== 调用生成API ========== 579 | if stream: 580 | yield self._create_stream_chunk("提交视频生成任务...\n") 581 | 582 | # I2V: 首尾帧生成 583 | if video_type == "i2v" and start_media_id: 584 | if end_media_id: 585 | # 有首尾帧 586 | result = await self.flow_client.generate_video_start_end( 587 | at=token.at, 588 | project_id=project_id, 589 | prompt=prompt, 590 | model_key=model_config["model_key"], 591 | aspect_ratio=model_config["aspect_ratio"], 592 | start_media_id=start_media_id, 593 | end_media_id=end_media_id, 594 | user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" 595 | ) 596 | else: 597 | # 只有首帧 598 | result = await self.flow_client.generate_video_start_image( 599 | at=token.at, 600 | project_id=project_id, 601 | prompt=prompt, 602 | model_key=model_config["model_key"], 603 | aspect_ratio=model_config["aspect_ratio"], 604 | start_media_id=start_media_id, 605 | user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" 606 | ) 607 | 608 | # R2V: 多图生成 609 | elif video_type == "r2v" and reference_images: 610 | result = await self.flow_client.generate_video_reference_images( 611 | at=token.at, 612 | project_id=project_id, 613 | prompt=prompt, 614 | model_key=model_config["model_key"], 615 | aspect_ratio=model_config["aspect_ratio"], 616 | reference_images=reference_images, 617 | user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" 618 | ) 619 | 620 | # T2V 或 R2V无图: 纯文本生成 621 | else: 622 | result = await self.flow_client.generate_video_text( 623 | at=token.at, 624 | project_id=project_id, 625 | prompt=prompt, 626 | model_key=model_config["model_key"], 627 | aspect_ratio=model_config["aspect_ratio"], 628 | user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" 629 | ) 630 | 631 | # 获取task_id和operations 632 | operations = result.get("operations", []) 633 | if not operations: 634 | yield self._create_error_response("生成任务创建失败") 635 | return 636 | 637 | operation = operations[0] 638 | task_id = operation["operation"]["name"] 639 | scene_id = operation.get("sceneId") 640 | 641 | # 保存Task到数据库 642 | task = Task( 643 | task_id=task_id, 644 | token_id=token.id, 645 | model=model_config["model_key"], 646 | prompt=prompt, 647 | status="processing", 648 | scene_id=scene_id 649 | ) 650 | await self.db.create_task(task) 651 | 652 | # 轮询结果 653 | if stream: 654 | yield self._create_stream_chunk(f"视频生成中...\n") 655 | 656 | async for chunk in self._poll_video_result(token, operations, stream): 657 | yield chunk 658 | 659 | finally: 660 | # 释放并发槽位 661 | if self.concurrency_manager: 662 | await self.concurrency_manager.release_video(token.id) 663 | 664 | async def _poll_video_result( 665 | self, 666 | token, 667 | operations: List[Dict], 668 | stream: bool 669 | ) -> AsyncGenerator: 670 | """轮询视频生成结果""" 671 | 672 | max_attempts = config.max_poll_attempts 673 | poll_interval = config.poll_interval 674 | 675 | for attempt in range(max_attempts): 676 | await asyncio.sleep(poll_interval) 677 | 678 | try: 679 | result = await self.flow_client.check_video_status(token.at, operations) 680 | checked_operations = result.get("operations", []) 681 | 682 | if not checked_operations: 683 | continue 684 | 685 | operation = checked_operations[0] 686 | status = operation.get("status") 687 | 688 | # 状态更新 - 每20秒报告一次 (poll_interval=3秒, 20秒约7次轮询) 689 | progress_update_interval = 7 # 每7次轮询 = 21秒 690 | if stream and attempt % progress_update_interval == 0: # 每20秒报告一次 691 | progress = min(int((attempt / max_attempts) * 100), 95) 692 | yield self._create_stream_chunk(f"生成进度: {progress}%\n") 693 | 694 | # 检查状态 695 | if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL": 696 | # 成功 697 | metadata = operation["operation"].get("metadata", {}) 698 | video_info = metadata.get("video", {}) 699 | video_url = video_info.get("fifeUrl") 700 | 701 | if not video_url: 702 | yield self._create_error_response("视频URL为空") 703 | return 704 | 705 | # 缓存视频 (如果启用) 706 | local_url = video_url 707 | if config.cache_enabled: 708 | try: 709 | if stream: 710 | yield self._create_stream_chunk("正在缓存视频文件...\n") 711 | cached_filename = await self.file_cache.download_and_cache(video_url, "video") 712 | local_url = f"{self._get_base_url()}/tmp/{cached_filename}" 713 | if stream: 714 | yield self._create_stream_chunk("✅ 视频缓存成功,准备返回缓存地址...\n") 715 | except Exception as e: 716 | debug_logger.log_error(f"Failed to cache video: {str(e)}") 717 | # 缓存失败不影响结果返回,使用原始URL 718 | local_url = video_url 719 | if stream: 720 | yield self._create_stream_chunk(f"⚠️ 缓存失败: {str(e)}\n正在返回源链接...\n") 721 | else: 722 | if stream: 723 | yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n") 724 | 725 | # 更新数据库 726 | task_id = operation["operation"]["name"] 727 | await self.db.update_task( 728 | task_id, 729 | status="completed", 730 | progress=100, 731 | result_urls=[local_url], 732 | completed_at=time.time() 733 | ) 734 | 735 | # 返回结果 736 | if stream: 737 | yield self._create_stream_chunk( 738 | f"", 739 | finish_reason="stop" 740 | ) 741 | else: 742 | yield self._create_completion_response( 743 | local_url, # 直接传URL,让方法内部格式化 744 | media_type="video" 745 | ) 746 | return 747 | 748 | elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"): 749 | # 失败 750 | yield self._create_error_response(f"视频生成失败: {status}") 751 | return 752 | 753 | except Exception as e: 754 | debug_logger.log_error(f"Poll error: {str(e)}") 755 | continue 756 | 757 | # 超时 758 | yield self._create_error_response(f"视频生成超时 (已轮询{max_attempts}次)") 759 | 760 | # ========== 响应格式化 ========== 761 | 762 | def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str: 763 | """创建流式响应chunk""" 764 | import json 765 | import time 766 | 767 | chunk = { 768 | "id": f"chatcmpl-{int(time.time())}", 769 | "object": "chat.completion.chunk", 770 | "created": int(time.time()), 771 | "model": "flow2api", 772 | "choices": [{ 773 | "index": 0, 774 | "delta": {}, 775 | "finish_reason": finish_reason 776 | }] 777 | } 778 | 779 | if role: 780 | chunk["choices"][0]["delta"]["role"] = role 781 | 782 | if finish_reason: 783 | chunk["choices"][0]["delta"]["content"] = content 784 | else: 785 | chunk["choices"][0]["delta"]["reasoning_content"] = content 786 | 787 | return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" 788 | 789 | def _create_completion_response(self, content: str, media_type: str = "image", is_availability_check: bool = False) -> str: 790 | """创建非流式响应 791 | 792 | Args: 793 | content: 媒体URL或纯文本消息 794 | media_type: 媒体类型 ("image" 或 "video") 795 | is_availability_check: 是否为可用性检查响应 (纯文本消息) 796 | 797 | Returns: 798 | JSON格式的响应 799 | """ 800 | import json 801 | import time 802 | 803 | # 可用性检查: 返回纯文本消息 804 | if is_availability_check: 805 | formatted_content = content 806 | else: 807 | # 媒体生成: 根据媒体类型格式化内容为Markdown 808 | if media_type == "video": 809 | formatted_content = f"```html\n\n```" 810 | else: # image 811 | formatted_content = f"![Generated Image]({content})" 812 | 813 | response = { 814 | "id": f"chatcmpl-{int(time.time())}", 815 | "object": "chat.completion", 816 | "created": int(time.time()), 817 | "model": "flow2api", 818 | "choices": [{ 819 | "index": 0, 820 | "message": { 821 | "role": "assistant", 822 | "content": formatted_content 823 | }, 824 | "finish_reason": "stop" 825 | }] 826 | } 827 | 828 | return json.dumps(response, ensure_ascii=False) 829 | 830 | def _create_error_response(self, error_message: str) -> str: 831 | """创建错误响应""" 832 | import json 833 | 834 | error = { 835 | "error": { 836 | "message": error_message, 837 | "type": "invalid_request_error", 838 | "code": "generation_failed" 839 | } 840 | } 841 | 842 | return json.dumps(error, ensure_ascii=False) 843 | 844 | def _get_base_url(self) -> str: 845 | """获取基础URL用于缓存文件访问""" 846 | # 优先使用配置的cache_base_url 847 | if config.cache_base_url: 848 | return config.cache_base_url 849 | # 否则使用服务器地址 850 | return f"http://{config.server_host}:{config.server_port}" 851 | 852 | async def _log_request( 853 | self, 854 | token_id: Optional[int], 855 | operation: str, 856 | request_data: Dict[str, Any], 857 | response_data: Dict[str, Any], 858 | status_code: int, 859 | duration: float 860 | ): 861 | """记录请求到数据库""" 862 | try: 863 | log = RequestLog( 864 | token_id=token_id, 865 | operation=operation, 866 | request_body=json.dumps(request_data, ensure_ascii=False), 867 | response_body=json.dumps(response_data, ensure_ascii=False), 868 | status_code=status_code, 869 | duration=duration 870 | ) 871 | await self.db.add_request_log(log) 872 | except Exception as e: 873 | # 日志记录失败不影响主流程 874 | debug_logger.log_error(f"Failed to log request: {e}") 875 | 876 | --------------------------------------------------------------------------------