feat(douyu): support elite handbook lock orders

This commit is contained in:
yml2213
2026-08-28 14:06:11 +08:00
parent 49aae8b74c
commit 9ee4e49a50
8 changed files with 471 additions and 59 deletions
+1
View File
@@ -12,6 +12,7 @@ __pycache__/
# 运行时数据 # 运行时数据
data/ data/
logs/ logs/
artifacts/
*.db *.db
*.log *.log
+124 -30
View File
@@ -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]:
+105
View File
@@ -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()
+25
View File
@@ -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,
+130 -22
View File
@@ -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,
+3 -1
View File
@@ -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",
}, },
+72 -3
View File
@@ -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>
+11 -3
View File
@@ -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')