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

590 lines
22 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.
"""虎牙任务执行器:积分与商城(由 huya_runner.py 按功能域拆分)。"""
from __future__ import annotations
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM
from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask
from .huya_pay_link import pay_qr_path, resolve_pay_targets
from .huya_points_exchange import (
EXCHANGE_STATUS_EXCHANGED,
EXCHANGE_STATUS_PAYMENT_REQUIRED,
complete_exchange_after_payment,
normalize_spu_id,
prepare_exchange,
)
# 积分查询超时:HTTP 通道正常几百毫秒,10 秒足够;失败时别让整批任务长时间挂住。
HUYA_POINTS_QUERY_TIMEOUT_SECONDS = 10.0
class GoodsMixin:
"""积分与商城域:积分/兑换记录/商品刷新/兑换。"""
if TYPE_CHECKING:
payload: dict
@staticmethod
def _to_int(value: Any) -> int: ...
@staticmethod
def _format_local_time(timestamp: int) -> str: ...
def _resolve_uid(self, account_info: dict) -> int: ...
def _push_log(self, level: str, message: str) -> None: ...
def _activity_client(self, uid: int, cookie: str): ...
def _shop_client(self, uid: int, cookie: str): ...
def _http_client(self): ...
def _probe_bind_status(
self, uid: int, cookie: str, b_act_id_int: int, outer_act_id: str = ""
): ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ...
def _wait_recharge_payment(self, *args: Any, **kwargs: Any): ...
def _wait_until(self, when: datetime, uid: int) -> bool: ...
def _parse_scheduled_time(self, value: Any) -> datetime | None: ...
def _execute_query_act_tasks(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
"""读取精英宝典任务详情,供专用工作台展示购买/观看任务。"""
uid = self._resolve_uid(account_info)
cookie = account_info.get("cookie") or ""
if not uid or not cookie:
self._mark_task(worker_db, task, "failed", "账号 UID 或 Cookie 为空")
return
act_id = self._to_int(self.payload.get("act_id") or 25135)
if not act_id:
self._mark_task(worker_db, task, "failed", "精英宝典活动 ID 无效")
return
client: Any = self._activity_client(uid, cookie)
response = client.get_act_task_detail(uid=uid, cookie=cookie, act_id=act_id)
if response is None:
self._mark_task(worker_db, task, "error", "虎牙活动任务接口无响应")
return
result = response.to_dict()
result["act_id"] = act_id
act_info = client.get_act_info(act_id=act_id)
user_tasks = client.get_act_user_task_detail(
uid=uid, cookie=cookie, act_id=act_id
)
if act_info is not None:
result["act_info"] = act_info.to_dict()
if user_tasks is not None:
result["user_task_detail"] = user_tasks.to_dict()
if response.status != 200:
self._mark_task(
worker_db,
task,
"failed",
response.msg or f"虎牙活动任务查询失败: {response.status}",
result,
)
return
account.status = "tasks_queried"
account.updated_at = datetime.now(UTC)
self._mark_task(
worker_db,
task,
"success",
f"已读取 {len(result.get('tasks', []))} 项精英宝典任务",
result,
)
def _execute_query_points(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
"""查询积分:先验 CK,再走积分中心 HTTP 接口。
2026-10-15 真机实测:旧活动接口 ``webActUI.getUserScore`` 对任何 sid 都返回
603「活动已结束」(该接口已下线,与配置无关),不再作为回退——否则积分中心
超时后还要再等一次 15 秒。积分中心接口返回 ``tag2``=当前积分、
``tag3``=兑换门槛积分,另可顺手取 GameBizUI 的成长值/成长等级。
CK 判定复用外部单协作的新探针(HTTP + outer_act_id + 15 秒):积分中心对
失效 CK 仍可能返回 200/0 分,不能拿积分结果判断凭据是否有效。
"""
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
return
cookie = account_info.get("cookie") or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
# ---- 先验 CK:没有回包就立即判失效,不再空跑积分与旧接口 ----
ck_valid = False
protocol = self._bind_protocol(config_info, self.payload)
b_act_id_int = protocol["b_act_id"]
if protocol["bind_gid"] or b_act_id_int:
bind_status, _bind_query_result = self._probe_bind_status(
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=protocol["outer_act_id"],
gid=protocol["gid"],
is_use_outer_act_id=protocol["is_use_outer_act_id"],
scene=protocol["scene"],
)
if bind_status is None:
account.status = "ck_expired"
account.updated_at = datetime.now(UTC)
self._mark_task(
worker_db,
task,
"error",
"虎牙角色信息接口无响应(CK 已失效,请重新登录或重新导入 Cookie)",
{
"ck_expired": True,
"ck_expired_reason": "角色信息接口无响应",
},
)
return
ck_valid = int(getattr(bind_status, "status", 0) or 0) == 200
goods_num = (
self._to_int(self.payload.get("goods_num")) or POINTS_REDEEM_GOODS_NUM
)
# 直接走 HTTP:积分中心不需要 WSS 会话,省掉每个账号建连/初始化的几秒等待。
client: Any = self._http_client()
redeem = client.get_redeem_need_goods(
uid=uid,
guid="",
cookie=cookie,
goods_num=goods_num,
timeout=HUYA_POINTS_QUERY_TIMEOUT_SECONDS,
)
if redeem is None:
offline_result: dict[str, Any] = {"goods_num": goods_num}
if ck_valid:
offline_result["ck_valid"] = True
self._mark_task(
worker_db,
task,
"error",
"虎牙积分中心接口无响应(CK 探针正常,请稍后重试)"
if ck_valid
else "虎牙积分中心接口无响应",
offline_result,
)
return
result = redeem.to_dict()
result["goods_num"] = goods_num
if redeem.code != 200:
self._mark_task(
worker_db,
task,
"failed",
redeem.message or f"虎牙积分查询失败: {redeem.code}",
result,
)
return
points = self._to_int(redeem.value("tag2"))
result["points"] = points
result["redeem_threshold"] = self._to_int(redeem.value("tag3"))
growth = client.get_grow_level_user_info(
uid=uid,
guid="",
cookie=cookie,
timeout=HUYA_POINTS_QUERY_TIMEOUT_SECONDS,
)
if growth is not None and growth.code == 0:
result["grow_value"] = self._to_int(growth.value("tag1", "tag0"))
result["grow_level"] = self._to_int(growth.value("tag1", "tag1"))
result["nickname"] = growth.value("tag2") or ""
account.points = points
account.status = "points_queried"
account.updated_at = datetime.now(UTC)
self._mark_task(worker_db, task, "success", f"积分: {points}", result)
def _execute_query_exchange_records(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
return
cookie = account_info.get("cookie") or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
from core.huya.shop_structs import extract_points_center_sid
client: Any = self._http_client()
configured_sid = self._to_int(self.payload.get("sid") or config_info.get("sid"))
sid_int = configured_sid
sid_source = "config"
try:
page_code = (
str(config_info.get("points_page_code") or "").strip()
or "heping_baodian_credit_center_page"
)
source_id = (
str(config_info.get("points_source_id") or "").strip()
or "yellowcarlist"
)
scene = self._to_int(config_info.get("points_scene")) or 7
allocation = client.play_mall_query_allocation(
uid=uid,
guid="",
cookie=cookie,
page_code=page_code,
source_id=source_id,
scene=scene,
deep=True,
)
payload = getattr(allocation, "payload", None)
discovered_sid = extract_points_center_sid(payload)
if discovered_sid:
sid_int = discovered_sid
sid_source = "queryAllocation"
except Exception as exc: # noqa: BLE001 自动发现失败时回退配置值
self._push_log(
"warning",
f"[{uid}] 自动发现兑换活动 SID 失败,回退配置值: {exc}",
)
if not sid_int:
self._mark_task(
worker_db,
task,
"failed",
"无法确定当前兑换活动 SID,请先刷新积分中心商品",
)
return
response = client.get_user_prize_records(uid=uid, cookie=cookie, sid=sid_int)
if response is None:
self._mark_task(worker_db, task, "error", "虎牙兑换记录接口无响应")
return
result = response.to_dict()
result["sid"] = sid_int
result["sid_source"] = sid_source
result["configured_sid"] = configured_sid
if response.status != 200:
self._mark_task(
worker_db,
task,
"failed",
response.msg or f"虎牙兑换记录查询失败: {response.status}",
result,
)
return
records = result.get("records", [])
for index, item in enumerate(records, start=1):
item["index"] = index
item["exchange_time_text"] = self._format_local_time(
int(item.get("exchange_time") or 0)
)
if item.get("score") is not None:
item["score_text"] = f"{int(item.get('score') or 0)}积分"
account.status = "exchange_records_queried"
account.updated_at = datetime.now(UTC)
count = len(records)
message = f"兑换记录 {count} 条" if count else "暂无兑换记录"
self._mark_task(worker_db, task, "success", message, result)
def _execute_refresh_goods(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
return
cookie = account_info.get("cookie") or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
from .huya_collab_service import fetch_points_center_gifts
sid_int = self._to_int(config_info.get("sid")) or 2203
page_code = (
str(config_info.get("points_page_code") or "").strip()
or "heping_baodian_credit_center_page"
)
source_id = (
str(config_info.get("points_source_id") or "").strip() or "yellowcarlist"
)
scene = self._to_int(config_info.get("points_scene")) or 7
page_id = self._to_int(config_info.get("points_page_id")) or None
tab_ids = [
int(part)
for part in str(config_info.get("points_tab_ids") or "")
.replace(",", ",")
.replace(" ", ",")
.split(",")
if part.isdigit()
]
goods, message = fetch_points_center_gifts(
uid,
cookie,
client=self._http_client(),
page_code=page_code,
source_id=source_id,
scene=scene,
page_id=page_id,
tab_ids=tab_ids or None,
)
now = datetime.now(UTC)
worker_db.query(HuyaGoodsSnapshot).filter(
HuyaGoodsSnapshot.account_id == account.id,
HuyaGoodsSnapshot.sid == sid_int,
).delete(synchronize_session=False)
for item in goods:
worker_db.add(
HuyaGoodsSnapshot(
account_id=account.id,
sid=sid_int,
product_id=item["product_id"],
name=item["name"],
price=item["price"],
remain_text=item["remain_text"],
raw=item,
updated_at=now,
)
)
result = {
"source": "PlayMallUI.queryAllocation",
"sid": sid_int,
"page_code": page_code,
"source_id": source_id,
"scene": scene,
"goods": goods,
}
if not goods:
self._mark_task(worker_db, task, "failed", message, result)
return
account.status = "goods_refreshed"
account.updated_at = now
self._mark_task(
worker_db,
task,
"success",
f"已刷新积分中心商品 {len(goods)} 个",
result,
)
def _execute_exchange_goods(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
spu_id = normalize_spu_id(self.payload.get("product_id"))
if not spu_id:
self._mark_task(worker_db, task, "failed", "请选择兑换商品")
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
return
cookie = account_info.get("cookie") or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
scheduled_at = self._parse_scheduled_time(self.payload.get("scheduled_at"))
if self.payload.get("scheduled_at") and scheduled_at is None:
self._mark_task(worker_db, task, "failed", "定时兑换时间格式无效")
return
if (
scheduled_at
and scheduled_at.timestamp() > time.time()
and not self._wait_until(scheduled_at, uid)
):
self._mark_task(worker_db, task, "stopped", "兑换任务已停止")
return
pid = self._to_int(config_info.get("room_pid"))
if not pid:
self._mark_task(worker_db, task, "failed", "请先配置虎牙直播间 ID")
return
pay_channel = self._normalize_pay_channel(
self.payload.get("pay_channel") or config_info.get("pay_channel")
)
client: Any = self._http_client()
prepared = prepare_exchange(
client,
uid=uid,
cookie=cookie,
pid=pid,
spu_id=spu_id,
pay_channel=pay_channel,
stopped=self._stop.is_set,
log=lambda message: self._push_log("info", message),
)
common = {
"spu_id": spu_id,
"product_name": (prepared.target.get("name") if prepared.target else "")
or str(self.payload.get("product_name") or spu_id),
"scheduled_at": scheduled_at.isoformat() if scheduled_at else "",
"executed_at": datetime.now(UTC).isoformat(),
"redeem_state": prepared.state.to_dict() if prepared.state else None,
"target": prepared.target,
"exchange_plan": prepared.plan,
}
if prepared.status == EXCHANGE_STATUS_EXCHANGED:
result = {
**common,
"exchange_mode": "points_only",
"exchange_order": prepared.order,
"payment_status": "not_required",
"payment_status_label": "积分直兑",
}
account.status = "goods_exchanged"
account.updated_at = datetime.now(UTC)
if prepared.state:
account.points = prepared.state.user_score
self._mark_task(worker_db, task, "success", prepared.message, result)
return
if prepared.status != EXCHANGE_STATUS_PAYMENT_REQUIRED or not prepared.purchase:
self._mark_task(
worker_db,
task,
"failed",
prepared.message,
{**common, **prepared.detail},
)
return
purchase = prepared.purchase
pay_targets = resolve_pay_targets(purchase.pay_url)
result = {
**common,
"exchange_mode": "buy_points_then_exchange",
"points_package": prepared.plan,
"points_purchase": {
"order_id": purchase.order_id,
"app_order_id": purchase.app_order_id,
"pay_order_id": purchase.pay_order_id,
"product_name": purchase.product_name,
"count": purchase.count,
"unit_price": purchase.unit_price,
"amount": purchase.amount,
"supplier_name": purchase.supplier_name,
"order": purchase.order_raw,
"pay": purchase.pay_raw,
},
"pay_url": purchase.pay_url,
"pay_qr_code": pay_targets.qr_code,
"pay_cashier_url": pay_targets.cashier_url,
"pay_qr_path": pay_qr_path(task.id) if task is not None else "",
"payment_status": "pending",
"payment_status_label": "等待支付积分包",
"payment_paid": False,
}
account.status = "exchange_payment_pending"
account.updated_at = datetime.now(UTC)
self._update_task_progress(
worker_db,
task,
"running",
f"积分不足,需先购买 {prepared.plan['name'] if prepared.plan else '积分包'},等待扫码支付",
result,
)
payment_status, _payment_order = self._wait_recharge_payment(
client=client,
uid=uid,
guid="",
cookie=cookie,
order_id=purchase.order_id,
sid=self._to_int(config_info.get("sid")) or 2203,
result=result,
)
if payment_status == "paid":
completed = complete_exchange_after_payment(
client,
uid=uid,
cookie=cookie,
pid=pid,
spu_id=spu_id,
)
result.update(
{
"post_payment_redeem_state": (
completed.state.to_dict() if completed.state else None
),
"exchange_order": completed.order,
"target": completed.target or prepared.target,
}
)
if completed.status == EXCHANGE_STATUS_EXCHANGED:
account.status = "goods_exchanged"
account.updated_at = datetime.now(UTC)
if completed.state:
account.points = completed.state.user_score
self._mark_task(
worker_db,
task,
"success",
f"积分包支付成功,{completed.message}",
result,
)
else:
self._mark_task(
worker_db,
task,
"failed",
f"积分包支付成功,但兑换失败:{completed.message}",
result,
)
return
if payment_status == "stopped":
self._mark_task(
worker_db,
task,
"stopped",
"积分包支付监听已停止",
result,
)
return
self._mark_task(
worker_db,
task,
"timeout",
f"积分包支付超时,订单 #{purchase.order_id} 仍未完成",
result,
)