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 ( + + {showAll && ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {showAll && ( + + ({ + checked: ['1', 'true', 'on', 'yes'].includes(String(value || '0').toLowerCase()), + })} + normalize={(checked: boolean) => (checked ? '1' : '0')} + extra={默认关闭;开启后仅精英宝典兑换链路使用全局代理配置} + > + + + + )} + + ); +} diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index c6efe53..6a1eb8d 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -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 留空表示自动。" />
- - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 3dbbe68..c948965 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -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(null); const [ckImportOpen, setCkImportOpen] = useState(false); const [config, setConfig] = useState(null); - const [configDraft, setConfigDraft] = useState(null); + const [configForm] = Form.useForm(); 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 = <>} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }{canTransferCollab && selectedIds.length === 1 && }
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: '右键打开账号动作' })} />; - const configField = (key: Exclude, label: string) => configDraft && {label} setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} />; const contextMenuStyle = contextMenu ? { position: 'fixed' as const, zIndex: 1000, @@ -759,7 +767,7 @@ export default function HuyaElitePage() { } 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="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} />
!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)) }} /> - setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={560}>{configLoading ?
加载中...
: configDraft && {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(可选)')}
兑换使用代理
默认关闭;开启后兑换链路使用全局代理配置
setConfigDraft({ ...configDraft, exchange_use_proxy: checked ? '1' : '0' })} />
空闲轮询间隔(秒) { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} />
}
+ setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={680}>{configLoading ?
加载中...
:
空闲轮询间隔(秒) { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} />
}
setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} /> ; } diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 169ca82..97fa07f 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -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([]); const [tasks, setTasks] = useState([]); const [goods, setGoods] = useState([]); + const [goodsLoading, setGoodsLoading] = useState(false); const [rechargeGoods, setRechargeGoods] = useState([]); const [taskTypes, setTaskTypes] = useState>(FALLBACK_TASK_TYPES); const [tags, setTags] = useState([]); @@ -322,6 +324,7 @@ export default function HuyaTasksPage() { const accountTableAreaRef = useRef(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() { @@ -1738,7 +1771,9 @@ export default function HuyaTasksPage() { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -