Files
live-hub-py/web/backend/routers/huya/_common.py
T
yml2213 73536d62c8 feat(huya): 工作台补手机号/精英宝典列与一键查询
- 手机号列:外部 CK 的 `----手机号` 本来就落在 huya_accounts.game_phone,
  列表一直没展示;现在单独成列,并纳入搜索
- 精英宝典列:显示「已开通/未开通/未查询」+ 开通时间 + 有效期
  · 开通状态 = 积分中心 getRedeemNeedGoods 的 tag5(真机对照网页:1=已开通,
    0=未开通,此时 tag7 会列出开通套餐),拿不准时为 NULL(显示「未查询」)
  · 开通时间 = revenueWebUI.queryUserOrderList 里已支付的宝典订单 pay_time
    (按 SPU 白名单 + 订单名含「精英宝典」识别,未支付订单不计,取最近一次)
  · 有效期 = PlayMallUI.queryAllocation 页面配置的本赛季节点 tag10/tag11
    (2026-10-15 抓包:精英宝典福利季丨Q4,2026-10-09 ~ 2026-12-31 23:59:59),
    不写死索引,递归找带 tag10/tag11 的节点
- 新任务 query_all「一键查询」:勾选账号一次跑完角色绑定 + 宝典开通 + 积分,
  按部分失败如实汇总(某个接口挂了不影响其它结论),并把宝典状态写回账号
- 迁移 20261015_0053:huya_accounts 新增 elite_opened / elite_open_time /
  elite_expire_time(时间存 Unix 秒,与斗鱼 elite_expire_time 同约定)
- 开通商品按页面「赠送指定礼物得积分」那一排对齐成 6 款(去掉页面已不用的
  旧版精英宝典 hy-5879340),列表接口只返回账号级快照并按 spu 去重
- 建 WSS 会话失败(积分中心端点静默不回包)时回退 HTTP cdnws,
  不再让整批任务判失败
- 列表列宽改按比例分配,避免宽屏下被拉出奇怪的宽度;最近操作不再重复状态
- 新增 tests/test_huya_elite_query.py(10 条);同步迁移头版本断言

真机验证:#65 已开通(10-13 20:41:45,有效期 12-31)、#70 新开通
(10-15 16:37:37)判定与订单/网页一致
2026-10-15 16:53:30 +08:00

556 lines
18 KiB
Python

