diff --git a/tests/test_douyu_cookie_visibility.py b/tests/test_douyu_cookie_visibility.py index 06c254a..e393b1c 100644 --- a/tests/test_douyu_cookie_visibility.py +++ b/tests/test_douyu_cookie_visibility.py @@ -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 diff --git a/tests/test_douyu_workbench_scopes.py b/tests/test_douyu_workbench_scopes.py index a355cc5..8ce4db7 100644 --- a/tests/test_douyu_workbench_scopes.py +++ b/tests/test_douyu_workbench_scopes.py @@ -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 diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index b6a51e5..d314a8f 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -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)$"), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index d72df79..24e9389 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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 = "" diff --git a/web/frontend/src/api/douyu.ts b/web/frontend/src/api/douyu.ts index e20e48d..269693a 100644 --- a/web/frontend/src/api/douyu.ts +++ b/web/frontend/src/api/douyu.ts @@ -19,8 +19,18 @@ export const douyuApi = { api.get('/douyu/accounts', { params }), listAccountsPaged: (params: PageParams & { ids?: string; tag?: string }) => api.get, PaginatedResponse>('/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('/douyu/config'), getRechargeChannel: () => api.get('/douyu/recharge-channel'), updateConfig: (data: Partial) => api.put('/douyu/config', data), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 3c2b36b..56be72d 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx index 81d62ce..71f25e2 100644 --- a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx @@ -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([]); // 工作台账号归属由服务端按用户保存;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>(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 }) ), }, + { + title: 'CK状态', width: 100, align: 'center', + render: (_, record) => { + if (ckCheckingIds.has(record.id)) { + return }>检测中; + } + if (record.ck_check_status === 'valid') { + return ( + + 有效 + + ); + } + if (record.ck_check_status === 'invalid') { + return ( + + 失效 + + ); + } + return 未检测; + }, + }, { title: '游戏名', dataIndex: 'game_name', width: 170, render: (_, record) => { @@ -1742,6 +1826,25 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) + + {isAdmin && ( + + )} {selectedIds.length > 0 && ( )} @@ -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' },