diff --git a/tests/test_huya_workbench_import_ck.py b/tests/test_huya_workbench_import_ck.py new file mode 100644 index 0000000..2c6f2b7 --- /dev/null +++ b/tests/test_huya_workbench_import_ck.py @@ -0,0 +1,81 @@ +"""工作台「导入外部 CK」:入库 + 自动加入工作台 + 归属导入者。""" + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import HuyaAccount, HuyaWorkbench, HuyaWorkbenchAccount, User +from web.backend.routers.huya import import_workbench_cookies +from web.backend.schemas import HuyaWorkbenchCookieImport + +COOKIE_1 = ( + "yyuid=1001; udb_uid=1001; udb_passport=hy_test_1; " + "udb_cred=cred1; udb_biztoken=biz1" +) +COOKIE_2 = ( + "yyuid=1002; udb_uid=1002; udb_passport=hy_test_2; " + "udb_cred=cred2; udb_biztoken=biz2" +) + + +class TestImportExternalCkToWorkbench: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.user = User(username="operator", password_hash="hash", role="super_admin") + self.session.add(self.user) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def test_import_creates_accounts_and_joins_workbench(self): + result = import_workbench_cookies( + HuyaWorkbenchCookieImport( + text=f"小号A----{COOKIE_1}\n这不是一行 CK\n{COOKIE_2}", + tag="外部CK", + handbook_scope="elite", + ), + db=self.session, + current=self.user, + ) + + assert result["success"] is True + assert result["count"] == 2 + assert result["skipped"] == 1 + assert len(result["account_ids"]) == 2 + + accounts = { + account.yyuid: account for account in self.session.query(HuyaAccount).all() + } + assert set(accounts) == {"1001", "1002"} + # 无归属的新账号自动归属导入者,否则客服在工作台看不到自己导入的账号 + assert all(account.assigned_to == self.user.id for account in accounts.values()) + assert accounts["1001"].tag == "外部CK" + + joined = { + row.account_id + for row in self.session.query(HuyaWorkbenchAccount) + .filter(HuyaWorkbenchAccount.user_id == self.user.id) + .all() + } + assert joined == set(result["account_ids"]) + assert self.session.query(HuyaWorkbench).count() == 1 + + def test_reimport_is_idempotent(self): + payload = HuyaWorkbenchCookieImport(text=COOKIE_1, handbook_scope="elite") + first = import_workbench_cookies(payload, db=self.session, current=self.user) + second = import_workbench_cookies(payload, db=self.session, current=self.user) + assert first["account_ids"] == second["account_ids"] + assert len(first["account_ids"]) == 1 + assert self.session.query(HuyaAccount).count() == 1 + assert self.session.query(HuyaWorkbenchAccount).count() == 1 + + def test_all_invalid_lines_reports_zero(self): + result = import_workbench_cookies( + HuyaWorkbenchCookieImport(text="垃圾行\n更多垃圾", handbook_scope="elite"), + db=self.session, + current=self.user, + ) + assert result["count"] == 0 + assert result["skipped"] == 2 + assert result["account_ids"] == [] + assert self.session.query(HuyaAccount).count() == 0 + assert self.session.query(HuyaWorkbench).count() == 0 diff --git a/web/backend/routers/huya/__init__.py b/web/backend/routers/huya/__init__.py index f58fb56..2102716 100644 --- a/web/backend/routers/huya/__init__.py +++ b/web/backend/routers/huya/__init__.py @@ -39,6 +39,7 @@ from .workbench import ( create_task_batch, get_config, get_task, + import_workbench_cookies, list_goods, list_huya_workbench_accounts, list_recharge_goods, @@ -79,6 +80,7 @@ __all__ = [ "get_task", "import_cookies", "import_password_accounts", + "import_workbench_cookies", "list_accounts", "list_goods", "list_huya_account_ids", diff --git a/web/backend/routers/huya/_common.py b/web/backend/routers/huya/_common.py index 58006b4..b58d1c0 100644 --- a/web/backend/routers/huya/_common.py +++ b/web/backend/routers/huya/_common.py @@ -89,6 +89,7 @@ from ...schemas import ( HuyaTaskOut, HuyaTestSmsUrlRequest, HuyaWorkbenchAccountsUpdate, + HuyaWorkbenchCookieImport, ) from ...services.audit_service import record_audit from ...services.huya_exchange_targets import ( @@ -111,6 +112,7 @@ from ...services.huya_service import ( ensure_huya_config, huya_config_value, import_huya_cookies, + import_huya_cookies_detailed, import_huya_password_accounts, rebuild_web_cookie_from_raw, save_huya_login_cookie_to_account, diff --git a/web/backend/routers/huya/workbench.py b/web/backend/routers/huya/workbench.py index 3469a0e..891ef4a 100644 --- a/web/backend/routers/huya/workbench.py +++ b/web/backend/routers/huya/workbench.py @@ -384,3 +384,70 @@ async def ws_huya_logs(websocket: WebSocket, batch_id: str): latest = huya_batch_registry.get(batch_id) if latest and latest.get("finished"): huya_batch_registry.pop(batch_id) + + +@router.post("/workbench-accounts/import-cookies") +def import_workbench_cookies( + req: HuyaWorkbenchCookieImport, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """导入外部 CK:入库为虎牙账号并直接加入当前用户的工作台。 + + 导入后的账号会出现在工作台账号列表里,可照常执行「生成开通支付码 / 绑定二维码 / + 查询角色 / 刷新积分」等操作。没有归属的新账号自动归属到导入者,否则客服看不到。 + """ + _require_huya_perm(current, "huya:import") + accounts, skipped = import_huya_cookies_detailed( + db, req.text, req.tag, assign_to=current.id + ) + imported_ids = sorted({account.id for account in accounts}) + if imported_ids: + workbench = ( + db.query(HuyaWorkbench) + .filter( + HuyaWorkbench.user_id == current.id, + HuyaWorkbench.handbook_scope == req.handbook_scope, + ) + .first() + ) + if workbench is None: + db.add(HuyaWorkbench(user_id=current.id, handbook_scope=req.handbook_scope)) + else: + workbench.updated_at = datetime.now(UTC) + existing_ids = { + account_id + for (account_id,) in db.query(HuyaWorkbenchAccount.account_id) + .filter( + HuyaWorkbenchAccount.user_id == current.id, + HuyaWorkbenchAccount.handbook_scope == req.handbook_scope, + ) + .all() + } + db.add_all( + [ + HuyaWorkbenchAccount( + user_id=current.id, + handbook_scope=req.handbook_scope, + account_id=account_id, + ) + for account_id in imported_ids + if account_id not in existing_ids + ] + ) + record_audit( + db, + current, + action="huya:workbench:import_ck", + target=f"huya_workbench:{req.handbook_scope}", + detail={"count": len(imported_ids), "skipped": skipped, "tag": req.tag}, + ) + db.commit() + + return { + "message": f"导入 {len(imported_ids)} 条,跳过 {skipped} 条", + "success": True, + "count": len(imported_ids), + "skipped": skipped, + "account_ids": _workbench_account_ids(db, current, req.handbook_scope), + } diff --git a/web/backend/schemas.py b/web/backend/schemas.py index d131a65..b7ca787 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -657,6 +657,14 @@ class HuyaWorkbenchAccountsUpdate(BaseModel): account_ids: list[int] = Field(default_factory=list) +class HuyaWorkbenchCookieImport(BaseModel): + """工作台「导入外部 CK」:把外部 Cookie 入库并直接加入自己的工作台。""" + + text: str = Field(..., min_length=1) + tag: str = "" + handbook_scope: str = Field("elite", pattern="^elite$|^browser_exchange$") + + class HuyaBrowserExchangeRequest(BaseModel): """浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。""" diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 59bc3ee..5d3ee3d 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -349,9 +349,16 @@ def upsert_huya_cookie( return account -def import_huya_cookies(db: Session, text: str, tag: str = "") -> tuple[int, int]: - """导入虎牙 Cookie,返回 (成功数, 跳过数)。""" - created_or_updated = 0 +def import_huya_cookies_detailed( + db: Session, text: str, tag: str = "", assign_to: int | None = None +) -> tuple[list[HuyaAccount], int]: + """导入虎牙 Cookie,返回 (账号列表, 跳过数)。 + + 与 ``import_huya_cookies`` 的差别:把每个账号对象交回调用方 —— 工作台 + 「导入外部 CK」需要拿到刚导入的账号加入自己的工作台集合;``assign_to`` + 用于给尚无归属的新账号打上导入者(否则客服看不到自己导入的账号)。 + """ + accounts: list[HuyaAccount] = [] skipped = 0 tag = (tag or "").strip() @@ -361,12 +368,22 @@ def import_huya_cookies(db: Session, text: str, tag: str = "") -> tuple[int, int skipped += 1 continue - _upsert_huya_account(db, parsed, tag=tag) - created_or_updated += 1 + account = _upsert_huya_account(db, parsed, tag=tag) + if assign_to and account.assigned_to is None: + account.assigned_to = assign_to + accounts.append(account) - if created_or_updated: + if accounts: db.commit() - return created_or_updated, skipped + for account in accounts: + db.refresh(account) + return accounts, skipped + + +def import_huya_cookies(db: Session, text: str, tag: str = "") -> tuple[int, int]: + """导入虎牙 Cookie,返回 (成功数, 跳过数)。""" + accounts, skipped = import_huya_cookies_detailed(db, text, tag) + return len(accounts), skipped def persist_huya_session_cookie( diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index d7c8558..580e9c8 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -32,6 +32,8 @@ import type { HuyaTaskItem, HuyaBrowserExchangeKit, HuyaExchangeTargetKey, + HuyaExternalCkImportRequest, + HuyaExternalCkImportResult, HuyaWorkbenchScope, MessageCountResponse, MessageDeletedResponse, @@ -140,6 +142,9 @@ export const huyaApi = { updateConfig: (data: Partial) => api.put('/huya/config', data), listGoods: () => api.get('/huya/goods'), listRechargeGoods: () => api.get('/huya/recharge-goods'), + /** 工作台导入外部 CK: 入库并把账号加入自己的工作台 (text 每行一条 CK) */ + importWorkbenchCookies: (data: HuyaExternalCkImportRequest) => + api.post('/huya/workbench-accounts/import-cookies', data), 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 a9a87e0..7d4f9fc 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1027,6 +1027,19 @@ export interface HuyaRechargeGoodsItem { updated_at: string | null; } +/** 工作台「导入外部 CK」:请求体 */ +export interface HuyaExternalCkImportRequest { + text: string; // 多行文本,每行一条 CK(支持「账号----密码----CK」) + tag?: string; + handbook_scope?: HuyaWorkbenchScope; +} + +/** 工作台「导入外部 CK」:结果(account_ids 是导入后的完整工作台账号集合) */ +export interface HuyaExternalCkImportResult extends MessageCountResponse { + skipped: number; + account_ids: number[]; +} + // ==================== Proxy ==================== export interface ProxyConfig { diff --git a/web/frontend/src/components/HuyaExternalCkImportModal.tsx b/web/frontend/src/components/HuyaExternalCkImportModal.tsx new file mode 100644 index 0000000..5fba4de --- /dev/null +++ b/web/frontend/src/components/HuyaExternalCkImportModal.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; +import { Alert, Input, Modal, Select, Space, Typography } from 'antd'; +import { huyaApi, type HuyaExternalCkImportResult, type HuyaWorkbenchScope } from '../api/modules'; +import { getErrorMessage } from '../utils/error'; +import { message } from '../utils/antdMessage'; + +const { Text } = Typography; + +export interface HuyaExternalCkImportModalProps { + open: boolean; + onClose: () => void; + /** 已有标签,供下拉选择 */ + tags: string[]; + defaultTag?: string; + scope?: HuyaWorkbenchScope; + /** 导入成功后回调(带完整的工作台账号集合) */ + onImported: (result: HuyaExternalCkImportResult) => void; +} + +/** 工作台「导入外部 CK」:粘贴外部 Cookie,入库并直接加入工作台,随后可在工作台操作 */ +export default function HuyaExternalCkImportModal({ + open, onClose, tags, defaultTag, scope = 'elite', onImported, +}: HuyaExternalCkImportModalProps) { + const [text, setText] = useState(''); + const [tag, setTag] = useState(defaultTag || ''); + const [importing, setImporting] = useState(false); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!open) return; + setResult(null); + setTag(defaultTag || ''); + }, [defaultTag, open]); + + const submit = async () => { + const value = text.trim(); + if (!value) { message.warning('请先粘贴 CK,每行一条'); return; } + setImporting(true); + try { + const saved = await huyaApi.importWorkbenchCookies({ text: value, tag, handbook_scope: scope }); + setResult(saved); + message.success(saved.message || `已导入 ${saved.count} 条`); + onImported(saved); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setImporting(false); + } + }; + + return ( + void submit()} + confirmLoading={importing} + okText="导入" + cancelText="取消" + > + + setText(event.target.value)} + placeholder={'每行一条 CK,支持 N 行批量导入\n支持格式:纯 CK 或 账号----密码----CK'} + /> + + 标签 + } placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{selectedIds.length > 0 && }
setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1040, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; + const accountsBody = <>} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }
setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1040, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, 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 ? { @@ -282,5 +285,6 @@ export default function HuyaElitePage() { setQrTask(null)} centered>{qrTask &&
{resultText(qrTask, 'mini_qrcode_image') ? 绑定二维码 : }

{qrTask.message || qrTask.status}

{qrTask.task_type === 'create_recharge_order' && 扫码后订单状态会自动轮询更新}
}
setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} />
!accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /> setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ?
加载中...
: configDraft && {configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}空闲轮询间隔(秒) { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} />}
+ setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} /> ; }