"""虎牙路由共享:权限、查询、序列化。"""
import asyncio
import csv
import io
import shutil
import threading
from datetime import UTC, datetime
from typing import cast
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import StreamingResponse
from loguru import logger
from sqlalchemy import func, or_
from sqlalchemy.orm import Session, defer, joinedload
from core.huya import (
HuyaAppLoginError,
HuyaAppQrAuthRequiredError,
HuyaCredentialError,
HuyaLoginError,
login_huya_app_password,
login_huya_password,
login_huya_sms,
send_huya_sms_code,
)
from core.huya.cookie_utils import (
browser_login_script,
cookie_pairs,
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,
)
from core.huya.device_profile import (
_save_db as _save_profile_db,
)
from core.sms_provider import parse_sms_lines
from ...database import SessionLocal, get_db
from ...deps import authenticate_websocket, get_current_user, require_permission
from ...models import (
HuyaAccount,
HuyaConfig,
HuyaGoodsSnapshot,
HuyaRechargeGoodsSnapshot,
HuyaRegisterItem,
HuyaRegisterSuccessLog,
HuyaTask,
HuyaWorkbench,
HuyaWorkbenchAccount,
ProxyConfig,
User,
)
from ...permissions import user_has_permission
from ...schemas import (
AccountAssign,
AccountBulkSelection,
AccountBulkTag,
AccountTag,
BatchAssign,
HuyaAccountOut,
HuyaAppPasswordLoginRequest,
HuyaAutoRegisterBatchOut,
HuyaAutoRegisterRequest,
HuyaAutoRegisterRetryRequest,
HuyaBrowserExchangeRequest,
HuyaConfigOut,
HuyaConfigUpdate,
HuyaCookieImport,
HuyaDeviceBindingsBatchDeleteRequest,
HuyaGoodsOut,
HuyaPasswordAccountImport,
HuyaPasswordLoginRequest,
HuyaPasswordLoginSelectedRequest,
HuyaRechargeGoodsOut,
HuyaRegisterSuccessLogOut,
HuyaSmsCodeRequest,
HuyaSmsLoginRequest,
HuyaTaskBatchRequest,
HuyaTaskOut,
HuyaTestSmsUrlRequest,
HuyaWorkbenchAccountsUpdate,
HuyaWorkbenchCookieImport,
)
from ...services.audit_service import record_audit
from ...services.huya_exchange_targets import (
DEFAULT_EXCHANGE_TARGET,
exchange_target_options,
exchange_target_url,
)
from ...services.huya_register_runner import (
export_success_logs_text,
huya_register_registry,
list_success_logs,
)
from ...services.huya_runner import HuyaBatchRunner, huya_batch_registry
from ...services.huya_service import (
HUYA_CONFIG_FIELDS,
SUPPORTED_TASK_TYPES,
apply_huya_config_defaults,
cleanup_orphan_huya_tasks,
create_planned_tasks,
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,
upsert_huya_cookie,
)
def _has_huya_perm(user: User, permission: str) -> bool:
"""虎牙新权限兼容旧的 huya:account 大权限。"""
return user_has_permission(user, permission) or user_has_permission(
user, "huya:account"
)
def _require_huya_perm(user: User, permission: str) -> None:
if not _has_huya_perm(user, permission):
raise HTTPException(status_code=403, detail="权限不足")
def _require_huya_tag_permission(user: User) -> None:
"""标签权限兼容历史上使用导入权限的运营人员。"""
if not (_has_huya_perm(user, "huya:tag") or _has_huya_perm(user, "huya:import")):
raise HTTPException(status_code=403, detail="无权修改虎牙账号标签")
def _can_view_huya_all(user: User) -> bool:
return _has_huya_perm(user, "huya:view_all")
def _can_view_huya_assigned(user: User) -> bool:
return _has_huya_perm(user, "huya:view_assigned")
def _can_view_huya_cookie(user: User) -> bool:
return _has_huya_perm(user, "huya:cookie:view") or _has_huya_perm(
user, "huya:cookie:export"
)
def _visible_huya_accounts_query(db: Session, current: User):
"""返回当前用户可见的虎牙账号查询。"""
query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user))
if _can_view_huya_all(current):
return query
if _can_view_huya_assigned(current):
return query.filter(HuyaAccount.assigned_to == current.id)
raise HTTPException(status_code=403, detail="无权查看虎牙账号")
def _filter_huya_accounts_query(
query,
current: User,
*,
assigned_only: bool = False,
tag: str | None = None,
has_cookie: bool = False,
search: str = "",
):
"""复用虎牙账号列表筛选条件,供分页和批量操作保持一致。"""
if assigned_only and _can_view_huya_all(current):
query = query.filter(HuyaAccount.assigned_to.isnot(None))
if tag:
query = query.filter(HuyaAccount.tag == tag)
if has_cookie:
query = query.filter(HuyaAccount.cookie != "")
search_text = (search or "").strip()
if search_text:
pattern = f"%{search_text}%"
query = query.filter(
or_(
HuyaAccount.uid.ilike(pattern),
HuyaAccount.yyuid.ilike(pattern),
HuyaAccount.username.ilike(pattern),
HuyaAccount.nickname.ilike(pattern),
HuyaAccount.tag.ilike(pattern),
HuyaAccount.game_name.ilike(pattern),
HuyaAccount.game_phone.ilike(pattern),
)
)
return query
def _selected_huya_account_ids(
db: Session, current: User, req: AccountBulkSelection
) -> list[int]:
"""解析虎牙批量操作目标:当前筛选全部或显式选择的 ID。"""
base_query = _visible_huya_accounts_query(db, current)
if req.all_matching:
rows = (
_filter_huya_accounts_query(
base_query,
current,
assigned_only=req.assigned_only,
tag=req.tag,
has_cookie=req.has_cookie,
search=req.search,
)
.with_entities(HuyaAccount.id)
.order_by(None)
.all()
)
return [account_id for (account_id,) in rows]
seen = set()
requested_ids = []
for account_id in req.account_ids:
if account_id not in seen:
seen.add(account_id)
requested_ids.append(account_id)
if not requested_ids:
return []
rows = (
base_query.filter(HuyaAccount.id.in_(requested_ids))
.with_entities(HuyaAccount.id)
.order_by(None)
.all()
)
allowed = {account_id for (account_id,) in rows}
return [account_id for account_id in requested_ids if account_id in allowed]
def _clear_huya_account_references(db: Session, account_ids: list[int]) -> None:
"""删除账号前保留注册历史,把历史流水中的账号引用置空。"""
if not account_ids:
return
db.query(HuyaRegisterItem).filter(
HuyaRegisterItem.account_id.in_(account_ids)
).update(
{HuyaRegisterItem.account_id: None},
synchronize_session=False,
)
db.query(HuyaRegisterSuccessLog).filter(
HuyaRegisterSuccessLog.account_id.in_(account_ids)
).update(
{HuyaRegisterSuccessLog.account_id: None},
synchronize_session=False,
)
def _visible_huya_tasks_query(db: Session, current: User):
"""返回当前用户可查看的虎牙任务查询。"""
query = db.query(HuyaTask).options(joinedload(HuyaTask.account))
if _can_view_huya_all(current):
return query
if _can_view_huya_assigned(current):
return query.join(HuyaTask.account).filter(
HuyaAccount.assigned_to == current.id
)
raise HTTPException(status_code=403, detail="无权查看虎牙任务")
def _huya_task_summary(query):
"""按状态汇总虎牙任务,避免概览页拉完整任务列表。"""
rows = (
query.enable_eagerloads(False)
.order_by(None)
.with_entities(HuyaTask.status, func.count(HuyaTask.id))
.group_by(HuyaTask.status)
.all()
)
status_counts = {status or "": count for status, count in rows}
return {
"total": sum(status_counts.values()),
"success": status_counts.get("success", 0),
"failed": sum(status_counts.get(status, 0) for status in ("failed", "error")),
"status_counts": status_counts,
}
def _require_huya_task_account_access(
db: Session, current: User, account_ids: list[int]
) -> None:
"""确保任务只会提交到当前用户可操作的虎牙账号。"""
requested_ids = set(account_ids)
query = db.query(HuyaAccount.id).filter(HuyaAccount.id.in_(requested_ids))
if not _can_view_huya_all(current):
if not _can_view_huya_assigned(current):
raise HTTPException(status_code=403, detail="无权操作虎牙账号")
query = query.filter(HuyaAccount.assigned_to == current.id)
allowed_ids = {account_id for (account_id,) in query.all()}
if allowed_ids != requested_ids:
raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号")
def _workbench_account_ids(db: Session, current: User, scope: str) -> list[int]:
query = db.query(HuyaWorkbenchAccount.account_id).filter(
HuyaWorkbenchAccount.user_id == current.id,
HuyaWorkbenchAccount.handbook_scope == scope,
)
if not _can_view_huya_all(current):
query = query.join(
HuyaAccount, HuyaAccount.id == HuyaWorkbenchAccount.account_id
).filter(HuyaAccount.assigned_to == current.id)
return [
account_id
for (account_id,) in query.order_by(HuyaWorkbenchAccount.id.asc()).all()
]
def _require_huya_batch_owner(db: Session, current: User, batch_id: str) -> None:
"""客服只能停止或订阅自己创建的任务批次。"""
if _can_view_huya_all(current):
return
exists = (
db.query(HuyaTask.id)
.filter(HuyaTask.batch_id == batch_id, HuyaTask.created_by == current.id)
.first()
)
if not exists:
raise HTTPException(status_code=403, detail="无权操作该虎牙任务批次")
def _fmt_cookie_preview(cookie: str) -> str:
if not cookie:
return ""
return cookie[:50] + "..." if len(cookie) > 50 else cookie
def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccountOut:
cookie = normalize_huya_cookie(account.cookie or "")
return HuyaAccountOut(
id=account.id,
uid=account.uid or "",
yyuid=account.yyuid or "",
username=account.username or "",
has_password=bool(account.account_password),
has_cookie=bool(account.cookie),
nickname=account.nickname or "",
cookie=cookie if include_cookie else "",
cookie_preview=_fmt_cookie_preview(cookie) if include_cookie else "***",
tag=account.tag or "",
remark=account.remark or "",
status=account.status or "",
login_channel=getattr(account, "login_channel", "") or "",
points=account.points,
game_name=account.game_name or "",
game_channel=account.game_channel or "",
game_phone=account.game_phone or "",
elite_opened=account.elite_opened,
elite_open_time=account.elite_open_time,
elite_expire_time=account.elite_expire_time,
sms_url=account.sms_url or "",
assigned_to=account.assigned_to,
assigned_username=account.assigned_user.username
if account.assigned_user
else None,
created_at=account.created_at,
updated_at=account.updated_at,
)
def _account_out_light(
account: HuyaAccount, *, has_password: bool = False, has_cookie: bool = False
) -> HuyaAccountOut:
"""虎牙账号列表轻量输出,不读取加密字段。"""
return HuyaAccountOut(
id=account.id,
uid=account.uid or "",
yyuid=account.yyuid or "",
username=account.username or "",
has_password=has_password,
has_cookie=has_cookie,
nickname=account.nickname or "",
cookie="",
cookie_preview="***",
tag=account.tag or "",
remark=account.remark or "",
status=account.status or "",
login_channel=getattr(account, "login_channel", "") or "",
points=account.points,
game_name=account.game_name or "",
game_channel=account.game_channel or "",
game_phone=account.game_phone or "",
elite_opened=account.elite_opened,
elite_open_time=account.elite_open_time,
elite_expire_time=account.elite_expire_time,
sms_url=account.sms_url or "",
assigned_to=account.assigned_to,
assigned_username=account.assigned_user.username
if account.assigned_user
else None,
created_at=account.created_at,
updated_at=account.updated_at,
)
def _sanitize_task_result(
result: dict | None, *, include_images: bool = False
) -> dict | None:
"""列表接口默认剥离 base64 图片,避免轮询每次传 1MB+ 数据。"""
if not isinstance(result, dict):
return result
data = dict(result)
image = data.get("mini_qrcode_image")
if isinstance(image, str) and image:
data["has_mini_qrcode"] = True
if not include_images:
data.pop("mini_qrcode_image", None)
elif data.get("has_mini_qrcode"):
data["has_mini_qrcode"] = True
return data
def _task_out(task: HuyaTask, *, include_images: bool = False) -> HuyaTaskOut:
account = task.account
return HuyaTaskOut(
id=task.id,
batch_id=task.batch_id,
account_id=task.account_id,
account_uid=account.uid if account else "",
account_nickname=account.nickname if account else "",
task_type=task.task_type,
handbook_scope=getattr(task, "handbook_scope", "legacy") or "legacy",
status=task.status or "",
message=task.message or "",
result=_sanitize_task_result(
task.result if isinstance(task.result, dict) else None,
include_images=include_images,
),
created_by=task.created_by,
created_at=task.created_at,
finished_at=task.finished_at,
)
def _config_out(config: HuyaConfig) -> HuyaConfigOut:
return HuyaConfigOut(
room_pid=huya_config_value("room_pid", config.room_pid),
sid=huya_config_value("sid", config.sid),
outer_act_id=huya_config_value("outer_act_id", config.outer_act_id),
bind_act_id=huya_config_value("bind_act_id", config.bind_act_id),
pay_channel=huya_config_value("pay_channel", config.pay_channel),
updated_at=config.updated_at,
)
def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict:
cookie = normalize_huya_cookie(account.cookie or "")
account_name = (
account.nickname or account.username or account.uid or str(account.id)
)
return {
"id": account.id,
"account_id": account.id,
"account_username": account_name,
"uid": account.uid or "",
"yyuid": account.yyuid or "",
"assigned_to": account.assigned_to,
"assigned_username": account.assigned_user.username
if account.assigned_user
else None,
"created_at": account.updated_at.isoformat() if account.updated_at else None,
"cookie": cookie if include_cookie else "",
"cookie_preview": _fmt_cookie_preview(cookie) if include_cookie else "***",
"has_raw_cookie": bool(account.raw_cookie),
}
def _cookie_out_light(account: HuyaAccount) -> dict:
"""虎牙 Cookie 列表轻量输出,不读取完整加密 Cookie。"""
account_name = (
account.nickname or account.username or account.uid or str(account.id)
)
return {
"id": account.id,
"account_id": account.id,
"account_username": account_name,
"uid": account.uid or "",
"yyuid": account.yyuid or "",
"assigned_to": account.assigned_to,
"assigned_username": account.assigned_user.username
if account.assigned_user
else None,
"created_at": account.updated_at.isoformat() if account.updated_at else None,
"cookie": "",
"cookie_preview": "***",
"has_raw_cookie": bool(account.raw_cookie),
}
def _huya_password_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]:
"""不解密密码字段,仅判断密文字段是否非空。"""
if not account_ids:
return {}
rows = (
db.query(
HuyaAccount.id, (HuyaAccount.account_password != "").label("has_password")
)
.filter(HuyaAccount.id.in_(account_ids))
.all()
)
return {account_id: bool(has_password) for account_id, has_password in rows}
def _huya_cookie_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]:
if not account_ids:
return {}
rows = (
db.query(HuyaAccount.id, (HuyaAccount.cookie != "").label("has_cookie"))
.filter(HuyaAccount.id.in_(account_ids))
.all()
)
return {account_id: bool(has_cookie) for account_id, has_cookie in rows}
_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
def _unbind_device_accounts(accounts: list[str]) -> tuple[list[str], list[str]]:
"""批量解绑实现: 返回 (成功解绑账号, 无绑定记录的账号)。"""
profiles = _load_profile_db()
removed: list[str] = []
missing: list[str] = []
seen = set()
for account in accounts:
account = (account or "").strip()
if not account or account in seen:
continue
seen.add(account)
if account not in profiles:
missing.append(account)
continue
profiles.pop(account)
state_dir = _fp_state_dir(account)
if state_dir.exists():
shutil.rmtree(state_dir, ignore_errors=True)
removed.append(account)
if removed:
_save_profile_db(profiles)
return removed, missing
__all__ = [name for name in globals() if not name.startswith("__")]