- 手机号列:外部 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)判定与订单/网页一致
556 lines
18 KiB
Python
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("__")]
|