diff --git a/.gitignore b/.gitignore index 3c73ebb..f327801 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ __pycache__/ # 运行时数据 data/ logs/ +artifacts/ *.db *.log @@ -38,4 +39,4 @@ evidence/final_capture.json evidence/final_wupdata.bin evidence/frida_cred_dump.json -work/ \ No newline at end of file +work/ diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index 8527310..3e6642b 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -3,9 +3,7 @@ from __future__ import annotations import json -import random import re -import string import time from collections.abc import Callable from typing import Any @@ -39,7 +37,9 @@ class DouyuActivityClient: BIND_INFO_V2_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo" BIND_API = "https://www.douyu.com/japi/carnivalApi/tencent/bind" 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" 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" @@ -749,45 +749,139 @@ class DouyuActivityClient: records = (payload.get("data") or {}).get("list") or [] return {"records": records, "raw": payload} - def exchange_goods(self, *, manual_id: str, rid: str, commodity_id: str, ctn: str | None = None) -> dict[str, Any]: - """兑换活动商品。""" - ctn_value = ctn or cookie_value(self.cookie, "acf_ccn") or self.acf_ccn(refresh_subscribe=False) + @staticmethod + def _multipart_fields(data: dict[str, Any]) -> dict[str, tuple[None, str]]: + """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() - randstr = "".join(random.choices(string.ascii_letters + string.digits, k=16)) - data = { - "manualID": manual_id, - "rid": rid, - "commodityID": commodity_id, - "ctn": ctn_value, - "csrfToken": token, - "randstr": randstr, - "token[error][code]": "-1", - "token[data]": "", - } payload = self._request_json( "post", - self.EXCHANGE_API, - "兑换商品", - data=data, + self.CREATE_EXCHANGE_ORDER_API, + "锁定兑换商品", + files=self._multipart_fields({ + "manualID": manual_id, + "rid": rid, + "commodityID": commodity_id, + "num": num, + "csrfToken": token, + }), 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}", - "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"): - 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 { "commodity_id": commodity_id, - "ctn": ctn_value, - "csrf_token": token, - "randstr": randstr, - "raw": payload, + "order_id": locked["order_id"], + "exchange_id": paid["exchange_id"], + "commodity_image": paid["commodity_image"] or locked["commodity_image"], + "exchange_num": paid["exchange_num"], + "lock_order": locked, + "payment": paid, } def gold_account(self) -> dict[str, Any]: diff --git a/tests/test_douyu_elite_lock_exchange.py b/tests/test_douyu_elite_lock_exchange.py new file mode 100644 index 0000000..a8b9197 --- /dev/null +++ b/tests/test_douyu_elite_lock_exchange.py @@ -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() diff --git a/tests/test_douyu_workbench_scopes.py b/tests/test_douyu_workbench_scopes.py index f799f66..edaf525 100644 --- a/tests/test_douyu_workbench_scopes.py +++ b/tests/test_douyu_workbench_scopes.py @@ -90,6 +90,31 @@ class DouyuWorkbenchScopeTests(unittest.TestCase): 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): self.session.add(DouyuWorkbenchAccount( user_id=self.user.id, diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index c34e360..92f33f9 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -2686,6 +2686,99 @@ class DouyuBatchRunner: points = result["points"] 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): import time as time_mod payload = self._task_payload(task) @@ -2694,40 +2787,52 @@ class DouyuBatchRunner: self._mark_task(db, task, "failed", "请选择兑换商品") return client = self._client(cookie) - ctn = client.acf_ccn(refresh_subscribe=False) - result = None + locked = client.create_exchange_order( + manual_id=str(config["manual_id"]), + rid=str(config["rid"]), + commodity_id=commodity_id, + num=1, + ) + payment = None last_error = "" for attempt in range(8 + 1): if self._stop.is_set(): - self._mark_task(db, task, "stopped", "任务已停止") + self._mark_task(db, task, "stopped", "任务已停止,商品锁单仍可能有效", locked) return try: - result = client.exchange_goods( + payment = client.pay_exchange_order( manual_id=str(config["manual_id"]), - rid=str(config["rid"]), - commodity_id=commodity_id, - ctn=ctn, + order_id=locked["order_id"], ) break except DouyuActivityError as exc: last_error = str(exc) 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 - error_lower = last_error.lower() - if any(kw in error_lower for kw in ("无效", "太快", "csrf")): - self._push_log("info", f" 重试 {attempt + 1}/8: {last_error},刷新 csrf_token...") - 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}") + self._push_log( + "info", + f" 锁单 {locked['order_id']} 支付重试 {attempt + 1}/8: {last_error}", + ) time_mod.sleep(0.3) - if result is None: + if payment is None: self._mark_task(db, task, "failed", f"兑换失败: {last_error}") 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 = ( db.query(DouyuGoodsSnapshot) .filter(DouyuGoodsSnapshot.commodity_id == commodity_id) @@ -2738,6 +2843,7 @@ class DouyuBatchRunner: # 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功) 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}") @@ -3077,6 +3183,8 @@ class DouyuBatchRunner: "create_gold_qr": self._execute_create_gold_qr, "donate_elite_gift": self._execute_donate_elite_gift, "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_esports_goods": self._execute_exchange_esports_goods, "query_game_name": self._execute_query_game_name, diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index b120de3..6644603 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -29,6 +29,8 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码", "create_gold_qr": "充值鱼翅", "donate_elite_gift": "赠送精英令", "query_points": "一键查询积分", + "lock_goods": "锁定商品", + "pay_locked_order": "支付锁单", "exchange_goods": "兑换商品", "query_game_name": "一键获取游戏名", "query_change_bind_time": "一键查询换绑时间", @@ -52,7 +54,7 @@ DOUYU_HANDBOOK_SCOPES = {"elite", "esports", "peace"} DOUYU_HANDBOOK_TASK_TYPES = { "elite": { "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", "prefetch_csrf_token", }, diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 4ea794a..72a35ab 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -63,6 +63,7 @@ const ELITE_QUICK_ACTIONS = [ { key: 'query_change_bind_time', icon: }, { key: 'query_limited_goods', icon: }, { key: 'query_gold_balance', icon: }, + { key: 'lock_goods', icon: }, { key: 'exchange_goods', icon: }, { key: 'create_gold_qr', icon: }, { key: 'donate_elite_gift', icon: }, @@ -102,6 +103,8 @@ const ELITE_TASK_TYPES = new Set([ 'create_gold_qr', 'donate_elite_gift', 'query_points', + 'lock_goods', + 'pay_locked_order', 'exchange_goods', 'query_game_name', 'query_change_bind_time', @@ -150,6 +153,12 @@ function resultText(result: Record | null | undefined, key: str 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, key); +} + function resultFlag(result: Record | null | undefined, key: string): boolean { const value = result?.[key]; if (value === true) return true; @@ -419,6 +428,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const [confirmFailCountdown, setConfirmFailCountdown] = useState(0); const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null); const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState(null); + const [nowSeconds, setNowSeconds] = useState(0); const locallyStartedBatchIds = useRef>(new Set()); const autoOpenQrReady = useRef(false); const autoOpenEsportsBindReady = useRef(false); @@ -426,6 +436,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const logs = useWebSocketLogs(); 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 map = new Map(); for (const task of tasks) { @@ -568,6 +584,24 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return map; }, [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(); + 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 map = new Map(); for (const task of tasks) { @@ -958,7 +992,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) 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('请先选择兑换商品'); return; } @@ -973,7 +1007,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) } } const payload: Record = { ...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; } 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 -; + 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 ( + + {orderId} + + + ); + }, + }] : []), ]; const [configFormValues, setConfigFormValues] = useState(null); @@ -1653,7 +1721,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) || selectedXpdGoodsSoldOut || !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; }; @@ -1854,6 +1922,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) />
{renderActionButton('refresh_goods')} + {renderActionButton('lock_goods')} {renderActionButton('exchange_goods', 'primary')}
diff --git a/web/frontend/src/utils/exchangeImage.ts b/web/frontend/src/utils/exchangeImage.ts index 5c62e04..31c0a39 100644 --- a/web/frontend/src/utils/exchangeImage.ts +++ b/web/frontend/src/utils/exchangeImage.ts @@ -1,6 +1,11 @@ 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_H = 470; 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') || rawString(goods, 'name') || rawString(goods, 'sGoodsName') - || task.message.replace(/^兑换[^::]*[::]?\s*/, '').trim() + || task.message.replace(/^(?:兑换[^::]*|锁单支付成功)[::]?\s*/, '').trim() || rawString(task.result, 'commodity_id')) .replace(/[((]\d+\s*(?:点券|碎片|积分)[))]\s*$/, '') .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 channel = rawString(task.result, 'game_channel'); const accountName = rawString(task.result, 'account_name')