feat(web): 新增『虎牙设备绑定』管理页 — 一号一环境可视化

后端 (/huya/device-bindings):
- GET: 三方合并列表 (设备画像 + hydevice 指纹状态 + 账号表登录渠道)
  标识脱敏展示 (fingerprint/guid32 只留前12位)
- DELETE: 解绑 = 删除画像 + hydevice 状态 → 下次登录自动生成全新环境重新注册
前端:
- HuyaDeviceBindingsPage: 汇总卡片(绑定数/指纹状态/入库账号) + 明细表
  (机型/屏幕/CDID40/GUID32/Hebe/hydevice状态/登录渠道/最后登录) + 解绑重绑
- 路由 /huya/device-bindings + 菜单『设备绑定』(账号管理之下)
This commit is contained in:
yml2213
2026-08-29 16:39:30 +08:00
parent bf6dee1850
commit fda3d5257b
6 changed files with 243 additions and 0 deletions
+71
View File
@@ -3,9 +3,13 @@
import asyncio import asyncio
import csv import csv
import io import io
import json
import shutil
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import func, or_ from sqlalchemy import func, or_
@@ -22,6 +26,8 @@ from core.huya import (
send_huya_sms_code, send_huya_sms_code,
) )
from core.huya.cookie_utils import normalize_huya_cookie 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 core.sms_provider import parse_sms_lines
from ..database import SessionLocal, get_db 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) latest = huya_batch_registry.get(batch_id)
if latest and latest.get("finished"): if latest and latest.get("finished"):
huya_batch_registry.pop(batch_id) 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 协议登录将自动生成全新设备环境并重新注册"}
+2
View File
@@ -23,6 +23,7 @@ const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage')); const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage')); const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage')); const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
const HuyaDeviceBindingsPage = lazy(() => import('./pages/HuyaDeviceBindingsPage'));
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage')); const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage')); const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage')); const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
@@ -88,6 +89,7 @@ function AppContent() {
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} /> <Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} />
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage key="peace" handbook="peace" />)} /> <Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage key="peace" handbook="peace" />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} /> <Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/device-bindings" element={lazyRoute(<HuyaDeviceBindingsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} /> <Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} /> <Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} /> <Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
+7
View File
@@ -5,6 +5,7 @@ import type {
AccountBulkTagRequest, AccountBulkTagRequest,
HuyaAccountItem, HuyaAccountItem,
HuyaAccountSummary, HuyaAccountSummary,
HuyaDeviceBindingListResult,
HuyaAutoRegisterBatch, HuyaAutoRegisterBatch,
HuyaAutoRegisterRequest, HuyaAutoRegisterRequest,
HuyaAutoRegisterRetryRequest, HuyaAutoRegisterRetryRequest,
@@ -42,6 +43,12 @@ export const huyaApi = {
listAccountsPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_cookie?: boolean }) => listAccountsPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_cookie?: boolean }) =>
api.get<PaginatedResponse<HuyaAccountItem>, PaginatedResponse<HuyaAccountItem>>('/huya/accounts', { params }), api.get<PaginatedResponse<HuyaAccountItem>, PaginatedResponse<HuyaAccountItem>>('/huya/accounts', { params }),
accountsSummary: () => api.get<HuyaAccountSummary, HuyaAccountSummary>('/huya/accounts/summary'), accountsSummary: () => api.get<HuyaAccountSummary, HuyaAccountSummary>('/huya/accounts/summary'),
/** 设备绑定: 账号 ↔ 环境列表 (画像+指纹状态+账号表合并) */
listDeviceBindings: () =>
api.get<HuyaDeviceBindingListResult, HuyaDeviceBindingListResult>('/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 = '') => importCookies: (text: string, tag: string = '') =>
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }), api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
importPasswordAccounts: (text: string, tag: string = '') => importPasswordAccounts: (text: string, tag: string = '') =>
+23
View File
@@ -463,6 +463,29 @@ export interface HuyaAccountItem {
updated_at: string | null; 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 { export interface HuyaAccountSummary extends BasicSummary {
password_ready_count: number; password_ready_count: number;
point_count: number; point_count: number;
+1
View File
@@ -83,6 +83,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
// 虎牙 // 虎牙
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) { if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> }); huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> });
huyaItems.push({ key: '/huya/device-bindings', label: '设备绑定', icon: <ApiOutlined /> });
} }
if (canAny(['huya:account', 'huya:import'])) { if (canAny(['huya:account', 'huya:import'])) {
huyaItems.push({ key: '/huya/register', label: '自动注册', icon: <MobileOutlined /> }); huyaItems.push({ key: '/huya/register', label: '自动注册', icon: <MobileOutlined /> });
@@ -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<string, { label: string; color: string }> = {
app: { label: 'App 协议', color: 'processing' },
web: { label: 'Web 旧版', color: 'warning' },
sms: { label: '短信', color: 'cyan' },
};
export default function HuyaDeviceBindingsPage() {
const [items, setItems] = useState<HuyaDeviceBindingItem[]>([]);
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<HuyaDeviceBindingItem>['columns'] = [
{ title: '账号', dataIndex: 'account', width: 150, ellipsis: true,
render: (v: string) => <Text copyable={{ text: v }}>{v}</Text> },
{ title: '机型', width: 150,
render: (_: unknown, r: HuyaDeviceBindingItem) => (
<span>{r.model || '-'} <Text type="secondary" style={{ fontSize: 12 }}>({r.vendor})</Text></span>
) },
{ 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) => <Tag color="geekblue">{n} </Tag> },
{ title: 'hydevice 状态', dataIndex: 'has_hydevice_state', width: 110, align: 'center',
render: (ok: boolean) => ok
? <Tag color="success"></Tag>
: <Tooltip title="尚无高信任指纹状态, 下次 App 登录时生成"><Tag></Tag></Tooltip> },
{ title: '登录渠道', dataIndex: 'login_channel', width: 100, align: 'center',
render: (c: string) => {
const info = CHANNEL_LABELS[c];
return info ? <Tag color={info.color}>{info.label}</Tag> : <Tag></Tag>;
} },
{ title: '账号状态', dataIndex: 'account_status', width: 100, align: 'center',
render: (s: string) => s === '未入库' ? <Tag></Tag> : <span>{s}</span> },
{ title: '最后登录', dataIndex: 'last_login_at', width: 160,
render: (_: unknown, r: HuyaDeviceBindingItem) => {
if (!r.last_login_at) return <Text type="secondary">-</Text>;
const time = new Date(r.last_login_at * 1000).toLocaleString();
return <span>{time} {r.last_login_ok === false && <Tag color="error"></Tag>}</span>;
} },
{ title: '操作', width: 110, align: 'center',
render: (_: unknown, r: HuyaDeviceBindingItem) => (
<Popconfirm
title={`解绑 ${r.account}?`}
description="删除设备画像与指纹状态, 下次登录将生成全新设备环境并重新注册"
onConfirm={() => handleUnbind(r.account)}
>
<Button danger size="small" icon={<RestOutlined />} loading={unbinding === r.account}>
</Button>
</Popconfirm>
) },
];
const withState = items.filter((i) => i.has_hydevice_state).length;
const linked = items.filter((i) => i.account_status !== '未入库').length;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}></h2>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</div>
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}><Card size="small"><Statistic title="绑定环境总数" value={items.length} prefix={<LinkOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card size="small"><Statistic title="已生成指纹状态" value={withState} /></Card></Col>
<Col xs={24} sm={8}><Card size="small"><Statistic title="已入库账号" value={linked} /></Card></Col>
</Row>
<Card size="small" style={{ marginBottom: 16 }}>
<Text type="secondary">
一号一环境: 每个账号绑定一套独立设备画像 (//40/GUID32/Hebe) hydevice
; App safedeviceid/device_id
t1.t0 app (R15),
</Text>
</Card>
<Table<HuyaDeviceBindingItem>
rowKey="account"
columns={columns}
dataSource={items}
loading={loading}
pagination={{ pageSize: 20, showSizeChanger: false, showTotal: (t) => `${t}` }}
size="middle"
/>
</div>
);
}