From 6d4cb8bf9a3d7cedd20fe942e3ca01bc6ab22158 Mon Sep 17 00:00:00 2001 From: genz27 Date: Tue, 3 Mar 2026 20:03:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81token=E7=BA=A7?= =?UTF-8?q?=E6=89=93=E7=A0=81=E4=BB=A3=E7=90=86=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=9C=89=E5=A4=B4Docker=E5=8F=8C=E9=95=9C=E5=83=8F=E5=8F=91?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-publish.yml | 21 +++- Dockerfile.headed | 32 +++++++ README.md | 19 ++++ docker-compose.headed.yml | 21 ++++ docker/entrypoint.headed.sh | 33 +++++++ src/api/admin.py | 9 ++ src/core/database.py | 8 +- src/core/models.py | 3 + src/main.py | 2 +- src/services/browser_captcha.py | 116 ++++++++++++++++++----- src/services/browser_captcha_personal.py | 47 +++++++-- src/services/flow_client.py | 73 ++++++++++---- src/services/generation_handler.py | 21 ++-- src/services/token_manager.py | 12 ++- static/manage.html | 26 +++-- 15 files changed, 373 insertions(+), 70 deletions(-) create mode 100644 Dockerfile.headed create mode 100644 docker-compose.headed.yml create mode 100644 docker/entrypoint.headed.sh diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d52e092..274cac6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -18,6 +18,18 @@ env: jobs: build-and-push: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - variant: standard + dockerfile: Dockerfile + image_suffix: "" + cache_scope: standard + - variant: headed + dockerfile: Dockerfile.headed + image_suffix: -headed + cache_scope: headed permissions: contents: read packages: write @@ -44,7 +56,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_suffix }} tags: | type=ref,event=branch type=ref,event=pr @@ -52,13 +64,14 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push Docker image + - name: Build and push Docker image (${{ matrix.variant }}) uses: docker/build-push-action@v5 with: context: . + file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=${{ matrix.cache_scope }} + cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }} diff --git a/Dockerfile.headed b/Dockerfile.headed new file mode 100644 index 0000000..7631427 --- /dev/null +++ b/Dockerfile.headed @@ -0,0 +1,32 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PLAYWRIGHT_BROWSERS_PATH=0 \ + ALLOW_DOCKER_HEADED_CAPTCHA=true \ + DISPLAY=:99 \ + XVFB_WHD=1920x1080x24 + +COPY requirements.txt ./ + +# 有头模式基础依赖:虚拟显示、窗口管理器。 +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + xvfb \ + fluxbox \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt \ + && python -m playwright install --with-deps chromium + +COPY . . +COPY docker/entrypoint.headed.sh /usr/local/bin/entrypoint.headed.sh +RUN chmod +x /usr/local/bin/entrypoint.headed.sh + +EXPOSE 8000 + +CMD ["/usr/local/bin/entrypoint.headed.sh"] diff --git a/README.md b/README.md index 3a90653..4dbc90c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ - 由于Flow增加了额外的验证码,你可以自行选择使用浏览器打码或第三发打码: 注册[YesCaptcha](https://yescaptcha.com/i/13Xd8K)并获取api key,将其填入系统配置页面```YesCaptcha API密钥```区域 +- 默认 `docker-compose.yml` 建议搭配第三方打码(yescaptcha/capmonster/ezcaptcha/capsolver)。 +如需 Docker 内有头打码(browser/personal),请使用下方 `docker-compose.headed.yml`。 - 自动更新st浏览器拓展:[Flow2API-Token-Updater](https://github.com/TheSmallHanCat/Flow2API-Token-Updater) @@ -61,6 +63,23 @@ docker-compose -f docker-compose.warp.yml up -d docker-compose -f docker-compose.warp.yml logs -f ``` +#### Docker 有头打码模式(browser / personal) + +> 适用于你有虚拟化桌面需求、希望在容器里启用有头浏览器打码的场景。 +> 该模式默认启动 `Xvfb + Fluxbox` 实现容器内部可视化,并设置 `ALLOW_DOCKER_HEADED_CAPTCHA=true`。 +> 仅开放应用端口,不提供任何远程桌面连接端口。 + +```bash +# 启动有头模式(首次建议带 --build) +docker compose -f docker-compose.headed.yml up -d --build + +# 查看日志 +docker compose -f docker-compose.headed.yml logs -f +``` + +- API 端口:`8000` +- 进入管理后台后,将验证码方式设为 `browser` 或 `personal` + ### 方式二:本地部署 ```bash diff --git a/docker-compose.headed.yml b/docker-compose.headed.yml new file mode 100644 index 0000000..f521467 --- /dev/null +++ b/docker-compose.headed.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + flow2api-headed: + build: + context: . + dockerfile: Dockerfile.headed + image: flow2api:headed + container_name: flow2api-headed + ports: + - "8000:8000" + volumes: + - ./data:/app/data + - ./config/setting.toml:/app/config/setting.toml + environment: + - PYTHONUNBUFFERED=1 + - ALLOW_DOCKER_HEADED_CAPTCHA=true + - DISPLAY=:99 + - XVFB_WHD=1920x1080x24 + shm_size: "2gb" + restart: unless-stopped diff --git a/docker/entrypoint.headed.sh b/docker/entrypoint.headed.sh new file mode 100644 index 0000000..706b8cc --- /dev/null +++ b/docker/entrypoint.headed.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu + +export DISPLAY="${DISPLAY:-:99}" +export ALLOW_DOCKER_HEADED_CAPTCHA="${ALLOW_DOCKER_HEADED_CAPTCHA:-true}" +export XVFB_WHD="${XVFB_WHD:-1920x1080x24}" + +echo "[entrypoint] starting Xvfb on ${DISPLAY} (${XVFB_WHD})" +Xvfb "${DISPLAY}" -screen 0 "${XVFB_WHD}" -ac -nolisten tcp +extension RANDR >/tmp/xvfb.log 2>&1 & + +sleep 1 + +echo "[entrypoint] starting Fluxbox" +fluxbox >/tmp/fluxbox.log 2>&1 & + +if [ -z "${BROWSER_EXECUTABLE_PATH:-}" ]; then + BROWSER_EXECUTABLE_PATH="$(python - <<'PY' +from playwright.sync_api import sync_playwright + +try: + with sync_playwright() as p: + print(p.chromium.executable_path) +except Exception: + print("") +PY +)" + if [ -n "${BROWSER_EXECUTABLE_PATH}" ]; then + export BROWSER_EXECUTABLE_PATH + echo "[entrypoint] browser executable: ${BROWSER_EXECUTABLE_PATH}" + fi +fi + +exec python main.py diff --git a/src/api/admin.py b/src/api/admin.py index 3e0fa96..8a711f3 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -229,6 +229,7 @@ class AddTokenRequest(BaseModel): project_id: Optional[str] = None # 用户可选输入project_id project_name: Optional[str] = None remark: Optional[str] = None + captcha_proxy_url: Optional[str] = None image_enabled: bool = True video_enabled: bool = True image_concurrency: int = -1 @@ -240,6 +241,7 @@ class UpdateTokenRequest(BaseModel): project_id: Optional[str] = None # 用户可选输入project_id project_name: Optional[str] = None remark: Optional[str] = None + captcha_proxy_url: Optional[str] = None image_enabled: Optional[bool] = None video_enabled: Optional[bool] = None image_concurrency: Optional[int] = None @@ -301,6 +303,7 @@ class ImportTokenItem(BaseModel): access_token: Optional[str] = None session_token: Optional[str] = None is_active: bool = True + captcha_proxy_url: Optional[str] = None image_enabled: bool = True video_enabled: bool = True image_concurrency: int = -1 @@ -411,6 +414,7 @@ async def get_tokens(token: str = Depends(verify_admin_token)): "user_paygate_tier": row.get("user_paygate_tier"), "current_project_id": row.get("current_project_id"), # 🆕 项目ID "current_project_name": row.get("current_project_name"), # 🆕 项目名称 + "captcha_proxy_url": row.get("captcha_proxy_url") or "", "image_enabled": bool(row.get("image_enabled")), "video_enabled": bool(row.get("video_enabled")), "image_concurrency": row.get("image_concurrency"), @@ -433,6 +437,7 @@ async def add_token( project_id=request.project_id, # 🆕 支持用户指定project_id project_name=request.project_name, remark=request.remark, + captcha_proxy_url=request.captcha_proxy_url.strip() if request.captcha_proxy_url is not None else None, image_enabled=request.image_enabled, video_enabled=request.video_enabled, image_concurrency=request.image_concurrency, @@ -495,6 +500,7 @@ async def update_token( project_id=request.project_id, project_name=request.project_name, remark=request.remark, + captcha_proxy_url=request.captcha_proxy_url.strip() if request.captcha_proxy_url is not None else None, image_enabled=request.image_enabled, video_enabled=request.video_enabled, image_concurrency=request.image_concurrency, @@ -695,6 +701,7 @@ async def import_tokens( st=st, at=at, at_expires=at_expires, + captcha_proxy_url=item.captcha_proxy_url.strip() if item.captcha_proxy_url is not None else None, image_enabled=item.image_enabled, video_enabled=item.video_enabled, image_concurrency=item.image_concurrency, @@ -707,6 +714,7 @@ async def import_tokens( existing.st = st existing.at = at existing.at_expires = at_expires + existing.captcha_proxy_url = item.captcha_proxy_url existing.image_enabled = item.image_enabled existing.video_enabled = item.video_enabled existing.image_concurrency = item.image_concurrency @@ -716,6 +724,7 @@ async def import_tokens( # 添加新Token new_token = await token_manager.add_token( st=st, + captcha_proxy_url=item.captcha_proxy_url.strip() if item.captcha_proxy_url is not None else None, image_enabled=item.image_enabled, video_enabled=item.video_enabled, image_concurrency=item.image_concurrency, diff --git a/src/core/database.py b/src/core/database.py index 2f2cdde..7704580 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -283,6 +283,7 @@ class Database: ("video_enabled", "BOOLEAN DEFAULT 1"), ("image_concurrency", "INTEGER DEFAULT -1"), ("video_concurrency", "INTEGER DEFAULT -1"), + ("captcha_proxy_url", "TEXT"), # token级打码代理 ("ban_reason", "TEXT"), # 禁用原因 ("banned_at", "TIMESTAMP"), # 禁用时间 ] @@ -406,6 +407,7 @@ class Database: video_enabled BOOLEAN DEFAULT 1, image_concurrency INTEGER DEFAULT -1, video_concurrency INTEGER DEFAULT -1, + captcha_proxy_url TEXT, ban_reason TEXT, banned_at TIMESTAMP ) @@ -653,13 +655,13 @@ class Database: cursor = await db.execute(""" INSERT INTO tokens (st, at, at_expires, email, name, remark, is_active, credits, user_paygate_tier, current_project_id, current_project_name, - image_enabled, video_enabled, image_concurrency, video_concurrency) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + image_enabled, video_enabled, image_concurrency, video_concurrency, captcha_proxy_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (token.st, token.at, token.at_expires, token.email, token.name, token.remark, token.is_active, token.credits, token.user_paygate_tier, token.current_project_id, token.current_project_name, token.image_enabled, token.video_enabled, - token.image_concurrency, token.video_concurrency)) + token.image_concurrency, token.video_concurrency, token.captcha_proxy_url)) await db.commit() token_id = cursor.lastrowid diff --git a/src/core/models.py b/src/core/models.py index d98e567..d703108 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -38,6 +38,9 @@ class Token(BaseModel): image_concurrency: int = -1 # -1表示无限制 video_concurrency: int = -1 # -1表示无限制 + # 打码代理(token 级,可覆盖全局浏览器打码代理) + captcha_proxy_url: Optional[str] = None + # 429禁用相关 ban_reason: Optional[str] = None # 禁用原因: "429_rate_limit" 或 None banned_at: Optional[datetime] = None # 禁用时间 diff --git a/src/main.py b/src/main.py index 6243d01..65ced7f 100644 --- a/src/main.py +++ b/src/main.py @@ -105,7 +105,7 @@ async def lifespan(app: FastAPI): elif captcha_config.captcha_method == "browser": from .services.browser_captcha import BrowserCaptchaService browser_service = await BrowserCaptchaService.get_instance(db) - print("✓ Browser captcha service initialized (headless mode)") + print("✓ Browser captcha service initialized (headed mode)") # Initialize concurrency manager tokens = await token_manager.get_all_tokens() diff --git a/src/services/browser_captcha.py b/src/services/browser_captcha.py index b11231a..27d8217 100644 --- a/src/services/browser_captcha.py +++ b/src/services/browser_captcha.py @@ -44,6 +44,19 @@ def _is_running_in_docker() -> bool: IS_DOCKER = _is_running_in_docker() +def _is_truthy_env(name: str) -> bool: + """判断环境变量是否为 true。""" + value = os.environ.get(name, "") + return value.strip().lower() in {"1", "true", "yes", "on"} + + +ALLOW_DOCKER_HEADED = ( + _is_truthy_env("ALLOW_DOCKER_HEADED_CAPTCHA") + or _is_truthy_env("ALLOW_DOCKER_BROWSER_CAPTCHA") +) +DOCKER_HEADED_BLOCKED = IS_DOCKER and not ALLOW_DOCKER_HEADED + + # ==================== playwright 自动安装 ==================== def _run_pip_install(package: str, use_mirror: bool = False) -> bool: """运行 pip install 命令""" @@ -156,11 +169,19 @@ Route = None BrowserContext = None PLAYWRIGHT_AVAILABLE = False -if IS_DOCKER: - debug_logger.log_warning("[BrowserCaptcha] 检测到 Docker 环境,有头浏览器打码不可用,请使用第三方打码服务") - print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,有头浏览器打码不可用") - print("[BrowserCaptcha] 请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver") +if DOCKER_HEADED_BLOCKED: + debug_logger.log_warning( + "[BrowserCaptcha] 检测到 Docker 环境,默认禁用有头浏览器打码。" + "如需启用请设置 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb。" + ) + print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,默认禁用有头浏览器打码") + print("[BrowserCaptcha] 如需启用请设置 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb") else: + if IS_DOCKER and ALLOW_DOCKER_HEADED: + debug_logger.log_warning( + "[BrowserCaptcha] Docker 有头浏览器打码白名单已启用,请确保 DISPLAY/Xvfb 可用" + ) + print("[BrowserCaptcha] ✅ Docker 有头浏览器打码白名单已启用") if _ensure_playwright_installed(): try: from playwright.async_api import async_playwright, Route, BrowserContext @@ -344,7 +365,7 @@ class TokenBrowser: self._pending_release_tasks: List[asyncio.Task] = [] self._pending_release_lock = asyncio.Lock() - async def _create_browser(self) -> tuple: + async def _create_browser(self, token_proxy_url: Optional[str] = None) -> tuple: """创建新浏览器实例(新 UA),返回 (playwright, browser, context)""" import random @@ -360,21 +381,36 @@ class TokenBrowser: # 代理配置 proxy_option = None raw_proxy_url = None + proxy_source = "none" self._browser_proxy_active = False try: - if self.db: + candidate_proxy_url = None + if token_proxy_url and token_proxy_url.strip(): + candidate_proxy_url = token_proxy_url.strip() + proxy_source = "token" + elif self.db: captcha_config = await self.db.get_captcha_config() if captcha_config.browser_proxy_enabled and captcha_config.browser_proxy_url: candidate_proxy_url = captcha_config.browser_proxy_url.strip() - normalized_proxy_url, proxy_warning = normalize_browser_proxy_url(candidate_proxy_url) - if proxy_warning: - debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} {proxy_warning}") - proxy_option = parse_proxy_url(normalized_proxy_url) - if proxy_option: - raw_proxy_url = normalized_proxy_url - self._browser_proxy_active = True - debug_logger.log_info(f"[BrowserCaptcha] Token-{self.token_id} 使用代理: {proxy_option['server']}") - except: pass + proxy_source = "global" + + if candidate_proxy_url: + normalized_proxy_url, proxy_warning = normalize_browser_proxy_url(candidate_proxy_url) + if proxy_warning: + debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} {proxy_warning}") + proxy_option = parse_proxy_url(normalized_proxy_url) + if proxy_option: + raw_proxy_url = normalized_proxy_url + self._browser_proxy_active = True + debug_logger.log_info( + f"[BrowserCaptcha] Token-{self.token_id} 使用{proxy_source}代理: {proxy_option['server']}" + ) + else: + debug_logger.log_warning( + f"[BrowserCaptcha] Token-{self.token_id} {proxy_source}代理格式无效,已忽略" + ) + except Exception as e: + debug_logger.log_warning(f"[BrowserCaptcha] Token-{self.token_id} 读取代理配置失败: {e}") # 先记录创建时的指纹,后续会在页面中补齐 sec-ch-* 等信息 self._last_fingerprint = { @@ -1049,7 +1085,13 @@ class TokenBrowser: return None return dict(self._last_fingerprint) - async def get_token(self, project_id: str, website_key: str, action: str = "IMAGE_GENERATION") -> Optional[str]: + async def get_token( + self, + project_id: str, + website_key: str, + action: str = "IMAGE_GENERATION", + token_proxy_url: Optional[str] = None + ) -> Optional[str]: """获取 Token:启动新浏览器 -> 打码 -> 关闭浏览器""" async with self._semaphore: MAX_RETRIES = 3 @@ -1062,7 +1104,7 @@ class TokenBrowser: start_ts = time.time() # 每次都启动新浏览器(新 UA) - playwright, browser, context = await self._create_browser() + playwright, browser, context = await self._create_browser(token_proxy_url=token_proxy_url) # 执行打码 token = await self._execute_captcha(context, project_id, website_key, action) @@ -1251,10 +1293,15 @@ class BrowserCaptchaService: def _check_available(self): """检查服务是否可用""" - if IS_DOCKER: + if DOCKER_HEADED_BLOCKED: raise RuntimeError( - "有头浏览器打码在 Docker 环境中不可用。" - "请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver" + "检测到 Docker 环境,默认禁用有头浏览器打码。" + "如需启用请设置环境变量 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb。" + ) + if IS_DOCKER and not os.environ.get("DISPLAY"): + raise RuntimeError( + "Docker 有头浏览器打码已启用,但 DISPLAY 未设置。" + "请设置 DISPLAY(例如 :99)并启动 Xvfb。" ) if not PLAYWRIGHT_AVAILABLE or async_playwright is None: raise RuntimeError( @@ -1316,6 +1363,18 @@ class BrowserCaptchaService: browser_id = self._round_robin_index % self._browser_count self._round_robin_index += 1 return browser_id + + async def _resolve_token_proxy_url(self, token_id: Optional[int]) -> Optional[str]: + """读取 token 级打码代理,为空时回退全局配置。""" + if not token_id or not self.db: + return None + try: + token = await self.db.get_token(token_id) + if token and token.captcha_proxy_url and token.captcha_proxy_url.strip(): + return token.captcha_proxy_url.strip() + except Exception as e: + debug_logger.log_warning(f"[BrowserCaptcha] 读取 token({token_id}) 打码代理失败: {e}") + return None async def get_token(self, project_id: str, action: str = "IMAGE_GENERATION", token_id: int = None) -> tuple[Optional[str], int]: """获取 reCAPTCHA Token(轮询分配到不同浏览器) @@ -1323,7 +1382,7 @@ class BrowserCaptchaService: Args: project_id: 项目 ID action: reCAPTCHA action - token_id: 忽略,使用轮询分配 + token_id: 业务 token id(仅用于读取 token 级打码代理) Returns: (token, browser_id) 元组,调用方失败时用 browser_id 调用 report_error @@ -1332,6 +1391,7 @@ class BrowserCaptchaService: self._check_available() self._stats["req_total"] += 1 + token_proxy_url = await self._resolve_token_proxy_url(token_id) # 全局并发限制(如果已配置) if self._token_semaphore: @@ -1340,7 +1400,12 @@ class BrowserCaptchaService: browser_id = self._get_next_browser_id() browser = await self._get_or_create_browser(browser_id) - token = await browser.get_token(project_id, self.website_key, action) + token = await browser.get_token( + project_id, + self.website_key, + action, + token_proxy_url=token_proxy_url + ) if token: self._stats["gen_ok"] += 1 @@ -1354,7 +1419,12 @@ class BrowserCaptchaService: browser_id = self._get_next_browser_id() browser = await self._get_or_create_browser(browser_id) - token = await browser.get_token(project_id, self.website_key, action) + token = await browser.get_token( + project_id, + self.website_key, + action, + token_proxy_url=token_proxy_url + ) if token: self._stats["gen_ok"] += 1 diff --git a/src/services/browser_captcha_personal.py b/src/services/browser_captcha_personal.py index fcc6a75..a3ea3c6 100644 --- a/src/services/browser_captcha_personal.py +++ b/src/services/browser_captcha_personal.py @@ -37,6 +37,19 @@ def _is_running_in_docker() -> bool: IS_DOCKER = _is_running_in_docker() +def _is_truthy_env(name: str) -> bool: + """判断环境变量是否为 true。""" + value = os.environ.get(name, "") + return value.strip().lower() in {"1", "true", "yes", "on"} + + +ALLOW_DOCKER_HEADED = ( + _is_truthy_env("ALLOW_DOCKER_HEADED_CAPTCHA") + or _is_truthy_env("ALLOW_DOCKER_BROWSER_CAPTCHA") +) +DOCKER_HEADED_BLOCKED = IS_DOCKER and not ALLOW_DOCKER_HEADED + + # ==================== nodriver 自动安装 ==================== def _run_pip_install(package: str, use_mirror: bool = False) -> bool: """运行 pip install 命令 @@ -103,11 +116,19 @@ def _ensure_nodriver_installed() -> bool: uc = None NODRIVER_AVAILABLE = False -if IS_DOCKER: - debug_logger.log_warning("[BrowserCaptcha] 检测到 Docker 环境,内置浏览器打码不可用,请使用第三方打码服务") - print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,内置浏览器打码不可用") - print("[BrowserCaptcha] 请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver") +if DOCKER_HEADED_BLOCKED: + debug_logger.log_warning( + "[BrowserCaptcha] 检测到 Docker 环境,默认禁用内置浏览器打码。" + "如需启用请设置 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb。" + ) + print("[BrowserCaptcha] ⚠️ 检测到 Docker 环境,默认禁用内置浏览器打码") + print("[BrowserCaptcha] 如需启用请设置 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb") else: + if IS_DOCKER and ALLOW_DOCKER_HEADED: + debug_logger.log_warning( + "[BrowserCaptcha] Docker 内置浏览器打码白名单已启用,请确保 DISPLAY/Xvfb 可用" + ) + print("[BrowserCaptcha] ✅ Docker 内置浏览器打码白名单已启用") if _ensure_nodriver_installed(): try: import nodriver as uc @@ -173,10 +194,15 @@ class BrowserCaptchaService: def _check_available(self): """检查服务是否可用""" - if IS_DOCKER: + if DOCKER_HEADED_BLOCKED: raise RuntimeError( - "内置浏览器打码在 Docker 环境中不可用。" - "请使用第三方打码服务: yescaptcha, capmonster, ezcaptcha, capsolver" + "检测到 Docker 环境,默认禁用内置浏览器打码。" + "如需启用请设置环境变量 ALLOW_DOCKER_HEADED_CAPTCHA=true,并提供 DISPLAY/Xvfb。" + ) + if IS_DOCKER and not os.environ.get("DISPLAY"): + raise RuntimeError( + "Docker 内置浏览器打码已启用,但 DISPLAY 未设置。" + "请设置 DISPLAY(例如 :99)并启动 Xvfb。" ) if not NODRIVER_AVAILABLE or uc is None: raise RuntimeError( @@ -208,10 +234,17 @@ class BrowserCaptchaService: # 确保 user_data_dir 存在 os.makedirs(self.user_data_dir, exist_ok=True) + browser_executable_path = os.environ.get("BROWSER_EXECUTABLE_PATH", "").strip() or None + if browser_executable_path: + debug_logger.log_info( + f"[BrowserCaptcha] 使用指定浏览器可执行文件: {browser_executable_path}" + ) + # 启动 nodriver 浏览器 self.browser = await uc.start( headless=self.headless, user_data_dir=self.user_data_dir, + browser_executable_path=browser_executable_path, sandbox=False, # nodriver 需要此参数来禁用 sandbox browser_args=[ '--no-sandbox', diff --git a/src/services/flow_client.py b/src/services/flow_client.py index b5c86d9..e50fd8e 100644 --- a/src/services/flow_client.py +++ b/src/services/flow_client.py @@ -569,7 +569,8 @@ class FlowClient: prompt: str, model_name: str, aspect_ratio: str, - image_inputs: Optional[List[Dict]] = None + image_inputs: Optional[List[Dict]] = None, + token_id: Optional[int] = None ) -> tuple[dict, str]: """生成图片(同步返回) @@ -594,7 +595,11 @@ class FlowClient: for retry_attempt in range(max_retries): # 每次重试都重新获取 reCAPTCHA token - recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="IMAGE_GENERATION") + recaptcha_token, browser_id = await self._get_recaptcha_token( + project_id, + action="IMAGE_GENERATION", + token_id=token_id + ) if not recaptcha_token: raise Exception("Failed to obtain reCAPTCHA token") session_id = self._generate_session_id() @@ -668,7 +673,8 @@ class FlowClient: media_id: str, target_resolution: str = "UPSAMPLE_IMAGE_RESOLUTION_4K", user_paygate_tier: str = "PAYGATE_TIER_NOT_PAID", - session_id: Optional[str] = None + session_id: Optional[str] = None, + token_id: Optional[int] = None ) -> str: """放大图片到 2K/4K @@ -691,7 +697,11 @@ class FlowClient: for retry_attempt in range(max_retries): # 获取 reCAPTCHA token - 使用 IMAGE_GENERATION action - recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="IMAGE_GENERATION") + recaptcha_token, browser_id = await self._get_recaptcha_token( + project_id, + action="IMAGE_GENERATION", + token_id=token_id + ) if not recaptcha_token: raise Exception("Failed to obtain reCAPTCHA token") upsample_session_id = session_id or self._generate_session_id() @@ -751,7 +761,8 @@ class FlowClient: prompt: str, model_key: str, aspect_ratio: str, - user_paygate_tier: str = "PAYGATE_TIER_ONE" + user_paygate_tier: str = "PAYGATE_TIER_ONE", + token_id: Optional[int] = None ) -> dict: """文生视频,返回task_id @@ -781,7 +792,11 @@ class FlowClient: for retry_attempt in range(max_retries): # 每次重试都重新获取 reCAPTCHA token - 视频使用 VIDEO_GENERATION action - recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") + recaptcha_token, browser_id = await self._get_recaptcha_token( + project_id, + action="VIDEO_GENERATION", + token_id=token_id + ) if not recaptcha_token: raise Exception("Failed to obtain reCAPTCHA token") session_id = self._generate_session_id() @@ -847,7 +862,8 @@ class FlowClient: model_key: str, aspect_ratio: str, reference_images: List[Dict], - user_paygate_tier: str = "PAYGATE_TIER_ONE" + user_paygate_tier: str = "PAYGATE_TIER_ONE", + token_id: Optional[int] = None ) -> dict: """图生视频,返回task_id @@ -871,7 +887,11 @@ class FlowClient: for retry_attempt in range(max_retries): # 每次重试都重新获取 reCAPTCHA token - 视频使用 VIDEO_GENERATION action - recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") + recaptcha_token, browser_id = await self._get_recaptcha_token( + project_id, + action="VIDEO_GENERATION", + token_id=token_id + ) if not recaptcha_token: raise Exception("Failed to obtain reCAPTCHA token") session_id = self._generate_session_id() @@ -939,7 +959,8 @@ class FlowClient: aspect_ratio: str, start_media_id: str, end_media_id: str, - user_paygate_tier: str = "PAYGATE_TIER_ONE" + user_paygate_tier: str = "PAYGATE_TIER_ONE", + token_id: Optional[int] = None ) -> dict: """收尾帧生成视频,返回task_id @@ -964,7 +985,11 @@ class FlowClient: for retry_attempt in range(max_retries): # 每次重试都重新获取 reCAPTCHA token - 视频使用 VIDEO_GENERATION action - recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") + recaptcha_token, browser_id = await self._get_recaptcha_token( + project_id, + action="VIDEO_GENERATION", + token_id=token_id + ) if not recaptcha_token: raise Exception("Failed to obtain reCAPTCHA token") session_id = self._generate_session_id() @@ -1036,7 +1061,8 @@ class FlowClient: model_key: str, aspect_ratio: str, start_media_id: str, - user_paygate_tier: str = "PAYGATE_TIER_ONE" + user_paygate_tier: str = "PAYGATE_TIER_ONE", + token_id: Optional[int] = None ) -> dict: """仅首帧生成视频,返回task_id @@ -1060,7 +1086,11 @@ class FlowClient: for retry_attempt in range(max_retries): # 每次重试都重新获取 reCAPTCHA token - 视频使用 VIDEO_GENERATION action - recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") + recaptcha_token, browser_id = await self._get_recaptcha_token( + project_id, + action="VIDEO_GENERATION", + token_id=token_id + ) if not recaptcha_token: raise Exception("Failed to obtain reCAPTCHA token") session_id = self._generate_session_id() @@ -1131,7 +1161,8 @@ class FlowClient: video_media_id: str, aspect_ratio: str, resolution: str, - model_key: str + model_key: str, + token_id: Optional[int] = None ) -> dict: """视频放大到 4K/1080P,返回 task_id @@ -1153,7 +1184,11 @@ class FlowClient: last_error = None for retry_attempt in range(max_retries): - recaptcha_token, browser_id = await self._get_recaptcha_token(project_id, action="VIDEO_GENERATION") + recaptcha_token, browser_id = await self._get_recaptcha_token( + project_id, + action="VIDEO_GENERATION", + token_id=token_id + ) if not recaptcha_token: raise Exception("Failed to obtain reCAPTCHA token") session_id = self._generate_session_id() @@ -1371,7 +1406,12 @@ class FlowClient: """生成sceneId: UUID""" return str(uuid.uuid4()) - async def _get_recaptcha_token(self, project_id: str, action: str = "IMAGE_GENERATION") -> tuple[Optional[str], Optional[int]]: + async def _get_recaptcha_token( + self, + project_id: str, + action: str = "IMAGE_GENERATION", + token_id: Optional[int] = None + ) -> tuple[Optional[str], Optional[int]]: """获取reCAPTCHA token - 支持多种打码方式 Args: @@ -1379,6 +1419,7 @@ class FlowClient: action: reCAPTCHA action类型 - IMAGE_GENERATION: 图片生成和2K/4K图片放大 (默认) - VIDEO_GENERATION: 视频生成和视频放大 + token_id: 当前业务 token id(browser 模式下用于读取 token 级打码代理) Returns: (token, browser_id) 元组,browser_id 用于失败时调用 report_error @@ -1416,7 +1457,7 @@ class FlowClient: try: from .browser_captcha import BrowserCaptchaService service = await BrowserCaptchaService.get_instance(self.db) - token, browser_id = await service.get_token(project_id, action) + token, browser_id = await service.get_token(project_id, action, token_id=token_id) fingerprint = await service.get_fingerprint(browser_id) if token else None self._set_request_fingerprint(fingerprint if token else None) return token, browser_id diff --git a/src/services/generation_handler.py b/src/services/generation_handler.py index 5f076e2..d31db44 100644 --- a/src/services/generation_handler.py +++ b/src/services/generation_handler.py @@ -954,7 +954,8 @@ class GenerationHandler: prompt=prompt, model_name=model_config["model_name"], aspect_ratio=model_config["aspect_ratio"], - image_inputs=image_inputs + image_inputs=image_inputs, + token_id=token.id ) # 提取URL和mediaId @@ -988,7 +989,8 @@ class GenerationHandler: media_id=media_id, target_resolution=upsample_resolution, user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_NOT_PAID", - session_id=generation_session_id + session_id=generation_session_id, + token_id=token.id ) if encoded_image: @@ -1271,7 +1273,8 @@ class GenerationHandler: aspect_ratio=model_config["aspect_ratio"], start_media_id=start_media_id, end_media_id=end_media_id, - user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" + user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE", + token_id=token.id ) else: # 只有首帧 - 需要去掉 model_key 中的 _fl @@ -1288,7 +1291,8 @@ class GenerationHandler: model_key=actual_model_key, aspect_ratio=model_config["aspect_ratio"], start_media_id=start_media_id, - user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" + user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE", + token_id=token.id ) # R2V: 多图生成 @@ -1300,7 +1304,8 @@ class GenerationHandler: model_key=model_config["model_key"], aspect_ratio=model_config["aspect_ratio"], reference_images=reference_images, - user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" + user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE", + token_id=token.id ) # T2V 或 R2V无图: 纯文本生成 @@ -1311,7 +1316,8 @@ class GenerationHandler: prompt=prompt, model_key=model_config["model_key"], aspect_ratio=model_config["aspect_ratio"], - user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE" + user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE", + token_id=token.id ) # 获取task_id和operations @@ -1417,7 +1423,8 @@ class GenerationHandler: video_media_id=video_media_id, aspect_ratio=aspect_ratio, resolution=upsample_config["resolution"], - model_key=upsample_config["model_key"] + model_key=upsample_config["model_key"], + token_id=token.id ) upsample_operations = upsample_result.get("operations", []) diff --git a/src/services/token_manager.py b/src/services/token_manager.py index 40116f6..46fa0ea 100644 --- a/src/services/token_manager.py +++ b/src/services/token_manager.py @@ -57,7 +57,8 @@ class TokenManager: image_enabled: bool = True, video_enabled: bool = True, image_concurrency: int = -1, - video_concurrency: int = -1 + video_concurrency: int = -1, + captcha_proxy_url: Optional[str] = None ) -> Token: """Add a new token @@ -70,6 +71,7 @@ class TokenManager: video_enabled: 是否启用视频生成 image_concurrency: 图片并发限制 video_concurrency: 视频并发限制 + captcha_proxy_url: token级浏览器打码代理(可选,优先于全局) Returns: Token object @@ -146,7 +148,8 @@ class TokenManager: image_enabled=image_enabled, video_enabled=video_enabled, image_concurrency=image_concurrency, - video_concurrency=video_concurrency + video_concurrency=video_concurrency, + captcha_proxy_url=captcha_proxy_url ) # Step 6: 保存到数据库 @@ -177,7 +180,8 @@ class TokenManager: image_enabled: Optional[bool] = None, video_enabled: Optional[bool] = None, image_concurrency: Optional[int] = None, - video_concurrency: Optional[int] = None + video_concurrency: Optional[int] = None, + captcha_proxy_url: Optional[str] = None ): """Update token (支持修改project_id和project_name) @@ -205,6 +209,8 @@ class TokenManager: update_fields["image_concurrency"] = image_concurrency if video_concurrency is not None: update_fields["video_concurrency"] = video_concurrency + if captcha_proxy_url is not None: + update_fields["captcha_proxy_url"] = captcha_proxy_url # 检查token是否因429被禁用,如果是且未过期,则清空429状态 token = await self.db.get_token(token_id) diff --git a/static/manage.html b/static/manage.html index 2d5a02a..0b36ff4 100644 --- a/static/manage.html +++ b/static/manage.html @@ -569,6 +569,13 @@ + +
+ + +

