实现虎牙充值商品列表与支付二维码

This commit is contained in:
yml2213
2026-07-04 23:48:53 +08:00
parent fa0d49ff8c
commit 5513b5a313
12 changed files with 1209 additions and 61 deletions
+194
View File
@@ -120,6 +120,200 @@ class GetActPrizeListReq(TafStruct):
self.sid = ins.read_int32(1, default=self.sid) 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): class ActPrizeItem(TafStruct):
"""活动商品项,字段位置来自 getActPrizeList 实测响应。""" """活动商品项,字段位置来自 getActPrizeList 实测响应。"""
+27 -1
View File
@@ -286,6 +286,22 @@ class HuyaHttpClient:
timeout=timeout, 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( def check_user_bind_game_account(
self, self,
uid: int, uid: int,
@@ -593,6 +609,16 @@ class HuyaHttpClient:
self.logger(f" orderType={ot}: 无响应") self.logger(f" orderType={ot}: 无响应")
return last_result 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, def pay_order_submit(self, uid, guid, cookie, order_id, pay_type=1,
pid=0, source_id="yellowcarlist", scene=7, pid=0, source_id="yellowcarlist", scene=7,
item_count=1): item_count=1):
@@ -604,7 +630,7 @@ class HuyaHttpClient:
os.write_struct(0, self._build_user(uid, guid, cookie)) os.write_struct(0, self._build_user(uid, guid, cookie))
os.write_struct(1, self._build_shop_app(source_id, scene)) os.write_struct(1, self._build_shop_app(source_id, scene))
os.write_int64(2, order_id) 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") os.write_string(4, "QrCode")
callback_url = ( callback_url = (
f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback" f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback"
+223 -3
View File
@@ -223,11 +223,11 @@ class GetGoodsInfoReqV5(TafStruct):
class GoodsInfoRsp(TafStruct): class GoodsInfoRsp(TafStruct):
"""商品查询响应(简化:只取关键字段)""" """商品查询响应"""
def __init__(self): def __init__(self):
self.code: int = 0 # tag 0 self.code: int = 0 # tag 0
self.message: str = "" # tag 1 self.message: str = "" # tag 1
# tag 2 是 goodsInfo 结构,简化跳过 self.goodsInfo = None # tag 2
self.selfGoods: int = 0 # tag 3 self.selfGoods: int = 0 # tag 3
self.marketStatus: int = 0 # tag 5 self.marketStatus: int = 0 # tag 5
self.timestamp: int = 0 # tag 6 self.timestamp: int = 0 # tag 6
@@ -235,7 +235,7 @@ class GoodsInfoRsp(TafStruct):
def read_from(self, ins: TafInputStream): def read_from(self, ins: TafInputStream):
self.code = ins.read_int32(0, default=self.code) self.code = ins.read_int32(0, default=self.code)
self.message = ins.read_string(1, default=self.message) 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.selfGoods = ins.read_int32(3, default=self.selfGoods)
self.marketStatus = ins.read_int32(5, default=self.marketStatus) self.marketStatus = ins.read_int32(5, default=self.marketStatus)
self.timestamp = ins.read_int64(6, default=self.timestamp) self.timestamp = ins.read_int64(6, default=self.timestamp)
@@ -243,6 +243,215 @@ class GoodsInfoRsp(TafStruct):
def write_to(self, os: TafOutputStream): def write_to(self, os: TafOutputStream):
pass 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): def write_to(self, os: TafOutputStream):
pass 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,
}
@@ -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")
+19
View File
@@ -147,6 +147,25 @@ class HuyaGoodsSnapshot(Base):
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) 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): class ProxyConfig(Base):
"""代理配置(全局单条记录)""" """代理配置(全局单条记录)"""
__tablename__ = "proxy_config" __tablename__ = "proxy_config"
+12 -1
View File
@@ -9,13 +9,14 @@ from sqlalchemy.orm import Session, joinedload
from ..database import SessionLocal, get_db from ..database import SessionLocal, get_db
from ..deps import authenticate_websocket, require_permission 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 ( from ..schemas import (
HuyaAccountOut, HuyaAccountOut,
HuyaConfigOut, HuyaConfigOut,
HuyaConfigUpdate, HuyaConfigUpdate,
HuyaCookieImport, HuyaCookieImport,
HuyaGoodsOut, HuyaGoodsOut,
HuyaRechargeGoodsOut,
HuyaTaskBatchRequest, HuyaTaskBatchRequest,
HuyaTaskOut, HuyaTaskOut,
) )
@@ -200,6 +201,16 @@ def list_goods(
return rows 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") @router.post("/tasks/batch")
async def create_task_batch( async def create_task_batch(
req: HuyaTaskBatchRequest, req: HuyaTaskBatchRequest,
+36
View File
@@ -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): class ProxyConfigOut(BaseModel):
enabled: bool = False enabled: bool = False
+334 -1
View File
@@ -11,10 +11,24 @@ from sqlalchemy.orm import Session, joinedload
from core.huya import HuyaHttpClient from core.huya import HuyaHttpClient
from ..database import SessionLocal 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 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: class HuyaBatchRunner:
"""批量执行虎牙任务,通过队列推送实时日志。""" """批量执行虎牙任务,通过队列推送实时日志。"""
@@ -264,6 +278,319 @@ class HuyaBatchRunner:
message = f"已刷新商品 {len(goods)}" message = f"已刷新商品 {len(goods)}"
self._mark_task(worker_db, task, "success", message, {**result, "goods": 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( def _execute_get_bind_qr(
self, self,
worker_db: Session, worker_db: Session,
@@ -629,6 +956,8 @@ class HuyaBatchRunner:
"confirm_bind", "confirm_bind",
"query_game_name", "query_game_name",
"refresh_goods", "refresh_goods",
"refresh_recharge_goods",
"create_recharge_order",
}: }:
self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现") self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现")
self._push_log("warning", f"[{current}] {name} 暂未实现: {self.task_type}") 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) self._execute_query_points(worker_db, task, account, account_info, config_info)
elif self.task_type == "refresh_goods": elif self.task_type == "refresh_goods":
self._execute_refresh_goods(worker_db, task, account, account_info, config_info) 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": elif self.task_type == "get_bind_qr":
self._execute_get_bind_qr(worker_db, task, account, account_info, config_info) self._execute_get_bind_qr(worker_db, task, account, account_info, config_info)
elif self.task_type == "confirm_bind": elif self.task_type == "confirm_bind":
+4 -4
View File
@@ -13,12 +13,12 @@ from ..models import HuyaAccount, HuyaConfig, HuyaTask
SUPPORTED_TASK_TYPES = { SUPPORTED_TASK_TYPES = {
"get_bind_qr": "获取绑定二维码", "get_bind_qr": "获取绑定二维码",
"query_points": "一键查询积分", "query_points": "一键查询积分",
"open_elite_book": "开通精英宝典",
"recharge_points": "充值积分",
"query_game_name": "一键查询游戏名", "query_game_name": "一键查询游戏名",
"query_exchange_records": "一键查询兑换记录", "query_exchange_records": "一键查询兑换记录",
"confirm_bind": "确认绑定", "confirm_bind": "确认绑定",
"refresh_goods": "刷新商品列表", "refresh_goods": "刷新商品列表",
"refresh_recharge_goods": "刷新充值商品列表",
"create_recharge_order": "生成支付二维码",
} }
@@ -172,8 +172,8 @@ def create_planned_tasks(
batch_id = uuid.uuid4().hex[:12] batch_id = uuid.uuid4().hex[:12]
payload = payload or {} payload = payload or {}
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all() accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
if task_type == "refresh_goods" and accounts: if task_type in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"} and accounts:
# 商品列表是全局快照,使用一个可用 CK 刷新即可。 # 全局快照和单笔支付二维码都使用一个选中的 CK 即可。
accounts = accounts[:1] accounts = accounts[:1]
for account in accounts: for account in accounts:
db.add(HuyaTask( db.add(HuyaTask(
+2
View File
@@ -4,6 +4,7 @@ import type {
HuyaConfig, HuyaConfig,
HuyaCookieImportResult, HuyaCookieImportResult,
HuyaGoodsItem, HuyaGoodsItem,
HuyaRechargeGoodsItem,
HuyaTaskBatchRequest, HuyaTaskBatchRequest,
HuyaTaskBatchResult, HuyaTaskBatchResult,
HuyaTaskItem, HuyaTaskItem,
@@ -23,6 +24,7 @@ export const huyaApi = {
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'), getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data), updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'), listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
listRechargeGoods: () => api.get<HuyaRechargeGoodsItem[], HuyaRechargeGoodsItem[]>('/huya/recharge-goods'),
createTasks: (data: HuyaTaskBatchRequest) => createTasks: (data: HuyaTaskBatchRequest) =>
api.post<HuyaTaskBatchResult, HuyaTaskBatchResult>('/huya/tasks/batch', data), api.post<HuyaTaskBatchResult, HuyaTaskBatchResult>('/huya/tasks/batch', data),
listTasks: (batchId?: string) => listTasks: (batchId?: string) =>
+16
View File
@@ -183,6 +183,22 @@ export interface HuyaGoodsItem {
updated_at: string | null; 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<string, unknown> | null;
updated_at: string | null;
}
// ==================== Proxy ==================== // ==================== Proxy ====================
export interface ProxyConfig { export interface ProxyConfig {
+265 -51
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { 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'; } from 'antd';
import type { TableProps } from 'antd'; import type { TableProps } from 'antd';
import { import {
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined, AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined,
LinkOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, LinkOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
@@ -12,6 +12,7 @@ import {
type HuyaAccountItem, type HuyaAccountItem,
type HuyaConfig, type HuyaConfig,
type HuyaGoodsItem, type HuyaGoodsItem,
type HuyaRechargeGoodsItem,
type HuyaTaskItem, type HuyaTaskItem,
} from '../api/modules'; } from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel'; import RealtimeLogPanel from '../components/RealtimeLogPanel';
@@ -26,22 +27,22 @@ const FALLBACK_TASK_TYPES: Record<string, string> = {
get_bind_qr: '获取绑定二维码', get_bind_qr: '获取绑定二维码',
confirm_bind: '确认绑定', confirm_bind: '确认绑定',
query_points: '一键查询积分', query_points: '一键查询积分',
open_elite_book: '开通精英宝典',
recharge_points: '充值积分',
query_game_name: '一键查询游戏名', query_game_name: '一键查询游戏名',
query_exchange_records: '一键查询兑换记录', query_exchange_records: '一键查询兑换记录',
refresh_goods: '刷新商品列表', refresh_goods: '刷新商品列表',
refresh_recharge_goods: '刷新充值商品列表',
create_recharge_order: '生成支付二维码',
}; };
const QUICK_ACTIONS = [ const QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <LinkOutlined /> }, { key: 'get_bind_qr', icon: <LinkOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> }, { key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_points', icon: <SearchOutlined /> }, { key: 'query_points', icon: <SearchOutlined /> },
{ key: 'open_elite_book', icon: <GiftOutlined /> },
{ key: 'recharge_points', icon: <CreditCardOutlined /> },
{ key: 'query_game_name', icon: <AppstoreOutlined /> }, { key: 'query_game_name', icon: <AppstoreOutlined /> },
{ key: 'query_exchange_records', icon: <FieldTimeOutlined /> }, { key: 'query_exchange_records', icon: <FieldTimeOutlined /> },
{ key: 'refresh_goods', icon: <ReloadOutlined /> }, { key: 'refresh_goods', icon: <ReloadOutlined /> },
{ key: 'refresh_recharge_goods', icon: <ShoppingOutlined /> },
{ key: 'create_recharge_order', icon: <CreditCardOutlined /> },
]; ];
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
@@ -115,22 +116,33 @@ function formatRemainText(value: string): string {
return /^-?\d+(\.\d+)?$/.test(text) ? `${text}%` : text; 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 { function hasMiniQrcode(task: HuyaTaskItem): boolean {
return task.task_type === 'get_bind_qr' && Boolean(resultText(task.result, 'mini_qrcode_image')); 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() { export default function HuyaTasksPage() {
const { token } = theme.useToken(); const { token } = theme.useToken();
const [form] = Form.useForm<HuyaConfig>(); const [form] = Form.useForm<HuyaConfig>();
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]); const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]); const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]); const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
const [rechargeGoods, setRechargeGoods] = useState<HuyaRechargeGoodsItem[]>([]);
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES); const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectedTaskType, setSelectedTaskType] = useState('query_points'); const [selectedTaskType, setSelectedTaskType] = useState('query_points');
const [selectedGoodsId, setSelectedGoodsId] = useState<string>('');
const [selectedGoodsCategory, setSelectedGoodsCategory] = useState(''); const [selectedGoodsCategory, setSelectedGoodsCategory] = useState('');
const [selectedRechargeGoodsId, setSelectedRechargeGoodsId] = useState<string>('');
const [rechargeCount, setRechargeCount] = useState(1); const [rechargeCount, setRechargeCount] = useState(1);
const [rechargePayChannel, setRechargePayChannel] = useState('Weixin');
const [concurrency, setConcurrency] = useState(() => { const [concurrency, setConcurrency] = useState(() => {
const v = localStorage.getItem('huya_task_concurrency'); const v = localStorage.getItem('huya_task_concurrency');
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3; 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 [savingConfig, setSavingConfig] = useState(false);
const [batchId, setBatchId] = useState<string | null>(null); const [batchId, setBatchId] = useState<string | null>(null);
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null); const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
const [payTask, setPayTask] = useState<HuyaTaskItem | null>(null);
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set()); const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
const autoOpenQrReady = useRef(false); const autoOpenQrReady = useRef(false);
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
const autoOpenPayReady = useRef(false);
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
const { can } = usePermissions(); const { can } = usePermissions();
@@ -158,18 +173,30 @@ export default function HuyaTasksPage() {
autoOpenQrReady.current = true; 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) => { const openQrTask = useCallback((task: HuyaTaskItem) => {
autoOpenedQrTaskIds.current.add(task.id); autoOpenedQrTaskIds.current.add(task.id);
setQrTask(task); setQrTask(task);
}, []); }, []);
const openPayTask = useCallback((task: HuyaTaskItem) => {
autoOpenedPayTaskIds.current.add(task.id);
setPayTask(task);
}, []);
const loadAll = useCallback(async () => { const loadAll = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [accountResult, taskResult, goodsResult, configResult, taskTypeResult] = await Promise.allSettled([ const [accountResult, taskResult, goodsResult, rechargeGoodsResult, configResult, taskTypeResult] = await Promise.allSettled([
huyaApi.listAccounts(), huyaApi.listAccounts(),
huyaApi.listTasks(), huyaApi.listTasks(),
huyaApi.listGoods(), huyaApi.listGoods(),
huyaApi.listRechargeGoods(),
canConfig ? huyaApi.getConfig() : Promise.resolve(null), canConfig ? huyaApi.getConfig() : Promise.resolve(null),
huyaApi.taskTypes(), huyaApi.taskTypes(),
]); ]);
@@ -177,16 +204,19 @@ export default function HuyaTasksPage() {
if (accountResult.status === 'fulfilled') setAccounts(accountResult.value); if (accountResult.status === 'fulfilled') setAccounts(accountResult.value);
if (taskResult.status === 'fulfilled') { if (taskResult.status === 'fulfilled') {
rememberExistingQrcodes(taskResult.value); rememberExistingQrcodes(taskResult.value);
rememberExistingPaymentQrcodes(taskResult.value);
setTasks(taskResult.value); setTasks(taskResult.value);
} }
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.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 (configResult.status === 'fulfilled' && configResult.value) form.setFieldsValue(configResult.value);
if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value }); if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value });
const failedLabels = [ const failedLabels = [
accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '', accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '',
taskResult.status === 'rejected' ? `任务记录: ${getErrorMessage(taskResult.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)}` : '', configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '',
taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '', taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '',
].filter(Boolean); ].filter(Boolean);
@@ -196,17 +226,18 @@ export default function HuyaTasksPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [canConfig, form, rememberExistingQrcodes]); }, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
try { try {
const data = await huyaApi.listTasks(); const data = await huyaApi.listTasks();
rememberExistingQrcodes(data); rememberExistingQrcodes(data);
rememberExistingPaymentQrcodes(data);
setTasks(data); setTasks(data);
} catch { } catch {
// 轮询失败不打扰操作,下一轮继续刷新。 // 轮询失败不打扰操作,下一轮继续刷新。
} }
}, [rememberExistingQrcodes]); }, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
useEffect(() => { useEffect(() => {
loadAll(); loadAll();
@@ -225,6 +256,14 @@ export default function HuyaTasksPage() {
if (nextQrTask) openQrTask(nextQrTask); if (nextQrTask) openQrTask(nextQrTask);
}, [openQrTask, qrTask, tasks]); }, [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(() => { const accountOptions = useMemo(() => {
return accounts.map((account) => ({ value: account.id, label: accountLabel(account) })); return accounts.map((account) => ({ value: account.id, label: accountLabel(account) }));
}, [accounts]); }, [accounts]);
@@ -237,16 +276,34 @@ export default function HuyaTasksPage() {
)); ));
}, [goods]); }, [goods]);
const goodsOptions = useMemo(() => { const sortedRechargeGoods = useMemo(() => {
return sortedGoods.map((item) => ({ return [...rechargeGoods].sort((a, b) => {
value: item.product_id, const aOrder = goodsRawNumber({ raw: a.raw } as HuyaGoodsItem, 'raw_order');
label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`, const bOrder = goodsRawNumber({ raw: b.raw } as HuyaGoodsItem, 'raw_order');
})); return aOrder - bOrder || a.id - b.id;
}, [sortedGoods]); });
}, [rechargeGoods]);
const selectedGoods = useMemo(() => { const rechargeGoodsOptions = useMemo(() => {
return sortedGoods.find((item) => item.product_id === selectedGoodsId) || null; return sortedRechargeGoods.map((item) => ({
}, [sortedGoods, selectedGoodsId]); 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 goodsCategories = useMemo(() => {
const map = new Map<string, { key: string; label: string; sort: number; count: number }>(); const map = new Map<string, { key: string; label: string; sort: number; count: number }>();
@@ -283,11 +340,13 @@ export default function HuyaTasksPage() {
}, [sortedGoods, selectedGoodsCategory]); }, [sortedGoods, selectedGoodsCategory]);
const createPayload = (taskType: string) => { const createPayload = (taskType: string) => {
if (taskType !== 'recharge_points') return {}; if (taskType !== 'create_recharge_order') return {};
return { return {
product_id: selectedGoods?.product_id || selectedGoodsId, spu_id: selectedRechargeGoods?.spu_id || selectedRechargeGoodsId,
product_name: selectedGoods?.name || '', sku_id: selectedRechargeGoods?.sku_id || '',
product_name: selectedRechargeGoods?.name || '',
count: rechargeCount, count: rechargeCount,
pay_channel: rechargePayChannel,
}; };
}; };
@@ -296,7 +355,7 @@ export default function HuyaTasksPage() {
message.warning('请先选择虎牙 CK'); message.warning('请先选择虎牙 CK');
return; return;
} }
if (taskType === 'recharge_points' && !selectedGoodsId) { if (taskType === 'create_recharge_order' && !selectedRechargeGoodsId) {
message.warning('请先选择充值商品'); message.warning('请先选择充值商品');
return; return;
} }
@@ -312,7 +371,7 @@ export default function HuyaTasksPage() {
const finishTask = () => { const finishTask = () => {
setBatchId(null); setBatchId(null);
setStarting(false); setStarting(false);
if (taskType === 'refresh_goods') { if (taskType === 'refresh_goods' || taskType === 'refresh_recharge_goods') {
void loadAll(); void loadAll();
} else { } else {
void loadTasks(); void loadTasks();
@@ -354,6 +413,15 @@ export default function HuyaTasksPage() {
const qrAccountName = qrTask const qrAccountName = qrTask
? resultProfileNick(qrResult) || qrTask.account_nickname || qrTask.account_uid || `#${qrTask.account_id}` ? 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<string, unknown> | null, record: HuyaTaskItem) => { const renderTaskResult = (value: Record<string, unknown> | null, record: HuyaTaskItem) => {
const bindQrImage = resultText(value, 'mini_qrcode_image'); const bindQrImage = resultText(value, 'mini_qrcode_image');
@@ -400,6 +468,29 @@ export default function HuyaTasksPage() {
if (typeof goodsCount === 'number') return <Tag color="green"> {goodsCount} </Tag>; if (typeof goodsCount === 'number') return <Tag color="green"> {goodsCount} </Tag>;
return <Text type="secondary">-</Text>; return <Text type="secondary">-</Text>;
} }
if (record.task_type === 'refresh_recharge_goods') {
const goodsCount = value?.goods_count;
const failedCount = value?.failed_count;
if (typeof goodsCount === 'number') {
return (
<Space size={6}>
<Tag color="green"> {goodsCount} </Tag>
{typeof failedCount === 'number' && failedCount > 0 ? <Tag color="orange"> {failedCount}</Tag> : null}
</Space>
);
}
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'create_recharge_order') {
if (resultText(value, 'pay_url')) {
return (
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openPayTask(record)}>
</Button>
);
}
return <Text type="secondary">-</Text>;
}
const availableScore = value?.available_score; const availableScore = value?.available_score;
if (typeof availableScore === 'number') { if (typeof availableScore === 'number') {
@@ -479,6 +570,39 @@ export default function HuyaTasksPage() {
}, },
]; ];
const rechargeGoodsColumns: TableProps<HuyaRechargeGoodsItem>['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) || <Text type="secondary">-</Text>,
},
{
title: '库存',
dataIndex: 'stock',
width: 90,
align: 'center',
render: (value: number | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: '来源',
dataIndex: 'task_name',
width: 140,
ellipsis: true,
render: (value: string) => value || <Text type="secondary"></Text>,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 160,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flexShrink: 0, marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}> <div style={{ flexShrink: 0, marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
@@ -530,8 +654,8 @@ export default function HuyaTasksPage() {
<Form.Item label="支付渠道" name="pay_channel"> <Form.Item label="支付渠道" name="pay_channel">
<Select <Select
options={[ options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' }, { value: 'Zfb', label: '支付宝' },
{ value: 'Wx', label: '微信' },
]} ]}
/> />
</Form.Item> </Form.Item>
@@ -540,27 +664,7 @@ export default function HuyaTasksPage() {
</Form> </Form>
</Card> </Card>
<Card size="small" title={<Space><ShoppingOutlined /></Space>} style={{ marginBottom: 12 }}> <Card size="small" title={<Space><ShoppingOutlined /></Space>} style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 8 }} wrap>
<Select
showSearch
allowClear
placeholder="选择充值商品"
value={selectedGoodsId || undefined}
onChange={(value) => setSelectedGoodsId(value || '')}
options={goodsOptions}
style={{ minWidth: 240 }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<InputNumber
min={1}
max={99}
value={rechargeCount}
onChange={(value) => setRechargeCount(value || 1)}
addonAfter="份"
style={{ width: 120 }}
/>
</Space>
{goodsCategories.length > 0 && ( {goodsCategories.length > 0 && (
<Tabs <Tabs
size="small" size="small"
@@ -580,8 +684,78 @@ export default function HuyaTasksPage() {
rowKey="id" rowKey="id"
size="small" size="small"
pagination={false} pagination={false}
scroll={{ x: 760, y: 220 }} scroll={{ y: 220 }}
locale={{ emptyText: '暂无商品快照,请先刷新商品列表' }} locale={{ emptyText: '暂无兑换商品,请先刷新商品列表' }}
/>
</Card>
<Card
size="small"
title={<Space><CreditCardOutlined /></Space>}
extra={(
<Space size={6}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => startTask('refresh_recharge_goods')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
<Button
size="small"
type="primary"
icon={<QrcodeOutlined />}
onClick={() => startTask('create_recharge_order')}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
</Button>
</Space>
)}
style={{ marginBottom: 12 }}
>
<Space style={{ marginBottom: 8 }} wrap>
<Select
showSearch
allowClear
placeholder="选择充值商品"
value={selectedRechargeGoodsId || undefined}
onChange={(value) => setSelectedRechargeGoodsId(value || '')}
options={rechargeGoodsOptions}
style={{ minWidth: 220 }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<InputNumber
min={1}
max={999}
value={rechargeCount}
onChange={(value) => setRechargeCount(value || 1)}
addonAfter="份"
style={{ width: 120 }}
/>
<Select
value={rechargePayChannel}
onChange={setRechargePayChannel}
options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' },
]}
style={{ width: 110 }}
/>
</Space>
<Table
columns={rechargeGoodsColumns}
dataSource={sortedRechargeGoods}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 220 }}
locale={{ emptyText: '暂无充值商品,请先刷新充值商品列表' }}
onRow={(record) => ({
onClick: () => setSelectedRechargeGoodsId(record.spu_id),
})}
rowClassName={(record) => record.spu_id === selectedRechargeGoodsId ? 'ant-table-row-selected' : ''}
/> />
</Card> </Card>
</Col> </Col>
@@ -640,7 +814,18 @@ export default function HuyaTasksPage() {
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{QUICK_ACTIONS.map((item) => ( {QUICK_ACTIONS.map((item) => (
<Tooltip key={item.key} title={item.key === 'refresh_goods' ? '使用一个选中的 CK 刷新当前 SID 商品快照' : undefined}> <Tooltip
key={item.key}
title={
item.key === 'refresh_goods'
? '使用一个选中的 CK 刷新当前 SID 兑换商品'
: item.key === 'refresh_recharge_goods'
? '使用一个选中的 CK 刷新充值商品列表'
: item.key === 'create_recharge_order'
? '按左侧选择生成扫码支付二维码'
: undefined
}
>
<Button <Button
icon={item.icon} icon={item.icon}
size="small" size="small"
@@ -653,7 +838,7 @@ export default function HuyaTasksPage() {
))} ))}
</div> </div>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
</Text> </Text>
</div> </div>
</Card> </Card>
@@ -725,6 +910,35 @@ export default function HuyaTasksPage() {
</div> </div>
)} )}
</Modal> </Modal>
<Modal
title="支付二维码"
open={!!payTask}
onCancel={() => setPayTask(null)}
footer={null}
width={380}
>
{payTask && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '8px 0 12px' }}>
<Space direction="vertical" size={2} style={{ width: '100%', textAlign: 'center' }}>
<Text strong>{payProductName || '充值商品'}</Text>
<Text type="secondary">
{payChannelLabel || '支付'}{payAmountText ? ` / ${payAmountText}` : ''}{payAccountName ? ` / ${payAccountName}` : ''}
</Text>
{typeof payOrderId === 'number' || typeof payOrderId === 'string' ? (
<Text type="secondary" style={{ fontSize: 12 }}> {String(payOrderId)}</Text>
) : null}
</Space>
{payUrl ? (
<div style={{ padding: 14, borderRadius: 10, background: '#fff', lineHeight: 0 }}>
<QRCode value={payUrl} size={260} bordered={false} color="#000" bgColor="#fff" />
</div>
) : (
<Text type="secondary"></Text>
)}
</div>
)}
</Modal>
</div> </div>
); );
} }