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:
@@ -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 协议登录将自动生成全新设备环境并重新注册"}
|
||||||
|
|||||||
@@ -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 />)} />
|
||||||
|
|||||||
@@ -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 = '') =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user