diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index 2446e4e..3d0b9e0 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -3,9 +3,13 @@ import asyncio import csv import io +import json +import shutil import threading from datetime import datetime, timezone +from pathlib import Path + from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.responses import StreamingResponse from sqlalchemy import func, or_ @@ -22,6 +26,8 @@ from core.huya import ( send_huya_sms_code, ) from core.huya.cookie_utils import normalize_huya_cookie +from core.huya.device_fingerprint import account_state_dir as _fp_state_dir +from core.huya.device_profile import _load_db as _load_profile_db, _save_db as _save_profile_db from core.sms_provider import parse_sms_lines from ..database import SessionLocal, get_db @@ -1749,3 +1755,68 @@ 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) + + +# ==================== 设备绑定管理 (一号一环境, R40) ==================== +# 环境数据源: +# 1) data/huya_device_profiles.json — 账号 ↔ 设备画像 (core.huya.device_profile) +# 2) data/huya_fp_states/<账号>/ — hydevice 高信任指纹 localStorage (account_state_dir) +# 3) huya_accounts 表 — 登录渠道/账号状态 (登录成功后回填) +# 解绑 = 删除 1+2 → 下次 App 登录自动生成全新环境并重新注册签发 t2/t5。 + +_DEVICE_BINDINGS_PERMS_VIEW = ("huya:account", "huya:view_all", "huya:view_assigned") + + +def _mask_hex(value: str | None, keep: int = 12) -> str: + """设备标识脱敏展示: 只保留前 keep 位。""" + v = value or "" + return f"{v[:keep]}…({len(v)})" if len(v) > keep else v + + +@router.get("/device-bindings") +def list_device_bindings(db: Session = Depends(get_db), current: User = Depends(get_current_user)): + """列出所有账号 ↔ 设备环境绑定 (画像 + 指纹状态 + 账号表三方合并)。""" + if not any(user_has_permission(current, p) for p in _DEVICE_BINDINGS_PERMS_VIEW): + raise HTTPException(status_code=403, detail="无虎牙账号查看权限") + profiles = _load_profile_db() + accounts = {a.username: a for a in db.query(HuyaAccount).all() if a.username} + rows = [] + for account, prof in sorted(profiles.items()): + acct = accounts.get(account) + state_dir = _fp_state_dir(account) + last_login = prof.get("last_login") or {} + rows.append({ + "account": account, + "vendor": prof.get("vendor", ""), + "model": prof.get("model", ""), + "screen": prof.get("screen", ""), + "fingerprint_masked": _mask_hex(prof.get("fingerprint")), + "guid32_masked": _mask_hex(prof.get("guid32")), + "hebe_count": len(prof.get("hebe") or {}), + "hdid32": prof.get("hdid", ""), # t1.t0 app 级常量, 非敏感 + "has_hydevice_state": state_dir.exists(), + "login_channel": (acct.login_channel if acct else "") or "", + "account_status": (acct.status if acct else "") or "未入库", + "bound_at": prof.get("bound_at"), + "last_login_at": last_login.get("at"), + "last_login_ok": last_login.get("ok"), + }) + return {"items": rows, "total": len(rows)} + + +@router.delete("/device-bindings/{account}") +def delete_device_binding(account: str, db: Session = Depends(get_db), current: User = Depends(get_current_user)): + """解绑: 删除该账号的设备画像与 hydevice 指纹状态, 下次登录自动生成全新环境。""" + _require_huya_perm(current, "huya:import") + profiles = _load_profile_db() + if account not in profiles: + raise HTTPException(status_code=404, detail="该账号没有设备绑定记录") + profiles.pop(account) + _save_profile_db(profiles) + state_dir = _fp_state_dir(account) + removed_state = False + if state_dir.exists(): + shutil.rmtree(state_dir, ignore_errors=True) + removed_state = True + return {"ok": True, "account": account, "profile_removed": True, "fp_state_removed": removed_state, + "message": "已解绑, 下次 App 协议登录将自动生成全新设备环境并重新注册"} diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index b64e8ac..238512c 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -23,6 +23,7 @@ const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage')); const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage')); const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage')); const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage')); +const HuyaDeviceBindingsPage = lazy(() => import('./pages/HuyaDeviceBindingsPage')); const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage')); const YybRechargePage = lazy(() => import('./pages/YybRechargePage')); const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage')); @@ -88,6 +89,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 8b76c9c..084943c 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -5,6 +5,7 @@ import type { AccountBulkTagRequest, HuyaAccountItem, HuyaAccountSummary, + HuyaDeviceBindingListResult, HuyaAutoRegisterBatch, HuyaAutoRegisterRequest, HuyaAutoRegisterRetryRequest, @@ -42,6 +43,12 @@ export const huyaApi = { listAccountsPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_cookie?: boolean }) => api.get, PaginatedResponse>('/huya/accounts', { params }), accountsSummary: () => api.get('/huya/accounts/summary'), + /** 设备绑定: 账号 ↔ 环境列表 (画像+指纹状态+账号表合并) */ + listDeviceBindings: () => + api.get('/huya/device-bindings'), + /** 解绑: 删除设备画像与 hydevice 指纹状态, 下次登录自动生成全新环境 */ + deleteDeviceBinding: (account: string) => + api.delete<{ ok: boolean; message: string }, { ok: boolean; message: string }>(`/huya/device-bindings/${encodeURIComponent(account)}`), importCookies: (text: string, tag: string = '') => api.post('/huya/accounts/import-cookies', { text, tag }), importPasswordAccounts: (text: string, tag: string = '') => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 969cfb5..6e08e91 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -463,6 +463,29 @@ export interface HuyaAccountItem { updated_at: string | null; } +/** 账号 ↔ 设备环境绑定 (一号一环境, 后端 data/huya_device_profiles.json + huya_fp_states) */ +export interface HuyaDeviceBindingItem { + account: string; + vendor: string; + model: string; + screen: string; + fingerprint_masked: string; + guid32_masked: string; + hebe_count: number; + hdid32: string; + has_hydevice_state: boolean; + login_channel: string; + account_status: string; + bound_at: number | null; + last_login_at: number | null; + last_login_ok: boolean | null; +} + +export interface HuyaDeviceBindingListResult { + items: HuyaDeviceBindingItem[]; + total: number; +} + export interface HuyaAccountSummary extends BasicSummary { password_ready_count: number; point_count: number; diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index fedec14..ef6d61b 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -83,6 +83,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { // 虎牙 if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) { huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: }); + huyaItems.push({ key: '/huya/device-bindings', label: '设备绑定', icon: }); } if (canAny(['huya:account', 'huya:import'])) { huyaItems.push({ key: '/huya/register', label: '自动注册', icon: }); diff --git a/web/frontend/src/pages/HuyaDeviceBindingsPage.tsx b/web/frontend/src/pages/HuyaDeviceBindingsPage.tsx new file mode 100644 index 0000000..a1eb114 --- /dev/null +++ b/web/frontend/src/pages/HuyaDeviceBindingsPage.tsx @@ -0,0 +1,139 @@ +/** + * 虎牙设备绑定管理页 — 账号 ↔ 设备环境 (一号一环境) 可视化。 + * + * 数据来源 (后端 /huya/device-bindings 合并三处): + * 1. data/huya_device_profiles.json 账号画像 (机型/屏幕/指纹40/guid32/Hebe) + * 2. data/huya_fp_states/<账号>/ hydevice 高信任指纹状态 (sdid/40hex hdid 的根) + * 3. huya_accounts 表 登录渠道 / 账号状态 + * + * 解绑 = 删除 1+2 → 下次 App 协议登录自动生成全新环境并重新注册签发 t2/t5。 + */ +import { useCallback, useEffect, useState } from 'react'; +import { Button, Card, Col, Popconfirm, Row, Statistic, Table, Tag, Tooltip, Typography } from 'antd'; +import type { TableProps } from 'antd'; +import { LinkOutlined, ReloadOutlined, RestOutlined } from '@ant-design/icons'; +import { message } from '../utils/antdMessage'; +import { huyaApi, type HuyaDeviceBindingItem } from '../api/modules'; +import { getErrorMessage } from '../utils/error'; + +const { Text } = Typography; + +/** 登录渠道展示 (与账号管理页一致) */ +const CHANNEL_LABELS: Record = { + app: { label: 'App 协议', color: 'processing' }, + web: { label: 'Web 旧版', color: 'warning' }, + sms: { label: '短信', color: 'cyan' }, +}; + +export default function HuyaDeviceBindingsPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [unbinding, setUnbinding] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + try { + const result = await huyaApi.listDeviceBindings(); + setItems(result.items || []); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + /** 解绑: 清除画像+指纹状态, 下次登录生成全新设备 */ + const handleUnbind = async (account: string) => { + setUnbinding(account); + try { + await huyaApi.deleteDeviceBinding(account); + message.success(`已解绑 ${account}, 下次 App 协议登录将生成全新设备环境`); + load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setUnbinding(''); + } + }; + + const columns: TableProps['columns'] = [ + { title: '账号', dataIndex: 'account', width: 150, ellipsis: true, + render: (v: string) => {v} }, + { title: '机型', width: 150, + render: (_: unknown, r: HuyaDeviceBindingItem) => ( + {r.model || '-'} ({r.vendor}) + ) }, + { title: '屏幕', dataIndex: 'screen', width: 150, ellipsis: true }, + { title: '设备指纹 (CDID40)', dataIndex: 'fingerprint_masked', width: 170 }, + { title: 'GUID32', dataIndex: 'guid32_masked', width: 160 }, + { title: 'Hebe', dataIndex: 'hebe_count', width: 70, align: 'center', + render: (n: number) => {n} 项 }, + { title: 'hydevice 状态', dataIndex: 'has_hydevice_state', width: 110, align: 'center', + render: (ok: boolean) => ok + ? 已生成 + : 待生成 }, + { title: '登录渠道', dataIndex: 'login_channel', width: 100, align: 'center', + render: (c: string) => { + const info = CHANNEL_LABELS[c]; + return info ? {info.label} : 未登录; + } }, + { title: '账号状态', dataIndex: 'account_status', width: 100, align: 'center', + render: (s: string) => s === '未入库' ? 未入库 : {s} }, + { title: '最后登录', dataIndex: 'last_login_at', width: 160, + render: (_: unknown, r: HuyaDeviceBindingItem) => { + if (!r.last_login_at) return -; + const time = new Date(r.last_login_at * 1000).toLocaleString(); + return {time} {r.last_login_ok === false && 失败}; + } }, + { title: '操作', width: 110, align: 'center', + render: (_: unknown, r: HuyaDeviceBindingItem) => ( + handleUnbind(r.account)} + > + + + ) }, + ]; + + const withState = items.filter((i) => i.has_hydevice_state).length; + const linked = items.filter((i) => i.account_status !== '未入库').length; + + return ( +
+
+

虎牙设备绑定

+ +
+ + + } /> + + + + + + + 一号一环境: 每个账号绑定一套独立设备画像 (机型/屏幕/指纹40/GUID32/Hebe) 与独立的 hydevice + 指纹状态; App 协议登录时用该环境注册并由服务端签发 safedeviceid/device_id。 + 解绑后下次登录自动生成全新环境。登录帧 t1.t0 为 app 版本级常量 (R15), 不区分设备。 + + + + + rowKey="account" + columns={columns} + dataSource={items} + loading={loading} + pagination={{ pageSize: 20, showSizeChanger: false, showTotal: (t) => `共 ${t} 条` }} + size="middle" + /> + +
+ ); +}