更新
This commit is contained in:
+135
-37
@@ -20,7 +20,7 @@ from settings import CORS_ORIGINS, SERVE_WEB, STATIC_DIR
|
|||||||
from help_pages import serve_credential_tool
|
from help_pages import serve_credential_tool
|
||||||
from web_static import mount_frontend
|
from web_static import mount_frontend
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select, update, delete, text, func, case
|
from sqlalchemy import select, update, delete, text, func, case, or_, cast, String
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.database import engine, Base, get_db, AsyncSessionLocal
|
from models.database import engine, Base, get_db, AsyncSessionLocal
|
||||||
@@ -1080,42 +1080,91 @@ async def get_accounts(
|
|||||||
支持 q(昵称/抖音ID/手机号/账号ID 搜索)与 status 筛选。
|
支持 q(昵称/抖音ID/手机号/账号ID 搜索)与 status 筛选。
|
||||||
Cookie 解析等重逻辑只对当前页执行。
|
Cookie 解析等重逻辑只对当前页执行。
|
||||||
"""
|
"""
|
||||||
result = await db.execute(accounts_for_user(user))
|
if page is None:
|
||||||
accounts = result.scalars().all()
|
result = await db.execute(accounts_for_user(user))
|
||||||
# 更新内存中的运行状态与数据库同步,以防异常断开
|
accounts = result.scalars().all()
|
||||||
for acc in accounts:
|
# 兼容旧的全量接口行为。
|
||||||
|
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)
|
is_running = manager.is_running(acc.id)
|
||||||
if is_running and acc.status == "offline":
|
if is_running and acc.status == "offline":
|
||||||
acc.status = "online"
|
acc.status = "online"
|
||||||
elif not is_running and acc.status in ("online", "logging_in", "starting"):
|
elif not is_running and acc.status in ("online", "logging_in", "starting"):
|
||||||
acc.status = "offline"
|
acc.status = "offline"
|
||||||
|
|
||||||
if page is None:
|
|
||||||
return [_build_account_response(acc) for acc in accounts]
|
|
||||||
|
|
||||||
keyword = (q or "").strip().lower()
|
|
||||||
status_filter = (status or "").strip()
|
|
||||||
filtered = [
|
|
||||||
acc
|
|
||||||
for acc in accounts
|
|
||||||
if _account_matches_keyword(acc, keyword)
|
|
||||||
and (
|
|
||||||
not status_filter
|
|
||||||
or status_filter == "all"
|
|
||||||
or (
|
|
||||||
status_filter == "quota_disabled"
|
|
||||||
and bool(acc.quota_disabled)
|
|
||||||
)
|
|
||||||
or (
|
|
||||||
status_filter != "quota_disabled"
|
|
||||||
and not acc.quota_disabled
|
|
||||||
and acc.status == status_filter
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
total = len(filtered)
|
|
||||||
start = (page - 1) * page_size
|
|
||||||
items = filtered[start : start + page_size]
|
|
||||||
return {
|
return {
|
||||||
"items": [_build_account_response(acc) for acc in items],
|
"items": [_build_account_response(acc) for acc in items],
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -1330,14 +1379,55 @@ async def update_account(
|
|||||||
|
|
||||||
@app.get("/api/reply-queues", response_model=ReplyQueueSummaryResponse)
|
@app.get("/api/reply-queues", response_model=ReplyQueueSummaryResponse)
|
||||||
async def get_reply_queue_summaries(
|
async def get_reply_queue_summaries(
|
||||||
|
account_ids: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""聚合当前用户可见账号的排队数量,供账号页低频轮询。"""
|
"""聚合可见账号的排队数量;账号页可限定为当前页账号。"""
|
||||||
allowed_ids = await owned_account_ids(db, 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] = []
|
summaries: list[ReplyQueueSummaryItem] = []
|
||||||
total_pending = 0
|
total_pending = 0
|
||||||
for account_id, worker in list(manager.workers.items()):
|
for account_id, worker in worker_entries:
|
||||||
if allowed_ids is not None and account_id not in allowed_ids:
|
if allowed_ids is not None and account_id not in allowed_ids:
|
||||||
continue
|
continue
|
||||||
service = worker._im_service if worker else None
|
service = worker._im_service if worker else None
|
||||||
@@ -1688,7 +1778,11 @@ async def validate_account_credential(
|
|||||||
account = await get_owned_account(db, user, account_id)
|
account = await get_owned_account(db, user, account_id)
|
||||||
|
|
||||||
cookie_data = _get_account_cookie_data(account)
|
cookie_data = _get_account_cookie_data(account)
|
||||||
assessment = await assess_account_credential(cookie_data, account.im_session_data)
|
assessment = await assess_account_credential(
|
||||||
|
cookie_data,
|
||||||
|
account.im_session_data,
|
||||||
|
startup_priority=True,
|
||||||
|
)
|
||||||
return CredentialValidateResponse(**assessment)
|
return CredentialValidateResponse(**assessment)
|
||||||
|
|
||||||
|
|
||||||
@@ -1718,7 +1812,11 @@ async def _start_account_rpa_impl(
|
|||||||
return {"status": "running", "message": "RPA worker is already running."}
|
return {"status": "running", "message": "RPA worker is already running."}
|
||||||
|
|
||||||
cookie_data = _get_account_cookie_data(account)
|
cookie_data = _get_account_cookie_data(account)
|
||||||
assessment = await assess_account_credential(cookie_data, account.im_session_data)
|
assessment = await assess_account_credential(
|
||||||
|
cookie_data,
|
||||||
|
account.im_session_data,
|
||||||
|
startup_priority=True,
|
||||||
|
)
|
||||||
login_mode = requested_login_mode or assessment["login_mode"]
|
login_mode = requested_login_mode or assessment["login_mode"]
|
||||||
reset_performed = False
|
reset_performed = False
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ StartHandler = Callable[[int], Awaitable[dict[str, Any]]]
|
|||||||
JobToken = tuple[str, int]
|
JobToken = tuple[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class _StartPreparationTimeout(Exception):
|
||||||
|
"""Internal marker for the queue's own per-account deadline."""
|
||||||
|
|
||||||
|
|
||||||
def _configured_concurrency() -> int:
|
def _configured_concurrency() -> int:
|
||||||
try:
|
try:
|
||||||
return max(1, min(8, int(os.getenv("KEFU_BATCH_START_CONCURRENCY", "2"))))
|
return max(1, min(8, int(os.getenv("KEFU_BATCH_START_CONCURRENCY", "2"))))
|
||||||
@@ -25,6 +29,16 @@ def _configured_concurrency() -> int:
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_timeout_seconds() -> float:
|
||||||
|
try:
|
||||||
|
value = float(os.getenv("KEFU_BATCH_START_TIMEOUT_SECONDS", "90"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 90.0
|
||||||
|
if value <= 0:
|
||||||
|
return 0.0
|
||||||
|
return max(5.0, min(600.0, value))
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
def _utc_now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
@@ -55,10 +69,16 @@ class BatchStartQueue:
|
|||||||
handler: StartHandler,
|
handler: StartHandler,
|
||||||
concurrency: int | None = None,
|
concurrency: int | None = None,
|
||||||
max_batches: int = 100,
|
max_batches: int = 100,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._handler = handler
|
self._handler = handler
|
||||||
self.concurrency = max(1, int(concurrency or _configured_concurrency()))
|
self.concurrency = max(1, int(concurrency or _configured_concurrency()))
|
||||||
self.max_batches = max(10, int(max_batches or 100))
|
self.max_batches = max(10, int(max_batches or 100))
|
||||||
|
self.timeout_seconds = (
|
||||||
|
_configured_timeout_seconds()
|
||||||
|
if timeout_seconds is None
|
||||||
|
else max(0.0, float(timeout_seconds or 0.0))
|
||||||
|
)
|
||||||
self._queue: asyncio.Queue[JobToken] = asyncio.Queue()
|
self._queue: asyncio.Queue[JobToken] = asyncio.Queue()
|
||||||
self._pending_jobs: dict[int, JobToken] = {}
|
self._pending_jobs: dict[int, JobToken] = {}
|
||||||
self._active_tasks: dict[int, tuple[JobToken, asyncio.Task]] = {}
|
self._active_tasks: dict[int, tuple[JobToken, asyncio.Task]] = {}
|
||||||
@@ -157,7 +177,21 @@ class BatchStartQueue:
|
|||||||
)
|
)
|
||||||
self._active_tasks[account_id] = (job_token, handler_task)
|
self._active_tasks[account_id] = (job_token, handler_task)
|
||||||
|
|
||||||
result = await handler_task
|
if self.timeout_seconds > 0:
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
handler_task,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
# wait_for cancels its task only when this queue's
|
||||||
|
# deadline expires. Preserve a TimeoutError raised by
|
||||||
|
# the handler itself as its real account failure.
|
||||||
|
if handler_task.cancelled():
|
||||||
|
raise _StartPreparationTimeout from exc
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
result = await handler_task
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
record = self._batches.get(batch_id)
|
record = self._batches.get(batch_id)
|
||||||
if record:
|
if record:
|
||||||
@@ -171,6 +205,28 @@ class BatchStartQueue:
|
|||||||
elapsed_seconds=round(time.monotonic() - started_at, 3),
|
elapsed_seconds=round(time.monotonic() - started_at, 3),
|
||||||
)
|
)
|
||||||
record.updated_at = _utc_now()
|
record.updated_at = _utc_now()
|
||||||
|
except _StartPreparationTimeout:
|
||||||
|
elapsed = round(time.monotonic() - started_at, 3)
|
||||||
|
logger.warning(
|
||||||
|
"Batch start timed out account=%s worker=%s after %.1fs",
|
||||||
|
account_id,
|
||||||
|
worker_number,
|
||||||
|
self.timeout_seconds,
|
||||||
|
)
|
||||||
|
async with self._lock:
|
||||||
|
record = self._batches.get(batch_id)
|
||||||
|
if record:
|
||||||
|
item = record.items[account_id]
|
||||||
|
if item.get("status") != "cancelled":
|
||||||
|
item.update(
|
||||||
|
status="failed",
|
||||||
|
message=(
|
||||||
|
f"启动准备超过 {self.timeout_seconds:g} 秒,"
|
||||||
|
"已跳过并继续处理后续账号"
|
||||||
|
),
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
)
|
||||||
|
record.updated_at = _utc_now()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
record = self._batches.get(batch_id)
|
record = self._batches.get(batch_id)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from typing import Optional
|
|||||||
|
|
||||||
from rpa_engine.douyin_im.auth import DouyinAuth
|
from rpa_engine.douyin_im.auth import DouyinAuth
|
||||||
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
|
from rpa_engine.douyin_im.frontier import ensure_frontier_ws
|
||||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
|
||||||
from rpa_engine.douyin_im.session import DouyinImSession
|
from rpa_engine.douyin_im.session import DouyinImSession
|
||||||
from utils.cookie_store import analyze_cookie
|
from utils.cookie_store import analyze_cookie
|
||||||
|
|
||||||
@@ -131,13 +130,26 @@ async def build_cookie_credential_detail(
|
|||||||
async def validate_im_session(
|
async def validate_im_session(
|
||||||
session: DouyinImSession,
|
session: DouyinImSession,
|
||||||
_bypass_global_limit: bool = False,
|
_bypass_global_limit: bool = False,
|
||||||
|
*,
|
||||||
|
startup_priority: bool = False,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
if not _bypass_global_limit:
|
if not _bypass_global_limit:
|
||||||
from rpa_engine.douyin_im.traffic_control import get_traffic_controller
|
from rpa_engine.douyin_im.traffic_control import get_traffic_controller
|
||||||
|
|
||||||
controller = get_traffic_controller()
|
controller = get_traffic_controller()
|
||||||
async with controller.background_slot(0, "credential validation"):
|
# Startup validation must not sit behind hundreds of recurring
|
||||||
return await validate_im_session(session, _bypass_global_limit=True)
|
# conversation polls. It still shares the same global concurrency
|
||||||
|
# cap, so this changes ordering without increasing bandwidth usage.
|
||||||
|
async with controller.background_slot(
|
||||||
|
0,
|
||||||
|
"credential validation",
|
||||||
|
startup=startup_priority,
|
||||||
|
):
|
||||||
|
return await validate_im_session(
|
||||||
|
session,
|
||||||
|
_bypass_global_limit=True,
|
||||||
|
startup_priority=startup_priority,
|
||||||
|
)
|
||||||
|
|
||||||
if not session.can_direct_im():
|
if not session.can_direct_im():
|
||||||
if not has_im_session_token(session):
|
if not has_im_session_token(session):
|
||||||
@@ -155,17 +167,12 @@ async def validate_im_session(
|
|||||||
if not auth.is_sign_ready():
|
if not auth.is_sign_ready():
|
||||||
return False, "缺少 IM 签名密钥(web_protect/keys),请用浏览器登录补全"
|
return False, "缺少 IM 签名密钥(web_protect/keys),请用浏览器登录补全"
|
||||||
session.my_uid = int(uid)
|
session.my_uid = int(uid)
|
||||||
async with DouyinImHttpClient(session) as http:
|
# unread_count and ticket probes were previously issued here, but
|
||||||
await http.get_unread_count()
|
# neither result changed the final decision: unread failures become
|
||||||
# 若已缓存到会话票据,优先校验其是否仍新鲜(最理想)。
|
# zero and a stale/missing ticket is resolved lazily at send time.
|
||||||
if session.conv_meta:
|
# Keeping those probes doubled large-batch startup traffic without
|
||||||
ok, reason = await http.verify_messaging_capability(auth, session.my_uid)
|
# adding an authoritative validation signal.
|
||||||
if ok:
|
return True, "IM 凭证就绪(Cookie 与签名密钥齐全,可直连托管)"
|
||||||
return True, reason
|
|
||||||
# 没有缓存会话票据是首次登录的正常情况:会话 ticket 会在发送时即时
|
|
||||||
# 创建/获取(resolve_conversation_meta),因此只要 Cookie + sessionid +
|
|
||||||
# 签名密钥(web_protect/keys) + UID 齐全,就视为可 IM 直连托管,不必再开浏览器。
|
|
||||||
return True, "IM 凭证就绪(Cookie 与签名密钥齐全,可直连托管)"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"IM session validation failed: {e}")
|
logger.warning(f"IM session validation failed: {e}")
|
||||||
return False, f"IM 运行时验证失败: {e}"
|
return False, f"IM 运行时验证失败: {e}"
|
||||||
@@ -174,6 +181,8 @@ async def validate_im_session(
|
|||||||
async def assess_account_credential(
|
async def assess_account_credential(
|
||||||
cookie_data: Optional[str],
|
cookie_data: Optional[str],
|
||||||
im_session_data: Optional[str] = None,
|
im_session_data: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
startup_priority: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
cookie_info = analyze_cookie(cookie_data)
|
cookie_info = analyze_cookie(cookie_data)
|
||||||
result = {
|
result = {
|
||||||
@@ -212,7 +221,10 @@ async def assess_account_credential(
|
|||||||
result["should_reset"] = _should_reset_credentials(result)
|
result["should_reset"] = _should_reset_credentials(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
im_ok, im_reason = await validate_im_session(session)
|
im_ok, im_reason = await validate_im_session(
|
||||||
|
session,
|
||||||
|
startup_priority=startup_priority,
|
||||||
|
)
|
||||||
result["im_ready"] = im_ok
|
result["im_ready"] = im_ok
|
||||||
if im_ok:
|
if im_ok:
|
||||||
result["can_skip_browser"] = True
|
result["can_skip_browser"] = True
|
||||||
|
|||||||
@@ -737,7 +737,7 @@ class DouyinImHttpClient:
|
|||||||
pass
|
pass
|
||||||
return total
|
return total
|
||||||
|
|
||||||
async def get_conversations(self) -> list[dict]:
|
async def get_conversations(self, *, enrich_profiles: bool = True) -> list[dict]:
|
||||||
"""拉取会话列表,返回标准化会话"""
|
"""拉取会话列表,返回标准化会话"""
|
||||||
payloads = [
|
payloads = [
|
||||||
{"cursor": 0, "count": 50, "inbox_type": 0},
|
{"cursor": 0, "count": 50, "inbox_type": 0},
|
||||||
@@ -754,7 +754,12 @@ class DouyinImHttpClient:
|
|||||||
if data is None:
|
if data is None:
|
||||||
data = await self._request("GET", "/v1/conversation/list", body)
|
data = await self._request("GET", "/v1/conversation/list", body)
|
||||||
if data is None:
|
if data is None:
|
||||||
continue
|
# Payload variants only help with schema compatibility. They
|
||||||
|
# cannot repair a network outage, so stop after POST + GET
|
||||||
|
# both fail instead of occupying a scarce global slot for up
|
||||||
|
# to four more full request timeouts.
|
||||||
|
logger.warning("Conversation poll transport failed; skipping payload fallbacks")
|
||||||
|
break
|
||||||
|
|
||||||
status_code = data.get("status_code") if isinstance(data, dict) else None
|
status_code = data.get("status_code") if isinstance(data, dict) else None
|
||||||
error_text = ""
|
error_text = ""
|
||||||
@@ -814,6 +819,9 @@ class DouyinImHttpClient:
|
|||||||
enriched: list[dict] = []
|
enriched: list[dict] = []
|
||||||
for item in conversations:
|
for item in conversations:
|
||||||
conv = enrich_conversation_item(item, my_uid)
|
conv = enrich_conversation_item(item, my_uid)
|
||||||
|
if not enrich_profiles:
|
||||||
|
enriched.append(conv)
|
||||||
|
continue
|
||||||
peer_uid = str(conv.get("peer_uid") or "")
|
peer_uid = str(conv.get("peer_uid") or "")
|
||||||
name = (conv.get("sender_name") or "").strip()
|
name = (conv.get("sender_name") or "").strip()
|
||||||
avatar = str(conv.get("sender_avatar") or "").strip()
|
avatar = str(conv.get("sender_avatar") or "").strip()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Awaitable, Callable, Optional
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
@@ -26,6 +27,30 @@ LogFn = Callable[..., Awaitable[None]]
|
|||||||
ReceivedLogFn = Callable[..., Awaitable[None]]
|
ReceivedLogFn = Callable[..., Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def _env_poll_seconds(name: str, default: float, minimum: float = 5.0) -> float:
|
||||||
|
try:
|
||||||
|
return max(minimum, float(os.getenv(name, str(default))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _conversation_poll_timing(account_id: int, has_ws: bool) -> tuple[float, float]:
|
||||||
|
"""Return the reconciliation interval and a stable per-account stagger.
|
||||||
|
|
||||||
|
WebSocket is the real-time receive path. HTTP polling is only a safety
|
||||||
|
reconciliation when that path exists, so running it every 15 seconds for
|
||||||
|
hundreds of accounts wastes bandwidth and eventually starves new starts.
|
||||||
|
Accounts without WebSocket keep the original fast polling cadence.
|
||||||
|
"""
|
||||||
|
interval = _env_poll_seconds(
|
||||||
|
"KEFU_WS_RECONCILE_INTERVAL_SECONDS" if has_ws else "KEFU_HTTP_POLL_INTERVAL_SECONDS",
|
||||||
|
120.0 if has_ws else 15.0,
|
||||||
|
)
|
||||||
|
spread_ms = max(1, int(interval * 1000))
|
||||||
|
stagger = ((int(account_id or 0) * 2654435761) % spread_ms) / 1000.0
|
||||||
|
return interval, stagger
|
||||||
|
|
||||||
|
|
||||||
class DouyinImService:
|
class DouyinImService:
|
||||||
"""抖音 IM 直连服务:WebSocket 实时监听 + HTTP 轮询 + 自动回复"""
|
"""抖音 IM 直连服务:WebSocket 实时监听 + HTTP 轮询 + 自动回复"""
|
||||||
|
|
||||||
@@ -734,11 +759,18 @@ class DouyinImService:
|
|||||||
controller = get_traffic_controller()
|
controller = get_traffic_controller()
|
||||||
async with controller.background_slot(self.account_id, "conversation poll"):
|
async with controller.background_slot(self.account_id, "conversation poll"):
|
||||||
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
|
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
|
||||||
unread_total = await http.get_unread_count()
|
conversations = await http.get_conversations(enrich_profiles=False)
|
||||||
if unread_total:
|
# Profile enrichment may involve several slow third-party requests.
|
||||||
logger.info(f"IM unread total: {unread_total}")
|
# Run it after releasing the conversation-list slot; each individual
|
||||||
conversations = await http.get_conversations()
|
# lookup re-enters the shared controller and yields fairly to startup
|
||||||
await self._index_conversations(conversations)
|
# validation and other accounts between profiles.
|
||||||
|
await self._index_conversations(conversations)
|
||||||
|
unread_total = sum(
|
||||||
|
max(0, int(item.get("unread_count") or 0))
|
||||||
|
for item in conversations
|
||||||
|
)
|
||||||
|
if unread_total:
|
||||||
|
logger.info(f"IM unread total: {unread_total}")
|
||||||
# Message handling may wait in the global send lane. Do not keep one
|
# Message handling may wait in the global send lane. Do not keep one
|
||||||
# of the scarce background HTTP slots occupied while that happens.
|
# of the scarce background HTTP slots occupied while that happens.
|
||||||
for conv in conversations:
|
for conv in conversations:
|
||||||
@@ -811,8 +843,10 @@ class DouyinImService:
|
|||||||
)
|
)
|
||||||
await self._ws_client.start()
|
await self._ws_client.start()
|
||||||
|
|
||||||
|
initial_poll_succeeded = False
|
||||||
try:
|
try:
|
||||||
await self._poll_conversations()
|
await self._poll_conversations()
|
||||||
|
initial_poll_succeeded = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Initial conversation poll failed: {e}")
|
logger.warning(f"Initial conversation poll failed: {e}")
|
||||||
system_logger.record(
|
system_logger.record(
|
||||||
@@ -823,6 +857,34 @@ class DouyinImService:
|
|||||||
account_id=self.account_id,
|
account_id=self.account_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ws_connected = bool(
|
||||||
|
self._ws_client and getattr(self._ws_client, "connected", False)
|
||||||
|
)
|
||||||
|
poll_interval, poll_stagger = _conversation_poll_timing(
|
||||||
|
self.account_id,
|
||||||
|
ws_connected,
|
||||||
|
)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
if initial_poll_succeeded:
|
||||||
|
initial_retry_interval = poll_interval
|
||||||
|
initial_retry_stagger = poll_stagger
|
||||||
|
else:
|
||||||
|
# If the authoritative first poll failed, retry on the fast HTTP
|
||||||
|
# cadence even when WebSocket connected in the meantime.
|
||||||
|
initial_retry_interval, initial_retry_stagger = (
|
||||||
|
_conversation_poll_timing(self.account_id, False)
|
||||||
|
)
|
||||||
|
next_conversation_poll_at = (
|
||||||
|
loop.time() + initial_retry_interval + initial_retry_stagger
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Conversation reconciliation account=%s interval=%.1fs stagger=%.1fs ws=%s",
|
||||||
|
self.account_id,
|
||||||
|
poll_interval,
|
||||||
|
poll_stagger,
|
||||||
|
"yes" if ws_connected else "no",
|
||||||
|
)
|
||||||
|
|
||||||
loop_count = 0
|
loop_count = 0
|
||||||
while self._running:
|
while self._running:
|
||||||
# The initial poll above is authoritative. Sleep before the next
|
# The initial poll above is authoritative. Sleep before the next
|
||||||
@@ -832,8 +894,39 @@ class DouyinImService:
|
|||||||
break
|
break
|
||||||
loop_count += 1
|
loop_count += 1
|
||||||
try:
|
try:
|
||||||
if loop_count % 3 == 0:
|
current_ws_connected = bool(
|
||||||
await self._poll_conversations()
|
self._ws_client
|
||||||
|
and getattr(self._ws_client, "connected", False)
|
||||||
|
)
|
||||||
|
if current_ws_connected != ws_connected:
|
||||||
|
ws_connected = current_ws_connected
|
||||||
|
poll_interval, poll_stagger = _conversation_poll_timing(
|
||||||
|
self.account_id,
|
||||||
|
ws_connected,
|
||||||
|
)
|
||||||
|
candidate_poll_at = loop.time() + poll_interval + poll_stagger
|
||||||
|
# Never postpone an already scheduled reconciliation.
|
||||||
|
# In particular, reconnecting must preserve the earlier
|
||||||
|
# fallback poll that covers messages missed while offline.
|
||||||
|
next_conversation_poll_at = min(
|
||||||
|
next_conversation_poll_at,
|
||||||
|
candidate_poll_at,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Conversation reconciliation rescheduled account=%s "
|
||||||
|
"interval=%.1fs ws=%s",
|
||||||
|
self.account_id,
|
||||||
|
poll_interval,
|
||||||
|
"yes" if ws_connected else "no",
|
||||||
|
)
|
||||||
|
if loop.time() >= next_conversation_poll_at:
|
||||||
|
try:
|
||||||
|
await self._poll_conversations()
|
||||||
|
finally:
|
||||||
|
# Advance on both success and failure. Otherwise a
|
||||||
|
# past deadline retries every five-second loop tick
|
||||||
|
# during an outage and amplifies traffic.
|
||||||
|
next_conversation_poll_at = loop.time() + poll_interval
|
||||||
if loop_count % 6 == 0:
|
if loop_count % 6 == 0:
|
||||||
logger.info(f"IM direct tick #{loop_count} account={self.account_id}")
|
logger.info(f"IM direct tick #{loop_count} account={self.account_id}")
|
||||||
# 关注欢迎语:约每 60s 检测一次新粉丝(独立于私信轮询,失败不影响主循环)
|
# 关注欢迎语:约每 60s 检测一次新粉丝(独立于私信轮询,失败不影响主循环)
|
||||||
|
|||||||
@@ -396,6 +396,15 @@ class TrafficController:
|
|||||||
self._background = asyncio.Semaphore(
|
self._background = asyncio.Semaphore(
|
||||||
_env_int("KEFU_BACKGROUND_NETWORK_CONCURRENCY", 2)
|
_env_int("KEFU_BACKGROUND_NETWORK_CONCURRENCY", 2)
|
||||||
)
|
)
|
||||||
|
# Recurring polls can create hundreds of waiters when many accounts
|
||||||
|
# are online. Admit at most one normal waiter to the semaphore at a
|
||||||
|
# time so startup validation can join near the front instead of being
|
||||||
|
# buried behind the entire polling backlog. The shared semaphore is
|
||||||
|
# still the single bandwidth cap; startup work does not add extra
|
||||||
|
# network concurrency.
|
||||||
|
self._background_normal_admission = asyncio.Lock()
|
||||||
|
self._background_startup_clear = asyncio.Event()
|
||||||
|
self._background_startup_clear.set()
|
||||||
self._browser = asyncio.Semaphore(
|
self._browser = asyncio.Semaphore(
|
||||||
_env_int("KEFU_BROWSER_START_CONCURRENCY", 1)
|
_env_int("KEFU_BROWSER_START_CONCURRENCY", 1)
|
||||||
)
|
)
|
||||||
@@ -410,12 +419,20 @@ class TrafficController:
|
|||||||
)
|
)
|
||||||
self.background_waiting = 0
|
self.background_waiting = 0
|
||||||
self.background_active = 0
|
self.background_active = 0
|
||||||
|
self.background_startup_waiting = 0
|
||||||
|
self.background_startup_active = 0
|
||||||
self.browser_waiting = 0
|
self.browser_waiting = 0
|
||||||
self.browser_active = 0
|
self.browser_active = 0
|
||||||
self.media_proxy_active = 0
|
self.media_proxy_active = 0
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def background_slot(self, account_id: int = 0, description: str = "request"):
|
async def background_slot(
|
||||||
|
self,
|
||||||
|
account_id: int = 0,
|
||||||
|
description: str = "request",
|
||||||
|
*,
|
||||||
|
startup: bool = False,
|
||||||
|
):
|
||||||
current_task = asyncio.current_task()
|
current_task = asyncio.current_task()
|
||||||
owner_task, depth = self._background_owner.get()
|
owner_task, depth = self._background_owner.get()
|
||||||
if owner_task is current_task and depth > 0:
|
if owner_task is current_task and depth > 0:
|
||||||
@@ -429,12 +446,29 @@ class TrafficController:
|
|||||||
started = asyncio.get_running_loop().time()
|
started = asyncio.get_running_loop().time()
|
||||||
self.background_waiting += 1
|
self.background_waiting += 1
|
||||||
try:
|
try:
|
||||||
await self._background.acquire()
|
if startup:
|
||||||
|
self.background_startup_waiting += 1
|
||||||
|
self._background_startup_clear.clear()
|
||||||
|
try:
|
||||||
|
await self._background.acquire()
|
||||||
|
finally:
|
||||||
|
self.background_startup_waiting -= 1
|
||||||
|
if self.background_startup_waiting == 0:
|
||||||
|
self._background_startup_clear.set()
|
||||||
|
else:
|
||||||
|
# Only one recurring/background request may wait directly on
|
||||||
|
# the shared semaphore. A later startup request therefore
|
||||||
|
# has at most one normal request ahead of it, not hundreds.
|
||||||
|
async with self._background_normal_admission:
|
||||||
|
await self._background_startup_clear.wait()
|
||||||
|
await self._background.acquire()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
self.background_waiting -= 1
|
self.background_waiting -= 1
|
||||||
raise
|
raise
|
||||||
self.background_waiting -= 1
|
self.background_waiting -= 1
|
||||||
self.background_active += 1
|
self.background_active += 1
|
||||||
|
if startup:
|
||||||
|
self.background_startup_active += 1
|
||||||
token = self._background_owner.set((current_task, 1))
|
token = self._background_owner.set((current_task, 1))
|
||||||
waited = asyncio.get_running_loop().time() - started
|
waited = asyncio.get_running_loop().time() - started
|
||||||
if waited >= 1.0:
|
if waited >= 1.0:
|
||||||
@@ -448,6 +482,8 @@ class TrafficController:
|
|||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
self._background_owner.reset(token)
|
self._background_owner.reset(token)
|
||||||
|
if startup:
|
||||||
|
self.background_startup_active -= 1
|
||||||
self.background_active -= 1
|
self.background_active -= 1
|
||||||
self._background.release()
|
self._background.release()
|
||||||
|
|
||||||
@@ -492,6 +528,8 @@ class TrafficController:
|
|||||||
"background": {
|
"background": {
|
||||||
"active": self.background_active,
|
"active": self.background_active,
|
||||||
"waiting": self.background_waiting,
|
"waiting": self.background_waiting,
|
||||||
|
"startup_active": self.background_startup_active,
|
||||||
|
"startup_waiting": self.background_startup_waiting,
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"active": self.browser_active,
|
"active": self.browser_active,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class DouyinImWsClient:
|
|||||||
self.on_message = on_message
|
self.on_message = on_message
|
||||||
self.account_id = account_id
|
self.account_id = account_id
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self.connected = False
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._ws_app: Optional[WebSocketApp] = None
|
self._ws_app: Optional[WebSocketApp] = None
|
||||||
@@ -50,6 +51,7 @@ class DouyinImWsClient:
|
|||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self.connected = False
|
||||||
with self._ws_lock:
|
with self._ws_lock:
|
||||||
if self._ws_app:
|
if self._ws_app:
|
||||||
try:
|
try:
|
||||||
@@ -150,6 +152,7 @@ class DouyinImWsClient:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def on_open(_ws):
|
def on_open(_ws):
|
||||||
|
self.connected = True
|
||||||
logger.info("IM WebSocket connected")
|
logger.info("IM WebSocket connected")
|
||||||
system_logger.record(
|
system_logger.record(
|
||||||
"实时接收通道已连接",
|
"实时接收通道已连接",
|
||||||
@@ -174,6 +177,7 @@ class DouyinImWsClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def on_close(_ws, code, msg):
|
def on_close(_ws, code, msg):
|
||||||
|
self.connected = False
|
||||||
logger.info(f"IM WebSocket closed: code={code}, msg={msg}")
|
logger.info(f"IM WebSocket closed: code={code}, msg={msg}")
|
||||||
if self._running:
|
if self._running:
|
||||||
system_logger.record(
|
system_logger.record(
|
||||||
@@ -206,6 +210,7 @@ class DouyinImWsClient:
|
|||||||
try:
|
try:
|
||||||
ws_app.run_forever(origin="https://www.douyin.com", ping_interval=20, ping_timeout=10)
|
ws_app.run_forever(origin="https://www.douyin.com", ping_interval=20, ping_timeout=10)
|
||||||
finally:
|
finally:
|
||||||
|
self.connected = False
|
||||||
with self._ws_lock:
|
with self._ws_lock:
|
||||||
if self._ws_app is ws_app:
|
if self._ws_app is ws_app:
|
||||||
self._ws_app = None
|
self._ws_app = None
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
os.environ["KEFU_DB_TYPE"] = "sqlite"
|
||||||
|
os.environ["KEFU_DATABASE_URL"] = ""
|
||||||
|
os.environ["KEFU_DB_PATH"] = str(BACKEND_DIR / "kefu.db")
|
||||||
|
if str(BACKEND_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_DIR))
|
||||||
|
|
||||||
|
import main
|
||||||
|
from models.database import Base
|
||||||
|
from models.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class _CountResult:
|
||||||
|
def __init__(self, count: int):
|
||||||
|
self.count = count
|
||||||
|
|
||||||
|
def scalar_one(self):
|
||||||
|
return self.count
|
||||||
|
|
||||||
|
|
||||||
|
class _RowsResult:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self.rows = list(rows)
|
||||||
|
|
||||||
|
def scalars(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self.rows)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPaginationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_paginated_list_counts_then_loads_only_current_page(self):
|
||||||
|
page_rows = [
|
||||||
|
SimpleNamespace(id=10, status="offline"),
|
||||||
|
SimpleNamespace(id=11, status="online"),
|
||||||
|
]
|
||||||
|
db = SimpleNamespace(
|
||||||
|
execute=AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_CountResult(392),
|
||||||
|
_RowsResult(page_rows),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(main.manager, "is_running", side_effect=[False, True]),
|
||||||
|
patch.object(
|
||||||
|
main,
|
||||||
|
"_build_account_response",
|
||||||
|
side_effect=lambda account: {"id": account.id, "status": account.status},
|
||||||
|
) as build_response,
|
||||||
|
):
|
||||||
|
response = await main.get_accounts(
|
||||||
|
page=20,
|
||||||
|
page_size=20,
|
||||||
|
q=None,
|
||||||
|
status=None,
|
||||||
|
db=db,
|
||||||
|
user=SimpleNamespace(id=1, role="admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(db.execute.await_count, 2)
|
||||||
|
self.assertEqual(response["total"], 392)
|
||||||
|
self.assertEqual(response["page"], 20)
|
||||||
|
self.assertEqual(response["page_size"], 20)
|
||||||
|
self.assertEqual([item["id"] for item in response["items"]], [10, 11])
|
||||||
|
self.assertEqual(build_response.call_count, 2)
|
||||||
|
|
||||||
|
count_sql = str(db.execute.await_args_list[0].args[0]).upper()
|
||||||
|
page_sql = str(db.execute.await_args_list[1].args[0]).upper()
|
||||||
|
self.assertIn("COUNT", count_sql)
|
||||||
|
self.assertNotIn(" LIMIT ", count_sql)
|
||||||
|
self.assertIn(" LIMIT ", page_sql)
|
||||||
|
self.assertIn(" OFFSET ", page_sql)
|
||||||
|
|
||||||
|
async def test_status_filter_uses_effective_runtime_worker_state(self):
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
session_factory = sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
original_workers = main.manager.workers
|
||||||
|
main.manager.workers = {
|
||||||
|
1001: SimpleNamespace(is_running=True),
|
||||||
|
1002: SimpleNamespace(is_running=False),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with session_factory() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Account(id=1001, status="offline"),
|
||||||
|
Account(id=1002, status="online"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
main,
|
||||||
|
"_build_account_response",
|
||||||
|
side_effect=lambda account: {
|
||||||
|
"id": account.id,
|
||||||
|
"status": account.status,
|
||||||
|
},
|
||||||
|
):
|
||||||
|
online = await main.get_accounts(
|
||||||
|
page=1,
|
||||||
|
page_size=20,
|
||||||
|
q=None,
|
||||||
|
status="online",
|
||||||
|
db=db,
|
||||||
|
user=SimpleNamespace(id=1, role="admin"),
|
||||||
|
)
|
||||||
|
await db.rollback()
|
||||||
|
db.expire_all()
|
||||||
|
offline = await main.get_accounts(
|
||||||
|
page=1,
|
||||||
|
page_size=20,
|
||||||
|
q=None,
|
||||||
|
status="offline",
|
||||||
|
db=db,
|
||||||
|
user=SimpleNamespace(id=1, role="admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(online["total"], 1)
|
||||||
|
self.assertEqual(online["items"], [{"id": 1001, "status": "online"}])
|
||||||
|
self.assertEqual(offline["total"], 1)
|
||||||
|
self.assertEqual(offline["items"], [{"id": 1002, "status": "offline"}])
|
||||||
|
finally:
|
||||||
|
main.manager.workers = original_workers
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -17,8 +17,18 @@ from rpa_engine import batch_start as batch_start_module
|
|||||||
|
|
||||||
|
|
||||||
class BatchStartQueueTests(unittest.IsolatedAsyncioTestCase):
|
class BatchStartQueueTests(unittest.IsolatedAsyncioTestCase):
|
||||||
def _make_queue(self, handler, *, concurrency: int = 2) -> BatchStartQueue:
|
def _make_queue(
|
||||||
queue = BatchStartQueue(handler, concurrency=concurrency)
|
self,
|
||||||
|
handler,
|
||||||
|
*,
|
||||||
|
concurrency: int = 2,
|
||||||
|
timeout_seconds: float | None = None,
|
||||||
|
) -> BatchStartQueue:
|
||||||
|
queue = BatchStartQueue(
|
||||||
|
handler,
|
||||||
|
concurrency=concurrency,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
self.addAsyncCleanup(queue.stop)
|
self.addAsyncCleanup(queue.stop)
|
||||||
return queue
|
return queue
|
||||||
|
|
||||||
@@ -168,6 +178,54 @@ class BatchStartQueueTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(by_account[32]["status"], "submitted")
|
self.assertEqual(by_account[32]["status"], "submitted")
|
||||||
self.assertEqual(by_account[33]["status"], "submitted")
|
self.assertEqual(by_account[33]["status"], "submitted")
|
||||||
|
|
||||||
|
async def test_two_timeouts_release_both_workers_for_following_accounts(self):
|
||||||
|
never_release = asyncio.Event()
|
||||||
|
calls: list[int] = []
|
||||||
|
|
||||||
|
async def handler(account_id: int) -> dict:
|
||||||
|
calls.append(account_id)
|
||||||
|
if account_id in (71, 72):
|
||||||
|
await never_release.wait()
|
||||||
|
return {"message": f"started-{account_id}"}
|
||||||
|
|
||||||
|
queue = self._make_queue(
|
||||||
|
handler,
|
||||||
|
concurrency=2,
|
||||||
|
timeout_seconds=0.02,
|
||||||
|
)
|
||||||
|
with patch.object(batch_start_module.logger, "warning"):
|
||||||
|
submitted = await queue.submit([71, 72, 73, 74])
|
||||||
|
completed = await self._wait_for_complete(
|
||||||
|
queue,
|
||||||
|
submitted["batch_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(calls, [71, 72, 73, 74])
|
||||||
|
self.assertEqual(completed["failed_count"], 2)
|
||||||
|
self.assertEqual(completed["submitted_count"], 2)
|
||||||
|
by_account = {item["account_id"]: item for item in completed["items"]}
|
||||||
|
self.assertIn("已跳过并继续处理后续账号", by_account[71]["message"])
|
||||||
|
self.assertIn("已跳过并继续处理后续账号", by_account[72]["message"])
|
||||||
|
self.assertEqual(by_account[73]["status"], "submitted")
|
||||||
|
self.assertEqual(by_account[74]["status"], "submitted")
|
||||||
|
|
||||||
|
async def test_handler_timeout_error_keeps_its_original_detail(self):
|
||||||
|
async def handler(_account_id: int) -> dict:
|
||||||
|
raise asyncio.TimeoutError("upstream request timed out")
|
||||||
|
|
||||||
|
queue = self._make_queue(
|
||||||
|
handler,
|
||||||
|
concurrency=1,
|
||||||
|
timeout_seconds=10,
|
||||||
|
)
|
||||||
|
with patch.object(batch_start_module.logger, "exception"):
|
||||||
|
submitted = await queue.submit([75])
|
||||||
|
completed = await self._wait_for_complete(queue, submitted["batch_id"])
|
||||||
|
|
||||||
|
item = completed["items"][0]
|
||||||
|
self.assertEqual(item["status"], "failed")
|
||||||
|
self.assertEqual(item["message"], "upstream request timed out")
|
||||||
|
|
||||||
async def test_failed_account_can_be_submitted_again(self):
|
async def test_failed_account_can_be_submitted_again(self):
|
||||||
attempts = 0
|
attempts = 0
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||||
@@ -16,6 +17,8 @@ if str(BACKEND_DIR) not in sys.path:
|
|||||||
|
|
||||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||||
from rpa_engine.douyin_im.session import DouyinImSession
|
from rpa_engine.douyin_im.session import DouyinImSession
|
||||||
|
from rpa_engine.douyin_im.service import DouyinImService, _conversation_poll_timing
|
||||||
|
from rpa_engine.douyin_im import service as service_module
|
||||||
|
|
||||||
|
|
||||||
class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
||||||
@@ -75,6 +78,71 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
["POST", "GET"],
|
["POST", "GET"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_transport_outage_stops_after_one_post_and_get_pair(self):
|
||||||
|
client = self._make_client()
|
||||||
|
client._request = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with self.assertLogs("douyin_im.http", level="WARNING"):
|
||||||
|
self.assertEqual(await client.get_conversations(), [])
|
||||||
|
|
||||||
|
self.assertEqual(client._request.await_count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
[call.args[0] for call in client._request.await_args_list],
|
||||||
|
["POST", "GET"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_websocket_reconciliation_is_slow_and_http_fallback_stays_fast(self):
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"KEFU_WS_RECONCILE_INTERVAL_SECONDS": "120",
|
||||||
|
"KEFU_HTTP_POLL_INTERVAL_SECONDS": "15",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
ws_interval, ws_stagger = _conversation_poll_timing(123, True)
|
||||||
|
http_interval, http_stagger = _conversation_poll_timing(123, False)
|
||||||
|
|
||||||
|
self.assertEqual(ws_interval, 120)
|
||||||
|
self.assertEqual(http_interval, 15)
|
||||||
|
self.assertGreaterEqual(ws_stagger, 0)
|
||||||
|
self.assertLess(ws_stagger, ws_interval)
|
||||||
|
self.assertGreaterEqual(http_stagger, 0)
|
||||||
|
self.assertLess(http_stagger, http_interval)
|
||||||
|
|
||||||
|
async def test_service_poll_uses_one_conversation_request_without_unread_probe(self):
|
||||||
|
class _Controller:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def background_slot(self, *_args, **_kwargs):
|
||||||
|
yield
|
||||||
|
|
||||||
|
class _HttpClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.get_conversations = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
http = _HttpClient()
|
||||||
|
service = DouyinImService(
|
||||||
|
session=DouyinImSession(cookies={"sessionid": "test"}, my_uid=10001),
|
||||||
|
match_reply=AsyncMock(return_value=None),
|
||||||
|
log_fn=AsyncMock(),
|
||||||
|
account_id=9,
|
||||||
|
)
|
||||||
|
service._index_conversations = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(service_module, "get_traffic_controller", return_value=_Controller()),
|
||||||
|
patch.object(service_module, "DouyinImHttpClient", return_value=http),
|
||||||
|
):
|
||||||
|
await service._poll_conversations()
|
||||||
|
|
||||||
|
http.get_conversations.assert_awaited_once_with(enrich_profiles=False)
|
||||||
|
service._index_conversations.assert_awaited_once_with([])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -84,6 +84,27 @@ class ReplyQueueApiTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(response.total_pending, 1)
|
self.assertEqual(response.total_pending, 1)
|
||||||
self.assertEqual([item.account_id for item in response.items], [1])
|
self.assertEqual([item.account_id for item in response.items], [1])
|
||||||
|
|
||||||
|
async def test_summary_with_account_ids_only_scans_requested_page(self):
|
||||||
|
service_one = _FakeService(1, [_queue_item(1)])
|
||||||
|
service_two = _FakeService(2, [_queue_item(2, "job-2")])
|
||||||
|
service_one.get_reply_queue_snapshot = AsyncMock(return_value=service_one.items)
|
||||||
|
service_two.get_reply_queue_snapshot = AsyncMock(return_value=service_two.items)
|
||||||
|
main.manager.workers = {
|
||||||
|
1: SimpleNamespace(is_running=True, _im_service=service_one),
|
||||||
|
2: SimpleNamespace(is_running=True, _im_service=service_two),
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await main.get_reply_queue_summaries(
|
||||||
|
account_ids="2",
|
||||||
|
db=object(),
|
||||||
|
user=SimpleNamespace(id=1, role="admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
service_one.get_reply_queue_snapshot.assert_not_awaited()
|
||||||
|
service_two.get_reply_queue_snapshot.assert_awaited_once()
|
||||||
|
self.assertEqual(response.total_pending, 1)
|
||||||
|
self.assertEqual([item.account_id for item in response.items], [2])
|
||||||
|
|
||||||
async def test_offline_account_detail_returns_empty_snapshot(self):
|
async def test_offline_account_detail_returns_empty_snapshot(self):
|
||||||
account = SimpleNamespace(id=1, reply_delay_seconds=0)
|
account = SimpleNamespace(id=1, reply_delay_seconds=0)
|
||||||
with (
|
with (
|
||||||
|
|||||||
@@ -458,6 +458,70 @@ class BackgroundTrafficLimitTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(len(tasks_seen), 3)
|
self.assertEqual(len(tasks_seen), 3)
|
||||||
self.assertTrue(all(task is tasks_seen[0] for task in tasks_seen))
|
self.assertTrue(all(task is tasks_seen[0] for task in tasks_seen))
|
||||||
|
|
||||||
|
async def test_startup_request_is_not_buried_behind_normal_backlog(self):
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"KEFU_BACKGROUND_NETWORK_CONCURRENCY": "1"},
|
||||||
|
):
|
||||||
|
controller = TrafficController()
|
||||||
|
self.addAsyncCleanup(controller.stop)
|
||||||
|
|
||||||
|
entered: list[str] = []
|
||||||
|
releases = {
|
||||||
|
name: asyncio.Event()
|
||||||
|
for name in ("active", "normal-1", "normal-2", "startup")
|
||||||
|
}
|
||||||
|
|
||||||
|
async def request(name: str, *, startup: bool = False) -> None:
|
||||||
|
async with controller.background_slot(
|
||||||
|
1,
|
||||||
|
name,
|
||||||
|
startup=startup,
|
||||||
|
):
|
||||||
|
entered.append(name)
|
||||||
|
await releases[name].wait()
|
||||||
|
|
||||||
|
active = asyncio.create_task(request("active"))
|
||||||
|
while entered != ["active"]:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
normal_one = asyncio.create_task(request("normal-1"))
|
||||||
|
normal_two = asyncio.create_task(request("normal-2"))
|
||||||
|
# Let one normal request reach the shared semaphore while the other is
|
||||||
|
# held at normal admission, then add the priority startup request.
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
startup = asyncio.create_task(request("startup", startup=True))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
releases["active"].set()
|
||||||
|
while len(entered) < 2:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
self.assertEqual(entered[:2], ["active", "normal-1"])
|
||||||
|
|
||||||
|
releases["normal-1"].set()
|
||||||
|
while len(entered) < 3:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
self.assertEqual(entered[:3], ["active", "normal-1", "startup"])
|
||||||
|
|
||||||
|
releases["startup"].set()
|
||||||
|
while len(entered) < 4:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
releases["normal-2"].set()
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(active, normal_one, normal_two, startup),
|
||||||
|
timeout=0.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
entered,
|
||||||
|
["active", "normal-1", "startup", "normal-2"],
|
||||||
|
)
|
||||||
|
self.assertEqual(controller.background_active, 0)
|
||||||
|
self.assertEqual(controller.background_waiting, 0)
|
||||||
|
self.assertEqual(controller.background_startup_active, 0)
|
||||||
|
self.assertEqual(controller.background_startup_waiting, 0)
|
||||||
|
|
||||||
|
|
||||||
class TrafficControllerLoopIsolationTests(unittest.TestCase):
|
class TrafficControllerLoopIsolationTests(unittest.TestCase):
|
||||||
def test_get_traffic_controller_does_not_reuse_asyncio_primitives(self):
|
def test_get_traffic_controller_does_not_reuse_asyncio_primitives(self):
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -76,6 +76,13 @@ let batchStatusTimer = null
|
|||||||
let batchStatusRequestActive = false
|
let batchStatusRequestActive = false
|
||||||
let batchStatusGeneration = 0
|
let batchStatusGeneration = 0
|
||||||
const BATCH_STATUS_POLL_MS = 1500
|
const BATCH_STATUS_POLL_MS = 1500
|
||||||
|
const BATCH_STATUS_MAX_POLL_MS = 5000
|
||||||
|
const BATCH_STATUS_BACKOFF_STEP_MS = 750
|
||||||
|
let batchStatusPollDelayMs = BATCH_STATUS_POLL_MS
|
||||||
|
let batchStatusLastFinished = null
|
||||||
|
let batchStatusLastTotal = null
|
||||||
|
let batchStatusLastProcessing = null
|
||||||
|
let batchStatusLastQueued = null
|
||||||
const selectedIds = ref([])
|
const selectedIds = ref([])
|
||||||
const addVisible = ref(false)
|
const addVisible = ref(false)
|
||||||
const addSaving = ref(false)
|
const addSaving = ref(false)
|
||||||
@@ -711,9 +718,18 @@ const applyQueueSnapshot = (data, accountId) => {
|
|||||||
|
|
||||||
const fetchReplyQueueSummaries = async ({ silent = true } = {}) => {
|
const fetchReplyQueueSummaries = async ({ silent = true } = {}) => {
|
||||||
if (queueSummaryLoading.value) return
|
if (queueSummaryLoading.value) return
|
||||||
|
const accountIds = accounts.value
|
||||||
|
.map((account) => Number(account?.id))
|
||||||
|
.filter((accountId) => Number.isInteger(accountId) && accountId > 0)
|
||||||
|
if (!accountIds.length) {
|
||||||
|
replyQueueSummaries.value = {}
|
||||||
|
return
|
||||||
|
}
|
||||||
queueSummaryLoading.value = true
|
queueSummaryLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/reply-queues')
|
const res = await api.get('/reply-queues', {
|
||||||
|
params: { account_ids: accountIds.join(',') }
|
||||||
|
})
|
||||||
const rows = Array.isArray(res.data?.items) ? res.data.items : []
|
const rows = Array.isArray(res.data?.items) ? res.data.items : []
|
||||||
const next = {}
|
const next = {}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -1152,6 +1168,11 @@ const stopBatchStatusPolling = () => {
|
|||||||
batchStatusTimer = null
|
batchStatusTimer = null
|
||||||
}
|
}
|
||||||
batchStatusRequestActive = false
|
batchStatusRequestActive = false
|
||||||
|
batchStatusPollDelayMs = BATCH_STATUS_POLL_MS
|
||||||
|
batchStatusLastFinished = null
|
||||||
|
batchStatusLastTotal = null
|
||||||
|
batchStatusLastProcessing = null
|
||||||
|
batchStatusLastQueued = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const finishBatchStart = async (snapshot) => {
|
const finishBatchStart = async (snapshot) => {
|
||||||
@@ -1181,6 +1202,7 @@ const finishBatchStart = async (snapshot) => {
|
|||||||
}
|
}
|
||||||
await fetchAccounts()
|
await fetchAccounts()
|
||||||
batchStarting.value = false
|
batchStarting.value = false
|
||||||
|
startReplyQueueSummaryPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollBatchStartStatus = (batchId, initialSnapshot = null) => {
|
const pollBatchStartStatus = (batchId, initialSnapshot = null) => {
|
||||||
@@ -1196,11 +1218,31 @@ const pollBatchStartStatus = (batchId, initialSnapshot = null) => {
|
|||||||
Math.max(0, Number(snapshot?.failed_count) || 0) +
|
Math.max(0, Number(snapshot?.failed_count) || 0) +
|
||||||
Math.max(0, Number(snapshot?.skipped_count) || 0) +
|
Math.max(0, Number(snapshot?.skipped_count) || 0) +
|
||||||
Math.max(0, Number(snapshot?.cancelled_count) || 0)
|
Math.max(0, Number(snapshot?.cancelled_count) || 0)
|
||||||
message.loading({
|
const processing = Math.max(0, Number(snapshot?.processing_count) || 0)
|
||||||
content: `账号启动队列处理中:${Math.min(finished, total)}/${total}`,
|
const queued = Math.max(0, Number(snapshot?.queued_count) || 0)
|
||||||
key: 'batch_start',
|
const visibleFinished = Math.min(finished, total)
|
||||||
duration: 0
|
const progressChanged =
|
||||||
})
|
visibleFinished !== batchStatusLastFinished ||
|
||||||
|
total !== batchStatusLastTotal ||
|
||||||
|
processing !== batchStatusLastProcessing ||
|
||||||
|
queued !== batchStatusLastQueued
|
||||||
|
if (progressChanged) {
|
||||||
|
batchStatusLastFinished = visibleFinished
|
||||||
|
batchStatusLastTotal = total
|
||||||
|
batchStatusLastProcessing = processing
|
||||||
|
batchStatusLastQueued = queued
|
||||||
|
batchStatusPollDelayMs = BATCH_STATUS_POLL_MS
|
||||||
|
message.loading({
|
||||||
|
content: `账号启动队列处理中:${visibleFinished}/${total}(正在处理 ${processing},等待 ${queued},系统正错峰启动)`,
|
||||||
|
key: 'batch_start',
|
||||||
|
duration: 0
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
batchStatusPollDelayMs = Math.min(
|
||||||
|
BATCH_STATUS_MAX_POLL_MS,
|
||||||
|
batchStatusPollDelayMs + BATCH_STATUS_BACKOFF_STEP_MS
|
||||||
|
)
|
||||||
|
}
|
||||||
if (snapshot?.complete) {
|
if (snapshot?.complete) {
|
||||||
await finishBatchStart(snapshot)
|
await finishBatchStart(snapshot)
|
||||||
return true
|
return true
|
||||||
@@ -1230,12 +1272,13 @@ const pollBatchStartStatus = (batchId, initialSnapshot = null) => {
|
|||||||
duration: 5
|
duration: 5
|
||||||
})
|
})
|
||||||
await fetchAccounts()
|
await fetchAccounts()
|
||||||
|
startReplyQueueSummaryPolling()
|
||||||
return
|
return
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === batchStatusGeneration) batchStatusRequestActive = false
|
if (generation === batchStatusGeneration) batchStatusRequestActive = false
|
||||||
}
|
}
|
||||||
if (generation === batchStatusGeneration && activeStartBatchId.value === batchId) {
|
if (generation === batchStatusGeneration && activeStartBatchId.value === batchId) {
|
||||||
batchStatusTimer = setTimeout(poll, BATCH_STATUS_POLL_MS)
|
batchStatusTimer = setTimeout(poll, batchStatusPollDelayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,7 +1288,7 @@ const pollBatchStartStatus = (batchId, initialSnapshot = null) => {
|
|||||||
generation === batchStatusGeneration &&
|
generation === batchStatusGeneration &&
|
||||||
activeStartBatchId.value === batchId
|
activeStartBatchId.value === batchId
|
||||||
) {
|
) {
|
||||||
batchStatusTimer = setTimeout(poll, BATCH_STATUS_POLL_MS)
|
batchStatusTimer = setTimeout(poll, batchStatusPollDelayMs)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1254,6 +1297,7 @@ const pollBatchStartStatus = (batchId, initialSnapshot = null) => {
|
|||||||
const runBatchStart = async ({ accountIds = [], allAccounts = false }) => {
|
const runBatchStart = async ({ accountIds = [], allAccounts = false }) => {
|
||||||
if (batchStarting.value) return
|
if (batchStarting.value) return
|
||||||
batchStarting.value = true
|
batchStarting.value = true
|
||||||
|
stopReplyQueueSummaryPolling()
|
||||||
stopBatchStatusPolling()
|
stopBatchStatusPolling()
|
||||||
const submitGeneration = batchStatusGeneration
|
const submitGeneration = batchStatusGeneration
|
||||||
message.loading({ content: '正在提交账号启动队列...', key: 'batch_start', duration: 0 })
|
message.loading({ content: '正在提交账号启动队列...', key: 'batch_start', duration: 0 })
|
||||||
@@ -1275,6 +1319,7 @@ const runBatchStart = async ({ accountIds = [], allAccounts = false }) => {
|
|||||||
if (submitGeneration !== batchStatusGeneration) return
|
if (submitGeneration !== batchStatusGeneration) return
|
||||||
batchStarting.value = false
|
batchStarting.value = false
|
||||||
activeStartBatchId.value = null
|
activeStartBatchId.value = null
|
||||||
|
startReplyQueueSummaryPolling()
|
||||||
message.error({
|
message.error({
|
||||||
content: error.response?.data?.detail || error.message || '提交批量启动失败',
|
content: error.response?.data?.detail || error.message || '提交批量启动失败',
|
||||||
key: 'batch_start',
|
key: 'batch_start',
|
||||||
|
|||||||
Reference in New Issue
Block a user