Files
dy/backend/main.py
T
2026-07-28 15:04:17 +08:00

2881 lines
102 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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_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",
*,
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,
)
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
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 限制导致无法预览。
说明:浏览器 <img>/<audio> 标签直接请求该接口时无法携带 JWT,故此处不做登录鉴权;
安全性由下方的「仅允许抖音 CDN 域名」白名单保证,不会被当作通用代理滥用。
"""
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(status_code=400, detail="无效媒体地址")
if not _is_allowed_media_host(parsed.hostname or ""):
raise HTTPException(status_code=400, detail="不支持的媒体域名")
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://www.douyin.com/",
}
try:
from rpa_engine.douyin_im.traffic_control import get_traffic_controller
async with get_traffic_controller().media_proxy_slot():
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
async with client.stream("GET", url, headers=headers) as resp:
resp.raise_for_status()
if not _is_allowed_media_host(resp.url.host or ""):
raise HTTPException(status_code=400, detail="媒体地址跳转到非白名单域名")
content_length = int(resp.headers.get("content-length") or 0)
if content_length > _MEDIA_PROXY_MAX_BYTES:
raise HTTPException(status_code=413, detail="媒体文件过大,无法代理预览")
content_type = resp.headers.get("content-type") or ""
content_buffer = bytearray()
async for chunk in resp.aiter_bytes():
content_buffer.extend(chunk)
if len(content_buffer) > _MEDIA_PROXY_MAX_BYTES:
raise HTTPException(status_code=413, detail="媒体文件过大,无法代理预览")
content = bytes(content_buffer)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"媒体加载失败: {exc}") from exc
# 抖音 IM 图片 CDN 常返回 application/octet-stream,浏览器 <img> 不渲染;
# 按文件头嗅探真实类型并改正,否则前端只显示破图。
if not content_type or "octet-stream" in content_type.lower() or content_type.lower().startswith("application/"):
sniffed = _sniff_media_type(content)
if sniffed:
content_type = sniffed
elif not content_type:
content_type = "application/octet-stream"
return Response(content=content, media_type=content_type)
def _sniff_media_type(data: bytes) -> str:
if not data or len(data) < 12:
return ""
if data[:3] == b"\xff\xd8\xff":
return "image/jpeg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if data[:4] in (b"GIF8",):
return "image/gif"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
if data[:2] == b"BM":
return "image/bmp"
if data[:4] == b"\x00\x00\x01\x00":
return "image/x-icon"
# 音频/视频常见
if data[4:8] == b"ftyp":
return "video/mp4"
if data[:3] == b"ID3" or data[:2] == b"\xff\xfb":
return "audio/mpeg"
if data[:4] == b"OggS":
return "audio/ogg"
return ""
# 系统诊断日志:内存缓冲区 -> 数据库的后台落库任务
_system_log_flush_task: Optional[asyncio.Task] = None
async def _seed_system_logs():
"""启动时把最近的系统日志读回内存缓冲区。"""
try:
async with AsyncSessionLocal() as db:
stmt = select(SystemLog).order_by(SystemLog.id.desc()).limit(500)
rows = (await db.execute(stmt)).scalars().all()
system_logger.seed([
{
"id": r.id,
"account_id": r.account_id,
"level": r.level,
"category": r.category,
"event": r.event,
"detail": r.detail or "",
"created_at": (r.created_at or datetime.utcnow()).isoformat(),
}
for r in rows
])
except Exception as e:
print(f"Seed system logs failed: {e}")
async def _flush_system_logs_loop():
"""周期性地把内存中的诊断日志落库以便持久化。"""
while True:
try:
await asyncio.sleep(2)
pending = system_logger.drain_pending()
if not pending:
continue
async with AsyncSessionLocal() as db:
for e in pending:
try:
created = datetime.fromisoformat(e["created_at"])
except Exception:
created = datetime.utcnow()
db.add(SystemLog(
account_id=e.get("account_id"),
level=e.get("level") or "info",
category=e.get("category") or "system",
event=(e.get("event") or "")[:255],
detail=e.get("detail") or "",
created_at=created,
))
await db.commit()
except asyncio.CancelledError:
break
except Exception as exc:
print(f"Flush system logs failed: {exc}")
async def _sync_legacy_cookie_files():
"""将旧版仅保存在文件的 Cookie 同步到数据库"""
async with AsyncSessionLocal() as db:
# Only legacy rows without a database Cookie need filesystem work.
# Selecting full Account entities used to hydrate every large
# cookie_data / im_session_data value on each process start, which is
# especially expensive with hundreds of hosted accounts.
result = await db.execute(
select(Account.id).where(
Account.cookie_data.is_(None) | (Account.cookie_data == "")
)
)
account_ids = list(result.scalars().all())
changed = False
for account_id in account_ids:
file_data = read_cookie_file(int(account_id))
if file_data:
await db.execute(
update(Account)
.where(Account.id == int(account_id))
.values(
cookie_data=file_data,
cookie_path=get_cookie_path(int(account_id)),
cookie_updated_at=datetime.utcnow(),
)
)
changed = True
if changed:
await db.commit()
def _account_has_cookie(account: Account) -> bool:
if account.cookie_data:
return True
if account.cookie_path and os.path.exists(account.cookie_path):
return True
return os.path.exists(get_cookie_path(account.id))
def _build_account_im_session(account: Account) -> DouyinImSession:
cookie_data = _get_account_cookie_data(account)
storage = json.loads(cookie_data) if cookie_data else {}
session = build_im_session_from_storage(storage, account.im_session_data)
session.user_agent = resolve_user_agent(account.user_agent or session.user_agent)
return session
def _resolve_log_peer_id(session: DouyinImSession, conversation_id: str) -> str:
"""手动发送日志统一存粉丝 UID,便于前端按 peer 合并会话。"""
my_uid = int(session.my_uid or 0)
if not my_uid:
try:
from rpa_engine.douyin_im.auth import DouyinAuth
auth = DouyinAuth.from_im_session(session)
my_uid = int(auth.get_uid() or 0)
except Exception:
my_uid = 0
peer_uid = resolve_peer_uid(conversation_id, my_uid)
if peer_uid:
return str(peer_uid)
return (conversation_id or "").strip()
async def _conversations_from_logs(
db: AsyncSession,
account_id: int,
my_uid: int = 0,
) -> list[dict]:
"""从消息日志聚合会话列表(兜底)。"""
stmt = (
select(MessageLog)
.where(MessageLog.account_id == account_id)
.order_by(MessageLog.created_at.desc())
.limit(500)
)
result = await db.execute(stmt)
logs = result.scalars().all()
seen: dict[str, dict] = {}
for log in logs:
if log.sender_name == "[系统发送]":
continue
raw_id = (log.sender_id or "").strip()
name = (log.sender_name or "").strip()
conv_id = normalize_conversation_id(raw_id, my_uid) if my_uid else raw_id
if not conv_id and name.isdigit() and my_uid:
conv_id = build_conversation_id(my_uid, int(name))
if not conv_id and name.isdigit():
conv_id = name
key = conv_id or name
peer_uid = raw_id if raw_id.isdigit() else ""
if not peer_uid and conv_id and my_uid:
from rpa_engine.douyin_im.conv_util import resolve_peer_uid
resolved = resolve_peer_uid(conv_id, int(my_uid))
if resolved:
peer_uid = str(resolved)
if not key or key in seen:
continue
seen[key] = {
"conversation_id": conv_id,
"sender_name": name or (f"用户{conv_id[-6:]}" if conv_id.isdigit() else "未知用户"),
"sender_id": peer_uid or conv_id or raw_id or None,
"peer_uid": peer_uid,
"sender_avatar": (log.sender_avatar or "").strip() or None,
"content": message_preview(log.message_content or ""),
"unread_count": 0,
}
return list(seen.values())
async def _reset_account_credentials(account_id: int, db: AsyncSession) -> Account:
"""清除账号 Cookie、IM 会话等登录数据,并停止托管。"""
await manager.stop_worker(account_id)
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
clear_cookie_file(account_id)
account.cookie_data = None
account.cookie_path = None
account.cookie_updated_at = None
account.im_session_data = None
account.qr_code_base64 = None
account.error_message = None
account.status = "offline"
account.updated_at = datetime.utcnow()
await db.execute(
update(AccountProfileDetail)
.where(AccountProfileDetail.account_id == account_id)
.values(sec_user_id=None, synced_at=None)
)
await db.commit()
await db.refresh(account)
return account
async def _persist_im_session_data(account_id: int, session: DouyinImSession, db: AsyncSession) -> None:
payload = json.dumps(session.to_dict(), ensure_ascii=False)
await db.execute(
update(Account)
.where(Account.id == account_id)
.values(im_session_data=payload, updated_at=datetime.utcnow())
)
await db.commit()
async def _seed_app_config():
from auth.system_settings import ensure_default_settings
async with AsyncSessionLocal() as db:
await ensure_default_settings(db)
async def _seed_admin_user():
"""首次启动创建默认管理员,并将无归属抖音账号划归管理员。"""
admin_password = os.getenv("KEFU_ADMIN_PASSWORD", "admin123")
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.username == "admin"))
admin = result.scalar_one_or_none()
if not admin:
admin = User(
username="admin",
password_hash=hash_password(admin_password),
display_name="系统管理员",
role="admin",
is_active=True,
email_verified=True,
max_accounts=-1,
)
db.add(admin)
await db.commit()
await db.refresh(admin)
print(f"Default admin created: username=admin password={admin_password}")
await db.execute(
update(Account).where(Account.owner_id.is_(None)).values(owner_id=admin.id)
)
await db.commit()
# 初始化数据库
@app.on_event("startup")
async def startup():
# a_bogus/web_protect/ts_sign 仍需在线程池中执行阻塞的 Node 调用,但账号启动、
# 后台请求和发送通道都已有独立并发限制,因此线程池按 CPU 有界配置即可。
import concurrent.futures as _futures
try:
_pool_size = int(os.getenv("KEFU_THREAD_POOL_SIZE", "0") or 0)
except ValueError:
_pool_size = 0
if _pool_size <= 0:
# WebSocket connections are fully asynchronous now. The executor is
# only for short signing / compatibility calls, whose network lanes
# are already bounded. Keeping 64 threads on a 2-core host increases
# context switching and swap pressure without adding throughput.
_pool_size = max(8, min(32, (os.cpu_count() or 1) * 4))
loop = asyncio.get_running_loop()
loop.set_default_executor(
_futures.ThreadPoolExecutor(
max_workers=_pool_size,
thread_name_prefix="kefu-sign",
)
)
print(f"[kefu] thread pool size = {_pool_size}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(_migrate_accounts_table)
await conn.run_sync(_migrate_rules_table)
await conn.run_sync(_migrate_message_logs_table)
await conn.run_sync(_migrate_account_videos_table)
await conn.run_sync(_migrate_users_table)
await conn.run_sync(_migrate_payment_orders_table)
await conn.run_sync(_migrate_accounts_quota_disabled)
await _seed_app_config()
await _seed_admin_user()
# 进程启动时没有任何内存 Worker;复位异常退出遗留的运行状态。
# 同时,账号数量/并发限制已移除,清理历史“额度停用”标记。
async with AsyncSessionLocal() as db:
await db.execute(
update(Account)
.where(Account.status.in_(("online", "logging_in", "starting")))
.values(status="offline")
)
await db.execute(
update(Account)
.where(Account.quota_disabled.is_(True))
.values(quota_disabled=False)
)
await db.commit()
await _sync_legacy_cookie_files()
await _seed_system_logs()
global _system_log_flush_task
_system_log_flush_task = asyncio.create_task(_flush_system_logs_loop())
db_info = database_config_to_response()
print(f"Database tables created successfully ({db_info['db_type']}: {db_info['database_url_display']}).")
print("Douyin RPA backend ready (login: wait-for-scan + auto-save cookie).")
@app.on_event("shutdown")
async def shutdown():
# Stop accounts concurrently with global deadlines. Sequentially waiting
# for hundreds of WebSocket close handshakes can otherwise turn a normal
# deployment restart into a many-minute outage.
try:
stop_concurrency = max(
1,
min(64, int(os.getenv("KEFU_SHUTDOWN_CONCURRENCY", "32") or 32)),
)
except ValueError:
stop_concurrency = 32
try:
shutdown_timeout = max(
5.0,
min(
180.0,
float(os.getenv("KEFU_SHUTDOWN_TIMEOUT_SECONDS", "60") or 60),
),
)
except ValueError:
shutdown_timeout = 60.0
try:
batch_stop_timeout = max(
1.0,
min(
30.0,
float(os.getenv("KEFU_BATCH_STOP_TIMEOUT_SECONDS", "10") or 10),
),
)
except ValueError:
batch_stop_timeout = 10.0
# Stop queued preparations first so no new workers appear while the
# existing workers are being drained below. Its cancellation path may
# itself wait for a half-open login/DB operation, so it needs an
# independent deadline; the remaining workers are still covered by the
# bounded parallel stop below.
try:
await asyncio.wait_for(
batch_start_queue.stop(),
timeout=batch_stop_timeout,
)
except asyncio.TimeoutError:
logger.warning(
"Batch-start queue shutdown exceeded %.1fs; continuing with worker drain",
batch_stop_timeout,
)
stop_gate = asyncio.Semaphore(stop_concurrency)
async def _stop_account(account_id: int) -> None:
async with stop_gate:
try:
await manager.stop_worker(account_id)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Failed to stop account %s during shutdown", account_id)
stop_tasks = [
asyncio.create_task(
_stop_account(account_id),
name=f"shutdown-account-{account_id}",
)
for account_id in list(manager.workers.keys())
]
if stop_tasks:
try:
await asyncio.wait_for(
asyncio.gather(*stop_tasks),
timeout=shutdown_timeout,
)
except asyncio.TimeoutError:
logger.warning(
"Account shutdown exceeded %.1fs; cancelling remaining tasks",
shutdown_timeout,
)
for task in stop_tasks:
if not task.done():
task.cancel()
await asyncio.gather(*stop_tasks, return_exceptions=True)
from rpa_engine.douyin_im.service import _shutdown_initial_unread_dispatcher
try:
await asyncio.wait_for(
_shutdown_initial_unread_dispatcher(),
timeout=5.0,
)
except asyncio.TimeoutError:
logger.warning("Initial-unread dispatcher shutdown exceeded 5s")
from rpa_engine.douyin_im.traffic_control import shutdown_traffic_controller
await shutdown_traffic_controller()
if _system_log_flush_task:
_system_log_flush_task.cancel()
try:
await _system_log_flush_task
except asyncio.CancelledError:
pass
# 退出前把剩余日志落库
pending = system_logger.drain_pending()
if pending:
async with AsyncSessionLocal() as db:
for e in pending:
try:
created = datetime.fromisoformat(e["created_at"])
except Exception:
created = datetime.utcnow()
db.add(SystemLog(
account_id=e.get("account_id"),
level=e.get("level") or "info",
category=e.get("category") or "system",
event=(e.get("event") or "")[:255],
detail=e.get("detail") or "",
created_at=created,
))
await db.commit()
print("All RPA workers stopped.")
# --- Pydantic 模型的定义 ---
class AccountCreate(BaseModel):
phone: Optional[str] = None
cookie_data: Optional[str] = None
class AccountResponse(BaseModel):
id: int
username: Optional[str] = None
avatar_url: Optional[str] = None
douyin_uid: Optional[str] = None
phone: Optional[str] = None
status: str
error_message: Optional[str] = None
has_cookie: bool = False
cookie_valid: bool = False
cookie_expired: bool = False
cookie_status: Optional[str] = None
cookie_updated_at: Optional[datetime] = None
cookie_count: int = 0
has_sessionid: bool = False
reply_delay_seconds: Optional[int] = None
reply_delay_effective: int = 0
reply_cooldown_seconds: Optional[int] = None
reply_cooldown_effective: int = 0
follow_welcome_enabled: bool = False
follow_welcome_content: Optional[str] = None
user_agent: Optional[str] = None
user_agent_label: Optional[str] = None
quota_disabled: bool = False
class Config:
from_attributes = True
class AccountOptionResponse(BaseModel):
"""Small account payload used by selectors on non-account pages.
Keeping this separate from ``AccountResponse`` prevents account dropdowns
from loading and parsing every account's Cookie and IM session blobs.
"""
id: int
username: Optional[str] = None
avatar_url: Optional[str] = None
douyin_uid: Optional[str] = None
phone: Optional[str] = None
status: str
has_cookie: bool = False
reply_cooldown_seconds: Optional[int] = None
reply_cooldown_effective: int = 0
quota_disabled: bool = False
class DashboardAccountStatsResponse(BaseModel):
"""Safe account totals shown to every authenticated dashboard user."""
total_accounts: int = 0
online_accounts: int = 0
my_accounts: int = 0
my_online_accounts: int = 0
class AccountUpdate(BaseModel):
phone: Optional[str] = None
username: Optional[str] = None
# 正数=账号专属排队间隔;0/null=未单独配置,继承系统默认
reply_delay_seconds: Optional[int] = None
# >=0 设为该账号专属冷却秒数;<0(如 -1)恢复为继承全局设置
reply_cooldown_seconds: Optional[int] = None
follow_welcome_enabled: Optional[bool] = None
follow_welcome_content: Optional[str] = None
user_agent: Optional[str] = None
class ReplyQueueItemResponse(BaseModel):
job_id: str
account_id: int
position: int
status: str
expedited: bool = False
description: str = ""
sender_name: str = "未知用户"
sender_id: Optional[str] = None
sender_avatar: Optional[str] = None
conversation_id: Optional[str] = None
incoming_content: str = ""
incoming_contents: List[str] = []
message_count: int = 1
replies: List[str] = []
interval_seconds: int = 0
enqueued_at: datetime
scheduled_at: datetime
remaining_seconds: int = 0
class ReplyQueueResponse(BaseModel):
account_id: int
running: bool = False
pending_count: int = 0
interval_seconds: int = 0
global_send_interval_seconds: float = 1.0
global_send_pending_count: int = 0
global_account_pending_count: int = 0
global_active_account_id: Optional[int] = None
server_time: datetime
items: List[ReplyQueueItemResponse] = []
class ReplyQueueSummaryItem(BaseModel):
account_id: int
running: bool = False
pending_count: int = 0
next_scheduled_at: Optional[datetime] = None
next_remaining_seconds: int = 0
class ReplyQueueSummaryResponse(BaseModel):
total_pending: int = 0
server_time: datetime
items: List[ReplyQueueSummaryItem] = []
class ReplyQueueActionResponse(BaseModel):
job_id: str
status: str
message: str
shifted_count: int = 0
class AccountCookieUpdate(BaseModel):
cookie_data: str
class AccountCookieResponse(BaseModel):
account_id: int
cookie_data: Optional[str] = None
cookie_updated_at: Optional[datetime] = None
cookie_count: int = 0
cookie_valid: bool = False
cookie_expired: bool = False
cookie_status: Optional[str] = None
expires_at: Optional[str] = None
key_names: List[str] = []
has_sessionid: bool = False
sessionid: Optional[str] = None
sessionid_ss: Optional[str] = None
im_ready: bool = False
im_status: Optional[str] = None
can_skip_browser: bool = False
should_reset: bool = False
class AccountVideoItem(BaseModel):
id: int
aweme_id: str
title: str = ""
cover_url: Optional[str] = None
video_url: Optional[str] = None
share_url: Optional[str] = None
play_url: Optional[str] = None
create_time: Optional[str] = None
digg_count: Optional[int] = None
comment_count: Optional[int] = None
play_count: Optional[int] = None
media_type: Optional[str] = None
class AccountProfileDetailResponse(BaseModel):
account_id: int
uid: Optional[str] = None
nickname: Optional[str] = None
avatar_url: Optional[str] = None
unique_id: Optional[str] = None
signature: Optional[str] = None
sec_user_id: Optional[str] = None
profile_url: Optional[str] = None
video_count: Optional[int] = None
video_count_douyin: Optional[int] = None
cached_work_count: int = 0
playable_video_count: int = 0
video_work_count: int = 0
image_work_count: int = 0
follower_count: Optional[int] = None
following_count: Optional[int] = None
total_favorited: Optional[int] = None
favoriting_count: Optional[int] = None
fetched: bool = False
message: Optional[str] = None
synced_at: Optional[str] = None
videos: List[AccountVideoItem] = []
def _get_account_cookie_data(account: Account) -> Optional[str]:
if account.cookie_data:
return account.cookie_data
return read_cookie_file(account.id)
async def _build_cookie_response(
account: Account,
cookie_data: Optional[str] = None,
*,
runtime_check: bool = True,
) -> AccountCookieResponse:
cookie_data = cookie_data if cookie_data is not None else _get_account_cookie_data(account)
summary = cookie_summary(cookie_data)
im_detail = await build_cookie_credential_detail(
cookie_data,
account.im_session_data,
runtime_check=runtime_check,
)
return AccountCookieResponse(
account_id=account.id,
cookie_data=cookie_data,
cookie_updated_at=account.cookie_updated_at,
cookie_count=summary["cookie_count"],
cookie_valid=summary["cookie_valid"],
cookie_expired=summary["cookie_expired"],
cookie_status=summary["reason"],
expires_at=summary["expires_at"],
key_names=summary["key_names"],
has_sessionid=im_detail["has_sessionid"],
sessionid=im_detail["sessionid"] or None,
sessionid_ss=im_detail["sessionid_ss"] or None,
im_ready=im_detail["im_ready"],
im_status=im_detail["im_status"],
can_skip_browser=im_detail["can_skip_browser"],
should_reset=im_detail.get("should_reset", False),
)
def _global_cooldown_seconds() -> int:
try:
from auth.system_settings import get_cached_settings
return max(0, int(get_cached_settings().auto_reply_cooldown_seconds or 0))
except Exception:
return 0
def _global_reply_delay_seconds() -> int:
try:
from auth.system_settings import get_cached_settings
return max(0, int(get_cached_settings().auto_reply_delay_seconds or 0))
except Exception:
return 0
def _resolve_effective_reply_delay(account: Account) -> int:
account_value = max(0, int(account.reply_delay_seconds or 0))
if account_value > 0:
return account_value
return _global_reply_delay_seconds()
def _resolve_effective_cooldown(account: Account) -> int:
if account.reply_cooldown_seconds is not None:
return max(0, int(account.reply_cooldown_seconds))
return _global_cooldown_seconds()
def _build_account_response(account: Account) -> AccountResponse:
cookie_data = _get_account_cookie_data(account)
summary = cookie_summary(cookie_data)
return AccountResponse(
id=account.id,
username=account.username,
avatar_url=account.avatar_url,
douyin_uid=account.douyin_uid,
phone=account.phone,
status=account.status,
error_message=account.error_message,
has_cookie=_account_has_cookie(account),
cookie_valid=summary["cookie_valid"],
cookie_expired=summary["cookie_expired"],
cookie_status=summary["reason"],
cookie_updated_at=account.cookie_updated_at,
cookie_count=summary["cookie_count"],
has_sessionid=summary.get("has_sessionid", False),
reply_delay_seconds=(
int(account.reply_delay_seconds)
if int(account.reply_delay_seconds or 0) > 0
else None
),
reply_delay_effective=_resolve_effective_reply_delay(account),
reply_cooldown_seconds=(
int(account.reply_cooldown_seconds)
if account.reply_cooldown_seconds is not None
else None
),
reply_cooldown_effective=_resolve_effective_cooldown(account),
follow_welcome_enabled=bool(account.follow_welcome_enabled),
follow_welcome_content=account.follow_welcome_content or None,
user_agent=account.user_agent or None,
user_agent_label=profile_label_for_ua(account.user_agent),
quota_disabled=bool(account.quota_disabled),
)
class RuleCreate(BaseModel):
account_id: Optional[int] = None
keyword: str
reply_content: str
match_type: str = "contains" # exact, contains, regex, default
is_active: Optional[bool] = None # 创建/更新时可指定启用状态;None=默认启用/不修改
class RuleResponse(BaseModel):
id: int
account_id: Optional[int] = None
keyword: str
reply_content: str
match_type: str
sort_order: int = 0
is_active: bool
class Config:
from_attributes = True
class RuleReorder(BaseModel):
rule_ids: List[int]
class LogResponse(BaseModel):
id: int
account_id: int
sender_name: str
sender_id: Optional[str] = None
sender_avatar: Optional[str] = None
message_content: str
reply_content: Optional[str] = None
status: str
error_message: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class ReceivedMessageLogResponse(BaseModel):
id: int
account_id: int
conversation_id: Optional[str] = None
sender_id: Optional[str] = None
sender_name: Optional[str] = None
sender_avatar: Optional[str] = None
message_type: Optional[int] = None
server_message_id: Optional[str] = None
raw_content: str
created_at: datetime
class Config:
from_attributes = True
class SystemLogResponse(BaseModel):
id: int
account_id: Optional[int] = None
level: str
category: str
event: str
detail: str = ""
created_at: str
class ConversationResponse(BaseModel):
conversation_id: str
sender_name: str
sender_id: Optional[str] = None
sender_avatar: Optional[str] = None
content: str = ""
unread_count: int = 0
class SendMessageRequest(BaseModel):
conversation_id: str
content: str = ""
message_type: Optional[str] = None # text | image | sticker
media_url: Optional[str] = None
sticker_url: Optional[str] = None
width: Optional[int] = None
height: Optional[int] = None
sticker_id: Optional[str] = None
class UploadImageResponse(BaseModel):
url: str
width: Optional[int] = None
height: Optional[int] = None
payload: str
class SendMessageResponse(BaseModel):
success: bool
message: str
need_browser_login: bool = False
class CredentialValidateResponse(BaseModel):
has_cookie: bool = False
cookie_valid: bool = False
cookie_status: Optional[str] = None
has_sessionid: bool = False
im_ready: bool = False
can_skip_browser: bool = False
should_reset: bool = False
login_mode: str = "browser"
message: str = ""
class StartAccountRequest(BaseModel):
login_mode: Optional[str] = None # im_direct | browser | auto
class BatchStartRequest(BaseModel):
account_ids: List[int] = Field(default_factory=list)
all_accounts: bool = False
class DeviceProfileItem(BaseModel):
id: str
label: str
platform: str
user_agent: str
@app.get("/api/device-profiles", response_model=List[DeviceProfileItem])
async def get_device_profiles(user: User = Depends(get_current_user)):
"""可选的伪装设备头(User-Agent)预设列表。"""
return list_device_profiles()
# --- API 路由接口 ---
# 1. 账号管理接口
def _account_matches_keyword(account: Account, keyword: str) -> bool:
if not keyword:
return True
haystack = [
account.username,
account.douyin_uid,
account.phone,
profile_label_for_ua(account.user_agent),
account.id,
]
return any(
keyword in str(item).lower()
for item in haystack
if item is not None and item != ""
)
@app.get(
"/api/dashboard/account-stats",
response_model=DashboardAccountStatsResponse,
)
async def get_dashboard_account_stats(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return public platform totals without exposing other users' accounts."""
result = await db.execute(
select(
func.count(Account.id).label("total_accounts"),
func.coalesce(
func.sum(case((Account.status == "online", 1), else_=0)),
0,
).label("online_accounts"),
func.coalesce(
func.sum(case((Account.owner_id == user.id, 1), else_=0)),
0,
).label("my_accounts"),
func.coalesce(
func.sum(
case(
(
(Account.owner_id == user.id)
& (Account.status == "online"),
1,
),
else_=0,
)
),
0,
).label("my_online_accounts"),
)
)
row = result.one()
return DashboardAccountStatsResponse(
total_accounts=int(row.total_accounts or 0),
online_accounts=int(row.online_accounts or 0),
my_accounts=int(row.my_accounts or 0),
my_online_accounts=int(row.my_online_accounts or 0),
)
@app.get("/api/accounts")
async def get_accounts(
page: Optional[int] = Query(None, ge=1),
page_size: int = Query(9, ge=1, le=100),
q: Optional[str] = None,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""账号列表。
- 不带 page 参数:返回全量数组(旧行为,供下拉选择等场景)。
- 带 page 参数:返回 {items, total, page, page_size} 分页结构,
支持 q(昵称/抖音ID/手机号/账号ID 搜索)与 status 筛选。
Cookie 解析等重逻辑只对当前页执行。
"""
if page is None:
result = await db.execute(accounts_for_user(user))
accounts = result.scalars().all()
# 兼容旧的全量接口行为。
for acc in accounts:
is_running = manager.is_running(acc.id)
if is_running and acc.status == "offline":
acc.status = "online"
elif not is_running and acc.status in ("online", "logging_in", "starting"):
acc.status = "offline"
return [_build_account_response(acc) for acc in accounts]
keyword = (q or "").strip().lower()
status_filter = (status or "").strip()
stmt = accounts_for_user(user)
running_account_ids = [
int(account_id)
for account_id, worker in list(manager.workers.items())
if worker and worker.is_running
]
runtime_running = Account.id.in_(running_account_ids)
effective_status = case(
(
runtime_running & (Account.status == "offline"),
"online",
),
(
(~runtime_running)
& Account.status.in_(("online", "logging_in", "starting")),
"offline",
),
else_=Account.status,
)
if keyword:
like_value = f"%{keyword}%"
search_conditions = [
func.lower(Account.username).like(like_value),
func.lower(Account.douyin_uid).like(like_value),
func.lower(Account.phone).like(like_value),
func.lower(Account.user_agent).like(like_value),
cast(Account.id, String).like(like_value),
]
matching_profiles = [
profile
for profile in list_device_profiles()
if keyword in str(profile.get("label") or "").lower()
or keyword in str(profile.get("platform") or "").lower()
]
if matching_profiles:
matching_uas = [profile["user_agent"] for profile in matching_profiles]
search_conditions.append(Account.user_agent.in_(matching_uas))
if any(profile.get("id") == "chrome_win120" for profile in matching_profiles):
search_conditions.append(Account.user_agent.is_(None))
stmt = stmt.where(or_(*search_conditions))
if status_filter and status_filter != "all":
if status_filter == "quota_disabled":
stmt = stmt.where(Account.quota_disabled.is_(True))
else:
stmt = stmt.where(
Account.quota_disabled.is_not(True),
effective_status == status_filter,
)
total_result = await db.execute(
stmt.with_only_columns(func.count(Account.id)).order_by(None)
)
total = int(total_result.scalar_one() or 0)
result = await db.execute(
stmt.order_by(Account.id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = result.scalars().all()
# Only reconcile the current page. The old implementation hydrated every
# account including large Cookie/IM blobs on every refresh, which became
# visibly slow beyond a few hundred accounts.
for acc in items:
is_running = manager.is_running(acc.id)
if is_running and acc.status == "offline":
acc.status = "online"
elif not is_running and acc.status in ("online", "logging_in", "starting"):
acc.status = "offline"
return {
"items": [_build_account_response(acc) for acc in items],
"total": total,
"page": page,
"page_size": page_size,
}
@app.get("/api/account-options", response_model=List[AccountOptionResponse])
async def get_account_options(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return only fields required by account dropdowns.
The legacy unpaginated ``/api/accounts`` endpoint hydrates large Cookie and
IM session columns and then parses Cookie JSON for every account. With
hundreds of accounts that makes simply opening Messages, Rules or Logs
expensive. This query deliberately selects only short scalar fields.
"""
has_db_cookie = case(
(
Account.cookie_data.is_not(None)
& (Account.cookie_data != ""),
True,
),
else_=False,
).label("has_db_cookie")
stmt = select(
Account.id,
Account.username,
Account.avatar_url,
Account.douyin_uid,
Account.phone,
Account.status,
Account.cookie_path,
has_db_cookie,
Account.reply_cooldown_seconds,
Account.quota_disabled,
)
if not is_admin(user.role):
stmt = stmt.where(Account.owner_id == user.id)
rows = (await db.execute(stmt.order_by(Account.id.asc()))).all()
global_cooldown = _global_cooldown_seconds()
options: list[AccountOptionResponse] = []
for row in rows:
account_id = int(row.id)
is_running = manager.is_running(account_id)
runtime_status = str(row.status or "offline")
if is_running and runtime_status == "offline":
runtime_status = "online"
elif not is_running and runtime_status in ("online", "logging_in", "starting"):
runtime_status = "offline"
has_cookie = bool(row.has_db_cookie)
if not has_cookie and row.cookie_path:
has_cookie = os.path.exists(str(row.cookie_path))
cooldown_override = row.reply_cooldown_seconds
options.append(
AccountOptionResponse(
id=account_id,
username=row.username,
avatar_url=row.avatar_url,
douyin_uid=row.douyin_uid,
phone=row.phone,
status=runtime_status,
has_cookie=has_cookie,
reply_cooldown_seconds=(
int(cooldown_override) if cooldown_override is not None else None
),
reply_cooldown_effective=(
max(0, int(cooldown_override))
if cooldown_override is not None
else global_cooldown
),
quota_disabled=bool(row.quota_disabled),
)
)
return options
@app.get("/api/accounts/{account_id}", response_model=AccountResponse)
async def get_account(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
account = await get_owned_account(db, user, account_id)
return _build_account_response(account)
def _douyin_profile_url(sec_user_id: Optional[str]) -> Optional[str]:
sec = (sec_user_id or "").strip()
if not sec:
return None
return f"https://www.douyin.com/user/{sec}"
def _build_profile_response(data: dict) -> AccountProfileDetailResponse:
account_id = int(data["account_id"])
videos = []
for item in data.get("videos") or []:
aweme_id = str(item.get("aweme_id") or "")
videos.append(
AccountVideoItem(
id=int(item["id"]),
aweme_id=aweme_id,
title=str(item.get("title") or ""),
cover_url=item.get("cover_url"),
video_url=item.get("video_url"),
share_url=item.get("share_url"),
play_url=f"/api/accounts/{account_id}/videos/{aweme_id}/play" if aweme_id else None,
create_time=item.get("create_time"),
digg_count=item.get("digg_count"),
comment_count=item.get("comment_count"),
play_count=item.get("play_count"),
media_type=item.get("media_type"),
)
)
return AccountProfileDetailResponse(
account_id=account_id,
uid=data.get("uid"),
nickname=data.get("nickname"),
avatar_url=data.get("avatar_url"),
unique_id=data.get("unique_id"),
signature=data.get("signature"),
sec_user_id=data.get("sec_user_id"),
profile_url=data.get("profile_url") or _douyin_profile_url(data.get("sec_user_id")),
video_count=data.get("video_count"),
video_count_douyin=data.get("video_count_douyin"),
cached_work_count=int(data.get("cached_work_count") or 0),
playable_video_count=int(data.get("playable_video_count") or 0),
video_work_count=int(data.get("video_work_count") or 0),
image_work_count=int(data.get("image_work_count") or 0),
follower_count=data.get("follower_count"),
following_count=data.get("following_count"),
total_favorited=data.get("total_favorited"),
favoriting_count=data.get("favoriting_count"),
fetched=bool(data.get("fetched")),
message=data.get("message"),
synced_at=data.get("synced_at"),
videos=videos,
)
@app.get("/api/accounts/{account_id}/profile", response_model=AccountProfileDetailResponse)
async def get_account_profile(
account_id: int,
sync: bool = False,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取托管账号详细资料与作品列表。sync=true 时从抖音同步并写入本地数据库。"""
account = await get_owned_account(db, user, account_id)
cookie_data = _get_account_cookie_data(account)
from rpa_engine.account_profile import load_account_profile_from_db, sync_account_profile_to_db
if sync:
if not cookie_data:
return AccountProfileDetailResponse(
account_id=account_id,
uid=account.douyin_uid,
nickname=account.username,
avatar_url=account.avatar_url,
fetched=False,
message="未保存 Cookie,无法从抖音同步资料",
)
try:
data = await asyncio.wait_for(
sync_account_profile_to_db(db, account, cookie_data),
timeout=35,
)
return _build_profile_response(data)
except asyncio.TimeoutError:
# 抖音接口在云服务器上偶发响应缓慢,超过 nginx 上游超时会直接 504。
# 这里限定整体时长,超时则回滚未完成的事务并返回本地缓存资料。
try:
await db.rollback()
except Exception:
pass
data = await load_account_profile_from_db(db, account)
data["message"] = (
data.get("message")
or "从抖音同步资料超时,已返回本地缓存,请稍后重试"
)
return _build_profile_response(data)
data = await load_account_profile_from_db(db, account)
if not data.get("fetched") and not cookie_data:
data["message"] = data.get("message") or "未保存 Cookie,无法拉取抖音资料"
return _build_profile_response(data)
@app.get("/api/accounts/{account_id}/videos/{aweme_id}/play")
async def stream_account_video(
account_id: int,
aweme_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""代理播放托管账号作品视频(带 Cookie / Referer)。"""
account = await get_owned_account(db, user, account_id)
video = (
await db.execute(
select(AccountVideo).where(
AccountVideo.account_id == account_id,
AccountVideo.aweme_id == aweme_id,
)
)
).scalar_one_or_none()
if not video or not video.video_url:
raise HTTPException(status_code=404, detail="视频不存在或未缓存播放地址")
cookie_data = _get_account_cookie_data(account)
headers = {
"User-Agent": account.user_agent or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": video.share_url or "https://www.douyin.com/",
"Accept": "*/*",
}
cookies: dict[str, str] = {}
if cookie_data:
try:
from rpa_engine.douyin_im.session import DouyinImSession
session = DouyinImSession.from_storage_state(json.loads(cookie_data) or {})
cookies = dict(session.cookies or {})
except Exception:
pass
async with httpx.AsyncClient(follow_redirects=True, timeout=120.0) as client:
req = client.build_request(
"GET",
video.video_url,
headers=headers,
cookies=cookies,
)
resp = await client.send(req, stream=True)
if resp.status_code >= 400:
await resp.aclose()
raise HTTPException(status_code=502, detail=f"视频拉取失败 ({resp.status_code})")
media_type = resp.headers.get("content-type") or "video/mp4"
async def body_iter():
try:
async for chunk in resp.aiter_bytes():
yield chunk
finally:
await resp.aclose()
return StreamingResponse(body_iter(), media_type=media_type)
@app.put("/api/accounts/{account_id}", response_model=AccountResponse)
async def update_account(
account_id: int,
body: AccountUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
account = await get_owned_account(db, user, account_id, write=True)
follow_config_changed = bool(
{"follow_welcome_enabled", "follow_welcome_content"}
& set(body.model_fields_set)
)
if body.phone is not None:
account.phone = body.phone
if body.username is not None:
account.username = body.username
if "reply_delay_seconds" in body.model_fields_set:
# 兼容旧列:0 作为“未设置”,由系统默认值兜底;显式 null 同样清除账号值。
account.reply_delay_seconds = max(0, int(body.reply_delay_seconds or 0))
if body.reply_cooldown_seconds is not None:
# 负值表示恢复继承全局设置
account.reply_cooldown_seconds = (
None if int(body.reply_cooldown_seconds) < 0 else max(0, int(body.reply_cooldown_seconds))
)
if body.follow_welcome_enabled is not None:
account.follow_welcome_enabled = bool(body.follow_welcome_enabled)
if body.follow_welcome_content is not None:
account.follow_welcome_content = (body.follow_welcome_content or "").strip() or None
if body.user_agent is not None:
ua = (body.user_agent or "").strip()
account.user_agent = ua or None
account.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(account)
if follow_config_changed:
worker = manager.workers.get(account_id)
invalidate = getattr(worker, "invalidate_follow_welcome_config", None)
if callable(invalidate):
invalidate()
return _build_account_response(account)
@app.get("/api/reply-queues", response_model=ReplyQueueSummaryResponse)
async def get_reply_queue_summaries(
account_ids: Optional[str] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""聚合可见账号的排队数量;账号页可限定为当前页账号。"""
requested_ids: list[int] | None = None
if account_ids is not None:
requested_ids = []
seen_ids: set[int] = set()
for raw in str(account_ids).split(","):
token = raw.strip()
if not token:
continue
try:
account_id = int(token)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="账号编号格式错误")
if account_id > 0 and account_id not in seen_ids:
seen_ids.add(account_id)
requested_ids.append(account_id)
if len(requested_ids) > 100:
raise HTTPException(status_code=400, detail="单次最多查询 100 个账号的回复队列")
allowed_ids: set[int] | None
if requested_ids is None:
allowed_ids = await owned_account_ids(db, user)
worker_entries = list(manager.workers.items())
else:
if is_admin(user.role):
allowed_ids = set(requested_ids)
elif requested_ids:
owned_result = await db.execute(
select(Account.id).where(
Account.owner_id == user.id,
Account.id.in_(requested_ids),
)
)
allowed_ids = {int(row[0]) for row in owned_result.all()}
else:
allowed_ids = set()
worker_entries = [
(account_id, manager.workers.get(account_id))
for account_id in requested_ids
if account_id in allowed_ids and account_id in manager.workers
]
summaries: list[ReplyQueueSummaryItem] = []
total_pending = 0
for account_id, worker in worker_entries:
if allowed_ids is not None and account_id not in allowed_ids:
continue
service = worker._im_service if worker else None
if not service:
continue
items = await service.get_reply_queue_snapshot()
if not items:
continue
pending_count = len(items)
total_pending += pending_count
first = items[0]
summaries.append(
ReplyQueueSummaryItem(
account_id=account_id,
running=bool(worker.is_running and service._running),
pending_count=pending_count,
next_scheduled_at=first.get("scheduled_at"),
next_remaining_seconds=int(first.get("remaining_seconds") or 0),
)
)
return ReplyQueueSummaryResponse(
total_pending=total_pending,
server_time=datetime.now(timezone.utc),
items=summaries,
)
@app.get(
"/api/accounts/{account_id}/reply-queue",
response_model=ReplyQueueResponse,
)
async def get_account_reply_queue(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""查看一个账号的完整自动回复排队内容。"""
account = await get_owned_account(db, user, account_id)
worker = manager.workers.get(account_id)
service = worker._im_service if worker else None
items = await service.get_reply_queue_snapshot() if service else []
from rpa_engine.douyin_im.traffic_control import get_traffic_controller
global_send = await get_traffic_controller().send_queue.snapshot()
queued_for_account = int(global_send.get("per_account", {}).get(account_id, 0) or 0)
if global_send.get("active_account_id") == account_id:
queued_for_account += 1
return ReplyQueueResponse(
account_id=account_id,
running=bool(worker and worker.is_running and service and service._running),
pending_count=len(items),
interval_seconds=_resolve_effective_reply_delay(account),
global_send_interval_seconds=float(global_send.get("interval_seconds") or 0),
global_send_pending_count=int(global_send.get("pending_count") or 0),
global_account_pending_count=queued_for_account,
global_active_account_id=global_send.get("active_account_id"),
server_time=datetime.now(timezone.utc),
items=items,
)
@app.post(
"/api/accounts/{account_id}/reply-queue/{job_id}/send-now",
response_model=ReplyQueueActionResponse,
status_code=202,
)
async def send_account_queued_reply_now(
account_id: int,
job_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
"""将指定任务原子移入紧急队列,并把它后面的普通任务前移一槽。"""
await get_owned_account(db, user, account_id, write=True)
worker = manager.workers.get(account_id)
service = worker._im_service if worker else None
if not worker or not worker.is_running or not service or not service._running:
raise HTTPException(status_code=409, detail="账号托管未运行,无法发送队列任务")
result = await service.send_queued_reply_now(job_id)
status = result.get("status")
if status == "not_running":
raise HTTPException(status_code=409, detail="账号回复队列已停止")
if status == "not_found":
raise HTTPException(status_code=404, detail="队列任务不存在,可能已发送或已取消")
if status == "already_sending":
return ReplyQueueActionResponse(
job_id=job_id,
status=status,
message="该任务正在发送中",
)
if status == "already_requested":
return ReplyQueueActionResponse(
job_id=job_id,
status=status,
message="该任务已排入立即发送队列",
)
shifted_count = int(result.get("shifted_count") or 0)
system_logger.record(
"队列任务已手动设为立即发送",
detail=f"任务 {job_id} 已进入紧急发送队列,后续 {shifted_count} 项已自动前移。",
level="info",
category="send",
account_id=account_id,
)
return ReplyQueueActionResponse(
job_id=job_id,
status="accepted",
message=(
f"已安排立即发送,后续 {shifted_count} 项已自动前移"
if shifted_count
else "已安排立即发送"
),
shifted_count=shifted_count,
)
DESKTOP_LOGIN_SEC_USER_ID_MISSING = (
"该账号缺少 sec_user_id,或账号身份已过期,暂不允许软件端登录。"
"请先在后台账号管理中同步详细资料,或重新扫码刷新账号身份后再试。"
)
DESKTOP_LOGIN_SEC_USER_ID_UNKNOWN = (
"暂时无法核验 sec_user_id,暂不允许软件端登录,请稍后重试。"
)
async def _require_desktop_login_sec_user_id(
account: Account,
db: AsyncSession,
) -> None:
"""Fail closed before a desktop client can receive an account Cookie."""
try:
result = await db.execute(
select(AccountProfileDetail).where(
AccountProfileDetail.account_id == account.id
)
)
profile = result.scalar_one_or_none()
except Exception as exc:
logger.exception(
"Desktop login sec_user_id check failed for account %s", account.id
)
raise HTTPException(
status_code=503,
detail=DESKTOP_LOGIN_SEC_USER_ID_UNKNOWN,
) from exc
sec_user_id = str(getattr(profile, "sec_user_id", "") or "").strip()
identity_stale = bool(
account.cookie_updated_at
and (
profile is None
or profile.synced_at is None
or account.cookie_updated_at > profile.synced_at
)
)
if not sec_user_id or identity_stale:
raise HTTPException(
status_code=409,
detail=DESKTOP_LOGIN_SEC_USER_ID_MISSING,
)
@app.get("/api/accounts/{account_id}/cookie", response_model=AccountCookieResponse)
async def get_account_cookie(
account_id: int,
purpose: Optional[str] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Read a Cookie for management or for legacy desktop login clients.
Older released desktop clients call this route without a purpose. Keep
those clients protected immediately by applying the desktop identity guard
by default. The web console explicitly uses ``purpose=management`` so a
missing identity can still be repaired there.
"""
account = await get_owned_account(db, user, account_id)
cookie_data = _get_account_cookie_data(account)
if cookie_data and purpose != "management":
await _require_desktop_login_sec_user_id(account, db)
return await _build_cookie_response(
account,
cookie_data,
# Opening the edit dialog is a read-only management action. A live
# Douyin credential probe can take many seconds and, with hundreds of
# hosted accounts, would wait behind recurring background traffic.
# The dialog only needs the locally stored credential fields; users
# can still request an authoritative online check with the explicit
# "recheck credential" action.
runtime_check=purpose != "management",
)
@app.get(
"/api/accounts/{account_id}/desktop-login-credential",
response_model=AccountCookieResponse,
)
async def get_desktop_login_credential(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return credentials only when the selected Douyin identity is verified.
This endpoint is intentionally separate from the normal Cookie management
endpoint: an account missing ``sec_user_id`` must still be editable in the
web console so the user can repair or replace its credentials.
"""
account = await get_owned_account(db, user, account_id)
cookie_data = _get_account_cookie_data(account)
if not cookie_data:
# Preserve the clients' existing, more specific "no login state"
# message; without Cookie data there is no credential to disclose.
return await _build_cookie_response(account, cookie_data)
await _require_desktop_login_sec_user_id(account, db)
return await _build_cookie_response(account, cookie_data)
@app.put("/api/accounts/{account_id}/cookie", response_model=AccountCookieResponse)
async def update_account_cookie(
account_id: int,
body: AccountCookieUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
# Validate first: malformed input must not take a healthy hosted account
# offline. Filesystem and database mutations happen only after the worker
# preparation lock has been acquired below.
try:
parsed_data = validate_cookie_json(body.cookie_data)
standard_json_str = json.dumps(parsed_data, ensure_ascii=False, indent=2)
except (ValueError, Exception) as e:
raise HTTPException(status_code=400, detail=f"Cookie 格式错误: {e}")
# Serialize credential replacement against both single-account and batch
# preparations. Keep the lock until the new Cookie and cleared identity
# are committed so no worker can start in the stop/commit gap.
async with manager.preparation_lock(account_id):
account = await get_owned_account(db, user, account_id, write=True)
await batch_start_queue.cancel_account(account_id)
await manager.stop_worker(account_id)
try:
cookie_path = write_cookie_file(account_id, standard_json_str)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Cookie 保存失败: {exc}") from exc
account.cookie_data = standard_json_str
account.cookie_path = cookie_path
account.cookie_updated_at = datetime.utcnow()
account.updated_at = datetime.utcnow()
await db.execute(
update(AccountProfileDetail)
.where(AccountProfileDetail.account_id == account_id)
.values(sec_user_id=None, synced_at=None)
)
try:
from rpa_engine.account_profile import apply_douyin_profile
await apply_douyin_profile(db, account, standard_json_str)
except Exception as exc:
logger.warning(f"Sync profile after cookie update failed: {exc}")
await db.commit()
await db.refresh(account)
return await _build_cookie_response(account, standard_json_str)
@app.delete("/api/accounts/{account_id}/cookie")
async def delete_account_cookie(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
# Deleting credentials uses the same preparation lock as starting a
# worker, preventing a new worker from appearing after stop_worker but
# before the cleared credentials are committed.
async with manager.preparation_lock(account_id):
account = await get_owned_account(db, user, account_id, write=True)
await batch_start_queue.cancel_account(account_id)
await manager.stop_worker(account_id)
clear_cookie_file(account_id)
account.cookie_data = None
account.cookie_path = None
account.cookie_updated_at = None
account.im_session_data = None
account.updated_at = datetime.utcnow()
await db.execute(
update(AccountProfileDetail)
.where(AccountProfileDetail.account_id == account_id)
.values(sec_user_id=None, synced_at=None)
)
await db.commit()
return {"message": "Cookie cleared successfully."}
@app.post("/api/accounts", response_model=AccountResponse)
async def create_account(
account_in: AccountCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
cookie_data = (account_in.cookie_data or "").strip()
standard_json_str = None
if cookie_data:
try:
parsed_data = validate_cookie_json(cookie_data)
standard_json_str = json.dumps(parsed_data, ensure_ascii=False, indent=2)
except (ValueError, Exception) as e:
raise HTTPException(status_code=400, detail=f"Cookie 格式错误: {e}")
await ensure_can_add_account(db, user)
account = Account(phone=account_in.phone, status="offline", owner_id=user.id)
db.add(account)
await db.commit()
await db.refresh(account)
if cookie_data:
cookie_path = write_cookie_file(account.id, standard_json_str)
account.cookie_data = standard_json_str
account.cookie_path = cookie_path
account.cookie_updated_at = datetime.utcnow()
try:
from rpa_engine.account_profile import apply_douyin_profile
await apply_douyin_profile(db, account, standard_json_str)
except Exception as exc:
logger.warning(f"Sync profile after account create failed: {exc}")
await db.commit()
await db.refresh(account)
return _build_account_response(account)
@app.delete("/api/accounts/{account_id}")
async def delete_account(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
await get_owned_account(db, user, account_id, write=True)
# 停止运行中的任务
await batch_start_queue.cancel_account(account_id)
await manager.stop_worker(account_id)
clear_cookie_file(account_id)
# 从数据库删除
await db.execute(delete(Account).where(Account.id == account_id))
await db.commit()
return {"message": f"Account {account_id} deleted successfully."}
@app.post("/api/accounts/{account_id}/validate-credential", response_model=CredentialValidateResponse)
async def validate_account_credential(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
account = await get_owned_account(db, user, account_id)
cookie_data = _get_account_cookie_data(account)
assessment = await assess_account_credential(
cookie_data,
account.im_session_data,
startup_priority=True,
)
return CredentialValidateResponse(**assessment)
@app.post("/api/accounts/{account_id}/reset-credentials")
async def reset_account_credentials(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
await get_owned_account(db, user, account_id, write=True)
await batch_start_queue.cancel_account(account_id)
account = await _reset_account_credentials(account_id, db)
return {
"message": "已清除 Cookie 与 IM 会话数据,请重新登录",
"status": account.status,
}
async def _start_account_rpa_impl(
account: Account,
db: AsyncSession,
requested_login_mode: Optional[str] = None,
*,
wait_for_ready: bool = False,
) -> dict:
"""Validate once, persist the starting state, then spawn one worker."""
account_id = int(account.id)
if manager.is_running(account_id):
return {"status": "running", "message": "RPA worker is already running."}
cookie_data = _get_account_cookie_data(account)
assessment = await assess_account_credential(
cookie_data,
account.im_session_data,
startup_priority=True,
)
login_mode = requested_login_mode or assessment["login_mode"]
reset_performed = False
if assessment.get("should_reset") and login_mode != "im_direct":
account = await _reset_account_credentials(account_id, db)
reset_performed = True
cookie_data = None
assessment = await assess_account_credential(None, None)
login_mode = "browser"
elif assessment.get("should_reset") and login_mode == "im_direct":
raise HTTPException(
status_code=400,
detail=assessment["message"] or "凭证已失效,请先清除旧数据后重新登录",
)
if login_mode == "im_direct" and not assessment["can_skip_browser"]:
raise HTTPException(
status_code=400,
detail=assessment["message"] or "凭证未通过验证,无法直连 IM",
)
if wait_for_ready and login_mode != "im_direct":
# A bulk operation cannot complete an interactive QR/browser login.
# Launching hundreds of browser tasks would only move the backlog out
# of the queue and recreate the original server stall. The single
# account start endpoint remains unchanged for interactive login.
raise RuntimeError(
"该账号需要手动浏览器登录,已跳过批量启动,请单独启动"
)
# Write before spawning the task. This gives both single and batch calls
# an immediate authoritative status and avoids racing the worker's first
# database update. Batch readiness keeps the number of accounts reaching
# this commit bounded instead of letting the whole batch write at once.
state_changed = (
account.status != "starting"
or account.qr_code_base64 is not None
or account.error_message is not None
)
if state_changed:
account.status = "starting"
account.qr_code_base64 = None
account.error_message = None
await db.commit()
try:
started = await manager.start_worker(
account_id,
login_mode=login_mode,
wait_until_ready=wait_for_ready,
credential_prevalidated=bool(
login_mode == "im_direct" and assessment["can_skip_browser"]
),
)
except Exception as exc:
account.status = "error"
account.error_message = str(exc) or "启动托管失败"
await db.commit()
raise
if started:
if wait_for_ready:
msg = "IM 托管已完成初始化"
elif login_mode == "im_direct":
msg = assessment["message"] or "凭证有效,正在直连 IM 托管(无需浏览器)"
elif reset_performed:
msg = "凭证已失效,已清除旧数据,正在打开浏览器重新登录..."
elif cookie_data:
msg = "凭证需刷新,将打开浏览器登录/采集 IM 会话..."
else:
msg = "未保存 Cookie,将打开浏览器扫码登录..."
return {
"status": "running" if wait_for_ready else "starting",
"login_mode": login_mode,
"cookie_valid": assessment["cookie_valid"],
"im_ready": assessment["im_ready"],
"skip_qr": login_mode == "im_direct",
"skip_browser": login_mode == "im_direct",
"message": msg,
}
return {"status": "running", "message": "RPA worker is already running."}
async def _start_queued_account(account_id: int) -> dict:
"""Own-session handler used by BatchStartQueue background workers."""
async with manager.preparation_lock(account_id):
async with AsyncSessionLocal() as db:
result = await db.execute(select(Account).where(Account.id == account_id))
account = result.scalar_one_or_none()
if not account:
raise RuntimeError("账号不存在或已删除")
if account.quota_disabled:
raise RuntimeError("账号已停用,无法启动托管")
try:
return await _start_account_rpa_impl(
account,
db,
wait_for_ready=True,
)
except asyncio.CancelledError:
raise
except Exception as exc:
if not manager.is_running(account_id):
account.status = "error"
account.error_message = str(getattr(exc, "detail", None) or exc or "启动失败")
await db.commit()
raise
batch_start_queue = BatchStartQueue(_start_queued_account)
@app.post("/api/accounts/{account_id}/start")
async def start_account_rpa(
account_id: int,
body: StartAccountRequest = StartAccountRequest(),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
account = await get_owned_account(db, user, account_id, write=True)
await batch_start_queue.cancel_account(account_id)
async with manager.preparation_lock(account_id):
return await _start_account_rpa_impl(account, db, body.login_mode)
@app.post("/api/account-start-batches", status_code=202)
async def submit_account_start_batch(
body: BatchStartRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
requested_ids = list(
dict.fromkeys(int(value) for value in body.account_ids if int(value) > 0)
)
if not body.all_accounts and not requested_ids:
raise HTTPException(status_code=400, detail="请选择要启动的账号")
if len(requested_ids) > 1000:
raise HTTPException(status_code=400, detail="单次最多提交 1000 个账号")
# The submit path needs only ids and the disabled flag. Do not hydrate
# every account's large cookie/session/QR columns just to enqueue ids.
candidate_stmt = select(Account.id, Account.quota_disabled)
if not is_admin(user.role):
candidate_stmt = candidate_stmt.where(Account.owner_id == user.id)
if not body.all_accounts:
candidate_stmt = candidate_stmt.where(Account.id.in_(requested_ids))
result = await db.execute(candidate_stmt)
candidates = result.all()
eligible_ids: list[int] = []
skipped_running = 0
skipped_disabled = 0
for account_id, quota_disabled in candidates:
if quota_disabled:
skipped_disabled += 1
elif manager.is_running(account_id):
skipped_running += 1
else:
eligible_ids.append(int(account_id))
batch = await batch_start_queue.submit(
eligible_ids,
owner_id=int(user.id),
metadata={
"requested_count": (
len(candidates) if body.all_accounts else len(requested_ids)
),
"accessible_count": len(candidates),
"skipped_running_count": skipped_running,
"skipped_disabled_count": skipped_disabled,
},
)
return batch
@app.get("/api/account-start-batches/{batch_id}")
async def get_account_start_batch(
batch_id: str,
user: User = Depends(get_current_user),
):
batch = await batch_start_queue.get_batch(
batch_id,
owner_id=int(user.id),
include_items=False,
)
if not batch:
raise HTTPException(status_code=404, detail="启动批次不存在或已过期")
return batch
@app.post("/api/accounts/{account_id}/stop")
async def stop_account_rpa(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
account = await get_owned_account(db, user, account_id, write=True)
await batch_start_queue.cancel_account(account_id)
stopped = await manager.stop_worker(account_id)
# 强制将数据库中的状态重置为 offline
account.status = "offline"
await db.commit()
if stopped:
return {"status": "stopped", "message": "RPA worker stopped successfully."}
else:
return {"status": "offline", "message": "RPA worker was not running."}
@app.get("/api/accounts/{account_id}/qr")
async def get_account_qr(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
account = await get_owned_account(db, user, account_id)
return {
"status": account.status,
"qr_code_base64": account.qr_code_base64,
"error_message": account.error_message
}
# 2. 自动回复规则接口
@app.get("/api/rules")
async def get_rules(
account_id: Optional[int] = None,
page: Optional[int] = Query(None, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""规则列表。
- 不带 page 参数:返回全量数组(旧行为)。
- 带 page 参数:数据库层 LIMIT/OFFSET 分页,返回 {items, total, page, page_size}。
"""
if account_id is not None:
await get_owned_account(db, user, account_id)
stmt = select(AutoReplyRule).where(AutoReplyRule.account_id == account_id)
else:
stmt = rules_for_user(user)
stmt = stmt.order_by(AutoReplyRule.sort_order.asc(), AutoReplyRule.id.asc())
if page is None:
result = await db.execute(stmt)
return [RuleResponse.model_validate(r) for r in result.scalars().all()]
total = int(
(
await db.execute(
select(func.count()).select_from(stmt.subquery())
)
).scalar()
or 0
)
result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
return {
"items": [RuleResponse.model_validate(r) for r in result.scalars().all()],
"total": total,
"page": page,
"page_size": page_size,
}
@app.post("/api/rules", response_model=RuleResponse)
async def create_rule(
rule_in: RuleCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
if rule_in.match_type == "default":
rule_in.keyword = ""
if rule_in.account_id is None:
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
await get_owned_account(db, user, rule_in.account_id, write=True)
sort_stmt = select(func.max(AutoReplyRule.sort_order)).where(
AutoReplyRule.account_id == rule_in.account_id
)
max_sort = (await db.execute(sort_stmt)).scalar()
next_sort = int(max_sort or 0) + 1
rule = AutoReplyRule(
owner_id=user.id,
account_id=rule_in.account_id,
keyword=rule_in.keyword,
reply_content=rule_in.reply_content,
match_type=rule_in.match_type,
sort_order=next_sort,
is_active=True if rule_in.is_active is None else bool(rule_in.is_active),
)
db.add(rule)
await db.commit()
await db.refresh(rule)
return rule
@app.put("/api/rules/{rule_id}", response_model=RuleResponse)
async def update_rule(
rule_id: int,
rule_in: RuleCreate,
is_active: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
rule = await get_accessible_rule(db, user, rule_id, write=True)
if rule_in.match_type == "default":
rule_in.keyword = ""
if rule_in.account_id is None:
raise HTTPException(status_code=400, detail="请选择适用账号,每条规则必须绑定一个托管账号")
await get_owned_account(db, user, rule_in.account_id, write=True)
rule.account_id = rule_in.account_id
rule.keyword = rule_in.keyword
rule.reply_content = rule_in.reply_content
rule.match_type = rule_in.match_type
# 优先使用请求体中的 is_active,其次兼容旧的查询参数
if rule_in.is_active is not None:
rule.is_active = bool(rule_in.is_active)
elif is_active is not None:
rule.is_active = is_active
await db.commit()
await db.refresh(rule)
return rule
@app.post("/api/rules/{rule_id}/toggle", response_model=RuleResponse)
async def toggle_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
rule = await get_accessible_rule(db, user, rule_id, write=True)
rule.is_active = not rule.is_active
await db.commit()
await db.refresh(rule)
return rule
@app.delete("/api/rules/{rule_id}")
async def delete_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
await get_accessible_rule(db, user, rule_id, write=True)
await db.execute(delete(AutoReplyRule).where(AutoReplyRule.id == rule_id))
await db.commit()
return {"message": "Rule deleted successfully."}
class RuleMove(BaseModel):
direction: str # up | down
@app.post("/api/rules/{rule_id}/move")
async def move_rule(
rule_id: int,
body: RuleMove,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
"""在同账号规则内上移/下移一位(服务端交换排序,适配前端分页)。"""
rule = await get_accessible_rule(db, user, rule_id, write=True)
stmt = (
select(AutoReplyRule)
.where(AutoReplyRule.account_id == rule.account_id)
.order_by(AutoReplyRule.sort_order.asc(), AutoReplyRule.id.asc())
)
siblings = list((await db.execute(stmt)).scalars().all())
index = next((i for i, r in enumerate(siblings) if r.id == rule.id), -1)
target = index - 1 if body.direction == "up" else index + 1
if index < 0 or target < 0 or target >= len(siblings):
return {"message": "已在边界,无需移动"}
siblings[index], siblings[target] = siblings[target], siblings[index]
for i, r in enumerate(siblings):
r.sort_order = i
await db.commit()
return {"message": "Rule moved successfully."}
@app.post("/api/rules/reorder")
async def reorder_rules(
body: RuleReorder,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
if not body.rule_ids:
return {"message": "No rules to reorder."}
for index, rule_id in enumerate(body.rule_ids):
rule = await get_accessible_rule(db, user, rule_id, write=True)
rule.sort_order = index
await db.commit()
return {"message": "Rules reordered successfully."}
# 3. 消息日志接口
@app.get("/api/logs/stats")
async def get_logs_stats(
account_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""消息日志全量统计(数据库计数,不受列表 limit 限制)。"""
if account_id is not None:
await get_owned_account(db, user, account_id)
# Select only the indexed status column and calculate both counters in one
# scan. The old implementation queried the growing log table twice on
# every dashboard refresh.
base = (
logs_for_user(user, account_id)
.with_only_columns(MessageLog.status)
.order_by(None)
.subquery()
)
row = (
await db.execute(
select(
func.count().label("total"),
func.coalesce(
func.sum(case((base.c.status == "replied", 1), else_=0)),
0,
).label("replied"),
).select_from(base)
)
).one()
return {"total": int(row.total or 0), "replied": int(row.replied or 0)}
@app.get("/api/logs", response_model=List[LogResponse])
async def get_logs(
account_id: Optional[int] = None,
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if account_id is not None:
await get_owned_account(db, user, account_id)
limit = max(1, min(int(limit or 50), 500))
stmt = (
logs_for_user(user, account_id)
.order_by(MessageLog.created_at.desc())
.offset(max(0, int(offset or 0)))
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
@app.get("/api/received-messages", response_model=List[ReceivedMessageLogResponse])
async def get_received_messages(
account_id: Optional[int] = None,
limit: int = 100,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""接收消息原始日志:仅包含收到的消息,内容为接口/通道原样记录。"""
if account_id is not None:
await get_owned_account(db, user, account_id)
limit = max(1, min(int(limit or 100), 500))
stmt = (
received_logs_for_user(user, account_id)
.order_by(ReceivedMessageLog.created_at.desc())
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
@app.get("/api/system-logs", response_model=List[SystemLogResponse])
async def get_system_logs(
account_id: Optional[int] = None,
level: Optional[str] = None,
category: Optional[str] = None,
limit: int = 200,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""系统诊断日志:私信收发 / 实时连接 / 鉴权 等链路事件,用于排查失败原因。"""
if account_id is not None:
await get_owned_account(db, user, account_id)
entries = system_logger.get_logs(
account_id=account_id,
level=level,
category=category,
limit=max(1, min(int(limit or 200), 1000)),
)
if not is_admin(user.role):
allowed = await owned_account_ids(db, user)
entries = [e for e in entries if e.get("account_id") in allowed]
return [SystemLogResponse(**e) for e in entries]
@app.delete("/api/system-logs")
async def clear_system_logs(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
"""清空系统诊断日志(内存缓冲区 + 数据库历史)。"""
system_logger.clear()
await db.execute(delete(SystemLog))
await db.commit()
return {"message": "系统诊断日志已清空"}
@app.get("/api/accounts/{account_id}/conversations", response_model=List[ConversationResponse])
async def get_account_conversations(
account_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
account = await get_owned_account(db, user, account_id)
if not _account_has_cookie(account):
raise HTTPException(status_code=400, detail="账号未保存 Cookie,无法拉取私信会话")
session = _build_account_im_session(account)
if not session.can_direct_im():
raise HTTPException(status_code=400, detail="Cookie 无效或缺少 sessionid,无法访问 IM")
conversations: list[dict] = []
worker = manager.workers.get(account_id)
if worker and worker._im_service:
conversations = worker._im_service.get_cached_conversations()
if not conversations:
async with DouyinImHttpClient(session, account_id=account_id) as http:
conversations = await http.get_conversations()
if not conversations:
my_uid = session.my_uid or 0
if not my_uid:
from rpa_engine.douyin_im.auth import DouyinAuth
auth = DouyinAuth()
auth.perepare_auth(
session.cookie_header(),
session.web_protect_str,
session.keys_str,
)
my_uid = auth.get_uid() or 0
conversations = await _conversations_from_logs(db, account_id, my_uid)
return [
ConversationResponse(
conversation_id=str(c.get("conversation_id") or ""),
sender_name=str(c.get("sender_name") or "未知用户"),
sender_id=str(c.get("sender_id") or c.get("peer_uid") or "") or None,
sender_avatar=c.get("sender_avatar") or None,
content=str(c.get("content") or ""),
unread_count=int(c.get("unread_count") or 0),
)
for c in conversations
if c.get("conversation_id") or c.get("sender_name")
]
@app.post("/api/accounts/{account_id}/messages/upload-image", response_model=UploadImageResponse)
async def upload_message_image(
account_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
account = await get_owned_account(db, user, account_id, write=True)
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="仅支持上传图片文件")
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="图片为空")
if len(raw) > 8 * 1024 * 1024:
raise HTTPException(status_code=400, detail="图片大小不能超过 8MB")
ext = ".jpg"
if file.content_type == "image/png":
ext = ".png"
elif file.content_type == "image/gif":
ext = ".gif"
elif file.content_type == "image/webp":
ext = ".webp"
account_dir = os.path.join(UPLOAD_DIR, str(account_id))
os.makedirs(account_dir, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(account_dir, filename)
with open(path, "wb") as f:
f.write(raw)
width = None
height = None
try:
from PIL import Image
with Image.open(BytesIO(raw)) as img:
width, height = img.size
except Exception:
pass
public_url = f"/api/media/messages/{account_id}/{filename}"
from rpa_engine.douyin_im.message_content import serialize_message_content
from rpa_engine.douyin_im.image_upload import upload_im_image
from rpa_engine.douyin_im.traffic_control import submit_outbound
session = _build_account_im_session(account)
worker = manager.workers.get(account_id)
if worker and worker._im_service:
session = worker._im_service.session
image_spec: dict = {
"type": "image",
"text": "[图片]",
"url": public_url,
}
if width:
image_spec["width"] = width
if height:
image_spec["height"] = height
uploaded = await submit_outbound(
account_id,
lambda: asyncio.to_thread(
upload_im_image,
session,
raw,
filename=filename,
content_type=file.content_type or "image/jpeg",
),
description=f"IM image upload {filename}",
)
if uploaded.get("uri"):
for key in ("uri", "url_list", "md5"):
if uploaded.get(key):
image_spec[key] = uploaded[key]
if uploaded.get("url"):
image_spec["douyin_url"] = uploaded["url"]
if uploaded.get("width") and not image_spec.get("width"):
image_spec["width"] = uploaded["width"]
if uploaded.get("height") and not image_spec.get("height"):
image_spec["height"] = uploaded["height"]
elif uploaded.get("error"):
logger.warning("Douyin image pre-upload failed account=%s: %s", account_id, uploaded["error"])
raise HTTPException(status_code=502, detail=f"图片上传失败:{uploaded['error']}")
payload = serialize_message_content(image_spec)
return UploadImageResponse(url=public_url, width=width, height=height, payload=payload)
@app.post("/api/accounts/{account_id}/messages/send", response_model=SendMessageResponse)
async def send_account_message(
account_id: int,
body: SendMessageRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_write),
):
account = await get_owned_account(db, user, account_id, write=True)
content = normalize_outgoing_content(
content=body.content or "",
message_type=body.message_type,
media_url=body.media_url,
sticker_url=body.sticker_url,
width=body.width,
height=body.height,
sticker_id=body.sticker_id,
)
if not content:
raise HTTPException(status_code=400, detail="消息内容不能为空")
if not body.conversation_id:
raise HTTPException(status_code=400, detail="conversation_id 不能为空")
worker = manager.workers.get(account_id)
session = _build_account_im_session(account)
last_error = ""
if not (worker and worker._im_service) and not _account_has_cookie(account):
raise HTTPException(status_code=400, detail="账号未保存 Cookie,无法发送私信")
async def _do_send() -> bool:
nonlocal last_error
if worker and worker._im_service:
ok = await worker._im_service.send_message(body.conversation_id, content)
last_error = worker._im_service.last_error or ""
if ok:
await _persist_im_session_data(account_id, worker._im_service.session, db)
return ok
async with DouyinImHttpClient(session, account_id=account_id) as http:
ok = await http.send_text_message(body.conversation_id, content)
last_error = http.last_error or ""
if ok:
session.conv_meta = http.session.conv_meta
await _persist_im_session_data(account_id, session, db)
return ok
try:
sent = await asyncio.wait_for(_do_send(), timeout=45)
except asyncio.TimeoutError:
# 云服务器到抖音的网络偶发缓慢,整条发送链路(解析 ticket + 签名 + 发送)
# 可能超过 nginx 上游超时而直接 504。这里限定整体时长,超时返回受控错误。
sent = False
last_error = last_error or "发送超时"
try:
await db.rollback()
except Exception:
pass
failed_log = MessageLog(
account_id=account_id,
sender_name="[系统发送]",
sender_id=_resolve_log_peer_id(session, body.conversation_id),
message_content=content,
reply_content=content,
status="failed",
error_message="发送超时(抖音接口响应缓慢),请稍后重试",
)
db.add(failed_log)
try:
await db.commit()
except Exception:
await db.rollback()
return SendMessageResponse(
success=False,
message="发送超时(抖音接口响应缓慢),请稍后重试",
)
if sent:
log = MessageLog(
account_id=account_id,
sender_name="[系统发送]",
sender_id=_resolve_log_peer_id(session, body.conversation_id),
message_content=content,
reply_content=content,
status="sent",
)
db.add(log)
await db.commit()
system_logger.record(
"手动发送私信成功",
detail=f"会话 {body.conversation_id}{message_preview(content)}",
level="success",
category="send",
account_id=account_id,
)
return SendMessageResponse(success=True, message="私信发送成功")
failed_log = MessageLog(
account_id=account_id,
sender_name="[系统发送]",
sender_id=_resolve_log_peer_id(session, body.conversation_id),
message_content=content,
reply_content=content,
status="failed",
error_message=last_error or "私信发送失败",
)
db.add(failed_log)
await db.commit()
need_browser = (
not session.keys_str
or not session.web_protect_str
or not (session.conv_meta or {})
or "ticket" in (last_error or "")
or "签名密钥" in (last_error or "")
or "web_protect" in (last_error or "")
or last_error == "INVALID_REQUEST"
)
if need_browser:
msg = last_error or "缺少 IM 签名密钥"
if last_error == "INVALID_REQUEST":
msg = "IM 会话创建失败(INVALID_REQUEST),请停止托管后用浏览器模式重新登录并打开私信页"
return SendMessageResponse(
success=False,
need_browser_login=True,
message=msg,
)
return SendMessageResponse(
success=False,
message=last_error or "私信发送失败,请确认 Cookie/IM 会话仍有效",
)
if SERVE_WEB:
if not mount_frontend(app, STATIC_DIR):
print(
f"[kefu] 未找到前端构建目录 {STATIC_DIR},当前仅提供 API。"
" 请执行 install.sh / install.bat 或 cd frontend && npm run build"
)