├── 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 |
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)
6 | [](https://www.python.org/)
7 | [](https://fastapi.tiangolo.com/)
8 | [](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 | [](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 图片格式: 
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"",
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""
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 |
--------------------------------------------------------------------------------