diff --git a/core/huya/http_client.py b/core/huya/http_client.py
index 62792ea..86c800b 100644
--- a/core/huya/http_client.py
+++ b/core/huya/http_client.py
@@ -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,
diff --git a/core/huya/rpc_requests.py b/core/huya/rpc_requests.py
new file mode 100644
index 0000000..02c7f4b
--- /dev/null
+++ b/core/huya/rpc_requests.py
@@ -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)
diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py
index 4ee1cfa..9963f1f 100644
--- a/core/huya/wss_client.py
+++ b/core/huya/wss_client.py
@@ -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
diff --git a/tests/test_huya_activity_query.py b/tests/test_huya_activity_query.py
new file mode 100644
index 0000000..3df02ae
--- /dev/null
+++ b/tests/test_huya_activity_query.py
@@ -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
diff --git a/tests/test_huya_rpc_requests.py b/tests/test_huya_rpc_requests.py
new file mode 100644
index 0000000..89dd21d
--- /dev/null
+++ b/tests/test_huya_rpc_requests.py
@@ -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
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 3acf135..da69ff0 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -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 = (
diff --git a/tests/test_proxy_runtime.py b/tests/test_proxy_runtime.py
new file mode 100644
index 0000000..a9a1538
--- /dev/null
+++ b/tests/test_proxy_runtime.py
@@ -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
diff --git a/tests/test_weibo_collab.py b/tests/test_weibo_collab.py
index 9b166c3..fdea71e 100644
--- a/tests/test_weibo_collab.py
+++ b/tests/test_weibo_collab.py
@@ -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)
diff --git a/web/backend/services/account_check_service.py b/web/backend/services/account_check_service.py
index 2222bf3..0997024 100644
--- a/web/backend/services/account_check_service.py
+++ b/web/backend/services/account_check_service.py
@@ -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, ""
diff --git a/web/backend/services/collab_common.py b/web/backend/services/collab_common.py
new file mode 100644
index 0000000..d187f3f
--- /dev/null
+++ b/web/backend/services/collab_common.py
@@ -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
+ )
diff --git a/web/backend/services/douyu_collab_service.py b/web/backend/services/douyu_collab_service.py
index 5346edc..f1b87b4 100644
--- a/web/backend/services/douyu_collab_service.py
+++ b/web/backend/services/douyu_collab_service.py
@@ -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(
diff --git a/web/backend/services/douyu_goods_service.py b/web/backend/services/douyu_goods_service.py
new file mode 100644
index 0000000..1bccfd1
--- /dev/null
+++ b/web/backend/services/douyu_goods_service.py
@@ -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()
diff --git a/web/backend/services/douyu_runner_core.py b/web/backend/services/douyu_runner_core.py
index e4777a7..69f0d0a 100644
--- a/web/backend/services/douyu_runner_core.py
+++ b/web/backend/services/douyu_runner_core.py
@@ -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)
diff --git a/web/backend/services/huya_activity_query.py b/web/backend/services/huya_activity_query.py
new file mode 100644
index 0000000..f6b1ba8
--- /dev/null
+++ b/web/backend/services/huya_activity_query.py
@@ -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
diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py
index 2372299..fbddc5a 100644
--- a/web/backend/services/huya_collab_service.py
+++ b/web/backend/services/huya_collab_service.py
@@ -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))
diff --git a/web/backend/services/huya_register_runner.py b/web/backend/services/huya_register_runner.py
index db63e91..cca830f 100644
--- a/web/backend/services/huya_register_runner.py
+++ b/web/backend/services/huya_register_runner.py
@@ -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)
diff --git a/web/backend/services/huya_runner_core.py b/web/backend/services/huya_runner_core.py
index f465ff3..3982e92 100644
--- a/web/backend/services/huya_runner_core.py
+++ b/web/backend/services/huya_runner_core.py
@@ -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]:
"""返回兑换任务的代理、来源和错误;其他任务不会调用这里。"""
diff --git a/web/backend/services/huya_runner_goods.py b/web/backend/services/huya_runner_goods.py
index db5935d..5421cf6 100644
--- a/web/backend/services/huya_runner_goods.py
+++ b/web/backend/services/huya_runner_goods.py
@@ -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()
diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py
index e7927f2..5ff88b8 100644
--- a/web/backend/services/login_service.py
+++ b/web/backend/services/login_service.py
@@ -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, ""
diff --git a/web/backend/services/proxy_service.py b/web/backend/services/proxy_service.py
index 7afb1ea..63cf49f 100644
--- a/web/backend/services/proxy_service.py
+++ b/web/backend/services/proxy_service.py
@@ -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 构建白名单参数,自动处理新旧字段兼容。
diff --git a/web/backend/services/weibo_collab_service.py b/web/backend/services/weibo_collab_service.py
index 0d0953e..bc6f48c 100644
--- a/web/backend/services/weibo_collab_service.py
+++ b/web/backend/services/weibo_collab_service.py
@@ -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),
}
diff --git a/web/frontend/src/components/HuyaConfigFields.tsx b/web/frontend/src/components/HuyaConfigFields.tsx
new file mode 100644
index 0000000..c30e853
--- /dev/null
+++ b/web/frontend/src/components/HuyaConfigFields.tsx
@@ -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 (
+