import os import sys import json import asyncio import logging import uuid from datetime import datetime, timezone from io import BytesIO from typing import List, Optional if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, UploadFile, File, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import Response, StreamingResponse from urllib.parse import urlparse import httpx from settings import CORS_ORIGINS, SERVE_WEB, STATIC_DIR from help_pages import serve_credential_tool from web_static import mount_frontend from pydantic import BaseModel, Field from sqlalchemy import select, update, delete, text, func from sqlalchemy.ext.asyncio import AsyncSession from models.database import engine, Base, get_db, AsyncSessionLocal from models.db_migrate import ( migrate_accounts_quota_disabled as _migrate_accounts_quota_disabled, migrate_accounts_table as _migrate_accounts_table, migrate_account_videos_table as _migrate_account_videos_table, migrate_message_logs_table as _migrate_message_logs_table, migrate_payment_orders_table as _migrate_payment_orders_table, migrate_rules_table as _migrate_rules_table, migrate_users_table as _migrate_users_table, ) from models.db_config import database_config_to_response from models.models import Account, AccountProfileDetail, AccountVideo, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User from auth.router import router as auth_router, users_router from auth.settings_router import router as settings_router from payments.router import router as payments_router from desktop_router import router as desktop_router from link_cards_router import router as link_cards_router, UPLOAD_DIR as LINK_CARD_UPLOAD_DIR from auth.dependencies import get_current_user, require_admin, require_write from auth.account_limits import ensure_can_add_account from auth.scopes import ( accounts_for_user, get_owned_account, get_accessible_rule, logs_for_user, rules_for_user, owned_account_ids, received_logs_for_user, system_logs_for_user, ) from auth.roles import is_admin from auth.passwords import hash_password from rpa_engine.batch_start import BatchStartQueue from rpa_engine.playwright_worker import DouyinWorker from rpa_engine.douyin_im.session import DouyinImSession from rpa_engine.douyin_im.http_client import DouyinImHttpClient from rpa_engine.douyin_im.conv_util import build_conversation_id, normalize_conversation_id, resolve_peer_uid from rpa_engine.douyin_im.message_content import ( message_preview, normalize_outgoing_content, parse_stored_content, ) from rpa_engine.credential import ( assess_account_credential, build_im_session_from_storage, build_cookie_credential_detail, ) from utils.cookie_store import ( write_cookie_file, read_cookie_file, clear_cookie_file, get_cookie_path, cookie_summary, validate_cookie_json, analyze_cookie, ) from rpa_engine.device_profiles import list_device_profiles, profile_label_for_ua, resolve_user_agent logger = logging.getLogger("main") from utils import system_logger app = FastAPI(title="抖音多账号自动回复管理系统 API") app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # RPA 任务管理器 class WorkerManager: def __init__(self): self.workers = {} # account_id -> DouyinWorker self._account_locks: dict[int, asyncio.Lock] = {} self._preparation_locks: dict[int, asyncio.Lock] = {} def _account_lock(self, account_id: int) -> asyncio.Lock: return self._account_locks.setdefault(int(account_id), asyncio.Lock()) def preparation_lock(self, account_id: int) -> asyncio.Lock: """Serialize credential preparation across single and batch starts.""" return self._preparation_locks.setdefault(int(account_id), asyncio.Lock()) async def start_worker(self, account_id: int, login_mode: str = "auto"): async with self._account_lock(account_id): if account_id in self.workers: worker = self.workers[account_id] if worker.is_running: return False del self.workers[account_id] worker = DouyinWorker(account_id, login_mode=login_mode) self.workers[account_id] = worker await worker.start() return True async def stop_worker(self, account_id: int): async with self._account_lock(account_id): if account_id in self.workers: worker = self.workers[account_id] await worker.stop() if self.workers.get(account_id) is worker: del self.workers[account_id] return True return False def is_running(self, account_id: int) -> bool: worker = self.workers.get(account_id) return worker.is_running if worker else False manager = WorkerManager() UPLOAD_DIR = os.path.join(os.path.dirname(__file__), "uploads", "messages") os.makedirs(UPLOAD_DIR, exist_ok=True) app.mount("/api/media/messages", StaticFiles(directory=UPLOAD_DIR), name="message-media") os.makedirs(LINK_CARD_UPLOAD_DIR, exist_ok=True) app.mount("/api/media/link-cards", StaticFiles(directory=LINK_CARD_UPLOAD_DIR), name="link-card-media") app.include_router(auth_router) app.include_router(users_router) app.include_router(settings_router) app.include_router(payments_router) app.include_router(desktop_router) app.include_router(link_cards_router) @app.get("/help/credential-tool", include_in_schema=False) async def help_credential_tool_page(): """凭证采集工具(Playwright storage_state 本地生成页)。""" return serve_credential_tool() @app.get("/api/help/credential-tool", include_in_schema=False) async def api_help_credential_tool_page(): """同上,兼容前端开发代理(/api -> 8800)。""" return serve_credential_tool() @app.get("/api/health") async def health_check(): """健康检查,供宝塔/Nginx/监控探活。""" web_ready = STATIC_DIR.is_dir() and (STATIC_DIR / "index.html").is_file() return { "status": "ok", "web_ui": web_ready, "serve_web": SERVE_WEB, } _MEDIA_PROXY_HOSTS = ( "douyinpic.com", "douyin.com", "byteimg.com", "ibyteimg.com", "snssdk.com", "amemv.com", "douyinstatic.com", "bytednsdoc.com", ) try: _MEDIA_PROXY_MAX_BYTES = max( 1024 * 1024, int(os.getenv("KEFU_MEDIA_PROXY_MAX_BYTES", str(20 * 1024 * 1024))), ) except (TypeError, ValueError): _MEDIA_PROXY_MAX_BYTES = 20 * 1024 * 1024 def _is_allowed_media_host(hostname: str) -> bool: host = str(hostname or "").lower().rstrip(".") return any(host == allowed or host.endswith(f".{allowed}") for allowed in _MEDIA_PROXY_HOSTS) @app.get("/api/media/proxy") async def proxy_media( url: str = Query(..., min_length=8, max_length=4096), ): """代理抖音 CDN 图片/媒体,解决浏览器 Referer 限制导致无法预览。 说明:浏览器 /