feat(huya): 统一实时商品目录并增加兑换代理开关

This commit is contained in:
yml
2026-11-13 20:12:42 +08:00
parent 563b32b7b7
commit ad782e1954
23 changed files with 930 additions and 511 deletions
-4
View File
@@ -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))
-19
View File
@@ -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
):
-10
View File
@@ -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
+6 -5
View File
@@ -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/*`;开工前需确认当前分支与库迁移版本对齐 |
+27 -92
View File
@@ -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"))
+79 -3
View File
@@ -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()
+143
View File
@@ -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"
+31 -7
View File
@@ -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 个礼包",
)
),
)
+3
View File
@@ -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)
@@ -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)
+7
View File
@@ -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
)
+2
View File
@@ -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",
+3
View File
@@ -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,
)
+2 -1
View File
@@ -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
+69 -1
View File
@@ -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),
+4
View File
@@ -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):
+11 -263
View File
@@ -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,
+332
View File
@@ -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})",
)
+13 -47
View File
@@ -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,
+109 -53
View File
@@ -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,
+4
View File
@@ -177,6 +177,10 @@ export const huyaApi = {
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
listLiveGoods: (accountId: number) =>
api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods/live', {
params: { account_id: accountId, _ts: Date.now() },
}),
listRechargeGoods: () => api.get<HuyaRechargeGoodsItem[], HuyaRechargeGoodsItem[]>('/huya/recharge-goods'),
/** 工作台导入外部 CK: 入库并把账号加入自己的工作台 (text 每行一条 CK) */
importWorkbenchCookies: (data: HuyaExternalCkImportRequest) =>
+2
View File
@@ -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;
}
+38 -6
View File
@@ -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<number[]>([]);
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
const [goodsLoading, setGoodsLoading] = useState(false);
const [selectedGoodsId, setSelectedGoodsId] = useState('');
const [rechargeGoods, setRechargeGoods] = useState<HuyaRechargeGoodsItem[]>([]);
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<number | null>(null);
const qrImageCacheRef = useRef<Map<number, string>>(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() {
<div className="huya-operation-section"><div className="huya-operation-title"><QrcodeOutlined /><span>绑定与查询</span></div><div className="huya-action-grid">
<Button size="small" type="primary" icon={<SearchOutlined />} onClick={() => void startTask('query_all')} disabled={starting || !selectedIds.length}>一键查询</Button><Button size="small" icon={<QrcodeOutlined />} onClick={() => void startTask('get_bind_qr')} disabled={starting || !selectedIds.length}>绑定二维码</Button><Button size="small" icon={<SearchOutlined />} onClick={() => void startTask('query_game_name')} disabled={starting || !selectedIds.length}>查询角色</Button><Button size="small" icon={<CheckCircleOutlined />} onClick={() => void startTask('confirm_bind')} disabled={starting || !selectedIds.length}>确认绑定</Button><Button size="small" icon={<SearchOutlined />} onClick={() => void startTask('query_points')} disabled={starting || !selectedIds.length}>刷新积分</Button><Button size="small" onClick={() => void startTask('query_act_tasks')} disabled={starting || !selectedIds.length}>宝典任务</Button><Button size="small" onClick={() => void startTask('query_exchange_records')} disabled={starting || !selectedIds.length}>兑换记录</Button>
</div></div>
<div className="huya-operation-section"><div className="huya-operation-title"><ShoppingOutlined /><span>兑换</span></div><Select size="small" showSearch optionFilterProp="label" value={selectedGoodsId || undefined} onChange={setSelectedGoodsId} options={filteredGoods.map((item) => ({ value: item.product_id, label: item.label }))} placeholder="选择兑换商品" style={{ width: '100%' }} /><div className="huya-action-grid"><Button size="small" icon={<ReloadOutlined />} onClick={() => void startTask('refresh_goods')} disabled={starting || !selectedIds.length}>刷新商品</Button><Button size="small" type="primary" icon={<ShoppingOutlined />} onClick={() => void startTask('exchange_goods')} disabled={starting || !selectedIds.length || !selectedGoodsId}>兑换商品</Button></div></div>
<div className="huya-operation-section"><div className="huya-operation-title"><ShoppingOutlined /><span>兑换</span></div><Select size="small" showSearch optionFilterProp="label" loading={goodsLoading} disabled={!goodsAccountId || goodsLoading || !goods.length} value={selectedGoodsId || undefined} onChange={setSelectedGoodsId} options={filteredGoods.map((item) => ({ value: item.product_id, label: item.label }))} placeholder={goodsLoading ? '正在读取实时商品' : !goodsAccountId ? '请先选择账号' : '暂无实时商品'} style={{ width: '100%' }} /><div className="huya-action-grid"><Button size="small" icon={<ReloadOutlined />} loading={goodsLoading} onClick={() => void loadLiveGoods(goodsAccountId)} disabled={starting || !goodsAccountId || goodsLoading}>刷新商品</Button><Button size="small" type="primary" icon={<ShoppingOutlined />} onClick={() => void startTask('exchange_goods')} disabled={starting || !selectedIds.length || !selectedGoodsId || goodsLoading}>兑换商品</Button></div></div>
<div className="huya-operation-section"><div className="huya-operation-title"><GiftOutlined /><span>开通宝典</span></div>{eliteRechargeGoods.length > 1 ? <Select size="small" showSearch optionFilterProp="label" value={selectedEliteGoods?.spu_id} onChange={selectEliteGoods} options={eliteRechargeGoods.map((item) => ({ value: item.spu_id, label: rechargeGoodsLabel(item) }))} style={{ width: '100%' }} /> : <Text style={{ fontSize: 12 }} ellipsis>{selectedEliteGoods ? `${rechargeGoodsLabel(selectedEliteGoods)}${rechargeEliteLimit < RECHARGE_COUNT_MAX ? ` · 限购 ${rechargeEliteLimit} 份` : ''}` : '列表里还没有精英宝典,请先刷新开通商品'}</Text>}<Button block size="small" type="primary" icon={<QrcodeOutlined />} onClick={() => { if (selectedEliteGoods) void startTask('create_recharge_order', selectedIds, { spu: selectedEliteGoods.spu_id, count: 1 }); }} disabled={starting || !selectedIds.length || !selectedEliteGoods} style={{ marginTop: 6 }}>确认开通宝典并生成支付码</Button></div>
<div className="huya-operation-section"><div className="huya-operation-title"><ShoppingOutlined /><span>其他开通商品</span></div><Select size="small" showSearch optionFilterProp="label" value={selectedRechargeSpu || undefined} onChange={selectRechargeGoods} options={otherRechargeGoods.map((item) => ({ value: item.spu_id, label: rechargeGoodsLabel(item) }))} placeholder="先刷新开通商品" style={{ width: '100%' }} /><div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}><Text type="secondary" style={{ fontSize: 12 }}>数量</Text><InputNumber size="small" min={1} max={rechargeBuyLimit} value={rechargeCountValue} onChange={changeRechargeCount} style={{ width: 80 }} /><Text type="secondary" style={{ fontSize: 12 }}>{rechargeAmountText}{rechargeBuyLimit < RECHARGE_COUNT_MAX ? ` · 限购 ${rechargeBuyLimit} 份` : ''}</Text></div><Button block size="small" type="primary" icon={<QrcodeOutlined />} onClick={() => void startTask('create_recharge_order')} disabled={starting || !selectedIds.length || !selectedRechargeSpu} style={{ marginTop: 6 }}>{rechargeCountValue > 1 ? `确认充值并生成支付码 ×${rechargeCountValue}` : '确认充值并生成支付码'}</Button><Button block size="small" icon={<ReloadOutlined />} onClick={() => void startTask('refresh_recharge_goods')} disabled={starting || !selectedIds.length} style={{ marginTop: 6 }}>刷新开通商品</Button></div>
<RealtimeLogPanel logs={logs.logs} connected={logs.connected} title="实时日志" emptyText="暂无任务日志" height={150} collapsible defaultVisible={false} />
@@ -727,7 +759,7 @@ export default function HuyaElitePage() {
</div>}
</Modal>
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => 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="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={560}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}{configField('points_page_code', '积分中心 pageCode')}{configField('points_source_id', '积分中心 sourceId')}{configField('points_scene', '积分中心 scene')}{configField('points_page_id', '页签 pageId(可选)')}{configField('points_tab_ids', '页签 IDs(可选)')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={560}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}{configField('points_page_code', '积分中心 pageCode')}{configField('points_source_id', '积分中心 sourceId')}{configField('points_scene', '积分中心 scene')}{configField('points_page_id', '页签 pageId(可选)')}{configField('points_tab_ids', '页签 IDs(可选)')}<Space style={{ width: '100%', justifyContent: 'space-between' }} align="center"><div><Text>兑换使用代理</Text><br /><Text type="secondary">默认关闭;开启后兑换链路使用全局代理配置</Text></div><Switch checked={EXCHANGE_PROXY_ENABLED_VALUES.has(String(configDraft.exchange_use_proxy || '0').toLowerCase())} onChange={(checked) => setConfigDraft({ ...configDraft, exchange_use_proxy: checked ? '1' : '0' })} /></Space><Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
</div>;
}