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

444 lines
13 KiB
Python

"""虎牙积分中心兑换的唯一编排入口。
2026-11-13 HAR 实证:
* 积分不足时,先通过商城 ``createOrderV5 / payOrderSubmitV5`` 购买服务端推荐的
积分包;
* 支付到账后,再调用 ``getRedeemNeedGoods`` 确认积分;
* 积分足够后使用 ``createOrderV6`` 兑换目标道具,纯积分兑换不再发起支付。
精英宝典工作台、通用任务页以及后续浏览器入口都只调用本模块,不再直接实现
``scoreExchangePrize`` 或商城下单分支。
"""
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from .huya_shop_flow import ShopOrderOutcome, run_open_flow
EXCHANGE_STATUS_EXCHANGED = "exchanged"
EXCHANGE_STATUS_PAYMENT_REQUIRED = "payment_required"
EXCHANGE_STATUS_FAILED = "failed"
def _int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _text(value: Any) -> str:
return str(value or "").strip()
def normalize_spu_id(value: Any) -> str:
"""把数值或 ``hy-`` 前缀 SPU 统一成接口使用的字符串形式。"""
text = _text(value)
if not text:
return ""
if text.startswith("hy-"):
return text
if text.isdigit():
return f"hy-{text}"
return text
def spu_number(value: Any) -> int:
text = normalize_spu_id(value)
if "-" in text:
text = text.rsplit("-", 1)[-1]
return _int(text)
def _response_code(response: Any) -> int:
if response is None:
return 0
return _int(
getattr(response, "code", None)
or getattr(response, "status", None)
or getattr(response, "value", lambda: 0)("tag0")
)
def _response_message(response: Any) -> str:
if response is None:
return ""
return _text(
getattr(response, "message", "")
or getattr(response, "msg", "")
or getattr(response, "value", lambda: "")(*("tag1",))
)
def _value(response: Any, *path: str) -> Any:
if response is None:
return None
getter = getattr(response, "value", None)
if not callable(getter):
return None
return getter(*path)
def _stock_remaining(value: Any) -> int | None:
stock = _int(value)
return stock if stock > 0 else None
def _parse_plan(item: Any) -> dict[str, Any] | None:
if not isinstance(item, dict):
return None
spu_id = normalize_spu_id(item.get("tag0"))
sku_id = _int(item.get("tag1"))
name = _text(item.get("tag2"))
if not spu_id or not sku_id:
return None
gain_score = _int(item.get("tag5"))
price = _int(item.get("tag6"))
custom_count = max(1, _int(item.get("tag7")) or 1)
credit_type = _int(item.get("tag9"))
once_max = _int(item.get("tag21"))
count = custom_count if credit_type == 2 else 1
if once_max > 0:
count = min(count, once_max)
groups = item.get("tag8")
group_items = groups if isinstance(groups, list) else []
return {
"spu_id": spu_id,
"sku_id": sku_id,
"name": name or spu_id,
"gain_score": gain_score,
"price": price,
"count": count,
"credit_type": credit_type,
"once_max": once_max,
"group_items": group_items,
"remaining": _stock_remaining(item.get("tag20")),
}
@dataclass
class RedeemState:
code: int
message: str
user_score: int
redeem_need_score: int
need_buy_score: int
meet_precondition: bool
full_score: bool
plans: list[dict[str, Any]] = field(default_factory=list)
raw: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"code": self.code,
"message": self.message,
"user_score": self.user_score,
"redeem_need_score": self.redeem_need_score,
"need_buy_score": self.need_buy_score,
"meet_precondition": self.meet_precondition,
"full_score": self.full_score,
"plans": self.plans,
}
@dataclass
class PointsExchangeOutcome:
status: str
message: str
state: RedeemState | None = None
target: dict[str, Any] = field(default_factory=dict)
order: dict[str, Any] = field(default_factory=dict)
plan: dict[str, Any] | None = None
purchase: ShopOrderOutcome | None = None
detail: dict[str, Any] = field(default_factory=dict)
def _parse_redeem_state(response: Any) -> RedeemState:
code = _response_code(response)
plans_raw = _value(response, "tag7")
plans = [
plan
for plan in (
_parse_plan(item)
for item in (plans_raw if isinstance(plans_raw, list) else [])
)
if plan is not None
]
return RedeemState(
code=code,
message=_response_message(response),
user_score=_int(_value(response, "tag2")),
redeem_need_score=_int(_value(response, "tag3")),
need_buy_score=_int(_value(response, "tag4")),
meet_precondition=_int(_value(response, "tag5")) == 1,
full_score=_int(_value(response, "tag6")) == 1,
plans=plans,
raw=response.to_dict() if hasattr(response, "to_dict") else {},
)
def get_redeem_state(
client: Any,
*,
uid: int,
cookie: str,
spu_id: str,
) -> RedeemState:
response = client.get_redeem_need_goods(
uid=uid,
guid="",
cookie=cookie,
goods_num=spu_number(spu_id),
)
return _parse_redeem_state(response)
def _get_target_goods(
client: Any,
*,
uid: int,
cookie: str,
pid: int,
spu_id: str,
) -> dict[str, Any]:
response = client.get_goods_info(
uid=uid,
guid="",
cookie=cookie,
pid=pid,
spu_id=normalize_spu_id(spu_id),
sku_id=0,
game_id="",
)
if response is None:
raise RuntimeError("兑换商品详情接口无响应")
if _response_code(response) != 200:
raise RuntimeError(_response_message(response) or "兑换商品详情获取失败")
detail = response.to_dict() if hasattr(response, "to_dict") else {}
sku_id = _int(detail.get("sku_id"))
if not sku_id:
raise RuntimeError("兑换商品缺少 SKU,请先刷新商品列表")
return {
"spu_id": normalize_spu_id(spu_id),
"sku_id": sku_id,
"name": _text(detail.get("name")) or normalize_spu_id(spu_id),
"price": _int(detail.get("price")),
"goods_type": _int(detail.get("goods_type")),
"virtual_type": _int(detail.get("virtual_type")),
"detail": detail,
}
def _create_redeem_order(
client: Any,
*,
uid: int,
cookie: str,
pid: int,
target: dict[str, Any],
) -> PointsExchangeOutcome:
response = client.create_order_v6(
uid=uid,
guid="",
cookie=cookie,
pid=pid,
items=[
{
"spu_id": target["spu_id"],
"sku_id": target["sku_id"],
"item_count": 1,
"goods_type": target.get("goods_type", 0),
"virtual_type": target.get("virtual_type", 0),
}
],
)
if response is None:
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
"兑换下单接口无响应",
target=target,
)
order = response.to_dict() if hasattr(response, "to_dict") else {}
if _response_code(response) != 200:
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
_response_message(response) or "积分兑换下单失败",
target=target,
order=order,
)
return PointsExchangeOutcome(
EXCHANGE_STATUS_EXCHANGED,
f"兑换成功: {target['name']}",
target=target,
order=order,
)
def select_points_plan(
state: RedeemState,
) -> dict[str, Any] | None:
if not state.plans:
return None
simple = [plan for plan in state.plans if not plan.get("group_items")]
candidates = simple or list(state.plans)
enough = [
plan
for plan in candidates
if plan["gain_score"] * max(1, plan["count"]) >= state.need_buy_score
]
pool = enough or candidates
return min(
pool,
key=lambda plan: (
plan["price"] * max(1, plan["count"]),
plan["gain_score"] * max(1, plan["count"]),
),
)
def prepare_exchange(
client: Any,
*,
uid: int,
cookie: str,
pid: int,
spu_id: str,
pay_channel: str = "Zfb",
stopped: Callable[[], bool] | None = None,
log: Callable[[str], None] | None = None,
) -> PointsExchangeOutcome:
"""检查兑换状态;直接兑换或返回一张待支付的积分包订单。"""
state = get_redeem_state(client, uid=uid, cookie=cookie, spu_id=spu_id)
if state.code != 200:
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
state.message or f"兑换状态查询失败: {state.code}",
state=state,
)
if not state.meet_precondition:
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
"当前账号未满足兑换前置条件,请先开通对应宝典",
state=state,
)
try:
target = _get_target_goods(
client, uid=uid, cookie=cookie, pid=pid, spu_id=spu_id
)
except RuntimeError as exc:
return PointsExchangeOutcome(EXCHANGE_STATUS_FAILED, str(exc), state=state)
if state.full_score:
outcome = _create_redeem_order(
client, uid=uid, cookie=cookie, pid=pid, target=target
)
outcome.state = state
return outcome
plan = select_points_plan(state)
if plan is None:
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
"积分不足,服务端没有返回可购买的积分包",
state=state,
target=target,
)
if plan.get("group_items"):
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
"服务端返回组合积分包,当前版本无法安全自动下单",
state=state,
target=target,
plan=plan,
)
purchase = run_open_flow(
client,
uid=uid,
cookie=cookie,
pid=pid,
spu_id=plan["spu_id"],
sku_id=plan["sku_id"],
count=int(plan["count"]),
pay_channel=pay_channel,
snapshot_price=int(plan["price"]),
log=log,
stopped=stopped,
)
if not purchase.ok:
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
purchase.message,
state=state,
target=target,
plan=plan,
purchase=purchase,
detail=purchase.detail,
)
return PointsExchangeOutcome(
EXCHANGE_STATUS_PAYMENT_REQUIRED,
f"积分不足,已生成积分包支付码: {plan['name']}",
state=state,
target=target,
plan=plan,
purchase=purchase,
)
def complete_exchange_after_payment(
client: Any,
*,
uid: int,
cookie: str,
pid: int,
spu_id: str,
attempts: int = 5,
interval: float = 3.0,
sleep: Callable[[float], None] = time.sleep,
) -> PointsExchangeOutcome:
"""支付成功后等待积分到账并提交 V6 兑换。"""
state: RedeemState | None = None
last_message = "积分尚未到账"
for index in range(max(1, attempts)):
state = get_redeem_state(client, uid=uid, cookie=cookie, spu_id=spu_id)
if state.code != 200:
last_message = state.message or f"兑换状态查询失败: {state.code}"
elif not state.meet_precondition:
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
"支付成功,但账号仍未满足兑换前置条件",
state=state,
)
elif state.full_score:
try:
target = _get_target_goods(
client, uid=uid, cookie=cookie, pid=pid, spu_id=spu_id
)
except RuntimeError as exc:
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED, str(exc), state=state
)
outcome = _create_redeem_order(
client, uid=uid, cookie=cookie, pid=pid, target=target
)
outcome.state = state
return outcome
else:
last_message = (
f"积分到账中:当前 {state.user_score},还需 {state.need_buy_score}"
)
if index + 1 < max(1, attempts):
sleep(interval)
return PointsExchangeOutcome(
EXCHANGE_STATUS_FAILED,
last_message,
state=state,
)