refactor(douyu): runner 按功能域拆分 Mixin + 精英手册兑换对齐真机流程 (HAR 审核通过)
拆分: douyu_runner.py (-3186行) → core/bind/manual/gold/donate/goods/xpd 七个 Mixin + 入口聚合类, douyu_batch_registry 供 routers 重导出 精英手册兑换对齐 HAR 抓包 (activity_client +379 行): - csrf 复用 Cookie 值不再每次 generateCsrf (HAR 实测全程零 csrf 请求), 仅服务端报 csrf 错误时 force_refresh 重试一次 - pay Referer 补 roomId 对齐浏览器 - 新增手册链路接口: elite_user_info/storedetail/pre_exchange check+confirm/ subscribe/batch_limit/赠品兑换/自动转换 - resolve_exchange_plan: 浏览器兑换按钮状态机路由 (normal/subscribe/ pre_exchange/wait/blocked) - 兑换执行: 人类节奏抖动 → 锁单(火爆重试1次) → 支付分级退避 (频控 30/60/90s 4次上限, 普错 2/5/10/20s 5次上限, 300s 锁单期 15s 余量, 可中断睡眠) 测试: 新增 17 项 (csrf 复用语义/状态机路由/Referer), 全量 94 通过; 未夹带代理改动
This commit is contained in:
+358
-21
@@ -41,6 +41,22 @@ class DouyuActivityClient:
|
|||||||
PAY_EXCHANGE_ORDER_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/payExchangeOrder"
|
PAY_EXCHANGE_ORDER_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/payExchangeOrder"
|
||||||
EXCHANGE_ORDER_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/getExchangeOrderList"
|
EXCHANGE_ORDER_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/getExchangeOrderList"
|
||||||
EXCHANGE_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchangeList"
|
EXCHANGE_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchangeList"
|
||||||
|
# 2026-08-30 页面新增链路(对齐浏览器):
|
||||||
|
# 预兑 user/queryExchange -> user/confirmExchange;取消预兑 user/queryCancelExchange -> user/cancelExchange;
|
||||||
|
# 预约到货 user/sub / user/cancelSub;批量兑换 batchExchangeLimit;单步兑换 exchange;自动兑换开关。
|
||||||
|
ELITE_USER_INFO_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/getUserInfo"
|
||||||
|
GOODS_DETAIL_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storedetail"
|
||||||
|
EXCHANGE_GIFT_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchange"
|
||||||
|
PRE_EXCHANGE_CHECK_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryExchange"
|
||||||
|
PRE_EXCHANGE_CONFIRM_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/confirmExchange"
|
||||||
|
PRE_EXCHANGE_CANCEL_CHECK_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/queryCancelExchange"
|
||||||
|
PRE_EXCHANGE_CANCEL_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/cancelExchange"
|
||||||
|
COMMODITY_SUBSCRIBE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/sub"
|
||||||
|
COMMODITY_UNSUBSCRIBE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/user/cancelSub"
|
||||||
|
BATCH_EXCHANGE_LIMIT_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/batchExchangeLimit"
|
||||||
|
AUTO_CONVERT_QUERY_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/automaticallyConvert"
|
||||||
|
AUTO_CONVERT_SAVE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/automaticallyConvertSave"
|
||||||
|
EXCHANGE_HOME_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/activity/exchangeHome"
|
||||||
CREDIT_BALANCE_API = "https://www.douyu.com/japi/oms/web/peace/credit/balance"
|
CREDIT_BALANCE_API = "https://www.douyu.com/japi/oms/web/peace/credit/balance"
|
||||||
CREDIT_DIFF_API = "https://www.douyu.com/japi/oms/web/peace/credit/diff"
|
CREDIT_DIFF_API = "https://www.douyu.com/japi/oms/web/peace/credit/diff"
|
||||||
PEACE_ITEM_API = "https://www.douyu.com/japi/oms/web/peace/item"
|
PEACE_ITEM_API = "https://www.douyu.com/japi/oms/web/peace/item"
|
||||||
@@ -186,8 +202,18 @@ class DouyuActivityClient:
|
|||||||
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict[str, Any]:
|
def _request_json(self, method: str, url: str, source: str, **kwargs) -> dict[str, Any]:
|
||||||
return self._json(self._request(method, url, source=source, **kwargs), source)
|
return self._json(self._request(method, url, source=source, **kwargs), source)
|
||||||
|
|
||||||
def csrf_token(self) -> str:
|
def csrf_token(self, *, force_refresh: bool = False) -> str:
|
||||||
"""获取 cvl_csrf_token。"""
|
"""获取 cvl_csrf_token;优先复用 Cookie 中已有值,与浏览器行为一致。
|
||||||
|
|
||||||
|
2026-08-30 抓包实证:页面只读 Cookie `cvl_csrf_token` 的值作为 csrfToken
|
||||||
|
复用(同一 token 跨多次接口、跨十几秒复用),Cookie 里没有/失效时才调用
|
||||||
|
generateCsrf,生成的 token 写回 Cookie 继续复用。
|
||||||
|
force_refresh=True 用于服务端提示 token 失效后的强制刷新。
|
||||||
|
"""
|
||||||
|
if not force_refresh:
|
||||||
|
token = cookie_value(self.cookie, "cvl_csrf_token")
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
"post",
|
"post",
|
||||||
self.CSRF_API,
|
self.CSRF_API,
|
||||||
@@ -205,6 +231,58 @@ class DouyuActivityClient:
|
|||||||
raise DouyuActivityError("响应 Cookie 中没有 cvl_csrf_token")
|
raise DouyuActivityError("响应 Cookie 中没有 cvl_csrf_token")
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_csrf_error(payload: dict[str, Any]) -> bool:
|
||||||
|
"""判断响应是否为 csrfToken 校验失败(此时应刷新并重试一次)。"""
|
||||||
|
return "csrf" in str(payload.get("msg") or "").lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _peace_page_referer(rid: str = "") -> str:
|
||||||
|
"""精英手册首页 Referer(对齐浏览器 getUserInfo 等接口)。"""
|
||||||
|
if rid:
|
||||||
|
return f"https://www.douyu.com/pages/live-peace-handbook/web?ditchname=pass0&roomId={rid}"
|
||||||
|
return "https://www.douyu.com/pages/live-peace-handbook/web?ditchname=pass0"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _peace_shop_referer(rid: str = "") -> str:
|
||||||
|
"""精英手册商城页 Referer(对齐浏览器 create/pay/storedetail 等接口)。"""
|
||||||
|
if rid:
|
||||||
|
return f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}"
|
||||||
|
return "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0"
|
||||||
|
|
||||||
|
def _post_csrf_form(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
source: str,
|
||||||
|
fields: dict[str, Any],
|
||||||
|
referer: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""提交带 csrfToken 的 multipart 表单(对齐浏览器请求层)。
|
||||||
|
|
||||||
|
浏览器 csrf 配置 {tvk: cvl_csrf_token, tn: csrfToken} 会把 Cookie 中的
|
||||||
|
token 值拼进表单;这里同样复用 Cookie 值,仅在服务端提示 token 失效时
|
||||||
|
刷新一次后重试。
|
||||||
|
"""
|
||||||
|
token = self.csrf_token()
|
||||||
|
payload = self._request_json(
|
||||||
|
"post",
|
||||||
|
url,
|
||||||
|
source,
|
||||||
|
files=self._multipart_fields({**fields, "csrfToken": token}),
|
||||||
|
headers={"Referer": referer},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0") and self._is_csrf_error(payload):
|
||||||
|
self.logger(f"{source}: csrfToken 校验失败,刷新后重试一次")
|
||||||
|
token = self.csrf_token(force_refresh=True)
|
||||||
|
payload = self._request_json(
|
||||||
|
"post",
|
||||||
|
url,
|
||||||
|
source,
|
||||||
|
files=self._multipart_fields({**fields, "csrfToken": token}),
|
||||||
|
headers={"Referer": referer},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
def acf_ccn(self, *, refresh_subscribe: bool = True) -> str:
|
def acf_ccn(self, *, refresh_subscribe: bool = True) -> str:
|
||||||
"""获取 acf_ccn;必要时通过订阅接口刷新一次。"""
|
"""获取 acf_ccn;必要时通过订阅接口刷新一次。"""
|
||||||
response = self._request(
|
response = self._request(
|
||||||
@@ -797,21 +875,16 @@ class DouyuActivityClient:
|
|||||||
"""Lock stock for an activity commodity before spending points."""
|
"""Lock stock for an activity commodity before spending points."""
|
||||||
if num < 1:
|
if num < 1:
|
||||||
raise DouyuActivityError("锁单数量必须大于 0")
|
raise DouyuActivityError("锁单数量必须大于 0")
|
||||||
token = self.csrf_token()
|
payload = self._post_csrf_form(
|
||||||
payload = self._request_json(
|
|
||||||
"post",
|
|
||||||
self.CREATE_EXCHANGE_ORDER_API,
|
self.CREATE_EXCHANGE_ORDER_API,
|
||||||
"锁定兑换商品",
|
"锁定兑换商品",
|
||||||
files=self._multipart_fields({
|
{
|
||||||
"manualID": manual_id,
|
"manualID": manual_id,
|
||||||
"rid": rid,
|
"rid": rid,
|
||||||
"commodityID": commodity_id,
|
"commodityID": commodity_id,
|
||||||
"num": num,
|
"num": num,
|
||||||
"csrfToken": token,
|
|
||||||
}),
|
|
||||||
headers={
|
|
||||||
"Referer": f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}",
|
|
||||||
},
|
},
|
||||||
|
referer=self._peace_shop_referer(rid),
|
||||||
)
|
)
|
||||||
if payload.get("error") not in (0, "0"):
|
if payload.get("error") not in (0, "0"):
|
||||||
raise DouyuActivityError(payload.get("msg") or "锁定兑换商品失败")
|
raise DouyuActivityError(payload.get("msg") or "锁定兑换商品失败")
|
||||||
@@ -838,28 +911,22 @@ class DouyuActivityClient:
|
|||||||
"expire_seconds": data.get("expireSeconds"),
|
"expire_seconds": data.get("expireSeconds"),
|
||||||
"locked_at": locked_at,
|
"locked_at": locked_at,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"csrf_token": token,
|
|
||||||
"raw": payload,
|
"raw": payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
def pay_exchange_order(self, *, manual_id: str, order_id: str) -> dict[str, Any]:
|
def pay_exchange_order(self, *, manual_id: str, order_id: str, rid: str = "") -> dict[str, Any]:
|
||||||
"""Pay a previously locked exchange order with handbook points."""
|
"""Pay a previously locked exchange order with handbook points."""
|
||||||
normalized_order_id = str(order_id).strip()
|
normalized_order_id = str(order_id).strip()
|
||||||
if not normalized_order_id:
|
if not normalized_order_id:
|
||||||
raise DouyuActivityError("锁单订单号不能为空")
|
raise DouyuActivityError("锁单订单号不能为空")
|
||||||
token = self.csrf_token()
|
payload = self._post_csrf_form(
|
||||||
payload = self._request_json(
|
|
||||||
"post",
|
|
||||||
self.PAY_EXCHANGE_ORDER_API,
|
self.PAY_EXCHANGE_ORDER_API,
|
||||||
"支付锁单",
|
"支付锁单",
|
||||||
files=self._multipart_fields({
|
{
|
||||||
"orderID": normalized_order_id,
|
"orderID": normalized_order_id,
|
||||||
"manualID": manual_id,
|
"manualID": manual_id,
|
||||||
"csrfToken": token,
|
|
||||||
}),
|
|
||||||
headers={
|
|
||||||
"Referer": "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0",
|
|
||||||
},
|
},
|
||||||
|
referer=self._peace_shop_referer(rid),
|
||||||
)
|
)
|
||||||
if payload.get("error") not in (0, "0"):
|
if payload.get("error") not in (0, "0"):
|
||||||
raise DouyuActivityError(payload.get("msg") or "支付锁单失败")
|
raise DouyuActivityError(payload.get("msg") or "支付锁单失败")
|
||||||
@@ -872,7 +939,6 @@ class DouyuActivityClient:
|
|||||||
"exchange_num": data.get("exchangeNum"),
|
"exchange_num": data.get("exchangeNum"),
|
||||||
"s_type": data.get("sType"),
|
"s_type": data.get("sType"),
|
||||||
"g_type": data.get("gType"),
|
"g_type": data.get("gType"),
|
||||||
"csrf_token": token,
|
|
||||||
"raw": payload,
|
"raw": payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,6 +982,277 @@ class DouyuActivityClient:
|
|||||||
"payment": paid,
|
"payment": paid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def elite_user_info(self, *, manual_id: str, rid: str = "") -> dict[str, Any]:
|
||||||
|
"""查询精英手册状态(对齐页面 getUserInfo,含 manualType/manualScore)。
|
||||||
|
|
||||||
|
浏览器每次进入手册页都会先请求该接口,用于判断商品是否"精英专享"、
|
||||||
|
是否已达兑换上限等状态;兑换前先查一次与页面行为一致。
|
||||||
|
"""
|
||||||
|
payload = self._request_json(
|
||||||
|
"get",
|
||||||
|
self.ELITE_USER_INFO_API,
|
||||||
|
"查询手册状态",
|
||||||
|
params={"manualID": manual_id},
|
||||||
|
headers={"Origin": "", "Referer": self._peace_page_referer(rid)},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0", None):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "查询手册状态失败")
|
||||||
|
user_info = (payload.get("data") or {}).get("userInfo") or {}
|
||||||
|
return {
|
||||||
|
"manual_id": manual_id,
|
||||||
|
"manual_type": user_info.get("manualType"),
|
||||||
|
"manual_score": user_info.get("manualScore"),
|
||||||
|
"expire_time": user_info.get("expireTime"),
|
||||||
|
"user_info": user_info,
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def query_goods_detail(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]:
|
||||||
|
"""查询商品详情 storedetail(对齐浏览器 getGoodsDetail)。
|
||||||
|
|
||||||
|
详情里带有兑换状态机所需的全部标记:preExchange.isExists / subscribeType /
|
||||||
|
exchangeInfo.userStatus / batchExchange / roomLimited / storeStatus 等。
|
||||||
|
"""
|
||||||
|
payload = self._request_json(
|
||||||
|
"get",
|
||||||
|
self.GOODS_DETAIL_API,
|
||||||
|
"查询商品详情",
|
||||||
|
params={"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"},
|
||||||
|
headers={"Origin": "", "Referer": self._peace_shop_referer(rid)},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "查询商品详情失败")
|
||||||
|
return {"detail": payload.get("data") or {}, "raw": payload}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_exchange_plan(
|
||||||
|
detail: dict[str, Any],
|
||||||
|
*,
|
||||||
|
manual_type: Any = None,
|
||||||
|
batch_num_limit: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""对齐浏览器兑换按钮状态机($e / Ve / Ge)给出自动化兑换计划。
|
||||||
|
|
||||||
|
浏览器判定顺序(2026-08-30 bundle index.4872272b.js / index.0539fbf8.js):
|
||||||
|
存在预扣订单 -> 已达兑换上限 -> 无库存 -> 限定房间 -> 活动未开启(预约/预兑) ->
|
||||||
|
精英专享未开通 -> 已预兑等待开放 -> 立即兑换(经典锁单/支付)。
|
||||||
|
action:
|
||||||
|
classic 经典链路 createExchangeOrder -> payExchangeOrder(批量商品 num 受上限约束)
|
||||||
|
pre_exchange 预兑商品(subscribeType=2 且未开启):user/queryExchange -> user/confirmExchange
|
||||||
|
subscribe 预约到货商品(subscribeType=1 且未开启):user/sub
|
||||||
|
wait 已预兑(userStatus=1)但预扣订单尚未生成,等待开放后重试
|
||||||
|
blocked 明确不可兑换,text 为原因(文案对齐浏览器/服务端)
|
||||||
|
"""
|
||||||
|
detail = detail or {}
|
||||||
|
pre = detail.get("preExchange") or {}
|
||||||
|
pre_is_exists = int(pre.get("isExists") or 0)
|
||||||
|
exchange_info = detail.get("exchangeInfo") or {}
|
||||||
|
user_status = int(exchange_info.get("userStatus") or 0)
|
||||||
|
subscribe_type = int(detail.get("subscribeType") or 0)
|
||||||
|
status = int(detail.get("status") or 0)
|
||||||
|
store_status = int(detail.get("storeStatus") or 0)
|
||||||
|
open_status = int(detail.get("openStatus") or 0)
|
||||||
|
unlimited = (
|
||||||
|
int(detail.get("unlimitedMode") or 0) == 1
|
||||||
|
and int(detail.get("unlimitedStatus") or 0) == 1
|
||||||
|
)
|
||||||
|
room_limited = int(detail.get("roomLimited") or 0) == 1
|
||||||
|
is_limit_room = int(detail.get("isLimitRoom") or 0) == 1
|
||||||
|
elite_limited = int(detail.get("eliteLimited") or 0) == 1
|
||||||
|
batch_exchange = int(detail.get("batchExchange") or 0) > 0
|
||||||
|
max_num = 1
|
||||||
|
if batch_exchange and batch_num_limit is not None:
|
||||||
|
try:
|
||||||
|
max_num = max(1, int(batch_num_limit or 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_num = 1
|
||||||
|
|
||||||
|
plan: dict[str, Any] = {
|
||||||
|
"action": "classic",
|
||||||
|
"text": "",
|
||||||
|
"max_num": max_num,
|
||||||
|
"batch_exchange": batch_exchange,
|
||||||
|
"pre_exchange": bool(pre_is_exists),
|
||||||
|
"subscribe_type": subscribe_type,
|
||||||
|
"user_status": user_status,
|
||||||
|
}
|
||||||
|
if pre_is_exists:
|
||||||
|
plan.update(action="classic", text="存在预扣订单,继续兑换")
|
||||||
|
elif status == 1 or (subscribe_type == 2 and user_status == 2):
|
||||||
|
plan.update(action="blocked", text="已达兑换上限")
|
||||||
|
elif store_status == 0 and not unlimited:
|
||||||
|
plan.update(action="blocked", text="无库存")
|
||||||
|
elif room_limited and not is_limit_room:
|
||||||
|
plan.update(action="blocked", text="限定房间兑换,请前往指定直播间")
|
||||||
|
elif open_status == 0:
|
||||||
|
if subscribe_type == 1:
|
||||||
|
plan.update(action="subscribe", text="活动未开启,预约到货")
|
||||||
|
elif subscribe_type == 2:
|
||||||
|
plan.update(action="pre_exchange", text="活动未开启,预兑")
|
||||||
|
else:
|
||||||
|
plan.update(action="blocked", text="活动未开启")
|
||||||
|
elif elite_limited and manual_type is not None and int(manual_type or 0) != 1:
|
||||||
|
plan.update(action="blocked", text="需要开通精英手册,才可以兑换本道具")
|
||||||
|
elif subscribe_type == 2 and user_status == 1:
|
||||||
|
plan.update(action="wait", text="已预兑,等待开放后继续兑换")
|
||||||
|
else:
|
||||||
|
plan.update(action="classic", text="立即兑换")
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def batch_exchange_limit(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]:
|
||||||
|
"""查询批量兑换上限(对齐页面 batchExchangeLimit,仅 batchExchange=1 商品)。"""
|
||||||
|
payload = self._post_csrf_form(
|
||||||
|
self.BATCH_EXCHANGE_LIMIT_API,
|
||||||
|
"查询批量兑换上限",
|
||||||
|
{"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"},
|
||||||
|
referer=self._peace_shop_referer(rid),
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "查询批量兑换上限失败")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
return {"limit": data.get("limit"), "raw": payload}
|
||||||
|
|
||||||
|
def pre_exchange_check(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]:
|
||||||
|
"""查询预兑状态(对齐页面 user/queryExchange,预兑弹窗前必查)。"""
|
||||||
|
payload = self._request_json(
|
||||||
|
"get",
|
||||||
|
self.PRE_EXCHANGE_CHECK_API,
|
||||||
|
"查询预兑状态",
|
||||||
|
params={"manualID": manual_id, "commodityID": commodity_id},
|
||||||
|
headers={"Origin": "", "Referer": self._peace_shop_referer()},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "查询预兑状态失败")
|
||||||
|
return {"data": payload.get("data") or {}, "raw": payload}
|
||||||
|
|
||||||
|
def pre_exchange_cancel_check(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]:
|
||||||
|
"""查询取消预兑信息(对齐页面 user/queryCancelExchange)。"""
|
||||||
|
payload = self._request_json(
|
||||||
|
"get",
|
||||||
|
self.PRE_EXCHANGE_CANCEL_CHECK_API,
|
||||||
|
"查询取消预兑信息",
|
||||||
|
params={"manualID": manual_id, "commodityID": commodity_id},
|
||||||
|
headers={"Origin": "", "Referer": self._peace_shop_referer()},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "查询取消预兑信息失败")
|
||||||
|
return {"data": payload.get("data") or {}, "raw": payload}
|
||||||
|
|
||||||
|
def confirm_pre_exchange(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]:
|
||||||
|
"""确认预兑(对齐页面 user/confirmExchange,成功提示"预兑成功")。"""
|
||||||
|
payload = self._post_csrf_form(
|
||||||
|
self.PRE_EXCHANGE_CONFIRM_API,
|
||||||
|
"确认预兑",
|
||||||
|
{"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"},
|
||||||
|
referer=self._peace_shop_referer(rid),
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "确认预兑失败")
|
||||||
|
return {"raw": payload}
|
||||||
|
|
||||||
|
def cancel_pre_exchange(self, *, manual_id: str, commodity_id: str, rid: str = "") -> dict[str, Any]:
|
||||||
|
"""取消预兑(对齐页面 user/cancelExchange)。"""
|
||||||
|
payload = self._post_csrf_form(
|
||||||
|
self.PRE_EXCHANGE_CANCEL_API,
|
||||||
|
"取消预兑",
|
||||||
|
{"manualID": manual_id, "commodityID": commodity_id, "rid": rid or "0"},
|
||||||
|
referer=self._peace_shop_referer(rid),
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "取消预兑失败")
|
||||||
|
return {"raw": payload}
|
||||||
|
|
||||||
|
def subscribe_commodity(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]:
|
||||||
|
"""预约到货(对齐页面 user/sub,成功提示"预约成功")。"""
|
||||||
|
payload = self._post_csrf_form(
|
||||||
|
self.COMMODITY_SUBSCRIBE_API,
|
||||||
|
"预约到货",
|
||||||
|
{"manualID": manual_id, "commodityID": commodity_id},
|
||||||
|
referer=self._peace_shop_referer(),
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "预约到货失败")
|
||||||
|
return {"raw": payload}
|
||||||
|
|
||||||
|
def unsubscribe_commodity(self, *, manual_id: str, commodity_id: str) -> dict[str, Any]:
|
||||||
|
"""取消预约到货(对齐页面 user/cancelSub)。"""
|
||||||
|
payload = self._post_csrf_form(
|
||||||
|
self.COMMODITY_UNSUBSCRIBE_API,
|
||||||
|
"取消预约到货",
|
||||||
|
{"manualID": manual_id, "commodityID": commodity_id},
|
||||||
|
referer=self._peace_shop_referer(),
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "取消预约到货失败")
|
||||||
|
return {"raw": payload}
|
||||||
|
|
||||||
|
def exchange_gift(self, *, manual_id: str, commodity_id: str, rid: str = "", num: int = 1) -> dict[str, Any]:
|
||||||
|
"""单步兑换(对齐页面 postExchangeGift -> /ordnc/activity/peace/exchange)。
|
||||||
|
|
||||||
|
2026-08-30 bundle 中新链路,页面主要用于兑换中心/自动兑换场景;字段按页面
|
||||||
|
调用({manualID, commodityID, num, rid} + csrfToken)对齐。
|
||||||
|
"""
|
||||||
|
payload = self._post_csrf_form(
|
||||||
|
self.EXCHANGE_GIFT_API,
|
||||||
|
"单步兑换",
|
||||||
|
{
|
||||||
|
"manualID": manual_id,
|
||||||
|
"commodityID": commodity_id,
|
||||||
|
"num": max(1, int(num)),
|
||||||
|
"rid": rid or "0",
|
||||||
|
},
|
||||||
|
referer=self._peace_shop_referer(rid),
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "兑换失败")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
return {
|
||||||
|
"exchange_id": str(data.get("exchangeId") or ""),
|
||||||
|
"commodity_type": data.get("commodityType"),
|
||||||
|
"commodity_image": data.get("commodityImage") or "",
|
||||||
|
"exchange_num": data.get("exchangeNum"),
|
||||||
|
"s_type": data.get("sType"),
|
||||||
|
"g_type": data.get("gType"),
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def auto_convert_switch(self, *, manual_id: str) -> dict[str, Any]:
|
||||||
|
"""查询自动兑换开关(对齐页面 activity/automaticallyConvert)。"""
|
||||||
|
payload = self._request_json(
|
||||||
|
"get",
|
||||||
|
self.AUTO_CONVERT_QUERY_API,
|
||||||
|
"查询自动兑换开关",
|
||||||
|
params={"manualID": manual_id},
|
||||||
|
headers={"Origin": "", "Referer": self._peace_shop_referer()},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "查询自动兑换开关失败")
|
||||||
|
return {"data": payload.get("data") or {}, "raw": payload}
|
||||||
|
|
||||||
|
def set_auto_convert(self, *, manual_id: str, switch: Any) -> dict[str, Any]:
|
||||||
|
"""设置自动兑换开关(对齐页面 automaticallyConvertSave {manualID, switch})。"""
|
||||||
|
payload = self._post_csrf_form(
|
||||||
|
self.AUTO_CONVERT_SAVE_API,
|
||||||
|
"设置自动兑换开关",
|
||||||
|
{"manualID": manual_id, "switch": switch},
|
||||||
|
referer=self._peace_shop_referer(),
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "设置自动兑换开关失败")
|
||||||
|
return {"raw": payload}
|
||||||
|
|
||||||
|
def exchange_home_info(self) -> dict[str, Any]:
|
||||||
|
"""查询兑换中心信息(对齐页面 activity/exchangeHome)。"""
|
||||||
|
payload = self._request_json(
|
||||||
|
"get",
|
||||||
|
self.EXCHANGE_HOME_API,
|
||||||
|
"查询兑换中心",
|
||||||
|
headers={"Origin": "", "Referer": self._peace_shop_referer()},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "查询兑换中心失败")
|
||||||
|
return {"data": payload.get("data") or {}, "raw": payload}
|
||||||
|
|
||||||
def gold_account(self) -> dict[str, Any]:
|
def gold_account(self) -> dict[str, Any]:
|
||||||
"""查询 cz 侧鱼翅余额。"""
|
"""查询 cz 侧鱼翅余额。"""
|
||||||
payload = self._request_json(
|
payload = self._request_json(
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""精英手册兑换:csrf 复用、新链路接口与浏览器状态机路由的测试。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, call
|
||||||
|
|
||||||
|
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
||||||
|
|
||||||
|
|
||||||
|
class CsrfReuseTests(unittest.TestCase):
|
||||||
|
def test_reuses_cookie_token_without_any_http_call(self):
|
||||||
|
client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=existing-token")
|
||||||
|
client._request_json = Mock()
|
||||||
|
|
||||||
|
self.assertEqual(client.csrf_token(), "existing-token")
|
||||||
|
client._request_json.assert_not_called()
|
||||||
|
|
||||||
|
def test_generates_csrf_when_cookie_missing(self):
|
||||||
|
client = DouyuActivityClient("acf_uid=100")
|
||||||
|
|
||||||
|
def fake_generate(*_args, **_kwargs):
|
||||||
|
# 模拟 generateCsrf 响应把新 token 写回 Cookie
|
||||||
|
client._set_cookie_value("cvl_csrf_token", "new-token")
|
||||||
|
return {"error": 0}
|
||||||
|
|
||||||
|
client._request_json = Mock(side_effect=fake_generate)
|
||||||
|
|
||||||
|
self.assertEqual(client.csrf_token(), "new-token")
|
||||||
|
client._request_json.assert_called_once()
|
||||||
|
|
||||||
|
def test_force_refresh_rotates_token(self):
|
||||||
|
client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=old-token")
|
||||||
|
client._request_json = Mock(return_value={"error": 0})
|
||||||
|
client._set_cookie_value("cvl_csrf_token", "rotated-token")
|
||||||
|
|
||||||
|
self.assertEqual(client.csrf_token(force_refresh=True), "rotated-token")
|
||||||
|
|
||||||
|
def test_refreshes_csrf_once_on_token_error(self):
|
||||||
|
client = DouyuActivityClient("acf_uid=100")
|
||||||
|
client.csrf_token = Mock(side_effect=["bad-token", "good-token"])
|
||||||
|
client._request_json = Mock(side_effect=[
|
||||||
|
{"error": 1, "msg": "csrfToken 校验失败"},
|
||||||
|
{"error": 0, "data": {"orderId": "9", "expireSeconds": "300"}},
|
||||||
|
])
|
||||||
|
|
||||||
|
result = client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
|
||||||
|
|
||||||
|
self.assertEqual(result["order_id"], "9")
|
||||||
|
self.assertEqual(client.csrf_token.call_args_list, [call(), call(force_refresh=True)])
|
||||||
|
|
||||||
|
def test_no_refresh_on_regular_error(self):
|
||||||
|
client = DouyuActivityClient("acf_uid=100")
|
||||||
|
client.csrf_token = Mock(return_value="t")
|
||||||
|
client._request_json = Mock(return_value={"error": 1, "msg": "需要开通精英手册,才可以兑换本道具哦"})
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(DouyuActivityError, "需要开通精英手册"):
|
||||||
|
client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
|
||||||
|
client.csrf_token.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_pay_referer_includes_room_id_like_browser(self):
|
||||||
|
client = DouyuActivityClient("acf_uid=100")
|
||||||
|
client.csrf_token = Mock(return_value="t")
|
||||||
|
client._request_json = Mock(return_value={"error": 0, "data": {"exchangeId": "11098933"}})
|
||||||
|
|
||||||
|
client.pay_exchange_order(manual_id="m", order_id="5874", rid="9263298")
|
||||||
|
|
||||||
|
headers = client._request_json.call_args.kwargs["headers"]
|
||||||
|
self.assertEqual(
|
||||||
|
headers["Referer"],
|
||||||
|
"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangePlanTests(unittest.TestCase):
|
||||||
|
"""对齐浏览器兑换按钮状态机($e / Ve / Ge)的路由判定。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def detail(**overrides):
|
||||||
|
base = {
|
||||||
|
"status": 0,
|
||||||
|
"storeStatus": 1,
|
||||||
|
"openStatus": 1,
|
||||||
|
"roomLimited": 0,
|
||||||
|
"isLimitRoom": 0,
|
||||||
|
"eliteLimited": 0,
|
||||||
|
"subscribeType": 0,
|
||||||
|
"exchangeInfo": {"userStatus": 0},
|
||||||
|
"preExchange": {"isExists": 0, "expiringTime": "0"},
|
||||||
|
"batchExchange": 0,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
def test_normal_item_uses_classic_flow(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail())
|
||||||
|
self.assertEqual(plan["action"], "classic")
|
||||||
|
self.assertEqual(plan["max_num"], 1)
|
||||||
|
|
||||||
|
def test_pre_exchange_order_still_classic(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(preExchange={"isExists": 1}))
|
||||||
|
self.assertEqual(plan["action"], "classic")
|
||||||
|
|
||||||
|
def test_reached_exchange_limit_blocked(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(status=1))
|
||||||
|
self.assertEqual(plan["action"], "blocked")
|
||||||
|
self.assertIn("上限", plan["text"])
|
||||||
|
|
||||||
|
def test_no_stock_blocked(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(storeStatus=0))
|
||||||
|
self.assertEqual(plan["action"], "blocked")
|
||||||
|
self.assertIn("无库存", plan["text"])
|
||||||
|
|
||||||
|
def test_room_limited_and_not_in_room_blocked(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
|
self.detail(roomLimited=1, isLimitRoom=0)
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["action"], "blocked")
|
||||||
|
self.assertIn("限定房间", plan["text"])
|
||||||
|
|
||||||
|
def test_not_open_subscribe_item_subscribes(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
|
self.detail(openStatus=0, subscribeType=1)
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["action"], "subscribe")
|
||||||
|
|
||||||
|
def test_not_open_pre_exchange_item_pre_exchanges(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
|
self.detail(openStatus=0, subscribeType=2)
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["action"], "pre_exchange")
|
||||||
|
|
||||||
|
def test_not_open_plain_item_blocked(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(openStatus=0))
|
||||||
|
self.assertEqual(plan["action"], "blocked")
|
||||||
|
self.assertIn("未开启", plan["text"])
|
||||||
|
|
||||||
|
def test_elite_limited_requires_manual(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
|
self.detail(eliteLimited=1), manual_type=0
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["action"], "blocked")
|
||||||
|
self.assertIn("精英手册", plan["text"])
|
||||||
|
# 已开通手册(manualType=1)不受精英专享限制
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
|
self.detail(eliteLimited=1), manual_type=1
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["action"], "classic")
|
||||||
|
|
||||||
|
def test_pre_exchanged_waits_for_open(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
|
self.detail(subscribeType=2, exchangeInfo={"userStatus": 1})
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["action"], "wait")
|
||||||
|
|
||||||
|
def test_batch_exchange_clamps_num(self):
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
|
self.detail(batchExchange=1), batch_num_limit="5"
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["max_num"], 5)
|
||||||
|
self.assertTrue(plan["batch_exchange"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -54,7 +54,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
@patch("web.backend.services.douyu_runner.FishFinRechargeClient")
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
||||||
def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(self, client_class):
|
def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(self, client_class):
|
||||||
supplier = Mock()
|
supplier = Mock()
|
||||||
supplier.create_order.return_value = {
|
supplier.create_order.return_value = {
|
||||||
@@ -99,7 +99,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
self.assertNotIn("pay_url", self.task.result)
|
self.assertNotIn("pay_url", self.task.result)
|
||||||
self.assertNotIn("sign", self.task.result["supplier_order"])
|
self.assertNotIn("sign", self.task.result["supplier_order"])
|
||||||
|
|
||||||
@patch("web.backend.services.douyu_runner.FishFinRechargeClient")
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
||||||
def test_supplier_channel_rejects_account_without_nickname(self, client_class):
|
def test_supplier_channel_rejects_account_without_nickname(self, client_class):
|
||||||
self.account.nickname = ""
|
self.account.nickname = ""
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
@@ -157,7 +157,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
self.assertIn("10001", audit.detail)
|
self.assertIn("10001", audit.detail)
|
||||||
self.assertNotIn("password", audit.detail)
|
self.assertNotIn("password", audit.detail)
|
||||||
|
|
||||||
@patch("web.backend.services.douyu_runner.FishFinRechargeClient")
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
||||||
def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class):
|
def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class):
|
||||||
supplier = Mock()
|
supplier = Mock()
|
||||||
supplier.create_order.return_value = {"code": 400, "msg": "商品已下架"}
|
supplier.create_order.return_value = {"code": 400, "msg": "商品已下架"}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
|||||||
|
"""斗鱼任务执行器:公共基础(由 douyu_runner.py 按功能域拆分)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.douyu import DouyuActivityClient
|
||||||
|
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot
|
||||||
|
from .douyu_service import DOUYU_CONFIG_FIELDS, douyu_config_value, ensure_douyu_config, douyu_task_payload
|
||||||
|
|
||||||
|
# 支付/到账轮询(手册与充值共用)
|
||||||
|
DOUYU_PAYMENT_POLL_SECONDS = 600
|
||||||
|
DOUYU_PAYMENT_POLL_INTERVAL = 5
|
||||||
|
|
||||||
|
class DouyuBatchRunnerCore:
|
||||||
|
"""斗鱼任务执行器公共基础:批次状态、日志、任务落库与客户端构造。"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
batch_id: str,
|
||||||
|
task_type: str,
|
||||||
|
payload: Optional[dict] = None,
|
||||||
|
log_queue: Optional[asyncio.Queue] = None,
|
||||||
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
|
concurrency: int = 3,
|
||||||
|
):
|
||||||
|
self.db = db
|
||||||
|
self.batch_id = batch_id
|
||||||
|
self.task_type = task_type
|
||||||
|
self.payload = payload or {}
|
||||||
|
self.log_queue = log_queue
|
||||||
|
self.loop = loop
|
||||||
|
self.concurrency = max(1, min(concurrency, 10))
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._counter_lock = threading.Lock()
|
||||||
|
self._started = 0
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def _push_log(self, level: str, message: str):
|
||||||
|
if level == "result":
|
||||||
|
try:
|
||||||
|
douyu_batch_registry.mark_finished(self.batch_id)
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
|
if level != "result" and message:
|
||||||
|
log_func = getattr(logger, level, logger.info)
|
||||||
|
log_func(f"[douyu] {message}")
|
||||||
|
if self.log_queue and self.loop:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self.log_queue.put({"level": level, "message": message}),
|
||||||
|
self.loop,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _account_name(account: Account) -> str:
|
||||||
|
return account.nickname or account.username or account.uid or f"#{account.id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_int(value) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_wait_time(seconds: int | None) -> str:
|
||||||
|
if seconds is None:
|
||||||
|
return ""
|
||||||
|
seconds = max(0, int(seconds))
|
||||||
|
days, rem = divmod(seconds, 86400)
|
||||||
|
hours, rem = divmod(rem, 3600)
|
||||||
|
minutes, sec = divmod(rem, 60)
|
||||||
|
if days:
|
||||||
|
return f"{days}天{hours}小时{minutes}分"
|
||||||
|
if hours:
|
||||||
|
return f"{hours}小时{minutes}分{sec}秒"
|
||||||
|
return f"{minutes}分{sec}秒"
|
||||||
|
|
||||||
|
def _push_task_event(self, task: DouyuTask) -> None:
|
||||||
|
"""向批次 WS 推送任务状态事件(level=task),前端即时更新不依赖轮询。"""
|
||||||
|
if not self.log_queue or not self.loop:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = douyu_task_payload(task)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[douyu] 推送任务状态失败: task_id={}", task.id)
|
||||||
|
return
|
||||||
|
event = {
|
||||||
|
"level": "task",
|
||||||
|
"message": "",
|
||||||
|
"task": payload,
|
||||||
|
}
|
||||||
|
asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop)
|
||||||
|
|
||||||
|
def _mark_task(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
status: str,
|
||||||
|
message: str,
|
||||||
|
result: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
task.status = status
|
||||||
|
task.message = message[:512]
|
||||||
|
if result is not None:
|
||||||
|
task.result = result
|
||||||
|
task.finished_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
self._push_task_event(task)
|
||||||
|
|
||||||
|
def _update_task_progress(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
status: str,
|
||||||
|
message: str,
|
||||||
|
result: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
task.status = status
|
||||||
|
task.message = message[:512]
|
||||||
|
if result is not None:
|
||||||
|
task.result = result
|
||||||
|
db.commit()
|
||||||
|
self._push_task_event(task)
|
||||||
|
|
||||||
|
def _upsert_goods(self, db: Session, goods: list[dict]) -> None:
|
||||||
|
now = datetime.now(timezone.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()
|
||||||
|
|
||||||
|
def _upsert_esports_goods(self, db: Session, goods: list[dict]) -> None:
|
||||||
|
now = datetime.now(timezone.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(DouyuEsportsGoodsSnapshot)
|
||||||
|
.filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
row = DouyuEsportsGoodsSnapshot(commodity_id=commodity_id)
|
||||||
|
db.add(row)
|
||||||
|
row.name = str(raw.get("commodityName") or raw.get("name") or "")
|
||||||
|
row.score = self._to_int(raw.get("score"))
|
||||||
|
row.status = str(raw.get("status") or "")
|
||||||
|
row.raw = raw
|
||||||
|
row.updated_at = now
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None:
|
||||||
|
"""同步和平小店商品快照,移除上一次热门抢购等遗留商品。"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
commodity_ids = {
|
||||||
|
str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
||||||
|
for raw in goods
|
||||||
|
}
|
||||||
|
commodity_ids.discard("")
|
||||||
|
query = db.query(DouyuXpdGoodsSnapshot)
|
||||||
|
if commodity_ids:
|
||||||
|
query.filter(~DouyuXpdGoodsSnapshot.commodity_id.in_(commodity_ids)).delete(
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query.delete(synchronize_session=False)
|
||||||
|
for raw in goods:
|
||||||
|
commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "")
|
||||||
|
if not commodity_id:
|
||||||
|
continue
|
||||||
|
row = (
|
||||||
|
db.query(DouyuXpdGoodsSnapshot)
|
||||||
|
.filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
row = DouyuXpdGoodsSnapshot(commodity_id=commodity_id)
|
||||||
|
db.add(row)
|
||||||
|
row.name = str(raw.get("name") or raw.get("sGoodsName") or "")
|
||||||
|
row.price = self._to_int(raw.get("price") or raw.get("iPrice"))
|
||||||
|
row.org_price = self._to_int(raw.get("org_price") or raw.get("iOrgPrice"))
|
||||||
|
row.category = str(raw.get("category") or raw.get("iCategoryId") or "")
|
||||||
|
goods_left = raw.get("goods_left")
|
||||||
|
if goods_left is None:
|
||||||
|
goods_left = raw.get("iGoodsLeft")
|
||||||
|
row.goods_left = self._to_int(goods_left)
|
||||||
|
row.raw = raw
|
||||||
|
row.updated_at = now
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def _config_info(self, db: Session) -> dict:
|
||||||
|
config = ensure_douyu_config(db)
|
||||||
|
return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS}
|
||||||
|
|
||||||
|
def _task_payload(self, task: DouyuTask) -> dict:
|
||||||
|
result = task.result if isinstance(task.result, dict) else {}
|
||||||
|
payload = result.get("payload") if isinstance(result.get("payload"), dict) else {}
|
||||||
|
return {**payload, **self.payload}
|
||||||
|
|
||||||
|
def _client(self, cookie: str) -> DouyuActivityClient:
|
||||||
|
return DouyuActivityClient(cookie, logger=lambda msg: self._push_log("debug", msg))
|
||||||
|
|
||||||
|
def _sleep_interruptible(self, seconds: float) -> bool:
|
||||||
|
"""分段睡眠,任务停止时提前返回;返回 False 表示已被停止。"""
|
||||||
|
waited = 0.0
|
||||||
|
step = 0.5
|
||||||
|
while waited < seconds:
|
||||||
|
if self._stop.is_set():
|
||||||
|
return False
|
||||||
|
time.sleep(min(step, seconds - waited))
|
||||||
|
waited += step
|
||||||
|
return not self._stop.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
class DouyuBatchRegistry:
|
||||||
|
"""管理运行中的斗鱼任务批次。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._batches: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def register(self, batch_id: str, log_queue: asyncio.Queue,
|
||||||
|
loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner):
|
||||||
|
self._batches[batch_id] = {
|
||||||
|
"log_queue": log_queue,
|
||||||
|
"loop": loop,
|
||||||
|
"runner": runner,
|
||||||
|
"finished": False,
|
||||||
|
"updated_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self, batch_id: str):
|
||||||
|
return self._batches.get(batch_id)
|
||||||
|
|
||||||
|
def pop(self, batch_id: str):
|
||||||
|
return self._batches.pop(batch_id, None)
|
||||||
|
|
||||||
|
def mark_finished(self, batch_id: str):
|
||||||
|
if batch_id in self._batches:
|
||||||
|
self._batches[batch_id]["finished"] = True
|
||||||
|
self._batches[batch_id]["updated_at"] = time.time()
|
||||||
|
|
||||||
|
def active_ids(self) -> set[str]:
|
||||||
|
return {
|
||||||
|
batch_id
|
||||||
|
for batch_id, info in self._batches.items()
|
||||||
|
if not info.get("finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
douyu_batch_registry = DouyuBatchRegistry()
|
||||||
|
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""斗鱼任务执行器:送礼(由 douyu_runner.py 按功能域拆分)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.douyu import DouyuActivityClient
|
||||||
|
from ..models import Account, DouyuTask
|
||||||
|
|
||||||
|
DOUYU_GIFT_POINTS_REFRESH_TIMES = 3
|
||||||
|
DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2
|
||||||
|
|
||||||
|
class DonateMixin:
|
||||||
|
"""送礼域:精英令/电竞任务礼物赠送与积分确认。"""
|
||||||
|
def _refresh_points_after_elite_gift(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
client: DouyuActivityClient,
|
||||||
|
cookie: str,
|
||||||
|
ctn: str | None,
|
||||||
|
result: dict,
|
||||||
|
baseline_points: int | None,
|
||||||
|
gift_count: int,
|
||||||
|
) -> dict:
|
||||||
|
"""赠送精英令后短轮询积分;1 个精英令约等于 10 积分。"""
|
||||||
|
expected_gain = max(0, gift_count) * 10
|
||||||
|
target_points = baseline_points + expected_gain if baseline_points is not None else None
|
||||||
|
result["gift_points_baseline"] = baseline_points
|
||||||
|
result["gift_points_expected_gain"] = expected_gain
|
||||||
|
result["gift_points_target"] = target_points
|
||||||
|
|
||||||
|
last_points = None
|
||||||
|
refresh_result: dict = {}
|
||||||
|
for index in range(1, DOUYU_GIFT_POINTS_REFRESH_TIMES + 1):
|
||||||
|
refresh_result = self._refresh_account_points(client, account, cookie, ctn=ctn)
|
||||||
|
db.commit()
|
||||||
|
last_points = refresh_result["points"]
|
||||||
|
result.update(refresh_result)
|
||||||
|
result["gift_points_refresh_count"] = index
|
||||||
|
if target_points is None or (last_points is not None and last_points >= target_points):
|
||||||
|
result["gift_points_confirmed"] = target_points is None or last_points is not None
|
||||||
|
return refresh_result
|
||||||
|
if index < DOUYU_GIFT_POINTS_REFRESH_TIMES:
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
f"赠送精英令成功,等待积分同步(当前 {last_points if last_points is not None else '-'},预期 {target_points})",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
if self._stop.wait(DOUYU_GIFT_POINTS_REFRESH_INTERVAL):
|
||||||
|
break
|
||||||
|
|
||||||
|
result["gift_points_confirmed"] = False
|
||||||
|
result["points"] = last_points
|
||||||
|
return refresh_result
|
||||||
|
|
||||||
|
def _execute_donate_esports_gift(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
*,
|
||||||
|
gift_name: str,
|
||||||
|
config_gift_id_key: str,
|
||||||
|
config_skin_id_key: str,
|
||||||
|
):
|
||||||
|
"""赠送电竞手册任务礼物并刷新独立积分。"""
|
||||||
|
payload = self._task_payload(task)
|
||||||
|
try:
|
||||||
|
gift_count = max(1, int(payload.get("gift_count") or payload.get("count") or 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._mark_task(db, task, "failed", "赠送数量必须是正整数")
|
||||||
|
return
|
||||||
|
|
||||||
|
manual_id = str(config.get("esports_manual_id") or "").strip()
|
||||||
|
gift_id = str(payload.get("gift_id") or config.get(config_gift_id_key) or "").strip()
|
||||||
|
skin_id = str(payload.get("skin_id") or config.get(config_skin_id_key) or "").strip()
|
||||||
|
room_id = str(payload.get("room_id") or config.get("room_id") or "").strip()
|
||||||
|
if not manual_id or not gift_id or not skin_id or not room_id:
|
||||||
|
self._mark_task(db, task, "failed", "请先完整配置电竞手册、房间和礼物参数")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = self._client(cookie)
|
||||||
|
baseline_points = account.esports_points
|
||||||
|
try:
|
||||||
|
baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id)
|
||||||
|
baseline_points = baseline["esports_points"]
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
self._push_log("warning", f"赠送{gift_name}前刷新电竞积分失败: {exc}")
|
||||||
|
|
||||||
|
result = client.donate_esports_gift(
|
||||||
|
gift_name=gift_name,
|
||||||
|
gift_count=gift_count,
|
||||||
|
room_id=room_id,
|
||||||
|
gift_id=gift_id,
|
||||||
|
skin_id=skin_id,
|
||||||
|
)
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"gift_name": gift_name,
|
||||||
|
"gift_id": gift_id,
|
||||||
|
"skin_id": skin_id,
|
||||||
|
"gift_count": gift_count,
|
||||||
|
"esports_points_baseline": baseline_points,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
refresh_errors = []
|
||||||
|
try:
|
||||||
|
result.update(self._refresh_account_gold_balance(client, account))
|
||||||
|
except Exception as exc:
|
||||||
|
refresh_errors.append(f"鱼翅余额: {exc}")
|
||||||
|
try:
|
||||||
|
points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
|
||||||
|
result.update(points_result)
|
||||||
|
result["esports_points_after_gift"] = points_result["esports_points"]
|
||||||
|
result["esports_points_changed"] = (
|
||||||
|
baseline_points is not None
|
||||||
|
and points_result["esports_points"] is not None
|
||||||
|
and points_result["esports_points"] != baseline_points
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
refresh_errors.append(f"电竞积分: {exc}")
|
||||||
|
if refresh_errors:
|
||||||
|
result["refresh_errors"] = refresh_errors
|
||||||
|
|
||||||
|
account.esports_bind_status = "esports_gift_donated"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
message = f"赠送{gift_name}成功: {gift_count}"
|
||||||
|
if account.gold_balance is not None:
|
||||||
|
message += f",鱼翅余额: {account.gold_balance}"
|
||||||
|
if account.esports_points is not None:
|
||||||
|
message += f",电竞积分: {account.esports_points}"
|
||||||
|
self._mark_task(db, task, "success", message, result)
|
||||||
|
|
||||||
|
def _execute_donate_esports_chicken_gift(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""赠送冠军鸡腿。"""
|
||||||
|
self._execute_donate_esports_gift(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
account,
|
||||||
|
cookie,
|
||||||
|
config,
|
||||||
|
gift_name="冠军鸡腿",
|
||||||
|
config_gift_id_key="esports_chicken_gift_id",
|
||||||
|
config_skin_id_key="esports_chicken_skin_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_donate_esports_firework_gift(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""赠送冠军烟花。"""
|
||||||
|
self._execute_donate_esports_gift(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
account,
|
||||||
|
cookie,
|
||||||
|
config,
|
||||||
|
gift_name="冠军烟花",
|
||||||
|
config_gift_id_key="esports_firework_gift_id",
|
||||||
|
config_skin_id_key="esports_firework_skin_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_donate_elite_gift(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
payload = self._task_payload(task)
|
||||||
|
gift_count = int(payload.get("gift_count") or payload.get("count") or 1)
|
||||||
|
client = self._client(cookie)
|
||||||
|
ctn = None
|
||||||
|
baseline_points = account.points
|
||||||
|
try:
|
||||||
|
ctn = client.acf_ccn(refresh_subscribe=False)
|
||||||
|
baseline_result = self._refresh_account_points(client, account, cookie, ctn=ctn)
|
||||||
|
db.commit()
|
||||||
|
baseline_points = baseline_result["points"]
|
||||||
|
except Exception as exc:
|
||||||
|
self._push_log("warning", f"赠送精英令前刷新积分失败: {exc}")
|
||||||
|
result = client.donate_elite_gift(
|
||||||
|
gift_count=gift_count,
|
||||||
|
room_id=str(payload.get("room_id") or config["room_id"]),
|
||||||
|
gift_id=str(payload.get("gift_id") or config["gift_id"]),
|
||||||
|
skin_id=str(payload.get("skin_id") or config["skin_id"]),
|
||||||
|
)
|
||||||
|
result["gift_points_baseline"] = baseline_points
|
||||||
|
refresh_errors = []
|
||||||
|
try:
|
||||||
|
result.update(self._refresh_account_gold_balance(client, account))
|
||||||
|
except Exception as exc:
|
||||||
|
refresh_errors.append(f"鱼翅余额: {exc}")
|
||||||
|
try:
|
||||||
|
result.update(
|
||||||
|
self._refresh_points_after_elite_gift(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
account,
|
||||||
|
client,
|
||||||
|
cookie,
|
||||||
|
ctn,
|
||||||
|
result,
|
||||||
|
baseline_points,
|
||||||
|
gift_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
refresh_errors.append(f"积分: {exc}")
|
||||||
|
if refresh_errors:
|
||||||
|
result["refresh_errors"] = refresh_errors
|
||||||
|
account.bind_status = "gift_donated"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
message = f"赠送精英令成功: {gift_count}"
|
||||||
|
if account.gold_balance is not None:
|
||||||
|
message += f",鱼翅余额: {account.gold_balance}"
|
||||||
|
if account.points is not None:
|
||||||
|
message += f",积分: {account.points}"
|
||||||
|
if result.get("gift_points_target") is not None and not result.get("gift_points_confirmed"):
|
||||||
|
message += f"(未确认涨到 {result['gift_points_target']})"
|
||||||
|
self._mark_task(db, task, "success", message, result)
|
||||||
|
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""斗鱼任务执行器:鱼翅充值(由 douyu_runner.py 按功能域拆分)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.douyu import DouyuActivityClient, FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeError
|
||||||
|
from ..models import Account, DouyuTask
|
||||||
|
from .douyu_service import update_account_profile_from_cookie
|
||||||
|
|
||||||
|
class GoldMixin:
|
||||||
|
"""鱼翅充值域:扫码充值、供应商直充与到账轮询。"""
|
||||||
|
def _refresh_account_gold_balance(self, client: DouyuActivityClient, account: Account) -> dict:
|
||||||
|
"""刷新鱼翅和钱包兑换余额并写回账号表。"""
|
||||||
|
gold = client.gold_account()
|
||||||
|
exchange = client.exchange_balance()
|
||||||
|
account.gold_balance = self._to_int(gold.get("gold"))
|
||||||
|
account.exchange_balance = self._to_int(exchange.get("count"))
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
return {
|
||||||
|
"gold_balance": account.gold_balance,
|
||||||
|
"exchange_balance": account.exchange_balance,
|
||||||
|
"gold": gold,
|
||||||
|
"exchange_balance_query": exchange,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _wait_gold_balance_after_payment(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
client: DouyuActivityClient,
|
||||||
|
result: dict,
|
||||||
|
baseline_gold: int | None,
|
||||||
|
) -> bool:
|
||||||
|
"""等待鱼翅充值到账;余额变化后写回账号表。"""
|
||||||
|
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
|
||||||
|
result["payment_polling"] = True
|
||||||
|
result["baseline_gold_balance"] = baseline_gold
|
||||||
|
poll_count = 0
|
||||||
|
last_gold = baseline_gold
|
||||||
|
baseline_ready = baseline_gold is not None
|
||||||
|
while not self._stop.is_set() and time.monotonic() <= deadline:
|
||||||
|
try:
|
||||||
|
balance_result = self._refresh_account_gold_balance(client, account)
|
||||||
|
db.commit()
|
||||||
|
poll_count += 1
|
||||||
|
last_gold = balance_result["gold_balance"]
|
||||||
|
result.update(balance_result)
|
||||||
|
result["payment_poll_count"] = poll_count
|
||||||
|
result["payment_polling"] = True
|
||||||
|
if not baseline_ready and last_gold is not None:
|
||||||
|
baseline_gold = last_gold
|
||||||
|
result["baseline_gold_balance"] = baseline_gold
|
||||||
|
baseline_ready = True
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
f"鱼翅支付码已生成,已记录当前余额 {last_gold},等待到账",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL):
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
changed = last_gold is not None and (baseline_gold is None or last_gold != baseline_gold)
|
||||||
|
if changed:
|
||||||
|
result["payment_polling"] = False
|
||||||
|
result["gold_recharged"] = True
|
||||||
|
return True
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
f"鱼翅支付码已生成,等待到账(当前鱼翅 {last_gold if last_gold is not None else '-'})",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
poll_count += 1
|
||||||
|
result["payment_poll_count"] = poll_count
|
||||||
|
result["payment_poll_error"] = str(exc)
|
||||||
|
self._update_task_progress(db, task, "running", f"等待鱼翅到账: {exc}", result)
|
||||||
|
if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL):
|
||||||
|
break
|
||||||
|
result["payment_polling"] = False
|
||||||
|
result["gold_recharged"] = False
|
||||||
|
result["gold_balance"] = last_gold
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _supplier_value(payload: dict, *keys: str):
|
||||||
|
"""兼容供应商将订单字段放在响应根节点、data 或 result 节点。"""
|
||||||
|
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||||||
|
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||||
|
for source in (payload, data, result):
|
||||||
|
for key in keys:
|
||||||
|
if source.get(key) is not None:
|
||||||
|
return source[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _supplier_order_status(cls, payload: dict) -> int | None:
|
||||||
|
"""提取供应商订单状态,文档约定 0-4。"""
|
||||||
|
return cls._to_int(cls._supplier_value(payload, "order_status", "orderStatus", "supplier_order_status"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _supplier_message(cls, payload: dict) -> str:
|
||||||
|
"""提取供应商可展示的业务消息。"""
|
||||||
|
value = cls._supplier_value(payload, "msg", "message", "error_msg")
|
||||||
|
return str(value or "")[:256]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _supplier_result(payload: dict) -> dict:
|
||||||
|
"""保存必要订单状态,避免把完整供应商响应或签名暴露到任务结果。"""
|
||||||
|
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||||||
|
response_result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||||
|
result = {
|
||||||
|
key: value
|
||||||
|
for key, value in {**payload, **data, **response_result}.items()
|
||||||
|
if key not in {"sign", "cards", "card_no", "card_pwd", "recharge_arg"}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _supplier_out_order_id(task: DouyuTask) -> str:
|
||||||
|
"""生成可追踪的供应商外部订单号;已有订单号必须在重试时复用。"""
|
||||||
|
existing = str(task.supplier_out_order_id or "").strip()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
batch_token = re.sub(r"[^A-Za-z0-9]", "", str(task.batch_id or "")).upper()[:16] or "LOCAL"
|
||||||
|
return f"DYGF{batch_token}T{task.id}"
|
||||||
|
|
||||||
|
def _wait_supplier_gold_order(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
client: FishFinRechargeClient,
|
||||||
|
result: dict,
|
||||||
|
) -> int | None:
|
||||||
|
"""轮询供应商直充订单至结束状态。"""
|
||||||
|
order_no = str(result["out_order_id"])
|
||||||
|
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
|
||||||
|
poll_count = 0
|
||||||
|
result["payment_polling"] = True
|
||||||
|
while not self._stop.is_set() and time.monotonic() <= deadline:
|
||||||
|
try:
|
||||||
|
# 回调可能已在另一个数据库会话中结束订单,刷新后直接使用其结果。
|
||||||
|
db.refresh(task)
|
||||||
|
if task.status in {"success", "failed"}:
|
||||||
|
callback_result = task.result if isinstance(task.result, dict) else result
|
||||||
|
result.update(callback_result)
|
||||||
|
result["payment_polling"] = False
|
||||||
|
return self._supplier_order_status(callback_result)
|
||||||
|
payload = client.query_order(order_no)
|
||||||
|
code = self._to_int(self._supplier_value(payload, "code"))
|
||||||
|
status = self._supplier_order_status(payload)
|
||||||
|
poll_count += 1
|
||||||
|
result.update({
|
||||||
|
"payment_poll_count": poll_count,
|
||||||
|
"supplier_code": code,
|
||||||
|
"supplier_order_status": status,
|
||||||
|
"supplier_order": self._supplier_result(payload),
|
||||||
|
})
|
||||||
|
if code != 200:
|
||||||
|
result["payment_polling"] = False
|
||||||
|
return status if status in {2, 3, 4} else 4
|
||||||
|
if status in {2, 3, 4}:
|
||||||
|
result["payment_polling"] = False
|
||||||
|
return status
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
f"供应商直充订单处理中(状态 {status if status is not None else '-'})",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
except FishFinRechargeError as exc:
|
||||||
|
poll_count += 1
|
||||||
|
result["payment_poll_count"] = poll_count
|
||||||
|
result["payment_poll_error"] = str(exc)
|
||||||
|
self._update_task_progress(db, task, "running", f"查询供应商订单失败: {exc}", result)
|
||||||
|
if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL):
|
||||||
|
break
|
||||||
|
result["payment_polling"] = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
payload = self._task_payload(task)
|
||||||
|
amount = int(payload.get("amount") or payload.get("gold_amount") or 1)
|
||||||
|
channel = str(config.get("gold_recharge_channel") or "wechat_qr")
|
||||||
|
if channel == "supplier_api":
|
||||||
|
try:
|
||||||
|
self._execute_create_gold_supplier_order(db, task, account, cookie, config, amount)
|
||||||
|
except FishFinRechargeError as exc:
|
||||||
|
self._mark_task(db, task, "failed", str(exc), {"recharge_channel": "supplier_api"})
|
||||||
|
return
|
||||||
|
client = self._client(cookie)
|
||||||
|
baseline_gold = account.gold_balance
|
||||||
|
try:
|
||||||
|
baseline = self._refresh_account_gold_balance(client, account)
|
||||||
|
baseline_gold = baseline["gold_balance"]
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
self._push_log("warning", f"生成鱼翅码前刷新余额失败: {exc}")
|
||||||
|
result = client.create_gold_qr(amount=amount, pay_type=int(config["gold_pay_type"]))
|
||||||
|
account.bind_status = "gold_qr_created"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._update_task_progress(db, task, "running", f"鱼翅 {amount} 元支付码已生成,等待到账", result)
|
||||||
|
recharged = self._wait_gold_balance_after_payment(db, task, account, client, result, baseline_gold)
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止", result)
|
||||||
|
return
|
||||||
|
if recharged:
|
||||||
|
account.bind_status = "gold_recharged"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"鱼翅已到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"failed",
|
||||||
|
f"未检测到鱼翅到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_create_gold_supplier_order(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
amount: int,
|
||||||
|
) -> None:
|
||||||
|
"""创建供应商鱼翅直充订单并轮询订单状态。"""
|
||||||
|
product_id = str(config.get("gold_api_product_id") or "").strip()
|
||||||
|
template_name = str(config.get("gold_api_account_template_name") or "斗鱼昵称").strip()
|
||||||
|
if not product_id:
|
||||||
|
raise FishFinRechargeError("请先在配置中填写供应商直充商品 ID")
|
||||||
|
# 充值商品按斗鱼昵称识别账号,UID 只能作为审计信息,不能作为充值值。
|
||||||
|
update_account_profile_from_cookie(account, cookie)
|
||||||
|
recharge_account = str(account.nickname or "").strip()
|
||||||
|
if not recharge_account:
|
||||||
|
raise FishFinRechargeError("账号缺少斗鱼昵称,无法发起供应商直充")
|
||||||
|
|
||||||
|
# 首次生成后持久化,网络重试或进程重启都继续查询同一笔订单。
|
||||||
|
order_no = self._supplier_out_order_id(task)
|
||||||
|
task.supplier_out_order_id = order_no
|
||||||
|
db.commit()
|
||||||
|
# pay_amount 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。
|
||||||
|
pay_amount = Decimal(amount)
|
||||||
|
|
||||||
|
def trace(event: dict) -> None:
|
||||||
|
"""将脱敏供应商协议信息输出到任务日志,便于线上联调。"""
|
||||||
|
stage = event.get("stage")
|
||||||
|
if stage == "request":
|
||||||
|
params = event.get("params") or {}
|
||||||
|
self._push_log(
|
||||||
|
"info",
|
||||||
|
"供应商直充 | 下单 "
|
||||||
|
f"| 外部单号={params.get('out_order_id') or '-'} "
|
||||||
|
f"| 数量={params.get('buy_num') or '-'} "
|
||||||
|
f"| 金额={params.get('pay_amount') or '-'} "
|
||||||
|
f"| 商品={params.get('product_id') or '-'}",
|
||||||
|
)
|
||||||
|
if event.get("json_body"):
|
||||||
|
self._push_log(
|
||||||
|
"debug",
|
||||||
|
"供应商协议 | 请求 "
|
||||||
|
f"| {event.get('method')} {event.get('path')} "
|
||||||
|
f"| 签名摘要={event.get('sign_digest')} "
|
||||||
|
f"| 参数={FishFinRechargeClient._json_text(event['json_body'])}",
|
||||||
|
)
|
||||||
|
elif stage == "response":
|
||||||
|
status = self._to_int(event.get("order_status"))
|
||||||
|
status_labels = {0: "待处理", 1: "处理中", 2: "成功", 3: "失败", 4: "异常"}
|
||||||
|
status_text = status_labels.get(status, "-")
|
||||||
|
reason = str(event.get("fail_reason") or event.get("message") or "-")
|
||||||
|
self._push_log(
|
||||||
|
"info",
|
||||||
|
"供应商直充 | 响应 "
|
||||||
|
f"| HTTP={event.get('http_status') or '-'} "
|
||||||
|
f"| 业务码={event.get('code') or '-'} "
|
||||||
|
f"| 外部单号={event.get('out_order_id') or '-'} "
|
||||||
|
f"| 供应商单号={event.get('order_id') or '-'} "
|
||||||
|
f"| 状态={status_text} "
|
||||||
|
f"| 提示={reason}",
|
||||||
|
)
|
||||||
|
if event.get("response_body"):
|
||||||
|
self._push_log(
|
||||||
|
"debug",
|
||||||
|
"供应商协议 | 响应 "
|
||||||
|
f"| HTTP={event.get('http_status')} "
|
||||||
|
f"| 内容={FishFinRechargeClient._json_text(event['response_body'])}",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = FishFinRechargeClient(FishFinRechargeConfig.from_env(), trace=trace)
|
||||||
|
order_payload = client.create_order(
|
||||||
|
buy_num=amount,
|
||||||
|
pay_amount=pay_amount,
|
||||||
|
out_order_id=order_no,
|
||||||
|
product_id=product_id,
|
||||||
|
recharge_arg=[{"templateName": template_name, "templateVal": recharge_account}],
|
||||||
|
order_type=0,
|
||||||
|
notify_url=client.config.notify_url,
|
||||||
|
)
|
||||||
|
code = self._to_int(self._supplier_value(order_payload, "code"))
|
||||||
|
status = self._supplier_order_status(order_payload)
|
||||||
|
result = {
|
||||||
|
"recharge_channel": "supplier_api",
|
||||||
|
"out_order_id": order_no,
|
||||||
|
"order_id": self._supplier_value(order_payload, "order_id", "orderId"),
|
||||||
|
"recharge_account": recharge_account,
|
||||||
|
"douyu_uid": str(account.uid or "").strip(),
|
||||||
|
"buy_num": amount,
|
||||||
|
"product_id": product_id,
|
||||||
|
"pay_amount": format(pay_amount.normalize(), "f"),
|
||||||
|
"order_type": 0,
|
||||||
|
"supplier_code": code,
|
||||||
|
"supplier_order_status": status,
|
||||||
|
"supplier_order": self._supplier_result(order_payload),
|
||||||
|
}
|
||||||
|
if code != 200:
|
||||||
|
self._mark_task(db, task, "failed", self._supplier_message(order_payload) or "供应商创建直充订单失败", result)
|
||||||
|
return
|
||||||
|
account.bind_status = "gold_api_order_created"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._update_task_progress(db, task, "running", "供应商直充订单已创建,等待到账", result)
|
||||||
|
if status not in {2, 3, 4}:
|
||||||
|
status = self._wait_supplier_gold_order(db, task, client, result)
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止", result)
|
||||||
|
return
|
||||||
|
if status == 2:
|
||||||
|
account.bind_status = "gold_recharged"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(db, task, "success", "供应商直充成功", result)
|
||||||
|
return
|
||||||
|
if status in {3, 4}:
|
||||||
|
self._mark_task(db, task, "failed", "供应商直充失败", result)
|
||||||
|
return
|
||||||
|
self._mark_task(db, task, "failed", "供应商直充订单查询超时", result)
|
||||||
|
|
||||||
|
def _execute_query_gold_balance(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
client = self._client(cookie)
|
||||||
|
result = self._refresh_account_gold_balance(client, account)
|
||||||
|
account.bind_status = "gold_balance_queried"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"鱼翅余额: {account.gold_balance if account.gold_balance is not None else '-'}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"""斗鱼任务执行器:商城兑换(由 douyu_runner.py 按功能域拆分)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.douyu import DouyuActivityClient, DouyuActivityError
|
||||||
|
from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask
|
||||||
|
|
||||||
|
# 兑换节奏与重试(对齐 8.30 浏览器抓包:锁单->支付间隔约 2.2~4.2s;火爆类错误要长退避而不是秒级连打)
|
||||||
|
DOUYU_EXCHANGE_PRE_CREATE_JITTER = (0.3, 1.2)
|
||||||
|
DOUYU_EXCHANGE_LOCK_PAY_DELAY_RANGE = (2.0, 4.0)
|
||||||
|
DOUYU_EXCHANGE_LOCK_TTL_SECONDS = 300
|
||||||
|
DOUYU_EXCHANGE_RATE_LIMIT_HINTS = ("火爆", "频繁", "稍后", "太热", "限流", "繁忙", "人多", "排队", "手慢")
|
||||||
|
DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS = (30, 60, 90)
|
||||||
|
DOUYU_EXCHANGE_GENERIC_BACKOFFS = (2, 5, 10, 20)
|
||||||
|
|
||||||
|
class GoodsMixin:
|
||||||
|
"""商城兑换域:商品刷新、锁单/支付/兑换、兑换节奏与退避重试。"""
|
||||||
|
def _execute_refresh_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
client = self._client(cookie)
|
||||||
|
result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"])
|
||||||
|
goods = result["goods"]
|
||||||
|
self._upsert_goods(db, goods)
|
||||||
|
account.bind_status = account.bind_status or "active"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(db, task, "success", f"已刷新商品 {len(goods)} 个", {"goods_count": len(goods), "goods": goods})
|
||||||
|
|
||||||
|
def _execute_refresh_esports_goods(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""刷新电竞手册皮肤商城快照。"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
result = client.list_esports_goods(
|
||||||
|
manual_id=str(config["esports_manual_id"]),
|
||||||
|
rid=str(config["room_id"]),
|
||||||
|
)
|
||||||
|
goods = result["goods"]
|
||||||
|
self._upsert_esports_goods(db, goods)
|
||||||
|
account.esports_bind_status = "esports_goods_refreshed"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"已刷新电竞皮肤 {len(goods)} 个",
|
||||||
|
{"goods_count": len(goods), "esports_store_score": result["score"], "goods": goods},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_lock_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
payload = self._task_payload(task)
|
||||||
|
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
|
||||||
|
if not commodity_id:
|
||||||
|
self._mark_task(db, task, "failed", "请选择锁定商品")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
num = int(payload.get("num") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
num = 1
|
||||||
|
client = self._client(cookie)
|
||||||
|
result = client.create_exchange_order(
|
||||||
|
manual_id=str(config["manual_id"]),
|
||||||
|
rid=str(config["rid"]),
|
||||||
|
commodity_id=commodity_id,
|
||||||
|
num=max(1, num),
|
||||||
|
)
|
||||||
|
goods = (
|
||||||
|
db.query(DouyuGoodsSnapshot)
|
||||||
|
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
account.bind_status = "goods_locked"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
expire_seconds = result.get("expire_seconds")
|
||||||
|
expire_text = f",{expire_seconds} 秒内有效" if expire_seconds else ""
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"锁单成功: {(goods.name if goods else '') or commodity_id}(订单 {result['order_id']}{expire_text})",
|
||||||
|
{
|
||||||
|
"goods": goods.raw if goods else None,
|
||||||
|
"game_name": account.game_name or "",
|
||||||
|
"game_channel": account.game_channel or "",
|
||||||
|
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
|
||||||
|
**result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_pay_locked_order(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
payload = self._task_payload(task)
|
||||||
|
order_id = str(payload.get("order_id") or payload.get("orderId") or "").strip()
|
||||||
|
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
|
||||||
|
if not order_id:
|
||||||
|
self._mark_task(db, task, "failed", "锁单订单号不能为空")
|
||||||
|
return
|
||||||
|
client = self._client(cookie)
|
||||||
|
payment = client.pay_exchange_order(
|
||||||
|
manual_id=str(config["manual_id"]),
|
||||||
|
order_id=order_id,
|
||||||
|
rid=str(config.get("rid") or ""),
|
||||||
|
)
|
||||||
|
goods = None
|
||||||
|
if commodity_id:
|
||||||
|
goods = (
|
||||||
|
db.query(DouyuGoodsSnapshot)
|
||||||
|
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
account.bind_status = "goods_exchanged"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
result = {
|
||||||
|
"commodity_id": commodity_id,
|
||||||
|
"order_id": order_id,
|
||||||
|
"exchange_id": payment["exchange_id"],
|
||||||
|
"commodity_image": payment["commodity_image"],
|
||||||
|
"exchange_num": payment["exchange_num"],
|
||||||
|
"payment": payment,
|
||||||
|
"goods": goods.raw if goods else None,
|
||||||
|
"game_name": account.game_name or "",
|
||||||
|
"game_channel": account.game_channel or "",
|
||||||
|
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
ctn = client.acf_ccn(refresh_subscribe=False)
|
||||||
|
result.update(self._refresh_account_points(client, account, cookie, ctn=ctn))
|
||||||
|
except Exception as exc:
|
||||||
|
self._push_log("warning", f"支付锁单后刷新积分失败: {exc}")
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"锁单支付成功: {(goods.name if goods else '') or commodity_id or order_id}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_rate_limited_error(message: str) -> bool:
|
||||||
|
"""识别频控/火爆类错误文案,命中时按长退避重试。"""
|
||||||
|
return any(hint in message for hint in DOUYU_EXCHANGE_RATE_LIMIT_HINTS)
|
||||||
|
|
||||||
|
def _pay_exchange_with_backoff(
|
||||||
|
self,
|
||||||
|
client: DouyuActivityClient,
|
||||||
|
*,
|
||||||
|
manual_id: str,
|
||||||
|
order_id: str,
|
||||||
|
rid: str,
|
||||||
|
locked_at: float,
|
||||||
|
) -> dict | None:
|
||||||
|
"""支付锁单,按错误类型退避重试(不再 0.3s 无脑连打)。
|
||||||
|
|
||||||
|
频控/火爆类错误:30/60/90 秒退避,最多 4 次尝试且不超出锁单有效期;
|
||||||
|
其他错误:2/5/10/20 秒退避,最多 5 次尝试。
|
||||||
|
"""
|
||||||
|
for attempt in range(6):
|
||||||
|
if self._stop.is_set():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return client.pay_exchange_order(
|
||||||
|
manual_id=manual_id,
|
||||||
|
order_id=order_id,
|
||||||
|
rid=rid,
|
||||||
|
)
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
last_error = str(exc)
|
||||||
|
if self._is_rate_limited_error(last_error):
|
||||||
|
if attempt >= len(DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS):
|
||||||
|
raise DouyuActivityError(
|
||||||
|
f"锁单 {order_id} 支付被频控拦截(已退避重试{attempt}次): {last_error}"
|
||||||
|
) from exc
|
||||||
|
backoff = DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS[attempt]
|
||||||
|
else:
|
||||||
|
if attempt >= len(DOUYU_EXCHANGE_GENERIC_BACKOFFS):
|
||||||
|
raise DouyuActivityError(
|
||||||
|
f"锁单 {order_id} 支付失败(已重试{attempt}次): {last_error}"
|
||||||
|
) from exc
|
||||||
|
backoff = DOUYU_EXCHANGE_GENERIC_BACKOFFS[attempt]
|
||||||
|
# 超出锁单有效期(服务端 300s)前留 15s 余量,放弃继续重试
|
||||||
|
remaining = locked_at + DOUYU_EXCHANGE_LOCK_TTL_SECONDS - time.time()
|
||||||
|
if remaining - backoff < 15:
|
||||||
|
raise DouyuActivityError(
|
||||||
|
f"锁单 {order_id} 支付失败且临近过期({int(remaining)}s): {last_error}"
|
||||||
|
) from exc
|
||||||
|
kind = "频控" if self._is_rate_limited_error(last_error) else "错误"
|
||||||
|
self._push_log(
|
||||||
|
"info",
|
||||||
|
f" 锁单 {order_id} 支付{kind}退避 {backoff:.0f}s 后重试 {attempt + 1}/"
|
||||||
|
f"{len(DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS) if self._is_rate_limited_error(last_error) else len(DOUYU_EXCHANGE_GENERIC_BACKOFFS) + 1}: {last_error}",
|
||||||
|
)
|
||||||
|
if not self._sleep_interruptible(backoff):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _execute_exchange_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
payload = self._task_payload(task)
|
||||||
|
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
|
||||||
|
if not commodity_id:
|
||||||
|
self._mark_task(db, task, "failed", "请选择兑换商品")
|
||||||
|
return
|
||||||
|
client = self._client(cookie)
|
||||||
|
manual_id = str(config["manual_id"])
|
||||||
|
rid = str(config.get("rid") or "")
|
||||||
|
result: dict = {"commodity_id": commodity_id}
|
||||||
|
|
||||||
|
# ---- 对齐浏览器:先查手册状态 + 商品详情,按页面状态机选兑换链路 ----
|
||||||
|
manual_type = None
|
||||||
|
try:
|
||||||
|
user_info = client.elite_user_info(manual_id=manual_id, rid=rid)
|
||||||
|
manual_type = user_info.get("manual_type")
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
self._push_log("warning", f"查询手册状态失败,按经典流程继续: {exc}")
|
||||||
|
detail = None
|
||||||
|
try:
|
||||||
|
detail = client.query_goods_detail(manual_id=manual_id, commodity_id=commodity_id, rid=rid)["detail"]
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
self._push_log("warning", f"查询商品详情失败,按经典流程继续: {exc}")
|
||||||
|
|
||||||
|
batch_num_limit = None
|
||||||
|
if detail and int(detail.get("batchExchange") or 0) > 0:
|
||||||
|
try:
|
||||||
|
batch_num_limit = client.batch_exchange_limit(
|
||||||
|
manual_id=manual_id, commodity_id=commodity_id, rid=rid
|
||||||
|
)["limit"]
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
self._push_log("warning", f"查询批量兑换上限失败: {exc}")
|
||||||
|
|
||||||
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
|
detail or {}, manual_type=manual_type, batch_num_limit=batch_num_limit
|
||||||
|
)
|
||||||
|
result["plan"] = plan
|
||||||
|
if detail:
|
||||||
|
result["detail"] = detail
|
||||||
|
action = plan["action"]
|
||||||
|
|
||||||
|
def finish_failed(message: str) -> None:
|
||||||
|
self._mark_task(
|
||||||
|
db, task, "failed", message,
|
||||||
|
{"commodity_id": commodity_id, "plan": plan, "detail": detail},
|
||||||
|
)
|
||||||
|
|
||||||
|
if action in ("blocked", "wait"):
|
||||||
|
finish_failed(f"兑换失败: {plan['text']}")
|
||||||
|
return
|
||||||
|
|
||||||
|
goods = (
|
||||||
|
db.query(DouyuGoodsSnapshot)
|
||||||
|
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
name = (goods.name if goods else "") or commodity_id
|
||||||
|
|
||||||
|
if action == "subscribe":
|
||||||
|
try:
|
||||||
|
client.subscribe_commodity(manual_id=manual_id, commodity_id=commodity_id)
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
finish_failed(f"预约到货失败: {exc}")
|
||||||
|
return
|
||||||
|
account.bind_status = "goods_subscribed"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(
|
||||||
|
db, task, "success", f"已预约到货: {name} ({plan['text']})",
|
||||||
|
{"commodity_id": commodity_id, "plan": plan, "detail": detail},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "pre_exchange":
|
||||||
|
try:
|
||||||
|
check = client.pre_exchange_check(manual_id=manual_id, commodity_id=commodity_id)
|
||||||
|
user_status = int((check.get("data") or {}).get("userStatus") or 0)
|
||||||
|
if user_status == 1 and not plan["pre_exchange"]:
|
||||||
|
self._mark_task(
|
||||||
|
db, task, "success", f"已预兑: {name},等待开放后继续兑换",
|
||||||
|
{"commodity_id": commodity_id, "plan": plan, "detail": detail, "check": check},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
confirm = client.confirm_pre_exchange(manual_id=manual_id, commodity_id=commodity_id, rid=rid)
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
finish_failed(f"预兑失败: {exc}")
|
||||||
|
return
|
||||||
|
account.bind_status = "goods_pre_exchanged"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(
|
||||||
|
db, task, "success", f"预兑成功: {name},开放后可继续兑换",
|
||||||
|
{"commodity_id": commodity_id, "plan": plan, "detail": detail, "check": check, "confirm": confirm},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- 经典链路:锁单 -> 人类节奏等待 -> 支付(错误分类退避重试)----
|
||||||
|
try:
|
||||||
|
num = max(1, int(payload.get("num") or 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
num = 1
|
||||||
|
num = min(num, int(plan.get("max_num") or 1))
|
||||||
|
|
||||||
|
# 少量随机抖动(浏览器点击商品到锁单之间有人工间隔)
|
||||||
|
self._push_log("info", f" 兑换 {name}: {plan['text']}")
|
||||||
|
if not self._sleep_interruptible(random.uniform(*DOUYU_EXCHANGE_PRE_CREATE_JITTER)):
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止", result)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
locked = client.create_exchange_order(
|
||||||
|
manual_id=manual_id,
|
||||||
|
rid=rid,
|
||||||
|
commodity_id=commodity_id,
|
||||||
|
num=num,
|
||||||
|
)
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
# 锁单本身被频控时做一次长退避重试,避免无脑连打
|
||||||
|
backoff = DOUYU_EXCHANGE_RATE_LIMIT_BACKOFFS[0] if self._is_rate_limited_error(str(exc)) else 3
|
||||||
|
self._push_log("info", f" 锁定兑换商品失败({exc}),{backoff:.0f}s 后重试一次")
|
||||||
|
if not self._sleep_interruptible(backoff):
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止", result)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
locked = client.create_exchange_order(
|
||||||
|
manual_id=manual_id,
|
||||||
|
rid=rid,
|
||||||
|
commodity_id=commodity_id,
|
||||||
|
num=num,
|
||||||
|
)
|
||||||
|
except DouyuActivityError as exc2:
|
||||||
|
finish_failed(f"锁定兑换商品失败: {exc2}")
|
||||||
|
return
|
||||||
|
result.update({**locked, "lock_order": locked, "goods": goods.raw if goods else None})
|
||||||
|
|
||||||
|
# 锁单成功后按浏览器节奏(抓包实测 2.24s~4.2s)等待再支付
|
||||||
|
delay = random.uniform(*DOUYU_EXCHANGE_LOCK_PAY_DELAY_RANGE)
|
||||||
|
self._push_log("info", f" 锁单成功(订单 {locked['order_id']}),{delay:.1f}s 后支付")
|
||||||
|
if not self._sleep_interruptible(delay):
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payment = self._pay_exchange_with_backoff(
|
||||||
|
client,
|
||||||
|
manual_id=manual_id,
|
||||||
|
order_id=locked["order_id"],
|
||||||
|
rid=rid,
|
||||||
|
locked_at=locked["locked_at"],
|
||||||
|
)
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
finish_failed(str(exc))
|
||||||
|
return
|
||||||
|
if payment is None:
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked)
|
||||||
|
return
|
||||||
|
|
||||||
|
result.update({
|
||||||
|
"order_id": locked["order_id"],
|
||||||
|
"exchange_id": payment["exchange_id"],
|
||||||
|
"commodity_image": payment["commodity_image"] or locked["commodity_image"],
|
||||||
|
"exchange_num": payment["exchange_num"],
|
||||||
|
"payment": payment,
|
||||||
|
})
|
||||||
|
account.bind_status = "goods_exchanged"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
# 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功)
|
||||||
|
points_refresh = None
|
||||||
|
try:
|
||||||
|
ctn = client.acf_ccn(refresh_subscribe=False)
|
||||||
|
points_refresh = self._refresh_account_points(client, account, cookie, ctn=ctn)
|
||||||
|
except Exception as exc:
|
||||||
|
self._push_log("warning", f"兑换后刷新积分失败: {exc}")
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"兑换成功: {name}",
|
||||||
|
{
|
||||||
|
"goods": goods.raw if goods else None,
|
||||||
|
"game_name": account.game_name or "",
|
||||||
|
"game_channel": account.game_channel or "",
|
||||||
|
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
|
||||||
|
**result,
|
||||||
|
**(points_refresh or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_exchange_esports_goods(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""兑换电竞手册皮肤,并同步电竞积分。"""
|
||||||
|
payload = self._task_payload(task)
|
||||||
|
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
|
||||||
|
if not commodity_id:
|
||||||
|
self._mark_task(db, task, "failed", "请选择电竞皮肤")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
quantity = max(1, int(payload.get("quantity") or payload.get("num") or 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._mark_task(db, task, "failed", "兑换数量必须是正整数")
|
||||||
|
return
|
||||||
|
|
||||||
|
manual_id = str(config.get("esports_manual_id") or "").strip()
|
||||||
|
room_id = str(config.get("room_id") or "").strip()
|
||||||
|
if not manual_id or not room_id:
|
||||||
|
self._mark_task(db, task, "failed", "请先配置电竞手册 manualID 和房间 ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = self._client(cookie)
|
||||||
|
baseline_points = account.esports_points
|
||||||
|
try:
|
||||||
|
baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id)
|
||||||
|
baseline_points = baseline["esports_points"]
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
self._push_log("warning", f"兑换电竞皮肤前刷新积分失败: {exc}")
|
||||||
|
|
||||||
|
result = client.exchange_esports_goods(
|
||||||
|
manual_id=manual_id,
|
||||||
|
rid=room_id,
|
||||||
|
commodity_id=commodity_id,
|
||||||
|
quantity=quantity,
|
||||||
|
)
|
||||||
|
goods = (
|
||||||
|
db.query(DouyuEsportsGoodsSnapshot)
|
||||||
|
.filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
result["goods"] = goods.raw if goods else None
|
||||||
|
result["esports_points_baseline"] = baseline_points
|
||||||
|
try:
|
||||||
|
points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
|
||||||
|
result.update(points_result)
|
||||||
|
result["esports_points_after_exchange"] = points_result["esports_points"]
|
||||||
|
except Exception as exc:
|
||||||
|
result["esports_points_refresh_error"] = str(exc)
|
||||||
|
|
||||||
|
account.esports_bind_status = "esports_goods_exchanged"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
name = (goods.name if goods else "") or commodity_id
|
||||||
|
message = f"兑换电竞皮肤成功: {name}"
|
||||||
|
if quantity > 1:
|
||||||
|
message += f" x{quantity}"
|
||||||
|
if account.esports_points is not None:
|
||||||
|
message += f",电竞积分: {account.esports_points}"
|
||||||
|
result["game_name"] = account.esports_game_name or ""
|
||||||
|
result["game_channel"] = account.esports_game_channel or ""
|
||||||
|
result["account_name"] = account.nickname or account.username or account.uid or f"#{account.id}"
|
||||||
|
self._mark_task(db, task, "success", message, result)
|
||||||
|
|
||||||
|
def _execute_query_limited_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
client = self._client(cookie)
|
||||||
|
result = client.query_limited_goods(manual_id=str(config["manual_id"]), rid=str(config["rid"]))
|
||||||
|
limited = result["limited_goods"]
|
||||||
|
names = [str(item.get("commodityName") or "") for item in limited if item.get("commodityName")]
|
||||||
|
message = "无限制商品" if not names else f"限兑 {len(names)} 个: {', '.join(names[:5])}"
|
||||||
|
account.bind_status = "limited_goods_queried"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(db, task, "success", message, {"limited_count": len(limited), "limited_goods": limited})
|
||||||
|
|
||||||
|
def _execute_query_exchange_records(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
client = self._client(cookie)
|
||||||
|
result = client.exchange_records(manual_id=str(config["manual_id"]))
|
||||||
|
records = result["records"]
|
||||||
|
account.bind_status = "exchange_records_queried"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(db, task, "success", f"兑换记录 {len(records)} 条" if records else "暂无兑换记录", result)
|
||||||
|
|
||||||
|
def _execute_prefetch_csrf_token(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
client = self._client(cookie)
|
||||||
|
token = client.csrf_token()
|
||||||
|
account.bind_status = "csrf_token_ready"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(db, task, "success", "获取 csrf_token 成功", {"csrf_token": token, "cookie": client.cookie})
|
||||||
|
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""斗鱼任务执行器:手册开通与积分(由 douyu_runner.py 按功能域拆分)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.douyu import DouyuActivityClient, DouyuActivityError
|
||||||
|
from ..models import Account, DouyuTask
|
||||||
|
from .douyu_service import account_uid, update_account_profile_from_cookie
|
||||||
|
|
||||||
|
class ManualMixin:
|
||||||
|
"""手册域:精英/电竞手册开通支付、积分查询与到账轮询。"""
|
||||||
|
def _refresh_account_points(
|
||||||
|
self,
|
||||||
|
client: DouyuActivityClient,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
*,
|
||||||
|
ctn: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""刷新账号积分并写回账号表。"""
|
||||||
|
uid = account_uid(account, cookie)
|
||||||
|
if not uid:
|
||||||
|
raise DouyuActivityError("Cookie 中没有 acf_uid,无法查询积分")
|
||||||
|
ctn_value = ctn or client.acf_ccn(refresh_subscribe=False)
|
||||||
|
result = client.query_points(uid=uid, ctn=ctn_value)
|
||||||
|
points = self._to_int(result.get("points"))
|
||||||
|
account.uid = uid
|
||||||
|
account.points = points
|
||||||
|
update_account_profile_from_cookie(account, cookie)
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
return {"points": points, "points_query": result}
|
||||||
|
|
||||||
|
def _wait_points_after_payment(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
client: DouyuActivityClient,
|
||||||
|
cookie: str,
|
||||||
|
ctn: str,
|
||||||
|
result: dict,
|
||||||
|
) -> bool:
|
||||||
|
"""等待宝典支付到账;积分达到 300 视为开通成功。"""
|
||||||
|
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
|
||||||
|
result["payment_polling"] = True
|
||||||
|
result["payment_target_points"] = 300
|
||||||
|
poll_count = 0
|
||||||
|
last_points = None
|
||||||
|
while not self._stop.is_set() and time.monotonic() <= deadline:
|
||||||
|
try:
|
||||||
|
points_result = self._refresh_account_points(client, account, cookie, ctn=ctn)
|
||||||
|
db.commit()
|
||||||
|
poll_count += 1
|
||||||
|
last_points = points_result["points"]
|
||||||
|
result.update(points_result)
|
||||||
|
result["payment_poll_count"] = poll_count
|
||||||
|
result["payment_polling"] = True
|
||||||
|
if last_points is not None and last_points >= 300:
|
||||||
|
result["payment_polling"] = False
|
||||||
|
result["elite_opened"] = True
|
||||||
|
return True
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
f"精英宝典支付码已生成,等待开通到账(当前积分 {last_points if last_points is not None else '-'})",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
poll_count += 1
|
||||||
|
result["payment_poll_count"] = poll_count
|
||||||
|
result["payment_poll_error"] = str(exc)
|
||||||
|
self._update_task_progress(db, task, "running", f"等待开通到账: {exc}", result)
|
||||||
|
if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL):
|
||||||
|
break
|
||||||
|
result["payment_polling"] = False
|
||||||
|
result["elite_opened"] = False
|
||||||
|
result["points"] = last_points
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _refresh_esports_handbook(
|
||||||
|
self,
|
||||||
|
client: DouyuActivityClient,
|
||||||
|
account: Account,
|
||||||
|
*,
|
||||||
|
manual_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""刷新电竞手册开通状态并将积分写回账号。"""
|
||||||
|
result = client.esports_user_info(manual_id=manual_id)
|
||||||
|
manual_type = self._to_int(result.get("manual_type"))
|
||||||
|
manual_score = self._to_int(result.get("manual_score"))
|
||||||
|
account.esports_points = manual_score
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
return {
|
||||||
|
"esports_manual_type": manual_type,
|
||||||
|
"esports_manual_score": manual_score,
|
||||||
|
"esports_expire_time": result.get("expire_time"),
|
||||||
|
"esports_user_info": result,
|
||||||
|
"esports_points": manual_score,
|
||||||
|
"points": manual_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _wait_esports_open_after_payment(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
client: DouyuActivityClient,
|
||||||
|
*,
|
||||||
|
manual_id: str,
|
||||||
|
result: dict,
|
||||||
|
baseline_manual_type: int | None,
|
||||||
|
baseline_manual_score: int | None,
|
||||||
|
) -> bool:
|
||||||
|
"""等待电竞手册支付到账,以 manualType=1 或积分变化作为成功条件。"""
|
||||||
|
deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS
|
||||||
|
result["payment_polling"] = True
|
||||||
|
result["esports_manual_type_baseline"] = baseline_manual_type
|
||||||
|
result["esports_manual_score_baseline"] = baseline_manual_score
|
||||||
|
poll_count = 0
|
||||||
|
last_manual_type = baseline_manual_type
|
||||||
|
last_manual_score = baseline_manual_score
|
||||||
|
|
||||||
|
while not self._stop.is_set() and time.monotonic() <= deadline:
|
||||||
|
try:
|
||||||
|
handbook_result = self._refresh_esports_handbook(
|
||||||
|
client,
|
||||||
|
account,
|
||||||
|
manual_id=manual_id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
poll_count += 1
|
||||||
|
last_manual_type = handbook_result["esports_manual_type"]
|
||||||
|
last_manual_score = handbook_result["esports_manual_score"]
|
||||||
|
result.update(handbook_result)
|
||||||
|
result["payment_poll_count"] = poll_count
|
||||||
|
result["payment_polling"] = True
|
||||||
|
opened = (
|
||||||
|
last_manual_type is not None
|
||||||
|
and last_manual_type >= 1
|
||||||
|
) or (
|
||||||
|
baseline_manual_score is not None
|
||||||
|
and last_manual_score is not None
|
||||||
|
and last_manual_score > baseline_manual_score
|
||||||
|
)
|
||||||
|
if opened:
|
||||||
|
result["payment_polling"] = False
|
||||||
|
result["esports_opened"] = True
|
||||||
|
return True
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
"电竞手册支付码已生成,等待开通到账"
|
||||||
|
f"(类型 {last_manual_type if last_manual_type is not None else '-'},"
|
||||||
|
f"积分 {last_manual_score if last_manual_score is not None else '-'})",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
poll_count += 1
|
||||||
|
result["payment_poll_count"] = poll_count
|
||||||
|
result["payment_poll_error"] = str(exc)
|
||||||
|
self._update_task_progress(db, task, "running", f"等待电竞手册到账: {exc}", result)
|
||||||
|
if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL):
|
||||||
|
break
|
||||||
|
|
||||||
|
result["payment_polling"] = False
|
||||||
|
result["esports_opened"] = False
|
||||||
|
result["esports_manual_type"] = last_manual_type
|
||||||
|
result["esports_manual_score"] = last_manual_score
|
||||||
|
result["esports_points"] = last_manual_score
|
||||||
|
result["points"] = last_manual_score
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _execute_create_elite_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
client = self._client(cookie)
|
||||||
|
ctn = str(self._task_payload(task).get("ctn") or "")
|
||||||
|
if not ctn:
|
||||||
|
ctn = client.acf_ccn(refresh_subscribe=True)
|
||||||
|
act_alias = self._confirm_act_alias(config) or self._bind_qr_act_alias(config)
|
||||||
|
if not act_alias:
|
||||||
|
self._mark_task(db, task, "failed", "请先配置开通宝典活动 actAlias")
|
||||||
|
return
|
||||||
|
result = client.create_elite_qr(
|
||||||
|
ctn=ctn,
|
||||||
|
act_alias=act_alias,
|
||||||
|
amount=int(config["elite_amount"]),
|
||||||
|
room_id=str(config["room_id"]),
|
||||||
|
)
|
||||||
|
account.bind_status = "elite_qr_created"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._update_task_progress(db, task, "running", "精英宝典支付码已生成,等待开通到账", result)
|
||||||
|
opened = self._wait_points_after_payment(db, task, account, client, cookie, ctn, result)
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止", result)
|
||||||
|
return
|
||||||
|
if opened:
|
||||||
|
account.bind_status = "elite_opened"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(db, task, "success", f"精英宝典已开通,积分: {account.points}", result)
|
||||||
|
return
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"failed",
|
||||||
|
f"未检测到精英宝典开通到账,当前积分: {account.points if account.points is not None else '-'}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_create_esports_qr(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""生成电竞手册支付二维码,并通过活动状态确认开通到账。"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
ctn = str(self._task_payload(task).get("ctn") or "")
|
||||||
|
if not ctn:
|
||||||
|
ctn = client.acf_ccn(refresh_subscribe=True)
|
||||||
|
act_alias = str(config.get("esports_act_alias") or "").strip()
|
||||||
|
manual_id = str(config.get("esports_manual_id") or "").strip()
|
||||||
|
if not act_alias or not manual_id:
|
||||||
|
self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias 和 manualID")
|
||||||
|
return
|
||||||
|
|
||||||
|
baseline_manual_type = None
|
||||||
|
baseline_manual_score = None
|
||||||
|
baseline_result: dict = {}
|
||||||
|
try:
|
||||||
|
baseline_result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
|
||||||
|
baseline_manual_type = baseline_result["esports_manual_type"]
|
||||||
|
baseline_manual_score = baseline_result["esports_manual_score"]
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
self._push_log("warning", f"生成电竞手册支付码前查询活动状态失败: {exc}")
|
||||||
|
|
||||||
|
if baseline_manual_type is not None and baseline_manual_type >= 1:
|
||||||
|
account.esports_bind_status = "esports_opened"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"电竞手册已开通,积分: {baseline_manual_score if baseline_manual_score is not None else '-'}",
|
||||||
|
{**baseline_result, "esports_opened": True, "payment_polling": False},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = client.create_esports_qr(
|
||||||
|
ctn=ctn,
|
||||||
|
act_alias=act_alias,
|
||||||
|
amount=int(config["esports_amount"]),
|
||||||
|
room_id=str(config["room_id"]),
|
||||||
|
)
|
||||||
|
result.update(baseline_result)
|
||||||
|
account.esports_bind_status = "esports_qr_created"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._update_task_progress(db, task, "running", "电竞手册支付码已生成,等待开通到账", result)
|
||||||
|
opened = self._wait_esports_open_after_payment(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
account,
|
||||||
|
client,
|
||||||
|
manual_id=manual_id,
|
||||||
|
result=result,
|
||||||
|
baseline_manual_type=baseline_manual_type,
|
||||||
|
baseline_manual_score=baseline_manual_score,
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止", result)
|
||||||
|
return
|
||||||
|
if opened:
|
||||||
|
account.esports_bind_status = "esports_opened"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"电竞手册已开通,积分: {account.esports_points if account.esports_points is not None else '-'}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"failed",
|
||||||
|
"未检测到电竞手册开通到账"
|
||||||
|
f",类型: {result.get('esports_manual_type', '-')},"
|
||||||
|
f"积分: {result.get('esports_manual_score', '-')}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_query_esports_points(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""查询电竞手册积分。"""
|
||||||
|
manual_id = str(config.get("esports_manual_id") or "").strip()
|
||||||
|
if not manual_id:
|
||||||
|
self._mark_task(db, task, "failed", "请先配置电竞手册 manualID")
|
||||||
|
return
|
||||||
|
client = self._client(cookie)
|
||||||
|
result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
|
||||||
|
account.esports_bind_status = "esports_points_queried"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
points = result["esports_points"]
|
||||||
|
self._mark_task(db, task, "success", f"电竞积分: {points if points is not None else '-'}", result)
|
||||||
|
|
||||||
|
def _execute_query_points(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
|
client = self._client(cookie)
|
||||||
|
ctn = client.acf_ccn(refresh_subscribe=False)
|
||||||
|
result = self._refresh_account_points(client, account, cookie, ctn=ctn)
|
||||||
|
account.bind_status = "points_queried"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
points = result["points"]
|
||||||
|
self._mark_task(db, task, "success", f"积分: {points if points is not None else '-'}", result)
|
||||||
|
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
"""斗鱼任务执行器:和平小店(由 douyu_runner.py 按功能域拆分)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.douyu import DouyuActivityClient, DouyuActivityError
|
||||||
|
from ..models import Account, DouyuTask, DouyuXpdGoodsSnapshot
|
||||||
|
|
||||||
|
DOUYU_XPD_BIND_POLL_SECONDS = 300
|
||||||
|
DOUYU_XPD_BIND_POLL_INTERVAL = 5
|
||||||
|
|
||||||
|
class XpdMixin:
|
||||||
|
"""和平小店域:绑定、商品、余额、碎片与兑换。"""
|
||||||
|
def _xpd_role_context(self, client: DouyuActivityClient, config: dict) -> dict:
|
||||||
|
"""获取小店 H5 参数 + 绑定角色信息,小店任务共用。"""
|
||||||
|
embed = client.xpd_embed_query(
|
||||||
|
act_alias=str(config["xpd_act_alias"]),
|
||||||
|
rid=str(config["xpd_rid"]),
|
||||||
|
)
|
||||||
|
role = client.xpd_get_role(
|
||||||
|
embed_query=embed["query"],
|
||||||
|
act_id=str(config["xpd_act_id"]),
|
||||||
|
rid=str(config["xpd_rid"]),
|
||||||
|
)
|
||||||
|
return {"embed": embed, "role": role}
|
||||||
|
|
||||||
|
def _xpd_area_id(self, role: dict, account: Account) -> int:
|
||||||
|
"""角色大区: 优先使用接口值,微信=1、手Q=2,未知回退已存值。"""
|
||||||
|
raw_area = role.get("area")
|
||||||
|
if raw_area not in (None, ""):
|
||||||
|
try:
|
||||||
|
area_id = int(raw_area)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
area_id = 0
|
||||||
|
if area_id > 0:
|
||||||
|
return area_id
|
||||||
|
role_type = str(role.get("type") or "")
|
||||||
|
if role_type == "wx":
|
||||||
|
return 1
|
||||||
|
if role_type == "qq":
|
||||||
|
return 2
|
||||||
|
return account.xpd_area_id or 1
|
||||||
|
|
||||||
|
def _apply_xpd_role_to_account(self, account: Account, role: dict, area_id: int) -> None:
|
||||||
|
account.xpd_game_name = str(role.get("role_name") or "") or account.xpd_game_name
|
||||||
|
account.xpd_openid = str(role.get("game_open_id") or "") or account.xpd_openid
|
||||||
|
account.xpd_role_id = str(role.get("role_id") or "") or account.xpd_role_id
|
||||||
|
account.xpd_plat_id = self._to_int(role.get("plat_id"))
|
||||||
|
account.xpd_area_id = area_id
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def _execute_query_xpd_role(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""查询和平小店绑定角色。"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
ctx = self._xpd_role_context(client, config)
|
||||||
|
role = ctx["role"]
|
||||||
|
if not role.get("role_id"):
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店绑定角色")
|
||||||
|
return
|
||||||
|
area_id = self._xpd_area_id(role, account)
|
||||||
|
self._apply_xpd_role_to_account(account, role, area_id)
|
||||||
|
account.xpd_bind_status = "xpd_bound"
|
||||||
|
db.commit()
|
||||||
|
role_text = str(role.get("role_name") or "-")
|
||||||
|
channel = "微信" if role.get("type") == "wx" else ("手Q" if role.get("type") == "qq" else str(role.get("type") or "-"))
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"小店角色: {role_text}({channel})",
|
||||||
|
{"role": role, "area_id": area_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_get_xpd_bind_qr(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""生成和平小店绑定二维码并轮询等待微信扫码绑定/换绑完成。
|
||||||
|
|
||||||
|
识别到新角色后仅标记"待确认",不自动回写账号,由用户手动确认绑定。
|
||||||
|
"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
act_alias = str(config.get("xpd_act_alias") or "").strip()
|
||||||
|
if not act_alias:
|
||||||
|
self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias")
|
||||||
|
return
|
||||||
|
result = client.xpd_bind_qr(act_alias=act_alias)
|
||||||
|
account.xpd_bind_status = "xpd_bind_qr_ready"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
# 记录绑定前状态:已绑定账号生成二维码后必须等扫码换绑,不能立即成功
|
||||||
|
try:
|
||||||
|
before = client.xpd_bind_info(act_alias=act_alias)
|
||||||
|
result["before_bound"] = bool(before.get("bind_role"))
|
||||||
|
result["before_role_name"] = str(before.get("role_name") or "")
|
||||||
|
result["before_area_name"] = str(before.get("area_name") or "")
|
||||||
|
result["before_plat_name"] = str(before.get("plat_name") or "")
|
||||||
|
except Exception:
|
||||||
|
result["before_bound"] = False
|
||||||
|
result["before_role_name"] = ""
|
||||||
|
result["bind_polling"] = True
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
"二维码已生成,请微信扫码在小程序中绑定角色",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
state = self._wait_xpd_bind(db, task, client, act_alias, result)
|
||||||
|
result["bind_polling"] = False
|
||||||
|
if state == "stopped":
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止", result)
|
||||||
|
return
|
||||||
|
if state == "pending":
|
||||||
|
role_text = str(result.get("role_name") or "-")
|
||||||
|
self._mark_task(db, task, "success", f"已识别角色: {role_text},待确认绑定", result)
|
||||||
|
return
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"failed",
|
||||||
|
"未检测到小店绑定(二维码仍有效,可再次生成后扫码)",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wait_xpd_bind(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
client: DouyuActivityClient,
|
||||||
|
act_alias: str,
|
||||||
|
result: dict,
|
||||||
|
) -> str:
|
||||||
|
"""轮询 bindInfo 检测绑定/换绑角色,识别到后停在"待确认",不自动回写账号。
|
||||||
|
|
||||||
|
- 绑定前未绑定:检测到 bind_role=1 即识别到待确认角色
|
||||||
|
- 绑定前已绑定(换绑):检测到角色名变化才算换绑完成,角色不变继续等
|
||||||
|
返回 "pending"=已识别待确认角色, "stopped"=任务停止, "timeout"=超时未识别。
|
||||||
|
"""
|
||||||
|
before_bound = bool(result.get("before_bound"))
|
||||||
|
before_role_name = str(result.get("before_role_name") or "")
|
||||||
|
deadline = time.monotonic() + DOUYU_XPD_BIND_POLL_SECONDS
|
||||||
|
poll_count = 0
|
||||||
|
while not self._stop.is_set() and time.monotonic() <= deadline:
|
||||||
|
try:
|
||||||
|
info = client.xpd_bind_info(act_alias=act_alias)
|
||||||
|
poll_count += 1
|
||||||
|
result["bind_poll_count"] = poll_count
|
||||||
|
result["bind_polling"] = True
|
||||||
|
role_name = str(info.get("role_name") or "")
|
||||||
|
bound_now = bool(info.get("bind_role"))
|
||||||
|
changed = before_bound and bool(role_name) and role_name != before_role_name
|
||||||
|
if (not before_bound and bound_now and role_name) or changed:
|
||||||
|
result.update({key: value for key, value in info.items() if key != "raw"})
|
||||||
|
result["bind_polling"] = False
|
||||||
|
result["xpd_pending_confirm"] = True
|
||||||
|
return "pending"
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
f"等待扫码绑定(第 {poll_count} 次)",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
poll_count += 1
|
||||||
|
result["bind_poll_count"] = poll_count
|
||||||
|
result["bind_poll_error"] = str(exc)
|
||||||
|
self._update_task_progress(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"running",
|
||||||
|
f"等待扫码绑定: {exc}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
if self._stop.wait(DOUYU_XPD_BIND_POLL_INTERVAL):
|
||||||
|
break
|
||||||
|
result["bind_polling"] = False
|
||||||
|
return "stopped" if self._stop.is_set() else "timeout"
|
||||||
|
|
||||||
|
def _execute_confirm_xpd_bind(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""确认和平小店绑定:回查 bindInfo,确认绑定角色后将账号回写为已绑定。"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
act_alias = str(config.get("xpd_act_alias") or "").strip()
|
||||||
|
if not act_alias:
|
||||||
|
self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias")
|
||||||
|
return
|
||||||
|
result = client.xpd_bind_info(act_alias=act_alias)
|
||||||
|
role_name = str(result.get("role_name") or "")
|
||||||
|
if not result.get("bind_role") or not role_name:
|
||||||
|
account.xpd_bind_status = "xpd_not_bound"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
self._mark_task(db, task, "failed", "尚未检测到小店绑定角色,请先扫码绑定", result)
|
||||||
|
return
|
||||||
|
# 优先用完整角色信息回写(与查询角色一致),失败时回退 bindInfo 角色名
|
||||||
|
try:
|
||||||
|
ctx = self._xpd_role_context(client, config)
|
||||||
|
role = ctx["role"]
|
||||||
|
if role.get("role_id"):
|
||||||
|
self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account))
|
||||||
|
else:
|
||||||
|
account.xpd_game_name = role_name
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
account.xpd_game_name = role_name
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
account.xpd_bind_status = "xpd_bound"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
result["xpd_pending_confirm"] = False
|
||||||
|
result["xpd_bound"] = True
|
||||||
|
self._mark_task(db, task, "success", f"小店绑定成功: {role_name}", result)
|
||||||
|
|
||||||
|
def _execute_query_xpd_bind_info(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""查询和平小店绑定信息(bindInfo)。
|
||||||
|
|
||||||
|
仅查询展示,不回写账号;确认绑定由 confirm_xpd_bind 任务完成。
|
||||||
|
"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
act_alias = str(config.get("xpd_act_alias") or "").strip()
|
||||||
|
if not act_alias:
|
||||||
|
self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias")
|
||||||
|
return
|
||||||
|
result = client.xpd_bind_info(act_alias=act_alias)
|
||||||
|
status = "已绑定" if result.get("bind_role") else "未绑定"
|
||||||
|
text = str(result.get("role_name") or result.get("nick") or "-")
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"小店绑定: {status} ({text})",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_refresh_xpd_goods(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""刷新和平小店商品列表快照(全局数据,任一可用 CK 即可)。"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
ctx = self._xpd_role_context(client, config)
|
||||||
|
role = ctx["role"]
|
||||||
|
if not role.get("role_id"):
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店绑定角色")
|
||||||
|
return
|
||||||
|
area_id = self._xpd_area_id(role, account)
|
||||||
|
result = client.xpd_list_goods(
|
||||||
|
embed_query=ctx["embed"]["query"],
|
||||||
|
act_id=str(config["xpd_act_id"]),
|
||||||
|
openid=str(role.get("game_open_id") or ""),
|
||||||
|
roleid=str(role.get("role_id") or ""),
|
||||||
|
areaid=str(area_id),
|
||||||
|
)
|
||||||
|
goods = result["goods"]
|
||||||
|
self._upsert_xpd_goods(db, goods)
|
||||||
|
self._apply_xpd_role_to_account(account, role, area_id)
|
||||||
|
account.xpd_bind_status = "xpd_goods_refreshed"
|
||||||
|
db.commit()
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"已刷新小店商品 {len(goods)} 个",
|
||||||
|
{"goods_count": len(goods), "goods": goods},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_query_xpd_balance(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""查询和平小店点券余额。"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
ctx = self._xpd_role_context(client, config)
|
||||||
|
role = ctx["role"]
|
||||||
|
if not role.get("role_id"):
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店绑定角色")
|
||||||
|
return
|
||||||
|
area_id = self._xpd_area_id(role, account)
|
||||||
|
role_plat = role.get("plat_id")
|
||||||
|
plat = str(role_plat) if role_plat not in (None, "") else "1"
|
||||||
|
result = client.xpd_balance(
|
||||||
|
embed_query=ctx["embed"]["query"],
|
||||||
|
act_id=str(config["xpd_act_id"]),
|
||||||
|
openid=str(role.get("game_open_id") or ""),
|
||||||
|
roleid=str(role.get("role_id") or ""),
|
||||||
|
plat=plat,
|
||||||
|
areaid=str(area_id),
|
||||||
|
)
|
||||||
|
balance = result.get("balance")
|
||||||
|
self._apply_xpd_role_to_account(account, role, area_id)
|
||||||
|
account.xpd_balance = balance
|
||||||
|
account.xpd_bind_status = "xpd_balance_queried"
|
||||||
|
db.commit()
|
||||||
|
if balance is None:
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店点券余额")
|
||||||
|
return
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"小店点券余额: {balance}",
|
||||||
|
{"balance": balance, "role": role, "area_id": area_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_query_xpd_fragments(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""查询和平小店扭蛋碎片数量。
|
||||||
|
|
||||||
|
优先现查角色;getrole 受限(Livelink 风控/失效)时回退账号已存角色,
|
||||||
|
保证已绑定账号仍可查询。
|
||||||
|
"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
act_id = str(config["xpd_act_id"])
|
||||||
|
embed_query: dict = {}
|
||||||
|
openid = str(account.xpd_openid or "")
|
||||||
|
roleid = str(account.xpd_role_id or "")
|
||||||
|
stored_plat = account.xpd_plat_id
|
||||||
|
plat = str(stored_plat) if stored_plat is not None else "1"
|
||||||
|
areaid = str(account.xpd_area_id or 1)
|
||||||
|
role: dict = {}
|
||||||
|
try:
|
||||||
|
ctx = self._xpd_role_context(client, config)
|
||||||
|
embed_query = ctx["embed"]["query"]
|
||||||
|
role = ctx["role"] if isinstance(ctx.get("role"), dict) else {}
|
||||||
|
if role.get("role_id"):
|
||||||
|
role_area = self._xpd_area_id(role, account)
|
||||||
|
openid = str(role.get("game_open_id") or "") or openid
|
||||||
|
roleid = str(role.get("role_id") or "") or roleid
|
||||||
|
role_plat = role.get("plat_id")
|
||||||
|
plat = str(role_plat) if role_plat not in (None, "") else plat
|
||||||
|
areaid = str(role_area) or areaid
|
||||||
|
self._apply_xpd_role_to_account(account, role, role_area)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not openid or not roleid:
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定")
|
||||||
|
return
|
||||||
|
result = client.xpd_fragments(
|
||||||
|
embed_query=embed_query,
|
||||||
|
act_id=act_id,
|
||||||
|
openid=openid,
|
||||||
|
roleid=roleid,
|
||||||
|
plat=plat,
|
||||||
|
areaid=areaid,
|
||||||
|
)
|
||||||
|
fragments = result.get("fragments")
|
||||||
|
account.xpd_fragments = fragments
|
||||||
|
account.xpd_bind_status = "xpd_fragments_queried"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
if fragments is None:
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店扭蛋碎片数量")
|
||||||
|
return
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"小店扭蛋碎片: {fragments}",
|
||||||
|
{"fragments": fragments, "role": role, "area_id": int(areaid)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_query_xpd_purchase_records(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""查询和平小店道聚城购买记录。"""
|
||||||
|
client = self._client(cookie)
|
||||||
|
embed = client.xpd_embed_query(
|
||||||
|
act_alias=str(config["xpd_act_alias"]),
|
||||||
|
rid=str(config["xpd_rid"]),
|
||||||
|
)
|
||||||
|
result = client.xpd_purchase_records(
|
||||||
|
embed_query=embed["query"],
|
||||||
|
act_id=str(config["xpd_act_id"]),
|
||||||
|
)
|
||||||
|
account.xpd_bind_status = "xpd_purchase_records_queried"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
total = result.get("total") or len(result.get("records") or [])
|
||||||
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"小店兑换记录 {total} 条" if total else "暂无小店兑换记录",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_exchange_xpd_goods(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
task: DouyuTask,
|
||||||
|
account: Account,
|
||||||
|
cookie: str,
|
||||||
|
config: dict,
|
||||||
|
):
|
||||||
|
"""兑换和平小店商品,使用本次签发的道聚城短时授权。"""
|
||||||
|
payload = self._task_payload(task)
|
||||||
|
commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip()
|
||||||
|
if not commodity_id:
|
||||||
|
self._mark_task(db, task, "failed", "请选择小店商品")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pay_type = int(payload.get("pay_type") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._mark_task(db, task, "failed", "兑换货币参数无效")
|
||||||
|
return
|
||||||
|
if pay_type not in (1, 5):
|
||||||
|
self._mark_task(db, task, "failed", "小店兑换仅支持点券或扭蛋碎片")
|
||||||
|
return
|
||||||
|
|
||||||
|
goods = (
|
||||||
|
db.query(DouyuXpdGoodsSnapshot)
|
||||||
|
.filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not goods:
|
||||||
|
self._mark_task(db, task, "failed", "未找到小店商品快照,请先刷新商品列表")
|
||||||
|
return
|
||||||
|
goods_snapshot = goods.raw if isinstance(goods.raw, dict) else {}
|
||||||
|
goods_raw = goods_snapshot.get("raw") if isinstance(goods_snapshot.get("raw"), dict) else goods_snapshot
|
||||||
|
price_key = "iPrice" if pay_type == 1 else "iJb2Price"
|
||||||
|
price = self._to_int(goods_raw.get(price_key))
|
||||||
|
if price is None:
|
||||||
|
price = goods.price if pay_type == 1 else None
|
||||||
|
if price is None or price <= 0:
|
||||||
|
currency = "点券" if pay_type == 1 else "扭蛋碎片"
|
||||||
|
self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换")
|
||||||
|
return
|
||||||
|
# iGoodsLeft=-1 表示活动未公开库存,不是售罄;只有 0 才阻止兑换。
|
||||||
|
if goods.goods_left == 0:
|
||||||
|
self._mark_task(db, task, "failed", "该商品库存不足,请刷新商品列表后重试")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = self._client(cookie)
|
||||||
|
embed = client.xpd_embed_query(
|
||||||
|
act_alias=str(config["xpd_act_alias"]),
|
||||||
|
rid=str(config["xpd_rid"]),
|
||||||
|
)
|
||||||
|
role: dict = {}
|
||||||
|
try:
|
||||||
|
role = client.xpd_get_role(
|
||||||
|
embed_query=embed["query"],
|
||||||
|
act_id=str(config["xpd_act_id"]),
|
||||||
|
rid=str(config["xpd_rid"]),
|
||||||
|
)
|
||||||
|
if role.get("role_id"):
|
||||||
|
self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account))
|
||||||
|
except DouyuActivityError as exc:
|
||||||
|
self._push_log("warning", f"小店兑换前刷新角色失败,使用已保存角色: {exc}")
|
||||||
|
if not role.get("role_id") and not account.xpd_role_id:
|
||||||
|
self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = client.xpd_exchange_goods(
|
||||||
|
embed_query=embed["query"],
|
||||||
|
act_id=str(config["xpd_act_id"]),
|
||||||
|
rid=str(config["xpd_rid"]),
|
||||||
|
commodity_id=commodity_id,
|
||||||
|
price=price,
|
||||||
|
picture=str(goods_raw.get("sGoodsPic") or ""),
|
||||||
|
pay_type=pay_type,
|
||||||
|
action_id=str(goods_raw.get("iActionId") or ""),
|
||||||
|
)
|
||||||
|
if pay_type == 1 and result.get("new_balance") is not None:
|
||||||
|
account.xpd_balance = result["new_balance"]
|
||||||
|
if pay_type == 5 and result.get("new_balance") is not None:
|
||||||
|
account.xpd_fragments = result["new_balance"]
|
||||||
|
account.xpd_bind_status = "xpd_goods_exchanged"
|
||||||
|
account.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
currency = "点券" if pay_type == 1 else "扭蛋碎片"
|
||||||
|
display_role = role.get("role_name") or account.xpd_game_name or ""
|
||||||
|
channel = "微信" if (role.get("type") == "wx" or account.xpd_area_id == 1) else "手Q"
|
||||||
|
result.update({
|
||||||
|
"goods": {**goods_raw, "commodityName": goods.name or ""},
|
||||||
|
"game_name": display_role,
|
||||||
|
"game_channel": channel,
|
||||||
|
"account_name": account.nickname or account.username or account.uid or f"#{account.id}",
|
||||||
|
"currency": currency,
|
||||||
|
})
|
||||||
|
self._mark_task(db, task, "success", f"兑换小店商品成功: {goods.name or commodity_id}({price}{currency})", result)
|
||||||
|
|
||||||
Reference in New Issue
Block a user