更新
This commit is contained in:
+329
-41
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user