diff --git a/core/douyu/activity_client.py b/core/douyu/activity_client.py index 4b6b542..70533dc 100644 --- a/core/douyu/activity_client.py +++ b/core/douyu/activity_client.py @@ -826,6 +826,7 @@ class DouyuActivityClient: # 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/" + XPD_LIVE_BUY_API = "https://apps.game.qq.com/daoju/igw/livebuy/" XPD_UA = ( "Mozilla/5.0 (Linux; Android 12; HBN-AL00 Build/V417IR; wv) " "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " @@ -965,7 +966,7 @@ class DouyuActivityClient: "iRelationGoodsid,dtModifyTime,sBuyLimitInfo,sGoodsWaterMark,dtShowBeginTime," "dtShowEndTime,iSort,iGoodsId,sGoodsName,iJbPrice,iJbOrgPrice,iJb2Price," "iJb2OrgPrice,iPrice,iOrgPrice,sGoodsPic,sGoodsDesc,sPayType,dtBeginTime," - "dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,dtRushBegin,dtRushEnd" + "dtEndTime,iActionId,sExtShowInfo,sExtInfo,iCategoryId,iGoodsLeft,dtRushBegin,dtRushEnd" ), "actionFields": "iActionId,sActionName,dtBeginTime,dtEndTime", "order_by": "dtShowBeginTime", @@ -1146,3 +1147,79 @@ class DouyuActivityClient: ) info = self._xpd_parse_var(response.text, "jbInfos") return {"fragments": self._xpd_int(info.get("jb2")), "raw": info} + + def xpd_exchange_goods( + self, + *, + embed_query: dict[str, str], + act_id: str, + rid: str, + commodity_id: str, + price: int, + picture: str = "", + pay_type: int = 1, + action_id: str = "", + ) -> dict[str, Any]: + """兑换和平小店商品。 + + 道聚城的 code/sig 为短时授权参数,调用方必须在每次兑换前重新获取。 + 该接口会实际扣除点券或扭蛋碎片,因此遇到网络错误不做自动重试。 + """ + if pay_type not in (1, 5): + raise DouyuActivityError("小店兑换仅支持点券或扭蛋碎片") + if not commodity_id: + raise DouyuActivityError("小店兑换缺少商品 ID") + if price < 0: + raise DouyuActivityError("小店兑换商品价格无效") + + params = { + "_service": "buy.plug.svr.web", + "_plug_id": "7200", + "_jsvar": "buyInfo", + "acctype": "livelink", + "_app_id": "2123", + "iActionId": action_id or act_id, + "propid": commodity_id, + "paytype": str(pay_type), + "source": rid, + "sAnchorId": rid, + "attach": json.dumps({"ch": "0"}, separators=(",", ":")), + "apptype": "3", + "_biz_code": "cjm", + "_act_id": act_id, + "sLiveUserInfo": json.dumps( + {"sAnchorId": rid, "sVideoId": 0}, separators=(",", ":"), + ), + "appext": json.dumps( + {"user_price": price, "sGoodsPic": picture, "paytype": pay_type}, + separators=(",", ":"), + ), + "isCode": "1", + "authType": "delegate", + "_sid": "8", + "set": "8", + } + for key in ("gameId", "actId", "appId", "livePlatId", "code", "timestamp", "v", "sig"): + value = str(embed_query.get(key) or "") + if value: + params[key] = value + + response = self._request( + "get", + self.XPD_LIVE_BUY_API, + source="兑换小店商品", + params=params, + headers=self._xpd_daoju_headers(), + ) + info = self._xpd_parse_var(response.text, "buyInfo") + if str(info.get("ret")) != "0" or str(info.get("msg") or "").lower() != "ok": + raise DouyuActivityError(str(info.get("msg") or "小店兑换失败")) + return { + "commodity_id": commodity_id, + "pay_type": pay_type, + "price": price, + "serial": str(info.get("serial") or info.get("sSerialNum") or ""), + "event_id": str(info.get("event_id") or ""), + "new_balance": self._xpd_int(info.get("newBalance")), + "raw": info, + } diff --git a/tests/test_douyu_xpd_exchange.py b/tests/test_douyu_xpd_exchange.py new file mode 100644 index 0000000..71356e3 --- /dev/null +++ b/tests/test_douyu_xpd_exchange.py @@ -0,0 +1,84 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import Mock + +from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError + + +class XpdExchangeTests(unittest.TestCase): + def setUp(self): + self.client = DouyuActivityClient( + 'acf_uid=100; acf_stk=stk; acf_ltkid=ltkid', + ) + + def test_exchange_uses_fresh_embed_authorization_and_point_price(self): + self.client._request = Mock(return_value=SimpleNamespace( + text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};', + )) + + result = self.client.xpd_exchange_goods( + embed_query={ + 'gameId': 'cjm', 'actId': '46195', 'appId': 'bp_cf', + 'livePlatId': 'douyu', 'code': 'fresh-code', + 'timestamp': '123', 'v': '2.0', 'sig': 'fresh-signature', + }, + act_id='46195', + rid='9263298', + commodity_id='goods-1', + price=1200, + picture='https://cdn.example/item.jpg', + pay_type=1, + action_id='46195', + ) + + self.assertEqual(result['serial'], 'order-1') + self.assertEqual(result['new_balance'], 88) + call = self.client._request.call_args + self.assertEqual(call.args[:2], ('get', self.client.XPD_LIVE_BUY_API)) + params = call.kwargs['params'] + self.assertEqual(params['code'], 'fresh-code') + self.assertEqual(params['sig'], 'fresh-signature') + self.assertEqual(params['propid'], 'goods-1') + self.assertEqual(params['paytype'], '1') + self.assertEqual(json.loads(params['appext']), { + 'user_price': 1200, + 'sGoodsPic': 'https://cdn.example/item.jpg', + 'paytype': 1, + }) + + def test_exchange_surfaces_daoju_failure_message(self): + self.client._request = Mock(return_value=SimpleNamespace( + text='var buyInfo={"ret":"-1","msg":"点券不足"};', + )) + + with self.assertRaisesRegex(DouyuActivityError, '点券不足'): + self.client.xpd_exchange_goods( + embed_query={'code': 'fresh-code'}, + act_id='46195', + rid='9263298', + commodity_id='goods-1', + price=1200, + ) + + def test_list_goods_requests_and_preserves_inventory(self): + self.client._request = Mock(return_value=SimpleNamespace( + text=( + 'var recommend={"data":{"client_data":{"itemsdetail":[{' + '"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",' + '"iJb2Price":"0","iGoodsLeft":"0"}]}}};' + ), + )) + + result = self.client.xpd_list_goods( + embed_query={}, act_id='46195', openid='openid', roleid='roleid', + ) + + self.assertEqual(result['goods'][0]['price'], 6800) + self.assertEqual(result['goods'][0]['goods_left'], 0) + fields = self.client._request.call_args.kwargs['params']['fields'] + self.assertIn('iGoodsLeft', fields.split(',')) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 4d7dcb3..165b570 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -455,7 +455,10 @@ class DouyuBatchRunner: row.price = self._to_int(raw.get("price") or raw.get("iPrice")) row.org_price = self._to_int(raw.get("org_price") or raw.get("iOrgPrice")) row.category = str(raw.get("category") or raw.get("iCategoryId") or "") - row.goods_left = self._to_int(raw.get("goods_left") or raw.get("iGoodsLeft")) + goods_left = raw.get("goods_left") + if goods_left is None: + goods_left = raw.get("iGoodsLeft") + row.goods_left = self._to_int(goods_left) row.raw = raw row.updated_at = now db.commit() @@ -1414,6 +1417,101 @@ class DouyuBatchRunner: {"fragments": fragments, "role": role, "area_id": int(areaid)}, ) + def _execute_exchange_xpd_goods( + self, + db: Session, + task: DouyuTask, + account: Account, + cookie: str, + config: dict, + ): + """兑换和平小店商品,使用本次签发的道聚城短时授权。""" + payload = self._task_payload(task) + commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip() + if not commodity_id: + self._mark_task(db, task, "failed", "请选择小店商品") + return + try: + pay_type = int(payload.get("pay_type") or 1) + except (TypeError, ValueError): + self._mark_task(db, task, "failed", "兑换货币参数无效") + return + if pay_type not in (1, 5): + self._mark_task(db, task, "failed", "小店兑换仅支持点券或扭蛋碎片") + return + + goods = ( + db.query(DouyuXpdGoodsSnapshot) + .filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + if not goods: + self._mark_task(db, task, "failed", "未找到小店商品快照,请先刷新商品列表") + return + goods_snapshot = goods.raw if isinstance(goods.raw, dict) else {} + goods_raw = goods_snapshot.get("raw") if isinstance(goods_snapshot.get("raw"), dict) else goods_snapshot + price_key = "iPrice" if pay_type == 1 else "iJb2Price" + price = self._to_int(goods_raw.get(price_key)) + if price is None: + price = goods.price if pay_type == 1 else None + if price is None or price <= 0: + currency = "点券" if pay_type == 1 else "扭蛋碎片" + self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换") + return + if goods.goods_left is not None and goods.goods_left <= 0: + self._mark_task(db, task, "failed", "该商品库存不足,请刷新商品列表后重试") + return + + client = self._client(cookie) + embed = client.xpd_embed_query( + act_alias=str(config["xpd_act_alias"]), + rid=str(config["xpd_rid"]), + ) + role: dict = {} + try: + role = client.xpd_get_role( + embed_query=embed["query"], + act_id=str(config["xpd_act_id"]), + rid=str(config["xpd_rid"]), + ) + if role.get("role_id"): + self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account)) + except DouyuActivityError as exc: + self._push_log("warning", f"小店兑换前刷新角色失败,使用已保存角色: {exc}") + if not role.get("role_id") and not account.xpd_role_id: + self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定") + return + + result = client.xpd_exchange_goods( + embed_query=embed["query"], + act_id=str(config["xpd_act_id"]), + rid=str(config["xpd_rid"]), + commodity_id=commodity_id, + price=price, + picture=str(goods_raw.get("sGoodsPic") or ""), + pay_type=pay_type, + action_id=str(goods_raw.get("iActionId") or ""), + ) + if pay_type == 1 and result.get("new_balance") is not None: + account.xpd_balance = result["new_balance"] + if pay_type == 5 and result.get("new_balance") is not None: + account.xpd_fragments = result["new_balance"] + account.xpd_bind_status = "xpd_goods_exchanged" + account.updated_at = datetime.now(timezone.utc) + db.commit() + + currency = "点券" if pay_type == 1 else "扭蛋碎片" + display_role = role.get("role_name") or account.xpd_game_name or "" + channel = "微信" if (role.get("type") == "wx" or account.xpd_area_id == 1) else "手Q" + result.update({ + "goods": goods_raw, + "game_name": display_role, + "game_channel": channel, + "account_name": account.nickname or account.username or account.uid or f"#{account.id}", + "currency": currency, + }) + self._mark_task(db, task, "success", f"兑换小店商品成功: {goods.name or commodity_id}({price}{currency})", result) + def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) qr_act_alias = self._bind_qr_act_alias(config) @@ -2710,6 +2808,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, + "exchange_xpd_goods": self._execute_exchange_xpd_goods, }.get(task.task_type) if handler is None: self._mark_task(worker_db, task, "failed", "不支持的任务类型") diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 80c6f53..326622f 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": "查询小店扭蛋碎片", + "exchange_xpd_goods": "兑换小店商品", } diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index c18904e..5ad8575 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -91,6 +91,7 @@ const PEACE_QUICK_ACTIONS = [ { key: 'query_xpd_balance', icon: }, { key: 'query_xpd_fragments', icon: }, { key: 'refresh_xpd_goods', icon: }, + { key: 'exchange_xpd_goods', icon: }, ]; const ELITE_TASK_TYPES = new Set([ @@ -133,6 +134,7 @@ const PEACE_TASK_TYPES = new Set([ 'refresh_xpd_goods', 'query_xpd_balance', 'query_xpd_fragments', + 'exchange_xpd_goods', ]); const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount'; const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count'; @@ -231,9 +233,34 @@ function goodsLabel(item: DouyuGoodsItem): string { function xpdGoodsLabel(item: DouyuGoodsItem): string { const xpd = item as DouyuGoodsItem & { price?: number | null; goods_left?: number | null }; - const price = xpd.price != null ? ` / ${xpd.price}点券` : ''; - const left = xpd.goods_left != null ? ` / 剩${xpd.goods_left}` : ''; - return `${item.name || item.commodity_id}${price}${left}`; + const raw = item.raw?.raw as Record | undefined; + const pointPrice = Number(xpd.price ?? raw?.iPrice); + const fragmentPrice = Number(raw?.iJb2Price); + const prices = [ + Number.isFinite(pointPrice) && pointPrice > 0 ? `${pointPrice}点券` : '', + Number.isFinite(fragmentPrice) && fragmentPrice > 0 ? `${fragmentPrice}碎片` : '', + ].filter(Boolean); + const stock = xpd.goods_left; + const stockText = stock != null && Number.isFinite(Number(stock)) + ? `库存${stock}` + : '库存未知'; + return `${item.name || item.commodity_id}${prices.length ? ` / ${prices.join(' 或 ')}` : ' / 暂不可兑换'} / ${stockText}`; +} + +function xpdGoodsPaymentOptions(item: DouyuGoodsItem | undefined): Array<{ value: '1' | '5'; label: string }> { + if (!item) return []; + const xpd = item as DouyuGoodsItem & { price?: number | null }; + const raw = item.raw?.raw as Record | undefined; + const pointPrice = Number(xpd.price ?? raw?.iPrice); + const fragmentPrice = Number(raw?.iJb2Price); + return [ + Number.isFinite(pointPrice) && pointPrice > 0 + ? { value: '1' as const, label: `使用点券兑换(${pointPrice}点券)` } + : null, + Number.isFinite(fragmentPrice) && fragmentPrice > 0 + ? { value: '5' as const, label: `使用扭蛋碎片兑换(${fragmentPrice}碎片)` } + : null, + ].filter((option): option is { value: '1' | '5'; label: string } => option !== null); } function formatWaitSeconds(seconds: number | null | undefined): string { @@ -337,6 +364,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const [goldAmount, setGoldAmount] = useState(() => savedPositiveInteger(DOUYU_GOLD_AMOUNT_STORAGE_KEY)); const [giftCount, setGiftCount] = useState(() => savedPositiveInteger(DOUYU_GIFT_COUNT_STORAGE_KEY)); const [selectedGoodsId, setSelectedGoodsId] = useState(''); + const [xpdPayType, setXpdPayType] = useState<'1' | '5'>('1'); const [accountSearch, setAccountSearch] = useState(''); const [layoutMode, setLayoutMode] = useState<'stack' | 'split'>(() => { const v = localStorage.getItem(DOUYU_LAYOUT_MODE_STORAGE_KEY); @@ -847,6 +875,26 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) // 多批次并发:不再用全局 busy 标志阻塞 UI,刷新频率直接用各子条件判断 + const selectedXpdGoods = useMemo( + () => goods.find((item) => item.commodity_id === selectedGoodsId), + [goods, selectedGoodsId], + ); + const xpdPaymentOptions = useMemo( + () => xpdGoodsPaymentOptions(selectedXpdGoods), + [selectedXpdGoods], + ); + const selectedXpdGoodsSoldOut = (() => { + const stock = (selectedXpdGoods as (DouyuGoodsItem & { goods_left?: number | null }) | undefined)?.goods_left; + return stock != null && Number.isFinite(Number(stock)) && Number(stock) <= 0; + })(); + + useEffect(() => { + if (xpdPaymentOptions.length === 0) return; + if (!xpdPaymentOptions.some((option) => option.value === xpdPayType)) { + setXpdPayType(xpdPaymentOptions[0].value); + } + }, [xpdPayType, xpdPaymentOptions]); + const startTask = async ( taskType: string, accountIds?: number[], @@ -858,12 +906,25 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return; } // 多批次并发:不再拦截,允许在已有批次运行时继续创建新批次 - if (['exchange_goods', 'exchange_esports_goods'].includes(taskType) && !selectedGoodsId) { + if (['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType) && !selectedGoodsId) { message.warning('请先选择兑换商品'); return; } + if (taskType === 'exchange_xpd_goods') { + if (selectedXpdGoodsSoldOut) { + message.warning('该商品库存不足,请刷新商品列表后重试'); + return; + } + if (!xpdPaymentOptions.some((option) => option.value === xpdPayType)) { + message.warning('该商品不支持当前兑换方式'); + return; + } + } const payload: Record = { ...extraPayload }; - if (['exchange_goods', 'exchange_esports_goods'].includes(taskType)) payload.commodity_id = selectedGoodsId; + if (['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType)) { + payload.commodity_id = selectedGoodsId; + } + if (taskType === 'exchange_xpd_goods') payload.pay_type = xpdPayType; if (taskType === 'create_gold_qr') payload.amount = goldAmount; if (['donate_elite_gift', 'donate_esports_chicken_gift', 'donate_esports_firework_gift'].includes(taskType)) { payload.gift_count = giftCount; @@ -1484,6 +1545,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]); const actionDisabled = (taskType: string) => { if (selectedIds.length === 0) return true; + if (taskType === 'exchange_xpd_goods') { + return !selectedGoodsId + || selectedXpdGoodsSoldOut + || !xpdPaymentOptions.some((option) => option.value === xpdPayType); + } if (['exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId; // 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段) return selectedHasRunning; @@ -1578,7 +1644,19 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) optionFilterProp="label" style={{ width: '100%' }} /> - {renderActionButton('refresh_xpd_goods')} + option.value === xpdPayType) ? xpdPayType : undefined} + onChange={setXpdPayType} + options={xpdPaymentOptions} + placeholder={selectedGoodsId ? '该商品暂无可用兑换方式' : '先选择商品'} + disabled={!selectedGoodsId || xpdPaymentOptions.length === 0} + style={{ width: '100%' }} + /> + + {renderActionButton('refresh_xpd_goods')} + {renderActionButton('exchange_xpd_goods', 'primary')} + diff --git a/web/frontend/src/utils/exchangeImage.ts b/web/frontend/src/utils/exchangeImage.ts index 100e083..d847d74 100644 --- a/web/frontend/src/utils/exchangeImage.ts +++ b/web/frontend/src/utils/exchangeImage.ts @@ -1,6 +1,6 @@ import type { DouyuTaskItem } from '../api/types'; -const EXCHANGE_TASK_TYPES = new Set(['exchange_goods', 'exchange_esports_goods']); +const EXCHANGE_TASK_TYPES = new Set(['exchange_goods', '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'; @@ -28,9 +28,10 @@ function goodsOf(task: DouyuTaskItem): { const goods = (task.result?.goods ?? null) as Record | null; const name = rawString(goods, 'commodityName') || rawString(goods, 'name') + || rawString(goods, 'sGoodsName') || task.message.replace(/^兑换[^::]*[::]?\s*/, '').trim() || rawString(task.result, 'commodity_id'); - const pic = rawString(goods, 'webPic') || rawString(goods, 'pic'); + const pic = rawString(goods, 'webPic') || rawString(goods, 'pic') || rawString(goods, 'sGoodsPic'); const roleName = rawString(task.result, 'game_name'); const channel = rawString(task.result, 'game_channel'); const accountName = rawString(task.result, 'account_name')