From 5513b5a313e3437ab205d9acadd260981b639621 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 4 Jul 2026 23:48:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=99=8E=E7=89=99=E5=85=85?= =?UTF-8?q?=E5=80=BC=E5=95=86=E5=93=81=E5=88=97=E8=A1=A8=E4=B8=8E=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E4=BA=8C=E7=BB=B4=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/huya/activity_structs.py | 194 ++++++++++ core/huya/http_client.py | 28 +- core/huya/shop_structs.py | 226 +++++++++++- .../20260704_0005_huya_recharge_goods.py | 77 ++++ web/backend/models.py | 19 + web/backend/routers/huya.py | 13 +- web/backend/schemas.py | 36 ++ web/backend/services/huya_runner.py | 335 +++++++++++++++++- web/backend/services/huya_service.py | 8 +- web/frontend/src/api/huya.ts | 2 + web/frontend/src/api/types.ts | 16 + web/frontend/src/pages/HuyaTasksPage.tsx | 316 ++++++++++++++--- 12 files changed, 1209 insertions(+), 61 deletions(-) create mode 100644 web/backend/migrations/versions/20260704_0005_huya_recharge_goods.py diff --git a/core/huya/activity_structs.py b/core/huya/activity_structs.py index c1a45bb..870d9c1 100644 --- a/core/huya/activity_structs.py +++ b/core/huya/activity_structs.py @@ -120,6 +120,200 @@ class GetActPrizeListReq(TafStruct): self.sid = ins.read_int32(1, default=self.sid) +class GetActTaskDetailReq(TafStruct): + """webActUI.getActTaskDetail 请求。""" + + def __init__(self): + self.userId = ActivityUserId() + self.actId: int = 0 + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.userId) + os.write_int32(1, self.actId) + + def read_from(self, ins: TafInputStream): + self.userId = ins.read_struct(0, ActivityUserId) or self.userId + self.actId = ins.read_int32(1, default=self.actId) + + +class ActTaskPrizeInfo(TafStruct): + """活动任务奖励项,刷新充值商品时只取展示需要字段。""" + + def __init__(self): + self.actId: int = 0 + self.taskId: int = 0 + self.prizeId: int = 0 + self.name: str = "" + self.count: int = 0 + self.percentText: str = "" + self.extra: dict = {} + + def read_from(self, ins: TafInputStream): + self.actId = ins.read_int64(0, default=self.actId) + self.taskId = ins.read_int64(1, default=self.taskId) + self.prizeId = ins.read_int64(2, default=self.prizeId) + self.name = ins.read_string(3, default=self.name) + self.count = ins.read_int64(4, default=self.count) + self.percentText = ins.read_string(9, default=self.percentText) + self.extra = ins.read_map(12) + while True: + pos = ins.buf.tell() + tag, dtype = ins.read_head() + if dtype == TafType.STRUCT_END: + ins.buf.seek(pos) + return + ins.skip_field(dtype) + + def write_to(self, os: TafOutputStream): + pass + + @staticmethod + def read_list_item(ins: TafInputStream, _tag: int): + item = ActTaskPrizeInfo() + item.read_from(ins) + _end_tag, dtype = ins.read_head() + if dtype != TafType.STRUCT_END: + raise ValueError(f"期望任务奖励 STRUCT_END,实际 0x{dtype:02x}") + return item + + def to_dict(self) -> dict: + return { + "act_id": self.actId, + "task_id": self.taskId, + "prize_id": self.prizeId, + "name": self.name, + "count": self.count, + "percent_text": self.percentText, + "extra": dict(self.extra), + } + + +class ActTaskDetailItem(TafStruct): + """活动任务项,字段位置来自 getActTaskDetail 实测响应。""" + + def __init__(self): + self.actId: int = 0 + self.taskId: int = 0 + self.name: str = "" + self.taskStatus: int = 0 + self.taskType: int = 0 + self.limitType: int = 0 + self.taskParams: str = "" + self.targetValue: str = "" + self.icon: str = "" + self.description: str = "" + self.prizes: list[ActTaskPrizeInfo] = [] + self.taskUrl: str = "" + self.startTime: str = "" + self.endTime: str = "" + + @staticmethod + def _extract_spu_id(text: str) -> str: + import urllib.parse + + raw = str(text or "") + if not raw: + return "" + query = raw + if "?" in raw: + query = raw.split("?", 1)[1] + if "#" in query and "?" in query.split("#", 1)[1]: + query = query.split("#", 1)[1].split("?", 1)[1] + params = urllib.parse.parse_qs(query, keep_blank_values=True) + for key in ("spuId", "spu_id"): + value = params.get(key) + if value and value[0]: + return value[0] + for part in raw.replace("#", "&").split("&"): + if part.startswith("spuId="): + return urllib.parse.unquote(part.split("=", 1)[1]) + return "" + + def read_from(self, ins: TafInputStream): + self.actId = ins.read_int64(0, default=self.actId) + self.taskId = ins.read_int64(1, default=self.taskId) + self.name = ins.read_string(2, default=self.name) + self.taskStatus = ins.read_int32(3, default=self.taskStatus) + self.taskType = ins.read_int32(4, default=self.taskType) + self.limitType = ins.read_int32(5, default=self.limitType) + self.taskParams = ins.read_string(6, default=self.taskParams) + self.targetValue = ins.read_string(7, default=self.targetValue) + self.icon = ins.read_string(8, default=self.icon) + self.description = ins.read_string(9, default=self.description) + self.prizes = ins.read_list(11, item_reader=ActTaskPrizeInfo.read_list_item) + self.taskUrl = ins.read_string(12, default=self.taskUrl) + self.startTime = ins.read_string(25, default=self.startTime) + self.endTime = ins.read_string(26, default=self.endTime) + while True: + pos = ins.buf.tell() + tag, dtype = ins.read_head() + if dtype == TafType.STRUCT_END: + ins.buf.seek(pos) + return + ins.skip_field(dtype) + + def write_to(self, os: TafOutputStream): + pass + + @staticmethod + def read_list_item(ins: TafInputStream, _tag: int): + item = ActTaskDetailItem() + item.read_from(ins) + _end_tag, dtype = ins.read_head() + if dtype != TafType.STRUCT_END: + raise ValueError(f"期望任务 STRUCT_END,实际 0x{dtype:02x}") + return item + + @property + def spu_id(self) -> str: + return self._extract_spu_id(self.taskUrl) or self._extract_spu_id(self.taskParams) + + def to_dict(self) -> dict: + return { + "act_id": self.actId, + "task_id": self.taskId, + "name": self.name, + "task_status": self.taskStatus, + "task_type": self.taskType, + "limit_type": self.limitType, + "task_params": self.taskParams, + "target_value": self.targetValue, + "icon": self.icon, + "description": self.description, + "task_url": self.taskUrl, + "spu_id": self.spu_id, + "start_time": self.startTime, + "end_time": self.endTime, + "prizes": [item.to_dict() for item in self.prizes], + } + + +class GetActTaskDetailResp(TafStruct): + """webActUI.getActTaskDetail 响应。""" + + def __init__(self): + self.status: int = 0 + self.msg: str = "" + self.tasks: list[ActTaskDetailItem] = [] + + def read_from(self, ins: TafInputStream): + self.status = ins.read_int32(0, default=self.status) + self.msg = ins.read_string(1, default=self.msg) + self.tasks = ins.read_list(2, item_reader=ActTaskDetailItem.read_list_item) + + def write_to(self, os: TafOutputStream): + pass + + def to_dict(self) -> dict: + tasks = [item.to_dict() for item in self.tasks] + return { + "status": self.status, + "msg": self.msg, + "task_count": len(tasks), + "tasks": tasks, + } + + class ActPrizeItem(TafStruct): """活动商品项,字段位置来自 getActPrizeList 实测响应。""" diff --git a/core/huya/http_client.py b/core/huya/http_client.py index 8196b9f..8b67a44 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -286,6 +286,22 @@ class HuyaHttpClient: timeout=timeout, ) + def get_act_task_detail(self, uid: int, cookie: str, act_id: int, timeout: float = 15.0): + """查询活动任务详情,用于发现充值商品 SPU。""" + from .activity_structs import GetActTaskDetailReq, GetActTaskDetailResp + req = GetActTaskDetailReq() + req.userId = self._build_activity_user(uid, cookie) + req.actId = int(act_id or 0) + return self.call_rpc( + "webActUI", + "getActTaskDetail", + req, + GetActTaskDetailResp, + uid=uid, + cookie=cookie, + timeout=timeout, + ) + def check_user_bind_game_account( self, uid: int, @@ -593,6 +609,16 @@ class HuyaHttpClient: self.logger(f" orderType={ot}: 无响应") return last_result + @staticmethod + def _normalize_pay_channel(pay_type) -> str: + text = str(pay_type or "").strip() + lowered = text.lower() + if lowered in {"", "1", "zfb", "alipay", "支付宝"}: + return "Zfb" + if lowered in {"2", "wx", "weixin", "wechat", "微信"}: + return "Weixin" + return text + def pay_order_submit(self, uid, guid, cookie, order_id, pay_type=1, pid=0, source_id="yellowcarlist", scene=7, item_count=1): @@ -604,7 +630,7 @@ class HuyaHttpClient: os.write_struct(0, self._build_user(uid, guid, cookie)) os.write_struct(1, self._build_shop_app(source_id, scene)) os.write_int64(2, order_id) - os.write_string(3, "Zfb" if pay_type == 1 else str(pay_type)) + os.write_string(3, self._normalize_pay_channel(pay_type)) os.write_string(4, "QrCode") callback_url = ( f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback" diff --git a/core/huya/shop_structs.py b/core/huya/shop_structs.py index d5c3e56..b107bcc 100644 --- a/core/huya/shop_structs.py +++ b/core/huya/shop_structs.py @@ -223,11 +223,11 @@ class GetGoodsInfoReqV5(TafStruct): class GoodsInfoRsp(TafStruct): - """商品查询响应(简化:只取关键字段)""" + """商品查询响应。""" def __init__(self): self.code: int = 0 # tag 0 self.message: str = "" # tag 1 - # tag 2 是 goodsInfo 结构,简化跳过 + self.goodsInfo = None # tag 2 self.selfGoods: int = 0 # tag 3 self.marketStatus: int = 0 # tag 5 self.timestamp: int = 0 # tag 6 @@ -235,7 +235,7 @@ class GoodsInfoRsp(TafStruct): def read_from(self, ins: TafInputStream): self.code = ins.read_int32(0, default=self.code) self.message = ins.read_string(1, default=self.message) - # goodsInfo (tag 2) 跳过 + self.goodsInfo = ins.read_struct(2, GoodsInfoDetail) self.selfGoods = ins.read_int32(3, default=self.selfGoods) self.marketStatus = ins.read_int32(5, default=self.marketStatus) self.timestamp = ins.read_int64(6, default=self.timestamp) @@ -243,6 +243,215 @@ class GoodsInfoRsp(TafStruct): def write_to(self, os: TafOutputStream): pass + @property + def spu_id(self) -> str: + return self.goodsInfo.baseInfo.spuId if self.goodsInfo else "" + + @property + def sku_id(self) -> int: + return self.goodsInfo.priceInfo.first_sku_id if self.goodsInfo else 0 + + @property + def name(self) -> str: + return self.goodsInfo.baseInfo.name if self.goodsInfo else "" + + @property + def price(self) -> int: + return self.goodsInfo.priceInfo.price if self.goodsInfo else 0 + + @property + def stock(self) -> int: + return self.goodsInfo.priceInfo.stock if self.goodsInfo else 0 + + @property + def buy_limit(self) -> int: + return self.goodsInfo.priceInfo.buyLimit if self.goodsInfo else 0 + + def to_dict(self) -> dict: + goods = self.goodsInfo.to_dict() if self.goodsInfo else {} + return { + "code": self.code, + "message": self.message, + "spu_id": goods.get("spu_id", ""), + "sku_id": goods.get("sku_id", 0), + "name": goods.get("name", ""), + "description": goods.get("description", ""), + "icon": goods.get("icon", ""), + "detail_url": goods.get("detail_url", ""), + "price": goods.get("price", 0), + "min_price": goods.get("min_price", 0), + "max_price": goods.get("max_price", 0), + "stock": goods.get("stock", 0), + "buy_limit": goods.get("buy_limit", 0), + "sku_list": goods.get("sku_list", []), + "self_goods": self.selfGoods, + "market_status": self.marketStatus, + "timestamp": self.timestamp, + "goods": goods, + } + + +class GoodsBaseInfo(TafStruct): + """商品基础信息(getGoodsInfoV5 tag2.tag0)。""" + def __init__(self): + self.spuId: str = "" + self.appId: str = "" + self.name: str = "" + self.description: str = "" + self.icon: str = "" + self.detailUrl: str = "" + self.detailHtml: str = "" + + def read_from(self, ins: TafInputStream): + self.spuId = ins.read_string(0, default=self.spuId) + self.appId = ins.read_string(1, default=self.appId) + self.name = ins.read_string(2, default=self.name) + self.description = ins.read_string(4, default=self.description) + self.icon = ins.read_string(5, default=self.icon) + self.detailUrl = ins.read_string(6, default=self.detailUrl) + self.detailHtml = ins.read_string(7, default=self.detailHtml) + _skip_to_struct_end(ins) + + def write_to(self, os: TafOutputStream): + pass + + def to_dict(self) -> dict: + return { + "spu_id": self.spuId, + "app_id": self.appId, + "name": self.name, + "description": self.description, + "icon": self.icon, + "detail_url": self.detailUrl, + } + + +class GoodsSkuItem(TafStruct): + """商品 SKU 信息(getGoodsInfoV5 tag2.tag4.tag3 map value)。""" + def __init__(self): + self.skuId: int = 0 + self.spuId: str = "" + self.price: int = 0 + self.stock: int = 0 + self.status: int = 0 + + def read_from(self, ins: TafInputStream): + self.skuId = ins.read_int64(0, default=self.skuId) + self.spuId = ins.read_string(1, default=self.spuId) + self.price = ins.read_int64(2, default=self.price) + self.stock = ins.read_int64(3, default=self.stock) + self.status = ins.read_int32(4, default=self.status) + _skip_to_struct_end(ins) + + def write_to(self, os: TafOutputStream): + pass + + @staticmethod + def read_map_value(ins: TafInputStream, dtype: int): + if dtype != TafType.STRUCT_BEGIN: + ins.skip_field(dtype) + return None + item = GoodsSkuItem() + item.read_from(ins) + _end_tag, end_type = ins.read_head() + if end_type != TafType.STRUCT_END: + raise ValueError(f"期望 SKU STRUCT_END,实际 0x{end_type:02x}") + return item + + def to_dict(self) -> dict: + return { + "sku_id": self.skuId, + "spu_id": self.spuId, + "price": self.price, + "stock": self.stock, + "status": self.status, + } + + +class GoodsPriceInfo(TafStruct): + """商品价格与 SKU 信息(getGoodsInfoV5 tag2.tag4)。""" + def __init__(self): + self.spuId: str = "" + self.minPrice: int = 0 + self.maxPrice: int = 0 + self.skuMap: Dict[int, GoodsSkuItem] = {} + self.stock: int = 0 + self.buyLimit: int = 0 + self.price: int = 0 + + def _read_sku_map(self, ins: TafInputStream): + found = ins._find_tag(3, required=False) + if not found: + return {} + if found[1] != TafType.MAP: + ins.skip_field(found[1]) + return {} + count = ins._read_int_len() + result = {} + for _ in range(count): + _, key_type = ins.read_head() + key = ins._read_int_value(key_type) + _, value_type = ins.read_head() + item = GoodsSkuItem.read_map_value(ins, value_type) + if item is not None: + result[int(key)] = item + return result + + def read_from(self, ins: TafInputStream): + self.spuId = ins.read_string(0, default=self.spuId) + self.minPrice = ins.read_int64(1, default=self.minPrice) + self.maxPrice = ins.read_int64(2, default=self.maxPrice) + self.skuMap = self._read_sku_map(ins) + self.stock = ins.read_int64(4, default=self.stock) + self.buyLimit = ins.read_int64(5, default=self.buyLimit) + self.price = ins.read_int64(7, default=self.price) + _skip_to_struct_end(ins) + + def write_to(self, os: TafOutputStream): + pass + + @property + def first_sku_id(self) -> int: + if not self.skuMap: + return 0 + return sorted(self.skuMap.keys())[0] + + @property + def sku_list(self) -> list[dict]: + return [self.skuMap[key].to_dict() for key in sorted(self.skuMap.keys())] + + def to_dict(self) -> dict: + return { + "spu_id": self.spuId, + "sku_id": self.first_sku_id, + "price": self.price or self.minPrice, + "min_price": self.minPrice, + "max_price": self.maxPrice, + "stock": self.stock, + "buy_limit": self.buyLimit, + "sku_list": self.sku_list, + } + + +class GoodsInfoDetail(TafStruct): + """getGoodsInfoV5 响应里的 goodsInfo 主体。""" + def __init__(self): + self.baseInfo = GoodsBaseInfo() + self.priceInfo = GoodsPriceInfo() + + def read_from(self, ins: TafInputStream): + self.baseInfo = ins.read_struct(0, GoodsBaseInfo) or self.baseInfo + self.priceInfo = ins.read_struct(4, GoodsPriceInfo) or self.priceInfo + _skip_to_struct_end(ins) + + def write_to(self, os: TafOutputStream): + pass + + def to_dict(self) -> dict: + data = self.baseInfo.to_dict() + data.update(self.priceInfo.to_dict()) + return data + # ============================================================ # 订单历史 @@ -581,3 +790,14 @@ class PayOrderRes(TafStruct): def write_to(self, os: TafOutputStream): pass + + def to_dict(self) -> dict: + return { + "code": self.code, + "message": self.message, + "order_id": self.orderId, + "app_order_id": self.appOrderId, + "pay_order_id": self.payOrderId, + "pay_url": self.payUrl, + "amount": self.amount, + } diff --git a/web/backend/migrations/versions/20260704_0005_huya_recharge_goods.py b/web/backend/migrations/versions/20260704_0005_huya_recharge_goods.py new file mode 100644 index 0000000..be0ed06 --- /dev/null +++ b/web/backend/migrations/versions/20260704_0005_huya_recharge_goods.py @@ -0,0 +1,77 @@ +"""新增虎牙充值商品快照表 + +Revision ID: 20260704_0005 +Revises: 20260704_0004 +Create Date: 2026-07-04 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260704_0005" +down_revision: Union[str, None] = "20260704_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _indexes(bind, table_name: str) -> set[str]: + if not _has_table(bind, table_name): + return set() + return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} + + +def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: + if name not in _indexes(bind, table_name): + op.create_index(name, table_name, columns, unique=unique) + + +def upgrade() -> None: + bind = op.get_bind() + if not _has_table(bind, "huya_recharge_goods_snapshot"): + op.create_table( + "huya_recharge_goods_snapshot", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("spu_id", sa.String(length=64), nullable=False), + sa.Column("sku_id", sa.String(length=64), nullable=True), + sa.Column("name", sa.String(length=256), nullable=True), + sa.Column("price", sa.Integer(), nullable=True), + sa.Column("stock", sa.Integer(), nullable=True), + sa.Column("buy_limit", sa.Integer(), nullable=True), + sa.Column("icon", sa.String(length=512), nullable=True), + sa.Column("description", sa.String(length=512), nullable=True), + sa.Column("task_id", sa.String(length=64), nullable=True), + sa.Column("task_name", sa.String(length=256), nullable=True), + sa.Column("raw", sa.JSON(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + _create_index_if_missing( + bind, + "ix_huya_recharge_goods_snapshot_spu_id", + "huya_recharge_goods_snapshot", + ["spu_id"], + ) + _create_index_if_missing( + bind, + "ix_huya_recharge_goods_snapshot_sku_id", + "huya_recharge_goods_snapshot", + ["sku_id"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "huya_recharge_goods_snapshot"): + indexes = _indexes(bind, "huya_recharge_goods_snapshot") + if "ix_huya_recharge_goods_snapshot_sku_id" in indexes: + op.drop_index("ix_huya_recharge_goods_snapshot_sku_id", table_name="huya_recharge_goods_snapshot") + if "ix_huya_recharge_goods_snapshot_spu_id" in indexes: + op.drop_index("ix_huya_recharge_goods_snapshot_spu_id", table_name="huya_recharge_goods_snapshot") + op.drop_table("huya_recharge_goods_snapshot") diff --git a/web/backend/models.py b/web/backend/models.py index 0c6b4bd..ee465cd 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -147,6 +147,25 @@ class HuyaGoodsSnapshot(Base): updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) +class HuyaRechargeGoodsSnapshot(Base): + """虎牙充值商品快照""" + __tablename__ = "huya_recharge_goods_snapshot" + + id = Column(Integer, primary_key=True, autoincrement=True) + spu_id = Column(String(64), nullable=False, index=True) + sku_id = Column(String(64), default="", index=True) + name = Column(String(256), default="") + price = Column(Integer, nullable=True) + stock = Column(Integer, nullable=True) + buy_limit = Column(Integer, nullable=True) + icon = Column(String(512), default="") + description = Column(String(512), default="") + task_id = Column(String(64), default="") + task_name = Column(String(256), default="") + raw = Column(JSON, nullable=True) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + class ProxyConfig(Base): """代理配置(全局单条记录)""" __tablename__ = "proxy_config" diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py index d098f0a..5b5bf79 100644 --- a/web/backend/routers/huya.py +++ b/web/backend/routers/huya.py @@ -9,13 +9,14 @@ from sqlalchemy.orm import Session, joinedload from ..database import SessionLocal, get_db from ..deps import authenticate_websocket, require_permission -from ..models import HuyaAccount, HuyaConfig, HuyaGoodsSnapshot, HuyaTask, User +from ..models import HuyaAccount, HuyaConfig, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask, User from ..schemas import ( HuyaAccountOut, HuyaConfigOut, HuyaConfigUpdate, HuyaCookieImport, HuyaGoodsOut, + HuyaRechargeGoodsOut, HuyaTaskBatchRequest, HuyaTaskOut, ) @@ -200,6 +201,16 @@ def list_goods( return rows +@router.get("/recharge-goods", response_model=list[HuyaRechargeGoodsOut]) +def list_recharge_goods( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """查看已缓存的虎牙充值商品快照。""" + rows = db.query(HuyaRechargeGoodsSnapshot).order_by(HuyaRechargeGoodsSnapshot.id.asc()).all() + return rows + + @router.post("/tasks/batch") async def create_task_batch( req: HuyaTaskBatchRequest, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index c589276..369b4a5 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -314,6 +314,42 @@ class HuyaGoodsOut(BaseModel): } +class HuyaRechargeGoodsOut(BaseModel): + id: int + spu_id: str + sku_id: str = "" + name: str = "" + price: Optional[int] = None + stock: Optional[int] = None + buy_limit: Optional[int] = None + icon: str = "" + description: str = "" + task_id: str = "" + task_name: str = "" + raw: Optional[dict[str, Any]] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + @model_serializer + def _serialize(self) -> dict[str, Any]: + return { + "id": self.id, + "spu_id": self.spu_id, + "sku_id": self.sku_id, + "name": self.name, + "price": self.price, + "stock": self.stock, + "buy_limit": self.buy_limit, + "icon": self.icon, + "description": self.description, + "task_id": self.task_id, + "task_name": self.task_name, + "raw": self.raw, + "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + } + + # ---- 代理配置 ---- class ProxyConfigOut(BaseModel): enabled: bool = False diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index 8bc5fc7..c8b1c21 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -11,10 +11,24 @@ from sqlalchemy.orm import Session, joinedload from core.huya import HuyaHttpClient from ..database import SessionLocal -from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask +from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask from .huya_service import HUYA_CONFIG_FIELDS, cookie_value, ensure_huya_config, huya_config_value +HUYA_RECHARGE_ACT_ID = 25135 +HUYA_RECHARGE_SOURCE_ID = "yellowcarlist" +HUYA_RECHARGE_SCENE = 4 +HUYA_RECHARGE_EXTRA_PRODUCTS = [ + { + "spu_id": "hy-5879340", + "name": "精英宝典", + "task_name": "开通精英宝典", + "description": "得300积分丨解锁道具兑换权益", + "sort": 0, + }, +] + + class HuyaBatchRunner: """批量执行虎牙任务,通过队列推送实时日志。""" @@ -264,6 +278,319 @@ class HuyaBatchRunner: message = f"已刷新商品 {len(goods)} 个" self._mark_task(worker_db, task, "success", message, {**result, "goods": goods}) + @staticmethod + def _normalize_pay_channel(value) -> str: + text = str(value or "").strip() + lowered = text.lower() + if lowered in {"weixin", "wx", "wechat", "微信"}: + return "Weixin" + return "Zfb" + + @staticmethod + def _pay_channel_label(value: str) -> str: + return "微信" if value == "Weixin" else "支付宝" + + @staticmethod + def _recharge_price_text(price: int | None) -> str: + if not price: + return "" + return f"{price / 100:.2f}元" + + def _execute_refresh_recharge_goods( + self, + worker_db: Session, + task: HuyaTask, + account: HuyaAccount, + account_info: dict, + config_info: dict, + ): + pid = self._to_int(config_info.get("room_pid")) + if not pid: + self._mark_task(worker_db, task, "failed", "请先配置虎牙直播间 ID") + 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 + + client = HuyaHttpClient(logger=lambda msg: self._push_log("info", f"[{uid}] {msg}")) + task_resp = client.get_act_task_detail(uid=uid, cookie=cookie, act_id=HUYA_RECHARGE_ACT_ID) + if task_resp is None: + self._mark_task(worker_db, task, "error", "虎牙充值任务详情接口无响应") + return + + task_result = task_resp.to_dict() + if task_resp.status != 200: + self._mark_task( + worker_db, + task, + "failed", + task_resp.msg or f"虎牙充值任务详情获取失败: {task_resp.status}", + task_result, + ) + return + + candidates: list[dict] = [] + seen: set[str] = set() + + def add_candidate(item: dict): + spu_id = str(item.get("spu_id") or "").strip() + if not spu_id or spu_id in seen: + return + seen.add(spu_id) + candidates.append(item) + + for item in HUYA_RECHARGE_EXTRA_PRODUCTS: + add_candidate(dict(item)) + + for index, item in enumerate(task_result.get("tasks", []), start=1): + if int(item.get("task_type") or 0) != 67: + continue + add_candidate({ + "spu_id": item.get("spu_id") or "", + "name": item.get("name") or "", + "task_id": str(item.get("task_id") or ""), + "task_name": item.get("name") or "", + "description": item.get("description") or "", + "icon": item.get("icon") or "", + "task_url": item.get("task_url") or "", + "prizes": item.get("prizes") or [], + "sort": index, + }) + + if not candidates: + self._mark_task(worker_db, task, "failed", "未从活动任务中发现充值商品", task_result) + return + + now = datetime.now(timezone.utc) + goods: list[dict] = [] + failed: list[dict] = [] + + for candidate in candidates: + spu_id = candidate["spu_id"] + detail_resp = client.get_goods_info( + uid=uid, + guid="", + cookie=cookie, + pid=pid, + spu_id=spu_id, + sku_id=0, + game_id="0", + source_id=HUYA_RECHARGE_SOURCE_ID, + scene=HUYA_RECHARGE_SCENE, + ) + if detail_resp is None: + failed.append({"spu_id": spu_id, "message": "商品详情接口无响应"}) + continue + detail = detail_resp.to_dict() + if detail_resp.code != 200 or not detail.get("sku_id"): + failed.append({ + "spu_id": spu_id, + "message": detail_resp.message or f"商品详情获取失败: {detail_resp.code}", + "detail": detail, + }) + continue + + item = { + **candidate, + **detail, + "spu_id": detail.get("spu_id") or spu_id, + "sku_id": str(detail.get("sku_id") or ""), + "name": detail.get("name") or candidate.get("name") or spu_id, + "description": detail.get("description") or candidate.get("description") or "", + "icon": detail.get("icon") or candidate.get("icon") or "", + "task_id": candidate.get("task_id") or "", + "task_name": candidate.get("task_name") or candidate.get("name") or "", + "raw_order": int(candidate.get("sort") or 0), + } + goods.append(item) + + worker_db.query(HuyaRechargeGoodsSnapshot).delete(synchronize_session=False) + for item in goods: + worker_db.add(HuyaRechargeGoodsSnapshot( + spu_id=item["spu_id"], + sku_id=item["sku_id"], + name=item["name"], + price=item.get("price") or None, + stock=item.get("stock") or None, + buy_limit=item.get("buy_limit") or None, + icon=item.get("icon") or "", + description=item.get("description") or "", + task_id=item.get("task_id") or "", + task_name=item.get("task_name") or "", + raw=item, + updated_at=now, + )) + + account.status = "recharge_goods_refreshed" + account.updated_at = now + message = f"已刷新充值商品 {len(goods)} 个" + if failed: + message += f",失败 {len(failed)} 个" + result = { + "act_id": HUYA_RECHARGE_ACT_ID, + "goods_count": len(goods), + "failed_count": len(failed), + "goods": goods, + "failed": failed, + "task_detail": task_result, + } + self._mark_task(worker_db, task, "success" if goods else "failed", message, result) + + def _execute_create_recharge_order( + self, + worker_db: Session, + task: HuyaTask, + account: HuyaAccount, + account_info: dict, + config_info: dict, + ): + pid = self._to_int(config_info.get("room_pid")) + if not pid: + self._mark_task(worker_db, task, "failed", "请先配置虎牙直播间 ID") + return + + spu_id = str(self.payload.get("spu_id") or "").strip() + if not spu_id: + self._mark_task(worker_db, task, "failed", "请选择充值商品") + return + + count = self._to_int(self.payload.get("count")) or 1 + count = max(1, min(count, 999)) + pay_channel = self._normalize_pay_channel(self.payload.get("pay_channel") or config_info.get("pay_channel")) + + 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 + + snapshot = worker_db.query(HuyaRechargeGoodsSnapshot).filter( + HuyaRechargeGoodsSnapshot.spu_id == spu_id + ).first() + payload_sku_id = self._to_int(self.payload.get("sku_id")) + sku_id = payload_sku_id or self._to_int(snapshot.sku_id if snapshot else "") + product_name = str(self.payload.get("product_name") or (snapshot.name if snapshot else "") or spu_id) + unit_price = int(snapshot.price or 0) if snapshot else 0 + + client = HuyaHttpClient(logger=lambda msg: self._push_log("info", f"[{uid}] {msg}")) + detail_resp = client.get_goods_info( + uid=uid, + guid="", + cookie=cookie, + pid=pid, + spu_id=spu_id, + sku_id=sku_id or 0, + game_id="0", + source_id=HUYA_RECHARGE_SOURCE_ID, + scene=HUYA_RECHARGE_SCENE, + ) + if detail_resp is None: + self._mark_task(worker_db, task, "error", "虎牙充值商品详情接口无响应") + return + detail = detail_resp.to_dict() + if detail_resp.code != 200: + self._mark_task( + worker_db, + task, + "failed", + detail_resp.message or f"虎牙充值商品详情获取失败: {detail_resp.code}", + detail, + ) + return + + sku_id = int(detail.get("sku_id") or sku_id or 0) + product_name = detail.get("name") or product_name + unit_price = int(detail.get("price") or unit_price or 0) + if not sku_id: + self._mark_task(worker_db, task, "failed", "充值商品缺少 SKU,请先刷新充值商品列表", detail) + return + + order_resp = client.create_order( + uid=uid, + guid="", + cookie=cookie, + pid=pid, + spu_id=spu_id, + sku_id=sku_id, + item_count=count, + source_id=HUYA_RECHARGE_SOURCE_ID, + game_id="0", + scene=HUYA_RECHARGE_SCENE, + order_type=6, + ) + if order_resp is None: + self._mark_task(worker_db, task, "error", "虎牙下单接口无响应") + return + order_result = order_resp.to_dict() + if order_resp.code != 200 or not order_resp.orderId: + self._mark_task( + worker_db, + task, + "failed", + order_resp.message or f"虎牙下单失败: {order_resp.code}", + {"goods": detail, "order": order_result}, + ) + return + + pay_resp = client.pay_order_submit( + uid=uid, + guid="", + cookie=cookie, + order_id=order_resp.orderId, + pay_type=pay_channel, + pid=pid, + source_id=HUYA_RECHARGE_SOURCE_ID, + scene=HUYA_RECHARGE_SCENE, + item_count=count, + ) + if pay_resp is None: + self._mark_task(worker_db, task, "error", "虎牙支付接口无响应", {"goods": detail, "order": order_result}) + return + pay_result = pay_resp.to_dict() + if pay_resp.code != 200 or not pay_resp.payUrl: + self._mark_task( + worker_db, + task, + "failed", + pay_resp.message or f"虎牙支付二维码生成失败: {pay_resp.code}", + {"goods": detail, "order": order_result, "pay": pay_result}, + ) + return + + amount = int(pay_resp.amount or unit_price * count or 0) + result = { + "spu_id": spu_id, + "sku_id": sku_id, + "product_name": product_name, + "count": count, + "unit_price": unit_price, + "amount": amount, + "amount_text": self._recharge_price_text(amount), + "pay_channel": pay_channel, + "pay_channel_label": self._pay_channel_label(pay_channel), + "order_id": order_resp.orderId, + "app_order_id": pay_resp.appOrderId, + "pay_order_id": pay_resp.payOrderId, + "pay_url": pay_resp.payUrl, + "goods": detail, + "order": order_result, + } + account.status = "recharge_order_created" + account.updated_at = datetime.now(timezone.utc) + message = f"{product_name} x{count} {self._pay_channel_label(pay_channel)} {result['amount_text']}" + self._mark_task(worker_db, task, "success", message, result) + def _execute_get_bind_qr( self, worker_db: Session, @@ -629,6 +956,8 @@ class HuyaBatchRunner: "confirm_bind", "query_game_name", "refresh_goods", + "refresh_recharge_goods", + "create_recharge_order", }: self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现") self._push_log("warning", f"[{current}] {name} 暂未实现: {self.task_type}") @@ -639,6 +968,10 @@ class HuyaBatchRunner: self._execute_query_points(worker_db, task, account, account_info, config_info) elif self.task_type == "refresh_goods": self._execute_refresh_goods(worker_db, task, account, account_info, config_info) + elif self.task_type == "refresh_recharge_goods": + self._execute_refresh_recharge_goods(worker_db, task, account, account_info, config_info) + elif self.task_type == "create_recharge_order": + self._execute_create_recharge_order(worker_db, task, account, account_info, config_info) elif self.task_type == "get_bind_qr": self._execute_get_bind_qr(worker_db, task, account, account_info, config_info) elif self.task_type == "confirm_bind": diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index b24c623..053115d 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -13,12 +13,12 @@ from ..models import HuyaAccount, HuyaConfig, HuyaTask SUPPORTED_TASK_TYPES = { "get_bind_qr": "获取绑定二维码", "query_points": "一键查询积分", - "open_elite_book": "开通精英宝典", - "recharge_points": "充值积分", "query_game_name": "一键查询游戏名", "query_exchange_records": "一键查询兑换记录", "confirm_bind": "确认绑定", "refresh_goods": "刷新商品列表", + "refresh_recharge_goods": "刷新充值商品列表", + "create_recharge_order": "生成支付二维码", } @@ -172,8 +172,8 @@ def create_planned_tasks( batch_id = uuid.uuid4().hex[:12] payload = payload or {} accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all() - if task_type == "refresh_goods" and accounts: - # 商品列表是全局快照,使用一个可用 CK 刷新即可。 + if task_type in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"} and accounts: + # 全局快照和单笔支付二维码都使用一个选中的 CK 即可。 accounts = accounts[:1] for account in accounts: db.add(HuyaTask( diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 7dbe9c2..c6dfcb5 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -4,6 +4,7 @@ import type { HuyaConfig, HuyaCookieImportResult, HuyaGoodsItem, + HuyaRechargeGoodsItem, HuyaTaskBatchRequest, HuyaTaskBatchResult, HuyaTaskItem, @@ -23,6 +24,7 @@ export const huyaApi = { getConfig: () => api.get('/huya/config'), updateConfig: (data: Partial) => api.put('/huya/config', data), listGoods: () => api.get('/huya/goods'), + listRechargeGoods: () => api.get('/huya/recharge-goods'), createTasks: (data: HuyaTaskBatchRequest) => api.post('/huya/tasks/batch', data), listTasks: (batchId?: string) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 646f6f8..14f45b7 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -183,6 +183,22 @@ export interface HuyaGoodsItem { updated_at: string | null; } +export interface HuyaRechargeGoodsItem { + id: number; + spu_id: string; + sku_id: string; + name: string; + price: number | null; + stock: number | null; + buy_limit: number | null; + icon: string; + description: string; + task_id: string; + task_name: string; + raw: Record | null; + updated_at: string | null; +} + // ==================== Proxy ==================== export interface ProxyConfig { diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 2f6c2c1..30b62b8 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Button, Card, Col, Form, Input, InputNumber, message, Modal, Row, Select, Space, Table, Tabs, Tag, Tooltip, Typography, theme, + Button, Card, Col, Form, Input, InputNumber, message, Modal, QRCode, Row, Select, Space, Table, Tabs, Tag, Tooltip, Typography, theme, } from 'antd'; import type { TableProps } from 'antd'; import { - AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined, + AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, LinkOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, } from '@ant-design/icons'; import { @@ -12,6 +12,7 @@ import { type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, + type HuyaRechargeGoodsItem, type HuyaTaskItem, } from '../api/modules'; import RealtimeLogPanel from '../components/RealtimeLogPanel'; @@ -26,22 +27,22 @@ const FALLBACK_TASK_TYPES: Record = { get_bind_qr: '获取绑定二维码', confirm_bind: '确认绑定', query_points: '一键查询积分', - open_elite_book: '开通精英宝典', - recharge_points: '充值积分', query_game_name: '一键查询游戏名', query_exchange_records: '一键查询兑换记录', refresh_goods: '刷新商品列表', + refresh_recharge_goods: '刷新充值商品列表', + create_recharge_order: '生成支付二维码', }; const QUICK_ACTIONS = [ { key: 'get_bind_qr', icon: }, { key: 'confirm_bind', icon: }, { key: 'query_points', icon: }, - { key: 'open_elite_book', icon: }, - { key: 'recharge_points', icon: }, { key: 'query_game_name', icon: }, { key: 'query_exchange_records', icon: }, { key: 'refresh_goods', icon: }, + { key: 'refresh_recharge_goods', icon: }, + { key: 'create_recharge_order', icon: }, ]; const STATUS_COLORS: Record = { @@ -115,22 +116,33 @@ function formatRemainText(value: string): string { return /^-?\d+(\.\d+)?$/.test(text) ? `${text}%` : text; } +function formatPriceText(value: number | null | undefined): string { + if (!value) return ''; + return `¥${(value / 100).toFixed(2)}`; +} + function hasMiniQrcode(task: HuyaTaskItem): boolean { return task.task_type === 'get_bind_qr' && Boolean(resultText(task.result, 'mini_qrcode_image')); } +function hasPaymentQrcode(task: HuyaTaskItem): boolean { + return task.task_type === 'create_recharge_order' && Boolean(resultText(task.result, 'pay_url')); +} + export default function HuyaTasksPage() { const { token } = theme.useToken(); const [form] = Form.useForm(); const [accounts, setAccounts] = useState([]); const [tasks, setTasks] = useState([]); const [goods, setGoods] = useState([]); + const [rechargeGoods, setRechargeGoods] = useState([]); const [taskTypes, setTaskTypes] = useState>(FALLBACK_TASK_TYPES); const [selectedIds, setSelectedIds] = useState([]); const [selectedTaskType, setSelectedTaskType] = useState('query_points'); - const [selectedGoodsId, setSelectedGoodsId] = useState(''); const [selectedGoodsCategory, setSelectedGoodsCategory] = useState(''); + const [selectedRechargeGoodsId, setSelectedRechargeGoodsId] = useState(''); const [rechargeCount, setRechargeCount] = useState(1); + const [rechargePayChannel, setRechargePayChannel] = useState('Weixin'); const [concurrency, setConcurrency] = useState(() => { const v = localStorage.getItem('huya_task_concurrency'); return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3; @@ -140,8 +152,11 @@ export default function HuyaTasksPage() { const [savingConfig, setSavingConfig] = useState(false); const [batchId, setBatchId] = useState(null); const [qrTask, setQrTask] = useState(null); + const [payTask, setPayTask] = useState(null); const autoOpenedQrTaskIds = useRef>(new Set()); const autoOpenQrReady = useRef(false); + const autoOpenedPayTaskIds = useRef>(new Set()); + const autoOpenPayReady = useRef(false); const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); const { can } = usePermissions(); @@ -158,18 +173,30 @@ export default function HuyaTasksPage() { autoOpenQrReady.current = true; }, []); + const rememberExistingPaymentQrcodes = useCallback((items: HuyaTaskItem[]) => { + if (autoOpenPayReady.current) return; + items.filter(hasPaymentQrcode).forEach((task) => autoOpenedPayTaskIds.current.add(task.id)); + autoOpenPayReady.current = true; + }, []); + const openQrTask = useCallback((task: HuyaTaskItem) => { autoOpenedQrTaskIds.current.add(task.id); setQrTask(task); }, []); + const openPayTask = useCallback((task: HuyaTaskItem) => { + autoOpenedPayTaskIds.current.add(task.id); + setPayTask(task); + }, []); + const loadAll = useCallback(async () => { setLoading(true); try { - const [accountResult, taskResult, goodsResult, configResult, taskTypeResult] = await Promise.allSettled([ + const [accountResult, taskResult, goodsResult, rechargeGoodsResult, configResult, taskTypeResult] = await Promise.allSettled([ huyaApi.listAccounts(), huyaApi.listTasks(), huyaApi.listGoods(), + huyaApi.listRechargeGoods(), canConfig ? huyaApi.getConfig() : Promise.resolve(null), huyaApi.taskTypes(), ]); @@ -177,16 +204,19 @@ export default function HuyaTasksPage() { if (accountResult.status === 'fulfilled') setAccounts(accountResult.value); if (taskResult.status === 'fulfilled') { rememberExistingQrcodes(taskResult.value); + rememberExistingPaymentQrcodes(taskResult.value); setTasks(taskResult.value); } if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value); + if (rechargeGoodsResult.status === 'fulfilled') setRechargeGoods(rechargeGoodsResult.value); if (configResult.status === 'fulfilled' && configResult.value) form.setFieldsValue(configResult.value); if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value }); const failedLabels = [ accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '', taskResult.status === 'rejected' ? `任务记录: ${getErrorMessage(taskResult.reason)}` : '', - goodsResult.status === 'rejected' ? `商品快照: ${getErrorMessage(goodsResult.reason)}` : '', + goodsResult.status === 'rejected' ? `兑换商品: ${getErrorMessage(goodsResult.reason)}` : '', + rechargeGoodsResult.status === 'rejected' ? `充值商品: ${getErrorMessage(rechargeGoodsResult.reason)}` : '', configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '', taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '', ].filter(Boolean); @@ -196,17 +226,18 @@ export default function HuyaTasksPage() { } finally { setLoading(false); } - }, [canConfig, form, rememberExistingQrcodes]); + }, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]); const loadTasks = useCallback(async () => { try { const data = await huyaApi.listTasks(); rememberExistingQrcodes(data); + rememberExistingPaymentQrcodes(data); setTasks(data); } catch { // 轮询失败不打扰操作,下一轮继续刷新。 } - }, [rememberExistingQrcodes]); + }, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]); useEffect(() => { loadAll(); @@ -225,6 +256,14 @@ export default function HuyaTasksPage() { if (nextQrTask) openQrTask(nextQrTask); }, [openQrTask, qrTask, tasks]); + useEffect(() => { + if (!autoOpenPayReady.current || payTask) return; + const nextPayTask = tasks + .filter((task) => hasPaymentQrcode(task) && !autoOpenedPayTaskIds.current.has(task.id)) + .sort((a, b) => b.id - a.id)[0]; + if (nextPayTask) openPayTask(nextPayTask); + }, [openPayTask, payTask, tasks]); + const accountOptions = useMemo(() => { return accounts.map((account) => ({ value: account.id, label: accountLabel(account) })); }, [accounts]); @@ -237,16 +276,34 @@ export default function HuyaTasksPage() { )); }, [goods]); - const goodsOptions = useMemo(() => { - return sortedGoods.map((item) => ({ - value: item.product_id, - label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`, - })); - }, [sortedGoods]); + const sortedRechargeGoods = useMemo(() => { + return [...rechargeGoods].sort((a, b) => { + const aOrder = goodsRawNumber({ raw: a.raw } as HuyaGoodsItem, 'raw_order'); + const bOrder = goodsRawNumber({ raw: b.raw } as HuyaGoodsItem, 'raw_order'); + return aOrder - bOrder || a.id - b.id; + }); + }, [rechargeGoods]); - const selectedGoods = useMemo(() => { - return sortedGoods.find((item) => item.product_id === selectedGoodsId) || null; - }, [sortedGoods, selectedGoodsId]); + const rechargeGoodsOptions = useMemo(() => { + return sortedRechargeGoods.map((item) => ({ + value: item.spu_id, + label: `${item.name || item.spu_id}${item.price ? ` / ${formatPriceText(item.price)}` : ''}`, + })); + }, [sortedRechargeGoods]); + + const selectedRechargeGoods = useMemo(() => { + return sortedRechargeGoods.find((item) => item.spu_id === selectedRechargeGoodsId) || null; + }, [sortedRechargeGoods, selectedRechargeGoodsId]); + + useEffect(() => { + if (sortedRechargeGoods.length === 0) { + if (selectedRechargeGoodsId) setSelectedRechargeGoodsId(''); + return; + } + if (!sortedRechargeGoods.some((item) => item.spu_id === selectedRechargeGoodsId)) { + setSelectedRechargeGoodsId(sortedRechargeGoods[0].spu_id); + } + }, [selectedRechargeGoodsId, sortedRechargeGoods]); const goodsCategories = useMemo(() => { const map = new Map(); @@ -283,11 +340,13 @@ export default function HuyaTasksPage() { }, [sortedGoods, selectedGoodsCategory]); const createPayload = (taskType: string) => { - if (taskType !== 'recharge_points') return {}; + if (taskType !== 'create_recharge_order') return {}; return { - product_id: selectedGoods?.product_id || selectedGoodsId, - product_name: selectedGoods?.name || '', + spu_id: selectedRechargeGoods?.spu_id || selectedRechargeGoodsId, + sku_id: selectedRechargeGoods?.sku_id || '', + product_name: selectedRechargeGoods?.name || '', count: rechargeCount, + pay_channel: rechargePayChannel, }; }; @@ -296,7 +355,7 @@ export default function HuyaTasksPage() { message.warning('请先选择虎牙 CK'); return; } - if (taskType === 'recharge_points' && !selectedGoodsId) { + if (taskType === 'create_recharge_order' && !selectedRechargeGoodsId) { message.warning('请先选择充值商品'); return; } @@ -312,7 +371,7 @@ export default function HuyaTasksPage() { const finishTask = () => { setBatchId(null); setStarting(false); - if (taskType === 'refresh_goods') { + if (taskType === 'refresh_goods' || taskType === 'refresh_recharge_goods') { void loadAll(); } else { void loadTasks(); @@ -354,6 +413,15 @@ export default function HuyaTasksPage() { const qrAccountName = qrTask ? resultProfileNick(qrResult) || qrTask.account_nickname || qrTask.account_uid || `#${qrTask.account_id}` : ''; + const payResult = payTask?.result || null; + const payUrl = resultText(payResult, 'pay_url'); + const payProductName = resultText(payResult, 'product_name'); + const payAmountText = resultText(payResult, 'amount_text'); + const payChannelLabel = resultText(payResult, 'pay_channel_label'); + const payOrderId = payResult?.order_id; + const payAccountName = payTask + ? payTask.account_nickname || payTask.account_uid || `#${payTask.account_id}` + : ''; const renderTaskResult = (value: Record | null, record: HuyaTaskItem) => { const bindQrImage = resultText(value, 'mini_qrcode_image'); @@ -400,6 +468,29 @@ export default function HuyaTasksPage() { if (typeof goodsCount === 'number') return 商品 {goodsCount} 个; return -; } + if (record.task_type === 'refresh_recharge_goods') { + const goodsCount = value?.goods_count; + const failedCount = value?.failed_count; + if (typeof goodsCount === 'number') { + return ( + + 充值商品 {goodsCount} 个 + {typeof failedCount === 'number' && failedCount > 0 ? 失败 {failedCount} : null} + + ); + } + return -; + } + if (record.task_type === 'create_recharge_order') { + if (resultText(value, 'pay_url')) { + return ( + + ); + } + return -; + } const availableScore = value?.available_score; if (typeof availableScore === 'number') { @@ -479,6 +570,39 @@ export default function HuyaTasksPage() { }, ]; + const rechargeGoodsColumns: TableProps['columns'] = [ + { title: 'SPU', dataIndex: 'spu_id', width: 120, ellipsis: true }, + { title: 'SKU', dataIndex: 'sku_id', width: 100, ellipsis: true }, + { title: '名称', dataIndex: 'name', ellipsis: true }, + { + title: '单价', + dataIndex: 'price', + width: 90, + align: 'center', + render: (value: number | null) => formatPriceText(value) || -, + }, + { + title: '库存', + dataIndex: 'stock', + width: 90, + align: 'center', + render: (value: number | null) => value ?? -, + }, + { + title: '来源', + dataIndex: 'task_name', + width: 140, + ellipsis: true, + render: (value: string) => value || 商品详情, + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 160, + render: (value: string | null) => value ? formatTime(value) : -, + }, + ]; + return (
@@ -530,8 +654,8 @@ export default function HuyaTasksPage() { setSelectedGoodsId(value || '')} - options={goodsOptions} - style={{ minWidth: 240 }} - filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())} - /> - setRechargeCount(value || 1)} - addonAfter="份" - style={{ width: 120 }} - /> - + 兑换商品列表} style={{ marginBottom: 12 }}> {goodsCategories.length > 0 && ( + + + 充值商品列表} + extra={( + + + + + )} + style={{ marginBottom: 12 }} + > + + + + ({ + onClick: () => setSelectedRechargeGoodsId(record.spu_id), + })} + rowClassName={(record) => record.spu_id === selectedRechargeGoodsId ? 'ant-table-row-selected' : ''} /> @@ -640,7 +814,18 @@ export default function HuyaTasksPage() {
{QUICK_ACTIONS.map((item) => ( - +
@@ -725,6 +910,35 @@ export default function HuyaTasksPage() { )} + + setPayTask(null)} + footer={null} + width={380} + > + {payTask && ( +
+ + {payProductName || '充值商品'} + + {payChannelLabel || '支付'}{payAmountText ? ` / ${payAmountText}` : ''}{payAccountName ? ` / ${payAccountName}` : ''} + + {typeof payOrderId === 'number' || typeof payOrderId === 'string' ? ( + 订单号 {String(payOrderId)} + ) : null} + + {payUrl ? ( +
+ +
+ ) : ( + 暂无支付二维码 + )} +
+ )} +
); }