444 lines
13 KiB
Python
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,
|
|
)
|