更新
This commit is contained in:
+55
-1
@@ -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
|
from sqlalchemy import select, update, delete, text, func, case
|
||||||
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
|
||||||
@@ -625,6 +625,15 @@ class AccountResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardAccountStatsResponse(BaseModel):
|
||||||
|
"""Safe account totals shown to every authenticated dashboard user."""
|
||||||
|
|
||||||
|
total_accounts: int = 0
|
||||||
|
online_accounts: int = 0
|
||||||
|
my_accounts: int = 0
|
||||||
|
my_online_accounts: int = 0
|
||||||
|
|
||||||
|
|
||||||
class AccountUpdate(BaseModel):
|
class AccountUpdate(BaseModel):
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
@@ -1010,6 +1019,51 @@ def _account_matches_keyword(account: Account, keyword: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/dashboard/account-stats",
|
||||||
|
response_model=DashboardAccountStatsResponse,
|
||||||
|
)
|
||||||
|
async def get_dashboard_account_stats(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return public platform totals without exposing other users' accounts."""
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
func.count(Account.id).label("total_accounts"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(case((Account.status == "online", 1), else_=0)),
|
||||||
|
0,
|
||||||
|
).label("online_accounts"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(case((Account.owner_id == user.id, 1), else_=0)),
|
||||||
|
0,
|
||||||
|
).label("my_accounts"),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
(Account.owner_id == user.id)
|
||||||
|
& (Account.status == "online"),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
).label("my_online_accounts"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.one()
|
||||||
|
return DashboardAccountStatsResponse(
|
||||||
|
total_accounts=int(row.total_accounts or 0),
|
||||||
|
online_accounts=int(row.online_accounts or 0),
|
||||||
|
my_accounts=int(row.my_accounts or 0),
|
||||||
|
my_online_accounts=int(row.my_online_accounts or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/accounts")
|
@app.get("/api/accounts")
|
||||||
async def get_accounts(
|
async def get_accounts(
|
||||||
page: Optional[int] = Query(None, ge=1),
|
page: Optional[int] = Query(None, ge=1),
|
||||||
|
|||||||
@@ -87,20 +87,29 @@ class AccountReplyQueue:
|
|||||||
details: Optional[dict[str, Any]] = None,
|
details: Optional[dict[str, Any]] = None,
|
||||||
merge_key: str = "",
|
merge_key: str = "",
|
||||||
merge_keys: Optional[Iterable[str]] = None,
|
merge_keys: Optional[Iterable[str]] = None,
|
||||||
|
immediate_if_idle: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Append one reply job and return its current 1-based queue position."""
|
"""Append one reply job and return its current 1-based queue position.
|
||||||
|
|
||||||
|
When ``immediate_if_idle`` is enabled, the first job in a completely
|
||||||
|
idle account queue reserves a zero-second slot. Jobs arriving behind
|
||||||
|
it still reserve the configured interval, so the normal per-account
|
||||||
|
pacing resumes from the second job onward.
|
||||||
|
"""
|
||||||
interval = max(0.0, float(delay_seconds or 0))
|
interval = max(0.0, float(delay_seconds or 0))
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
async with self._state_lock:
|
async with self._state_lock:
|
||||||
if not self._running or not self._task or self._task.done():
|
if not self._running or not self._task or self._task.done():
|
||||||
raise RuntimeError("reply queue is not running")
|
raise RuntimeError("reply queue is not running")
|
||||||
due_at = max(loop.time(), self._tail_due_at) + interval
|
queue_is_idle = self.pending_count == 0
|
||||||
|
slot_seconds = 0.0 if immediate_if_idle and queue_is_idle else interval
|
||||||
|
due_at = max(loop.time(), self._tail_due_at) + slot_seconds
|
||||||
self._tail_due_at = due_at
|
self._tail_due_at = due_at
|
||||||
self._waiting.append(
|
self._waiting.append(
|
||||||
_QueueItem(
|
_QueueItem(
|
||||||
job_id=uuid.uuid4().hex,
|
job_id=uuid.uuid4().hex,
|
||||||
due_at=due_at,
|
due_at=due_at,
|
||||||
slot_seconds=interval,
|
slot_seconds=slot_seconds,
|
||||||
callback=callback,
|
callback=callback,
|
||||||
description=description,
|
description=description,
|
||||||
queued_at=time.time(),
|
queued_at=time.time(),
|
||||||
@@ -256,9 +265,10 @@ class AccountReplyQueue:
|
|||||||
item = self._waiting.pop(selected_index)
|
item = self._waiting.pop(selected_index)
|
||||||
shift_seconds = max(0.0, item.slot_seconds)
|
shift_seconds = max(0.0, item.slot_seconds)
|
||||||
shifted_count = 0
|
shifted_count = 0
|
||||||
for later in self._waiting[selected_index:]:
|
if shift_seconds > 0:
|
||||||
later.due_at -= shift_seconds
|
for later in self._waiting[selected_index:]:
|
||||||
shifted_count += 1
|
later.due_at -= shift_seconds
|
||||||
|
shifted_count += 1
|
||||||
|
|
||||||
item.due_at = asyncio.get_running_loop().time()
|
item.due_at = asyncio.get_running_loop().time()
|
||||||
item.expedited = True
|
item.expedited = True
|
||||||
|
|||||||
@@ -578,19 +578,32 @@ class DouyinImService:
|
|||||||
"replies": list(replies),
|
"replies": list(replies),
|
||||||
},
|
},
|
||||||
merge_keys=queue_merge_keys,
|
merge_keys=queue_merge_keys,
|
||||||
|
immediate_if_idle=True,
|
||||||
)
|
)
|
||||||
|
scheduled_wait = 0 if position == 1 else delay_seconds
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queued reply to %s for account %s: position=%s interval=%ss",
|
"Queued reply to %s for account %s: position=%s wait=%ss interval=%ss",
|
||||||
sender,
|
sender,
|
||||||
self.account_id,
|
self.account_id,
|
||||||
position,
|
position,
|
||||||
|
scheduled_wait,
|
||||||
delay_seconds,
|
delay_seconds,
|
||||||
)
|
)
|
||||||
|
if position == 1:
|
||||||
|
queue_detail = (
|
||||||
|
f"{sender} 是当前账号队列的首条任务,等待时间为 0 秒;"
|
||||||
|
f"后续任务仍按 {delay_seconds} 秒间隔排队。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
queue_detail = (
|
||||||
|
f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒,"
|
||||||
|
"后续任务继续依次排队。"
|
||||||
|
)
|
||||||
system_logger.record(
|
system_logger.record(
|
||||||
"自动回复已进入账号队列",
|
"自动回复已进入账号队列",
|
||||||
detail=(
|
detail=(
|
||||||
f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒,"
|
f"{queue_detail} 账号内计时与排位独立;"
|
||||||
"账号内计时与排位独立;到点后再进入全局带宽队列逐条投递。"
|
"发送时仍进入全局带宽队列逐条投递。"
|
||||||
),
|
),
|
||||||
level="info",
|
level="info",
|
||||||
category="send",
|
category="send",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from collections import namedtuple
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
_AggregateRow = namedtuple(
|
||||||
|
"_AggregateRow",
|
||||||
|
(
|
||||||
|
"total_accounts",
|
||||||
|
"online_accounts",
|
||||||
|
"my_accounts",
|
||||||
|
"my_online_accounts",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _AggregateResult:
|
||||||
|
def __init__(self, row):
|
||||||
|
self._row = row
|
||||||
|
|
||||||
|
def one(self):
|
||||||
|
return self._row
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardAccountStatsTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_conditional_aggregate_maps_global_and_personal_counts(self):
|
||||||
|
row = _AggregateRow(
|
||||||
|
total_accounts=12,
|
||||||
|
online_accounts=5,
|
||||||
|
my_accounts=3,
|
||||||
|
my_online_accounts=2,
|
||||||
|
)
|
||||||
|
db = SimpleNamespace(
|
||||||
|
execute=AsyncMock(return_value=_AggregateResult(row))
|
||||||
|
)
|
||||||
|
user = SimpleNamespace(id=42, role="operator")
|
||||||
|
|
||||||
|
response = await main.get_dashboard_account_stats(db=db, user=user)
|
||||||
|
|
||||||
|
self.assertIsInstance(response, main.DashboardAccountStatsResponse)
|
||||||
|
self.assertEqual(response.total_accounts, 12)
|
||||||
|
self.assertEqual(response.online_accounts, 5)
|
||||||
|
self.assertEqual(response.my_accounts, 3)
|
||||||
|
self.assertEqual(response.my_online_accounts, 2)
|
||||||
|
db.execute.assert_awaited_once()
|
||||||
|
|
||||||
|
async def test_owner_scope_is_only_inside_personal_aggregates(self):
|
||||||
|
row = _AggregateRow(
|
||||||
|
total_accounts=8,
|
||||||
|
online_accounts=4,
|
||||||
|
my_accounts=2,
|
||||||
|
my_online_accounts=1,
|
||||||
|
)
|
||||||
|
db = SimpleNamespace(
|
||||||
|
execute=AsyncMock(return_value=_AggregateResult(row))
|
||||||
|
)
|
||||||
|
user = SimpleNamespace(id=73, role="viewer")
|
||||||
|
|
||||||
|
await main.get_dashboard_account_stats(db=db, user=user)
|
||||||
|
|
||||||
|
statement = db.execute.await_args.args[0]
|
||||||
|
sql = " ".join(str(statement).lower().split())
|
||||||
|
compiled_params = list(statement.compile().params.values())
|
||||||
|
|
||||||
|
# All roles receive the same global totals. The current user id may
|
||||||
|
# appear in CASE expressions for the two personal counters, but must
|
||||||
|
# never filter the entire aggregate query through a global WHERE.
|
||||||
|
self.assertIn("owner_id", sql)
|
||||||
|
self.assertGreaterEqual(sql.count("case when"), 3)
|
||||||
|
self.assertEqual(sql.count("accounts.owner_id"), 2)
|
||||||
|
self.assertIn(73, compiled_params)
|
||||||
|
self.assertNotIn(" where ", f" {sql} ")
|
||||||
|
self.assertEqual(db.execute.await_count, 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -30,36 +30,127 @@ class AccountReplyQueueTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await asyncio.sleep(0.002)
|
await asyncio.sleep(0.002)
|
||||||
self.assertEqual(queue.pending_count, 0)
|
self.assertEqual(queue.pending_count, 0)
|
||||||
|
|
||||||
async def test_one_account_runs_three_jobs_at_successive_fifo_slots(self):
|
async def test_immediate_if_idle_runs_first_now_then_successive_fifo_slots(self):
|
||||||
queue = await self._start_queue(account_id=101)
|
queue = await self._start_queue(account_id=101)
|
||||||
interval = 0.05
|
interval = 0.05
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
started_at = loop.time()
|
started_at = loop.time()
|
||||||
calls: list[tuple[int, float]] = []
|
calls: list[tuple[int, float]] = []
|
||||||
finished = asyncio.Event()
|
finished = asyncio.Event()
|
||||||
|
first_started = asyncio.Event()
|
||||||
|
release_first = asyncio.Event()
|
||||||
|
|
||||||
def callback_for(index: int):
|
def callback_for(index: int):
|
||||||
async def callback() -> None:
|
async def callback() -> None:
|
||||||
calls.append((index, loop.time() - started_at))
|
calls.append((index, loop.time() - started_at))
|
||||||
|
if index == 0:
|
||||||
|
first_started.set()
|
||||||
|
await release_first.wait()
|
||||||
if len(calls) == 3:
|
if len(calls) == 3:
|
||||||
finished.set()
|
finished.set()
|
||||||
|
|
||||||
return callback
|
return callback
|
||||||
|
|
||||||
for index in range(3):
|
await queue.enqueue(
|
||||||
await queue.enqueue(interval, callback_for(index), description=str(index))
|
interval,
|
||||||
|
callback_for(0),
|
||||||
|
description="0",
|
||||||
|
immediate_if_idle=True,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(first_started.wait(), timeout=0.1)
|
||||||
|
for index in (1, 2):
|
||||||
|
await queue.enqueue(
|
||||||
|
interval,
|
||||||
|
callback_for(index),
|
||||||
|
description=str(index),
|
||||||
|
immediate_if_idle=True,
|
||||||
|
)
|
||||||
|
release_first.set()
|
||||||
|
|
||||||
await asyncio.wait_for(finished.wait(), timeout=0.75)
|
await asyncio.wait_for(finished.wait(), timeout=0.75)
|
||||||
await self._wait_until_idle(queue)
|
await self._wait_until_idle(queue)
|
||||||
|
|
||||||
self.assertEqual([index for index, _ in calls], [0, 1, 2])
|
self.assertEqual([index for index, _ in calls], [0, 1, 2])
|
||||||
elapsed = [timestamp for _, timestamp in calls]
|
elapsed = [timestamp for _, timestamp in calls]
|
||||||
for timestamp, expected in zip(elapsed, (interval, interval * 2, interval * 3)):
|
for timestamp, expected in zip(elapsed, (0, interval, interval * 2)):
|
||||||
self.assertGreaterEqual(timestamp, expected - 0.015)
|
self.assertGreaterEqual(timestamp, expected - 0.015)
|
||||||
self.assertLess(timestamp, expected + 0.15)
|
self.assertLess(timestamp, expected + 0.15)
|
||||||
self.assertGreaterEqual(elapsed[1] - elapsed[0], interval - 0.02)
|
self.assertGreaterEqual(elapsed[1] - elapsed[0], interval - 0.02)
|
||||||
self.assertGreaterEqual(elapsed[2] - elapsed[1], interval - 0.02)
|
self.assertGreaterEqual(elapsed[2] - elapsed[1], interval - 0.02)
|
||||||
|
|
||||||
|
async def test_immediate_if_idle_does_not_bypass_active_send(self):
|
||||||
|
queue = await self._start_queue(account_id=102)
|
||||||
|
interval = 1.0
|
||||||
|
first_started = asyncio.Event()
|
||||||
|
release_first = asyncio.Event()
|
||||||
|
second_started = asyncio.Event()
|
||||||
|
|
||||||
|
async def first_callback() -> None:
|
||||||
|
first_started.set()
|
||||||
|
await release_first.wait()
|
||||||
|
|
||||||
|
async def second_callback() -> None:
|
||||||
|
second_started.set()
|
||||||
|
|
||||||
|
await queue.enqueue(
|
||||||
|
interval,
|
||||||
|
first_callback,
|
||||||
|
description="first",
|
||||||
|
immediate_if_idle=True,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(first_started.wait(), timeout=0.1)
|
||||||
|
await queue.enqueue(
|
||||||
|
interval,
|
||||||
|
second_callback,
|
||||||
|
description="second",
|
||||||
|
immediate_if_idle=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = await queue.snapshot()
|
||||||
|
self.assertEqual([item["status"] for item in snapshot], ["sending", "waiting"])
|
||||||
|
self.assertEqual(snapshot[0]["interval_seconds"], 0)
|
||||||
|
self.assertEqual(snapshot[1]["interval_seconds"], int(interval))
|
||||||
|
|
||||||
|
release_first.set()
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
self.assertFalse(second_started.is_set())
|
||||||
|
|
||||||
|
async def test_immediate_if_idle_resets_after_queue_drains(self):
|
||||||
|
queue = await self._start_queue(account_id=103)
|
||||||
|
interval = 1.0
|
||||||
|
first_finished = asyncio.Event()
|
||||||
|
second_started = asyncio.Event()
|
||||||
|
release_second = asyncio.Event()
|
||||||
|
|
||||||
|
async def first_callback() -> None:
|
||||||
|
first_finished.set()
|
||||||
|
|
||||||
|
async def second_callback() -> None:
|
||||||
|
second_started.set()
|
||||||
|
await release_second.wait()
|
||||||
|
|
||||||
|
await queue.enqueue(
|
||||||
|
interval,
|
||||||
|
first_callback,
|
||||||
|
description="first wave",
|
||||||
|
immediate_if_idle=True,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(first_finished.wait(), timeout=0.1)
|
||||||
|
await self._wait_until_idle(queue)
|
||||||
|
|
||||||
|
await queue.enqueue(
|
||||||
|
interval,
|
||||||
|
second_callback,
|
||||||
|
description="second wave",
|
||||||
|
immediate_if_idle=True,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(second_started.wait(), timeout=0.1)
|
||||||
|
snapshot = await queue.snapshot()
|
||||||
|
self.assertEqual(len(snapshot), 1)
|
||||||
|
self.assertEqual(snapshot[0]["status"], "sending")
|
||||||
|
self.assertEqual(snapshot[0]["interval_seconds"], 0)
|
||||||
|
release_second.set()
|
||||||
|
|
||||||
async def test_separate_account_queues_reach_first_slot_without_blocking(self):
|
async def test_separate_account_queues_reach_first_slot_without_blocking(self):
|
||||||
first_queue = await self._start_queue(account_id=201)
|
first_queue = await self._start_queue(account_id=201)
|
||||||
second_queue = await self._start_queue(account_id=202)
|
second_queue = await self._start_queue(account_id=202)
|
||||||
@@ -194,6 +285,47 @@ class AccountReplyQueueTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
new_due = datetime.fromisoformat(new["scheduled_at"]).timestamp()
|
new_due = datetime.fromisoformat(new["scheduled_at"]).timestamp()
|
||||||
self.assertAlmostEqual(old_due - new_due, interval, delta=0.05)
|
self.assertAlmostEqual(old_due - new_due, interval, delta=0.05)
|
||||||
|
|
||||||
|
async def test_send_now_on_zero_slot_does_not_claim_later_jobs_shifted(self):
|
||||||
|
queue = await self._start_queue(account_id=512)
|
||||||
|
interval = 1.0
|
||||||
|
|
||||||
|
async def noop() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Both enqueues complete without yielding to the consumer, preserving
|
||||||
|
# the narrow management-API window where the zero-slot first job is
|
||||||
|
# still waiting and can be selected by send-now.
|
||||||
|
await queue.enqueue(
|
||||||
|
interval,
|
||||||
|
noop,
|
||||||
|
description="immediate first",
|
||||||
|
immediate_if_idle=True,
|
||||||
|
)
|
||||||
|
await queue.enqueue(
|
||||||
|
interval,
|
||||||
|
noop,
|
||||||
|
description="scheduled second",
|
||||||
|
immediate_if_idle=True,
|
||||||
|
)
|
||||||
|
before = await queue.snapshot()
|
||||||
|
second_before = next(
|
||||||
|
item for item in before if item["description"] == "scheduled second"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await queue.send_now(before[0]["job_id"])
|
||||||
|
after = await queue.snapshot()
|
||||||
|
second_after = next(
|
||||||
|
item for item in after if item["description"] == "scheduled second"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "accepted")
|
||||||
|
self.assertEqual(result["shifted_count"], 0)
|
||||||
|
second_due_before = datetime.fromisoformat(
|
||||||
|
second_before["scheduled_at"]
|
||||||
|
).timestamp()
|
||||||
|
second_due_after = datetime.fromisoformat(second_after["scheduled_at"]).timestamp()
|
||||||
|
self.assertAlmostEqual(second_due_after, second_due_before, delta=0.01)
|
||||||
|
|
||||||
async def test_send_now_middle_runs_first_and_only_shifts_jobs_behind_it(self):
|
async def test_send_now_middle_runs_first_and_only_shifts_jobs_behind_it(self):
|
||||||
queue = await self._start_queue(account_id=503)
|
queue = await self._start_queue(account_id=503)
|
||||||
interval = 0.12
|
interval = 0.12
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ from rpa_engine.playwright_worker import DouyinWorker
|
|||||||
|
|
||||||
class _RecordingQueue:
|
class _RecordingQueue:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.jobs: list[tuple[float, object, str, dict, frozenset[str]]] = []
|
self.jobs: list[
|
||||||
|
tuple[float, object, str, dict, frozenset[str], bool]
|
||||||
|
] = []
|
||||||
|
|
||||||
async def enqueue(
|
async def enqueue(
|
||||||
self,
|
self,
|
||||||
@@ -33,11 +35,19 @@ class _RecordingQueue:
|
|||||||
details=None,
|
details=None,
|
||||||
merge_key="",
|
merge_key="",
|
||||||
merge_keys=None,
|
merge_keys=None,
|
||||||
|
immediate_if_idle=False,
|
||||||
) -> int:
|
) -> int:
|
||||||
keys = merge_keys if merge_keys is not None else [merge_key]
|
keys = merge_keys if merge_keys is not None else [merge_key]
|
||||||
normalized_keys = frozenset(str(key) for key in keys if str(key or "").strip())
|
normalized_keys = frozenset(str(key) for key in keys if str(key or "").strip())
|
||||||
self.jobs.append(
|
self.jobs.append(
|
||||||
(delay_seconds, callback, description, dict(details or {}), normalized_keys)
|
(
|
||||||
|
delay_seconds,
|
||||||
|
callback,
|
||||||
|
description,
|
||||||
|
dict(details or {}),
|
||||||
|
normalized_keys,
|
||||||
|
bool(immediate_if_idle),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return len(self.jobs)
|
return len(self.jobs)
|
||||||
|
|
||||||
@@ -75,6 +85,7 @@ class _RecordingQueue:
|
|||||||
job[2],
|
job[2],
|
||||||
merged_details,
|
merged_details,
|
||||||
frozenset(job[4] | incoming_keys),
|
frozenset(job[4] | incoming_keys),
|
||||||
|
job[5],
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"status": "merged",
|
"status": "merged",
|
||||||
@@ -123,6 +134,8 @@ class ReplyQueueIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(service._reply_queue.jobs), 1)
|
self.assertEqual(len(service._reply_queue.jobs), 1)
|
||||||
self.assertEqual(match_reply.await_count, 1)
|
self.assertEqual(match_reply.await_count, 1)
|
||||||
|
self.assertEqual(service._reply_queue.jobs[0][0], 60)
|
||||||
|
self.assertTrue(service._reply_queue.jobs[0][5])
|
||||||
details = service._reply_queue.jobs[0][3]
|
details = service._reply_queue.jobs[0][3]
|
||||||
self.assertEqual(details["sender_name"], "张三")
|
self.assertEqual(details["sender_name"], "张三")
|
||||||
self.assertEqual(details["conversation_id"], "conv-1")
|
self.assertEqual(details["conversation_id"], "conv-1")
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const handleLogout = () => {
|
|||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="/accounts">
|
<a-menu-item key="/accounts">
|
||||||
<template #icon><UserOutlined /></template>
|
<template #icon><UserOutlined /></template>
|
||||||
<span>账号管理</span>
|
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="/messages">
|
<a-menu-item key="/messages">
|
||||||
<template #icon><MessageOutlined /></template>
|
<template #icon><MessageOutlined /></template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import { useIsMobile } from '../composables/useIsMobile'
|
import { useIsMobile } from '../composables/useIsMobile'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const profileModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 860))
|
const profileModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 860))
|
||||||
@@ -983,6 +984,10 @@ const goAccountRulesPage = (accountId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
|
if (!auth.canWrite) {
|
||||||
|
message.warning('当前账号为只读角色,不能添加托管账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!canAddAccount.value) {
|
if (!canAddAccount.value) {
|
||||||
if (canPurchaseSlots.value) {
|
if (canPurchaseSlots.value) {
|
||||||
purchaseVisible.value = true
|
purchaseVisible.value = true
|
||||||
@@ -1705,13 +1710,21 @@ const clearCookie = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
auth.fetchMe()
|
await Promise.all([
|
||||||
fetchPaymentConfig()
|
auth.fetchMe(),
|
||||||
fetchDeviceProfiles()
|
fetchPaymentConfig(),
|
||||||
fetchAccounts()
|
fetchDeviceProfiles(),
|
||||||
fetchRules()
|
fetchAccounts(),
|
||||||
|
fetchRules(),
|
||||||
|
])
|
||||||
startReplyQueueSummaryPolling()
|
startReplyQueueSummaryPolling()
|
||||||
|
if (route.query.action === 'add') {
|
||||||
|
openAddModal()
|
||||||
|
const nextQuery = { ...route.query }
|
||||||
|
delete nextQuery.action
|
||||||
|
router.replace({ path: route.path, query: nextQuery })
|
||||||
|
}
|
||||||
// 不再定时轮询账号列表;进入页面加载一次,之后由各操作(启动/停止/登录等)
|
// 不再定时轮询账号列表;进入页面加载一次,之后由各操作(启动/停止/登录等)
|
||||||
// 或手动点击「刷新」按钮按需刷新
|
// 或手动点击「刷新」按钮按需刷新
|
||||||
})
|
})
|
||||||
@@ -1797,6 +1810,7 @@ onUnmounted(() => {
|
|||||||
购买额度
|
购买额度
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
|
v-if="auth.canWrite"
|
||||||
type="primary"
|
type="primary"
|
||||||
class="gradient-btn"
|
class="gradient-btn"
|
||||||
:disabled="!canAddAccount && !canPurchaseSlots"
|
:disabled="!canAddAccount && !canPurchaseSlots"
|
||||||
@@ -2052,7 +2066,7 @@ onUnmounted(() => {
|
|||||||
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
|
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
|
||||||
<h3>暂无托管账号</h3>
|
<h3>暂无托管账号</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">添加一个抖音账号,开始自动化回复工作吧</p>
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">添加一个抖音账号,开始自动化回复工作吧</p>
|
||||||
<a-button type="primary" class="gradient-btn" @click="openAddModal">
|
<a-button v-if="auth.canWrite" type="primary" class="gradient-btn" @click="openAddModal">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
立即添加
|
立即添加
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -2257,7 +2271,7 @@ onUnmounted(() => {
|
|||||||
placeholder="0 或留空则继承系统默认"
|
placeholder="0 或留空则继承系统默认"
|
||||||
/>
|
/>
|
||||||
<div class="field-hint">
|
<div class="field-hint">
|
||||||
设置 N 秒后,同一账号的待回复会话会依次排队:第 1 条在 N 秒后发送,第 2 条在 2N 秒后发送,以此类推;各账号队列互不影响。0 或留空表示继承系统默认,当前生效 {{ editForm.reply_delay_effective }} 秒(0 表示不启用兜底排队、立即回复)。
|
设置 N 秒后,同一账号队列为空时,首条回复等待 0 秒并立即发送;后续待回复会话按 N 秒、2N 秒依次排队。各账号队列互不影响,实际发送仍受全局带宽队列保护。0 或留空表示继承系统默认,当前生效 {{ editForm.reply_delay_effective }} 秒(0 表示不启用兜底排队、收到消息后立即回复)。
|
||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
import MessageBubble from '../components/MessageBubble.vue'
|
import MessageBubble from '../components/MessageBubble.vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
ThunderboltOutlined
|
ThunderboltOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
PlusOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
totalAccounts: 0,
|
totalAccounts: 0,
|
||||||
activeAccounts: 0,
|
activeAccounts: 0,
|
||||||
|
myAccounts: 0,
|
||||||
|
myActiveAccounts: 0,
|
||||||
totalMessages: 0,
|
totalMessages: 0,
|
||||||
repliedMessages: 0,
|
repliedMessages: 0,
|
||||||
replyRate: '0%'
|
replyRate: '0%'
|
||||||
@@ -24,14 +31,15 @@ const loading = ref(true)
|
|||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
const [accountsRes, statsRes] = await Promise.all([
|
const [accountStatsRes, statsRes] = await Promise.all([
|
||||||
api.get(`/accounts`),
|
api.get(`/dashboard/account-stats`),
|
||||||
api.get(`/logs/stats`)
|
api.get(`/logs/stats`)
|
||||||
])
|
])
|
||||||
|
|
||||||
const accounts = accountsRes.data
|
stats.value.totalAccounts = accountStatsRes.data.total_accounts || 0
|
||||||
stats.value.totalAccounts = accounts.length
|
stats.value.activeAccounts = accountStatsRes.data.online_accounts || 0
|
||||||
stats.value.activeAccounts = accounts.filter(a => a.status === 'online').length
|
stats.value.myAccounts = accountStatsRes.data.my_accounts || 0
|
||||||
|
stats.value.myActiveAccounts = accountStatsRes.data.my_online_accounts || 0
|
||||||
|
|
||||||
// 后端全量统计所有消息,不受列表条数限制
|
// 后端全量统计所有消息,不受列表条数限制
|
||||||
stats.value.totalMessages = statsRes.data.total || 0
|
stats.value.totalMessages = statsRes.data.total || 0
|
||||||
@@ -84,8 +92,27 @@ onUnmounted(() => {
|
|||||||
多账户自动回复RPA后台。支持快捷扫码登录、状态持久化保存、以及自定义关键字规则精准答复。
|
多账户自动回复RPA后台。支持快捷扫码登录、状态持久化保存、以及自定义关键字规则精准答复。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="banner-icon">
|
<div class="banner-side">
|
||||||
<ThunderboltOutlined style="font-size: 4rem; color: #c084fc; opacity: 0.3;" />
|
<div v-if="auth.canWrite" class="banner-actions">
|
||||||
|
<router-link to="/accounts">
|
||||||
|
<a-button size="large">
|
||||||
|
<template #icon><UserOutlined /></template>
|
||||||
|
{{ auth.isAdmin ? '账号管理' : `我的账号(${stats.myAccounts})` }}
|
||||||
|
</a-button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-if="auth.canWrite"
|
||||||
|
:to="{ path: '/accounts', query: { action: 'add' } }"
|
||||||
|
>
|
||||||
|
<a-button type="primary" size="large" class="gradient-btn">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
{{ auth.isAdmin ? '添加账号' : '添加自己的账号' }}
|
||||||
|
</a-button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="banner-icon">
|
||||||
|
<ThunderboltOutlined style="font-size: 4rem; color: #c084fc; opacity: 0.3;" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,7 +125,7 @@ onUnmounted(() => {
|
|||||||
<UserOutlined />
|
<UserOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-label">托管账号</span>
|
<span class="stat-label">全平台托管账号</span>
|
||||||
<h2 class="stat-value">{{ stats.totalAccounts }}</h2>
|
<h2 class="stat-value">{{ stats.totalAccounts }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +138,7 @@ onUnmounted(() => {
|
|||||||
<CheckCircleOutlined />
|
<CheckCircleOutlined />
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-label">在线运行</span>
|
<span class="stat-label">全平台在线运行</span>
|
||||||
<h2 class="stat-value text-green">{{ stats.activeAccounts }}</h2>
|
<h2 class="stat-value text-green">{{ stats.activeAccounts }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,8 +226,9 @@ onUnmounted(() => {
|
|||||||
<div class="quick-actions-grid" style="margin-top: 20px;">
|
<div class="quick-actions-grid" style="margin-top: 20px;">
|
||||||
<router-link to="/accounts" class="quick-action-card">
|
<router-link to="/accounts" class="quick-action-card">
|
||||||
<UserOutlined class="action-icon text-gradient" />
|
<UserOutlined class="action-icon text-gradient" />
|
||||||
<span>账号配置</span>
|
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
|
||||||
<p>扫码登录并托管多个抖音账号</p>
|
<p v-if="auth.isAdmin">管理全平台账号配置与运行状态</p>
|
||||||
|
<p v-else>仅查看和管理自己添加的账号,当前 {{ stats.myAccounts }} 个</p>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link to="/rules" class="quick-action-card">
|
<router-link to="/rules" class="quick-action-card">
|
||||||
@@ -225,6 +253,20 @@ onUnmounted(() => {
|
|||||||
border-left: 4px solid var(--primary-color);
|
border-left: 4px solid var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banner-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -436,6 +478,20 @@ onUnmounted(() => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.banner-side,
|
||||||
|
.banner-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions > a {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions :deep(.ant-btn) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -740,7 +740,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="field-hint">
|
<div class="field-hint">
|
||||||
账号未单独设置时使用此间隔。同一账号的第 1 条待回复在 N 秒后发送,第 2 条在 2N 秒后发送,以此类推;各账号队列互不影响。设为 0 表示不启用兜底排队,收到消息后立即回复。
|
账号未单独设置时使用此间隔。同一账号队列为空时,首条回复等待 0 秒并立即发送;后续待回复会话按 N 秒、2N 秒依次排队。各账号队列互不影响,实际发送仍受全局带宽队列保护。设为 0 表示不启用兜底排队,收到消息后立即回复。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
Reference in New Issue
Block a user