仅覆盖当前 Token 的浏览器打码代理,留空则使用全局打码代理

+
+
@@ -646,6 +653,13 @@
+ +
+ + +

填写后优先覆盖全局打码代理,清空后恢复走全局打码代理

+
+
@@ -737,13 +751,13 @@ refreshTokenAT=async(id)=>{try{showToast('正在更新AT...','info');const r=await apiRequest(`/api/tokens/${id}/refresh-at`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){const expiresDate=d.token.at_expires?new Date(d.token.at_expires):null;const expiresStr=expiresDate?expiresDate.toLocaleString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).replace(/\//g,'-'):'未知';showToast(`AT更新成功! 新过期时间: ${expiresStr}`,'success');await refreshTokens()}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}}, refreshTokens=async()=>{await loadTokens();await loadStats()}, openAddModal=()=>$('addModal').classList.remove('hidden'), - closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenST').value='';$('addTokenRemark').value='';$('addTokenProjectId').value='';$('addTokenProjectName').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1'}, - openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenST').value=token.st||'';$('editTokenRemark').value=token.remark||'';$('editTokenProjectId').value=token.current_project_id||'';$('editTokenProjectName').value=token.current_project_name||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')}, - closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenST').value='';$('editTokenRemark').value='';$('editTokenProjectId').value='';$('editTokenProjectName').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value=''}, - submitEditToken=async()=>{const id=parseInt($('editTokenId').value),st=$('editTokenST').value.trim(),remark=$('editTokenRemark').value.trim(),projectId=$('editTokenProjectId').value.trim(),projectName=$('editTokenProjectName').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!st)return showToast('请输入 Session Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}}, + closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenST').value='';$('addTokenRemark').value='';$('addTokenProjectId').value='';$('addTokenProjectName').value='';$('addTokenCaptchaProxyUrl').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1'}, + openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenST').value=token.st||'';$('editTokenRemark').value=token.remark||'';$('editTokenProjectId').value=token.current_project_id||'';$('editTokenProjectName').value=token.current_project_name||'';$('editTokenCaptchaProxyUrl').value=token.captcha_proxy_url||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')}, + closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenST').value='';$('editTokenRemark').value='';$('editTokenProjectId').value='';$('editTokenProjectName').value='';$('editTokenCaptchaProxyUrl').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value=''}, + submitEditToken=async()=>{const id=parseInt($('editTokenId').value),st=$('editTokenST').value.trim(),remark=$('editTokenRemark').value.trim(),projectId=$('editTokenProjectId').value.trim(),projectName=$('editTokenProjectName').value.trim(),captchaProxyUrl=$('editTokenCaptchaProxyUrl').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!st)return showToast('请输入 Session Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,captcha_proxy_url:captchaProxyUrl,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}}, convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}}, convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}}, - submitAddToken=async()=>{const st=$('addTokenST').value.trim(),remark=$('addTokenRemark').value.trim(),projectId=$('addTokenProjectId').value.trim(),projectName=$('addTokenProjectName').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!st)return showToast('请输入 Session Token','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}}, + submitAddToken=async()=>{const st=$('addTokenST').value.trim(),remark=$('addTokenRemark').value.trim(),projectId=$('addTokenProjectId').value.trim(),projectName=$('addTokenProjectName').value.trim(),captchaProxyUrl=$('addTokenCaptchaProxyUrl').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!st)return showToast('请输入 Session Token','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,captcha_proxy_url:captchaProxyUrl,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}}, testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}}, toggleToken=async(id,isActive)=>{const action=isActive?'disable':'enable';try{const r=await apiRequest(`/api/tokens/${id}/${action}`,{method:'POST'});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast(isActive?'Token已禁用':'Token已启用','success')):showToast('操作失败','error')}catch(e){showToast('操作失败: '+e.message,'error')}}, toggleTokenStatus=async(id,active)=>{try{const r=await apiRequest(`/api/tokens/${id}/status`,{method:'PUT',body:JSON.stringify({is_active:active})});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast('状态更新成功','success')):showToast('更新失败','error')}catch(e){showToast('更新失败: '+e.message,'error')}}, @@ -754,7 +768,7 @@ closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''}, openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''}, closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''}, - exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')}, + exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,is_active:t.is_active,captcha_proxy_url:t.captcha_proxy_url||'',image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')}, submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误:应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();const msg=`导入成功!新增: ${d.added||0}, 更新: ${d.updated||0}`;showToast(msg,'success')}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}}, submitSora2Activate=async()=>{const tokenId=parseInt($('sora2TokenId').value),inviteCode=$('sora2InviteCode').value.trim();if(!tokenId)return showToast('Token ID无效','error');if(!inviteCode)return showToast('请输入邀请码','error');if(inviteCode.length!==6)return showToast('邀请码必须是6位','error');const btn=$('sora2ActivateBtn'),btnText=$('sora2ActivateBtnText'),btnSpinner=$('sora2ActivateBtnSpinner');btn.disabled=true;btnText.textContent='激活中...';btnSpinner.classList.remove('hidden');try{showToast('正在激活Sora2...','info');const r=await apiRequest(`/api/tokens/${tokenId}/sora2/activate?invite_code=${inviteCode}`,{method:'POST'});if(!r){btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeSora2Modal();await refreshTokens();if(d.already_accepted){showToast('Sora2已激活(之前已接受)','success')}else{showToast(`Sora2激活成功!邀请码: ${d.invite_code||'无'}`,'success')}}else{showToast('激活失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('激活失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden')}}, loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}},