feat(douyu): support elite handbook lock orders
This commit is contained in:
@@ -12,6 +12,7 @@ __pycache__/
|
|||||||
# 运行时数据
|
# 运行时数据
|
||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
|
artifacts/
|
||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
+124
-30
@@ -3,9 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import random
|
|
||||||
import re
|
import re
|
||||||
import string
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -39,7 +37,9 @@ class DouyuActivityClient:
|
|||||||
BIND_INFO_V2_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo"
|
BIND_INFO_V2_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo"
|
||||||
BIND_API = "https://www.douyu.com/japi/carnivalApi/tencent/bind"
|
BIND_API = "https://www.douyu.com/japi/carnivalApi/tencent/bind"
|
||||||
GOODS_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storehome"
|
GOODS_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/storehome"
|
||||||
EXCHANGE_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchange"
|
CREATE_EXCHANGE_ORDER_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/createExchangeOrder"
|
||||||
|
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_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchangeList"
|
EXCHANGE_LIST_API = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchangeList"
|
||||||
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"
|
||||||
@@ -749,45 +749,139 @@ class DouyuActivityClient:
|
|||||||
records = (payload.get("data") or {}).get("list") or []
|
records = (payload.get("data") or {}).get("list") or []
|
||||||
return {"records": records, "raw": payload}
|
return {"records": records, "raw": payload}
|
||||||
|
|
||||||
def exchange_goods(self, *, manual_id: str, rid: str, commodity_id: str, ctn: str | None = None) -> dict[str, Any]:
|
@staticmethod
|
||||||
"""兑换活动商品。"""
|
def _multipart_fields(data: dict[str, Any]) -> dict[str, tuple[None, str]]:
|
||||||
ctn_value = ctn or cookie_value(self.cookie, "acf_ccn") or self.acf_ccn(refresh_subscribe=False)
|
"""Build requests multipart fields without uploading files."""
|
||||||
|
return {key: (None, str(value)) for key, value in data.items()}
|
||||||
|
|
||||||
|
def create_exchange_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
manual_id: str,
|
||||||
|
rid: str,
|
||||||
|
commodity_id: str,
|
||||||
|
num: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Lock stock for an activity commodity before spending points."""
|
||||||
|
if num < 1:
|
||||||
|
raise DouyuActivityError("锁单数量必须大于 0")
|
||||||
token = self.csrf_token()
|
token = self.csrf_token()
|
||||||
randstr = "".join(random.choices(string.ascii_letters + string.digits, k=16))
|
payload = self._request_json(
|
||||||
data = {
|
"post",
|
||||||
|
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,
|
||||||
"ctn": ctn_value,
|
"num": num,
|
||||||
"csrfToken": token,
|
"csrfToken": token,
|
||||||
"randstr": randstr,
|
}),
|
||||||
"token[error][code]": "-1",
|
|
||||||
"token[data]": "",
|
|
||||||
}
|
|
||||||
payload = self._request_json(
|
|
||||||
"post",
|
|
||||||
self.EXCHANGE_API,
|
|
||||||
"兑换商品",
|
|
||||||
data=data,
|
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
||||||
"Referer": f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}",
|
"Referer": f"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId={rid}",
|
||||||
"sec-ch-ua": f'"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
|
||||||
"sec-ch-ua-mobile": "?0",
|
|
||||||
"sec-ch-ua-platform": '"Windows"',
|
|
||||||
"Sec-Fetch-Site": "same-origin",
|
|
||||||
"Sec-Fetch-Mode": "cors",
|
|
||||||
"Sec-Fetch-Dest": "empty",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
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 "锁定兑换商品失败")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
order_id = str(data.get("orderId") or "").strip()
|
||||||
|
if not order_id:
|
||||||
|
raise DouyuActivityError("锁定兑换商品成功,但响应中没有 orderId")
|
||||||
|
locked_at = int(time.time())
|
||||||
|
try:
|
||||||
|
expire_seconds = int(data.get("expireSeconds") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
expire_seconds = 0
|
||||||
|
if expire_seconds > 0:
|
||||||
|
expires_at = locked_at + expire_seconds
|
||||||
|
else:
|
||||||
|
expires_at = None
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"commodity_id": commodity_id,
|
||||||
|
"commodity_name": data.get("commodityName") or "",
|
||||||
|
"commodity_image": data.get("commodityImage") or "",
|
||||||
|
"score": data.get("score"),
|
||||||
|
"num": data.get("num", num),
|
||||||
|
"expire_seconds": data.get("expireSeconds"),
|
||||||
|
"locked_at": locked_at,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"csrf_token": token,
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def pay_exchange_order(self, *, manual_id: str, order_id: str) -> dict[str, Any]:
|
||||||
|
"""Pay a previously locked exchange order with handbook points."""
|
||||||
|
normalized_order_id = str(order_id).strip()
|
||||||
|
if not normalized_order_id:
|
||||||
|
raise DouyuActivityError("锁单订单号不能为空")
|
||||||
|
token = self.csrf_token()
|
||||||
|
payload = self._request_json(
|
||||||
|
"post",
|
||||||
|
self.PAY_EXCHANGE_ORDER_API,
|
||||||
|
"支付锁单",
|
||||||
|
files=self._multipart_fields({
|
||||||
|
"orderID": normalized_order_id,
|
||||||
|
"manualID": manual_id,
|
||||||
|
"csrfToken": token,
|
||||||
|
}),
|
||||||
|
headers={
|
||||||
|
"Referer": "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "支付锁单失败")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
return {
|
||||||
|
"order_id": str(data.get("orderId") or normalized_order_id),
|
||||||
|
"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"),
|
||||||
|
"csrf_token": token,
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def exchange_orders(self, *, manual_id: str, page: int = 1, page_size: int = 10) -> dict[str, Any]:
|
||||||
|
"""Query current locked exchange orders."""
|
||||||
|
payload = self._request_json(
|
||||||
|
"get",
|
||||||
|
self.EXCHANGE_ORDER_LIST_API,
|
||||||
|
"查询锁单",
|
||||||
|
params={"page": page, "pageSize": page_size, "manualID": manual_id},
|
||||||
|
headers={
|
||||||
|
"Origin": "",
|
||||||
|
"Referer": "https://www.douyu.com/pages/live-peace-handbook/web/lock-order?ditchname=pass0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if payload.get("error") not in (0, "0"):
|
||||||
|
raise DouyuActivityError(payload.get("msg") or "查询锁单失败")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
return {
|
||||||
|
"orders": data.get("list") or [],
|
||||||
|
"total": data.get("total") or 0,
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def exchange_goods(self, *, manual_id: str, rid: str, commodity_id: str) -> dict[str, Any]:
|
||||||
|
"""Lock an activity commodity and immediately pay the locked order."""
|
||||||
|
locked = self.create_exchange_order(
|
||||||
|
manual_id=manual_id,
|
||||||
|
rid=rid,
|
||||||
|
commodity_id=commodity_id,
|
||||||
|
num=1,
|
||||||
|
)
|
||||||
|
paid = self.pay_exchange_order(manual_id=manual_id, order_id=locked["order_id"])
|
||||||
return {
|
return {
|
||||||
"commodity_id": commodity_id,
|
"commodity_id": commodity_id,
|
||||||
"ctn": ctn_value,
|
"order_id": locked["order_id"],
|
||||||
"csrf_token": token,
|
"exchange_id": paid["exchange_id"],
|
||||||
"randstr": randstr,
|
"commodity_image": paid["commodity_image"] or locked["commodity_image"],
|
||||||
"raw": payload,
|
"exchange_num": paid["exchange_num"],
|
||||||
|
"lock_order": locked,
|
||||||
|
"payment": paid,
|
||||||
}
|
}
|
||||||
|
|
||||||
def gold_account(self) -> dict[str, Any]:
|
def gold_account(self) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
||||||
|
|
||||||
|
|
||||||
|
class EliteLockExchangeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = DouyuActivityClient("acf_uid=100")
|
||||||
|
self.client.csrf_token = Mock(side_effect=["csrf-create", "csrf-pay"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def multipart_values(files):
|
||||||
|
return {key: value[1] for key, value in files.items()}
|
||||||
|
|
||||||
|
def test_exchange_locks_then_pays_with_fresh_csrf_tokens(self):
|
||||||
|
self.client._request_json = Mock(side_effect=[
|
||||||
|
{
|
||||||
|
"error": 0,
|
||||||
|
"msg": "请求正常",
|
||||||
|
"data": {
|
||||||
|
"orderId": "5874",
|
||||||
|
"commodityName": "套装-兔兔白日梦",
|
||||||
|
"commodityImage": "https://example.test/item.png",
|
||||||
|
"score": 1280,
|
||||||
|
"num": 1,
|
||||||
|
"expireSeconds": "300",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"error": 0,
|
||||||
|
"msg": "请求正常",
|
||||||
|
"data": {
|
||||||
|
"orderId": "5874",
|
||||||
|
"exchangeId": "11098933",
|
||||||
|
"commodityType": 3,
|
||||||
|
"commodityImage": "https://example.test/item.png",
|
||||||
|
"exchangeNum": 1,
|
||||||
|
"sType": 1,
|
||||||
|
"gType": 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
result = self.client.exchange_goods(
|
||||||
|
manual_id="G4KA4Qnz4LDp7",
|
||||||
|
rid="9263298",
|
||||||
|
commodity_id="20260120QYOOB_bag10",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["order_id"], "5874")
|
||||||
|
self.assertEqual(result["exchange_id"], "11098933")
|
||||||
|
self.assertEqual(self.client.csrf_token.call_count, 2)
|
||||||
|
create_call, pay_call = self.client._request_json.call_args_list
|
||||||
|
self.assertEqual(create_call.args[:3], (
|
||||||
|
"post", self.client.CREATE_EXCHANGE_ORDER_API, "锁定兑换商品",
|
||||||
|
))
|
||||||
|
self.assertEqual(self.multipart_values(create_call.kwargs["files"]), {
|
||||||
|
"manualID": "G4KA4Qnz4LDp7",
|
||||||
|
"rid": "9263298",
|
||||||
|
"commodityID": "20260120QYOOB_bag10",
|
||||||
|
"num": "1",
|
||||||
|
"csrfToken": "csrf-create",
|
||||||
|
})
|
||||||
|
self.assertNotIn("Content-Type", create_call.kwargs["headers"])
|
||||||
|
self.assertEqual(pay_call.args[:3], (
|
||||||
|
"post", self.client.PAY_EXCHANGE_ORDER_API, "支付锁单",
|
||||||
|
))
|
||||||
|
self.assertEqual(self.multipart_values(pay_call.kwargs["files"]), {
|
||||||
|
"orderID": "5874",
|
||||||
|
"manualID": "G4KA4Qnz4LDp7",
|
||||||
|
"csrfToken": "csrf-pay",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_lock_requires_order_id_in_success_response(self):
|
||||||
|
self.client._request_json = Mock(return_value={"error": 0, "data": {}})
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(DouyuActivityError, "没有 orderId"):
|
||||||
|
self.client.create_exchange_order(
|
||||||
|
manual_id="manual",
|
||||||
|
rid="9263298",
|
||||||
|
commodity_id="goods",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_locked_order_list_uses_captured_query_shape(self):
|
||||||
|
self.client._request_json = Mock(return_value={
|
||||||
|
"error": 0,
|
||||||
|
"data": {"total": 1, "list": [{"orderId": "5874"}]},
|
||||||
|
})
|
||||||
|
|
||||||
|
result = self.client.exchange_orders(manual_id="manual", page=2, page_size=20)
|
||||||
|
|
||||||
|
self.assertEqual(result["total"], 1)
|
||||||
|
self.assertEqual(result["orders"], [{"orderId": "5874"}])
|
||||||
|
call = self.client._request_json.call_args
|
||||||
|
self.assertEqual(call.args[:3], (
|
||||||
|
"get", self.client.EXCHANGE_ORDER_LIST_API, "查询锁单",
|
||||||
|
))
|
||||||
|
self.assertEqual(call.kwargs["params"], {
|
||||||
|
"page": 2, "pageSize": 20, "manualID": "manual",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -90,6 +90,31 @@ class DouyuWorkbenchScopeTests(unittest.TestCase):
|
|||||||
self.session, [self.account.id], "get_xpd_bind_qr", "elite", self.user.id,
|
self.session, [self.account.id], "get_xpd_bind_qr", "elite", self.user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_elite_workbench_accepts_lock_goods_task(self):
|
||||||
|
batch_id, count = create_douyu_planned_tasks(
|
||||||
|
self.session,
|
||||||
|
[self.account.id],
|
||||||
|
"lock_goods",
|
||||||
|
"elite",
|
||||||
|
self.user.id,
|
||||||
|
{"commodity_id": "goods-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = self.session.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).one()
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
self.assertEqual(task.task_type, "lock_goods")
|
||||||
|
self.assertEqual(task.result, {"payload": {"commodity_id": "goods-1"}})
|
||||||
|
|
||||||
|
_, pay_count = create_douyu_planned_tasks(
|
||||||
|
self.session,
|
||||||
|
[self.account.id],
|
||||||
|
"pay_locked_order",
|
||||||
|
"elite",
|
||||||
|
self.user.id,
|
||||||
|
{"order_id": "5874", "commodity_id": "goods-1"},
|
||||||
|
)
|
||||||
|
self.assertEqual(pay_count, 1)
|
||||||
|
|
||||||
def test_account_deletion_removes_workbench_membership(self):
|
def test_account_deletion_removes_workbench_membership(self):
|
||||||
self.session.add(DouyuWorkbenchAccount(
|
self.session.add(DouyuWorkbenchAccount(
|
||||||
user_id=self.user.id,
|
user_id=self.user.id,
|
||||||
|
|||||||
@@ -2686,6 +2686,99 @@ class DouyuBatchRunner:
|
|||||||
points = result["points"]
|
points = result["points"]
|
||||||
self._mark_task(db, task, "success", f"积分: {points if points is not None else '-'}", result)
|
self._mark_task(db, task, "success", f"积分: {points if points is not None else '-'}", result)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
def _execute_exchange_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
def _execute_exchange_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict):
|
||||||
import time as time_mod
|
import time as time_mod
|
||||||
payload = self._task_payload(task)
|
payload = self._task_payload(task)
|
||||||
@@ -2694,40 +2787,52 @@ class DouyuBatchRunner:
|
|||||||
self._mark_task(db, task, "failed", "请选择兑换商品")
|
self._mark_task(db, task, "failed", "请选择兑换商品")
|
||||||
return
|
return
|
||||||
client = self._client(cookie)
|
client = self._client(cookie)
|
||||||
ctn = client.acf_ccn(refresh_subscribe=False)
|
locked = client.create_exchange_order(
|
||||||
result = None
|
|
||||||
last_error = ""
|
|
||||||
for attempt in range(8 + 1):
|
|
||||||
if self._stop.is_set():
|
|
||||||
self._mark_task(db, task, "stopped", "任务已停止")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
result = client.exchange_goods(
|
|
||||||
manual_id=str(config["manual_id"]),
|
manual_id=str(config["manual_id"]),
|
||||||
rid=str(config["rid"]),
|
rid=str(config["rid"]),
|
||||||
commodity_id=commodity_id,
|
commodity_id=commodity_id,
|
||||||
ctn=ctn,
|
num=1,
|
||||||
|
)
|
||||||
|
payment = None
|
||||||
|
last_error = ""
|
||||||
|
for attempt in range(8 + 1):
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payment = client.pay_exchange_order(
|
||||||
|
manual_id=str(config["manual_id"]),
|
||||||
|
order_id=locked["order_id"],
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
except DouyuActivityError as exc:
|
except DouyuActivityError as exc:
|
||||||
last_error = str(exc)
|
last_error = str(exc)
|
||||||
if attempt >= 8:
|
if attempt >= 8:
|
||||||
self._mark_task(db, task, "failed", f"兑换失败(已重试{attempt}次): {last_error}")
|
self._mark_task(
|
||||||
|
db,
|
||||||
|
task,
|
||||||
|
"failed",
|
||||||
|
f"锁单 {locked['order_id']} 支付失败(已重试{attempt}次): {last_error}",
|
||||||
|
{"commodity_id": commodity_id, "lock_order": locked, **locked},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
error_lower = last_error.lower()
|
self._push_log(
|
||||||
if any(kw in error_lower for kw in ("无效", "太快", "csrf")):
|
"info",
|
||||||
self._push_log("info", f" 重试 {attempt + 1}/8: {last_error},刷新 csrf_token...")
|
f" 锁单 {locked['order_id']} 支付重试 {attempt + 1}/8: {last_error}",
|
||||||
try:
|
)
|
||||||
token = client.csrf_token()
|
|
||||||
self._push_log("debug", f" csrf_token 已刷新: {token[:12]}...")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
self._push_log("info", f" 重试 {attempt + 1}/8: {last_error}")
|
|
||||||
time_mod.sleep(0.3)
|
time_mod.sleep(0.3)
|
||||||
if result is None:
|
if payment is None:
|
||||||
self._mark_task(db, task, "failed", f"兑换失败: {last_error}")
|
self._mark_task(db, task, "failed", f"兑换失败: {last_error}")
|
||||||
return
|
return
|
||||||
|
result = {
|
||||||
|
"commodity_id": commodity_id,
|
||||||
|
"order_id": locked["order_id"],
|
||||||
|
"exchange_id": payment["exchange_id"],
|
||||||
|
"commodity_image": payment["commodity_image"] or locked["commodity_image"],
|
||||||
|
"exchange_num": payment["exchange_num"],
|
||||||
|
"lock_order": locked,
|
||||||
|
"payment": payment,
|
||||||
|
}
|
||||||
goods = (
|
goods = (
|
||||||
db.query(DouyuGoodsSnapshot)
|
db.query(DouyuGoodsSnapshot)
|
||||||
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
|
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
|
||||||
@@ -2738,6 +2843,7 @@ class DouyuBatchRunner:
|
|||||||
# 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功)
|
# 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功)
|
||||||
points_refresh = None
|
points_refresh = None
|
||||||
try:
|
try:
|
||||||
|
ctn = client.acf_ccn(refresh_subscribe=False)
|
||||||
points_refresh = self._refresh_account_points(client, account, cookie, ctn=ctn)
|
points_refresh = self._refresh_account_points(client, account, cookie, ctn=ctn)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._push_log("warning", f"兑换后刷新积分失败: {exc}")
|
self._push_log("warning", f"兑换后刷新积分失败: {exc}")
|
||||||
@@ -3077,6 +3183,8 @@ class DouyuBatchRunner:
|
|||||||
"create_gold_qr": self._execute_create_gold_qr,
|
"create_gold_qr": self._execute_create_gold_qr,
|
||||||
"donate_elite_gift": self._execute_donate_elite_gift,
|
"donate_elite_gift": self._execute_donate_elite_gift,
|
||||||
"query_points": self._execute_query_points,
|
"query_points": self._execute_query_points,
|
||||||
|
"lock_goods": self._execute_lock_goods,
|
||||||
|
"pay_locked_order": self._execute_pay_locked_order,
|
||||||
"exchange_goods": self._execute_exchange_goods,
|
"exchange_goods": self._execute_exchange_goods,
|
||||||
"exchange_esports_goods": self._execute_exchange_esports_goods,
|
"exchange_esports_goods": self._execute_exchange_esports_goods,
|
||||||
"query_game_name": self._execute_query_game_name,
|
"query_game_name": self._execute_query_game_name,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码",
|
|||||||
"create_gold_qr": "充值鱼翅",
|
"create_gold_qr": "充值鱼翅",
|
||||||
"donate_elite_gift": "赠送精英令",
|
"donate_elite_gift": "赠送精英令",
|
||||||
"query_points": "一键查询积分",
|
"query_points": "一键查询积分",
|
||||||
|
"lock_goods": "锁定商品",
|
||||||
|
"pay_locked_order": "支付锁单",
|
||||||
"exchange_goods": "兑换商品",
|
"exchange_goods": "兑换商品",
|
||||||
"query_game_name": "一键获取游戏名",
|
"query_game_name": "一键获取游戏名",
|
||||||
"query_change_bind_time": "一键查询换绑时间",
|
"query_change_bind_time": "一键查询换绑时间",
|
||||||
@@ -52,7 +54,7 @@ DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"}
|
|||||||
DOUYU_HANDBOOK_TASK_TYPES = {
|
DOUYU_HANDBOOK_TASK_TYPES = {
|
||||||
"elite": {
|
"elite": {
|
||||||
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift",
|
"get_bind_qr", "confirm_bind", "create_elite_qr", "create_gold_qr", "donate_elite_gift",
|
||||||
"query_points", "exchange_goods", "query_game_name", "query_change_bind_time",
|
"query_points", "lock_goods", "pay_locked_order", "exchange_goods", "query_game_name", "query_change_bind_time",
|
||||||
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records",
|
"query_limited_goods", "query_gold_balance", "refresh_goods", "query_exchange_records",
|
||||||
"prefetch_csrf_token",
|
"prefetch_csrf_token",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const ELITE_QUICK_ACTIONS = [
|
|||||||
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
|
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
|
||||||
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
|
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
|
||||||
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
|
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
|
||||||
|
{ key: 'lock_goods', icon: <ShoppingOutlined /> },
|
||||||
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
|
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
|
||||||
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
|
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
|
||||||
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
|
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
|
||||||
@@ -102,6 +103,8 @@ const ELITE_TASK_TYPES = new Set([
|
|||||||
'create_gold_qr',
|
'create_gold_qr',
|
||||||
'donate_elite_gift',
|
'donate_elite_gift',
|
||||||
'query_points',
|
'query_points',
|
||||||
|
'lock_goods',
|
||||||
|
'pay_locked_order',
|
||||||
'exchange_goods',
|
'exchange_goods',
|
||||||
'query_game_name',
|
'query_game_name',
|
||||||
'query_change_bind_time',
|
'query_change_bind_time',
|
||||||
@@ -150,6 +153,12 @@ function resultText(result: Record<string, unknown> | null | undefined, key: str
|
|||||||
return typeof value === 'string' ? value : '';
|
return typeof value === 'string' ? value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskPayloadText(task: DouyuTaskItem, key: string): string {
|
||||||
|
const payload = task.result?.payload;
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '';
|
||||||
|
return resultText(payload as Record<string, unknown>, key);
|
||||||
|
}
|
||||||
|
|
||||||
function resultFlag(result: Record<string, unknown> | null | undefined, key: string): boolean {
|
function resultFlag(result: Record<string, unknown> | null | undefined, key: string): boolean {
|
||||||
const value = result?.[key];
|
const value = result?.[key];
|
||||||
if (value === true) return true;
|
if (value === true) return true;
|
||||||
@@ -419,6 +428,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
|
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
|
||||||
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
|
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
|
||||||
const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState<number | null>(null);
|
const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState<number | null>(null);
|
||||||
|
const [nowSeconds, setNowSeconds] = useState(0);
|
||||||
const locallyStartedBatchIds = useRef<Set<string>>(new Set());
|
const locallyStartedBatchIds = useRef<Set<string>>(new Set());
|
||||||
const autoOpenQrReady = useRef(false);
|
const autoOpenQrReady = useRef(false);
|
||||||
const autoOpenEsportsBindReady = useRef(false);
|
const autoOpenEsportsBindReady = useRef(false);
|
||||||
@@ -426,6 +436,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
|
|
||||||
const logs = useWebSocketLogs();
|
const logs = useWebSocketLogs();
|
||||||
const canConfig = can('douyu:config');
|
const canConfig = can('douyu:config');
|
||||||
|
useEffect(() => {
|
||||||
|
const updateNow = () => setNowSeconds(Math.floor(Date.now() / 1000));
|
||||||
|
updateNow();
|
||||||
|
const timer = window.setInterval(updateNow, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
const latestQueryGameTaskByAccount = useMemo(() => {
|
const latestQueryGameTaskByAccount = useMemo(() => {
|
||||||
const map = new Map<number, DouyuTaskItem>();
|
const map = new Map<number, DouyuTaskItem>();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
@@ -568,6 +584,24 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
return map;
|
return map;
|
||||||
}, [visibleTasks]);
|
}, [visibleTasks]);
|
||||||
|
|
||||||
|
const latestLockedTaskByAccount = useMemo(() => {
|
||||||
|
const paidOrders = new Set(
|
||||||
|
visibleTasks
|
||||||
|
.filter((task) => task.task_type === 'pay_locked_order' && task.status === 'success')
|
||||||
|
.map((task) => `${task.account_id}:${resultText(task.result, 'order_id')}`)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const map = new Map<number, DouyuTaskItem>();
|
||||||
|
for (const task of visibleTasks) {
|
||||||
|
if (task.task_type !== 'lock_goods' || task.status !== 'success') continue;
|
||||||
|
const orderId = resultText(task.result, 'order_id');
|
||||||
|
if (!orderId || paidOrders.has(`${task.account_id}:${orderId}`)) continue;
|
||||||
|
const previous = map.get(task.account_id);
|
||||||
|
if (!previous || task.id > previous.id) map.set(task.account_id, task);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [visibleTasks]);
|
||||||
|
|
||||||
const latestXpdPurchaseRecordsTaskByAccount = useMemo(() => {
|
const latestXpdPurchaseRecordsTaskByAccount = useMemo(() => {
|
||||||
const map = new Map<number, DouyuTaskItem>();
|
const map = new Map<number, DouyuTaskItem>();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
@@ -958,7 +992,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 多批次并发:不再拦截,允许在已有批次运行时继续创建新批次
|
// 多批次并发:不再拦截,允许在已有批次运行时继续创建新批次
|
||||||
if (['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType) && !selectedGoodsId) {
|
if (['lock_goods', 'exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType) && !selectedGoodsId) {
|
||||||
message.warning('请先选择兑换商品');
|
message.warning('请先选择兑换商品');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -973,7 +1007,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const payload: Record<string, unknown> = { ...extraPayload };
|
const payload: Record<string, unknown> = { ...extraPayload };
|
||||||
if (['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType)) {
|
if (['lock_goods', 'exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType)) {
|
||||||
payload.commodity_id = selectedGoodsId;
|
payload.commodity_id = selectedGoodsId;
|
||||||
}
|
}
|
||||||
if (taskType === 'exchange_xpd_goods') payload.pay_type = xpdPayType;
|
if (taskType === 'exchange_xpd_goods') payload.pay_type = xpdPayType;
|
||||||
@@ -1624,6 +1658,40 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
}] : []),
|
}] : []),
|
||||||
|
...(!isPeaceHandbook && !isEsportsHandbook ? [{
|
||||||
|
title: '锁单', width: 150, align: 'center' as const,
|
||||||
|
render: (_: unknown, record: DouyuTaskAccountItem) => {
|
||||||
|
const task = latestLockedTaskByAccount.get(record.id);
|
||||||
|
if (!task) return <Text type="secondary">-</Text>;
|
||||||
|
const orderId = resultText(task.result, 'order_id');
|
||||||
|
const commodityId = resultText(task.result, 'commodity_id');
|
||||||
|
const expiresAt = resultNumber(task.result, 'expires_at');
|
||||||
|
const expired = expiresAt != null && nowSeconds > 0 && expiresAt <= nowSeconds;
|
||||||
|
const paymentTask = visibleTasks.find((item) => (
|
||||||
|
item.account_id === record.id
|
||||||
|
&& item.task_type === 'pay_locked_order'
|
||||||
|
&& taskPayloadText(item, 'order_id') === orderId
|
||||||
|
&& ['planned', 'pending', 'running'].includes(item.status)
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={1}>
|
||||||
|
<Text style={{ fontSize: 12 }}>{orderId}</Text>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
disabled={expired || !orderId}
|
||||||
|
loading={Boolean(paymentTask)}
|
||||||
|
onClick={() => void startTask('pay_locked_order', [record.id], {
|
||||||
|
order_id: orderId,
|
||||||
|
commodity_id: commodityId,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{expired ? '已过期' : '支付锁单'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const [configFormValues, setConfigFormValues] = useState<DouyuConfig | null>(null);
|
const [configFormValues, setConfigFormValues] = useState<DouyuConfig | null>(null);
|
||||||
@@ -1653,7 +1721,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
|| selectedXpdGoodsSoldOut
|
|| selectedXpdGoodsSoldOut
|
||||||
|| !xpdPaymentOptions.some((option) => option.value === xpdPayType);
|
|| !xpdPaymentOptions.some((option) => option.value === xpdPayType);
|
||||||
}
|
}
|
||||||
if (['exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId;
|
if (['lock_goods', 'exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId;
|
||||||
// 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段)
|
// 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段)
|
||||||
return selectedHasRunning;
|
return selectedHasRunning;
|
||||||
};
|
};
|
||||||
@@ -1854,6 +1922,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
|||||||
/>
|
/>
|
||||||
<div style={actionGridStyle}>
|
<div style={actionGridStyle}>
|
||||||
{renderActionButton('refresh_goods')}
|
{renderActionButton('refresh_goods')}
|
||||||
|
{renderActionButton('lock_goods')}
|
||||||
{renderActionButton('exchange_goods', 'primary')}
|
{renderActionButton('exchange_goods', 'primary')}
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { DouyuTaskItem } from '../api/types';
|
import type { DouyuTaskItem } from '../api/types';
|
||||||
|
|
||||||
const EXCHANGE_TASK_TYPES = new Set(['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods']);
|
const EXCHANGE_TASK_TYPES = new Set([
|
||||||
|
'exchange_goods',
|
||||||
|
'pay_locked_order',
|
||||||
|
'exchange_esports_goods',
|
||||||
|
'exchange_xpd_goods',
|
||||||
|
]);
|
||||||
const CARD_W = 320;
|
const CARD_W = 320;
|
||||||
const CARD_H = 470;
|
const CARD_H = 470;
|
||||||
const FONT_FAMILY = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif';
|
const FONT_FAMILY = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif';
|
||||||
@@ -29,11 +34,14 @@ function goodsOf(task: DouyuTaskItem): {
|
|||||||
const name = (rawString(goods, 'commodityName')
|
const name = (rawString(goods, 'commodityName')
|
||||||
|| rawString(goods, 'name')
|
|| rawString(goods, 'name')
|
||||||
|| rawString(goods, 'sGoodsName')
|
|| rawString(goods, 'sGoodsName')
|
||||||
|| task.message.replace(/^兑换[^::]*[::]?\s*/, '').trim()
|
|| task.message.replace(/^(?:兑换[^::]*|锁单支付成功)[::]?\s*/, '').trim()
|
||||||
|| rawString(task.result, 'commodity_id'))
|
|| rawString(task.result, 'commodity_id'))
|
||||||
.replace(/[((]\d+\s*(?:点券|碎片|积分)[))]\s*$/, '')
|
.replace(/[((]\d+\s*(?:点券|碎片|积分)[))]\s*$/, '')
|
||||||
.trim();
|
.trim();
|
||||||
const pic = rawString(goods, 'webPic') || rawString(goods, 'pic') || rawString(goods, 'sGoodsPic');
|
const pic = rawString(goods, 'webPic')
|
||||||
|
|| rawString(goods, 'pic')
|
||||||
|
|| rawString(goods, 'sGoodsPic')
|
||||||
|
|| rawString(task.result, 'commodity_image');
|
||||||
const roleName = rawString(task.result, 'game_name');
|
const roleName = rawString(task.result, 'game_name');
|
||||||
const channel = rawString(task.result, 'game_channel');
|
const channel = rawString(task.result, 'game_channel');
|
||||||
const accountName = rawString(task.result, 'account_name')
|
const accountName = rawString(task.result, 'account_name')
|
||||||
|
|||||||
Reference in New Issue
Block a user