diff --git a/tests/test_huya_workbench_transfer_collab.py b/tests/test_huya_workbench_transfer_collab.py new file mode 100644 index 0000000..a92a7e9 --- /dev/null +++ b/tests/test_huya_workbench_transfer_collab.py @@ -0,0 +1,99 @@ +"""精英宝典工作台「转入外部单协作」:移出工作台并取回 CK。""" + +import pytest +from fastapi import HTTPException + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import AuditLog, HuyaAccount, HuyaWorkbenchAccount, User +from web.backend.routers.huya.workbench import transfer_workbench_to_collab +from web.backend.schemas import HuyaWorkbenchTransferToCollab + + +class TestTransferWorkbenchToCollab: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.support = User(username="transfer-cs", password_hash="h", role="support") + self.account = HuyaAccount( + uid="100", + yyuid="100", + cookie="yyuid=100; udb_uid=100; udb_passport=hy_test", + assigned_to=None, + ) + self.session.add_all([self.support, self.account]) + self.session.commit() + self.account.assigned_to = self.support.id + self.session.commit() + + def teardown_method(self): + self.session.close() + + def _add_membership(self): + membership = HuyaWorkbenchAccount( + user_id=self.support.id, + handbook_scope="elite", + account_id=self.account.id, + ) + self.session.add(membership) + self.session.commit() + return membership + + def test_support_transfers_assigned_workbench_account(self): + self._add_membership() + + result = transfer_workbench_to_collab( + HuyaWorkbenchTransferToCollab(account_id=self.account.id), + db=self.session, + current=self.support, + ) + + assert result["success"] is True + assert result["account_id"] == self.account.id + assert "udb_uid=100" in result["ck_text"] + assert ( + self.session.query(HuyaWorkbenchAccount) + .filter(HuyaWorkbenchAccount.account_id == self.account.id) + .count() + == 0 + ) + audit = self.session.query(AuditLog).one() + assert audit.action == "huya:workbench:transfer_collab" + assert audit.target == f"huya_account:{self.account.id}" + + def test_account_not_in_workbench_is_rejected(self): + with pytest.raises(HTTPException) as excinfo: + transfer_workbench_to_collab( + HuyaWorkbenchTransferToCollab(account_id=self.account.id), + db=self.session, + current=self.support, + ) + assert excinfo.value.status_code == 404 + + def test_account_without_cookie_is_rejected(self): + self.account.cookie = "" + self.session.commit() + self._add_membership() + + with pytest.raises(HTTPException) as excinfo: + transfer_workbench_to_collab( + HuyaWorkbenchTransferToCollab(account_id=self.account.id), + db=self.session, + current=self.support, + ) + assert excinfo.value.status_code == 400 + + def test_support_cannot_transfer_other_users_account(self): + other = User(username="transfer-other", password_hash="h", role="support") + self.session.add(other) + self.session.commit() + self.account.assigned_to = other.id + self.session.commit() + self._add_membership() + + with pytest.raises(HTTPException) as excinfo: + transfer_workbench_to_collab( + HuyaWorkbenchTransferToCollab(account_id=self.account.id), + db=self.session, + current=self.support, + ) + assert excinfo.value.status_code == 403 diff --git a/web/backend/routers/huya/__init__.py b/web/backend/routers/huya/__init__.py index 1dfb1c0..1b22c04 100644 --- a/web/backend/routers/huya/__init__.py +++ b/web/backend/routers/huya/__init__.py @@ -50,6 +50,7 @@ from .workbench import ( stop_batch, task_types, tasks_summary, + transfer_workbench_to_collab, update_config, update_huya_workbench_accounts, ws_huya_logs, @@ -105,6 +106,7 @@ __all__ = [ "task_types", "tasks_summary", "test_sms_url_endpoint", + "transfer_workbench_to_collab", "update_config", "update_huya_workbench_accounts", "ws_huya_logs", diff --git a/web/backend/routers/huya/workbench.py b/web/backend/routers/huya/workbench.py index cb06072..4be0b7a 100644 --- a/web/backend/routers/huya/workbench.py +++ b/web/backend/routers/huya/workbench.py @@ -2,6 +2,7 @@ from fastapi import APIRouter +from ...schemas import HuyaWorkbenchTransferToCollab from ._common import * router = APIRouter() @@ -114,6 +115,51 @@ def update_huya_workbench_accounts( return {"account_ids": ids, "success": True} +@router.post("/workbench-accounts/transfer-to-collab") +def transfer_workbench_to_collab( + req: HuyaWorkbenchTransferToCollab, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """把单个精英宝典账号移出工作台,并返回其网页 CK 供外部单新建表单使用。""" + _require_huya_perm(current, "huya:collab:edit") + _require_huya_task_account_access(db, current, [req.account_id]) + + membership = ( + db.query(HuyaWorkbenchAccount) + .filter( + HuyaWorkbenchAccount.user_id == current.id, + HuyaWorkbenchAccount.handbook_scope == "elite", + HuyaWorkbenchAccount.account_id == req.account_id, + ) + .first() + ) + if membership is None: + raise HTTPException(status_code=404, detail="该账号不在精英宝典工作台") + + account = db.query(HuyaAccount).filter(HuyaAccount.id == req.account_id).first() + ck_text = str(account.cookie or "").strip() if account else "" + if not ck_text: + raise HTTPException( + status_code=400, detail="该账号没有可用 Cookie,无法转入外部单协作" + ) + + db.delete(membership) + record_audit( + db, + current, + action="huya:workbench:transfer_collab", + target=f"huya_account:{account.id}", + detail={"account_id": account.id, "uid": account.uid}, + ) + db.commit() + return { + "account_id": account.id, + "ck_text": ck_text, + "success": True, + } + + @router.get("/goods", response_model=list[HuyaGoodsOut]) def list_goods( db: Session = Depends(get_db), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 9178cf7..56de00d 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1199,6 +1199,12 @@ class HuyaWorkbenchCookieImport(BaseModel): handbook_scope: str = Field("elite", pattern="^elite$|^browser_exchange$") +class HuyaWorkbenchTransferToCollab(BaseModel): + """精英宝典工作台把单个账号转入外部单协作。""" + + account_id: int = Field(..., gt=0) + + class HuyaBrowserExchangeRequest(BaseModel): """浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。""" diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 06908d1..d0b295e 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -68,6 +68,8 @@ import type { HuyaExchangeTargetKey, HuyaExternalCkImportRequest, HuyaExternalCkImportResult, + HuyaWorkbenchTransferToCollabRequest, + HuyaWorkbenchTransferToCollabResult, HuyaWorkbenchScope, MessageCountResponse, MessageDeletedResponse, @@ -179,6 +181,12 @@ export const huyaApi = { /** 工作台导入外部 CK: 入库并把账号加入自己的工作台 (text 每行一条 CK) */ importWorkbenchCookies: (data: HuyaExternalCkImportRequest) => api.post('/huya/workbench-accounts/import-cookies', data), + /** 精英宝典账号转入外部单协作:移出工作台并取回该账号网页 CK */ + transferWorkbenchToCollab: (accountId: number) => + api.post( + '/huya/workbench-accounts/transfer-to-collab', + { account_id: accountId } satisfies HuyaWorkbenchTransferToCollabRequest, + ), listWorkbenchAccounts: (scope: HuyaWorkbenchScope = 'elite') => api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>( '/huya/workbench-accounts', { params: { handbook_scope: scope } }, diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 68c08c8..c8b13a7 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1274,6 +1274,17 @@ export interface HuyaExternalCkImportResult extends MessageCountResponse { account_ids: number[]; } +/** 精英宝典工作台「转入外部单协作」:请求体 */ +export interface HuyaWorkbenchTransferToCollabRequest { + account_id: number; +} + +/** 精英宝典工作台「转入外部单协作」:结果里的 ck_text 用于外部单新建表单预填 */ +export interface HuyaWorkbenchTransferToCollabResult extends MessageResponse { + account_id: number; + ck_text: string; +} + // ==================== Proxy ==================== export interface ProxyConfig { diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 8fd6105..0b87a95 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -7,6 +7,7 @@ * 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。 */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { Alert, App, Badge, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form, Image, Input, InputNumber, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic, @@ -499,6 +500,8 @@ function ImageSlotEditor({ export default function HuyaCollabPage() { const { modal } = App.useApp(); const { can, user } = usePermissions(); + const location = useLocation(); + const navigate = useNavigate(); const canEdit = can('huya:collab:edit'); const canManage = can('huya:collab:manage'); // 单行复制与批量导出共用这个权限点(默认运营和客服都有) @@ -819,6 +822,23 @@ export default function HuyaCollabPage() { [runCkPreview], ); + useEffect(() => { + const state = location.state as { transferCk?: string } | null; + const transferCk = state?.transferCk?.trim(); + if (!transferCk) return; + + setCreateOpen(true); + setCkPreview(null); + setPreviewing(false); + setGiftHint(''); + setDraftRestoredAt(null); + previewSeq.current += 1; + createForm.resetFields(); + createForm.setFieldsValue({ ck_text: transferCk }); + scheduleCkPreview(transferCk, true); + navigate(location.pathname, { replace: true, state: null }); + }, [createForm, location.pathname, location.state, navigate, scheduleCkPreview]); + const isArchiveView = filters.archive_scope === 'archived'; const selectedRows = useMemo( () => rows.filter((row) => selectedIds.includes(row.id)), diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 60948e8..f74bfab 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { MouseEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, } from 'antd'; import type { TableProps } from 'antd'; import { CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined, - ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, ZoomInOutlined, + ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, SwapOutlined, ZoomInOutlined, } from '@ant-design/icons'; import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules'; import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal'; @@ -169,9 +170,11 @@ function taskQrUrl(task: HuyaTaskItem | undefined): string { } export default function HuyaElitePage() { + const navigate = useNavigate(); const { can } = usePermissions(); const canConfig = can('huya:config'); const canImport = can('huya:import') || can('huya:elite:import_ck'); + const canTransferCollab = can('huya:collab:edit'); const [pool, setPool] = useState([]); const [accounts, setAccounts] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -188,6 +191,7 @@ export default function HuyaElitePage() { const [pageSize, setPageSize] = useState(savedPageSize); const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(false); + const [transferring, setTransferring] = useState(false); const [starting, setStarting] = useState(false); const [concurrency, setConcurrency] = useState(3); const [refreshInterval, setRefreshInterval] = useState(savedInterval); @@ -272,6 +276,31 @@ export default function HuyaElitePage() { setSelectedIds((prev) => prev.filter((id) => saved.account_ids.includes(id))); }; + const transferToCollab = async () => { + if (selectedIds.length !== 1) { + message.warning('一次只能选择一个账号转入外部单协作'); + return; + } + const accountId = selectedIds[0]; + const account = accounts.find((item) => item.id === accountId); + if (account && account.has_cookie === false) { + message.warning('该账号没有可用 Cookie,不能转入外部单协作'); + return; + } + setTransferring(true); + try { + const result = await huyaApi.transferWorkbenchToCollab(accountId); + message.success('已从精英宝典工作台移除,正在打开外部单协作'); + navigate('/huya/collab', { + replace: true, + state: { transferCk: result.ck_text }, + }); + } catch (error) { + setTransferring(false); + message.error(getErrorMessage(error)); + } + }; + // 开通商品分组:精英宝典(限购主商品,单独一块)与其他商品(币/徽章/令…,带数量那一块) const rechargeGoodsSorted = [...rechargeGoods].sort(byRechargeOrder); const eliteRechargeGoods = rechargeGoodsSorted.filter(isEliteHandbookGood); @@ -478,7 +507,7 @@ export default function HuyaElitePage() { ); - const accountsBody = <>} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }
setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; + const accountsBody = <>} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }{canTransferCollab && selectedIds.length === 1 && }
setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; const configField = (key: Exclude, label: string) => configDraft && {label} setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} />; const contextMenuStyle = contextMenu ? {