diff --git a/core/huya/elite_session.py b/core/huya/elite_session.py index 5ab88f6..b579486 100644 --- a/core/huya/elite_session.py +++ b/core/huya/elite_session.py @@ -179,10 +179,6 @@ class HuyaEliteWssSession: def query_user_score(self, uid: int, cookie: str, sid: int): return self.get_user_score(uid, cookie, sid) - def get_act_prize_list(self, uid: int, cookie: str, sid: int): - cookie = self._resolve_cookie(cookie) - return self._run(self.client.get_act_prize_list(uid, cookie, sid)) - def get_act_prize_detail(self, uid: int, cookie: str, sid: int, pid: int): cookie = self._resolve_cookie(cookie) return self._run(self.client.get_act_prize_detail(uid, cookie, sid, pid)) diff --git a/core/huya/http_client.py b/core/huya/http_client.py index c2f7586..62792ea 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -494,25 +494,6 @@ class HuyaHttpClient: timeout=timeout, ) - def get_act_prize_list( - self, uid: int, cookie: str, sid: int, timeout: float = 15.0 - ): - """查询活动可兑换商品列表。""" - from .activity_structs import GetActPrizeListReq, GetActPrizeListResp - - req = GetActPrizeListReq() - req.userId = self._build_activity_user(uid, cookie) - req.sid = sid - return self.call_rpc( - "webActUI", - "getActPrizeList", - req, - GetActPrizeListResp, - uid=uid, - cookie=cookie, - timeout=timeout, - ) - def get_user_prize_records( self, uid: int, cookie: str, sid: int, timeout: float = 15.0 ): diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py index fc1d07f..4ee1cfa 100644 --- a/core/huya/wss_client.py +++ b/core/huya/wss_client.py @@ -1033,16 +1033,6 @@ class HuyaWssClient: req.sid = int(sid or 0) return await self.call_rpc("webActUI", "getUserScore", req, GetUserScoreResp) - async def get_act_prize_list(self, uid: int, cookie: str, sid: int): - from .activity_structs import GetActPrizeListReq, GetActPrizeListResp - - req = GetActPrizeListReq() - req.userId = self._build_activity_user(uid, cookie) - req.sid = int(sid or 0) - return await self.call_rpc( - "webActUI", "getActPrizeList", req, GetActPrizeListResp - ) - async def get_act_prize_detail(self, uid: int, cookie: str, sid: int, pid: int): from .activity_structs import GetActPrizeDetailReq, GetActPrizeDetailResp diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 4dae3c7..5fb6ccb 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -421,8 +421,9 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid - **礼包来源**:`PlayMallUI.queryAllocation`(积分中心商品,只读、不落库;拉失败只提示 `gifts_message`,不影响 CK 的有效性结论)。商品积分取 `tag75.tag0.tag2`,与积分中心 - 页面显示一致;不再依赖活动 SID。旧 `webActUI.getActPrizeList` 保留给精英宝典工作台的 - 兑换链路,本模块不再调用; + 页面显示一致;不再依赖活动 SID。2026-11-13 最新 HAR 后,所有积分中心商品入口统一调用 + `services/huya_points_center.fetch_points_center_catalog`,旧 `webActUI.getActPrizeList` + 实现已删除; - **全部页签自动合并**(2026-11-11 HAR):基础请求只返回第一个页签,页签定义在 `tag2[].tag5` 解出的分类列表里;后端自动用 `pageId + waterfallTabId` 补拉其余页签。 当前最新场次实测为「精英专享 18 + 限量返场 21 = 39」。活动参数可在「虎牙配置」里改 @@ -700,7 +701,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid | — | 店铺怎么填(2026-10-16 用户拍板) | **点选按钮组**(不是下拉);允许"新店"自定义输入,后端自动登记进字典 | | — | 负责人默认值(同日拍板) | **默认 = 当前登录账号**,新建时不用选;要转单在列表/详情里改 | | — | 新建必填项(同日拍板) | **订单编号 / 店铺 / CK / 礼包名**必填;CK 必须探针通且读到绑定角色 | -| — | 礼包来源(2026-10-19 调整) | `PlayMallUI.queryAllocation` 积分中心商品,点选 + 记住上次选择;旧 `getActPrizeList` 保留给精英宝典工作台 | +| — | 礼包来源(2026-11-13 统一) | `PlayMallUI.queryAllocation` 积分中心商品,点选 + 记住上次选择;刷新、实时下拉、外单积分、兑换和赛季元数据统一调用 `fetch_points_center_catalog` | > 决定 7 的后果:**本模块不 import 任何 `huya_tasks` / runner 代码**。 > 以后要做「一键送进工作台」,那是一个**显式的**新动作(会创建 `HuyaAccount` 并写 @@ -1070,7 +1071,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid | 判定 | `结余 = 积分合计 - Σ required_points`;`<0` → `points_error/shortage`;`>阈值` → `points_error/excess`;其余 → `recharged`;CK 失效/积分失败/积分缺失整组保持原状态 | | 字段 | 新增 `required_points` + `points_checked_at/points_balance/points_required_total/points_surplus/points_threshold/points_check_reason/points_check_message/points_check_detail` | | 迁移 | `20261019_0061`:加列、`status` 默认改 `incomplete`、旧 `pending/bound` 按资料是否齐全转 `to_recharge` 或 `incomplete`;降级把 `to_recharge → bound`、`points_error → recharged` | -| 礼包来源 | 改用 `PlayMallUI.queryAllocation`(`tag75.tag0.tag2` 取积分,`tag13` 库存),不再依赖活动 SID;旧 `webActUI.getActPrizeList` 仅保留给精英宝典工作台 | +| 礼包来源 | 改用 `PlayMallUI.queryAllocation`(`tag75.tag0.tag2` 取积分,`tag14` 库存),不再依赖活动 SID;旧 `webActUI.getActPrizeList` 已删除 | | 前端 | 统计卡改为 全部/待补资料/待充值/已充值/已上脚本/抢购成功/抢购失败/积分异常/CK 失效,**可点击筛选并高亮**;工具栏新增「充值核账」;新建/补资料/详情增加「礼包所需积分」,选礼包自动带出;核账弹窗阈值默认 100 | | 审计 | 核账逐单写 `huya:collab:recharge_check`;自动推进/退回写 `huya:collab:status_auto`;主操作(create/update/image)先落审计,再追加自动状态,时间线因果顺序不再倒置 | | 生产修正 | 首版只按 `bind_game_account` 分组,生产发现「微信用户 / QQ用户」是通用昵称,8 个不同角色被合成一组(required_total=48000)。现改为「账号 + 角色」;`points_check_detail` 同时保留 `bind_game_account` 与 `bind_game_role` | @@ -1118,7 +1119,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid | CK 被导出后外传(导出到剪贴板 = 凭据出门) | 单行复制与批量导出统一走 `huya:collab:ck_export`(默认运营和客服可用,权限页可覆盖)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8) | | 积分 / 兑换记录查询被风控 | 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7) | | 自动改状态误判(接口抖动 / 兑换记录延迟 → 判成"抢购失败") | 三态判定:取不到或在 10 分钟确认窗口内没查到的都保持 `unknown`;只有过了窗口的 `scripted` 单才自动判失败;之后查到匹配记录可自动纠正为 `done`,每次查询留快照 | -| 积分中心 `queryAllocation` 拉不到 / 返回空 → 礼包列表空白 | 只影响「拉礼包」,不影响 CK 有效性;提示用户稍后点「强制刷新礼包」。旧 `getActPrizeList` 的 SID 过期问题只保留给精英宝典工作台(§11.4) | +| 积分中心 `queryAllocation` 拉不到 / 返回空 → 礼包列表空白 | 只影响「拉礼包」,不影响 CK 有效性;提示用户稍后点「强制刷新礼包」。2026-11-13 起唯一商品目录实现是 `fetch_points_center_catalog`,旧 `getActPrizeList` 已删除 | | 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` | | 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 | | 分支 | 按 AGENTS §1,新功能应从 `main` 开 `feat/*`;开工前需确认当前分支与库迁移版本对齐 | diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index b418f05..42ec2ec 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -42,6 +42,7 @@ from web.backend.schemas import ( HuyaExternalOrderUpdate, ) from web.backend.services import huya_collab_service as collab +from web.backend.services import huya_points_center as points_center from web.backend.services.huya_collab_events import collab_events @@ -840,32 +841,6 @@ class FakeBindResp: } -class FakeGiftResp: - """合成一份 ``getActPrizeList`` 响应(礼包列表)。""" - - def __init__(self, *, status: int = 200, msg: str = "", goods: list | None = None): - self.status = status - self.msg = msg - self._goods = ( - goods - if goods is not None - else [ - { - "product_id": "hy-1001", - "name": "不朽伊人大礼包", - "price": 200, - "remain_text": "39%", - "category_name": "礼包", - }, - {"product_id": "", "name": "缺 product_id 的脏数据"}, - {"product_id": "hy-1002", "name": ""}, - ] - ) - - def to_dict(self) -> dict: - return {"status": self.status, "msg": self.msg, "goods": self._goods} - - def points_center_blob( *, name: str = "不朽伊人大礼包", @@ -968,18 +943,15 @@ class FakeProbeClient: def __init__( self, *outcomes, - gifts: list | None = None, points_gifts: list | None = None, profile_nick: str | None = None, profile_outcome: object | None = None, ): self.outcomes = list(outcomes) - self.gift_outcomes = list(gifts or []) self.point_outcomes = list(points_gifts or []) self.profile_nick = profile_nick self.profile_outcome = profile_outcome self.calls: list[dict] = [] - self.gift_calls: list[dict] = [] self.points_calls: list[dict] = [] self.profile_calls: list[dict] = [] self._current_bind_outcome: object | None = None @@ -1001,13 +973,6 @@ class FakeProbeClient: raise outcome return outcome - def get_act_prize_list(self, **kwargs): - self.gift_calls.append(kwargs) - outcome = self.gift_outcomes.pop(0) if self.gift_outcomes else FakeGiftResp() - if isinstance(outcome, Exception): - raise outcome - return outcome - def play_mall_query_allocation(self, **kwargs): self.points_calls.append(kwargs) outcome = ( @@ -1492,7 +1457,6 @@ class TestCollabCkPreview(TestCollabBase): assert "已拉取 1 个礼包(积分中心)" in preview["gifts_message"] assert client.points_calls[0]["uid"] == 1199647239697 assert client.points_calls[0]["deep"] is True - assert client.gift_calls == [] def test_preview_of_invalid_ck_does_not_fetch_gifts(self): client = FakeProbeClient(None) @@ -1531,50 +1495,14 @@ class TestCollabCkPreview(TestCollabBase): class TestCollabGifts(TestCollabBase): - def test_gift_fetch_skips_dirty_rows(self): - gifts, message = collab.fetch_activity_gifts( - 1199647239697, fake_ck("1199647239697"), client=FakeProbeClient() - ) - assert [gift["name"] for gift in gifts] == ["不朽伊人大礼包"] - assert message == "已拉取 1 个礼包" - - def test_gift_fetch_reports_failure_without_raising(self): - gifts, message = collab.fetch_activity_gifts( - 1199647239697, - fake_ck("1199647239697"), - client=FakeProbeClient(gifts=[FakeGiftResp(status=500, msg="活动已结束")]), - ) - assert gifts == [] - assert "拉取礼包失败" in message and "500" in message - - def test_gift_fetch_survives_exception(self): - gifts, message = collab.fetch_activity_gifts( - 1199647239697, - fake_ck("1199647239697"), - client=FakeProbeClient(gifts=[RuntimeError("boom")]), - ) - assert gifts == [] - assert "RuntimeError" in message - - def test_gift_fetch_empty_list_hints_stale_sid(self): - """200 但 0 个商品 = SID 不是当前活动(真机 2026-10-16:2203 → 0,2277 → 30)。""" - gifts, message = collab.fetch_activity_gifts( - 1199647239697, - fake_ck("1199647239697"), - sid=2203, - client=FakeProbeClient(gifts=[FakeGiftResp(goods=[])]), - ) - assert gifts == [] - assert "SID=2203" in message and "工作台" in message - def test_points_center_gift_fetch_parses_fields(self): - gifts, message = collab.fetch_points_center_gifts( + catalog = points_center.fetch_points_center_catalog( 1199647239697, fake_ck("1199647239697"), client=FakeProbeClient(), ) - assert gifts == [ + assert catalog.goods == [ { "product_id": "hy-1001", "name": "不朽伊人大礼包", @@ -1587,7 +1515,7 @@ class TestCollabGifts(TestCollabBase): "icon": "", } ] - assert message == "已拉取 1 个礼包(积分中心)" + assert catalog.message == "已拉取 1 个礼包(积分中心)" def test_points_center_gift_fetch_loads_all_discovered_tabs(self): """基础响应只给第一个页签;其余页签按 pageId + tabId 自动补拉。""" @@ -1626,7 +1554,7 @@ class TestCollabGifts(TestCollabBase): ] ) - gifts, message = collab.fetch_points_center_gifts( + catalog = points_center.fetch_points_center_catalog( 1199647239697, fake_ck("1199647239697"), client=client, @@ -1635,14 +1563,17 @@ class TestCollabGifts(TestCollabBase): scene=8, ) - assert [gift["name"] for gift in gifts] == ["基础礼包", "套装-甜橙豚豚"] + assert [gift["name"] for gift in catalog.goods] == [ + "基础礼包", + "套装-甜橙豚豚", + ] assert client.points_calls[0]["page_code"] == "custom_page" assert client.points_calls[0]["source_id"] == "custom_source" assert client.points_calls[0]["scene"] == 8 assert "waterfall_tab_id" not in client.points_calls[0] assert client.points_calls[1]["page_id"] == 343 assert client.points_calls[1]["waterfall_tab_id"] == 433 - assert "2 个页签" in message + assert "2 个页签" in catalog.message def test_points_center_tab_override_uses_configured_ids(self): base_payload = { @@ -1670,7 +1601,7 @@ class TestCollabGifts(TestCollabBase): ] ) - gifts, message = collab.fetch_points_center_gifts( + catalog = points_center.fetch_points_center_catalog( 1199647239697, fake_ck("1199647239697"), client=client, @@ -1678,39 +1609,42 @@ class TestCollabGifts(TestCollabBase): tab_ids=[999], ) - assert [gift["name"] for gift in gifts] == ["基础礼包", "手工页签礼包"] + assert [gift["name"] for gift in catalog.goods] == [ + "基础礼包", + "手工页签礼包", + ] assert client.points_calls[1]["page_id"] == 343 assert client.points_calls[1]["waterfall_tab_id"] == 999 - assert "2 个页签" in message + assert "2 个页签" in catalog.message def test_points_center_gift_fetch_reports_failure(self): - gifts, message = collab.fetch_points_center_gifts( + catalog = points_center.fetch_points_center_catalog( 1199647239697, fake_ck("1199647239697"), client=FakeProbeClient( points_gifts=[FakePointsCenterResp(code=500, message="接口炸了")] ), ) - assert gifts == [] - assert "拉取礼包失败" in message and "500" in message + assert catalog.goods == [] + assert "拉取商品失败" in catalog.message and "500" in catalog.message def test_points_center_gift_fetch_survives_exception(self): - gifts, message = collab.fetch_points_center_gifts( + catalog = points_center.fetch_points_center_catalog( 1199647239697, fake_ck("1199647239697"), client=FakeProbeClient(points_gifts=[RuntimeError("boom")]), ) - assert gifts == [] - assert "RuntimeError" in message + assert catalog.goods == [] + assert "RuntimeError" in catalog.message def test_points_center_gift_fetch_empty_list(self): - gifts, message = collab.fetch_points_center_gifts( + catalog = points_center.fetch_points_center_catalog( 1199647239697, fake_ck("1199647239697"), client=FakeProbeClient(points_gifts=[FakePointsCenterResp(goods=[])]), ) - assert gifts == [] - assert "积分中心" in message and "空的" in message + assert catalog.goods == [] + assert "积分中心" in catalog.message and "空的" in catalog.message def test_points_center_parser_reads_tag75_points(self): from core.huya.shop_structs import parse_points_center_gifts @@ -1771,7 +1705,7 @@ class TestCollabGifts(TestCollabBase): ) self.db.commit() - assert collab.resolve_points_center_config(self.db) == { + assert points_center.resolve_points_center_config(self.db) == { "page_code": "custom_page", "source_id": "custom_source", "scene": 8, @@ -1793,6 +1727,7 @@ class TestCollabGifts(TestCollabBase): assert dumped["points_scene"] == "8" assert dumped["points_page_id"] == "343" assert dumped["points_tab_ids"] == "431,433" + assert dumped["exchange_use_proxy"] == "0" def test_preview_ignores_legacy_sid_and_uses_points_center(self): self.db.add(HuyaConfig(sid="2277")) diff --git a/tests/test_huya_exchange_proxy.py b/tests/test_huya_exchange_proxy.py index 368a727..af4eaa0 100644 --- a/tests/test_huya_exchange_proxy.py +++ b/tests/test_huya_exchange_proxy.py @@ -9,6 +9,7 @@ from unittest.mock import Mock from core.huya.http_client import HuyaHttpClient from web.backend.models import HuyaAccount, ProxyConfig from web.backend.services import huya_points_exchange as exchange +from web.backend.services import huya_runner_goods from web.backend.services.huya_runner import HuyaBatchRunner @@ -76,6 +77,24 @@ def _direct_client(): return client +def _mock_live_goods(monkeypatch): + monkeypatch.setattr( + huya_runner_goods, + "fetch_points_center_catalog", + lambda *_args, **_kwargs: SimpleNamespace( + goods=[ + { + "product_id": "hy-8315982", + "name": "gift", + "price": 20, + "remain_text": "100", + } + ], + message="已拉取 1 个礼包", + ), + ) + + def test_non_exchange_task_does_not_read_proxy_config(): db = Mock() runner = HuyaBatchRunner( @@ -98,6 +117,7 @@ def test_static_proxy_is_used_for_entire_exchange_task(monkeypatch): ) ) client = _direct_client() + _mock_live_goods(monkeypatch) calls = [] marks = [] @@ -119,7 +139,11 @@ def test_static_proxy_is_used_for_entire_exchange_task(monkeypatch): SimpleNamespace(id=1), HuyaAccount(uid="100", cookie="yyuid=100"), {"uid": 100, "cookie": "yyuid=100"}, - {"room_pid": "123", "pay_channel": "Zfb"}, + { + "room_pid": "123", + "pay_channel": "Zfb", + "exchange_use_proxy": "1", + }, ) assert calls == [ @@ -141,6 +165,7 @@ def test_api_proxy_is_fetched_once_per_exchange_task(monkeypatch): fetcher.fetch_new_proxy.return_value = "http://10.0.0.2:3128" runner._proxy_fetcher = fetcher client = _direct_client() + _mock_live_goods(monkeypatch) calls = [] marks = [] @@ -162,7 +187,11 @@ def test_api_proxy_is_fetched_once_per_exchange_task(monkeypatch): SimpleNamespace(id=2), HuyaAccount(uid="100", cookie="yyuid=100"), {"uid": 100, "cookie": "yyuid=100"}, - {"room_pid": "123", "pay_channel": "Zfb"}, + { + "room_pid": "123", + "pay_channel": "Zfb", + "exchange_use_proxy": "1", + }, ) fetcher.fetch_new_proxy.assert_called_once_with(max_attempts=3) @@ -197,7 +226,7 @@ def test_enabled_proxy_without_source_fails_exchange_before_request(monkeypatch) SimpleNamespace(id=3), HuyaAccount(uid="100", cookie="yyuid=100"), {"uid": 100, "cookie": "yyuid=100"}, - {"room_pid": "123"}, + {"room_pid": "123", "exchange_use_proxy": "1"}, ) assert marks == [ @@ -209,6 +238,53 @@ def test_enabled_proxy_without_source_fails_exchange_before_request(monkeypatch) ] +def test_exchange_proxy_switch_defaults_to_direct_connection(monkeypatch): + runner = _runner( + _cfg( + enabled=True, + http="http://10.0.0.1:8080", + https="http://10.0.0.1:8080", + ) + ) + client = _direct_client() + _mock_live_goods(monkeypatch) + calls = [] + marks = [] + + monkeypatch.setattr( + runner, + "_http_client", + lambda **kwargs: calls.append(kwargs) or client, + ) + monkeypatch.setattr( + runner, + "_exchange_proxies", + lambda: (_ for _ in ()).throw( + AssertionError("proxy switch is off; proxy config must not be read") + ), + ) + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + ) + + runner._execute_exchange_goods( + None, + SimpleNamespace(id=4), + HuyaAccount(uid="100", cookie="yyuid=100"), + {"uid": 100, "cookie": "yyuid=100"}, + {"room_pid": "123", "pay_channel": "Zfb"}, + ) + + assert calls == [{"proxies": None}] + assert marks[-1][0] == "success" + assert marks[-1][2]["proxy_used"] is False + assert marks[-1][2]["proxy_source"] == "disabled" + + def test_huya_http_client_routes_methods_through_configured_opener(monkeypatch): opener = Mock() opener.open.return_value = SimpleNamespace() diff --git a/tests/test_huya_live_goods.py b/tests/test_huya_live_goods.py new file mode 100644 index 0000000..176565f --- /dev/null +++ b/tests/test_huya_live_goods.py @@ -0,0 +1,143 @@ +"""Huya live goods selection and stale snapshot protection tests.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +from web.backend.models import HuyaAccount, HuyaGoodsSnapshot, User +from web.backend.routers.huya import list_goods, list_live_goods +from web.backend.services import huya_points_exchange as exchange +from web.backend.services import huya_runner_goods +from web.backend.services.huya_runner import HuyaBatchRunner + + +def _seed_account(db) -> tuple[User, HuyaAccount]: + user = User(username="operator", password_hash="hash", role="super_admin") + account = HuyaAccount( + uid="1199669301102", + yyuid="1199669301102", + nickname="小银紫资源", + cookie="yyuid=1199669301102; udb_uid=1199669301102", + ) + db.add_all([user, account]) + db.commit() + return user, account + + +def test_live_goods_endpoint_returns_current_spu(db, monkeypatch): + user, account = _seed_account(db) + monkeypatch.setattr( + "web.backend.routers.huya.workbench.fetch_points_center_catalog", + lambda *_args, **_kwargs: SimpleNamespace( + goods=[ + { + "product_id": "hy-9391246", + "name": "小狗蛇点点大礼包", + "price": 3960, + "remain_text": "2703", + } + ], + message="已拉取 1 个礼包", + sid=2277, + ), + ) + + rows = list_live_goods(account_id=account.id, db=db, current=user) + + assert [row.product_id for row in rows] == ["hy-9391246"] + assert rows[0].raw["product_id"] == "hy-9391246" + + +def test_list_goods_ignores_legacy_numeric_prize_ids(db): + user, account = _seed_account(db) + now = datetime.now(UTC) + db.add_all( + [ + HuyaGoodsSnapshot( + account_id=account.id, + sid=2277, + product_id="13649", + name="小狗蛇点点大礼包", + updated_at=now, + ), + HuyaGoodsSnapshot( + account_id=account.id, + sid=2277, + product_id="hy-9391246", + name="小狗蛇点点大礼包", + updated_at=now - timedelta(minutes=1), + ), + ] + ) + db.commit() + + rows = list_goods(db=db, current=user) + + assert [row.product_id for row in rows] == ["hy-9391246"] + + +def test_exchange_resolves_stale_prize_id_to_current_live_spu(monkeypatch): + runner = HuyaBatchRunner( + db=None, + batch_id="live-goods-repair", + task_type="exchange_goods", + payload={ + "product_id": "hy-13649", + "product_name": "小狗蛇点点大礼包", + }, + ) + client = SimpleNamespace() + marks = [] + prepared_args = {} + + monkeypatch.setattr(runner, "_http_client", lambda **_kwargs: client) + monkeypatch.setattr( + huya_runner_goods, + "fetch_points_center_catalog", + lambda *_args, **_kwargs: SimpleNamespace( + goods=[ + { + "product_id": "hy-9391246", + "name": "小狗蛇点点大礼包", + "price": 3960, + "remain_text": "2703", + } + ], + message="已拉取 1 个礼包", + ), + ) + + def fake_prepare(_client, **kwargs): + prepared_args.update(kwargs) + return exchange.PointsExchangeOutcome( + exchange.EXCHANGE_STATUS_EXCHANGED, + "兑换成功", + target={"name": "小狗蛇点点大礼包"}, + order={"order_id": 101}, + ) + + monkeypatch.setattr(huya_runner_goods, "prepare_exchange", fake_prepare) + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + ) + + runner._execute_exchange_goods( + None, + SimpleNamespace(id=1), + HuyaAccount(uid="1199669301102", cookie="yyuid=1199669301102"), + { + "uid": 1199669301102, + "cookie": "yyuid=1199669301102", + }, + {"room_pid": "1199650619883", "pay_channel": "Zfb"}, + ) + + assert prepared_args["spu_id"] == "hy-9391246" + assert marks[-1][0] == "success" + assert marks[-1][2]["submitted_product_id"] == "hy-13649" + assert marks[-1][2]["resolved_product_id"] == "hy-9391246" diff --git a/tests/test_huya_points_exchange.py b/tests/test_huya_points_exchange.py index c8cd3d4..d97a447 100644 --- a/tests/test_huya_points_exchange.py +++ b/tests/test_huya_points_exchange.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from web.backend.models import HuyaAccount from web.backend.services import huya_points_exchange as exchange +from web.backend.services import huya_runner_goods from web.backend.services.huya_runner import HuyaBatchRunner from web.backend.services.huya_shop_flow import ShopOrderOutcome @@ -79,6 +80,29 @@ def _state(*, full: bool, precondition: bool = True, plans=None): ) +def _mock_live_goods( + monkeypatch, + *, + product_id: str = "hy-8315982", + name: str = "幸运币礼包(小)", +): + monkeypatch.setattr( + huya_runner_goods, + "fetch_points_center_catalog", + lambda *_args, **_kwargs: SimpleNamespace( + goods=[ + { + "product_id": product_id, + "name": name, + "price": 20, + "remain_text": "100", + } + ], + message="已拉取 1 个礼包", + ), + ) + + def test_points_enough_uses_create_order_v6_without_payment(monkeypatch): client = _Client([_state(full=True)]) purchase_called = False @@ -209,6 +233,7 @@ def test_precondition_failure_does_not_create_order(): def test_runner_uses_points_exchange_service_for_direct_redeem(monkeypatch): client = _Client([_state(full=True)]) + _mock_live_goods(monkeypatch) runner = HuyaBatchRunner( db=None, batch_id="exchange-direct", @@ -261,6 +286,7 @@ def test_runner_waits_points_payment_then_redeems(monkeypatch): _state(full=True), ] ) + _mock_live_goods(monkeypatch) runner = HuyaBatchRunner( db=None, batch_id="exchange-pay", @@ -325,8 +351,6 @@ def test_runner_waits_points_payment_then_redeems(monkeypatch): def test_runner_refresh_goods_uses_points_center_query(monkeypatch): - from web.backend.services import huya_collab_service - runner = HuyaBatchRunner( db=None, batch_id="refresh-points-goods", @@ -351,12 +375,12 @@ def test_runner_refresh_goods_uses_points_center_query(monkeypatch): added.append(item) monkeypatch.setattr( - huya_collab_service, - "fetch_points_center_gifts", + huya_runner_goods, + "fetch_points_center_catalog", lambda uid, cookie, **kwargs: ( calls.append((uid, cookie, kwargs)) - or ( - [ + or SimpleNamespace( + goods=[ { "product_id": "hy-8315982", "name": "幸运币礼包(小)", @@ -365,7 +389,7 @@ def test_runner_refresh_goods_uses_points_center_query(monkeypatch): "stock": 11197, } ], - "已拉取 1 个礼包", + message="已拉取 1 个礼包", ) ), ) diff --git a/web/backend/huya_defaults.py b/web/backend/huya_defaults.py index 5655796..0f2b411 100644 --- a/web/backend/huya_defaults.py +++ b/web/backend/huya_defaults.py @@ -10,6 +10,8 @@ HUYA_DEFAULT_PAY_CHANNEL = "Zfb" HUYA_DEFAULT_POINTS_PAGE_CODE = "heping_baodian_credit_center_page" HUYA_DEFAULT_POINTS_SOURCE_ID = "yellowcarlist" HUYA_DEFAULT_POINTS_SCENE = "7" +# 精英宝典兑换独立控制是否复用全局代理,默认直连。 +HUYA_DEFAULT_EXCHANGE_USE_PROXY = "0" HUYA_CONFIG_DEFAULTS = { "room_pid": HUYA_DEFAULT_ROOM_PID, @@ -24,6 +26,7 @@ HUYA_CONFIG_DEFAULTS = { # 空 = 从 queryAllocation 响应的 tag0_2 自动发现。 "points_page_id": "", "points_tab_ids": "", + "exchange_use_proxy": HUYA_DEFAULT_EXCHANGE_USE_PROXY, } HUYA_CONFIG_FIELDS = tuple(HUYA_CONFIG_DEFAULTS) diff --git a/web/backend/migrations/versions/20261113_0081_huya_exchange_proxy.py b/web/backend/migrations/versions/20261113_0081_huya_exchange_proxy.py new file mode 100644 index 0000000..3a3cd7f --- /dev/null +++ b/web/backend/migrations/versions/20261113_0081_huya_exchange_proxy.py @@ -0,0 +1,45 @@ +"""虎牙精英宝典兑换增加独立代理开关。 + +默认关闭;开启后兑换链路才读取全局代理配置。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261113_0081" +down_revision: str | None = "20261112_0080" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "huya_config" +COLUMN = "exchange_use_proxy" + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table(TABLE): + return + existing = {column["name"] for column in inspector.get_columns(TABLE)} + if COLUMN not in existing: + op.add_column( + TABLE, + sa.Column( + COLUMN, + sa.String(length=1), + nullable=False, + server_default="0", + ), + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table(TABLE): + return + existing = {column["name"] for column in inspector.get_columns(TABLE)} + if COLUMN in existing: + op.drop_column(TABLE, COLUMN) diff --git a/web/backend/models.py b/web/backend/models.py index a515abc..d111ed1 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -25,6 +25,7 @@ from .database import Base, UTCDateTime from .huya_defaults import ( HUYA_DEFAULT_BIND_ACT_ID, HUYA_DEFAULT_BIND_GID, + HUYA_DEFAULT_EXCHANGE_USE_PROXY, HUYA_DEFAULT_OUTER_ACT_ID, HUYA_DEFAULT_PAY_CHANNEL, HUYA_DEFAULT_POINTS_PAGE_CODE, @@ -1413,6 +1414,12 @@ class HuyaConfig(Base): points_tab_ids: Mapped[str] = mapped_column( String(256), default="", server_default="" ) + # 仅控制兑换链路;关闭时即使全局代理已启用也强制直连。 + exchange_use_proxy: Mapped[str] = mapped_column( + String(1), + default=HUYA_DEFAULT_EXCHANGE_USE_PROXY, + server_default=HUYA_DEFAULT_EXCHANGE_USE_PROXY, + ) updated_at: Mapped[datetime] = mapped_column( UTCDateTime, default=_utcnow, onupdate=_utcnow ) diff --git a/web/backend/routers/huya/__init__.py b/web/backend/routers/huya/__init__.py index 1b22c04..db7c21e 100644 --- a/web/backend/routers/huya/__init__.py +++ b/web/backend/routers/huya/__init__.py @@ -45,6 +45,7 @@ from .workbench import ( import_workbench_cookies, list_goods, list_huya_workbench_accounts, + list_live_goods, list_recharge_goods, list_tasks, stop_batch, @@ -92,6 +93,7 @@ __all__ = [ "list_goods", "list_huya_account_ids", "list_huya_workbench_accounts", + "list_live_goods", "list_recharge_goods", "list_tags", "list_tasks", diff --git a/web/backend/routers/huya/_common.py b/web/backend/routers/huya/_common.py index f6b5af4..babbd8b 100644 --- a/web/backend/routers/huya/_common.py +++ b/web/backend/routers/huya/_common.py @@ -461,6 +461,9 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut: points_scene=huya_config_value("points_scene", config.points_scene), points_page_id=huya_config_value("points_page_id", config.points_page_id), points_tab_ids=huya_config_value("points_tab_ids", config.points_tab_ids), + exchange_use_proxy=huya_config_value( + "exchange_use_proxy", config.exchange_use_proxy + ), updated_at=config.updated_at, ) diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 6ece702..fd4dee3 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -73,6 +73,7 @@ from ...schemas import ( ) from ...services import huya_collab_service as collab from ...services.huya_collab_events import collab_events +from ...services.huya_points_center import resolve_points_center_config router = APIRouter() @@ -452,7 +453,7 @@ def preview_external_order_ck( req.ck_text, with_gifts=req.with_gifts, sid=collab.resolve_gifts_sid(db), - points_config=collab.resolve_points_center_config(db), + points_config=resolve_points_center_config(db), ) except collab.CollabError as exc: raise _http_error(exc) from exc diff --git a/web/backend/routers/huya/workbench.py b/web/backend/routers/huya/workbench.py index 4afb670..8e22ab7 100644 --- a/web/backend/routers/huya/workbench.py +++ b/web/backend/routers/huya/workbench.py @@ -1,8 +1,16 @@ """虎牙工作台:配置、商品快照、任务批次与日志。""" -from fastapi import APIRouter +from fastapi import APIRouter, Response + +from core.huya.cookie_utils import cookie_value +from core.huya.http_client import HuyaHttpClient from ...schemas import HuyaWorkbenchTransferToCollab +from ...services.huya_points_center import ( + fetch_points_center_catalog, + resolve_points_center_config, +) +from ...services.huya_service import ensure_huya_config from ._common import * router = APIRouter() @@ -169,6 +177,7 @@ def list_goods( rows = ( db.query(HuyaGoodsSnapshot) .filter(HuyaGoodsSnapshot.account_id.is_not(None)) + .filter(HuyaGoodsSnapshot.product_id.like("hy-%")) .order_by( HuyaGoodsSnapshot.updated_at.desc(), HuyaGoodsSnapshot.id.desc(), @@ -186,6 +195,65 @@ def list_goods( return list(latest.values()) +@router.get("/goods/live", response_model=list[HuyaGoodsOut]) +def list_live_goods( + account_id: int = Query(..., ge=1), + response: Response = None, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """按账号 CK 实时读取积分中心商品,不使用数据库快照。""" + if response is not None: + response.headers["Cache-Control"] = "no-store, max-age=0" + _require_huya_task_account_access(db, current, [account_id]) + account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() + if account is None: + raise HTTPException(status_code=404, detail="虎牙账号不存在") + cookie = normalize_huya_cookie(account.cookie or "") + if not cookie: + raise HTTPException( + status_code=400, detail="账号 Cookie 为空,无法读取实时商品" + ) + uid = ( + int(account.uid or account.yyuid or 0) + or int(cookie_value(cookie, "yyuid") or 0) + or int(cookie_value(cookie, "udb_uid") or 0) + ) + if not uid: + raise HTTPException(status_code=400, detail="无法解析账号 UID") + + query_options = resolve_points_center_config(db) + catalog = fetch_points_center_catalog( + uid, + cookie, + client=HuyaHttpClient( + logger=lambda value: logger.debug(f"[huya-live-goods] {value}") + ), + **query_options, + ) + if not catalog.goods: + raise HTTPException( + status_code=502, + detail=f"实时商品加载失败:{catalog.message}", + ) + now = datetime.now(UTC) + sid = catalog.sid or 0 + return [ + HuyaGoodsOut( + id=-(index + 1), + account_id=account.id, + sid=sid, + product_id=str(item["product_id"]), + name=str(item.get("name") or ""), + price=item.get("price"), + remain_text=str(item.get("remain_text") or ""), + raw=item, + updated_at=now, + ) + for index, item in enumerate(catalog.goods) + ] + + @router.get("/recharge-goods", response_model=list[HuyaRechargeGoodsOut]) def list_recharge_goods( db: Session = Depends(get_db), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index ec9978d..aba5bfe 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_valid from .huya_defaults import ( HUYA_DEFAULT_BIND_ACT_ID, HUYA_DEFAULT_BIND_GID, + HUYA_DEFAULT_EXCHANGE_USE_PROXY, HUYA_DEFAULT_OUTER_ACT_ID, HUYA_DEFAULT_PAY_CHANNEL, HUYA_DEFAULT_POINTS_PAGE_CODE, @@ -1261,6 +1262,7 @@ class HuyaConfigOut(BaseModel): points_scene: str = HUYA_DEFAULT_POINTS_SCENE points_page_id: str = "" points_tab_ids: str = "" + exchange_use_proxy: str = HUYA_DEFAULT_EXCHANGE_USE_PROXY updated_at: datetime | None = None @model_serializer @@ -1277,6 +1279,7 @@ class HuyaConfigOut(BaseModel): "points_scene": self.points_scene, "points_page_id": self.points_page_id, "points_tab_ids": self.points_tab_ids, + "exchange_use_proxy": self.exchange_use_proxy, "updated_at": _isoformat(self.updated_at), } @@ -1293,6 +1296,7 @@ class HuyaConfigUpdate(BaseModel): points_scene: str | None = None points_page_id: str | None = None points_tab_ids: str | None = None + exchange_use_proxy: str | None = Field(None, pattern="^(0|1)$") class HuyaTaskBatchRequest(BaseModel): diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 3d08981..2372299 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -35,9 +35,6 @@ from core.huya.http_client import query_bind_game_account from ..huya_defaults import ( HUYA_DEFAULT_BIND_GID, HUYA_DEFAULT_OUTER_ACT_ID, - HUYA_DEFAULT_POINTS_PAGE_CODE, - HUYA_DEFAULT_POINTS_SCENE, - HUYA_DEFAULT_POINTS_SOURCE_ID, HUYA_DEFAULT_SID, ) from ..models import ( @@ -51,9 +48,12 @@ from ..models import ( from .audit_service import record_audit from .file_service import build_file_url from .file_storage import normalize_object_key +from .huya_points_center import ( + fetch_points_center_catalog, + resolve_points_center_config, +) from .huya_service import ( build_web_cookie_from_raw, - huya_config_value, parse_huya_cookie_line, ) @@ -991,12 +991,13 @@ def _resolve_group_required_points( order.order_no for order in missing ], "组内没有可用的 CK,无法解析礼包积分" - catalog, message = fetch_points_center_gifts( + result = fetch_points_center_catalog( int(source.uid), str(source.cookie), - client=client, + client=client or _build_probe_client(), **resolve_points_center_config(db), ) + catalog, message = result.goods, result.message if not catalog: return [order.order_no for order in missing], message @@ -1659,14 +1660,12 @@ PROBE_SNAPSHOT_FIELDS: tuple[str, ...] = ( "bind_game_channel", ) -# 活动礼包(get_act_prize_list)用的 SID **兜底值**:真正该用的是工作台配置里的那个 +# 兑换记录等 webActUI 接口用的 SID **兜底值**:真正该用的是工作台配置里的那个 # (``huya_config.sid``,见 :func:`resolve_gifts_sid`)。 # 2026-10-16 真机定论:**SID 就是活动 ID,会随活动轮换** —— 库里 9 月的活动是 2203, # 今天是 2277。写死默认值时接口照样 ``status=200`` 但 **0 个商品**,界面上就是 # 「礼包列表空白」(换 SID 立刻回到 30 个礼包,跟 CK、UA、解析都无关)。 GIFTS_SID = int(HUYA_DEFAULT_SID) -# 一次最多回多少个礼包(避免前端下拉塞几百项)。 -GIFTS_LIMIT = 200 # 同一份 CK 的探针结论缓存时长("预览一次 + 提交校验一次" 只打一次接口)。 PROBE_CACHE_TTL_SECONDS = 90.0 _probe_cache: dict[str, tuple[float, CkProbeResult]] = {} @@ -1911,258 +1910,6 @@ def resolve_gifts_sid(db: Session) -> int: return value or GIFTS_SID -def resolve_points_center_config(db: Session) -> dict[str, Any]: - """积分中心活动参数:优先读虎牙配置,空值/脏值回落到默认。 - - 页签 ID 默认留空,由 ``queryAllocation`` 响应自动发现;``points_page_id`` - 与 ``points_tab_ids`` 只作为接口改版、自动发现失败时的人工覆盖。 - """ - row = db.query(HuyaConfig).first() - - def value(field: str, default: str) -> str: - return ( - huya_config_value( - field, getattr(row, field, None) if row is not None else None - ) - or default - ) - - try: - scene = int(value("points_scene", HUYA_DEFAULT_POINTS_SCENE)) - except (TypeError, ValueError): - scene = int(HUYA_DEFAULT_POINTS_SCENE) - - page_id_text = value("points_page_id", "") - try: - page_id = int(page_id_text) if page_id_text else None - except ValueError: - page_id = None - - tab_ids: list[int] = [] - for part in re.split(r"[,,\s]+", value("points_tab_ids", "")): - if part.isdigit(): - tab_id = int(part) - if tab_id not in tab_ids: - tab_ids.append(tab_id) - - return { - "page_code": value("points_page_code", HUYA_DEFAULT_POINTS_PAGE_CODE), - "source_id": value("points_source_id", HUYA_DEFAULT_POINTS_SOURCE_ID), - "scene": scene, - "page_id": page_id, - "tab_ids": tab_ids or None, - } - - -def fetch_activity_gifts( - uid: int, - cookie: str, - *, - sid: int | None = None, - client: Any | None = None, -) -> tuple[list[dict], str]: - """LEGACY:用这份 CK 拉 ``webActUI.getActPrizeList`` 活动礼包。 - - 外部单协作台账已改用 :func:`fetch_points_center_gifts` - (``PlayMallUI.queryAllocation``);本函数保留给旧调用/测试,不再从 - ``preview_ck`` 调用。 - """ - sid_value = int(sid or GIFTS_SID) - try: - gift_client = client or _build_probe_client() - resp = gift_client.get_act_prize_list( - uid=int(uid), cookie=cookie, sid=sid_value - ) - except Exception as exc: # noqa: BLE001 拉礼包失败不该让整个预览 500 - logger.debug(f"[huya-collab] 拉取活动礼包异常:{type(exc).__name__}: {exc}") - return [], f"拉取礼包失败:{type(exc).__name__}: {_clip(exc)}" - - if resp is None: - return [], "拉取礼包失败:接口没有返回数据" - status = int(getattr(resp, "status", 0) or 0) - if status != 200: - msg = _clip(getattr(resp, "msg", "")) - return [], f"拉取礼包失败:status={status}{(':' + msg) if msg else ''}" - - raw_goods = (resp.to_dict() or {}).get("goods") or [] - gifts: list[dict] = [] - for item in raw_goods: - product_id = str(item.get("product_id") or "").strip() - name = str(item.get("name") or "").strip() - if not product_id or not name: - continue - gifts.append( - { - "product_id": product_id, - "name": name[:256], - "price": item.get("price"), - "remain_text": str(item.get("remain_text") or "")[:64], - "stock": item.get("stock"), - "score": item.get("score"), - "category_name": str(item.get("category_name") or "")[:64], - "icon": str(item.get("icon") or "")[:512], - } - ) - gifts = gifts[:GIFTS_LIMIT] - if not gifts: - # 200 + 空列表 = 这个 SID 不是当前活动(见 resolve_gifts_sid 的真机定位)。 - return [], ( - f"活动 SID={sid_value} 没返回商品:接口通了但列表是空的," - "多半是活动换 ID 了 —— 去工作台「虎牙配置」把活动 SID 改成当前的再试" - ) - return gifts, f"已拉取 {len(gifts)} 个礼包" - - -def fetch_points_center_gifts( - uid: int, - cookie: str, - *, - client: Any | None = None, - page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE, - source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID, - scene: int = int(HUYA_DEFAULT_POINTS_SCENE), - page_id: int | None = None, - tab_ids: Sequence[int] | None = None, -) -> tuple[list[dict], str]: - """用这份 CK 拉**积分中心商品**(``PlayMallUI.queryAllocation``,只读)。 - - 2026-10-19 用户拍板:外部单协作台账改用这个接口,不再调用 - ``webActUI.getActPrizeList``。商品积分取 ``tag75.tag0.tag2``, - 与积分中心页面显示一致;旧接口保留给精英宝典工作台的兑换链路。 - - 2026-11-11 HAR 确认:基础请求只返回第一个页签的商品,其余页签定义在 - ``tag2[].tag0_2``,需要带 ``tag9=pageId`` 与 - ``tag7={"waterfallTabId":"tabId"}`` 再查。这里默认自动发现并拉全, - ``page_id`` / ``tab_ids`` 仅作为配置覆盖。 - """ - from core.huya.shop_structs import ( - extract_points_center_tabs, - parse_points_center_gifts, - ) - - gift_client = client or _build_probe_client() - - def call( - tab_id: int | None = None, tab_page_id: int | None = None - ) -> tuple[dict | None, str]: - kwargs: dict[str, Any] = { - "uid": int(uid), - "guid": "", - "cookie": cookie, - "page_code": page_code, - "source_id": source_id, - "scene": scene, - "deep": True, - } - if tab_id is not None: - kwargs["page_id"] = int(page_id or tab_page_id or 0) - kwargs["waterfall_tab_id"] = int(tab_id) - try: - resp = gift_client.play_mall_query_allocation(**kwargs) - except Exception as exc: # noqa: BLE001 单个页签失败不该带崩整批 - logger.debug( - f"[huya-collab] 拉取积分中心商品异常:{type(exc).__name__}: {exc}" - ) - return None, f"{type(exc).__name__}: {_clip(exc)}" - - if resp is None: - return None, "积分中心接口没有返回数据" - - code = getattr(resp, "code", None) - if code is None: - code = getattr(resp, "status", 0) - code = int(code or 0) - if code != 200: - msg = _clip(getattr(resp, "message", "") or getattr(resp, "msg", "")) - return None, f"code={code}{(':' + msg) if msg else ''}" - - payload = getattr(resp, "payload", None) - if payload is None and hasattr(resp, "to_dict"): - try: - payload = (resp.to_dict() or {}).get("payload") - except Exception: # noqa: BLE001 第三方响应结构异常时按空处理 - payload = None - return payload if isinstance(payload, dict) else None, "" - - payload, error = call() - if error: - return [], f"拉取礼包失败:{error}" - - gifts = parse_points_center_gifts(payload) - tabs = extract_points_center_tabs(payload) - if not gifts and not tabs: - return [], ( - "积分中心没返回商品:接口通了但列表是空的," - "检查账号是否仍有积分中心访问权限,或稍后重试" - ) - - discovered = {(int(tab["page_id"]), int(tab["tab_id"])): tab for tab in tabs} - loaded_categories = {str(gift.get("category_name") or "").strip() for gift in gifts} - tab_errors: list[str] = [] - if tab_ids: - targets: list[dict] = [] - for tab_id in tab_ids: - matched = next( - ( - tab - for (tab_page_id, tab_id_value), tab in discovered.items() - if tab_id_value == int(tab_id) - and (page_id is None or tab_page_id == int(page_id)) - ), - None, - ) - if matched is not None: - targets.append(matched) - elif page_id is not None: - targets.append( - { - "page_id": int(page_id), - "tab_id": int(tab_id), - "name": f"页签 {tab_id}", - } - ) - else: - tab_errors.append(f"页签 {tab_id}:响应里没发现,且未配置 pageId") - else: - targets = [ - tab - for tab in tabs - if str(tab.get("name") or "").strip() not in loaded_categories - ] - - seen_products = { - str(gift.get("product_id") or "") for gift in gifts if gift.get("product_id") - } - loaded_tabs = len(loaded_categories) if gifts else 0 - for tab in targets: - target_page_id = int(page_id or tab["page_id"]) - tab_payload, tab_error = call(int(tab["tab_id"]), target_page_id) - if tab_error: - tab_errors.append(f"{tab.get('name') or tab['tab_id']}:{tab_error}") - continue - tab_gifts = parse_points_center_gifts(tab_payload) - added = 0 - for gift in tab_gifts: - product_id = str(gift.get("product_id") or "") - if product_id and product_id not in seen_products: - seen_products.add(product_id) - gifts.append(gift) - added += 1 - if added or tab_gifts: - loaded_tabs += 1 - - gifts = gifts[:GIFTS_LIMIT] - if not gifts: - return [], ( - "积分中心没返回商品:接口通了但列表是空的," - "检查账号是否仍有积分中心访问权限,或稍后重试" - ) - suffix = f",{loaded_tabs} 个页签" if loaded_tabs > 1 else "" - if tab_errors: - suffix += f";部分页签未加载:{';'.join(tab_errors[:3])}" - return gifts, f"已拉取 {len(gifts)} 个礼包(积分中心{suffix})" - - # -------------------------------------------------------------------------- # 账号全生命周期(积分 / 兑换记录:抢购之后靠它判断到底兑换成功没有) # -------------------------------------------------------------------------- @@ -2804,12 +2551,13 @@ def preview_ck( gifts: list[dict] = [] gifts_message = "" if with_gifts and result.ok and uid_text: - gifts, gifts_message = fetch_points_center_gifts( + catalog = fetch_points_center_catalog( int(uid_text), cookie, - client=client, + client=client or _build_probe_client(), **(dict(points_config) if points_config else {}), ) + gifts, gifts_message = catalog.goods, catalog.message return { "ok": result.ok, "message": result.message, diff --git a/web/backend/services/huya_points_center.py b/web/backend/services/huya_points_center.py new file mode 100644 index 0000000..46d3e51 --- /dev/null +++ b/web/backend/services/huya_points_center.py @@ -0,0 +1,332 @@ +"""虎牙积分中心唯一目录读取实现。 + +2026-11-13 两份 HAR(``11.13-兑换记录.har``、 +``11.13-虎牙充值2元-兑换幸运币.har``)确认: + +* ``PlayMallUI.queryAllocation`` 是积分中心商品、页签、活动 SID 和赛季配置的 + 唯一权威来源; +* 基础响应只返回第一个页签,官方页面随后按 ``pageId + waterfallTabId`` + 补拉其它页签; +* ``webActUI.getActPrizeList`` 不在最新兑换链路中,旧实现已删除。 + +商品刷新、实时下拉、兑换前校验、外部单积分解析、CK 预览、兑换记录 SID 和 +宝典赛季都只能调用本模块,避免各入口重复实现后再次出现旧 prize_id。 +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from loguru import logger +from sqlalchemy.orm import Session + +from core.huya.shop_structs import ( + extract_points_center_sid, + extract_points_center_tabs, + parse_points_center_gifts, +) + +from ..huya_defaults import ( + HUYA_DEFAULT_POINTS_PAGE_CODE, + HUYA_DEFAULT_POINTS_SCENE, + HUYA_DEFAULT_POINTS_SOURCE_ID, +) +from ..models import HuyaConfig +from .huya_service import huya_config_value + +GIFTS_LIMIT = 200 +POINTS_SEASON_MS_FLOOR = 1_000_000_000_000 + + +def _clip(value: Any, limit: int = 200) -> str: + text = str(value if value is not None else "").strip() + return text if len(text) <= limit else text[: limit - 3] + "..." + + +def _walk_season_nodes(node: Any, found: list[dict]) -> None: + if isinstance(node, dict): + config = node.get("tag0") + if isinstance(config, dict): + try: + start_ms = int(config.get("tag10") or 0) + end_ms = int(config.get("tag11") or 0) + config_id = int(config.get("tag0") or 0) + except (TypeError, ValueError): + start_ms = end_ms = config_id = 0 + if start_ms >= POINTS_SEASON_MS_FLOOR and end_ms > start_ms: + found.append( + { + "name": _clip(config.get("tag2"), 128), + "start_ms": start_ms, + "end_ms": end_ms, + "config_id": config_id, + } + ) + for value in node.values(): + _walk_season_nodes(value, found) + elif isinstance(node, list): + for value in node: + _walk_season_nodes(value, found) + + +def extract_points_center_season(payload: Any) -> dict | None: + """取积分中心当前宝典赛季;认不出结构时返回 ``None``。""" + found: list[dict] = [] + _walk_season_nodes(payload, found) + return max(found, key=lambda item: item["end_ms"]) if found else None + + +def resolve_points_center_config(db: Session) -> dict[str, Any]: + """读取积分中心请求参数;空配置自动回落到当前页面默认值。""" + row = db.query(HuyaConfig).first() + + def value(field: str, default: str) -> str: + return ( + huya_config_value( + field, getattr(row, field, None) if row is not None else None + ) + or default + ) + + try: + scene = int(value("points_scene", HUYA_DEFAULT_POINTS_SCENE)) + except (TypeError, ValueError): + scene = int(HUYA_DEFAULT_POINTS_SCENE) + + page_id_text = value("points_page_id", "") + try: + page_id = int(page_id_text) if page_id_text else None + except ValueError: + page_id = None + + tab_ids: list[int] = [] + for part in re.split(r"[,,\s]+", value("points_tab_ids", "")): + if part.isdigit(): + tab_id = int(part) + if tab_id not in tab_ids: + tab_ids.append(tab_id) + + return points_center_options_from_config( + { + "points_page_code": value( + "points_page_code", HUYA_DEFAULT_POINTS_PAGE_CODE + ), + "points_source_id": value( + "points_source_id", HUYA_DEFAULT_POINTS_SOURCE_ID + ), + "points_scene": scene, + "points_page_id": page_id or "", + "points_tab_ids": ",".join(str(item) for item in tab_ids), + } + ) + + +def points_center_options_from_config( + config_info: Mapping[str, Any], +) -> dict[str, Any]: + """把配置字段规范的公共入口,任务和路由不再各自解析。""" + tab_ids = [ + int(part) + for part in str(config_info.get("points_tab_ids") or "") + .replace(",", ",") + .replace(" ", ",") + .split(",") + if part.isdigit() + ] + return { + "page_code": ( + str(config_info.get("points_page_code") or "").strip() + or HUYA_DEFAULT_POINTS_PAGE_CODE + ), + "source_id": ( + str(config_info.get("points_source_id") or "").strip() + or HUYA_DEFAULT_POINTS_SOURCE_ID + ), + "scene": int(config_info.get("points_scene") or 0) + or int(HUYA_DEFAULT_POINTS_SCENE), + "page_id": int(config_info.get("points_page_id") or 0) or None, + "tab_ids": tab_ids or None, + } + + +@dataclass +class PointsCenterCatalog: + goods: list[dict] = field(default_factory=list) + tabs: list[dict] = field(default_factory=list) + sid: int | None = None + season: dict | None = None + payload: dict | None = None + message: str = "" + + +def fetch_points_center_catalog( + uid: int, + cookie: str, + *, + client: Any | None = None, + page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE, + source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID, + scene: int = int(HUYA_DEFAULT_POINTS_SCENE), + page_id: int | None = None, + tab_ids: list[int] | None = None, + include_all_tabs: bool = True, +) -> PointsCenterCatalog: + """一次读取积分中心的商品、页签、SID 和赛季配置。 + + ``include_all_tabs=False`` 仅供只取 SID/赛季的调用方使用,避免为了元数据 + 额外请求所有商品页签。商品列表调用必须保持默认 ``True``。 + """ + if client is None: + from core.huya.http_client import HuyaHttpClient + + client = HuyaHttpClient( + logger=lambda message: logger.debug(f"[huya-points-center] {message}") + ) + + def call( + tab_id: int | None = None, tab_page_id: int | None = None + ) -> tuple[dict | None, str]: + kwargs: dict[str, Any] = { + "uid": int(uid), + "guid": "", + "cookie": cookie, + "page_code": page_code, + "source_id": source_id, + "scene": scene, + "deep": True, + } + if tab_id is not None: + kwargs["page_id"] = int(page_id or tab_page_id or 0) + kwargs["waterfall_tab_id"] = int(tab_id) + try: + response = client.play_mall_query_allocation(**kwargs) + except Exception as exc: # noqa: BLE001 外部接口异常收敛为目录消息 + logger.debug( + f"[huya-points-center] queryAllocation 异常:" + f"{type(exc).__name__}: {exc}" + ) + return None, f"{type(exc).__name__}: {_clip(exc)}" + + if response is None: + return None, "积分中心接口没有返回数据" + code = getattr(response, "code", None) + if code is None: + code = getattr(response, "status", 0) + code = int(code or 0) + if code != 200: + message = _clip( + getattr(response, "message", "") or getattr(response, "msg", "") + ) + return None, f"code={code}{(':' + message) if message else ''}" + + payload = getattr(response, "payload", None) + if payload is None and hasattr(response, "to_dict"): + try: + payload = (response.to_dict() or {}).get("payload") + except Exception: # noqa: BLE001 第三方响应结构异常时按空处理 + payload = None + return payload if isinstance(payload, dict) else None, "" + + base_payload, error = call() + if error: + return PointsCenterCatalog(message=f"拉取商品失败:{error}") + if base_payload is None: + return PointsCenterCatalog(message="积分中心接口没有返回商品数据") + + goods = parse_points_center_gifts(base_payload) + tabs = extract_points_center_tabs(base_payload) + sid = extract_points_center_sid(base_payload) + season = extract_points_center_season(base_payload) + if not include_all_tabs or (not goods and not tabs): + message = ( + f"已拉取 {len(goods)} 个礼包(积分中心)" + if goods + else "积分中心没返回商品:接口通了但列表是空的" + ) + return PointsCenterCatalog( + goods=goods[:GIFTS_LIMIT], + tabs=tabs, + sid=sid, + season=season, + payload=base_payload, + message=message, + ) + + discovered = {(int(tab["page_id"]), int(tab["tab_id"])): tab for tab in tabs} + loaded_categories = {str(gift.get("category_name") or "").strip() for gift in goods} + tab_errors: list[str] = [] + if tab_ids: + targets: list[dict] = [] + for tab_id in tab_ids: + matched = next( + ( + tab + for (tab_page_id, tab_id_value), tab in discovered.items() + if tab_id_value == int(tab_id) + and (page_id is None or tab_page_id == int(page_id)) + ), + None, + ) + if matched is not None: + targets.append(matched) + elif page_id is not None: + targets.append( + { + "page_id": int(page_id), + "tab_id": int(tab_id), + "name": f"页签 {tab_id}", + } + ) + else: + tab_errors.append(f"页签 {tab_id}:响应里没发现,且未配置 pageId") + else: + targets = [ + tab + for tab in tabs + if str(tab.get("name") or "").strip() not in loaded_categories + ] + + seen_products = { + str(gift.get("product_id") or "") for gift in goods if gift.get("product_id") + } + loaded_tabs = len(loaded_categories) if goods else 0 + for tab in targets: + target_page_id = int(page_id or tab["page_id"]) + tab_payload, tab_error = call(int(tab["tab_id"]), target_page_id) + if tab_error: + tab_errors.append(f"{tab.get('name') or tab['tab_id']}:{tab_error}") + continue + tab_goods = parse_points_center_gifts(tab_payload) + added = 0 + for gift in tab_goods: + product_id = str(gift.get("product_id") or "") + if product_id and product_id not in seen_products: + seen_products.add(product_id) + goods.append(gift) + added += 1 + if added or tab_goods: + loaded_tabs += 1 + + goods = goods[:GIFTS_LIMIT] + if not goods: + return PointsCenterCatalog( + tabs=tabs, + sid=sid, + season=season, + payload=base_payload, + message="积分中心没返回商品:接口通了但列表是空的", + ) + suffix = f",{loaded_tabs} 个页签" if loaded_tabs > 1 else "" + if tab_errors: + suffix += f";部分页签未加载:{';'.join(tab_errors[:3])}" + return PointsCenterCatalog( + goods=goods, + tabs=tabs, + sid=sid, + season=season, + payload=base_payload, + message=f"已拉取 {len(goods)} 个礼包(积分中心{suffix})", + ) diff --git a/web/backend/services/huya_runner_elite.py b/web/backend/services/huya_runner_elite.py index d79b4f2..82896e6 100644 --- a/web/backend/services/huya_runner_elite.py +++ b/web/backend/services/huya_runner_elite.py @@ -30,6 +30,10 @@ from sqlalchemy.orm import Session from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM from ..models import HuyaAccount, HuyaTask +from .huya_points_center import ( + extract_points_center_season, + fetch_points_center_catalog, +) # 宝典订单识别:spu 白名单 + 订单名兜底(官方换过多次 SKU,历史订单名带赛季, # 例如「精英宝典⎜得300积分⎜26年Q2」)。 @@ -45,8 +49,6 @@ ELITE_OPENING_WINDOW_MS = 24 * 60 * 60 * 1000 ELITE_PENDING_ORDER_STATUSES = frozenset({10, 1000}) ELITE_CANCELLED_ORDER_STATUSES = frozenset({90, 1001}) ELITE_PAID_ORDER_STATUSES = frozenset({20, 30, 40, 50, 55, 60, 70, 80}) -# 赛季(福利季)节点的毫秒时间戳下限:2001-09 之后的值才认 -ELITE_SEASON_MS_FLOOR = 1_000_000_000_000 class EliteMixin: @@ -205,56 +207,20 @@ class EliteMixin: } return {"status": "unopened", "label": "未开通", "order": order} - @classmethod - def _walk_season_nodes(cls, node: Any, found: list[dict]) -> None: - """递归找「赛季」节点:同时带 tag10/tag11 毫秒起止时间的结构。""" - if isinstance(node, dict): - start = node.get("tag10") - end = node.get("tag11") - if ( - isinstance(start, int) - and isinstance(end, int) - and start >= ELITE_SEASON_MS_FLOOR - and end > start - ): - found.append( - { - "name": str(node.get("tag2") or node.get("tag1") or ""), - "start_ms": start, - "end_ms": end, - "config_id": node.get("tag0") - if isinstance(node.get("tag0"), int) - else None, - } - ) - for value in node.values(): - cls._walk_season_nodes(value, found) - elif isinstance(node, list): - for value in node: - cls._walk_season_nodes(value, found) - @classmethod def _extract_elite_season(cls, payload: Any) -> dict | None: - """从积分中心页面配置里取宝典赛季(= 有效期)信息。 - - 真机(2026-10-15 抓包)实证:``PlayMallUI.queryAllocation`` 响应里 - ``tag2[1].tag2_2.tag0`` 是本赛季配置 —— tag1/tag2 = 「精英宝典福利季」/ - 「精英宝典福利季丨Q4」,tag10/tag11 = 赛季起止毫秒时间戳 - (1791475200000 → 2026-10-09 00:00,1798732799000 → 2026-12-31 23:59:59)。 - 这里不写死索引,按「带 tag10/tag11 的节点」找,取结束时间最晚的那个。 - """ - found: list[dict] = [] - cls._walk_season_nodes(payload, found) - if not found: - return None - return max(found, key=lambda item: item["end_ms"]) + """兼容旧测试入口;唯一解析实现位于 ``huya_points_center``。""" + return extract_points_center_season(payload) def _resolve_elite_season(self, client: Any, uid: int, cookie: str) -> dict | None: """查询积分中心页面配置并解析本赛季信息;查不到返回 None。""" - resp = client.play_mall_query_allocation(uid=uid, guid="", cookie=cookie) - if resp is None or getattr(resp, "code", 0) != 200: - return None - return self._extract_elite_season(getattr(resp, "payload", None)) + catalog = fetch_points_center_catalog( + uid, + cookie, + client=client, + include_all_tabs=False, + ) + return catalog.season def _refresh_elite_open_after_payment( self, diff --git a/web/backend/services/huya_runner_goods.py b/web/backend/services/huya_runner_goods.py index f7822f8..db5935d 100644 --- a/web/backend/services/huya_runner_goods.py +++ b/web/backend/services/huya_runner_goods.py @@ -12,6 +12,10 @@ from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask from .huya_pay_link import pay_qr_path, resolve_pay_targets +from .huya_points_center import ( + fetch_points_center_catalog, + points_center_options_from_config, +) from .huya_points_exchange import ( EXCHANGE_STATUS_EXCHANGED, EXCHANGE_STATUS_PAYMENT_REQUIRED, @@ -53,6 +57,54 @@ class GoodsMixin: def _wait_until(self, when: datetime, uid: int) -> bool: ... def _parse_scheduled_time(self, value: Any) -> datetime | None: ... + def _resolve_live_exchange_goods( + self, + client: Any, + *, + uid: int, + cookie: str, + config_info: dict, + submitted_product_id: str, + product_name: str, + ) -> tuple[dict | None, str]: + """用实时积分中心商品把旧 prize_id 或过期选择解析成当前 SPU。 + + 2026-11-13 两份最新 HAR 确认:页面商品列表使用 queryAllocation 返回的 + ``hy-*`` SPU;旧快照里的数字 prize_id 不能直接用于兑换。 + """ + catalog = fetch_points_center_catalog( + uid, + cookie, + client=client, + **points_center_options_from_config(config_info), + ) + goods, message = catalog.goods, catalog.message + if not goods: + return None, message or "实时商品列表为空" + + wanted_name = product_name.strip() + wanted_id = normalize_spu_id(submitted_product_id) + if wanted_name: + by_name = [ + item + for item in goods + if str(item.get("name") or "").strip() == wanted_name + ] + if len(by_name) == 1: + return by_name[0], "" + if len(by_name) > 1 and wanted_id: + for item in by_name: + if normalize_spu_id(item.get("product_id")) == wanted_id: + return item, "" + if wanted_id: + for item in goods: + if normalize_spu_id(item.get("product_id")) == wanted_id: + return item, "" + return ( + None, + "商品列表已更新,当前实时列表中找不到所选商品,请重新选择", + ) + def _execute_query_act_tasks( self, worker_db: Session, @@ -239,33 +291,21 @@ class GoodsMixin: self._mark_task(worker_db, task, "failed", "账号 Cookie 为空") return - from core.huya.shop_structs import extract_points_center_sid - client: Any = self._http_client() configured_sid = self._to_int(self.payload.get("sid") or config_info.get("sid")) sid_int = configured_sid sid_source = "config" try: - page_code = ( - str(config_info.get("points_page_code") or "").strip() - or "heping_baodian_credit_center_page" + # 2026-11-13 HAR:兑换记录只借 queryAllocation 取活动 SID, + # 不重复拉其它商品页签。 + catalog = fetch_points_center_catalog( + uid, + cookie, + client=client, + include_all_tabs=False, + **points_center_options_from_config(config_info), ) - source_id = ( - str(config_info.get("points_source_id") or "").strip() - or "yellowcarlist" - ) - scene = self._to_int(config_info.get("points_scene")) or 7 - allocation = client.play_mall_query_allocation( - uid=uid, - guid="", - cookie=cookie, - page_code=page_code, - source_id=source_id, - scene=scene, - deep=True, - ) - payload = getattr(allocation, "payload", None) - discovered_sid = extract_points_center_sid(payload) + discovered_sid = catalog.sid if discovered_sid: sid_int = discovered_sid sid_source = "queryAllocation" @@ -335,36 +375,15 @@ class GoodsMixin: self._mark_task(worker_db, task, "failed", "账号 Cookie 为空") return - from .huya_collab_service import fetch_points_center_gifts - sid_int = self._to_int(config_info.get("sid")) or 2203 - page_code = ( - str(config_info.get("points_page_code") or "").strip() - or "heping_baodian_credit_center_page" - ) - source_id = ( - str(config_info.get("points_source_id") or "").strip() or "yellowcarlist" - ) - scene = self._to_int(config_info.get("points_scene")) or 7 - page_id = self._to_int(config_info.get("points_page_id")) or None - tab_ids = [ - int(part) - for part in str(config_info.get("points_tab_ids") or "") - .replace(",", ",") - .replace(" ", ",") - .split(",") - if part.isdigit() - ] - goods, message = fetch_points_center_gifts( + query_options = points_center_options_from_config(config_info) + catalog = fetch_points_center_catalog( uid, cookie, client=self._http_client(), - page_code=page_code, - source_id=source_id, - scene=scene, - page_id=page_id, - tab_ids=tab_ids or None, + **query_options, ) + goods, message = catalog.goods, catalog.message now = datetime.now(UTC) worker_db.query(HuyaGoodsSnapshot).filter( HuyaGoodsSnapshot.account_id == account.id, @@ -387,9 +406,9 @@ class GoodsMixin: result = { "source": "PlayMallUI.queryAllocation", "sid": sid_int, - "page_code": page_code, - "source_id": source_id, - "scene": scene, + "page_code": query_options["page_code"], + "source_id": query_options["source_id"], + "scene": query_options["scene"], "goods": goods, } if not goods: @@ -413,8 +432,9 @@ class GoodsMixin: account_info: dict, config_info: dict, ): - spu_id = normalize_spu_id(self.payload.get("product_id")) - if not spu_id: + submitted_product_id = normalize_spu_id(self.payload.get("product_id")) + requested_product_name = str(self.payload.get("product_name") or "").strip() + if not submitted_product_id and not requested_product_name: self._mark_task(worker_db, task, "failed", "请选择兑换商品") return @@ -447,7 +467,13 @@ class GoodsMixin: pay_channel = self._normalize_pay_channel( self.payload.get("pay_channel") or config_info.get("pay_channel") ) - proxies, proxy_source, proxy_error = self._exchange_proxies() + exchange_use_proxy = str( + config_info.get("exchange_use_proxy") or "0" + ).strip().lower() in {"1", "true", "on", "yes"} + if exchange_use_proxy: + proxies, proxy_source, proxy_error = self._exchange_proxies() + else: + proxies, proxy_source, proxy_error = None, "disabled", "" if proxy_error: self._mark_task( worker_db, @@ -459,7 +485,35 @@ class GoodsMixin: return if proxies: self._push_log("info", f"[{uid}] 兑换任务使用代理: {proxy_source}") + elif exchange_use_proxy: + self._push_log("info", f"[{uid}] 兑换任务未使用代理: {proxy_source}") + else: + self._push_log("info", f"[{uid}] 兑换任务直连(兑换代理开关关闭)") client: Any = self._http_client(proxies=proxies) + live_goods, live_goods_error = self._resolve_live_exchange_goods( + client, + uid=uid, + cookie=cookie, + config_info=config_info, + submitted_product_id=submitted_product_id, + product_name=requested_product_name, + ) + if live_goods is None: + self._mark_task( + worker_db, + task, + "failed", + f"实时商品校验失败:{live_goods_error}", + { + "submitted_product_id": submitted_product_id, + "product_name": requested_product_name, + "proxy_used": bool(proxies), + "proxy_source": proxy_source, + }, + ) + return + spu_id = normalize_spu_id(live_goods.get("product_id")) + product_name = str(live_goods.get("name") or requested_product_name or spu_id) prepared = prepare_exchange( client, uid=uid, @@ -472,8 +526,10 @@ class GoodsMixin: ) common = { "spu_id": spu_id, + "submitted_product_id": submitted_product_id, + "resolved_product_id": spu_id, "product_name": (prepared.target.get("name") if prepared.target else "") - or str(self.payload.get("product_name") or spu_id), + or product_name, "scheduled_at": scheduled_at.isoformat() if scheduled_at else "", "executed_at": datetime.now(UTC).isoformat(), "redeem_state": prepared.state.to_dict() if prepared.state else None, diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index d0b295e..7b15424 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -177,6 +177,10 @@ export const huyaApi = { getConfig: () => api.get('/huya/config'), updateConfig: (data: Partial) => api.put('/huya/config', data), listGoods: () => api.get('/huya/goods'), + listLiveGoods: (accountId: number) => + api.get('/huya/goods/live', { + params: { account_id: accountId, _ts: Date.now() }, + }), listRechargeGoods: () => api.get('/huya/recharge-goods'), /** 工作台导入外部 CK: 入库并把账号加入自己的工作台 (text 每行一条 CK) */ importWorkbenchCookies: (data: HuyaExternalCkImportRequest) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index b3bee07..246aed6 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1251,6 +1251,8 @@ export interface HuyaConfig { points_page_id: string; /** 可选覆盖:页签 ID,逗号分隔;留空自动拉全部页签 */ points_tab_ids: string; + /** 精英宝典兑换是否使用全局代理;默认 0,仅兑换链路生效 */ + exchange_use_proxy: string; updated_at: string | null; } diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 80c95b5..3dbbe68 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { MouseEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { - Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Table, Tag, Tooltip, Typography, + Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Switch, Table, Tag, Tooltip, Typography, } from 'antd'; import type { TableProps } from 'antd'; import { @@ -27,6 +27,7 @@ const PAGE_SIZE_KEY = 'huya_elite_page_size'; const RECHARGE_SPU_KEY = 'huya_elite_recharge_spu'; const RECHARGE_COUNT_KEY = 'huya_elite_recharge_count'; const ELITE_SPU_KEY = 'huya_elite_elite_spu'; +const EXCHANGE_PROXY_ENABLED_VALUES = new Set(['1', 'true', 'on', 'yes']); // 与后端 ELITE_HANDBOOK_SPU_IDS 对齐:精英宝典单独一块,不和其他开通商品混在一个下拉里 const ELITE_HANDBOOK_SPU_IDS = ['hy-8328015', 'hy-5879340', 'hy-3845257', 'hy-2947756']; const ELITE_HANDBOOK_NAME_KEYWORD = '精英宝典'; @@ -199,6 +200,7 @@ export default function HuyaElitePage() { const [selectedIds, setSelectedIds] = useState([]); const [tasks, setTasks] = useState([]); const [goods, setGoods] = useState([]); + const [goodsLoading, setGoodsLoading] = useState(false); const [selectedGoodsId, setSelectedGoodsId] = useState(''); const [rechargeGoods, setRechargeGoods] = useState([]); const [selectedRechargeSpu, setSelectedRechargeSpu] = useState(savedRechargeSpu); @@ -239,6 +241,7 @@ export default function HuyaElitePage() { const [configSaving, setConfigSaving] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; accountIds: number[] } | null>(null); const tasksLoading = useRef(false); + const liveGoodsRequestId = useRef(0); const qrTaskFetchId = useRef(null); const qrImageCacheRef = useRef>(new Map()); const logs = useWebSocketLogs(); @@ -246,9 +249,9 @@ export default function HuyaElitePage() { const load = useCallback(async () => { setLoading(true); try { - const [poolResult, workbenchResult, goodsResult, rechargeGoodsResult, taskResult] = await Promise.all([ + const [poolResult, workbenchResult, rechargeGoodsResult, taskResult] = await Promise.all([ huyaApi.listAccounts({ include_cookie: false }), huyaApi.listWorkbenchAccounts(SCOPE), - huyaApi.listGoods(), huyaApi.listRechargeGoods(), huyaApi.listTasks(undefined, SCOPE), + huyaApi.listRechargeGoods(), huyaApi.listTasks(undefined, SCOPE), ]); setPool(poolResult); const byId = new Map(poolResult.map((item) => [item.id, item])); @@ -258,7 +261,6 @@ export default function HuyaElitePage() { } setAccounts(storedIds.map((id) => byId.get(Number(id))).filter((item): item is HuyaAccountItem => Boolean(item))); setSelectedIds((prev) => prev.filter((id) => storedIds.includes(id))); - setGoods(goodsResult); setRechargeGoods(rechargeGoodsResult); setTasks(taskResult); } catch (error) { message.error(getErrorMessage(error)); } finally { setLoading(false); } @@ -270,6 +272,36 @@ export default function HuyaElitePage() { try { setTasks(await huyaApi.listTasks(undefined, SCOPE)); } catch { /* 下一轮刷新 */ } finally { tasksLoading.current = false; } }, []); + const loadLiveGoods = useCallback(async (accountId: number | null) => { + const requestId = ++liveGoodsRequestId.current; + if (!accountId) { + setGoods([]); + setSelectedGoodsId(''); + return; + } + setGoodsLoading(true); + try { + const result = await huyaApi.listLiveGoods(accountId); + if (liveGoodsRequestId.current !== requestId) return; + setGoods(result); + setSelectedGoodsId((current) => ( + result.some((item) => item.product_id === current) + ? current + : result[0]?.product_id || '' + )); + } catch (error) { + if (liveGoodsRequestId.current !== requestId) return; + setGoods([]); + setSelectedGoodsId(''); + message.error(`实时商品加载失败:${getErrorMessage(error)}`); + } finally { + if (liveGoodsRequestId.current === requestId) setGoodsLoading(false); + } + }, []); + + const goodsAccountId = selectedIds[0] ?? accounts[0]?.id ?? null; + useEffect(() => { void loadLiveGoods(goodsAccountId); }, [goodsAccountId, loadLiveGoods]); + useEffect(() => { void load(); }, [load]); useEffect(() => { if (importOpen || ckImportOpen) void huyaApi.listTags().then(setImportTags).catch(() => {}); }, [ckImportOpen, importOpen]); useEffect(() => { @@ -622,7 +654,7 @@ export default function HuyaElitePage() {
绑定与查询
-
兑换
({ value: item.product_id, label: item.label }))} placeholder={goodsLoading ? '正在读取实时商品' : !goodsAccountId ? '请先选择账号' : '暂无实时商品'} style={{ width: '100%' }} />
开通宝典
{eliteRechargeGoods.length > 1 ? ({ value: item.spu_id, label: rechargeGoodsLabel(item) }))} placeholder="先刷新开通商品" style={{ width: '100%' }} />
数量{rechargeAmountText}{rechargeBuyLimit < RECHARGE_COUNT_MAX ? ` · 限购 ${rechargeBuyLimit} 份` : ''}
@@ -727,7 +759,7 @@ export default function HuyaElitePage() { } setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} />