- 手机号列:外部 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)判定与订单/网页一致
548 lines
18 KiB
Python
548 lines
18 KiB
Python
"""虎牙基础业务服务。"""
|
|
|
|
import csv
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from urllib.parse import urlparse
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from core.huya.cookie_utils import cookie_value, normalize_huya_cookie
|
|
|
|
from ..huya_defaults import HUYA_CONFIG_DEFAULTS, HUYA_CONFIG_FIELDS
|
|
from ..models import HuyaAccount, HuyaConfig, HuyaTask
|
|
|
|
SUPPORTED_TASK_TYPES = {
|
|
"query_act_tasks": "查询精英宝典任务",
|
|
"get_bind_qr": "获取绑定二维码",
|
|
"query_points": "一键查询积分",
|
|
"query_game_name": "一键查询游戏名",
|
|
"query_all": "一键查询(角色+宝典+积分)",
|
|
"query_exchange_records": "一键查询兑换记录",
|
|
"confirm_bind": "确认绑定",
|
|
"refresh_goods": "刷新商品列表",
|
|
"refresh_recharge_goods": "刷新充值商品列表",
|
|
"exchange_goods": "兑换商品",
|
|
"create_recharge_order": "生成支付二维码",
|
|
}
|
|
|
|
HUYA_HANDBOOK_TASK_TYPES = {
|
|
"query_act_tasks",
|
|
"get_bind_qr",
|
|
"confirm_bind",
|
|
"query_points",
|
|
"query_game_name",
|
|
"query_all",
|
|
"refresh_goods",
|
|
"exchange_goods",
|
|
"query_exchange_records",
|
|
"refresh_recharge_goods",
|
|
"create_recharge_order",
|
|
}
|
|
|
|
|
|
def huya_config_value(field: str, value: str | None) -> str:
|
|
"""读取配置值;空值自动回退到当前活动默认配置。"""
|
|
text = str(value or "").strip()
|
|
return text or HUYA_CONFIG_DEFAULTS[field]
|
|
|
|
|
|
def apply_huya_config_defaults(config: HuyaConfig) -> bool:
|
|
"""补齐虎牙配置默认值,返回是否发生变更。"""
|
|
changed = False
|
|
for field in HUYA_CONFIG_FIELDS:
|
|
if (
|
|
field == "bind_act_id"
|
|
and str(getattr(config, field, "") or "").strip() == "17096"
|
|
):
|
|
setattr(config, field, HUYA_CONFIG_DEFAULTS[field])
|
|
changed = True
|
|
continue
|
|
if (
|
|
field == "outer_act_id"
|
|
and str(getattr(config, field, "") or "").strip() == "9504"
|
|
):
|
|
setattr(config, field, HUYA_CONFIG_DEFAULTS[field])
|
|
changed = True
|
|
continue
|
|
normalized = huya_config_value(field, getattr(config, field, None))
|
|
if getattr(config, field, None) != normalized:
|
|
setattr(config, field, normalized)
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def _looks_like_huya_cookie(value: str) -> bool:
|
|
"""判断文本是否像虎牙 Cookie。"""
|
|
return "udb_" in value or "yyuid=" in value
|
|
|
|
|
|
def parse_huya_cookie_line(line: str) -> dict | None:
|
|
"""解析单行虎牙 CK,兼容纯 CK、账号----密码----CK、CK----手机号。"""
|
|
raw = (line or "").strip()
|
|
if not raw:
|
|
return None
|
|
|
|
parts = [part.strip() for part in raw.split("----")]
|
|
username_hint = ""
|
|
game_phone = ""
|
|
|
|
if len(parts) == 1:
|
|
cookie = raw
|
|
elif _looks_like_huya_cookie(parts[0]):
|
|
cookie = parts[0]
|
|
game_phone = parts[1] if len(parts) >= 2 else ""
|
|
elif _looks_like_huya_cookie(parts[-1]):
|
|
cookie = parts[-1]
|
|
username_hint = parts[0]
|
|
else:
|
|
return None
|
|
|
|
if not _looks_like_huya_cookie(cookie):
|
|
return None
|
|
cookie = normalize_huya_cookie(cookie)
|
|
|
|
yyuid = cookie_value(cookie, "yyuid")
|
|
uid = cookie_value(cookie, "udb_uid") or yyuid
|
|
username = cookie_value(cookie, "udb_passport") or cookie_value(cookie, "username")
|
|
if not username and username_hint:
|
|
username = username_hint
|
|
|
|
if not uid and not yyuid:
|
|
return None
|
|
|
|
return {
|
|
"uid": uid,
|
|
"yyuid": yyuid or uid,
|
|
"username": username or uid or yyuid,
|
|
"cookie": cookie,
|
|
"game_phone": game_phone,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class HuyaPasswordLine:
|
|
"""虎牙账号密码导入行。"""
|
|
|
|
username: str
|
|
password: str
|
|
cookie: str = ""
|
|
phone: str = ""
|
|
sms_url: str = ""
|
|
|
|
|
|
def _looks_like_url(value: str) -> bool:
|
|
"""判断文本是否像 URL。"""
|
|
parsed = urlparse(str(value or "").strip())
|
|
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
|
|
|
|
def split_huya_password_line(
|
|
line: str, require_four_segments: bool = False
|
|
) -> HuyaPasswordLine | None:
|
|
"""拆分虎牙账号密码行,兼容账号----密码----手机号----验证码链接。
|
|
|
|
若 require_four_segments 为 True,则严格限制必须包含手机号和有效接码链接。
|
|
"""
|
|
raw = (line or "").strip()
|
|
if not raw or raw.startswith("#"):
|
|
return None
|
|
|
|
if "----" in raw:
|
|
parts = [part.strip() for part in raw.split("----")]
|
|
elif "|" in raw:
|
|
parts = [part.strip() for part in raw.split("|")]
|
|
elif "\t" in raw:
|
|
parts = [part.strip() for part in raw.split("\t")]
|
|
elif "," in raw:
|
|
parts = [part.strip() for part in next(csv.reader([raw]))]
|
|
else:
|
|
parts = raw.split()
|
|
|
|
if len(parts) < 2:
|
|
return None
|
|
|
|
username = parts[0].strip()
|
|
password = parts[1].strip()
|
|
cookie = ""
|
|
phone = ""
|
|
sms_url = ""
|
|
extra = [part.strip() for part in parts[2:] if part.strip()]
|
|
if len(extra) >= 2 and _looks_like_url(extra[1]):
|
|
phone = extra[0]
|
|
sms_url = extra[1]
|
|
cookie = "----".join(extra[2:])
|
|
elif extra and _looks_like_url(extra[-1]) and len(extra) >= 2:
|
|
phone = extra[-2]
|
|
sms_url = extra[-1]
|
|
cookie = "----".join(extra[:-2])
|
|
elif extra:
|
|
cookie = "----".join(extra)
|
|
if not username or not password:
|
|
return None
|
|
if require_four_segments and (
|
|
not phone or not sms_url or not _looks_like_url(sms_url)
|
|
):
|
|
return None
|
|
return HuyaPasswordLine(
|
|
username=username,
|
|
password=password,
|
|
cookie=cookie,
|
|
phone=phone,
|
|
sms_url=sms_url,
|
|
)
|
|
|
|
|
|
def import_huya_password_accounts(
|
|
db: Session,
|
|
text: str,
|
|
tag: str = "",
|
|
require_four_segments: bool = True,
|
|
) -> tuple[int, int]:
|
|
"""导入虎牙账号密码,返回 (导入/更新数, 跳过数)。
|
|
|
|
默认仅允许四段格式(账号----密码----手机号----接码链接)。
|
|
"""
|
|
created_or_updated = 0
|
|
skipped = 0
|
|
tag = (tag or "").strip()
|
|
|
|
for line in (text or "").splitlines():
|
|
parsed = split_huya_password_line(
|
|
line, require_four_segments=require_four_segments
|
|
)
|
|
if not parsed:
|
|
if line.strip():
|
|
skipped += 1
|
|
continue
|
|
|
|
username = parsed.username
|
|
password = parsed.password
|
|
cookie = parsed.cookie
|
|
account = db.query(HuyaAccount).filter(HuyaAccount.username == username).first()
|
|
if account is None:
|
|
account = HuyaAccount(
|
|
uid="",
|
|
yyuid="",
|
|
username=username,
|
|
account_password=password,
|
|
cookie=normalize_huya_cookie(cookie) if cookie else "",
|
|
game_phone=parsed.phone,
|
|
sms_url=parsed.sms_url,
|
|
tag=tag,
|
|
status="password_imported",
|
|
)
|
|
db.add(account)
|
|
else:
|
|
account.account_password = password
|
|
if cookie:
|
|
account.cookie = normalize_huya_cookie(cookie)
|
|
if parsed.phone:
|
|
account.game_phone = parsed.phone
|
|
if parsed.sms_url:
|
|
account.sms_url = parsed.sms_url
|
|
if tag:
|
|
account.tag = tag
|
|
if not account.cookie:
|
|
account.status = "password_imported"
|
|
account.updated_at = datetime.now(UTC)
|
|
created_or_updated += 1
|
|
|
|
if created_or_updated:
|
|
db.commit()
|
|
return created_or_updated, skipped
|
|
|
|
|
|
def _upsert_huya_account(
|
|
db: Session, parsed: dict, tag: str = "", status: str | None = None
|
|
) -> HuyaAccount:
|
|
"""按 uid/yyuid 新增或更新虎牙账号。"""
|
|
account = None
|
|
if parsed["uid"]:
|
|
account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first()
|
|
if account is None and parsed["yyuid"]:
|
|
account = (
|
|
db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first()
|
|
)
|
|
|
|
if account is None:
|
|
account = HuyaAccount(
|
|
uid=parsed["uid"],
|
|
yyuid=parsed["yyuid"],
|
|
username=parsed["username"],
|
|
cookie=parsed["cookie"],
|
|
game_phone=parsed["game_phone"],
|
|
tag=tag,
|
|
status=status or "imported",
|
|
)
|
|
db.add(account)
|
|
else:
|
|
account.uid = parsed["uid"] or account.uid
|
|
account.yyuid = parsed["yyuid"] or account.yyuid
|
|
# 已存在账号保留原有 username(登录名/设备画像 key),避免重复导入或登录
|
|
# 前后 udb_passport 差异导致一号一设备绑定断裂。
|
|
if not account.username:
|
|
account.username = parsed["username"] or ""
|
|
account.cookie = parsed["cookie"]
|
|
account.game_phone = parsed["game_phone"] or account.game_phone
|
|
if tag:
|
|
account.tag = tag
|
|
account.status = status or "updated"
|
|
account.updated_at = datetime.now(UTC)
|
|
return account
|
|
|
|
|
|
def save_huya_login_cookie_to_account(
|
|
db: Session,
|
|
account: HuyaAccount,
|
|
cookie: str,
|
|
tag: str = "",
|
|
username_hint: str = "",
|
|
) -> HuyaAccount:
|
|
"""把登录成功后的 Cookie 回填到指定虎牙账号。"""
|
|
line = f"{username_hint}----{cookie}" if username_hint else cookie
|
|
parsed = parse_huya_cookie_line(line)
|
|
if not parsed:
|
|
raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid")
|
|
|
|
account.uid = parsed["uid"] or account.uid
|
|
account.yyuid = parsed["yyuid"] or account.yyuid
|
|
# 保留已有 username(登录名): 设备画像/指纹状态目录按登录用户名分账号隔离,
|
|
# 若被 cookie 里的 udb_passport 覆盖, 下一次登录会拿到不同的 key → 生成全新设备,
|
|
# 一号一设备绑定断裂且绑定页出现两条记录。仅在无用户名时回填。
|
|
if not account.username:
|
|
account.username = parsed["username"] or ""
|
|
account.cookie = parsed["cookie"]
|
|
account.game_phone = parsed["game_phone"] or account.game_phone
|
|
if tag:
|
|
account.tag = tag
|
|
account.status = "login_success"
|
|
account.updated_at = datetime.now(UTC)
|
|
db.commit()
|
|
db.refresh(account)
|
|
return account
|
|
|
|
|
|
def upsert_huya_cookie(
|
|
db: Session,
|
|
cookie: str,
|
|
tag: str = "",
|
|
username_hint: str = "",
|
|
raw_cookie: str = "",
|
|
) -> HuyaAccount:
|
|
"""保存单条登录得到的虎牙 Cookie。
|
|
|
|
Args:
|
|
cookie: 网页运行 Cookie(可能含本地生成的网页设备字段)。
|
|
raw_cookie: App 原始登录凭据(不含网页设备字段),单独落库供重建/审计。
|
|
"""
|
|
line = f"{username_hint}----{cookie}" if username_hint else cookie
|
|
parsed = parse_huya_cookie_line(line)
|
|
if not parsed:
|
|
raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid")
|
|
account = _upsert_huya_account(
|
|
db, parsed, tag=(tag or "").strip(), status="login_success"
|
|
)
|
|
if raw_cookie:
|
|
account.raw_cookie = normalize_huya_cookie(raw_cookie)
|
|
db.commit()
|
|
db.refresh(account)
|
|
return account
|
|
|
|
|
|
def import_huya_cookies_detailed(
|
|
db: Session, text: str, tag: str = "", assign_to: int | None = None
|
|
) -> tuple[list[HuyaAccount], int]:
|
|
"""导入虎牙 Cookie,返回 (账号列表, 跳过数)。
|
|
|
|
与 ``import_huya_cookies`` 的差别:把每个账号对象交回调用方 —— 工作台
|
|
「导入外部 CK」需要拿到刚导入的账号加入自己的工作台集合;``assign_to``
|
|
用于给尚无归属的新账号打上导入者(否则客服看不到自己导入的账号)。
|
|
"""
|
|
accounts: list[HuyaAccount] = []
|
|
skipped = 0
|
|
tag = (tag or "").strip()
|
|
|
|
for line in (text or "").splitlines():
|
|
parsed = parse_huya_cookie_line(line)
|
|
if not parsed:
|
|
skipped += 1
|
|
continue
|
|
|
|
account = _upsert_huya_account(db, parsed, tag=tag)
|
|
if assign_to and account.assigned_to is None:
|
|
account.assigned_to = assign_to
|
|
accounts.append(account)
|
|
|
|
if accounts:
|
|
db.commit()
|
|
for account in accounts:
|
|
db.refresh(account)
|
|
return accounts, skipped
|
|
|
|
|
|
def import_huya_cookies(db: Session, text: str, tag: str = "") -> tuple[int, int]:
|
|
"""导入虎牙 Cookie,返回 (成功数, 跳过数)。"""
|
|
accounts, skipped = import_huya_cookies_detailed(db, text, tag)
|
|
return len(accounts), skipped
|
|
|
|
|
|
def persist_huya_session_cookie(
|
|
db: Session,
|
|
account_id: int,
|
|
original_cookie: str,
|
|
filled_cookie: str,
|
|
) -> bool:
|
|
"""把活动会话补齐后的网页 Cookie 回写到账号。
|
|
|
|
原始登录态只有 App 字段时,wsLaunch 会补齐 guid 等网页设备字段;
|
|
不回写则下次任务又会重新生成设备态。
|
|
"""
|
|
filled = normalize_huya_cookie(filled_cookie or "")
|
|
original = normalize_huya_cookie(original_cookie or "")
|
|
if not filled or filled == original:
|
|
return False
|
|
account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first()
|
|
if account is None:
|
|
return False
|
|
if normalize_huya_cookie(account.cookie or "") == filled:
|
|
return False
|
|
account.cookie = filled
|
|
account.updated_at = datetime.now(UTC)
|
|
db.commit()
|
|
return True
|
|
|
|
|
|
def rebuild_web_cookie_from_raw(db: Session, account: HuyaAccount) -> str:
|
|
"""用 App 原始凭据重建网页运行 Cookie,不发起登录或支付。"""
|
|
raw = normalize_huya_cookie(account.raw_cookie or "")
|
|
if not raw:
|
|
raise ValueError("该账号没有 App 原始凭据,无法重建网页 Cookie")
|
|
|
|
from core.huya.cookie_utils import cookie_pairs
|
|
from core.huya.device_profile import _load_db, canonical_account_key
|
|
from core.huya.web_cookie_fields import (
|
|
fill_web_cookie_fields,
|
|
qimei_feature_from_device_profile,
|
|
)
|
|
|
|
uid = 0
|
|
for candidate in (account.uid, account.yyuid, cookie_value(raw, "udb_uid")):
|
|
text = str(candidate or "").strip()
|
|
if text.isdigit():
|
|
uid = int(text)
|
|
break
|
|
|
|
feature = None
|
|
username = (account.username or "").strip()
|
|
if username:
|
|
profiles = _load_db()
|
|
profile = profiles.get(canonical_account_key(username)) or profiles.get(
|
|
username
|
|
)
|
|
if profile:
|
|
feature = qimei_feature_from_device_profile(profile)
|
|
|
|
filled = fill_web_cookie_fields(raw, guid="", uid=uid, qimei_feature=feature)
|
|
pairs = dict(cookie_pairs(filled))
|
|
pairs["udb_appid"] = "5002"
|
|
rebuilt = "; ".join(f"{key}={value}" for key, value in pairs.items())
|
|
account.cookie = rebuilt
|
|
account.status = "login_success"
|
|
account.updated_at = datetime.now(UTC)
|
|
db.commit()
|
|
db.refresh(account)
|
|
return account.cookie
|
|
|
|
|
|
def ensure_huya_config(db: Session) -> HuyaConfig:
|
|
"""获取单条虎牙配置,不存在则创建。"""
|
|
config = db.query(HuyaConfig).first()
|
|
if config:
|
|
if apply_huya_config_defaults(config):
|
|
db.commit()
|
|
db.refresh(config)
|
|
return config
|
|
config = HuyaConfig(**HUYA_CONFIG_DEFAULTS)
|
|
db.add(config)
|
|
db.commit()
|
|
db.refresh(config)
|
|
return config
|
|
|
|
|
|
HUYA_ACTIVE_TASK_STATUSES = ("planned", "pending", "running")
|
|
HUYA_STALE_TASK_STATUSES = ("pending", "running")
|
|
|
|
|
|
def cleanup_orphan_huya_tasks(
|
|
db: Session,
|
|
*,
|
|
active_batch_ids: set[str] | None = None,
|
|
batch_id: str | None = None,
|
|
statuses: tuple[str, ...] = HUYA_ACTIVE_TASK_STATUSES,
|
|
message: str = "任务已中断(服务重启或批次丢失)",
|
|
) -> int:
|
|
"""清理没有执行器接管的虎牙任务,避免前端被历史 running 状态锁死。"""
|
|
query = db.query(HuyaTask).filter(HuyaTask.status.in_(statuses))
|
|
if batch_id:
|
|
query = query.filter(HuyaTask.batch_id == batch_id)
|
|
elif active_batch_ids is not None and active_batch_ids:
|
|
query = query.filter(~HuyaTask.batch_id.in_(list(active_batch_ids)))
|
|
# active_batch_ids is None 且未指定 batch_id:清理全部匹配状态
|
|
|
|
tasks = query.all()
|
|
if not tasks:
|
|
return 0
|
|
|
|
now = datetime.now(UTC)
|
|
for task in tasks:
|
|
task.status = "stopped"
|
|
task.message = message
|
|
task.finished_at = now
|
|
db.commit()
|
|
return len(tasks)
|
|
|
|
|
|
def create_planned_tasks(
|
|
db: Session,
|
|
account_ids: list[int],
|
|
task_type: str,
|
|
created_by: int,
|
|
payload: dict | None = None,
|
|
handbook_scope: str = "legacy",
|
|
) -> tuple[str, int]:
|
|
"""创建虎牙任务记录,等待后台执行器消费。"""
|
|
if task_type not in SUPPORTED_TASK_TYPES:
|
|
raise ValueError("不支持的任务类型")
|
|
if handbook_scope not in {"legacy", "elite"}:
|
|
raise ValueError("无效的工作台")
|
|
if handbook_scope == "elite" and task_type not in HUYA_HANDBOOK_TASK_TYPES:
|
|
raise ValueError("该任务不属于精英宝典工作台")
|
|
|
|
batch_id = uuid.uuid4().hex[:12]
|
|
payload = payload or {}
|
|
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
|
|
if (
|
|
task_type
|
|
in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"}
|
|
and accounts
|
|
):
|
|
# 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。
|
|
accounts = accounts[:1]
|
|
for account in accounts:
|
|
db.add(
|
|
HuyaTask(
|
|
batch_id=batch_id,
|
|
account_id=account.id,
|
|
task_type=task_type,
|
|
handbook_scope=handbook_scope,
|
|
status="planned",
|
|
message="任务已创建,等待执行",
|
|
result={"payload": payload} if payload else None,
|
|
created_by=created_by,
|
|
)
|
|
)
|
|
db.commit()
|
|
return batch_id, len(accounts)
|