refactor: 收口跨平台重复业务实现

This commit is contained in:
yml
2026-11-13 20:35:38 +08:00
parent ad782e1954
commit f0abca3d58
25 changed files with 1386 additions and 724 deletions
+122 -133
View File
@@ -286,6 +286,15 @@ class HuyaHttpClient:
info.sourceId = source_id
return info
@staticmethod
def _build_play_mall_app(source_id: str = "yellowcarlist"):
"""构造 PlayMallUI 请求的 PlayMallAppInfo。"""
from .shop_structs import PlayMallAppInfo
info = PlayMallAppInfo()
info.sourceId = source_id
return info
@staticmethod
def _build_order_env():
"""构造下单/支付环境参数。
@@ -462,16 +471,17 @@ class HuyaHttpClient:
self, uid: int, cookie: str, act_id: int, timeout: float = 15.0
):
"""读取当前用户任务状态,而不是只读取任务定义。"""
from .activity_structs import GetActUserTaskDetailReq, GetActUserTaskDetailResp
from .rpc_requests import activity_user_task_detail_request
req = GetActUserTaskDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.actId = int(act_id or 0)
rpc = activity_user_task_detail_request(
self._build_activity_user(uid, cookie),
act_id,
)
return self.call_rpc(
"webActUI",
"getActUserTaskDetail",
req,
GetActUserTaskDetailResp,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid=uid,
cookie=cookie,
timeout=timeout,
@@ -479,16 +489,17 @@ class HuyaHttpClient:
def query_user_score(self, uid: int, cookie: str, sid: int, timeout: float = 15.0):
"""查询活动可用积分。"""
from .activity_structs import GetUserScoreReq, GetUserScoreResp
from .rpc_requests import activity_score_request
req = GetUserScoreReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = sid
rpc = activity_score_request(
self._build_activity_user(uid, cookie),
sid,
)
return self.call_rpc(
"webActUI",
"getUserScore",
req,
GetUserScoreResp,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid=uid,
cookie=cookie,
timeout=timeout,
@@ -498,16 +509,17 @@ class HuyaHttpClient:
self, uid: int, cookie: str, sid: int, timeout: float = 15.0
):
"""查询用户兑换记录。"""
from .activity_structs import GetUserPrizeRecordsReq, GetUserPrizeRecordsResp
from .rpc_requests import activity_records_request
req = GetUserPrizeRecordsReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = sid
rpc = activity_records_request(
self._build_activity_user(uid, cookie),
sid,
)
return self.call_rpc(
"webActUI",
"getUserPrizeRecords",
req,
GetUserPrizeRecordsResp,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid=uid,
cookie=cookie,
timeout=timeout,
@@ -517,17 +529,18 @@ class HuyaHttpClient:
self, uid: int, cookie: str, sid: int, pid: int, timeout: float = 15.0
):
"""兑换活动积分商品。"""
from .activity_structs import ScoreExchangePrizeReq, ScoreExchangePrizeResp
from .rpc_requests import activity_exchange_request
req = ScoreExchangePrizeReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
req.pid = int(pid or 0)
rpc = activity_exchange_request(
self._build_activity_user(uid, cookie),
sid,
pid,
)
return self.call_rpc(
"webActUI",
"scoreExchangePrize",
req,
ScoreExchangePrizeResp,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid=uid,
cookie=cookie,
timeout=timeout,
@@ -537,17 +550,18 @@ class HuyaHttpClient:
self, uid: int, cookie: str, sid: int, pid: int, timeout: float = 15.0
):
"""查询单个兑换奖品详情和服务端限制。"""
from .activity_structs import GetActPrizeDetailReq, GetActPrizeDetailResp
from .rpc_requests import activity_prize_detail_request
req = GetActPrizeDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
req.pid = int(pid or 0)
rpc = activity_prize_detail_request(
self._build_activity_user(uid, cookie),
sid,
pid,
)
return self.call_rpc(
"webActUI",
"getActPrizeDetail",
req,
GetActPrizeDetailResp,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid=uid,
cookie=cookie,
timeout=timeout,
@@ -557,16 +571,17 @@ class HuyaHttpClient:
self, uid: int, cookie: str, act_id: int, timeout: float = 15.0
):
"""查询活动任务详情,用于发现充值商品 SPU。"""
from .activity_structs import GetActTaskDetailReq, GetActTaskDetailResp
from .rpc_requests import activity_task_detail_request
req = GetActTaskDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.actId = int(act_id or 0)
rpc = activity_task_detail_request(
self._build_activity_user(uid, cookie),
act_id,
)
return self.call_rpc(
"webActUI",
"getActTaskDetail",
req,
GetActTaskDetailResp,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid=uid,
cookie=cookie,
timeout=timeout,
@@ -922,17 +937,24 @@ class HuyaHttpClient:
source_id="yellowcarlist",
app_scene=SHOP_APP_SCENE,
):
from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp
from .rpc_requests import goods_info_request
req = GetGoodsInfoReqV5()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, app_scene)
req.pid = pid
req.spuId = spu_id
req.skuId = sku_id
req.gameId = game_id
rpc = goods_info_request(
self._build_shop_user(uid, cookie),
self._build_shop_app(source_id, app_scene),
pid=pid,
spu_id=spu_id,
sku_id=sku_id,
game_id=game_id,
)
return self.call_rpc(
"shopMiddleUI", "getGoodsInfoV5", req, GoodsInfoRsp, uid, guid, cookie
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid,
guid,
cookie,
)
def list_pay_channels(
@@ -1156,45 +1178,23 @@ class HuyaHttpClient:
2026-11-13 HAR:积分足够时官方只发这一单,不再调用
``scoreExchangePrize`` 或 ``payOrderSubmitV5``。
"""
from .shop_structs import (
CreateOrderAccountParam,
CreateOrderExtraParam,
CreateOrderItemReqV6,
CreateOrderPromotionParam,
CreateOrderReqV6,
CreateOrderRspV6,
from .rpc_requests import create_order_v6_request
rpc = create_order_v6_request(
self._build_shop_user(uid, cookie),
self._build_shop_app(source_id, SHOP_APP_SCENE),
pid=pid,
items=items,
source_id=source_id,
game_id=game_id,
biz_params=biz_params,
)
req = CreateOrderReqV6()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, SHOP_APP_SCENE)
req.items = []
for item in items:
row = CreateOrderItemReqV6()
row.spuId = str(item.get("spu_id") or "")
row.skuId = int(item.get("sku_id") or 0)
row.itemCount = int(item.get("item_count") or 1)
row.goodsType = int(item.get("goods_type") or 0)
row.virtualType = int(item.get("virtual_type") or 0)
req.items.append(row)
req.pid = int(pid or 0)
req.gameId = str(game_id or "0")
req.src = 2
req.orderType = 6
extra = CreateOrderExtraParam()
extra.bizParams = biz_params
req.extraParam = extra
req.sourceId = source_id
req.env = self._build_order_env()
req.orderScene = 3
req.promotionParam = CreateOrderPromotionParam()
req.accountParam = CreateOrderAccountParam()
result: Any = self.call_rpc(
"shopMiddleUI",
"createOrderV6",
req,
CreateOrderRspV6,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid,
guid,
cookie,
@@ -1430,25 +1430,21 @@ class HuyaHttpClient:
``deep=True`` 时保留 ``tag2[].tag5`` 的原始字节并放宽解码深度,
供 :func:`core.huya.shop_structs.parse_points_center_gifts` 解析商品列表。
"""
from .shop_structs import PlayMallQueryAllocationReq
from .rpc_requests import play_mall_allocation_request
req = PlayMallQueryAllocationReq()
req.userId = self._build_shop_user(uid, cookie)
req.pageCode = page_code
req.appInfo.sourceId = source_id
req.appInfo.scene = int(scene or 7)
if waterfall_tab_id not in (None, ""):
req.blank7 = json.dumps(
{"waterfallTabId": str(waterfall_tab_id)},
separators=(",", ":"),
)
req.flag9 = int(page_id or 0)
req.flags11 = []
rpc = play_mall_allocation_request(
self._build_shop_user(uid, cookie),
self._build_play_mall_app(source_id),
page_code=page_code,
scene=scene,
page_id=page_id,
waterfall_tab_id=waterfall_tab_id,
)
resp = self.call_rpc(
"PlayMallUI",
"queryAllocation",
req,
None,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid,
guid,
cookie,
@@ -1476,29 +1472,22 @@ class HuyaHttpClient:
timeout: float = 15.0,
):
"""PlayMallUI.getMyPromotion(返利/促销查询)。"""
from .shop_structs import (
PlayMallGetMyPromotionReq,
PromotionGoodsKey,
shop_spu_number,
)
from .rpc_requests import play_mall_promotion_request
spu_num = shop_spu_number(spu_id)
req = PlayMallGetMyPromotionReq()
req.userId = self._build_shop_user(uid, cookie)
req.appInfo.sourceId = source_id
goods = PromotionGoodsKey()
goods.supplierId = int(supplier_id or 0)
goods.spuNum = spu_num
goods.skuId = int(sku_id or 0)
goods.price = int(price or 0)
goods.gameId = int(game_id or 0)
goods.spuNum2 = spu_num
req.goods = goods
rpc = play_mall_promotion_request(
self._build_shop_user(uid, cookie),
self._build_play_mall_app(source_id),
spu_id=spu_id,
sku_id=sku_id,
price=price,
supplier_id=supplier_id,
game_id=game_id,
)
resp = self.call_rpc(
"PlayMallUI",
"getMyPromotion",
req,
None,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
uid,
guid,
cookie,
+212
View File
@@ -0,0 +1,212 @@
"""HTTP/WSS 共用的虎牙 RPC 请求构造。
两个传输客户端仍各自处理同步/异步发送与响应解码;这里只保存业务参数到
TAF 请求结构体的唯一映射,防止字段默认值在两条链路中发生漂移。
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from .activity_structs import (
GetActPrizeDetailReq,
GetActPrizeDetailResp,
GetActTaskDetailReq,
GetActTaskDetailResp,
GetActUserTaskDetailReq,
GetActUserTaskDetailResp,
GetUserPrizeRecordsReq,
GetUserPrizeRecordsResp,
GetUserScoreReq,
GetUserScoreResp,
ScoreExchangePrizeReq,
ScoreExchangePrizeResp,
)
from .shop_structs import (
CreateOrderAccountParam,
CreateOrderExtraParam,
CreateOrderItemReqV6,
CreateOrderPromotionParam,
CreateOrderReqV6,
CreateOrderRspV6,
GetGoodsInfoReqV5,
GoodsInfoRsp,
PlayMallGetMyPromotionReq,
PlayMallQueryAllocationReq,
PromotionGoodsKey,
shop_spu_number,
)
@dataclass(frozen=True)
class RpcRequest:
service: str
method: str
request: Any
response_type: Any = None
def activity_score_request(user: Any, sid: int) -> RpcRequest:
request = GetUserScoreReq()
request.userId = user
request.sid = int(sid or 0)
return RpcRequest("webActUI", "getUserScore", request, GetUserScoreResp)
def activity_records_request(user: Any, sid: int) -> RpcRequest:
request = GetUserPrizeRecordsReq()
request.userId = user
request.sid = int(sid or 0)
return RpcRequest(
"webActUI",
"getUserPrizeRecords",
request,
GetUserPrizeRecordsResp,
)
def activity_exchange_request(user: Any, sid: int, pid: int) -> RpcRequest:
request = ScoreExchangePrizeReq()
request.userId = user
request.sid = int(sid or 0)
request.pid = int(pid or 0)
return RpcRequest("webActUI", "scoreExchangePrize", request, ScoreExchangePrizeResp)
def activity_prize_detail_request(user: Any, sid: int, pid: int) -> RpcRequest:
request = GetActPrizeDetailReq()
request.userId = user
request.sid = int(sid or 0)
request.pid = int(pid or 0)
return RpcRequest(
"webActUI",
"getActPrizeDetail",
request,
GetActPrizeDetailResp,
)
def activity_task_detail_request(user: Any, act_id: int) -> RpcRequest:
request = GetActTaskDetailReq()
request.userId = user
request.actId = int(act_id or 0)
return RpcRequest("webActUI", "getActTaskDetail", request, GetActTaskDetailResp)
def activity_user_task_detail_request(user: Any, act_id: int) -> RpcRequest:
request = GetActUserTaskDetailReq()
request.userId = user
request.actId = int(act_id or 0)
return RpcRequest(
"webActUI",
"getActUserTaskDetail",
request,
GetActUserTaskDetailResp,
)
def goods_info_request(
user: Any,
shop_app: Any,
*,
pid: int,
spu_id: str,
sku_id: int = 0,
game_id: str = "",
) -> RpcRequest:
request = GetGoodsInfoReqV5()
request.userId = user
request.shopAppInfo = shop_app
request.pid = pid
request.spuId = spu_id
request.skuId = sku_id
request.gameId = game_id
return RpcRequest("shopMiddleUI", "getGoodsInfoV5", request, GoodsInfoRsp)
def play_mall_allocation_request(
user: Any,
app_info: Any,
*,
page_code: str,
scene: int,
page_id: int = 0,
waterfall_tab_id: int | str | None = None,
) -> RpcRequest:
request = PlayMallQueryAllocationReq()
request.userId = user
request.appInfo = app_info
request.appInfo.scene = int(scene or 0)
request.pageCode = page_code
if waterfall_tab_id not in (None, ""):
request.blank7 = json.dumps(
{"waterfallTabId": str(waterfall_tab_id)},
separators=(",", ":"),
)
request.flag9 = int(page_id or 0)
request.flags11 = []
return RpcRequest("PlayMallUI", "queryAllocation", request)
def play_mall_promotion_request(
user: Any,
app_info: Any,
*,
spu_id: str,
sku_id: int = 0,
price: int = 0,
supplier_id: int = 0,
game_id: int = 0,
) -> RpcRequest:
spu_num = shop_spu_number(spu_id)
request = PlayMallGetMyPromotionReq()
request.userId = user
request.appInfo = app_info
goods = PromotionGoodsKey()
goods.supplierId = int(supplier_id or 0)
goods.spuNum = spu_num
goods.skuId = int(sku_id or 0)
goods.price = int(price or 0)
goods.gameId = int(game_id or 0)
goods.spuNum2 = spu_num
request.goods = goods
return RpcRequest("PlayMallUI", "getMyPromotion", request)
def create_order_v6_request(
user: Any,
shop_app: Any,
*,
pid: int,
items: list[dict],
source_id: str,
game_id: str = "0",
biz_params: str,
) -> RpcRequest:
request = CreateOrderReqV6()
request.userId = user
request.shopAppInfo = shop_app
request.items = []
for item in items:
row = CreateOrderItemReqV6()
row.spuId = str(item.get("spu_id") or "")
row.skuId = int(item.get("sku_id") or 0)
row.itemCount = int(item.get("item_count") or 1)
row.goodsType = int(item.get("goods_type") or 0)
row.virtualType = int(item.get("virtual_type") or 0)
request.items.append(row)
request.pid = int(pid or 0)
request.gameId = str(game_id or "0")
request.src = 2
request.orderType = 6
extra = CreateOrderExtraParam()
extra.bizParams = biz_params
request.extraParam = extra
request.sourceId = source_id
request.env = {"user_click_seq": "[]"}
request.orderScene = 3
request.promotionParam = CreateOrderPromotionParam()
request.accountParam = CreateOrderAccountParam()
return RpcRequest("shopMiddleUI", "createOrderV6", request, CreateOrderRspV6)
+121 -111
View File
@@ -16,7 +16,6 @@
"""
import asyncio
import json
import random
import re
from collections.abc import Callable
@@ -984,18 +983,23 @@ class HuyaWssClient:
source_id: str = "yellowcarlist",
app_scene: int = SHOP_APP_SCENE,
):
from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp
from .rpc_requests import goods_info_request
req = GetGoodsInfoReqV5()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, app_scene)
req.pid = pid
req.spuId = spu_id
req.skuId = sku_id
req.gameId = game_id
rpc = goods_info_request(
self._build_biz_user(uid, cookie),
self._build_shop_app(source_id, app_scene),
pid=pid,
spu_id=spu_id,
sku_id=sku_id,
game_id=game_id,
)
return await self.call_rpc(
"shopMiddleUI", "getGoodsInfoV5", req, GoodsInfoRsp, timeout=15.0
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
timeout=15.0,
)
async def get_act_info(self, act_id: int):
@@ -1006,63 +1010,89 @@ class HuyaWssClient:
return await self.call_rpc("webActUI", "getActInfo", req, GetActInfoResp)
async def get_act_task_detail(self, uid: int, cookie: str, act_id: int):
from .activity_structs import GetActTaskDetailReq, GetActTaskDetailResp
from .rpc_requests import activity_task_detail_request
req = GetActTaskDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.actId = int(act_id or 0)
rpc = activity_task_detail_request(
self._build_activity_user(uid, cookie),
act_id,
)
return await self.call_rpc(
"webActUI", "getActTaskDetail", req, GetActTaskDetailResp
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
)
async def get_act_user_task_detail(self, uid: int, cookie: str, act_id: int):
from .activity_structs import GetActUserTaskDetailReq, GetActUserTaskDetailResp
from .rpc_requests import activity_user_task_detail_request
req = GetActUserTaskDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.actId = int(act_id or 0)
rpc = activity_user_task_detail_request(
self._build_activity_user(uid, cookie),
act_id,
)
return await self.call_rpc(
"webActUI", "getActUserTaskDetail", req, GetActUserTaskDetailResp
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
)
async def get_user_score(self, uid: int, cookie: str, sid: int):
from .activity_structs import GetUserScoreReq, GetUserScoreResp
from .rpc_requests import activity_score_request
req = GetUserScoreReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
return await self.call_rpc("webActUI", "getUserScore", req, GetUserScoreResp)
rpc = activity_score_request(
self._build_activity_user(uid, cookie),
sid,
)
return await self.call_rpc(
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
)
async def get_act_prize_detail(self, uid: int, cookie: str, sid: int, pid: int):
from .activity_structs import GetActPrizeDetailReq, GetActPrizeDetailResp
from .rpc_requests import activity_prize_detail_request
req = GetActPrizeDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
req.pid = int(pid or 0)
rpc = activity_prize_detail_request(
self._build_activity_user(uid, cookie),
sid,
pid,
)
return await self.call_rpc(
"webActUI", "getActPrizeDetail", req, GetActPrizeDetailResp
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
)
async def score_exchange_prize(self, uid: int, cookie: str, sid: int, pid: int):
from .activity_structs import ScoreExchangePrizeReq, ScoreExchangePrizeResp
from .rpc_requests import activity_exchange_request
req = ScoreExchangePrizeReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
req.pid = int(pid or 0)
rpc = activity_exchange_request(
self._build_activity_user(uid, cookie),
sid,
pid,
)
return await self.call_rpc(
"webActUI", "scoreExchangePrize", req, ScoreExchangePrizeResp
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
)
async def get_user_prize_records(self, uid: int, cookie: str, sid: int):
from .activity_structs import GetUserPrizeRecordsReq, GetUserPrizeRecordsResp
from .rpc_requests import activity_records_request
req = GetUserPrizeRecordsReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
rpc = activity_records_request(
self._build_activity_user(uid, cookie),
sid,
)
return await self.call_rpc(
"webActUI", "getUserPrizeRecords", req, GetUserPrizeRecordsResp
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
)
async def check_user_bind_game_account(
@@ -1311,44 +1341,22 @@ class HuyaWssClient:
biz_params: str = JIFENCENTER_BIZ_PARAMS,
):
"""积分兑换多商品下单(``shopMiddleUI.createOrderV6``)。"""
from .shop_structs import (
CreateOrderAccountParam,
CreateOrderExtraParam,
CreateOrderItemReqV6,
CreateOrderPromotionParam,
CreateOrderReqV6,
CreateOrderRspV6,
)
from .rpc_requests import create_order_v6_request
req = CreateOrderReqV6()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, SHOP_APP_SCENE)
req.items = []
for item in items:
row = CreateOrderItemReqV6()
row.spuId = str(item.get("spu_id") or "")
row.skuId = int(item.get("sku_id") or 0)
row.itemCount = int(item.get("item_count") or 1)
row.goodsType = int(item.get("goods_type") or 0)
row.virtualType = int(item.get("virtual_type") or 0)
req.items.append(row)
req.pid = int(pid or 0)
req.gameId = str(game_id or "0")
req.src = 2
req.orderType = 6
extra = CreateOrderExtraParam()
extra.bizParams = biz_params
req.extraParam = extra
req.sourceId = source_id
req.env = self._build_order_env()
req.orderScene = 3
req.promotionParam = CreateOrderPromotionParam()
req.accountParam = CreateOrderAccountParam()
rpc = create_order_v6_request(
self._build_biz_user(uid, cookie),
self._build_shop_app(source_id, SHOP_APP_SCENE),
pid=pid,
items=items,
source_id=source_id,
game_id=game_id,
biz_params=biz_params,
)
return await self.call_rpc(
"shopMiddleUI",
"createOrderV6",
req,
CreateOrderRspV6,
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
timeout=15.0,
)
@@ -1508,23 +1516,28 @@ class HuyaWssClient:
``deep=True`` 时保留 ``tag2[].tag5`` 原始字节并放宽解码深度,
供 ``parse_points_center_gifts`` 解析完整商品列表。
"""
from .shop_structs import PlayMallQueryAllocationReq
from .rpc_requests import play_mall_allocation_request
req = PlayMallQueryAllocationReq()
req.userId = self._build_biz_user(uid, cookie)
req.appInfo = self._build_play_mall_app(source_id)
req.appInfo.scene = int(scene or SHOP_APP_SCENE)
req.pageCode = page_code
if waterfall_tab_id not in (None, ""):
req.blank7 = json.dumps(
{"waterfallTabId": str(waterfall_tab_id)},
separators=(",", ":"),
)
req.flag9 = int(page_id or 0)
req.flags11 = []
rpc = play_mall_allocation_request(
self._build_biz_user(uid, cookie),
self._build_play_mall_app(source_id),
page_code=page_code,
scene=scene,
page_id=page_id,
waterfall_tab_id=waterfall_tab_id,
)
if not deep:
return await self.call_rpc_raw("PlayMallUI", "queryAllocation", req)
body = await self.call_rpc("PlayMallUI", "queryAllocation", req, None)
return await self.call_rpc_raw(
rpc.service,
rpc.method,
rpc.request,
)
body = await self.call_rpc(
rpc.service,
rpc.method,
rpc.request,
rpc.response_type,
)
if body is None:
return None
return self._raw_rsp_from_body(
@@ -1546,25 +1559,22 @@ class HuyaWssClient:
source_id: str = "yellowcarlist",
):
"""PlayMallUI.getMyPromotion — 查询用户可用返利/促销。"""
from .shop_structs import (
PlayMallGetMyPromotionReq,
PromotionGoodsKey,
shop_spu_number,
)
from .rpc_requests import play_mall_promotion_request
spu_num = shop_spu_number(spu_id)
req = PlayMallGetMyPromotionReq()
req.userId = self._build_biz_user(uid, cookie)
req.appInfo = self._build_play_mall_app(source_id)
goods = PromotionGoodsKey()
goods.supplierId = int(supplier_id or 0)
goods.spuNum = spu_num
goods.skuId = int(sku_id or 0)
goods.price = int(price or 0)
goods.gameId = int(game_id or 0)
goods.spuNum2 = spu_num
req.goods = goods
return await self.call_rpc_raw("PlayMallUI", "getMyPromotion", req)
rpc = play_mall_promotion_request(
self._build_biz_user(uid, cookie),
self._build_play_mall_app(source_id),
spu_id=spu_id,
sku_id=sku_id,
price=price,
supplier_id=supplier_id,
game_id=game_id,
)
return await self.call_rpc_raw(
rpc.service,
rpc.method,
rpc.request,
)
async def get_user_profile(
self, uid: int, guid: str, cookie: str, target_uid: int = 0
+99
View File
@@ -0,0 +1,99 @@
"""虎牙活动查询统一 SID 发现与接口分流测试。"""
from types import SimpleNamespace
from web.backend.services.huya_activity_query import query_huya_activity
class _Client:
def __init__(self, *, sid: int = 0):
self.sid = sid
self.score_calls: list[dict] = []
self.record_calls: list[dict] = []
def play_mall_query_allocation(self, **_kwargs):
if not self.sid:
raise RuntimeError("catalog unavailable")
return SimpleNamespace(
code=200,
payload={
"tag2": [
{
"tag2_2": {
"tag0": {
"tag7": self.sid,
}
}
}
]
},
)
def query_user_score(self, **kwargs):
self.score_calls.append(kwargs)
return SimpleNamespace(score=100)
def get_user_prize_records(self, **kwargs):
self.record_calls.append(kwargs)
return SimpleNamespace(records=[])
def test_activity_query_prefers_live_sid_for_score_and_records():
client = _Client(sid=2277)
result = query_huya_activity(
client,
uid=100,
cookie="yyuid=100",
configured_sid=2203,
want_score=True,
want_records=True,
)
assert result.sid == 2277
assert result.sid_source == "queryAllocation"
assert result.configured_sid == 2203
assert client.score_calls[0]["sid"] == 2277
assert client.record_calls[0]["sid"] == 2277
def test_activity_query_falls_back_to_configured_sid_when_catalog_fails():
client = _Client()
result = query_huya_activity(
client,
uid=100,
cookie="yyuid=100",
configured_sid=2203,
want_score=True,
)
assert result.sid == 2203
assert result.sid_source == "config"
assert "catalog unavailable" in result.sid_error
assert client.score_calls[0]["sid"] == 2203
def test_activity_query_reuses_provided_sid_without_catalog_call(monkeypatch):
client = _Client(sid=2277)
monkeypatch.setattr(
client,
"play_mall_query_allocation",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("provided SID must not trigger another catalog request")
),
)
result = query_huya_activity(
client,
uid=100,
cookie="yyuid=100",
configured_sid=2203,
current_sid=2277,
current_sid_source="queryAllocation",
want_score=True,
)
assert result.sid == 2277
assert result.sid_source == "queryAllocation"
assert client.score_calls[0]["sid"] == 2277
+73
View File
@@ -0,0 +1,73 @@
"""虎牙 HTTP/WSS 共用 RPC 请求构造测试。"""
from core.huya.activity_structs import GetUserPrizeRecordsReq
from core.huya.rpc_requests import (
activity_records_request,
create_order_v6_request,
play_mall_allocation_request,
)
from core.huya.shop_structs import (
CreateOrderReqV6,
PlayMallAppInfo,
PlayMallQueryAllocationReq,
UserId,
)
def test_activity_records_request_maps_user_and_sid():
user = UserId()
rpc = activity_records_request(user, 2277)
assert rpc.service == "webActUI"
assert rpc.method == "getUserPrizeRecords"
assert isinstance(rpc.request, GetUserPrizeRecordsReq)
assert rpc.request.userId is user
assert rpc.request.sid == 2277
def test_play_mall_allocation_request_maps_tab_parameters():
app = PlayMallAppInfo()
rpc = play_mall_allocation_request(
UserId(),
app,
page_code="heping_baodian_credit_center_page",
scene=7,
page_id=343,
waterfall_tab_id=431,
)
assert rpc.service == "PlayMallUI"
assert rpc.method == "queryAllocation"
assert isinstance(rpc.request, PlayMallQueryAllocationReq)
assert rpc.request.appInfo is app
assert rpc.request.appInfo.scene == 7
assert rpc.request.blank7 == '{"waterfallTabId":"431"}'
assert rpc.request.flag9 == 343
assert rpc.request.flags11 == []
def test_create_order_v6_request_maps_items_across_transports():
rpc = create_order_v6_request(
UserId(),
object(),
pid=123,
items=[
{
"spu_id": "hy-8315982",
"sku_id": 7747527,
"item_count": 1,
"goods_type": 5,
"virtual_type": 507,
}
],
source_id="yellowcarlist",
biz_params="jifencenter",
)
assert rpc.service == "shopMiddleUI"
assert rpc.method == "createOrderV6"
assert isinstance(rpc.request, CreateOrderReqV6)
assert rpc.request.orderType == 6
assert rpc.request.orderScene == 3
assert rpc.request.items[0].spuId == "hy-8315982"
assert rpc.request.items[0].skuId == 7747527
+1 -1
View File
@@ -35,7 +35,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261112_0080"
HEAD_REVISION = "20261113_0081"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
+58
View File
@@ -0,0 +1,58 @@
"""全局代理配置到运行时来源的唯一映射测试。"""
from web.backend.models import ProxyConfig
from web.backend.services.proxy_service import build_proxy_runtime
def _config(**overrides) -> ProxyConfig:
values = {
"enabled": True,
"api_url": "",
"http": "",
"https": "",
"whitelist_enabled": False,
"whitelist_platform": "xiequ",
"whitelist_credentials": None,
"whitelist_uid": "",
"whitelist_ukey": "",
}
values.update(overrides)
config = ProxyConfig()
for key, value in values.items():
setattr(config, key, value)
return config
def test_proxy_runtime_preserves_static_http_and_https_addresses():
runtime = build_proxy_runtime(
_config(
http="http://10.0.0.1:8080",
https="http://10.0.0.2:8080",
)
)
assert runtime.static_proxies == {
"http": "http://10.0.0.1:8080",
"https": "http://10.0.0.2:8080",
}
assert runtime.api_fetcher is None
def test_proxy_runtime_builds_api_fetcher_with_legacy_whitelist_fields():
runtime = build_proxy_runtime(
_config(
api_url="https://proxy.example/fetch",
whitelist_enabled=True,
whitelist_uid="legacy-user",
whitelist_ukey="legacy-key",
)
)
assert runtime.static_proxies is None
assert runtime.api_fetcher is not None
assert runtime.api_fetcher.api_url == "https://proxy.example/fetch"
def test_proxy_runtime_is_empty_when_caller_or_global_switch_is_off():
assert not build_proxy_runtime(_config(), enabled=False).enabled
assert not build_proxy_runtime(_config(enabled=False)).enabled
+5 -4
View File
@@ -9,6 +9,7 @@ from web.backend.models import AuditLog, User, WeiboAccount, WeiboExternalOrderM
from web.backend.routers import weibo_collab as weibo_collab_router
from web.backend.schemas import WeiboCollabCkExportOut
from web.backend.services import weibo_collab_service as collab
from web.backend.services import weibo_service
def make_account(db: Session, uid: str = "4014000001") -> WeiboAccount:
@@ -52,7 +53,7 @@ def test_create_orders_groups_and_balance_check(monkeypatch, db):
def list_mall(self, *_args, **_kwargs):
return {"weidou_balance": 400, "list": []}
monkeypatch.setattr(collab, "WeiboClient", FakeClient)
monkeypatch.setattr(weibo_service, "WeiboClient", FakeClient)
first = collab.create_order(
db,
@@ -144,7 +145,7 @@ def test_exchange_record_unique_match_and_archive(monkeypatch, db):
]
}
monkeypatch.setattr(collab, "WeiboClient", FakeClient)
monkeypatch.setattr(weibo_service, "WeiboClient", FakeClient)
collab.set_status(db, user, first, collab.STATUS_RECHARGED)
collab.set_status(db, user, first, collab.STATUS_SCRIPTED)
collab.set_status(db, user, second, collab.STATUS_RECHARGED)
@@ -210,7 +211,7 @@ def test_exchange_success_promotes_weidou_error_to_done(monkeypatch, db):
]
}
monkeypatch.setattr(collab, "WeiboClient", FakeClient)
monkeypatch.setattr(weibo_service, "WeiboClient", FakeClient)
result = collab.check_exchange_records(db, user, [order])
@@ -266,7 +267,7 @@ def test_balance_check_does_not_mix_roles_after_rebind(monkeypatch, db):
FakeClient.calls += 1
return {"weidou_balance": 200, "list": []}
monkeypatch.setattr(collab, "WeiboClient", FakeClient)
monkeypatch.setattr(weibo_service, "WeiboClient", FakeClient)
result = collab.check_balance_groups(db, user, [second], threshold=100)
+9 -34
View File
@@ -21,6 +21,7 @@ from core.douyu.proxy_fetcher import ProxyFetcher
from ..database import SessionLocal
from ..models import ProxyConfig as ProxyConfigModel
from .account_events_service import CATEGORY_AUTH, emit
from .proxy_service import build_proxy_runtime
def _now() -> datetime:
@@ -192,41 +193,16 @@ class AccountCheckRunner:
self.proxy_config = proxy_config
self._lock = threading.Lock()
self._stop = threading.Event()
self._proxy_runtime = build_proxy_runtime(
proxy_config,
enabled=self.batch.use_proxy,
stop_event=self._stop,
)
self._shared_proxy_fetcher = self._create_proxy_fetcher()
def _create_proxy_fetcher(self) -> ProxyFetcher | None:
"""按全局代理配置创建 API 代理获取器。"""
if (
not self.batch.use_proxy
or not self.proxy_config
or not self.proxy_config.enabled
or not self.proxy_config.api_url
):
return None
wl_platform = "xiequ"
wl_credentials = None
if self.proxy_config.whitelist_enabled:
wl_platform = (
getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
if (
not wl_credentials
and self.proxy_config.whitelist_uid
and self.proxy_config.whitelist_ukey
):
wl_credentials = {
"uid": self.proxy_config.whitelist_uid,
"ukey": self.proxy_config.whitelist_ukey,
}
return ProxyFetcher(
api_url=self.proxy_config.api_url,
whitelist_platform=wl_platform,
whitelist_credentials=wl_credentials,
stop_event=self._stop,
)
return self._proxy_runtime.api_fetcher
def _resolve_static_proxy(self) -> tuple[dict[str, str] | None, str]:
"""解析静态代理;API 代理由 DouyuLogin 内部通过 proxy_fetcher 获取。"""
@@ -236,9 +212,8 @@ class AccountCheckRunner:
if not self.proxy_config or not self.proxy_config.enabled:
return None, "代理不可用: 未启用代理配置"
if self.proxy_config.http or self.proxy_config.https:
proxy_url = self.proxy_config.http or self.proxy_config.https
return {"http": proxy_url, "https": proxy_url}, ""
if self._proxy_runtime.static_proxies:
return self._proxy_runtime.static_proxies, ""
if self._shared_proxy_fetcher:
return None, ""
+43
View File
@@ -0,0 +1,43 @@
"""三平台外部单协作共用的归档/作废可见性规则。"""
from __future__ import annotations
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
ARCHIVE_SCOPES: tuple[str, ...] = ("active", "archived", "all")
def normalize_archive_scope(value: str | None) -> str:
"""归档可见范围:默认只看活跃行。"""
scope = str(value or "active").strip().lower()
return scope if scope in ARCHIVE_SCOPES else "active"
def apply_order_visibility(
query: Any,
model: Any,
*,
archive_scope: str,
include_void: bool,
void_status: str = "void",
) -> Any:
"""给协作台账查询统一追加归档和作废过滤。"""
scope = normalize_archive_scope(archive_scope)
if scope == "active":
query = query.filter(model.archived_at.is_(None))
elif scope == "archived":
query = query.filter(model.archived_at.isnot(None))
if not include_void:
query = query.filter(model.status != void_status)
return query
def count_archived(db: Session, model: Any) -> int:
"""统计全部已归档行,不受当前归档视图过滤影响。"""
return int(
db.query(func.count(model.id)).filter(model.archived_at.isnot(None)).scalar()
or 0
)
+19 -88
View File
@@ -34,6 +34,14 @@ from ..models import (
User,
)
from .audit_service import record_audit
from .collab_common import apply_order_visibility, count_archived
from .douyu_goods_service import (
serialize_live_goods as _serialize_live_goods,
)
from .douyu_goods_service import (
serialized_snapshots,
upsert_goods_snapshot,
)
from .file_service import build_file_url
# ---------------------------------------------------------------------------
@@ -93,7 +101,6 @@ STATUS_TIMESTAMP_FIELDS = {
STATUS_DONE: "done_at",
}
ARCHIVE_SCOPES = ("active", "archived", "all")
ARCHIVABLE_STATUSES = (STATUS_DONE,)
# 同一账号核账只统计“还没有完成兑换”的单:done/scripted 不重复占积分。
POINTS_GROUP_STATUSES: tuple[str, ...] = (
@@ -249,34 +256,7 @@ def _client_for_account(account: Account) -> DouyuActivityClient:
def serialize_live_goods(goods: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""把 storehome 原始商品映射成前端稳定字段。"""
result: list[dict[str, Any]] = []
for raw in goods:
if not isinstance(raw, dict):
continue
commodity_id = _text(
_first_value(raw, "commodityId", "commodity_id"), max_length=64
)
name = _text(_first_value(raw, "commodityName", "name"), max_length=256)
open_status = _to_int(_first_value(raw, "openStatus", "open_status"))
mark = _text(_first_value(raw, "mark"), max_length=32)
status_value = _first_value(raw, "status")
result.append(
{
"commodity_id": commodity_id,
"name": name,
"score": _to_int(_first_value(raw, "score")),
"status": str(status_value) if status_value is not None else "",
"open_status": open_status,
"start_time": _to_int(_first_value(raw, "startTime", "start_time")),
"end_time": _to_int(_first_value(raw, "endTime", "end_time")),
"mark": mark,
"count": _to_int(_first_value(raw, "count")),
"elite_limited": _to_int(_first_value(raw, "eliteLimited")),
"is_limited": bool(mark) or open_status == 0,
"raw": _json_safe(raw),
}
)
return result
return _serialize_live_goods(goods)
def _probe_with_client(
@@ -1658,27 +1638,7 @@ def refresh_activity_goods(
manual_id=str(config.manual_id or ""), rid=str(config.rid or "")
)
goods = result.get("goods") or []
now = _now()
for raw in goods:
commodity_id = _text(
raw.get("commodityId") or raw.get("commodity_id"), max_length=64
)
if not commodity_id:
continue
row = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
if row is None:
row = DouyuGoodsSnapshot(commodity_id=commodity_id)
db.add(row)
row.name = _text(raw.get("commodityName") or raw.get("name"), max_length=256)
row.score = _to_int(raw.get("score"))
status_value = _first_value(raw, "status")
row.status = str(status_value) if status_value is not None else ""
row.raw = _json_safe(raw)
row.updated_at = now
upsert_goods_snapshot(db, goods, commit=False)
record_audit(
db,
current,
@@ -1691,31 +1651,7 @@ def refresh_activity_goods(
def serialized_goods(db: Session) -> list[dict[str, Any]]:
rows = db.query(DouyuGoodsSnapshot).order_by(DouyuGoodsSnapshot.id.asc()).all()
result: list[dict[str, Any]] = []
for row in rows:
raw = row.raw or {}
start = _to_int(_first_value(raw, "startTime", "start_time"))
end = _to_int(_first_value(raw, "endTime", "end_time"))
open_status = _to_int(_first_value(raw, "openStatus", "open_status"))
mark = _text(_first_value(raw, "mark"), max_length=32)
result.append(
{
"commodity_id": row.commodity_id,
"name": row.name,
"score": row.score,
"status": row.status,
"open_status": open_status,
"start_time": start,
"end_time": end,
"mark": mark,
"count": _to_int(_first_value(raw, "count")),
"elite_limited": _to_int(_first_value(raw, "eliteLimited")),
"is_limited": bool(mark) or open_status == 0,
"raw": raw,
}
)
return result
return serialized_snapshots(db)
def _metric_views(
@@ -1904,13 +1840,13 @@ def _apply_order_filters(
keyword: str = "",
ck_status: str = "",
):
scope = archive_scope if archive_scope in ARCHIVE_SCOPES else "active"
if scope == "active":
query = query.filter(DouyuExternalOrder.archived_at.is_(None))
elif scope == "archived":
query = query.filter(DouyuExternalOrder.archived_at.isnot(None))
if not include_void:
query = query.filter(DouyuExternalOrder.status != STATUS_VOID)
query = apply_order_visibility(
query,
DouyuExternalOrder,
archive_scope=archive_scope,
include_void=include_void,
void_status=STATUS_VOID,
)
if _text(status):
query = query.filter(DouyuExternalOrder.status == _text(status))
if _text(shop):
@@ -2082,12 +2018,7 @@ def summary(
"total": total,
"by_status": by_status,
"ck_invalid": int(ck_invalid),
"archived_total": (
db.query(func.count(DouyuExternalOrder.id))
.filter(DouyuExternalOrder.archived_at.isnot(None))
.scalar()
or 0
),
"archived_total": count_archived(db, DouyuExternalOrder),
"account_missing": (
db.query(func.count(DouyuExternalOrder.id))
.filter(
+130
View File
@@ -0,0 +1,130 @@
"""斗鱼活动商品的唯一解析与快照写入实现。"""
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from ..models import DouyuGoodsSnapshot
def _to_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(float(value))
except (TypeError, ValueError):
return None
def _text(value: Any, *, max_length: int | None = None) -> str:
text = str(value or "").strip()
return text[:max_length] if max_length else text
def _json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def _first_value(data: Mapping[str, Any], *keys: str) -> Any:
for key in keys:
if key in data and data.get(key) is not None:
return data.get(key)
return None
def serialize_goods(raw: Mapping[str, Any]) -> dict[str, Any]:
"""把 storehome 原始商品映射成前端稳定字段。"""
commodity_id = _text(
_first_value(raw, "commodityId", "commodity_id"), max_length=64
)
name = _text(_first_value(raw, "commodityName", "name"), max_length=256)
open_status = _to_int(_first_value(raw, "openStatus", "open_status"))
mark = _text(_first_value(raw, "mark"), max_length=32)
status_value = _first_value(raw, "status")
return {
"commodity_id": commodity_id,
"name": name,
"score": _to_int(_first_value(raw, "score")),
"status": str(status_value) if status_value is not None else "",
"open_status": open_status,
"start_time": _to_int(_first_value(raw, "startTime", "start_time")),
"end_time": _to_int(_first_value(raw, "endTime", "end_time")),
"mark": mark,
"count": _to_int(_first_value(raw, "count")),
"elite_limited": _to_int(_first_value(raw, "eliteLimited")),
"is_limited": bool(mark) or open_status == 0,
"raw": _json_safe(dict(raw)),
}
def serialize_live_goods(goods: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [serialize_goods(raw) for raw in goods if isinstance(raw, dict)]
def serialize_snapshot(row: DouyuGoodsSnapshot) -> dict[str, Any]:
raw = row.raw if isinstance(row.raw, dict) else {}
open_status = _to_int(_first_value(raw, "openStatus", "open_status"))
mark = _text(_first_value(raw, "mark"), max_length=32)
return {
"commodity_id": row.commodity_id,
"name": row.name,
"score": row.score,
"status": row.status,
"open_status": open_status,
"start_time": _to_int(_first_value(raw, "startTime", "start_time")),
"end_time": _to_int(_first_value(raw, "endTime", "end_time")),
"mark": mark,
"count": _to_int(_first_value(raw, "count")),
"elite_limited": _to_int(_first_value(raw, "eliteLimited")),
"is_limited": bool(mark) or open_status == 0,
"raw": raw,
}
def serialized_snapshots(db: Session) -> list[dict[str, Any]]:
rows = db.query(DouyuGoodsSnapshot).order_by(DouyuGoodsSnapshot.id.asc()).all()
return [serialize_snapshot(row) for row in rows]
def upsert_goods_snapshot(
db: Session,
goods: list[dict[str, Any]],
*,
commit: bool = True,
) -> None:
"""统一写入活动商品快照;零值字段必须保留。"""
now = datetime.now(UTC)
for raw in goods:
if not isinstance(raw, dict):
continue
commodity_id = _text(
_first_value(raw, "commodityId", "commodity_id"), max_length=64
)
if not commodity_id:
continue
row = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
if row is None:
row = DouyuGoodsSnapshot(commodity_id=commodity_id)
db.add(row)
row.name = _text(_first_value(raw, "commodityName", "name"), max_length=256)
row.score = _to_int(_first_value(raw, "score"))
status_value = _first_value(raw, "status")
row.status = str(status_value) if status_value is not None else ""
row.raw = _json_safe(raw)
row.updated_at = now
if commit:
db.commit()
+9 -45
View File
@@ -17,19 +17,20 @@ from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import (
Account,
DouyuEsportsGoodsSnapshot,
DouyuGoodsSnapshot,
DouyuTask,
DouyuXpdGoodsSnapshot,
)
from ..models import (
ProxyConfig as ProxyConfigModel,
)
from .douyu_goods_service import upsert_goods_snapshot
from .douyu_service import (
DOUYU_CONFIG_FIELDS,
douyu_config_value,
douyu_task_payload,
ensure_douyu_config,
)
from .proxy_service import build_proxy_runtime
if TYPE_CHECKING:
from .douyu_runner import DouyuBatchRunner
@@ -85,6 +86,10 @@ class DouyuBatchRunnerCore:
# 代理接入 (配置源 = 代理配置页, 与 CK 检测/虎牙注册同款):
# 仅兑换/锁单/支付/充值/送礼等写操作任务走代理, 查询/刷新/绑定类直连
self._proxy_cfg = db.query(ProxyConfigModel).first() if db else None
self._proxy_runtime = build_proxy_runtime(
self._proxy_cfg,
stop_event=self._stop,
)
self._proxy_fetcher = self._create_proxy_fetcher()
self._static_proxies = self._resolve_static_proxies()
if self._static_proxies:
@@ -96,33 +101,11 @@ class DouyuBatchRunnerCore:
def _create_proxy_fetcher(self) -> ProxyFetcher | None:
"""API 代理模式: 每个任务从代理 API 取 1 个新代理 (与虎牙注册链同款)."""
cfg = self._proxy_cfg
if not cfg or not cfg.enabled or not cfg.api_url:
return None
wl_platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
wl_credentials = getattr(cfg, "whitelist_credentials", None)
if (
not wl_credentials
and getattr(cfg, "whitelist_uid", "")
and getattr(cfg, "whitelist_ukey", "")
):
wl_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey}
return ProxyFetcher(
api_url=cfg.api_url,
whitelist_platform=wl_platform,
whitelist_credentials=wl_credentials if cfg.whitelist_enabled else None,
stop_event=self._stop,
)
return self._proxy_runtime.api_fetcher
def _resolve_static_proxies(self) -> dict[str, str] | None:
"""静态代理模式: 代理配置页手填的 http/https 地址."""
cfg = self._proxy_cfg
if not cfg or not cfg.enabled:
return None
http, https = (cfg.http or "").strip(), (cfg.https or "").strip()
if not http and not https:
return None
return {"http": http or https, "https": https or http}
return self._proxy_runtime.static_proxies
def _proxies_for_task(self) -> dict[str, str] | None:
"""取本任务出网代理。
@@ -237,26 +220,7 @@ class DouyuBatchRunnerCore:
self._push_task_event(task)
def _upsert_goods(self, db: Session, goods: list[dict]) -> None:
now = datetime.now(UTC)
for raw in goods:
commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "")
if not commodity_id:
continue
row = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
score = self._to_int(raw.get("score"))
if row is None:
row = DouyuGoodsSnapshot(commodity_id=commodity_id)
db.add(row)
row.name = str(raw.get("commodityName") or raw.get("name") or "")
row.score = score
row.status = str(raw.get("status") or "")
row.raw = raw
row.updated_at = now
db.commit()
upsert_goods_snapshot(db, goods)
def _upsert_esports_goods(self, db: Session, goods: list[dict]) -> None:
now = datetime.now(UTC)
+124
View File
@@ -0,0 +1,124 @@
"""虎牙活动积分与兑换记录的唯一查询入口。
当前活动 SID 必须优先从 ``PlayMallUI.queryAllocation`` 的实时响应发现;
``huya_config.sid`` 只作为接口不可用时的兼容兜底。工作台任务和外部单协作
都通过本模块查询,避免两个入口各自解析 SID 后出现「接口 200、记录 0 条」。
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from .huya_points_center import (
fetch_points_center_catalog,
points_center_options_from_config,
)
def _int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _clip(value: Any, limit: int = 200) -> str:
text = str(value if value is not None else "").strip()
return text if len(text) <= limit else text[: limit - 3] + "..."
@dataclass
class HuyaActivityQuery:
"""一次活动查询的原始结果;业务判定由调用方完成。"""
sid: int = 0
sid_source: str = ""
configured_sid: int = 0
score_response: Any = None
records_response: Any = None
errors: list[str] = field(default_factory=list)
sid_error: str = ""
def query_huya_activity(
client: Any,
*,
uid: int,
cookie: str,
config_info: Mapping[str, Any] | None = None,
configured_sid: int = 0,
current_sid: int = 0,
current_sid_source: str = "",
want_score: bool = False,
want_records: bool = False,
timeout: float = 15.0,
) -> HuyaActivityQuery:
"""发现当前 SID,并按需查询积分和兑换记录。
SID 发现失败但有配置兜底时不算查询错误,只在 ``sid_error`` 中保留诊断;
两个业务接口分别捕获异常,保证一个失败不会阻断另一个。
"""
configured = _int(configured_sid)
sid = _int(current_sid)
if sid:
sid_source = current_sid_source or "provided"
sid_error = ""
else:
options = points_center_options_from_config(config_info or {})
catalog = fetch_points_center_catalog(
uid,
cookie,
client=client,
include_all_tabs=False,
**options,
)
if catalog.sid:
sid = _int(catalog.sid)
sid_source = "queryAllocation"
sid_error = ""
elif configured:
sid = configured
sid_source = "config"
sid_error = catalog.message
else:
sid = 0
sid_source = ""
sid_error = catalog.message or "积分中心没有返回活动 SID"
result = HuyaActivityQuery(
sid=sid,
sid_source=sid_source,
configured_sid=configured,
sid_error=sid_error,
)
if not sid:
result.errors.append(f"无法确定当前活动 SID:{sid_error}")
return result
if want_score:
try:
result.score_response = client.query_user_score(
uid=uid,
cookie=cookie,
sid=sid,
timeout=timeout,
)
except Exception as exc: # noqa: BLE001 活动接口边界需要保留单项失败
result.errors.append(f"积分接口异常:{type(exc).__name__}: {_clip(exc)}")
if want_records:
try:
result.records_response = client.get_user_prize_records(
uid=uid,
cookie=cookie,
sid=sid,
timeout=timeout,
)
except Exception as exc: # noqa: BLE001 活动接口边界需要保留单项失败
result.errors.append(
f"兑换记录接口异常:{type(exc).__name__}: {_clip(exc)}"
)
return result
+80 -63
View File
@@ -46,8 +46,12 @@ from ..models import (
User,
)
from .audit_service import record_audit
from .collab_common import (
apply_order_visibility,
)
from .file_service import build_file_url
from .file_storage import normalize_object_key
from .huya_activity_query import query_huya_activity
from .huya_points_center import (
fetch_points_center_catalog,
resolve_points_center_config,
@@ -134,7 +138,6 @@ STATUS_TIMESTAMP_FIELDS = {
}
# 归档(可见性)与业务状态正交:只允许归档已经抢购成功、不再需要日常操作的终态行。
ARCHIVE_SCOPES: tuple[str, ...] = ("active", "archived", "all")
ARCHIVABLE_STATUSES: tuple[str, ...] = (STATUS_DONE,)
# DB-only 的批量归档不需要像外部探针那样限 50 条。
ARCHIVE_BATCH_MAX = 5000
@@ -969,14 +972,15 @@ def _resolve_group_required_points(
group_orders: Sequence[HuyaExternalOrder],
*,
client: Any | None = None,
) -> tuple[list[str], str]:
) -> tuple[list[str], str, int, str]:
"""给组内缺少 ``required_points`` 的订单按礼包名补一次快照。
返回 ``(仍未解析的单号, 说明)``。只有真的缺数据时才打积分中心接口。
返回 ``(仍未解析的单号, 说明, 当前活动 SID, SID 来源)``。只有真的缺数据时
才打积分中心接口;同一轮后续积分查询复用这里发现的 SID。
"""
missing = [order for order in group_orders if order.required_points is None]
if not missing:
return [], ""
return [], "", 0, ""
source = next(
(
@@ -987,9 +991,12 @@ def _resolve_group_required_points(
None,
)
if source is None:
return [
order.order_no for order in missing
], "组内没有可用的 CK,无法解析礼包积分"
return (
[order.order_no for order in missing],
"组内没有可用的 CK,无法解析礼包积分",
0,
"",
)
result = fetch_points_center_catalog(
int(source.uid),
@@ -999,7 +1006,13 @@ def _resolve_group_required_points(
)
catalog, message = result.goods, result.message
if not catalog:
return [order.order_no for order in missing], message
sid = int(result.sid or resolve_gifts_sid(db))
return (
[order.order_no for order in missing],
message,
sid,
"queryAllocation" if result.sid else "config",
)
by_name = {
str(gift.get("name") or "").strip(): gift
@@ -1026,7 +1039,13 @@ def _resolve_group_required_points(
unresolved.append(order.order_no)
continue
order.required_points = int(gift["price"])
return unresolved, message
sid = int(result.sid or resolve_gifts_sid(db))
return (
unresolved,
message,
sid,
"queryAllocation" if result.sid else "config",
)
def _check_recharge_group(
@@ -1089,11 +1108,13 @@ def _check_recharge_group(
code="bind_account_changed",
)
unresolved, catalog_message = _resolve_group_required_points(
db,
current,
group_orders,
client=client,
unresolved, catalog_message, current_sid, current_sid_source = (
_resolve_group_required_points(
db,
current,
group_orders,
client=client,
)
)
if unresolved:
raise CollabError(
@@ -1113,6 +1134,8 @@ def _check_recharge_group(
client=client,
source=score_source,
commit=False,
current_sid=current_sid,
current_sid_source=current_sid_source,
)
score = result.get("score")
if not result.get("ok") or score is None:
@@ -1888,11 +1911,10 @@ def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeR
def resolve_gifts_sid(db: Session) -> int:
"""当前活动 SID:**优先用工作台配置**(``huya_config.sid``),读不到再回落到默认值。
"""读取工作台配置的 SID 兜底值。
2026-10-16 真机定位:礼包接口的 SID 是「活动 ID」,会随活动轮换(9 月 2203 → 今天 2277),
对旧 SID 打过去是 ``status=200`` + 0 个商品 —— 界面上就表现为「礼包列表空白」。
工作台「虎牙配置」里改过 SID 之后,这里跟着变,不用改代码。
正常查询必须先走 :mod:`huya_activity_query`,从 ``queryAllocation`` 自动
发现当前 SID;本函数只在该接口不可用时提供兼容回退,不能再作为首选来源。
只读:配置行不存在时**不会**顺手建一行(预览是只读动作,设计文档 §2.3)。
"""
@@ -2201,6 +2223,8 @@ def query_order_activity(
client: Any | None = None,
source: str = "manual",
commit: bool = True,
current_sid: int = 0,
current_sid_source: str = "",
) -> dict:
"""查一次这个账号的**积分 / 兑换记录**,落一条生命周期快照,必要时自动改状态。
@@ -2238,31 +2262,23 @@ def query_order_activity(
if not uid or not cookie:
raise CollabError("这行还没有 CK,先贴一份再查积分/兑换记录", code="no_cookie")
sid = resolve_gifts_sid(db)
activity_client = client or _build_probe_client()
score_resp: Any = None
records_resp: Any = None
errors: list[str] = []
if want_score:
try:
score_resp = activity_client.query_user_score(
uid=int(uid),
cookie=cookie,
sid=sid,
timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 一个接口炸了不影响另一个
errors.append(f"积分接口异常:{type(exc).__name__}: {_clip(exc)}")
if want_records:
try:
records_resp = activity_client.get_user_prize_records(
uid=int(uid),
cookie=cookie,
sid=sid,
timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001
errors.append(f"兑换记录接口异常:{type(exc).__name__}: {_clip(exc)}")
activity = query_huya_activity(
activity_client,
uid=int(uid),
cookie=cookie,
config_info=resolve_points_center_config(db),
configured_sid=resolve_gifts_sid(db),
current_sid=current_sid,
current_sid_source=current_sid_source,
want_score=want_score,
want_records=want_records,
timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS,
)
sid = activity.sid
score_resp = activity.score_response
records_resp = activity.records_response
errors = activity.errors
score, gained, used, score_msg = (
_extract_score(score_resp) if want_score else (None, None, None, "")
@@ -2423,6 +2439,7 @@ def query_order_activity(
"order_no": order.order_no,
"uid": uid,
"sid": sid,
"sid_source": activity.sid_source,
"scope": scope,
"scope_label": ACTIVITY_SCOPE_LABELS.get(scope, scope),
"verdict": verdict,
@@ -2468,6 +2485,8 @@ def query_activity_batch(
)
results: list[dict] = []
current_sid = 0
current_sid_source = ""
for index, order in enumerate(rows):
if index and interval_seconds > 0:
time.sleep(interval_seconds)
@@ -2482,8 +2501,13 @@ def query_activity_batch(
client=client,
source="batch",
commit=False,
current_sid=current_sid,
current_sid_source=current_sid_source,
)
)
if not current_sid and results:
current_sid = int(results[-1].get("sid") or 0)
current_sid_source = str(results[-1].get("sid_source") or "")
except CollabError as exc:
results.append(
{
@@ -3714,12 +3738,6 @@ ORDER_SORT_KEYS: tuple[str, ...] = (
)
def normalize_archive_scope(value: str | None) -> str:
"""归档可见范围:默认只看活跃行,归档视图只返回已归档行。"""
scope = str(value or "active").strip().lower()
return scope if scope in ARCHIVE_SCOPES else "active"
def _filtered_orders_query(
db: Session,
*,
@@ -3745,13 +3763,13 @@ def _filtered_orders_query(
skin = (gift_name or "").strip()
if skin:
query = query.filter(HuyaExternalOrder.gift_name == skin)
if not include_void:
query = query.filter(HuyaExternalOrder.status != STATUS_VOID)
scope = normalize_archive_scope(archive_scope)
if scope == "active":
query = query.filter(HuyaExternalOrder.archived_at.is_(None))
elif scope == "archived":
query = query.filter(HuyaExternalOrder.archived_at.is_not(None))
query = apply_order_visibility(
query,
HuyaExternalOrder,
archive_scope=archive_scope,
include_void=include_void,
void_status=STATUS_VOID,
)
text = (keyword or "").strip()
if text:
like = f"%{text}%"
@@ -4039,14 +4057,13 @@ def summary(
db: Session, *, include_void: bool = False, archive_scope: str = "active"
) -> dict:
"""页头统计条:统计口径与当前列表的归档/作废过滤保持一致。"""
scope = normalize_archive_scope(archive_scope)
query = db.query(HuyaExternalOrder)
if not include_void:
query = query.filter(HuyaExternalOrder.status != STATUS_VOID)
if scope == "active":
query = query.filter(HuyaExternalOrder.archived_at.is_(None))
elif scope == "archived":
query = query.filter(HuyaExternalOrder.archived_at.is_not(None))
query = apply_order_visibility(
db.query(HuyaExternalOrder),
HuyaExternalOrder,
archive_scope=archive_scope,
include_void=include_void,
void_status=STATUS_VOID,
)
status_rows = (
query.with_entities(HuyaExternalOrder.status, func.count(HuyaExternalOrder.id))
+9 -35
View File
@@ -27,6 +27,7 @@ from ..models import (
ProxyConfig as ProxyConfigModel,
)
from .huya_service import upsert_huya_cookie
from .proxy_service import build_proxy_runtime
# 运行中状态:服务中断后视为未完成,可继续
RUNNING_ITEM_STATUSES = frozenset({"sending", "waiting", "changing", "logging"})
@@ -423,42 +424,16 @@ class HuyaRegisterRunner:
self.item_indices = item_indices # None 表示跑全部
self._lock = threading.Lock()
self._stop = threading.Event()
self._proxy_runtime = build_proxy_runtime(
proxy_config,
enabled=self.batch.use_proxy,
stop_event=self._stop,
)
self._shared_proxy_fetcher = self._create_proxy_fetcher()
def _create_proxy_fetcher(self) -> ProxyFetcher | None:
"""按需创建 API 代理获取器。"""
if (
not self.batch.use_proxy
or not self.proxy_config
or not self.proxy_config.enabled
):
return None
if not self.proxy_config.api_url:
return None
wl_platform = "xiequ"
wl_credentials = None
if self.proxy_config.whitelist_enabled:
wl_platform = (
getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
if (
not wl_credentials
and self.proxy_config.whitelist_uid
and self.proxy_config.whitelist_ukey
):
wl_credentials = {
"uid": self.proxy_config.whitelist_uid,
"ukey": self.proxy_config.whitelist_ukey,
}
return ProxyFetcher(
api_url=self.proxy_config.api_url,
whitelist_platform=wl_platform,
whitelist_credentials=wl_credentials,
stop_event=self._stop,
)
return self._proxy_runtime.api_fetcher
def stop(self):
self._stop.set()
@@ -613,9 +588,8 @@ class HuyaRegisterRunner:
if not self.proxy_config or not self.proxy_config.enabled:
return None, "已开启代理,但代理配置未启用"
if self.proxy_config.http or self.proxy_config.https:
proxy_url = self.proxy_config.http or self.proxy_config.https
return {"http": proxy_url, "https": proxy_url}, ""
if self._proxy_runtime.static_proxies:
return self._proxy_runtime.static_proxies, ""
if self._shared_proxy_fetcher:
proxy_url = self._shared_proxy_fetcher.fetch_new_proxy(max_attempts=3)
+8 -30
View File
@@ -17,6 +17,7 @@ from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import HuyaTask
from ..models import ProxyConfig as ProxyConfigModel
from .huya_service import cookie_value
from .proxy_service import ProxyRuntime, build_proxy_runtime
# 绑定跳转与充值下单共用(bind 与 recharge 两个域都引用了 sourceId)
HUYA_RECHARGE_SOURCE_ID = "yellowcarlist"
@@ -51,12 +52,17 @@ class HuyaBatchRunnerCore:
self._started = 0
self._wss_sessions = threading.local()
self._proxy_cfg = None
self._proxy_runtime = ProxyRuntime()
self._proxy_fetcher = None
self._static_proxies = None
if self.task_type == "exchange_goods":
self._proxy_cfg = (
db.query(ProxyConfigModel).first() if db is not None else None
)
self._proxy_runtime = build_proxy_runtime(
self._proxy_cfg,
stop_event=self._stop,
)
self._proxy_fetcher = self._create_proxy_fetcher()
self._static_proxies = self._resolve_static_proxies()
@@ -144,38 +150,10 @@ class HuyaBatchRunnerCore:
def _create_proxy_fetcher(self) -> ProxyFetcher | None:
"""兑换任务使用现有全局代理 API,每个账号任务取一个新出口。"""
cfg = self._proxy_cfg
if not cfg or not cfg.enabled or not cfg.api_url:
return None
platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
credentials = getattr(cfg, "whitelist_credentials", None)
if (
not credentials
and getattr(cfg, "whitelist_uid", "")
and getattr(cfg, "whitelist_ukey", "")
):
credentials = {
"uid": cfg.whitelist_uid,
"ukey": cfg.whitelist_ukey,
}
return ProxyFetcher(
api_url=cfg.api_url,
whitelist_platform=platform,
whitelist_credentials=(
credentials if getattr(cfg, "whitelist_enabled", False) else None
),
stop_event=self._stop,
)
return self._proxy_runtime.api_fetcher
def _resolve_static_proxies(self) -> dict[str, str] | None:
cfg = self._proxy_cfg
if not cfg or not cfg.enabled:
return None
http = str(getattr(cfg, "http", "") or "").strip()
https = str(getattr(cfg, "https", "") or "").strip()
if not http and not https:
return None
return {"http": http or https, "https": https or http}
return self._proxy_runtime.static_proxies
def _exchange_proxies(self) -> tuple[dict[str, str] | None, str, str]:
"""返回兑换任务的代理、来源和错误;其他任务不会调用这里。"""
+26 -22
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM
from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask
from .huya_activity_query import query_huya_activity
from .huya_pay_link import pay_qr_path, resolve_pay_targets
from .huya_points_center import (
fetch_points_center_catalog,
@@ -292,40 +293,43 @@ class GoodsMixin:
return
client: Any = self._http_client()
configured_sid = self._to_int(self.payload.get("sid") or config_info.get("sid"))
sid_int = configured_sid
sid_source = "config"
try:
# 2026-11-13 HAR:兑换记录只借 queryAllocation 取活动 SID,
# 不重复拉其它商品页签。
catalog = fetch_points_center_catalog(
uid,
cookie,
client=client,
include_all_tabs=False,
**points_center_options_from_config(config_info),
)
discovered_sid = catalog.sid
if discovered_sid:
sid_int = discovered_sid
sid_source = "queryAllocation"
except Exception as exc: # noqa: BLE001 自动发现失败时回退配置值
activity = query_huya_activity(
client,
uid=uid,
cookie=cookie,
config_info=config_info,
configured_sid=self._to_int(
self.payload.get("sid") or config_info.get("sid")
),
want_records=True,
)
sid_int = activity.sid
configured_sid = activity.configured_sid
sid_source = activity.sid_source
if activity.sid_error and sid_int:
self._push_log(
"warning",
f"[{uid}] 自动发现兑换活动 SID 失败,回退配置值: {exc}",
f"[{uid}] 自动发现兑换活动 SID 失败,回退配置值: {activity.sid_error}",
)
if not sid_int:
self._mark_task(
worker_db,
task,
"failed",
"无法确定当前兑换活动 SID,请先刷新积分中心商品",
activity.errors[-1]
if activity.errors
else "无法确定当前兑换活动 SID,请先刷新积分中心商品",
)
return
response = client.get_user_prize_records(uid=uid, cookie=cookie, sid=sid_int)
response = activity.records_response
if response is None:
self._mark_task(worker_db, task, "error", "虎牙兑换记录接口无响应")
self._mark_task(
worker_db,
task,
"error",
activity.errors[-1] if activity.errors else "虎牙兑换记录接口无响应",
)
return
result = response.to_dict()
+13 -33
View File
@@ -16,7 +16,6 @@ from sqlalchemy.orm import Session, object_session
from core.douyu import DouyuLogin, create_login_api_strategy
from core.douyu.login import AccountLike
from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import Account as AccountModel
from ..models import LoginTask
@@ -31,6 +30,7 @@ from .account_lifecycle import on_ck_verdict, on_login_success, sync_status
from .cookie_check_service import check_douyu_cookie
from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
from .login_failure import classify_failure, failure_kind_label, is_account_fault
from .proxy_service import build_proxy_runtime
CHECK_STATUS_MESSAGES = {
"account_cancelled": "账号已注销",
@@ -265,34 +265,11 @@ class LoginBatchRunner:
self._completed = 0
# 共享代理获取器(无池,每次取新代理)
self._shared_proxy_fetcher = None
proxy_config = self.proxy_config
if proxy_config and proxy_config.enabled and proxy_config.api_url:
wl_platform = "xiequ"
wl_credentials = None
if proxy_config.whitelist_enabled:
wl_platform = (
getattr(proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(proxy_config, "whitelist_credentials", None)
# 向后兼容
if (
not wl_credentials
and proxy_config.whitelist_uid
and proxy_config.whitelist_ukey
):
wl_platform = "xiequ"
wl_credentials = {
"uid": proxy_config.whitelist_uid,
"ukey": proxy_config.whitelist_ukey,
}
self._shared_proxy_fetcher = ProxyFetcher(
api_url=proxy_config.api_url,
whitelist_platform=wl_platform,
whitelist_credentials=wl_credentials,
stop_event=self._stop,
)
self._proxy_runtime = build_proxy_runtime(
self.proxy_config,
stop_event=self._stop,
)
self._shared_proxy_fetcher = self._proxy_runtime.api_fetcher
def stop(self):
self._stop.set()
@@ -369,10 +346,13 @@ class LoginBatchRunner:
if not self.proxy_config or not self.proxy_config.enabled:
return None, ""
# 静态代理
if self.proxy_config.http or self.proxy_config.https:
proxy_url = self.proxy_config.http or self.proxy_config.https
return {"http": proxy_url, "https": proxy_url}, f"使用静态代理: {proxy_url}"
if self._proxy_runtime.static_proxies:
proxy_url = (
self._proxy_runtime.static_proxies.get("https")
or self._proxy_runtime.static_proxies.get("http")
or ""
)
return self._proxy_runtime.static_proxies, f"使用静态代理: {proxy_url}"
# API代理:由 DouyuLogin 通过 proxy_fetcher 内部管理
return None, ""
+48
View File
@@ -3,6 +3,7 @@
import asyncio
import threading
import uuid
from dataclasses import dataclass
from sqlalchemy.orm import Session
@@ -11,6 +12,7 @@ from core.douyu.proxy import (
resolve_working_proxy,
verify_proxy_url,
)
from core.douyu.proxy_fetcher import ProxyFetcher
from core.douyu.proxy_platforms import create_adapter
from core.douyu.proxy_platforms.base import _get_local_exit_ip
@@ -18,6 +20,52 @@ from ..models import AuditLog
from ..models import ProxyConfig as ProxyConfigModel
@dataclass(frozen=True)
class ProxyRuntime:
"""一次任务运行期间可复用的代理来源。"""
enabled: bool = False
static_proxies: dict[str, str] | None = None
api_fetcher: ProxyFetcher | None = None
def build_proxy_runtime(
cfg: ProxyConfigModel | None,
*,
enabled: bool = True,
stop_event: threading.Event | None = None,
) -> ProxyRuntime:
"""把数据库代理配置规范成静态代理或 API 获取器。
调用方仍决定代理不可用时是失败还是降级直连;这里只负责唯一的配置映射,
避免登录、CK 检测、注册和任务执行器各写一套白名单兼容逻辑。
"""
if not enabled or cfg is None or not cfg.enabled:
return ProxyRuntime()
http = str(getattr(cfg, "http", "") or "").strip()
https = str(getattr(cfg, "https", "") or "").strip()
static_proxies = (
{"http": http or https, "https": https or http} if http or https else None
)
api_fetcher = None
api_url = str(getattr(cfg, "api_url", "") or "").strip()
if api_url:
whitelist = _build_whitelist_params(cfg)
api_fetcher = ProxyFetcher(
api_url=api_url,
whitelist_platform=whitelist["whitelist_platform"],
whitelist_credentials=whitelist["whitelist_credentials"],
stop_event=stop_event,
)
return ProxyRuntime(
enabled=True,
static_proxies=static_proxies,
api_fetcher=api_fetcher,
)
def _build_whitelist_params(cfg: ProxyConfigModel) -> dict:
"""从 ProxyConfig 构建白名单参数,自动处理新旧字段兼容。
+20 -19
View File
@@ -16,7 +16,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from core.weibo import WeiboClient, WeiboError
from core.weibo import WeiboError
from ..models import (
AuditLog,
@@ -28,7 +28,12 @@ from ..models import (
WeiboExternalOrderMetric,
)
from .audit_service import record_audit
from .weibo_service import query_weibo_mall, refresh_weibo_account
from .collab_common import apply_order_visibility, count_archived
from .weibo_service import (
query_weibo_exchange_records,
query_weibo_mall,
refresh_weibo_account,
)
STATUS_INCOMPLETE = "incomplete"
STATUS_TO_RECHARGE = "to_recharge"
@@ -99,7 +104,6 @@ VERDICT_LABELS = {
VERDICT_UNKNOWN: "无法判定",
}
ARCHIVE_SCOPES = ("active", "archived", "all")
ARCHIVABLE_STATUSES = (STATUS_DONE,)
MAX_IMAGES = 20
CHECK_BATCH_MAX = 50
@@ -1029,7 +1033,7 @@ def check_balance_groups(
)
continue
try:
data = WeiboClient(account.cookie or "").list_mall(activity)
data = query_weibo_mall(db, account, activity=activity)
except (WeiboError, ValueError) as exc:
for order in account_orders:
failures.append(
@@ -1233,8 +1237,10 @@ def check_exchange_records(
)
continue
try:
data = WeiboClient(account.cookie or "").list_exchange_records(
activity,
data = query_weibo_exchange_records(
db,
account,
activity=activity,
page=1,
page_size=100,
)
@@ -1622,13 +1628,13 @@ def _apply_filters(
keyword: str = "",
include_void: bool = False,
):
scope = archive_scope if archive_scope in ARCHIVE_SCOPES else "active"
if scope == "active":
query = query.filter(WeiboExternalOrder.archived_at.is_(None))
elif scope == "archived":
query = query.filter(WeiboExternalOrder.archived_at.isnot(None))
if not include_void:
query = query.filter(WeiboExternalOrder.status != STATUS_VOID)
query = apply_order_visibility(
query,
WeiboExternalOrder,
archive_scope=archive_scope,
include_void=include_void,
void_status=STATUS_VOID,
)
if _text(status):
query = query.filter(WeiboExternalOrder.status == status)
if _text(ck_status):
@@ -1798,12 +1804,7 @@ def summary(
),
"account_disabled": int(account_disabled),
"ck_invalid": int(ck_invalid),
"archived_total": (
db.query(func.count(WeiboExternalOrder.id))
.filter(WeiboExternalOrder.archived_at.isnot(None))
.scalar()
or 0
),
"archived_total": count_archived(db, WeiboExternalOrder),
}
@@ -0,0 +1,94 @@
import { Col, Form, Input, Row, Select, Switch, Typography } from 'antd';
const { Text } = Typography;
type HuyaConfigScope = 'all' | 'points';
interface HuyaConfigFieldsProps {
scope?: HuyaConfigScope;
}
export default function HuyaConfigFields({ scope = 'all' }: HuyaConfigFieldsProps) {
const showAll = scope === 'all';
return (
<Row gutter={12}>
{showAll && (
<>
<Col span={12}>
<Form.Item label="直播间 PID" name="room_pid">
<Input placeholder="1199650619883" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="活动 SID(兜底)" name="sid">
<Input placeholder="优先自动发现,如 2277" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="外部活动 ID" name="outer_act_id">
<Input placeholder="17096" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 gid" name="bind_gid">
<Input placeholder="3203" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定活动 ID" name="bind_act_id">
<Input placeholder="9271" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="支付渠道" name="pay_channel">
<Select options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' },
]} />
</Form.Item>
</Col>
</>
)}
<Col span={12}>
<Form.Item label="积分中心 pageCode" name="points_page_code">
<Input placeholder="heping_baodian_credit_center_page" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="积分中心 sourceId" name="points_source_id">
<Input placeholder="yellowcarlist" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="积分中心 scene" name="points_scene">
<Input placeholder="7" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="页签 pageId(可选)" name="points_page_id">
<Input placeholder="自动发现,如 343" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="页签 IDs(可选)" name="points_tab_ids">
<Input placeholder="自动拉全部,如 431,433" />
</Form.Item>
</Col>
{showAll && (
<Col span={24}>
<Form.Item
label="兑换使用代理"
name="exchange_use_proxy"
getValueProps={(value) => ({
checked: ['1', 'true', 'on', 'yes'].includes(String(value || '0').toLowerCase()),
})}
normalize={(checked: boolean) => (checked ? '1' : '0')}
extra={<Text type="secondary">默认关闭;开启后仅精英宝典兑换链路使用全局代理配置</Text>}
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
</Col>
)}
</Row>
);
}
+2 -27
View File
@@ -40,6 +40,7 @@ import {
type HuyaCollabSummary,
type HuyaConfig,
} from '../api/modules';
import HuyaConfigFields from '../components/HuyaConfigFields';
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
@@ -3790,33 +3791,7 @@ export default function HuyaCollabPage() {
description="保存后回到新建/补资料里点一次「强制刷新礼包」即可拉取最新商品;pageId 和页签 IDs 留空表示自动。"
/>
<Form form={pointsConfigForm} layout="vertical" disabled={pointsConfigLoading}>
<Row gutter={12}>
<Col span={12}>
<Form.Item label="pageCode" name="points_page_code">
<Input placeholder="heping_baodian_credit_center_page" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="sourceId" name="points_source_id">
<Input placeholder="yellowcarlist" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="scene" name="points_scene">
<Input placeholder="7" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="pageId(可选)" name="points_page_id">
<Input placeholder="自动发现,如 343" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="页签 IDs(可选)" name="points_tab_ids">
<Input placeholder="自动拉全部,如 431,433" />
</Form.Item>
</Col>
</Row>
<HuyaConfigFields scope="points" />
</Form>
</Space>
</Modal>
+17 -9
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Switch, Table, Tag, Tooltip, Typography,
Button, Card, Form, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
@@ -10,6 +10,7 @@ import {
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, SwapOutlined, ZoomInOutlined,
} from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
import HuyaConfigFields from '../components/HuyaConfigFields';
import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
@@ -27,7 +28,6 @@ const PAGE_SIZE_KEY = 'huya_elite_page_size';
const RECHARGE_SPU_KEY = 'huya_elite_recharge_spu';
const RECHARGE_COUNT_KEY = 'huya_elite_recharge_count';
const ELITE_SPU_KEY = 'huya_elite_elite_spu';
const EXCHANGE_PROXY_ENABLED_VALUES = new Set(['1', 'true', 'on', 'yes']);
// 与后端 ELITE_HANDBOOK_SPU_IDS 对齐:精英宝典单独一块,不和其他开通商品混在一个下拉里
const ELITE_HANDBOOK_SPU_IDS = ['hy-8328015', 'hy-5879340', 'hy-3845257', 'hy-2947756'];
const ELITE_HANDBOOK_NAME_KEYWORD = '精英宝典';
@@ -235,7 +235,7 @@ export default function HuyaElitePage() {
const [zoomPayTask, setZoomPayTask] = useState<HuyaTaskItem | null>(null);
const [ckImportOpen, setCkImportOpen] = useState(false);
const [config, setConfig] = useState<HuyaConfig | null>(null);
const [configDraft, setConfigDraft] = useState<HuyaConfig | null>(null);
const [configForm] = Form.useForm<HuyaConfig>();
const [configOpen, setConfigOpen] = useState(false);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
@@ -472,17 +472,26 @@ export default function HuyaElitePage() {
const openSettings = async () => {
if (!canConfig) return;
setConfigOpen(true);
if (config) { setConfigDraft({ ...config }); return; }
setConfigLoading(true);
try { const current = await huyaApi.getConfig(); setConfig(current); setConfigDraft({ ...current }); }
try {
const current = await huyaApi.getConfig();
setConfig(current);
configForm.setFieldsValue(current);
}
catch (error) { setConfigOpen(false); message.error(getErrorMessage(error)); }
finally { setConfigLoading(false); }
};
const saveConfig = async () => {
if (!configDraft) return;
setConfigSaving(true);
try { const saved = await huyaApi.updateConfig(configDraft); setConfig(saved); setConfigDraft({ ...saved }); setConfigOpen(false); message.success('配置已保存'); }
try {
const values = await configForm.validateFields();
const saved = await huyaApi.updateConfig(values);
setConfig(saved);
configForm.setFieldsValue(saved);
setConfigOpen(false);
message.success('配置已保存');
}
catch (error) { message.error(getErrorMessage(error)); } finally { setConfigSaving(false); }
};
@@ -663,7 +672,6 @@ export default function HuyaElitePage() {
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}{canTransferCollab && selectedIds.length === 1 && <Button size="small" icon={<SwapOutlined />} loading={transferring} onClick={() => void transferToCollab()}>转入外部单协作</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'query_exchange_records' && taskResultRecords(task).length > 0) setRecordsTask(task); else if (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
const contextMenuStyle = contextMenu ? {
position: 'fixed' as const,
zIndex: 1000,
@@ -759,7 +767,7 @@ export default function HuyaElitePage() {
</div>}
</Modal>
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={560}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}{configField('points_page_code', '积分中心 pageCode')}{configField('points_source_id', '积分中心 sourceId')}{configField('points_scene', '积分中心 scene')}{configField('points_page_id', '页签 pageId(可选)')}{configField('points_tab_ids', '页签 IDs(可选)')}<Space style={{ width: '100%', justifyContent: 'space-between' }} align="center"><div><Text>兑换使用代理</Text><br /><Text type="secondary">默认关闭;开启后兑换链路使用全局代理配置</Text></div><Switch checked={EXCHANGE_PROXY_ENABLED_VALUES.has(String(configDraft.exchange_use_proxy || '0').toLowerCase())} onChange={(checked) => setConfigDraft({ ...configDraft, exchange_use_proxy: checked ? '1' : '0' })} /></Space><Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={680}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : <Space direction="vertical" size={10} style={{ width: '100%' }}><Form form={configForm} layout="vertical"><HuyaConfigFields /></Form><Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
</div>;
}
+44 -70
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button, Card, Col, DatePicker, Dropdown, Form, Input, InputNumber, Modal, QRCode, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography, theme,
Button, Card, DatePicker, Dropdown, Form, Input, InputNumber, Modal, QRCode, Radio, Select, Space, Table, Tag, Tooltip, Typography, theme,
} from 'antd';
import { message } from '../utils/antdMessage';
import type { MenuProps, TableProps } from 'antd';
@@ -18,6 +18,7 @@ import {
type HuyaRechargeGoodsItem,
type HuyaTaskItem,
} from '../api/modules';
import HuyaConfigFields from '../components/HuyaConfigFields';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
@@ -282,6 +283,7 @@ export default function HuyaTasksPage() {
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
const [goodsLoading, setGoodsLoading] = useState(false);
const [rechargeGoods, setRechargeGoods] = useState<HuyaRechargeGoodsItem[]>([]);
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
const [tags, setTags] = useState<string[]>([]);
@@ -322,6 +324,7 @@ export default function HuyaTasksPage() {
const accountTableAreaRef = useRef<HTMLDivElement | null>(null);
const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460);
const tasksLoadingRef = useRef(false);
const liveGoodsRequestId = useRef(0);
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
const { can } = usePermissions();
@@ -403,10 +406,9 @@ export default function HuyaTasksPage() {
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [accountResult, taskResult, goodsResult, rechargeGoodsResult, configResult, taskTypeResult, tagResult] = await Promise.allSettled([
const [accountResult, taskResult, rechargeGoodsResult, configResult, taskTypeResult, tagResult] = await Promise.allSettled([
huyaApi.listAccounts({ include_cookie: false }),
huyaApi.listTasks(),
huyaApi.listGoods(),
huyaApi.listRechargeGoods(),
canConfig ? huyaApi.getConfig() : Promise.resolve(null),
huyaApi.taskTypes(),
@@ -437,7 +439,6 @@ export default function HuyaTasksPage() {
setBatchId(null);
}
}
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 });
@@ -446,7 +447,6 @@ export default function HuyaTasksPage() {
const failedLabels = [
accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '',
taskResult.status === 'rejected' ? `任务记录: ${getErrorMessage(taskResult.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)}` : '',
@@ -460,6 +460,38 @@ export default function HuyaTasksPage() {
}
}, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
const loadLiveGoods = useCallback(async (accountId: number | null) => {
const requestId = ++liveGoodsRequestId.current;
if (!accountId) {
setGoods([]);
setSelectedExchangeGoodsId('');
return;
}
setGoodsLoading(true);
try {
const result = await huyaApi.listLiveGoods(accountId);
if (liveGoodsRequestId.current !== requestId) return;
setGoods(result);
setSelectedExchangeGoodsId((current) => (
result.some((item) => item.product_id === current)
? current
: result[0]?.product_id || ''
));
} catch (error) {
if (liveGoodsRequestId.current !== requestId) return;
setGoods([]);
setSelectedExchangeGoodsId('');
message.error(`实时商品加载失败:${getErrorMessage(error)}`);
} finally {
if (liveGoodsRequestId.current === requestId) setGoodsLoading(false);
}
}, []);
const goodsAccountId = selectedIds[0] ?? accounts[0]?.id ?? null;
useEffect(() => {
void loadLiveGoods(goodsAccountId);
}, [goodsAccountId, loadLiveGoods]);
const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
@@ -1716,8 +1748,9 @@ export default function HuyaTasksPage() {
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => startTask('refresh_goods')}
disabled={!canTask || selectedIds.length === 0 || batchBusy || wsConnected}
loading={goodsLoading}
onClick={() => void loadLiveGoods(goodsAccountId)}
disabled={!canTask || !goodsAccountId || goodsLoading}
>
刷新
</Button>
@@ -1738,7 +1771,9 @@ export default function HuyaTasksPage() {
<Select
showSearch
allowClear
placeholder="选择兑换商品"
loading={goodsLoading}
disabled={!goodsAccountId || goodsLoading}
placeholder={goodsLoading ? '正在读取实时商品' : !goodsAccountId ? '请先选择账号' : '暂无实时商品'}
value={selectedExchangeGoodsId || undefined}
onChange={(value) => setSelectedExchangeGoodsId(value || '')}
options={goodsOptions}
@@ -1834,68 +1869,7 @@ export default function HuyaTasksPage() {
)}
>
<Form form={form} layout="vertical" disabled={!canConfig}>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="直播间 ID" name="room_pid">
<Input placeholder="1199650619883" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="SID" name="sid">
<Input placeholder="2203" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="兑换活动 ID" name="outer_act_id">
<Input placeholder="9504" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 gid" name="bind_gid">
<Input placeholder="3203" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 bActId" name="bind_act_id">
<Input placeholder="9271" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="积分中心 pageCode" name="points_page_code">
<Input placeholder="heping_baodian_credit_center_page" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="积分中心 sourceId" name="points_source_id">
<Input placeholder="yellowcarlist" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="积分中心 scene" name="points_scene">
<Input placeholder="7" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="页签 pageId(可选)" name="points_page_id">
<Input placeholder="留空自动发现,如 343" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="页签 IDs(可选,逗号分隔)" name="points_tab_ids">
<Input placeholder="留空自动拉全部,如 431,433" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="支付渠道" name="pay_channel">
<Select
options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' },
]}
/>
</Form.Item>
</Col>
</Row>
<HuyaConfigFields />
</Form>
</Card>
</Space>