import os
import sys
import json
import asyncio
import ipaddress
import logging
import time
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, or_, cast, String
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_roles_table as _migrate_roles_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, roles_router
from auth.settings_router import router as settings_router
from auth.role_service import seed_builtin_roles
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_accounts_cookie,
require_accounts_create,
require_accounts_delete,
require_accounts_start,
require_accounts_stop,
require_accounts_update,
require_messages_write,
require_rules_write,
require_system_logs_clear,
)
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 has_global_scope, has_permission, is_admin
from auth.permissions import LOGS_READ, RECEIVED_MESSAGES_READ, SYSTEM_LOGS_READ
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,
extract_user_agent_from_cookie_data,
)
from rpa_engine.device_profiles import list_device_profiles, profile_label_for_ua, resolve_user_agent
from rpa_engine.egress_channels import (
clamp_attempts,
discover_egress_channels,
)
logger = logging.getLogger("main")
def _ui_conversation_page_budget() -> int:
"""用户点开会话列表时允许翻的收件箱页数。
抖音收件箱按游标分页,一次请求只给一页(实测每页约 100-500KB);某账号翻
6 页拿到 35 个会话仍未翻完。所以这里必须有预算:只拿一页会把「其中一页」
当成完整列表,不设上限又可能为一次点击拉下好几 MB。
默认 3 页只是个折中——真正花多少流量换多完整的列表是业务取舍,
用 KEFU_UI_CONVERSATION_PAGES 调整;翻不完时仍会并入本地历史,
并且不会把残缺列表伪装成完整列表。
"""
try:
value = int(os.getenv("KEFU_UI_CONVERSATION_PAGES", "3") or 3)
except (TypeError, ValueError):
value = 3
return max(1, min(20, value))
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 任务管理器
# 自动重登录防抖:同账号 30 分钟内最多触发一次,防止「扫码失败→失效→再重登录」死循环
_AUTO_RELOGIN_COOLDOWN = float(os.getenv("KEFU_AUTO_RELOGIN_COOLDOWN", "1800") or 1800)
class WorkerManager:
def __init__(self):
self.workers = {} # account_id -> DouyinWorker
self._account_locks: dict[int, asyncio.Lock] = {}
self._preparation_locks: dict[int, asyncio.Lock] = {}
self._auto_relogin_tasks: dict[int, asyncio.Task] = {}
self._last_auto_relogin_at: dict[int, float] = {}
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",
*,
wait_until_ready: bool = False,
credential_prevalidated: bool = False,
):
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,
credential_prevalidated=credential_prevalidated,
# 登录态失效(KICK/INVALID_REQUEST/用户未登录)时自动重登录:
# 重新以 browser 模式拉起 worker,浏览器探测未登录 → 弹二维码
# → 用户扫码 → 自动采集凭证并恢复托管。
relogin_hook=self._schedule_auto_relogin,
)
self.workers[account_id] = worker
await worker.start()
if not wait_until_ready:
return True
try:
await worker.wait_until_ready()
except asyncio.CancelledError:
# Batch timeout/cancellation must not leave a detached worker
# continuing to initialize after its queue slot was released.
async with self._account_lock(account_id):
if self.workers.get(account_id) is worker:
try:
await worker.stop()
except Exception:
logger.exception(
"Failed to stop cancelled startup for account %s",
account_id,
)
finally:
if self.workers.get(account_id) is worker:
self.workers.pop(account_id, None)
raise
except Exception:
# A normal initialization failure marks the account error inside
# the worker. Give that task a short chance to finish its status
# write before removing it; cancelling immediately would overwrite
# the useful error with an offline state.
task = getattr(worker, "_task", None)
if task and not task.done():
try:
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
except (asyncio.TimeoutError, asyncio.CancelledError):
try:
await worker.stop()
except Exception:
logger.exception(
"Failed to stop unsuccessful startup for account %s",
account_id,
)
except Exception:
pass
async with self._account_lock(account_id):
if self.workers.get(account_id) is worker:
self.workers.pop(account_id, None)
raise
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
async def _schedule_auto_relogin(self, account_id: int) -> None:
"""登录态失效后由 worker 回调:防抖 + 后台异步执行自动重登录。
注意:本方法在 service 的发送协程里被 await,必须快速返回,
实际的浏览器重登录流程放到独立 task 中执行。
"""
now = time.monotonic()
last = self._last_auto_relogin_at.get(account_id, 0.0)
if now - last < _AUTO_RELOGIN_COOLDOWN:
logger.info(
f"Account {account_id}: auto relogin skipped "
f"(cooldown {_AUTO_RELOGIN_COOLDOWN}s)"
)
return
self._last_auto_relogin_at[account_id] = now
prev = self._auto_relogin_tasks.get(account_id)
if prev and not prev.done():
logger.info(f"Account {account_id}: auto relogin already in progress")
return
task = asyncio.create_task(
self._auto_relogin_account(account_id),
name=f"auto-relogin-{account_id}",
)
self._auto_relogin_tasks[account_id] = task
def _cleanup(done_task: asyncio.Task) -> None:
if self._auto_relogin_tasks.get(account_id) is done_task:
self._auto_relogin_tasks.pop(account_id, None)
task.add_done_callback(_cleanup)
async def _auto_relogin_account(self, account_id: int) -> None:
"""自动重登录:等旧 worker 退出,置 logging_in,以 browser 模式重启。
新 worker 的浏览器流程会先探测页面登录态:未登录则自动弹二维码
(qr_code_base64 写库,前端账号卡片轮询展示),用户扫码成功后自动
采集 IM 凭证并恢复托管;登录超时/失败则回落到 offline 等人工处理。
"""
try:
# 1) 等旧 worker 完全退出(on_im_session_invalid 已置 is_running=False,
# _run_loop 收尾需要一点时间)
for _ in range(100):
worker = self.workers.get(account_id)
if worker is None or not worker.is_running:
break
await asyncio.sleep(0.2)
# 2) 置 logging_in(前端显示「等待扫码」,二维码由新 worker 生成)
async with AsyncSessionLocal() as db:
account = (
await db.execute(
select(Account).where(Account.id == account_id)
)
).scalar_one_or_none()
if account is None:
return
account.status = "logging_in"
account.qr_code_base64 = None
account.error_message = None
await db.commit()
system_logger.record(
"登录态失效,正在自动重登录",
detail=(
"系统检测到抖音登录态失效,已自动打开登录流程。"
"请留意账号卡片上的二维码,用抖音 App 扫码后托管将自动恢复。"
),
level="warning",
category="auth",
account_id=account_id,
)
# 3) 以 browser 模式重启:浏览器探测未登录 → 弹二维码 → 扫码 → 恢复托管。
# 不等待就绪(wait_until_ready=False),让新 worker 自行走完整登录流程。
await self.start_worker(account_id, login_mode="browser")
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error(f"Account {account_id}: auto relogin failed: {exc}")
try:
async with AsyncSessionLocal() as db:
await db.execute(
update(Account)
.where(Account.id == account_id)
.values(
status="offline",
error_message=f"自动重登录失败:{exc}",
)
)
await db.commit()
except Exception:
pass
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(roles_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 限制导致无法预览。
说明:浏览器
/