From a342dddcb6b824237d9afaa698b6bcf41b67acb5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 8 Aug 2026 21:41:44 +0800 Subject: [PATCH] feat: add xpd purchase records view --- core/douyu/activity_client.py | 100 ++++++++++++++++ tests/test_douyu_xpd_exchange.py | 37 ++++++ web/backend/services/douyu_runner.py | 30 +++++ web/backend/services/douyu_service.py | 1 + web/frontend/src/pages/DouyuTasksPage.tsx | 134 +++++++++++++++++++++- 5 files changed, 300 insertions(+), 2 deletions(-) diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index c369caa..5903282 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -823,6 +823,7 @@ class DouyuActivityClient: XPD_BIND_INFO_API = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo" XPD_DAOJU_REFERER = "https://app.daoju.qq.com/" XPD_GET_ROLE_API = "https://apps.game.qq.com/daoju/igw/live" + XPD_ORDER_API = "https://apps.game.qq.com/daoju/igw/live/" # 2026-08 抓包(8.6-和平小店)实证:本期走 xn_live_cjm 变体(旧期为 recommend_live/common) XPD_RECOMMEND_API = "https://apps.game.qq.com/daoju/v3/recommend_xn_live_cjm/common" XPD_BALANCE_API = "https://apps.game.qq.com/daoju/igw/live/" @@ -1036,6 +1037,105 @@ class DouyuActivityClient: return {"goods": goods, "raw": {"pages": pages}} + def xpd_purchase_records( + self, + *, + embed_query: dict[str, str], + act_id: str, + page_size: int = 10, + max_pages: int = 100, + ) -> dict[str, Any]: + """查询和平小店购买记录,接口参数来自道聚城 H5 抓包。""" + page_size = max(1, min(int(page_size), 50)) + records: list[dict[str, Any]] = [] + pages: list[dict[str, Any]] = [] + total = 0 + + for page in range(1, max_pages + 1): + params = { + "_service": "order.list", + "_jsvar": "info", + "acctype": "livelink", + "_app_id": "2123", + "hist": "0", + "ps": str(page_size), + "pn": str(page), + "_biz_code": "cjm", + "_act_id": str(act_id), + "authType": "delegate", + "actId": embed_query.get("actId") or "", + "appId": embed_query.get("appId") or "bp_cf", + "gameId": embed_query.get("gameId") or "cjm", + "livePlatId": embed_query.get("livePlatId") or "douyu", + "code": embed_query.get("code") or "", + "timestamp": embed_query.get("timestamp") or "", + "v": embed_query.get("v") or "", + "sig": embed_query.get("sig") or "", + "_sid": "8", + "set": "8", + } + response = self._request( + "get", + self.XPD_ORDER_API, + source=f"查询小店购买记录第 {page} 页", + params=params, + headers=self._xpd_daoju_headers(), + ) + info = self._xpd_parse_var(response.text, "info") + if str(info.get("ret") or "0") != "0": + raise DouyuActivityError(str(info.get("msg") or "小店购买记录查询失败")) + data = info.get("data") if isinstance(info.get("data"), dict) else {} + page_records = data.get("list") if isinstance(data.get("list"), list) else [] + total = self._xpd_int(data.get("cnt")) or total + pages.append(info) + + for record in page_records: + if not isinstance(record, dict): + continue + goods_info = record.get("sGoodsInfo") + if isinstance(goods_info, str): + try: + goods_info = json.loads(goods_info) + except json.JSONDecodeError: + goods_info = {} + goods_list = goods_info.get("list") if isinstance(goods_info, dict) else [] + goods = ( + goods_list[0] + if isinstance(goods_list, list) and goods_list and isinstance(goods_list[0], dict) + else {} + ) + app_ext = record.get("sAppExt") + if isinstance(app_ext, str): + try: + app_ext = json.loads(app_ext) + except json.JSONDecodeError: + app_ext = {} + records.append({ + "serial": str(record.get("sSerialNum") or ""), + "goods_name": str( + goods.get("sGoodsName") + or goods.get("sPacketName") + or record.get("sActionDesc") + or "" + ), + "goods_image": str(goods.get("sGoodsPic") or (app_ext or {}).get("sGoodsPic") or ""), + "buy_time": str(record.get("dtBuyTime") or ""), + "pay_time": str(record.get("dtPayTime") or ""), + "status": str(record.get("iStatus") or ""), + "price": self._xpd_int(record.get("iPrice")), + "pay_amount": self._xpd_int(record.get("iPayAmount")), + "pay_type": str(record.get("sPayType") or ""), + "role_name": str(record.get("sRoleName") or ""), + "send_status": str(goods.get("iSendStatus") or ""), + "delivery_message": str(goods.get("deliveryMsg") or ""), + "raw": record, + }) + + if len(page_records) < page_size or len(records) >= total: + break + + return {"records": records, "total": total or len(records), "raw": {"pages": pages}} + def xpd_bind_qr(self, *, act_alias: str) -> dict[str, Any]: """生成和平小店绑定二维码链接(微信扫码进入 livelink 小程序绑定角色)。 diff --git a/tests/test_douyu_xpd_exchange.py b/tests/test_douyu_xpd_exchange.py index 6a8cbdd..626025e 100644 --- a/tests/test_douyu_xpd_exchange.py +++ b/tests/test_douyu_xpd_exchange.py @@ -106,6 +106,43 @@ class XpdExchangeTests(unittest.TestCase): self.assertEqual((first_params['page_begin'], first_params['page_num']), ('0', '1')) self.assertEqual((second_params['page_begin'], second_params['page_num']), ('10', '2')) + def test_purchase_records_parses_order_list_and_paginates(self): + first_record = { + 'sSerialNum': 'order-1', + 'dtBuyTime': '2026-08-08 01:00:42', + 'dtPayTime': '2026-08-08 01:00:55', + 'iStatus': '3', + 'iPrice': '800', + 'iPayAmount': '800', + 'sRoleName': '测试角色', + 'sGoodsInfo': json.dumps({ + 'list': [{'sGoodsName': '改名卡', 'sGoodsPic': 'https://cdn.example/item.jpg'}], + }), + } + self.client._request = Mock(side_effect=[ + SimpleNamespace(text=f'var info={{"ret":"0","msg":"ok","data":{{"cnt":11,"list":{json.dumps([first_record])}}}}};'), + SimpleNamespace(text='var info={"ret":"0","msg":"ok","data":{"cnt":11,"list":[]}};'), + ]) + + result = self.client.xpd_purchase_records( + embed_query={ + 'gameId': 'cjm', 'actId': '18882', 'appId': 'bp_cf', + 'livePlatId': 'douyu', 'code': 'fresh-code', + 'timestamp': '123', 'v': '2.0', 'sig': 'fresh-signature', + }, + act_id='46195', + page_size=1, + ) + + self.assertEqual(result['total'], 11) + self.assertEqual(result['records'][0]['goods_name'], '改名卡') + self.assertEqual(result['records'][0]['serial'], 'order-1') + self.assertEqual(self.client._request.call_count, 2) + params = self.client._request.call_args_list[0].kwargs['params'] + self.assertEqual(params['_service'], 'order.list') + self.assertEqual(params['pn'], '1') + self.assertEqual(params['code'], 'fresh-code') + def test_exchange_keeps_raw_price_from_goods_list(self): self.client._request = Mock(side_effect=[ diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index e98a826..9587ff9 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -1429,6 +1429,35 @@ class DouyuBatchRunner: {"fragments": fragments, "role": role, "area_id": int(areaid)}, ) + def _execute_query_xpd_purchase_records( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """查询和平小店道聚城购买记录。""" + client = self._client(cookie) + embed = client.xpd_embed_query( + act_alias=str(config["xpd_act_alias"]), + rid=str(config["xpd_rid"]), + ) + result = client.xpd_purchase_records( + embed_query=embed["query"], + act_id=str(config["xpd_act_id"]), + ) + account.xpd_bind_status = "xpd_purchase_records_queried" + account.updated_at = datetime.now(timezone.utc) + total = result.get("total") or len(result.get("records") or []) + self._mark_task( + db, + task, + "success", + f"小店兑换记录 {total} 条" if total else "暂无小店兑换记录", + result, + ) + def _execute_exchange_xpd_goods( self, db: Session, @@ -2821,6 +2850,7 @@ class DouyuBatchRunner: "refresh_xpd_goods": self._execute_refresh_xpd_goods, "query_xpd_balance": self._execute_query_xpd_balance, "query_xpd_fragments": self._execute_query_xpd_fragments, + "query_xpd_purchase_records": self._execute_query_xpd_purchase_records, "exchange_xpd_goods": self._execute_exchange_xpd_goods, }.get(task.task_type) if handler is None: diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 326622f..b885d67 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -44,6 +44,7 @@ SUPPORTED_DOUYU_TASK_TYPES = { "get_bind_qr": "获取绑定二维码", "refresh_xpd_goods": "刷新小店商品列表", "query_xpd_balance": "查询小店点券余额", "query_xpd_fragments": "查询小店扭蛋碎片", + "query_xpd_purchase_records": "查询小店兑换记录", "exchange_xpd_goods": "兑换小店商品", } diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index e5d7a5d..13bd880 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, theme, + Alert, Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, theme, } from 'antd'; import type { TableProps } from 'antd'; import { @@ -90,6 +90,7 @@ const PEACE_QUICK_ACTIONS = [ { key: 'query_xpd_role', icon: }, { key: 'query_xpd_balance', icon: }, { key: 'query_xpd_fragments', icon: }, + { key: 'query_xpd_purchase_records', icon: }, { key: 'refresh_xpd_goods', icon: }, { key: 'exchange_xpd_goods', icon: }, ]; @@ -134,6 +135,7 @@ const PEACE_TASK_TYPES = new Set([ 'refresh_xpd_goods', 'query_xpd_balance', 'query_xpd_fragments', + 'query_xpd_purchase_records', 'exchange_xpd_goods', ]); const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount'; @@ -418,6 +420,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const [confirmFailTip, setConfirmFailTip] = useState<{ taskId: number; message: string } | null>(null); const [confirmFailCountdown, setConfirmFailCountdown] = useState(0); const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null); + const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState(null); const autoOpenQrReady = useRef(false); const autoOpenEsportsBindReady = useRef(false); const autoOpenPayReady = useRef(false); @@ -566,6 +569,16 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return map; }, [visibleTasks]); + const latestXpdPurchaseRecordsTaskByAccount = useMemo(() => { + const map = new Map(); + for (const task of tasks) { + if (task.task_type !== 'query_xpd_purchase_records') continue; + const previous = map.get(task.account_id); + if (!previous || task.id > previous.id) map.set(task.account_id, task); + } + return map; + }, [tasks]); + const loadData = useCallback(async () => { setLoading(true); try { @@ -1540,6 +1553,26 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) ); }, }, + ...(isPeaceHandbook ? [{ + title: '兑换记录', width: 100, align: 'center' as const, + render: (_: unknown, record: DouyuTaskAccountItem) => { + const task = latestXpdPurchaseRecordsTaskByAccount.get(record.id); + const running = task && ['planned', 'pending', 'running'].includes(task.status); + return ( + + ); + }, + }] : []), ]; const [configFormValues, setConfigFormValues] = useState(null); @@ -1866,7 +1899,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) }, }} tableLayout="fixed" - scroll={{ x: 1180, y: Math.max(120, accountTableAreaHeight - 80) }} + scroll={{ x: 1280, y: Math.max(120, accountTableAreaHeight - 80) }} onRow={(record) => ({ onContextMenu: (e) => handleRowContextMenu(record, e), style: { cursor: 'context-menu' }, @@ -1934,6 +1967,16 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) } `; + const xpdPurchaseTask = xpdPurchaseRecordsAccountId == null + ? null + : latestXpdPurchaseRecordsTaskByAccount.get(xpdPurchaseRecordsAccountId) || null; + const xpdPurchaseRecords = Array.isArray(xpdPurchaseTask?.result?.records) + ? xpdPurchaseTask.result.records.filter( + (item): item is Record => Boolean(item && typeof item === 'object'), + ) + : []; + const xpdPurchaseAccount = accounts.find((account) => account.id === xpdPurchaseRecordsAccountId); + return (
@@ -2390,6 +2433,93 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) )} + {/* 和平小店兑换记录 Modal */} + + + 和平小店兑换记录 + + } + open={xpdPurchaseRecordsAccountId != null} + onCancel={() => setXpdPurchaseRecordsAccountId(null)} + footer={null} + width={980} + centered + > + + {xpdPurchaseAccount?.nickname || xpdPurchaseAccount?.username || `#${xpdPurchaseRecordsAccountId}`} + {xpdPurchaseTask?.result?.total != null ? `,共 ${String(xpdPurchaseTask.result.total)} 条` : ''} + + {xpdPurchaseTask && ['failed', 'error'].includes(xpdPurchaseTask.status) ? ( + + ) : ( + String(record.serial || `${record.buy_time || ''}-${index}`)} + size="small" + loading={!xpdPurchaseTask || ['planned', 'pending', 'running'].includes(xpdPurchaseTask.status)} + dataSource={xpdPurchaseRecords} + pagination={{ pageSize: 10, showLessItems: true }} + scroll={{ x: 850 }} + columns={[ + { + title: '商品', + dataIndex: 'goods_name', + width: 240, + render: (value: unknown) => String(value || '-'), + }, + { + title: '兑换时间', + dataIndex: 'buy_time', + width: 170, + render: (value: unknown) => String(value || '-'), + }, + { + title: '支付时间', + dataIndex: 'pay_time', + width: 170, + render: (value: unknown) => String(value || '-'), + }, + { + title: '价格', + width: 90, + render: (_: unknown, record: Record) => + record.pay_amount ?? record.price ?? '-', + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: unknown) => ( + + {String(value) === '3' ? '已完成' : String(value || '未知')} + + ), + }, + { + title: '角色', + dataIndex: 'role_name', + width: 150, + ellipsis: true, + render: (value: unknown) => String(value || '-'), + }, + { + title: '订单号', + dataIndex: 'serial', + width: 250, + ellipsis: true, + render: (value: unknown) => String(value || '-'), + }, + ]} + locale={{ emptyText: xpdPurchaseTask ? '暂无兑换记录' : '正在查询兑换记录...' }} + /> + )} + + {/* 兑换结果图片预览 Modal */}