This commit is contained in:
Your Name
2026-07-27 15:20:15 +08:00
parent 4970d8f8d3
commit 8ba13a8ff9
10 changed files with 424 additions and 39 deletions
+55 -1
View File
@@ -20,7 +20,7 @@ from settings import CORS_ORIGINS, SERVE_WEB, STATIC_DIR
from help_pages import serve_credential_tool
from web_static import mount_frontend
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 models.database import engine, Base, get_db, AsyncSessionLocal
@@ -625,6 +625,15 @@ class AccountResponse(BaseModel):
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):
phone: 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")
async def get_accounts(
page: Optional[int] = Query(None, ge=1),
+16 -6
View File
@@ -87,20 +87,29 @@ class AccountReplyQueue:
details: Optional[dict[str, Any]] = None,
merge_key: str = "",
merge_keys: Optional[Iterable[str]] = None,
immediate_if_idle: bool = False,
) -> 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))
loop = asyncio.get_running_loop()
async with self._state_lock:
if not self._running or not self._task or self._task.done():
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._waiting.append(
_QueueItem(
job_id=uuid.uuid4().hex,
due_at=due_at,
slot_seconds=interval,
slot_seconds=slot_seconds,
callback=callback,
description=description,
queued_at=time.time(),
@@ -256,9 +265,10 @@ class AccountReplyQueue:
item = self._waiting.pop(selected_index)
shift_seconds = max(0.0, item.slot_seconds)
shifted_count = 0
for later in self._waiting[selected_index:]:
later.due_at -= shift_seconds
shifted_count += 1
if shift_seconds > 0:
for later in self._waiting[selected_index:]:
later.due_at -= shift_seconds
shifted_count += 1
item.due_at = asyncio.get_running_loop().time()
item.expedited = True
+16 -3
View File
@@ -578,19 +578,32 @@ class DouyinImService:
"replies": list(replies),
},
merge_keys=queue_merge_keys,
immediate_if_idle=True,
)
scheduled_wait = 0 if position == 1 else delay_seconds
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,
self.account_id,
position,
scheduled_wait,
delay_seconds,
)
if position == 1:
queue_detail = (
f"{sender} 是当前账号队列的首条任务,等待时间为 0 秒;"
f"后续任务仍按 {delay_seconds} 秒间隔排队。"
)
else:
queue_detail = (
f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒,"
"后续任务继续依次排队。"
)
system_logger.record(
"自动回复已进入账号队列",
detail=(
f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒,"
"账号内计时与排位独立;到点后再进入全局带宽队列逐条投递。"
f"{queue_detail} 账号内计时与排位独立;"
"发送时仍进入全局带宽队列逐条投递。"
),
level="info",
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()
+136 -4
View File
@@ -30,36 +30,127 @@ class AccountReplyQueueTests(unittest.IsolatedAsyncioTestCase):
await asyncio.sleep(0.002)
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)
interval = 0.05
loop = asyncio.get_running_loop()
started_at = loop.time()
calls: list[tuple[int, float]] = []
finished = asyncio.Event()
first_started = asyncio.Event()
release_first = asyncio.Event()
def callback_for(index: int):
async def callback() -> None:
calls.append((index, loop.time() - started_at))
if index == 0:
first_started.set()
await release_first.wait()
if len(calls) == 3:
finished.set()
return callback
for index in range(3):
await queue.enqueue(interval, callback_for(index), description=str(index))
await queue.enqueue(
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 self._wait_until_idle(queue)
self.assertEqual([index for index, _ in calls], [0, 1, 2])
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.assertLess(timestamp, expected + 0.15)
self.assertGreaterEqual(elapsed[1] - elapsed[0], 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):
first_queue = await self._start_queue(account_id=201)
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()
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):
queue = await self._start_queue(account_id=503)
interval = 0.12
+15 -2
View File
@@ -23,7 +23,9 @@ from rpa_engine.playwright_worker import DouyinWorker
class _RecordingQueue:
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(
self,
@@ -33,11 +35,19 @@ class _RecordingQueue:
details=None,
merge_key="",
merge_keys=None,
immediate_if_idle=False,
) -> int:
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())
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)
@@ -75,6 +85,7 @@ class _RecordingQueue:
job[2],
merged_details,
frozenset(job[4] | incoming_keys),
job[5],
)
return {
"status": "merged",
@@ -123,6 +134,8 @@ class ReplyQueueIntegrationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(service._reply_queue.jobs), 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]
self.assertEqual(details["sender_name"], "张三")
self.assertEqual(details["conversation_id"], "conv-1")
+1 -1
View File
@@ -63,7 +63,7 @@ const handleLogout = () => {
</a-menu-item>
<a-menu-item key="/accounts">
<template #icon><UserOutlined /></template>
<span>账号管理</span>
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
</a-menu-item>
<a-menu-item key="/messages">
<template #icon><MessageOutlined /></template>
+23 -9
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import api from '../api'
import { message } from 'ant-design-vue'
import { useAuthStore } from '../stores/auth'
@@ -39,6 +39,7 @@ import {
import { useIsMobile } from '../composables/useIsMobile'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const profileModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 860))
@@ -983,6 +984,10 @@ const goAccountRulesPage = (accountId) => {
}
const openAddModal = () => {
if (!auth.canWrite) {
message.warning('当前账号为只读角色,不能添加托管账号')
return
}
if (!canAddAccount.value) {
if (canPurchaseSlots.value) {
purchaseVisible.value = true
@@ -1705,13 +1710,21 @@ const clearCookie = async () => {
}
}
onMounted(() => {
auth.fetchMe()
fetchPaymentConfig()
fetchDeviceProfiles()
fetchAccounts()
fetchRules()
onMounted(async () => {
await Promise.all([
auth.fetchMe(),
fetchPaymentConfig(),
fetchDeviceProfiles(),
fetchAccounts(),
fetchRules(),
])
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
v-if="auth.canWrite"
type="primary"
class="gradient-btn"
:disabled="!canAddAccount && !canPurchaseSlots"
@@ -2052,7 +2066,7 @@ onUnmounted(() => {
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
<h3>暂无托管账号</h3>
<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>
立即添加
</a-button>
@@ -2257,7 +2271,7 @@ onUnmounted(() => {
placeholder="0 或留空则继承系统默认"
/>
<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>
</a-form-item>
</a-col>
+68 -12
View File
@@ -2,18 +2,25 @@
import { ref, onMounted, onUnmounted } from 'vue'
import api from '../api'
import MessageBubble from '../components/MessageBubble.vue'
import { useAuthStore } from '../stores/auth'
import {
UserOutlined,
MessageOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ArrowRightOutlined,
ThunderboltOutlined
ThunderboltOutlined,
SettingOutlined,
PlusOutlined
} from '@ant-design/icons-vue'
const auth = useAuthStore()
const stats = ref({
totalAccounts: 0,
activeAccounts: 0,
myAccounts: 0,
myActiveAccounts: 0,
totalMessages: 0,
repliedMessages: 0,
replyRate: '0%'
@@ -24,14 +31,15 @@ const loading = ref(true)
const fetchStats = async () => {
try {
const [accountsRes, statsRes] = await Promise.all([
api.get(`/accounts`),
const [accountStatsRes, statsRes] = await Promise.all([
api.get(`/dashboard/account-stats`),
api.get(`/logs/stats`)
])
const accounts = accountsRes.data
stats.value.totalAccounts = accounts.length
stats.value.activeAccounts = accounts.filter(a => a.status === 'online').length
stats.value.totalAccounts = accountStatsRes.data.total_accounts || 0
stats.value.activeAccounts = accountStatsRes.data.online_accounts || 0
stats.value.myAccounts = accountStatsRes.data.my_accounts || 0
stats.value.myActiveAccounts = accountStatsRes.data.my_online_accounts || 0
// 后端全量统计所有消息,不受列表条数限制
stats.value.totalMessages = statsRes.data.total || 0
@@ -84,8 +92,27 @@ onUnmounted(() => {
多账户自动回复RPA后台支持快捷扫码登录状态持久化保存以及自定义关键字规则精准答复
</p>
</div>
<div class="banner-icon">
<ThunderboltOutlined style="font-size: 4rem; color: #c084fc; opacity: 0.3;" />
<div class="banner-side">
<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>
@@ -98,7 +125,7 @@ onUnmounted(() => {
<UserOutlined />
</div>
<div class="stat-info">
<span class="stat-label">托管账号</span>
<span class="stat-label">全平台托管账号</span>
<h2 class="stat-value">{{ stats.totalAccounts }}</h2>
</div>
</div>
@@ -111,7 +138,7 @@ onUnmounted(() => {
<CheckCircleOutlined />
</div>
<div class="stat-info">
<span class="stat-label">在线运行</span>
<span class="stat-label">全平台在线运行</span>
<h2 class="stat-value text-green">{{ stats.activeAccounts }}</h2>
</div>
</div>
@@ -199,8 +226,9 @@ onUnmounted(() => {
<div class="quick-actions-grid" style="margin-top: 20px;">
<router-link to="/accounts" class="quick-action-card">
<UserOutlined class="action-icon text-gradient" />
<span>账号配置</span>
<p>扫码登录并托管多个抖音账号</p>
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
<p v-if="auth.isAdmin">管理全平台账号配置与运行状态</p>
<p v-else>仅查看和管理自己添加的账号当前 {{ stats.myAccounts }} </p>
</router-link>
<router-link to="/rules" class="quick-action-card">
@@ -225,6 +253,20 @@ onUnmounted(() => {
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 {
display: flex;
align-items: center;
@@ -436,6 +478,20 @@ onUnmounted(() => {
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 {
padding: 16px;
}
+1 -1
View File
@@ -740,7 +740,7 @@ onMounted(() => {
/>
<div class="field-hint">
账号未单独设置时使用此间隔同一账号的第 1 条待回复在 N 发送 2 条在 2N 秒后发送以此类推各账号队列互不影响设为 0 表示不启用兜底排队收到消息后立即回复
账号未单独设置时使用此间隔同一账号队列为空时首条回复等待 0 并立即发送后续待回复会话按 N 2N 秒依次排队各账号队列互不影响实际发送仍受全局带宽队列保护设为 0 表示不启用兜底排队收到消息后立即回复
</div>
</a-form-item>