This commit is contained in:
Your Name
2026-08-27 18:32:03 +08:00
parent 4ac6990efe
commit 1f3addcf79
50 changed files with 9145 additions and 1760 deletions
+170 -1
View File
@@ -4,6 +4,7 @@ import json
import asyncio
import ipaddress
import logging
import time
import uuid
from datetime import datetime, timezone
from io import BytesIO
@@ -92,6 +93,7 @@ from utils.cookie_store import (
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 (
@@ -100,6 +102,22 @@ from rpa_engine.egress_channels import (
)
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")
@@ -113,11 +131,17 @@ app.add_middleware(
)
# 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())
@@ -145,6 +169,10 @@ class WorkerManager:
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()
@@ -209,6 +237,98 @@ class WorkerManager:
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")
@@ -443,6 +563,26 @@ def _account_has_cookie(account: Account) -> bool:
return os.path.exists(get_cookie_path(account.id))
def _backfill_user_agent_from_cookie(account: Account, standard_json_str: Optional[str]) -> None:
"""凭证里带登录头(user_agent)且账号未显式配置 UA 时自动回填。
已有自定义 UAaccount.user_agent 非空)的账号保持不变,避免覆盖用户选择;
cookie_data 为空或解析失败时静默跳过。回填后发送/接收链路经
_build_account_im_session 统一走 resolve_user_agent(account.user_agent)
保证 UA 全链路一致。
"""
if account.user_agent:
return
if not standard_json_str:
return
try:
ua = extract_user_agent_from_cookie_data(standard_json_str)
except Exception:
ua = ""
if ua:
account.user_agent = ua
def _build_account_im_session(account: Account) -> DouyinImSession:
cookie_data = _get_account_cookie_data(account)
storage = json.loads(cookie_data) if cookie_data else {}
@@ -941,6 +1081,8 @@ class AccountCookieResponse(BaseModel):
im_status: Optional[str] = None
can_skip_browser: bool = False
should_reset: bool = False
user_agent: Optional[str] = None
user_agent_label: Optional[str] = None
class AccountVideoItem(BaseModel):
@@ -1019,6 +1161,8 @@ async def _build_cookie_response(
im_status=im_detail["im_status"],
can_skip_browser=im_detail["can_skip_browser"],
should_reset=im_detail.get("should_reset", False),
user_agent=account.user_agent or None,
user_agent_label=profile_label_for_ua(account.user_agent),
)
@@ -2054,6 +2198,7 @@ async def update_account_cookie(
account.cookie_path = cookie_path
account.cookie_updated_at = datetime.utcnow()
account.updated_at = datetime.utcnow()
_backfill_user_agent_from_cookie(account, standard_json_str)
await db.execute(
update(AccountProfileDetail)
.where(AccountProfileDetail.account_id == account_id)
@@ -2126,6 +2271,7 @@ async def create_account(
account.cookie_data = standard_json_str
account.cookie_path = cookie_path
account.cookie_updated_at = datetime.utcnow()
_backfill_user_agent_from_cookie(account, standard_json_str)
try:
from rpa_engine.account_profile import apply_douyin_profile
@@ -2760,7 +2906,29 @@ async def get_account_conversations(
if not conversations:
async with DouyinImHttpClient(session, account_id=account_id) as http:
conversations = await http.get_conversations()
# 用户点开会话列表:从头翻,而不是「最近半小时有动静的会话」。
# 抖音没有「一次取回全部会话」的接口,收件箱是按游标分页的,
# 所以这里花一个翻页预算;翻不完时把 DB 历史并进来补齐,
# 免得把其中一页当成完整会话列表展示给用户。
page_budget = _ui_conversation_page_budget()
conversations = await http.get_conversations(
lookback_seconds=0,
max_pages=page_budget,
)
if http.inbox_truncated:
logger.info(
"Account %s conversation list truncated at %d pages; "
"merging local history",
account_id,
page_budget,
)
known = {
str(c.get("conversation_id") or "") for c in conversations
}
my_uid = session.my_uid or 0
for item in await _conversations_from_logs(db, account_id, my_uid):
if str(item.get("conversation_id") or "") not in known:
conversations.append(item)
if not conversations:
my_uid = session.my_uid or 0
@@ -2771,6 +2939,7 @@ async def get_account_conversations(
session.cookie_header(),
session.web_protect_str,
session.keys_str,
user_agent=session.user_agent or "",
)
my_uid = auth.get_uid() or 0
conversations = await _conversations_from_logs(db, account_id, my_uid)