"""虎牙积分中心兑换的唯一编排入口。 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, )