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, case
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 限制导致无法预览。
说明:浏览器
/