Files
live-hub-py/web/backend/services/huya_points_center.py
T

333 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙积分中心唯一目录读取实现。
2026-11-13 两份 HAR(``11.13-兑换记录.har``、
``11.13-虎牙充值2元-兑换幸运币.har``)确认:
* ``PlayMallUI.queryAllocation`` 是积分中心商品、页签、活动 SID 和赛季配置的
唯一权威来源;
* 基础响应只返回第一个页签,官方页面随后按 ``pageId + waterfallTabId``
补拉其它页签;
* ``webActUI.getActPrizeList`` 不在最新兑换链路中,旧实现已删除。
商品刷新、实时下拉、兑换前校验、外部单积分解析、CK 预览、兑换记录 SID 和
宝典赛季都只能调用本模块,避免各入口重复实现后再次出现旧 prize_id。
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
from sqlalchemy.orm import Session
from core.huya.shop_structs import (
extract_points_center_sid,
extract_points_center_tabs,
parse_points_center_gifts,
)
from ..huya_defaults import (
HUYA_DEFAULT_POINTS_PAGE_CODE,
HUYA_DEFAULT_POINTS_SCENE,
HUYA_DEFAULT_POINTS_SOURCE_ID,
)
from ..models import HuyaConfig
from .huya_service import huya_config_value
GIFTS_LIMIT = 200
POINTS_SEASON_MS_FLOOR = 1_000_000_000_000
def _clip(value: Any, limit: int = 200) -> str:
text = str(value if value is not None else "").strip()
return text if len(text) <= limit else text[: limit - 3] + "..."
def _walk_season_nodes(node: Any, found: list[dict]) -> None:
if isinstance(node, dict):
config = node.get("tag0")
if isinstance(config, dict):
try:
start_ms = int(config.get("tag10") or 0)
end_ms = int(config.get("tag11") or 0)
config_id = int(config.get("tag0") or 0)
except (TypeError, ValueError):
start_ms = end_ms = config_id = 0
if start_ms >= POINTS_SEASON_MS_FLOOR and end_ms > start_ms:
found.append(
{
"name": _clip(config.get("tag2"), 128),
"start_ms": start_ms,
"end_ms": end_ms,
"config_id": config_id,
}
)
for value in node.values():
_walk_season_nodes(value, found)
elif isinstance(node, list):
for value in node:
_walk_season_nodes(value, found)
def extract_points_center_season(payload: Any) -> dict | None:
"""取积分中心当前宝典赛季;认不出结构时返回 ``None``。"""
found: list[dict] = []
_walk_season_nodes(payload, found)
return max(found, key=lambda item: item["end_ms"]) if found else None
def resolve_points_center_config(db: Session) -> dict[str, Any]:
"""读取积分中心请求参数;空配置自动回落到当前页面默认值。"""
row = db.query(HuyaConfig).first()
def value(field: str, default: str) -> str:
return (
huya_config_value(
field, getattr(row, field, None) if row is not None else None
)
or default
)
try:
scene = int(value("points_scene", HUYA_DEFAULT_POINTS_SCENE))
except (TypeError, ValueError):
scene = int(HUYA_DEFAULT_POINTS_SCENE)
page_id_text = value("points_page_id", "")
try:
page_id = int(page_id_text) if page_id_text else None
except ValueError:
page_id = None
tab_ids: list[int] = []
for part in re.split(r"[,,\s]+", value("points_tab_ids", "")):
if part.isdigit():
tab_id = int(part)
if tab_id not in tab_ids:
tab_ids.append(tab_id)
return points_center_options_from_config(
{
"points_page_code": value(
"points_page_code", HUYA_DEFAULT_POINTS_PAGE_CODE
),
"points_source_id": value(
"points_source_id", HUYA_DEFAULT_POINTS_SOURCE_ID
),
"points_scene": scene,
"points_page_id": page_id or "",
"points_tab_ids": ",".join(str(item) for item in tab_ids),
}
)
def points_center_options_from_config(
config_info: Mapping[str, Any],
) -> dict[str, Any]:
"""把配置字段规范的公共入口,任务和路由不再各自解析。"""
tab_ids = [
int(part)
for part in str(config_info.get("points_tab_ids") or "")
.replace(",", ",")
.replace(" ", ",")
.split(",")
if part.isdigit()
]
return {
"page_code": (
str(config_info.get("points_page_code") or "").strip()
or HUYA_DEFAULT_POINTS_PAGE_CODE
),
"source_id": (
str(config_info.get("points_source_id") or "").strip()
or HUYA_DEFAULT_POINTS_SOURCE_ID
),
"scene": int(config_info.get("points_scene") or 0)
or int(HUYA_DEFAULT_POINTS_SCENE),
"page_id": int(config_info.get("points_page_id") or 0) or None,
"tab_ids": tab_ids or None,
}
@dataclass
class PointsCenterCatalog:
goods: list[dict] = field(default_factory=list)
tabs: list[dict] = field(default_factory=list)
sid: int | None = None
season: dict | None = None
payload: dict | None = None
message: str = ""
def fetch_points_center_catalog(
uid: int,
cookie: str,
*,
client: Any | None = None,
page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE,
source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID,
scene: int = int(HUYA_DEFAULT_POINTS_SCENE),
page_id: int | None = None,
tab_ids: list[int] | None = None,
include_all_tabs: bool = True,
) -> PointsCenterCatalog:
"""一次读取积分中心的商品、页签、SID 和赛季配置。
``include_all_tabs=False`` 仅供只取 SID/赛季的调用方使用,避免为了元数据
额外请求所有商品页签。商品列表调用必须保持默认 ``True``。
"""
if client is None:
from core.huya.http_client import HuyaHttpClient
client = HuyaHttpClient(
logger=lambda message: logger.debug(f"[huya-points-center] {message}")
)
def call(
tab_id: int | None = None, tab_page_id: int | None = None
) -> tuple[dict | None, str]:
kwargs: dict[str, Any] = {
"uid": int(uid),
"guid": "",
"cookie": cookie,
"page_code": page_code,
"source_id": source_id,
"scene": scene,
"deep": True,
}
if tab_id is not None:
kwargs["page_id"] = int(page_id or tab_page_id or 0)
kwargs["waterfall_tab_id"] = int(tab_id)
try:
response = client.play_mall_query_allocation(**kwargs)
except Exception as exc: # noqa: BLE001 外部接口异常收敛为目录消息
logger.debug(
f"[huya-points-center] queryAllocation 异常:"
f"{type(exc).__name__}: {exc}"
)
return None, f"{type(exc).__name__}: {_clip(exc)}"
if response is None:
return None, "积分中心接口没有返回数据"
code = getattr(response, "code", None)
if code is None:
code = getattr(response, "status", 0)
code = int(code or 0)
if code != 200:
message = _clip(
getattr(response, "message", "") or getattr(response, "msg", "")
)
return None, f"code={code}{(':' + message) if message else ''}"
payload = getattr(response, "payload", None)
if payload is None and hasattr(response, "to_dict"):
try:
payload = (response.to_dict() or {}).get("payload")
except Exception: # noqa: BLE001 第三方响应结构异常时按空处理
payload = None
return payload if isinstance(payload, dict) else None, ""
base_payload, error = call()
if error:
return PointsCenterCatalog(message=f"拉取商品失败:{error}")
if base_payload is None:
return PointsCenterCatalog(message="积分中心接口没有返回商品数据")
goods = parse_points_center_gifts(base_payload)
tabs = extract_points_center_tabs(base_payload)
sid = extract_points_center_sid(base_payload)
season = extract_points_center_season(base_payload)
if not include_all_tabs or (not goods and not tabs):
message = (
f"已拉取 {len(goods)} 个礼包(积分中心)"
if goods
else "积分中心没返回商品:接口通了但列表是空的"
)
return PointsCenterCatalog(
goods=goods[:GIFTS_LIMIT],
tabs=tabs,
sid=sid,
season=season,
payload=base_payload,
message=message,
)
discovered = {(int(tab["page_id"]), int(tab["tab_id"])): tab for tab in tabs}
loaded_categories = {str(gift.get("category_name") or "").strip() for gift in goods}
tab_errors: list[str] = []
if tab_ids:
targets: list[dict] = []
for tab_id in tab_ids:
matched = next(
(
tab
for (tab_page_id, tab_id_value), tab in discovered.items()
if tab_id_value == int(tab_id)
and (page_id is None or tab_page_id == int(page_id))
),
None,
)
if matched is not None:
targets.append(matched)
elif page_id is not None:
targets.append(
{
"page_id": int(page_id),
"tab_id": int(tab_id),
"name": f"页签 {tab_id}",
}
)
else:
tab_errors.append(f"页签 {tab_id}:响应里没发现,且未配置 pageId")
else:
targets = [
tab
for tab in tabs
if str(tab.get("name") or "").strip() not in loaded_categories
]
seen_products = {
str(gift.get("product_id") or "") for gift in goods if gift.get("product_id")
}
loaded_tabs = len(loaded_categories) if goods else 0
for tab in targets:
target_page_id = int(page_id or tab["page_id"])
tab_payload, tab_error = call(int(tab["tab_id"]), target_page_id)
if tab_error:
tab_errors.append(f"{tab.get('name') or tab['tab_id']}:{tab_error}")
continue
tab_goods = parse_points_center_gifts(tab_payload)
added = 0
for gift in tab_goods:
product_id = str(gift.get("product_id") or "")
if product_id and product_id not in seen_products:
seen_products.add(product_id)
goods.append(gift)
added += 1
if added or tab_goods:
loaded_tabs += 1
goods = goods[:GIFTS_LIMIT]
if not goods:
return PointsCenterCatalog(
tabs=tabs,
sid=sid,
season=season,
payload=base_payload,
message="积分中心没返回商品:接口通了但列表是空的",
)
suffix = f",{loaded_tabs} 个页签" if loaded_tabs > 1 else ""
if tab_errors:
suffix += f";部分页签未加载:{';'.join(tab_errors[:3])}"
return PointsCenterCatalog(
goods=goods,
tabs=tabs,
sid=sid,
season=season,
payload=base_payload,
message=f"已拉取 {len(goods)} 个礼包(积分中心{suffix})",
)