This commit is contained in:
Your Name
2026-07-28 15:04:17 +08:00
parent ac406a5f99
commit 8f68af1c2c
27 changed files with 3442 additions and 296 deletions
+329 -41
View File
@@ -107,7 +107,14 @@ class WorkerManager:
"""Serialize credential preparation across single and batch starts."""
return self._preparation_locks.setdefault(int(account_id), asyncio.Lock())
async def start_worker(self, account_id: int, login_mode: str = "auto"):
async 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]
@@ -115,11 +122,60 @@ class WorkerManager:
return False
del self.workers[account_id]
worker = DouyinWorker(account_id, login_mode=login_mode)
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:
@@ -331,17 +387,29 @@ async def _flush_system_logs_loop():
async def _sync_legacy_cookie_files():
"""将旧版仅保存在文件的 Cookie 同步到数据库"""
async with AsyncSessionLocal() as db:
result = await db.execute(select(Account))
accounts = result.scalars().all()
# 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 acc in accounts:
if acc.cookie_data:
continue
file_data = read_cookie_file(acc.id)
for account_id in account_ids:
file_data = read_cookie_file(int(account_id))
if file_data:
acc.cookie_data = file_data
acc.cookie_path = get_cookie_path(acc.id)
acc.cookie_updated_at = datetime.utcnow()
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()
@@ -501,10 +569,8 @@ async def _seed_admin_user():
# 初始化数据库
@app.on_event("startup")
async def startup():
# 放大默认线程池:每个托管账号的 a_bogus/web_protect/ts_sign 签名都是阻塞的 Node
# 子进程调用,经 asyncio.to_thread 跑在默认线程池里。Python 默认池大小仅
# min(32, cpu+4),在 1 核云服务器上只有 5 个线程,导致超过 5 个账号并发时第 6 个
# 账号的签名/取信息/发送会一直排队阻塞直至超时失败。这里显式放大线程池消除该瓶颈。
# a_bogus/web_protect/ts_sign 仍需在线程池中执行阻塞的 Node 调用,但账号启动、
# 后台请求和发送通道都已有独立并发限制,因此线程池按 CPU 有界配置即可。
import concurrent.futures as _futures
try:
@@ -512,7 +578,11 @@ async def startup():
except ValueError:
_pool_size = 0
if _pool_size <= 0:
_pool_size = max(64, ((os.cpu_count() or 1) * 8))
# 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(
@@ -557,12 +627,95 @@ async def startup():
@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.
await batch_start_queue.stop()
# 停止所有正在运行的 RPA 任务
for account_id in list(manager.workers.keys()):
await manager.stop_worker(account_id)
# 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:
@@ -625,6 +778,25 @@ class AccountResponse(BaseModel):
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."""
@@ -1177,6 +1349,83 @@ async def get_accounts(
"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,
@@ -1356,6 +1605,10 @@ async def update_account(
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
@@ -1379,6 +1632,11 @@ async def update_account(
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)
@@ -1820,6 +2078,8 @@ 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)
@@ -1853,10 +2113,19 @@ async def _start_account_rpa_impl(
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 submission normally wrote this state in bulk, so
# no per-account commit is needed in that path.
# 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
@@ -1869,7 +2138,14 @@ async def _start_account_rpa_impl(
await db.commit()
try:
started = await manager.start_worker(account_id, login_mode=login_mode)
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 "启动托管失败"
@@ -1877,7 +2153,9 @@ async def _start_account_rpa_impl(
raise
if started:
if login_mode == "im_direct":
if wait_for_ready:
msg = "IM 托管已完成初始化"
elif login_mode == "im_direct":
msg = assessment["message"] or "凭证有效,正在直连 IM 托管(无需浏览器)"
elif reset_performed:
msg = "凭证已失效,已清除旧数据,正在打开浏览器重新登录..."
@@ -1887,7 +2165,7 @@ async def _start_account_rpa_impl(
msg = "未保存 Cookie,将打开浏览器扫码登录..."
return {
"status": "starting",
"status": "running" if wait_for_ready else "starting",
"login_mode": login_mode,
"cookie_valid": assessment["cookie_valid"],
"im_ready": assessment["im_ready"],
@@ -1909,7 +2187,11 @@ async def _start_queued_account(account_id: int) -> dict:
if account.quota_disabled:
raise RuntimeError("账号已停用,无法启动托管")
try:
return await _start_account_rpa_impl(account, db)
return await _start_account_rpa_impl(
account,
db,
wait_for_ready=True,
)
except asyncio.CancelledError:
raise
except Exception as exc:
@@ -2217,21 +2499,27 @@ async def get_logs_stats(
"""消息日志全量统计(数据库计数,不受列表 limit 限制)。"""
if account_id is not None:
await get_owned_account(db, user, account_id)
base = logs_for_user(user, account_id).subquery()
total = int(
(await db.execute(select(func.count()).select_from(base))).scalar() or 0
# 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()
)
replied = int(
(
await db.execute(
select(func.count())
.select_from(base)
.where(base.c.status == "replied")
)
).scalar()
or 0
)
return {"total": total, "replied": replied}
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])