diff --git a/web/backend/crypto_storage.py b/web/backend/crypto_storage.py index c289fd6..5fe923a 100644 --- a/web/backend/crypto_storage.py +++ b/web/backend/crypto_storage.py @@ -148,6 +148,8 @@ _SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = ( ("accounts", "email"), ("accounts", "email_password"), ("login_tasks", "cookie"), + ("huya_accounts", "account_password"), + ("huya_accounts", "cookie"), ("proxy_config", "api_url"), ("proxy_config", "http"), ("proxy_config", "https"), diff --git a/web/backend/migrations/versions/20260705_0006_huya_password_accounts.py b/web/backend/migrations/versions/20260705_0006_huya_password_accounts.py new file mode 100644 index 0000000..bc56add --- /dev/null +++ b/web/backend/migrations/versions/20260705_0006_huya_password_accounts.py @@ -0,0 +1,42 @@ +"""虎牙账号增加密码导入字段 + +Revision ID: 20260705_0006 +Revises: 20260704_0005 +Create Date: 2026-07-05 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260705_0006" +down_revision: Union[str, None] = "20260704_0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _columns(bind, table_name: str) -> set[str]: + if not _has_table(bind, table_name): + return set() + return {column["name"] for column in sa.inspect(bind).get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + if "account_password" not in _columns(bind, "huya_accounts"): + op.add_column( + "huya_accounts", + sa.Column("account_password", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + if "account_password" in _columns(bind, "huya_accounts"): + op.drop_column("huya_accounts", "account_password") diff --git a/web/backend/models.py b/web/backend/models.py index ee465cd..21126ba 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -79,13 +79,14 @@ class LoginTask(Base): class HuyaAccount(Base): - """虎牙 Cookie 账号""" + """虎牙账号""" __tablename__ = "huya_accounts" id = Column(Integer, primary_key=True, autoincrement=True) uid = Column(String(32), default="", index=True) yyuid = Column(String(32), default="", index=True) username = Column(String(128), default="") + account_password = Column(EncryptedText(), default="") nickname = Column(String(128), default="") cookie = Column(EncryptedText(), nullable=False) tag = Column(String(64), default="") diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 2929aad..8856f0c 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -27,7 +27,9 @@ from ..schemas import ( HuyaConfigUpdate, HuyaCookieImport, HuyaGoodsOut, + HuyaPasswordAccountImport, HuyaPasswordLoginRequest, + HuyaPasswordLoginSelectedRequest, HuyaRechargeGoodsOut, HuyaTaskBatchRequest, HuyaTaskOut, @@ -40,6 +42,8 @@ from ..services.huya_service import ( ensure_huya_config, huya_config_value, import_huya_cookies, + import_huya_password_accounts, + save_huya_login_cookie_to_account, upsert_huya_cookie, ) from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry @@ -93,6 +97,7 @@ def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccou uid=account.uid or "", yyuid=account.yyuid or "", username=account.username or "", + has_password=bool(account.account_password), nickname=account.nickname or "", cookie=cookie if include_cookie else "", cookie_preview=_fmt_cookie_preview(cookie) if include_cookie else "***", @@ -200,6 +205,23 @@ def import_cookies( } +@router.post("/accounts/import-passwords") +def import_password_accounts( + req: HuyaPasswordAccountImport, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """导入虎牙账号密码,只入库不立即登录。""" + _require_huya_perm(current, "huya:import") + count, skipped = import_huya_password_accounts(db, req.text, req.tag) + return { + "message": f"导入/更新 {count} 个虎牙账号,跳过 {skipped} 条", + "success": True, + "count": count, + "skipped": skipped, + } + + @router.post("/accounts/password-login") def password_login_account( req: HuyaPasswordLoginRequest, @@ -236,6 +258,136 @@ def password_login_account( } +@router.post("/accounts/password-login/selected") +def password_login_selected_accounts( + req: HuyaPasswordLoginSelectedRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """对已导入的虎牙账号执行密码登录。""" + _require_huya_perm(current, "huya:import") + if not req.account_ids: + raise HTTPException(status_code=400, detail="请选择虎牙账号") + + accounts = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id.in_(req.account_ids)) + .all() + ) + account_map = {account.id: account for account in accounts} + results = [] + success_count = 0 + failed_count = 0 + include_cookie = _can_view_huya_cookie(current) + + for account_id in req.account_ids: + account = account_map.get(account_id) + if account is None: + failed_count += 1 + results.append({ + "line": account_id, + "username": "", + "success": False, + "message": "账号不存在或无权登录", + }) + continue + + username = (account.username or "").strip() + password = (account.account_password or "").strip() + if not username or not password: + failed_count += 1 + results.append({ + "line": account.id, + "username": username, + "success": False, + "message": "该账号未导入密码", + "account": _account_out(account, include_cookie=include_cookie), + }) + continue + + try: + result = login_huya_password( + username=username, + password=password, + cookie=account.cookie or None, + ) + if not result.success or not result.cookie: + account.status = "login_failed" + account.updated_at = datetime.now(timezone.utc) + db.commit() + failed_count += 1 + results.append({ + "line": account.id, + "username": username, + "success": False, + "message": result.message or "虎牙密码登录失败", + "account": _account_out(account, include_cookie=include_cookie), + "sdid": result.sdid, + }) + continue + + saved = save_huya_login_cookie_to_account( + db, + account, + result.cookie, + tag=account.tag or "", + username_hint=username, + ) + success_count += 1 + results.append({ + "line": account.id, + "username": username, + "success": True, + "message": "登录成功,Cookie 已保存", + "account": _account_out(saved, include_cookie=include_cookie), + "sdid": result.sdid, + }) + except HuyaCredentialError as exc: + account.status = "login_failed" + account.updated_at = datetime.now(timezone.utc) + db.commit() + failed_count += 1 + results.append({ + "line": account.id, + "username": username, + "success": False, + "message": str(exc), + "account": _account_out(account, include_cookie=include_cookie), + }) + except (HuyaLoginError, ValueError) as exc: + account.status = "login_failed" + account.updated_at = datetime.now(timezone.utc) + db.commit() + failed_count += 1 + results.append({ + "line": account.id, + "username": username, + "success": False, + "message": str(exc), + "account": _account_out(account, include_cookie=include_cookie), + }) + except Exception as exc: + account.status = "login_failed" + account.updated_at = datetime.now(timezone.utc) + db.commit() + failed_count += 1 + results.append({ + "line": account.id, + "username": username, + "success": False, + "message": f"虎牙密码登录失败: {exc}", + "account": _account_out(account, include_cookie=include_cookie), + }) + + return { + "message": f"登录完成:成功 {success_count} 条,失败 {failed_count} 条", + "success": True, + "count": success_count, + "failed": failed_count, + "results": results, + } + + @router.delete("/accounts/batch") def delete_accounts_batch( account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 681912e..a48b34e 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -183,11 +183,23 @@ class HuyaPasswordLoginRequest(BaseModel): cookie: str = "" +class HuyaPasswordAccountImport(BaseModel): + """导入虎牙账号密码,稍后再选择登录。""" + text: str = Field(..., min_length=1) + tag: str = "" + + +class HuyaPasswordLoginSelectedRequest(BaseModel): + """选择已导入的虎牙账号执行密码登录。""" + account_ids: list[int] + + class HuyaAccountOut(BaseModel): id: int uid: str = "" yyuid: str = "" username: str = "" + has_password: bool = False nickname: str = "" cookie: str = "" cookie_preview: str = "" @@ -212,6 +224,7 @@ class HuyaAccountOut(BaseModel): "uid": self.uid, "yyuid": self.yyuid, "username": self.username, + "has_password": self.has_password, "nickname": self.nickname, "cookie": self.cookie, "cookie_preview": self.cookie_preview, diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 2ccb02d..8b9b74e 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -1,5 +1,6 @@ """虎牙基础业务服务。""" +import csv import uuid from datetime import datetime, timezone @@ -93,6 +94,76 @@ def parse_huya_cookie_line(line: str) -> dict | None: } +def split_huya_password_line(line: str) -> tuple[str, str, str] | None: + """拆分虎牙账号密码行,返回账号、密码、预置 Cookie。""" + raw = (line or "").strip() + if not raw or raw.startswith("#"): + return None + + if "----" in raw: + parts = [part.strip() for part in raw.split("----")] + elif "|" in raw: + parts = [part.strip() for part in raw.split("|")] + elif "\t" in raw: + parts = [part.strip() for part in raw.split("\t")] + elif "," in raw: + parts = [part.strip() for part in next(csv.reader([raw]))] + else: + parts = raw.split() + + if len(parts) < 2: + return None + + username = parts[0].strip() + password = parts[1].strip() + cookie = "----".join(part.strip() for part in parts[2:] if part.strip()) + if not username or not password: + return None + return username, password, cookie + + +def import_huya_password_accounts(db: Session, text: str, tag: str = "") -> tuple[int, int]: + """导入虎牙账号密码,返回 (导入/更新数, 跳过数)。""" + created_or_updated = 0 + skipped = 0 + tag = (tag or "").strip() + + for line in (text or "").splitlines(): + parsed = split_huya_password_line(line) + if not parsed: + if line.strip(): + skipped += 1 + continue + + username, password, cookie = parsed + account = db.query(HuyaAccount).filter(HuyaAccount.username == username).first() + if account is None: + account = HuyaAccount( + uid="", + yyuid="", + username=username, + account_password=password, + cookie=normalize_huya_cookie(cookie) if cookie else "", + tag=tag, + status="password_imported", + ) + db.add(account) + else: + account.account_password = password + if cookie: + account.cookie = normalize_huya_cookie(cookie) + if tag: + account.tag = tag + if not account.cookie: + account.status = "password_imported" + account.updated_at = datetime.now(timezone.utc) + created_or_updated += 1 + + if created_or_updated: + db.commit() + return created_or_updated, skipped + + def _upsert_huya_account(db: Session, parsed: dict, tag: str = "", status: str | None = None) -> HuyaAccount: """按 uid/yyuid 新增或更新虎牙账号。""" account = None @@ -125,6 +196,33 @@ def _upsert_huya_account(db: Session, parsed: dict, tag: str = "", status: str | return account +def save_huya_login_cookie_to_account( + db: Session, + account: HuyaAccount, + cookie: str, + tag: str = "", + username_hint: str = "", +) -> HuyaAccount: + """把登录成功后的 Cookie 回填到指定虎牙账号。""" + line = f"{username_hint}----{cookie}" if username_hint else cookie + parsed = parse_huya_cookie_line(line) + if not parsed: + raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid") + + account.uid = parsed["uid"] or account.uid + account.yyuid = parsed["yyuid"] or account.yyuid + account.username = parsed["username"] or account.username + account.cookie = parsed["cookie"] + account.game_phone = parsed["game_phone"] or account.game_phone + if tag: + account.tag = tag + account.status = "login_success" + account.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(account) + return account + + def upsert_huya_cookie(db: Session, cookie: str, tag: str = "", username_hint: str = "") -> HuyaAccount: """保存单条登录得到的虎牙 Cookie。""" line = f"{username_hint}----{cookie}" if username_hint else cookie diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 9714fb3..47feb49 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -5,8 +5,11 @@ import type { HuyaCookieItem, HuyaCookieImportResult, HuyaGoodsItem, + HuyaPasswordAccountImportResult, + HuyaPasswordLoginBatchResult, HuyaPasswordLoginRequest, HuyaPasswordLoginResult, + HuyaPasswordLoginSelectedRequest, HuyaRechargeGoodsItem, HuyaTaskBatchRequest, HuyaTaskBatchResult, @@ -22,8 +25,12 @@ export const huyaApi = { api.get('/huya/accounts', { params }), importCookies: (text: string, tag: string = '') => api.post('/huya/accounts/import-cookies', { text, tag }), + importPasswordAccounts: (text: string, tag: string = '') => + api.post('/huya/accounts/import-passwords', { text, tag }), passwordLogin: (data: HuyaPasswordLoginRequest) => api.post('/huya/accounts/password-login', data), + passwordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) => + api.post('/huya/accounts/password-login/selected', data), assign: (id: number, assigned_to: number | null) => api.put(`/huya/accounts/${id}/assign`, { assigned_to }), batchAssign: (account_ids: number[], assigned_to: number | null) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 51d0589..49a73d8 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -116,6 +116,7 @@ export interface HuyaAccountItem { uid: string; yyuid: string; username: string; + has_password: boolean; nickname: string; cookie: string; cookie_preview: string; @@ -148,6 +149,28 @@ export interface HuyaPasswordLoginResult extends MessageResponse { sdid: string; } +export interface HuyaPasswordLoginBatchItem { + line: number; + username: string; + success: boolean; + message: string; + account?: HuyaAccountItem; + sdid?: string; +} + +export interface HuyaPasswordLoginBatchResult extends MessageCountResponse { + failed: number; + results: HuyaPasswordLoginBatchItem[]; +} + +export interface HuyaPasswordAccountImportResult extends MessageCountResponse { + skipped: number; +} + +export interface HuyaPasswordLoginSelectedRequest { + account_ids: number[]; +} + export interface HuyaCookieItem { id: number; account_id: number; diff --git a/web/frontend/src/pages/HuyaAccountsPage.tsx b/web/frontend/src/pages/HuyaAccountsPage.tsx index c06a4cc..4de52f2 100644 --- a/web/frontend/src/pages/HuyaAccountsPage.tsx +++ b/web/frontend/src/pages/HuyaAccountsPage.tsx @@ -1,10 +1,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { - Button, Card, Col, Form, Input, message, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Typography, + Button, Card, Col, Input, message, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Typography, } from 'antd'; import type { TableProps } from 'antd'; import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons'; -import { huyaApi, type HuyaAccountItem, type SupportUserItem } from '../api/modules'; +import { + huyaApi, + type HuyaAccountItem, + type HuyaPasswordLoginBatchItem, + type SupportUserItem, +} from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTime } from '../utils/time'; import { getErrorMessage } from '../utils/error'; @@ -15,7 +20,9 @@ const { TextArea } = Input; const STATUS_LABELS: Record = { imported: '已导入', updated: '已更新', + password_imported: '待登录', login_success: '登录成功', + login_failed: '登录失败', active: '正常', invalid: '失效', }; @@ -23,18 +30,13 @@ const STATUS_LABELS: Record = { const STATUS_COLORS: Record = { imported: 'blue', updated: 'cyan', + password_imported: 'warning', login_success: 'success', + login_failed: 'error', active: 'success', invalid: 'error', }; -interface PasswordLoginFormValues { - username: string; - password: string; - tag?: string; - cookie?: string; -} - export default function HuyaAccountsPage() { const [accounts, setAccounts] = useState([]); const [users, setUsers] = useState([]); @@ -44,8 +46,13 @@ export default function HuyaAccountsPage() { const [importText, setImportText] = useState(''); const [importTag, setImportTag] = useState(''); const [importing, setImporting] = useState(false); - const [passwordLoginOpen, setPasswordLoginOpen] = useState(false); + const [passwordImportOpen, setPasswordImportOpen] = useState(false); + const [passwordImportText, setPasswordImportText] = useState(''); + const [passwordImportTag, setPasswordImportTag] = useState([]); + const [passwordImporting, setPasswordImporting] = useState(false); + const [passwordLoginResultOpen, setPasswordLoginResultOpen] = useState(false); const [passwordLogging, setPasswordLogging] = useState(false); + const [passwordLoginResults, setPasswordLoginResults] = useState([]); const [searchText, setSearchText] = useState(''); const [tagFilter, setTagFilter] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -56,7 +63,6 @@ export default function HuyaAccountsPage() { return v ? Number(v) || 20 : 20; }); const [currentPage, setCurrentPage] = useState(1); - const [passwordLoginForm] = Form.useForm(); const { can } = usePermissions(); const canManage = can('huya:account'); @@ -146,29 +152,52 @@ export default function HuyaAccountsPage() { } }; - const openPasswordLogin = () => { - passwordLoginForm.resetFields(); - setPasswordLoginOpen(true); + const openPasswordImport = () => { + setPasswordImportText(''); + setPasswordImportTag([]); + setPasswordLoginResults([]); + setPasswordImportOpen(true); }; - const closePasswordLogin = () => { - if (passwordLogging) return; - setPasswordLoginOpen(false); - passwordLoginForm.resetFields(); + const handleImportPasswordAccounts = async () => { + if (!passwordImportText.trim()) { + message.warning('请先粘贴虎牙账号密码'); + return; + } + setPasswordImporting(true); + try { + const tag = passwordImportTag.length > 0 ? passwordImportTag[passwordImportTag.length - 1].trim() : ''; + const result = await huyaApi.importPasswordAccounts(passwordImportText, tag); + message.success(result.message); + setPasswordImportOpen(false); + setPasswordImportText(''); + setPasswordImportTag([]); + loadAccounts(); + loadTags(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setPasswordImporting(false); + } }; - const handlePasswordLogin = async (values: PasswordLoginFormValues) => { + const handleLoginSelected = async () => { + if (selectedRowKeys.length === 0) { + message.warning('请先选择虎牙账号'); + return; + } setPasswordLogging(true); try { - const result = await huyaApi.passwordLogin({ - username: values.username.trim(), - password: values.password, - tag: values.tag?.trim() || '', - cookie: values.cookie?.trim() || '', + const result = await huyaApi.passwordLoginSelected({ + account_ids: selectedRowKeys.map((key) => Number(key)), }); - message.success(result.message || '登录成功,Cookie 已保存'); - setPasswordLoginOpen(false); - passwordLoginForm.resetFields(); + setPasswordLoginResults(result.results || []); + setPasswordLoginResultOpen(true); + if (result.failed > 0) { + message.warning(result.message); + } else { + message.success(result.message || '登录成功,Cookie 已保存'); + } loadAccounts(); loadTags(); } catch (e: unknown) { @@ -192,7 +221,7 @@ export default function HuyaAccountsPage() { const handleDeleteSelected = async () => { if (selectedRowKeys.length === 0) { - message.warning('请先选择虎牙 CK'); + message.warning('请先选择虎牙账号'); return; } try { @@ -248,6 +277,28 @@ export default function HuyaAccountsPage() { const boundCount = accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length; const pointCount = accounts.filter((item) => item.points !== null && item.points !== undefined).length; const assignedCount = accounts.filter((item) => item.assigned_to).length; + const passwordReadyCount = accounts.filter((item) => item.has_password).length; + + const passwordLoginResultColumns: TableProps['columns'] = [ + { title: '账号ID', dataIndex: 'line', width: 80, align: 'center' }, + { title: '账号', dataIndex: 'username', width: 160, ellipsis: true }, + { + title: '状态', + dataIndex: 'success', + width: 90, + align: 'center', + render: (success: boolean) => ( + {success ? '成功' : '失败'} + ), + }, + { + title: '保存账号', + width: 160, + ellipsis: true, + render: (_: unknown, record) => record.account?.nickname || record.account?.username || record.account?.uid || '-', + }, + { title: '提示', dataIndex: 'message', ellipsis: true }, + ]; const columns: TableProps['columns'] = [ { title: 'ID', dataIndex: 'id', width: 70, align: 'center' }, @@ -260,6 +311,7 @@ export default function HuyaAccountsPage() { UID {record.uid || record.yyuid || '-'} + {record.has_password ? 已导入密码 : null} ), }, @@ -422,16 +474,26 @@ export default function HuyaAccountsPage() { 批量打标签 )} + {canImport && ( + + )} {canDelete && selectedRowKeys.length > 0 && ( - + )} {canImport && ( - )} {canImport && ( @@ -449,6 +511,9 @@ export default function HuyaAccountsPage() { + + + @@ -553,48 +618,65 @@ export default function HuyaAccountsPage() { passwordLoginForm.submit()} - okText="登录并保存" - cancelButtonProps={{ disabled: passwordLogging }} - confirmLoading={passwordLogging} - maskClosable={!passwordLogging} - closable={!passwordLogging} - width={560} + title="导入虎牙账号密码" + open={passwordImportOpen} + onCancel={() => { + if (passwordImporting) return; + setPasswordImportOpen(false); + }} + onOk={handleImportPasswordAccounts} + okText="导入" + cancelButtonProps={{ disabled: passwordImporting }} + confirmLoading={passwordImporting} + maskClosable={!passwordImporting} + closable={!passwordImporting} + width={720} > -
- - - - - - - - - - -