refactor: 收口跨平台重复业务实现
This commit is contained in:
+122
-133
@@ -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,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""HTTP/WSS 共用的虎牙 RPC 请求构造。
|
||||
|
||||
两个传输客户端仍各自处理同步/异步发送与响应解码;这里只保存业务参数到
|
||||
TAF 请求结构体的唯一映射,防止字段默认值在两条链路中发生漂移。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .activity_structs import (
|
||||
GetActPrizeDetailReq,
|
||||
GetActPrizeDetailResp,
|
||||
GetActTaskDetailReq,
|
||||
GetActTaskDetailResp,
|
||||
GetActUserTaskDetailReq,
|
||||
GetActUserTaskDetailResp,
|
||||
GetUserPrizeRecordsReq,
|
||||
GetUserPrizeRecordsResp,
|
||||
GetUserScoreReq,
|
||||
GetUserScoreResp,
|
||||
ScoreExchangePrizeReq,
|
||||
ScoreExchangePrizeResp,
|
||||
)
|
||||
from .shop_structs import (
|
||||
CreateOrderAccountParam,
|
||||
CreateOrderExtraParam,
|
||||
CreateOrderItemReqV6,
|
||||
CreateOrderPromotionParam,
|
||||
CreateOrderReqV6,
|
||||
CreateOrderRspV6,
|
||||
GetGoodsInfoReqV5,
|
||||
GoodsInfoRsp,
|
||||
PlayMallGetMyPromotionReq,
|
||||
PlayMallQueryAllocationReq,
|
||||
PromotionGoodsKey,
|
||||
shop_spu_number,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RpcRequest:
|
||||
service: str
|
||||
method: str
|
||||
request: Any
|
||||
response_type: Any = None
|
||||
|
||||
|
||||
def activity_score_request(user: Any, sid: int) -> RpcRequest:
|
||||
request = GetUserScoreReq()
|
||||
request.userId = user
|
||||
request.sid = int(sid or 0)
|
||||
return RpcRequest("webActUI", "getUserScore", request, GetUserScoreResp)
|
||||
|
||||
|
||||
def activity_records_request(user: Any, sid: int) -> RpcRequest:
|
||||
request = GetUserPrizeRecordsReq()
|
||||
request.userId = user
|
||||
request.sid = int(sid or 0)
|
||||
return RpcRequest(
|
||||
"webActUI",
|
||||
"getUserPrizeRecords",
|
||||
request,
|
||||
GetUserPrizeRecordsResp,
|
||||
)
|
||||
|
||||
|
||||
def activity_exchange_request(user: Any, sid: int, pid: int) -> RpcRequest:
|
||||
request = ScoreExchangePrizeReq()
|
||||
request.userId = user
|
||||
request.sid = int(sid or 0)
|
||||
request.pid = int(pid or 0)
|
||||
return RpcRequest("webActUI", "scoreExchangePrize", request, ScoreExchangePrizeResp)
|
||||
|
||||
|
||||
def activity_prize_detail_request(user: Any, sid: int, pid: int) -> RpcRequest:
|
||||
request = GetActPrizeDetailReq()
|
||||
request.userId = user
|
||||
request.sid = int(sid or 0)
|
||||
request.pid = int(pid or 0)
|
||||
return RpcRequest(
|
||||
"webActUI",
|
||||
"getActPrizeDetail",
|
||||
request,
|
||||
GetActPrizeDetailResp,
|
||||
)
|
||||
|
||||
|
||||
def activity_task_detail_request(user: Any, act_id: int) -> RpcRequest:
|
||||
request = GetActTaskDetailReq()
|
||||
request.userId = user
|
||||
request.actId = int(act_id or 0)
|
||||
return RpcRequest("webActUI", "getActTaskDetail", request, GetActTaskDetailResp)
|
||||
|
||||
|
||||
def activity_user_task_detail_request(user: Any, act_id: int) -> RpcRequest:
|
||||
request = GetActUserTaskDetailReq()
|
||||
request.userId = user
|
||||
request.actId = int(act_id or 0)
|
||||
return RpcRequest(
|
||||
"webActUI",
|
||||
"getActUserTaskDetail",
|
||||
request,
|
||||
GetActUserTaskDetailResp,
|
||||
)
|
||||
|
||||
|
||||
def goods_info_request(
|
||||
user: Any,
|
||||
shop_app: Any,
|
||||
*,
|
||||
pid: int,
|
||||
spu_id: str,
|
||||
sku_id: int = 0,
|
||||
game_id: str = "",
|
||||
) -> RpcRequest:
|
||||
request = GetGoodsInfoReqV5()
|
||||
request.userId = user
|
||||
request.shopAppInfo = shop_app
|
||||
request.pid = pid
|
||||
request.spuId = spu_id
|
||||
request.skuId = sku_id
|
||||
request.gameId = game_id
|
||||
return RpcRequest("shopMiddleUI", "getGoodsInfoV5", request, GoodsInfoRsp)
|
||||
|
||||
|
||||
def play_mall_allocation_request(
|
||||
user: Any,
|
||||
app_info: Any,
|
||||
*,
|
||||
page_code: str,
|
||||
scene: int,
|
||||
page_id: int = 0,
|
||||
waterfall_tab_id: int | str | None = None,
|
||||
) -> RpcRequest:
|
||||
request = PlayMallQueryAllocationReq()
|
||||
request.userId = user
|
||||
request.appInfo = app_info
|
||||
request.appInfo.scene = int(scene or 0)
|
||||
request.pageCode = page_code
|
||||
if waterfall_tab_id not in (None, ""):
|
||||
request.blank7 = json.dumps(
|
||||
{"waterfallTabId": str(waterfall_tab_id)},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
request.flag9 = int(page_id or 0)
|
||||
request.flags11 = []
|
||||
return RpcRequest("PlayMallUI", "queryAllocation", request)
|
||||
|
||||
|
||||
def play_mall_promotion_request(
|
||||
user: Any,
|
||||
app_info: Any,
|
||||
*,
|
||||
spu_id: str,
|
||||
sku_id: int = 0,
|
||||
price: int = 0,
|
||||
supplier_id: int = 0,
|
||||
game_id: int = 0,
|
||||
) -> RpcRequest:
|
||||
spu_num = shop_spu_number(spu_id)
|
||||
request = PlayMallGetMyPromotionReq()
|
||||
request.userId = user
|
||||
request.appInfo = app_info
|
||||
goods = PromotionGoodsKey()
|
||||
goods.supplierId = int(supplier_id or 0)
|
||||
goods.spuNum = spu_num
|
||||
goods.skuId = int(sku_id or 0)
|
||||
goods.price = int(price or 0)
|
||||
goods.gameId = int(game_id or 0)
|
||||
goods.spuNum2 = spu_num
|
||||
request.goods = goods
|
||||
return RpcRequest("PlayMallUI", "getMyPromotion", request)
|
||||
|
||||
|
||||
def create_order_v6_request(
|
||||
user: Any,
|
||||
shop_app: Any,
|
||||
*,
|
||||
pid: int,
|
||||
items: list[dict],
|
||||
source_id: str,
|
||||
game_id: str = "0",
|
||||
biz_params: str,
|
||||
) -> RpcRequest:
|
||||
request = CreateOrderReqV6()
|
||||
request.userId = user
|
||||
request.shopAppInfo = shop_app
|
||||
request.items = []
|
||||
for item in items:
|
||||
row = CreateOrderItemReqV6()
|
||||
row.spuId = str(item.get("spu_id") or "")
|
||||
row.skuId = int(item.get("sku_id") or 0)
|
||||
row.itemCount = int(item.get("item_count") or 1)
|
||||
row.goodsType = int(item.get("goods_type") or 0)
|
||||
row.virtualType = int(item.get("virtual_type") or 0)
|
||||
request.items.append(row)
|
||||
request.pid = int(pid or 0)
|
||||
request.gameId = str(game_id or "0")
|
||||
request.src = 2
|
||||
request.orderType = 6
|
||||
extra = CreateOrderExtraParam()
|
||||
extra.bizParams = biz_params
|
||||
request.extraParam = extra
|
||||
request.sourceId = source_id
|
||||
request.env = {"user_click_seq": "[]"}
|
||||
request.orderScene = 3
|
||||
request.promotionParam = CreateOrderPromotionParam()
|
||||
request.accountParam = CreateOrderAccountParam()
|
||||
return RpcRequest("shopMiddleUI", "createOrderV6", request, CreateOrderRspV6)
|
||||
+121
-111
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, ""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
"""返回兑换任务的代理、来源和错误;其他任务不会调用这里。"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, ""
|
||||
|
||||
@@ -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 构建白名单参数,自动处理新旧字段兼容。
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from zoneinfo import ZoneInfo
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.weibo import WeiboClient, WeiboError
|
||||
from core.weibo import WeiboError
|
||||
|
||||
from ..models import (
|
||||
AuditLog,
|
||||
@@ -28,7 +28,12 @@ from ..models import (
|
||||
WeiboExternalOrderMetric,
|
||||
)
|
||||
from .audit_service import record_audit
|
||||
from .weibo_service import query_weibo_mall, refresh_weibo_account
|
||||
from .collab_common import apply_order_visibility, count_archived
|
||||
from .weibo_service import (
|
||||
query_weibo_exchange_records,
|
||||
query_weibo_mall,
|
||||
refresh_weibo_account,
|
||||
)
|
||||
|
||||
STATUS_INCOMPLETE = "incomplete"
|
||||
STATUS_TO_RECHARGE = "to_recharge"
|
||||
@@ -99,7 +104,6 @@ VERDICT_LABELS = {
|
||||
VERDICT_UNKNOWN: "无法判定",
|
||||
}
|
||||
|
||||
ARCHIVE_SCOPES = ("active", "archived", "all")
|
||||
ARCHIVABLE_STATUSES = (STATUS_DONE,)
|
||||
MAX_IMAGES = 20
|
||||
CHECK_BATCH_MAX = 50
|
||||
@@ -1029,7 +1033,7 @@ def check_balance_groups(
|
||||
)
|
||||
continue
|
||||
try:
|
||||
data = WeiboClient(account.cookie or "").list_mall(activity)
|
||||
data = query_weibo_mall(db, account, activity=activity)
|
||||
except (WeiboError, ValueError) as exc:
|
||||
for order in account_orders:
|
||||
failures.append(
|
||||
@@ -1233,8 +1237,10 @@ def check_exchange_records(
|
||||
)
|
||||
continue
|
||||
try:
|
||||
data = WeiboClient(account.cookie or "").list_exchange_records(
|
||||
activity,
|
||||
data = query_weibo_exchange_records(
|
||||
db,
|
||||
account,
|
||||
activity=activity,
|
||||
page=1,
|
||||
page_size=100,
|
||||
)
|
||||
@@ -1622,13 +1628,13 @@ def _apply_filters(
|
||||
keyword: str = "",
|
||||
include_void: bool = False,
|
||||
):
|
||||
scope = archive_scope if archive_scope in ARCHIVE_SCOPES else "active"
|
||||
if scope == "active":
|
||||
query = query.filter(WeiboExternalOrder.archived_at.is_(None))
|
||||
elif scope == "archived":
|
||||
query = query.filter(WeiboExternalOrder.archived_at.isnot(None))
|
||||
if not include_void:
|
||||
query = query.filter(WeiboExternalOrder.status != STATUS_VOID)
|
||||
query = apply_order_visibility(
|
||||
query,
|
||||
WeiboExternalOrder,
|
||||
archive_scope=archive_scope,
|
||||
include_void=include_void,
|
||||
void_status=STATUS_VOID,
|
||||
)
|
||||
if _text(status):
|
||||
query = query.filter(WeiboExternalOrder.status == status)
|
||||
if _text(ck_status):
|
||||
@@ -1798,12 +1804,7 @@ def summary(
|
||||
),
|
||||
"account_disabled": int(account_disabled),
|
||||
"ck_invalid": int(ck_invalid),
|
||||
"archived_total": (
|
||||
db.query(func.count(WeiboExternalOrder.id))
|
||||
.filter(WeiboExternalOrder.archived_at.isnot(None))
|
||||
.scalar()
|
||||
or 0
|
||||
),
|
||||
"archived_total": count_archived(db, WeiboExternalOrder),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Col, Form, Input, Row, Select, Switch, Typography } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type HuyaConfigScope = 'all' | 'points';
|
||||
|
||||
interface HuyaConfigFieldsProps {
|
||||
scope?: HuyaConfigScope;
|
||||
}
|
||||
|
||||
export default function HuyaConfigFields({ scope = 'all' }: HuyaConfigFieldsProps) {
|
||||
const showAll = scope === 'all';
|
||||
return (
|
||||
<Row gutter={12}>
|
||||
{showAll && (
|
||||
<>
|
||||
<Col span={12}>
|
||||
<Form.Item label="直播间 PID" name="room_pid">
|
||||
<Input placeholder="1199650619883" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="活动 SID(兜底)" name="sid">
|
||||
<Input placeholder="优先自动发现,如 2277" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="外部活动 ID" name="outer_act_id">
|
||||
<Input placeholder="17096" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="绑定 gid" name="bind_gid">
|
||||
<Input placeholder="3203" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="绑定活动 ID" name="bind_act_id">
|
||||
<Input placeholder="9271" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="支付渠道" name="pay_channel">
|
||||
<Select options={[
|
||||
{ value: 'Weixin', label: '微信' },
|
||||
{ value: 'Zfb', label: '支付宝' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
<Col span={12}>
|
||||
<Form.Item label="积分中心 pageCode" name="points_page_code">
|
||||
<Input placeholder="heping_baodian_credit_center_page" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="积分中心 sourceId" name="points_source_id">
|
||||
<Input placeholder="yellowcarlist" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="积分中心 scene" name="points_scene">
|
||||
<Input placeholder="7" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="页签 pageId(可选)" name="points_page_id">
|
||||
<Input placeholder="自动发现,如 343" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="页签 IDs(可选)" name="points_tab_ids">
|
||||
<Input placeholder="自动拉全部,如 431,433" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{showAll && (
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
label="兑换使用代理"
|
||||
name="exchange_use_proxy"
|
||||
getValueProps={(value) => ({
|
||||
checked: ['1', 'true', 'on', 'yes'].includes(String(value || '0').toLowerCase()),
|
||||
})}
|
||||
normalize={(checked: boolean) => (checked ? '1' : '0')}
|
||||
extra={<Text type="secondary">默认关闭;开启后仅精英宝典兑换链路使用全局代理配置</Text>}
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type HuyaCollabSummary,
|
||||
type HuyaConfig,
|
||||
} from '../api/modules';
|
||||
import HuyaConfigFields from '../components/HuyaConfigFields';
|
||||
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
|
||||
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
|
||||
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
|
||||
@@ -3790,33 +3791,7 @@ export default function HuyaCollabPage() {
|
||||
description="保存后回到新建/补资料里点一次「强制刷新礼包」即可拉取最新商品;pageId 和页签 IDs 留空表示自动。"
|
||||
/>
|
||||
<Form form={pointsConfigForm} layout="vertical" disabled={pointsConfigLoading}>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="pageCode" name="points_page_code">
|
||||
<Input placeholder="heping_baodian_credit_center_page" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="sourceId" name="points_source_id">
|
||||
<Input placeholder="yellowcarlist" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="scene" name="points_scene">
|
||||
<Input placeholder="7" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="pageId(可选)" name="points_page_id">
|
||||
<Input placeholder="自动发现,如 343" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="页签 IDs(可选)" name="points_tab_ids">
|
||||
<Input placeholder="自动拉全部,如 431,433" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<HuyaConfigFields scope="points" />
|
||||
</Form>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Switch, Table, Tag, Tooltip, Typography,
|
||||
Button, Card, Form, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Table, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, SwapOutlined, ZoomInOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
|
||||
import HuyaConfigFields from '../components/HuyaConfigFields';
|
||||
import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal';
|
||||
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
@@ -27,7 +28,6 @@ const PAGE_SIZE_KEY = 'huya_elite_page_size';
|
||||
const RECHARGE_SPU_KEY = 'huya_elite_recharge_spu';
|
||||
const RECHARGE_COUNT_KEY = 'huya_elite_recharge_count';
|
||||
const ELITE_SPU_KEY = 'huya_elite_elite_spu';
|
||||
const EXCHANGE_PROXY_ENABLED_VALUES = new Set(['1', 'true', 'on', 'yes']);
|
||||
// 与后端 ELITE_HANDBOOK_SPU_IDS 对齐:精英宝典单独一块,不和其他开通商品混在一个下拉里
|
||||
const ELITE_HANDBOOK_SPU_IDS = ['hy-8328015', 'hy-5879340', 'hy-3845257', 'hy-2947756'];
|
||||
const ELITE_HANDBOOK_NAME_KEYWORD = '精英宝典';
|
||||
@@ -235,7 +235,7 @@ export default function HuyaElitePage() {
|
||||
const [zoomPayTask, setZoomPayTask] = useState<HuyaTaskItem | null>(null);
|
||||
const [ckImportOpen, setCkImportOpen] = useState(false);
|
||||
const [config, setConfig] = useState<HuyaConfig | null>(null);
|
||||
const [configDraft, setConfigDraft] = useState<HuyaConfig | null>(null);
|
||||
const [configForm] = Form.useForm<HuyaConfig>();
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
@@ -472,17 +472,26 @@ export default function HuyaElitePage() {
|
||||
const openSettings = async () => {
|
||||
if (!canConfig) return;
|
||||
setConfigOpen(true);
|
||||
if (config) { setConfigDraft({ ...config }); return; }
|
||||
setConfigLoading(true);
|
||||
try { const current = await huyaApi.getConfig(); setConfig(current); setConfigDraft({ ...current }); }
|
||||
try {
|
||||
const current = await huyaApi.getConfig();
|
||||
setConfig(current);
|
||||
configForm.setFieldsValue(current);
|
||||
}
|
||||
catch (error) { setConfigOpen(false); message.error(getErrorMessage(error)); }
|
||||
finally { setConfigLoading(false); }
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!configDraft) return;
|
||||
setConfigSaving(true);
|
||||
try { const saved = await huyaApi.updateConfig(configDraft); setConfig(saved); setConfigDraft({ ...saved }); setConfigOpen(false); message.success('配置已保存'); }
|
||||
try {
|
||||
const values = await configForm.validateFields();
|
||||
const saved = await huyaApi.updateConfig(values);
|
||||
setConfig(saved);
|
||||
configForm.setFieldsValue(saved);
|
||||
setConfigOpen(false);
|
||||
message.success('配置已保存');
|
||||
}
|
||||
catch (error) { message.error(getErrorMessage(error)); } finally { setConfigSaving(false); }
|
||||
};
|
||||
|
||||
@@ -663,7 +672,6 @@ export default function HuyaElitePage() {
|
||||
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}{canTransferCollab && selectedIds.length === 1 && <Button size="small" icon={<SwapOutlined />} loading={transferring} onClick={() => void transferToCollab()}>转入外部单协作</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'query_exchange_records' && taskResultRecords(task).length > 0) setRecordsTask(task); else if (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
|
||||
|
||||
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
|
||||
const contextMenuStyle = contextMenu ? {
|
||||
position: 'fixed' as const,
|
||||
zIndex: 1000,
|
||||
@@ -759,7 +767,7 @@ export default function HuyaElitePage() {
|
||||
</div>}
|
||||
</Modal>
|
||||
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
|
||||
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={560}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}{configField('points_page_code', '积分中心 pageCode')}{configField('points_source_id', '积分中心 sourceId')}{configField('points_scene', '积分中心 scene')}{configField('points_page_id', '页签 pageId(可选)')}{configField('points_tab_ids', '页签 IDs(可选)')}<Space style={{ width: '100%', justifyContent: 'space-between' }} align="center"><div><Text>兑换使用代理</Text><br /><Text type="secondary">默认关闭;开启后兑换链路使用全局代理配置</Text></div><Switch checked={EXCHANGE_PROXY_ENABLED_VALUES.has(String(configDraft.exchange_use_proxy || '0').toLowerCase())} onChange={(checked) => setConfigDraft({ ...configDraft, exchange_use_proxy: checked ? '1' : '0' })} /></Space><Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
|
||||
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={680}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : <Space direction="vertical" size={10} style={{ width: '100%' }}><Form form={configForm} layout="vertical"><HuyaConfigFields /></Form><Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
|
||||
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Col, DatePicker, Dropdown, Form, Input, InputNumber, Modal, QRCode, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography, theme,
|
||||
Button, Card, DatePicker, Dropdown, Form, Input, InputNumber, Modal, QRCode, Radio, Select, Space, Table, Tag, Tooltip, Typography, theme,
|
||||
} from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import type { MenuProps, TableProps } from 'antd';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type HuyaRechargeGoodsItem,
|
||||
type HuyaTaskItem,
|
||||
} from '../api/modules';
|
||||
import HuyaConfigFields from '../components/HuyaConfigFields';
|
||||
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||
@@ -282,6 +283,7 @@ export default function HuyaTasksPage() {
|
||||
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||||
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
|
||||
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
|
||||
const [goodsLoading, setGoodsLoading] = useState(false);
|
||||
const [rechargeGoods, setRechargeGoods] = useState<HuyaRechargeGoodsItem[]>([]);
|
||||
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
@@ -322,6 +324,7 @@ export default function HuyaTasksPage() {
|
||||
const accountTableAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460);
|
||||
const tasksLoadingRef = useRef(false);
|
||||
const liveGoodsRequestId = useRef(0);
|
||||
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
|
||||
const { can } = usePermissions();
|
||||
|
||||
@@ -403,10 +406,9 @@ export default function HuyaTasksPage() {
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [accountResult, taskResult, goodsResult, rechargeGoodsResult, configResult, taskTypeResult, tagResult] = await Promise.allSettled([
|
||||
const [accountResult, taskResult, rechargeGoodsResult, configResult, taskTypeResult, tagResult] = await Promise.allSettled([
|
||||
huyaApi.listAccounts({ include_cookie: false }),
|
||||
huyaApi.listTasks(),
|
||||
huyaApi.listGoods(),
|
||||
huyaApi.listRechargeGoods(),
|
||||
canConfig ? huyaApi.getConfig() : Promise.resolve(null),
|
||||
huyaApi.taskTypes(),
|
||||
@@ -437,7 +439,6 @@ export default function HuyaTasksPage() {
|
||||
setBatchId(null);
|
||||
}
|
||||
}
|
||||
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
|
||||
if (rechargeGoodsResult.status === 'fulfilled') setRechargeGoods(rechargeGoodsResult.value);
|
||||
if (configResult.status === 'fulfilled' && configResult.value) form.setFieldsValue(configResult.value);
|
||||
if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value });
|
||||
@@ -446,7 +447,6 @@ export default function HuyaTasksPage() {
|
||||
const failedLabels = [
|
||||
accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '',
|
||||
taskResult.status === 'rejected' ? `任务记录: ${getErrorMessage(taskResult.reason)}` : '',
|
||||
goodsResult.status === 'rejected' ? `兑换商品: ${getErrorMessage(goodsResult.reason)}` : '',
|
||||
rechargeGoodsResult.status === 'rejected' ? `充值商品: ${getErrorMessage(rechargeGoodsResult.reason)}` : '',
|
||||
configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '',
|
||||
taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '',
|
||||
@@ -460,6 +460,38 @@ export default function HuyaTasksPage() {
|
||||
}
|
||||
}, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
|
||||
|
||||
const loadLiveGoods = useCallback(async (accountId: number | null) => {
|
||||
const requestId = ++liveGoodsRequestId.current;
|
||||
if (!accountId) {
|
||||
setGoods([]);
|
||||
setSelectedExchangeGoodsId('');
|
||||
return;
|
||||
}
|
||||
setGoodsLoading(true);
|
||||
try {
|
||||
const result = await huyaApi.listLiveGoods(accountId);
|
||||
if (liveGoodsRequestId.current !== requestId) return;
|
||||
setGoods(result);
|
||||
setSelectedExchangeGoodsId((current) => (
|
||||
result.some((item) => item.product_id === current)
|
||||
? current
|
||||
: result[0]?.product_id || ''
|
||||
));
|
||||
} catch (error) {
|
||||
if (liveGoodsRequestId.current !== requestId) return;
|
||||
setGoods([]);
|
||||
setSelectedExchangeGoodsId('');
|
||||
message.error(`实时商品加载失败:${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
if (liveGoodsRequestId.current === requestId) setGoodsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const goodsAccountId = selectedIds[0] ?? accounts[0]?.id ?? null;
|
||||
useEffect(() => {
|
||||
void loadLiveGoods(goodsAccountId);
|
||||
}, [goodsAccountId, loadLiveGoods]);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
if (tasksLoadingRef.current) return;
|
||||
tasksLoadingRef.current = true;
|
||||
@@ -1716,8 +1748,9 @@ export default function HuyaTasksPage() {
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => startTask('refresh_goods')}
|
||||
disabled={!canTask || selectedIds.length === 0 || batchBusy || wsConnected}
|
||||
loading={goodsLoading}
|
||||
onClick={() => void loadLiveGoods(goodsAccountId)}
|
||||
disabled={!canTask || !goodsAccountId || goodsLoading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -1738,7 +1771,9 @@ export default function HuyaTasksPage() {
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="选择兑换商品"
|
||||
loading={goodsLoading}
|
||||
disabled={!goodsAccountId || goodsLoading}
|
||||
placeholder={goodsLoading ? '正在读取实时商品' : !goodsAccountId ? '请先选择账号' : '暂无实时商品'}
|
||||
value={selectedExchangeGoodsId || undefined}
|
||||
onChange={(value) => setSelectedExchangeGoodsId(value || '')}
|
||||
options={goodsOptions}
|
||||
@@ -1834,68 +1869,7 @@ export default function HuyaTasksPage() {
|
||||
)}
|
||||
>
|
||||
<Form form={form} layout="vertical" disabled={!canConfig}>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="直播间 ID" name="room_pid">
|
||||
<Input placeholder="1199650619883" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="SID" name="sid">
|
||||
<Input placeholder="2203" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="兑换活动 ID" name="outer_act_id">
|
||||
<Input placeholder="9504" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="绑定 gid" name="bind_gid">
|
||||
<Input placeholder="3203" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="绑定 bActId" name="bind_act_id">
|
||||
<Input placeholder="9271" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="积分中心 pageCode" name="points_page_code">
|
||||
<Input placeholder="heping_baodian_credit_center_page" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="积分中心 sourceId" name="points_source_id">
|
||||
<Input placeholder="yellowcarlist" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="积分中心 scene" name="points_scene">
|
||||
<Input placeholder="7" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="页签 pageId(可选)" name="points_page_id">
|
||||
<Input placeholder="留空自动发现,如 343" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="页签 IDs(可选,逗号分隔)" name="points_tab_ids">
|
||||
<Input placeholder="留空自动拉全部,如 431,433" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="支付渠道" name="pay_channel">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'Weixin', label: '微信' },
|
||||
{ value: 'Zfb', label: '支付宝' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<HuyaConfigFields />
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
Reference in New Issue
Block a user