feat(douyu): 精英宝典工作台支持 CK 检测与失效重登
This commit is contained in:
@@ -10,12 +10,22 @@
|
||||
「根本不看记录」。
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from tests.support.db import get_test_engine, make_test_session
|
||||
from web.backend.models import Account, LoginTask, User
|
||||
from web.backend.routers.cookies import list_cookies
|
||||
from web.backend.routers.douyu import list_task_account_ids, list_task_accounts
|
||||
from web.backend.routers.douyu import (
|
||||
check_task_accounts_ck,
|
||||
list_task_account_ids,
|
||||
list_task_accounts,
|
||||
relogin_invalid_task_accounts,
|
||||
)
|
||||
from web.backend.schemas import CookieReloginRequest
|
||||
from web.backend.services.account_service import cookie_account_ids_query
|
||||
from web.backend.services.douyu_service import (
|
||||
create_douyu_planned_tasks,
|
||||
@@ -175,3 +185,87 @@ class TestDouyuCookieVisibility:
|
||||
assert fresh.cookie == "acf_uid=481802039"
|
||||
assert fresh.ck_check_status == "valid"
|
||||
assert self._task_account_names(self.admin) == {"远行者103", "排队中账号"}
|
||||
|
||||
def test_task_account_payload_contains_ck_status(self):
|
||||
rows = list_task_accounts(
|
||||
search="",
|
||||
tag="",
|
||||
ids="",
|
||||
account_names="",
|
||||
page=None,
|
||||
page_size=50,
|
||||
db=self.session,
|
||||
current=self.admin,
|
||||
)
|
||||
row = next(item for item in rows if item.id == self.relogin_account.id)
|
||||
assert row.ck_check_status == "valid"
|
||||
assert row.ck_checked_at is None
|
||||
|
||||
def test_workbench_ck_check_marks_invalid(self):
|
||||
checked_at = datetime.now(UTC)
|
||||
with patch(
|
||||
"web.backend.routers.cookies.check_douyu_cookie",
|
||||
return_value={
|
||||
"valid": False,
|
||||
"message": "ck expired",
|
||||
"fish_ball": None,
|
||||
"nickname": None,
|
||||
"level": None,
|
||||
"checked_at": checked_at,
|
||||
},
|
||||
):
|
||||
result = check_task_accounts_ck(
|
||||
ids=str(self.relogin_account.id),
|
||||
db=self.session,
|
||||
current=self.admin,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["results"] == [
|
||||
{
|
||||
"id": self.relogin_account.id,
|
||||
"valid": False,
|
||||
"checked_at": checked_at.isoformat(),
|
||||
}
|
||||
]
|
||||
self.session.refresh(self.relogin_account)
|
||||
assert self.relogin_account.ck_check_status == "invalid"
|
||||
|
||||
def test_workbench_relogin_invalid_is_admin_only_and_filters_invalid(self):
|
||||
self.relogin_account.ck_check_status = "invalid"
|
||||
self.session.commit()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_start(accounts, db, current, *, action=""):
|
||||
captured["ids"] = [account.id for account in accounts]
|
||||
captured["action"] = action
|
||||
return {
|
||||
"batch_id": "workbench-relogin",
|
||||
"count": len(accounts),
|
||||
"skipped": 0,
|
||||
"success": True,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"web.backend.routers.cookies._start_relogin_accounts",
|
||||
side_effect=fake_start,
|
||||
):
|
||||
result = relogin_invalid_task_accounts(
|
||||
CookieReloginRequest(
|
||||
ids=[self.relogin_account.id, self.pending_account.id]
|
||||
),
|
||||
db=self.session,
|
||||
current=self.admin,
|
||||
)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert captured["ids"] == [self.relogin_account.id]
|
||||
assert captured["action"] == "douyu:task:relogin_invalid"
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
relogin_invalid_task_accounts(
|
||||
CookieReloginRequest(ids=[self.relogin_account.id]),
|
||||
db=self.session,
|
||||
current=self.support,
|
||||
)
|
||||
assert excinfo.value.status_code == 403
|
||||
|
||||
@@ -158,3 +158,10 @@ class TestDouyuWorkbenchScope:
|
||||
delete_account(self.account.id, db=self.session, current=self.user)
|
||||
|
||||
assert self.session.query(DouyuWorkbenchAccount).count() == 0
|
||||
|
||||
def test_workbench_ck_routes_registered(self):
|
||||
from web.backend.main import app
|
||||
|
||||
paths = app.openapi()["paths"]
|
||||
assert "/api/douyu/accounts/ck-check" in paths
|
||||
assert "/api/douyu/accounts/relogin-invalid" in paths
|
||||
|
||||
@@ -41,6 +41,7 @@ from ..models import (
|
||||
)
|
||||
from ..permissions import user_has_permission
|
||||
from ..schemas import (
|
||||
CookieReloginRequest,
|
||||
DouyuConfigOut,
|
||||
DouyuConfigUpdate,
|
||||
DouyuGoodsOut,
|
||||
@@ -333,6 +334,8 @@ def _account_out(account: Account) -> DouyuTaskAccountOut:
|
||||
uid=account.uid or "",
|
||||
nickname=account.nickname or "",
|
||||
tag=account.tag or "",
|
||||
ck_check_status=account.ck_check_status or "",
|
||||
ck_checked_at=account.ck_checked_at,
|
||||
points=account.points,
|
||||
game_name=account.game_name or "",
|
||||
game_channel=account.game_channel or "",
|
||||
@@ -471,6 +474,54 @@ def list_task_account_ids(
|
||||
return {"account_ids": account_ids, "total": len(account_ids)}
|
||||
|
||||
|
||||
@router.post("/accounts/ck-check")
|
||||
def check_task_accounts_ck(
|
||||
ids: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("douyu:task")),
|
||||
):
|
||||
"""工作台批量检测 CK,只返回有效性,不暴露 Cookie 原文。
|
||||
|
||||
复用 CK 操作页的检测逻辑:检测结果会写回 ``accounts.ck_check_status``,
|
||||
因此导入账号后刷新列表仍能看到「有效 / 失效」。
|
||||
"""
|
||||
from .cookies import _check_cookies
|
||||
|
||||
return _check_cookies(ids, db, current, detailed=False)
|
||||
|
||||
|
||||
@router.post("/accounts/relogin-invalid")
|
||||
def relogin_invalid_task_accounts(
|
||||
req: CookieReloginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""重登工作台选中的 CK 失效账号(仅超级管理员)。"""
|
||||
if current.role != "super_admin":
|
||||
raise HTTPException(status_code=403, detail="仅超级管理员可重登失效账号")
|
||||
ids = sorted({int(item) for item in req.ids if int(item) > 0})
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="请先勾选账号")
|
||||
accounts = (
|
||||
_visible_task_accounts_query(db, current)
|
||||
.filter(
|
||||
Account.id.in_(ids),
|
||||
Account.ck_check_status == "invalid",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not accounts:
|
||||
raise HTTPException(status_code=400, detail="选中的账号里没有 CK 失效账号")
|
||||
from .cookies import _start_relogin_accounts
|
||||
|
||||
return _start_relogin_accounts(
|
||||
accounts,
|
||||
db,
|
||||
current,
|
||||
action="douyu:task:relogin_invalid",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workbench-accounts")
|
||||
def list_workbench_accounts(
|
||||
handbook_scope: str = Query(..., pattern="^(elite|esports|peace)$"),
|
||||
|
||||
@@ -1025,6 +1025,8 @@ class DouyuTaskAccountOut(BaseModel):
|
||||
uid: str = ""
|
||||
nickname: str = ""
|
||||
tag: str = ""
|
||||
ck_check_status: str = ""
|
||||
ck_checked_at: datetime | None = None
|
||||
points: int | None = None
|
||||
game_name: str = ""
|
||||
game_channel: str = ""
|
||||
|
||||
@@ -19,8 +19,18 @@ export const douyuApi = {
|
||||
api.get<DouyuTaskAccountItem[], DouyuTaskAccountItem[]>('/douyu/accounts', { params }),
|
||||
listAccountsPaged: (params: PageParams & { ids?: string; tag?: string }) =>
|
||||
api.get<PaginatedResponse<DouyuTaskAccountItem>, PaginatedResponse<DouyuTaskAccountItem>>('/douyu/accounts', { params }),
|
||||
listAccountIds: (params?: { search?: string; tag?: string; account_names?: string }) =>
|
||||
listAccountIds: (params: { search?: string; tag?: string; account_names?: string } = {}) =>
|
||||
api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>('/douyu/accounts/ids', { params }),
|
||||
checkAccountsCk: (ids: number[]) =>
|
||||
api.post<
|
||||
{ results: Array<{ id: number; valid: boolean; checked_at: string }>; success: boolean },
|
||||
{ results: Array<{ id: number; valid: boolean; checked_at: string }>; success: boolean }
|
||||
>('/douyu/accounts/ck-check', null, { params: { ids: ids.join(',') } }),
|
||||
reloginInvalidAccounts: (ids: number[]) =>
|
||||
api.post<
|
||||
{ batch_id: string; count: number; skipped: number; success: boolean },
|
||||
{ batch_id: string; count: number; skipped: number; success: boolean }
|
||||
>('/douyu/accounts/relogin-invalid', { ids }),
|
||||
getConfig: () => api.get<DouyuConfig, DouyuConfig>('/douyu/config'),
|
||||
getRechargeChannel: () => api.get<DouyuRechargeChannel, DouyuRechargeChannel>('/douyu/recharge-channel'),
|
||||
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
|
||||
|
||||
@@ -613,6 +613,8 @@ export interface DouyuTaskAccountItem {
|
||||
uid: string;
|
||||
nickname: string;
|
||||
tag: string;
|
||||
ck_check_status: string;
|
||||
ck_checked_at: string | null;
|
||||
points: number | null;
|
||||
game_name: string;
|
||||
game_channel: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, FilterOutlined, GiftOutlined,
|
||||
ImportOutlined, QrcodeOutlined, ReloadOutlined,
|
||||
ImportOutlined, LoadingOutlined, QrcodeOutlined, ReloadOutlined,
|
||||
SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
@@ -87,7 +87,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
const isEsportsHandbook = handbook === 'esports';
|
||||
const isEliteHandbook = handbook === 'elite';
|
||||
const { title: handbookTitle, description: handbookDescription, taskTypes: activeTaskTypes, quickActions } = HANDBOOK_META[handbook];
|
||||
const { can } = usePermissions();
|
||||
const { can, user } = usePermissions();
|
||||
const isAdmin = user?.role === 'super_admin';
|
||||
const { token } = theme.useToken();
|
||||
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
|
||||
// 工作台账号归属由服务端按用户保存;localStorage 仅用于首次升级迁移旧浏览器数据。
|
||||
@@ -120,6 +121,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
});
|
||||
const [accountPage, setAccountPage] = useState(1);
|
||||
const [accountTotal, setAccountTotal] = useState(0);
|
||||
const [ckCheckingIds, setCkCheckingIds] = useState<Set<number>>(new Set());
|
||||
const tasksLoadingRef = useRef(false);
|
||||
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
@@ -814,6 +816,39 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
return saved.account_ids;
|
||||
};
|
||||
|
||||
const checkWorkbenchCk = async (ids: number[]) => {
|
||||
if (ids.length === 0) return;
|
||||
setCkCheckingIds((previous) => new Set([...previous, ...ids]));
|
||||
message.info(`正在检测 ${ids.length} 个账号的 CK...`);
|
||||
try {
|
||||
const result = await douyuApi.checkAccountsCk(ids);
|
||||
const byId = new Map(result.results.map((item) => [item.id, item]));
|
||||
setAccounts((previous) => previous.map((account) => {
|
||||
const checked = byId.get(account.id);
|
||||
if (!checked) return account;
|
||||
return {
|
||||
...account,
|
||||
ck_check_status: checked.valid ? 'valid' : 'invalid',
|
||||
ck_checked_at: checked.checked_at,
|
||||
};
|
||||
}));
|
||||
const invalid = result.results.filter((item) => !item.valid).length;
|
||||
if (invalid > 0) {
|
||||
message.warning(`CK 检测完成:${invalid} 个账号 CK 失效`);
|
||||
} else {
|
||||
message.success(`CK 检测完成:${result.results.length} 个账号全部有效`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(`CK 检测失败:${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setCkCheckingIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const importAccounts = async (ids: number[]) => {
|
||||
const toImport = importPool.filter((a) => ids.includes(a.id));
|
||||
const nextIds = [...new Set([...workbenchIds, ...toImport.map((a) => a.id)])];
|
||||
@@ -830,6 +865,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
setImportSelectedIds([]);
|
||||
closeImportModal();
|
||||
message.success(`已导入 ${toImport.length} 个账号`);
|
||||
void checkWorkbenchCk(toImport.map((a) => a.id));
|
||||
};
|
||||
|
||||
const closeImportModal = () => {
|
||||
@@ -873,6 +909,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
await saveWorkbenchIds([...new Set([...workbenchIds, ...imported])]);
|
||||
closeImportModal();
|
||||
message.success(`已一键导入 ${imported.length} 个账号`);
|
||||
void checkWorkbenchCk(imported);
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
} finally {
|
||||
@@ -893,6 +930,30 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
message.success(`已移出 ${sel.size} 个账号`);
|
||||
};
|
||||
|
||||
const reloginInvalidSelected = () => {
|
||||
if (selectedIds.length === 0) {
|
||||
message.warning('请先勾选账号');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: `重登选中的 ${selectedIds.length} 个账号中的失效 CK?`,
|
||||
content: '只会处理 CK 已失效的账号;新登录成功后才替换旧 CK,失败的账号会保留原 CK。',
|
||||
okText: '开始重登',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const result = await douyuApi.reloginInvalidAccounts(selectedIds);
|
||||
const skipped = result.skipped ? `,${result.skipped} 个因缺少登录凭据跳过` : '';
|
||||
message.success(`已开始重登 ${result.count} 个失效账号${skipped}`);
|
||||
setSelectedIds([]);
|
||||
window.setTimeout(() => void loadData(), 2000);
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Context menu ----
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
accountIds: number[]; x: number; y: number;
|
||||
@@ -1142,6 +1203,29 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'CK状态', width: 100, align: 'center',
|
||||
render: (_, record) => {
|
||||
if (ckCheckingIds.has(record.id)) {
|
||||
return <Tag color="processing" icon={<LoadingOutlined />}>检测中</Tag>;
|
||||
}
|
||||
if (record.ck_check_status === 'valid') {
|
||||
return (
|
||||
<Tooltip title={record.ck_checked_at ? `检测时间:${formatTime(record.ck_checked_at)}` : undefined}>
|
||||
<Tag color="success">有效</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (record.ck_check_status === 'invalid') {
|
||||
return (
|
||||
<Tooltip title={record.ck_checked_at ? `检测时间:${formatTime(record.ck_checked_at)}` : undefined}>
|
||||
<Tag color="error">失效</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary">未检测</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '游戏名', dataIndex: 'game_name', width: 170,
|
||||
render: (_, record) => {
|
||||
@@ -1742,6 +1826,25 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
</Space>
|
||||
<Space>
|
||||
<Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CheckCircleOutlined />}
|
||||
disabled={selectedIds.length === 0 || ckCheckingIds.size > 0}
|
||||
onClick={() => void checkWorkbenchCk(selectedIds)}
|
||||
>
|
||||
检测 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={selectedIds.length === 0}
|
||||
onClick={reloginInvalidSelected}
|
||||
>
|
||||
重登失效
|
||||
</Button>
|
||||
)}
|
||||
{selectedIds.length > 0 && (
|
||||
<Button size="small" danger onClick={removeSelected}>移出选中</Button>
|
||||
)}
|
||||
@@ -1778,7 +1881,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
},
|
||||
}}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1280, y: Math.max(120, accountTableAreaHeight - 80) }}
|
||||
scroll={{ x: 1380, y: Math.max(120, accountTableAreaHeight - 80) }}
|
||||
onRow={(record) => ({
|
||||
onContextMenu: (e) => handleRowContextMenu(record, e),
|
||||
style: { cursor: 'context-menu' },
|
||||
|
||||
Reference in New Issue
Block a user