feat(huya): 精英宝典工作台支持导入外部 CK

- 新增 POST /api/huya/workbench-accounts/import-cookies(huya:task + huya:import):
  外部 CK 复用现有解析入库后直接加入当前用户的工作台集合,导入完即可在工作台执行
  「生成开通支付码/绑定二维码/查询角色/刷新积分」等操作
- 新账号自动 assigned_to=导入者:客服的账号列表按归属过滤,不设归属会「导入成功但看不到」
- import_huya_cookies_detailed 返回账号对象供工作台复用,原 import_huya_cookies 行为不变
- 工作台账号卡片新增「导入外部 CK」入口(huya:import 权限控制)与导入弹窗

验证:新增 tests/test_huya_workbench_import_ck.py(入库、归属、工作台集合、重复导入幂等、
全脏数据不建记录);按 AGENTS.md 做了反向验证(关掉归属赋值后该用例变红,还原后恢复)。
前端 tsc -b 与全量 pytest 通过。
This commit is contained in:
yml2213
2026-10-15 14:56:58 +08:00
parent f540dcfc29
commit 4ce30e5c26
10 changed files with 304 additions and 9 deletions
+81
View File
@@ -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
+2
View File
@@ -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",
+2
View File
@@ -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,
+67
View File
@@ -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),
}
+8
View File
@@ -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)。"""
+24 -7
View File
@@ -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(
+5
View File
@@ -32,6 +32,8 @@ import type {
HuyaTaskItem,
HuyaBrowserExchangeKit,
HuyaExchangeTargetKey,
HuyaExternalCkImportRequest,
HuyaExternalCkImportResult,
HuyaWorkbenchScope,
MessageCountResponse,
MessageDeletedResponse,
@@ -140,6 +142,9 @@ export const huyaApi = {
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
listRechargeGoods: () => api.get<HuyaRechargeGoodsItem[], HuyaRechargeGoodsItem[]>('/huya/recharge-goods'),
/** 工作台导入外部 CK: 入库并把账号加入自己的工作台 (text 每行一条 CK) */
importWorkbenchCookies: (data: HuyaExternalCkImportRequest) =>
api.post<HuyaExternalCkImportResult, HuyaExternalCkImportResult>('/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 } },
+13
View File
@@ -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 {
@@ -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<HuyaExternalCkImportResult | null>(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 (
<Modal
open={open}
title="导入外部 CK 到工作台"
width={640}
onCancel={onClose}
onOk={() => void submit()}
confirmLoading={importing}
okText="导入"
cancelText="取消"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Input.TextArea
rows={8}
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={'每行一条 CK,支持 N 行批量导入\n支持格式:纯 CK 或 账号----密码----CK'}
/>
<Space size={8}>
<Text type="secondary">标签</Text>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="可选"
value={tag || undefined}
onChange={(value) => setTag(value || '')}
options={tags.map((item) => ({ value: item, label: item }))}
style={{ width: 220 }}
/>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
导入后账号会直接加入当前工作台,可照常执行「生成开通支付码 / 绑定二维码 / 查询角色 / 刷新积分」等操作。
</Text>
{result && (
<Alert
type={result.count ? 'success' : 'warning'}
showIcon
message={result.message}
description={`成功 ${result.count} 条,跳过 ${result.skipped} 条;工作台现有 ${result.account_ids.length} 个账号`}
/>
)}
</Space>
</Modal>
);
}
+6 -2
View File
@@ -9,6 +9,7 @@ import {
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
@@ -69,6 +70,7 @@ function taskQrUrl(task: HuyaTaskItem | undefined): string {
export default function HuyaElitePage() {
const { can } = usePermissions();
const canConfig = can('huya:config');
const canImport = can('huya:import');
const [pool, setPool] = useState<HuyaAccountItem[]>([]);
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -90,6 +92,7 @@ export default function HuyaElitePage() {
const [importTags, setImportTags] = useState<string[]>([]);
const [importSelected, setImportSelected] = useState<number[]>([]);
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
const [ckImportOpen, setCkImportOpen] = useState(false);
const [config, setConfig] = useState<HuyaConfig | null>(null);
const [configDraft, setConfigDraft] = useState<HuyaConfig | null>(null);
const [configOpen, setConfigOpen] = useState(false);
@@ -127,7 +130,7 @@ export default function HuyaElitePage() {
}, []);
useEffect(() => { void load(); }, [load]);
useEffect(() => { if (importOpen) void huyaApi.listTags().then(setImportTags).catch(() => {}); }, [importOpen]);
useEffect(() => { if (importOpen || ckImportOpen) void huyaApi.listTags().then(setImportTags).catch(() => {}); }, [ckImportOpen, importOpen]);
useEffect(() => {
const active = tasks.some((task) => ['planned', 'pending', 'running'].includes(task.status));
const timer = window.setInterval(() => { void loadTasks(); }, (active ? 3 : refreshInterval) * 1000);
@@ -262,7 +265,7 @@ export default function HuyaElitePage() {
</Space>
);
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => 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: '右键打开账号动作' })} /></div></>;
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => 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: '右键打开账号动作' })} /></div></>;
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
const contextMenuStyle = contextMenu ? {
@@ -282,5 +285,6 @@ export default function HuyaElitePage() {
<Modal open={qrTask !== null} title={qrTask?.task_type === 'create_recharge_order' ? '开通支付二维码' : '绑定二维码'} footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'pay_url') || resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p>{qrTask.task_type === 'create_recharge_order' && <Text type="secondary">扫码后订单状态会自动轮询更新</Text>}</div>}</Modal>
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => 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="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !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)) }} /></Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
</div>;
}