更新
This commit is contained in:
+170
-1
@@ -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 时自动回填。
|
||||
|
||||
已有自定义 UA(account.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)
|
||||
|
||||
Reference in New Issue
Block a user