diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py new file mode 100644 index 0000000..d3f35ac --- /dev/null +++ b/tests/test_huya_elite_query.py @@ -0,0 +1,379 @@ +"""虎牙精英宝典开通状态与「一键查询」(2026-10-15 真机实测)。 + +判定来源与真机结论: + +* 宝典开通状态 = 积分中心 ``shopMiddleUI.getRedeemNeedGoods`` 的 ``tag5``。 + 下面固化的两段响应字节分别来自已开通账号(hy_302016799)与未开通账号 + (hy_260034554),与网页显示的「已开通精英宝典 / 获取积分需先开通精英宝典」 + 一致。 +* 开通时间 = ``revenueWebUI.queryUserOrderList`` 里已支付的宝典订单。 +""" + +import json + +from core.huya.frame_decoder import decode_struct_bytes +from core.huya.http_client import HuyaHttpClient +from core.huya.shop_structs import ShopRawRsp +from web.backend.models import HuyaAccount +from web.backend.services.huya_runner import HuyaBatchRunner + +UID = 1199668595203 +COOKIE = f"yyuid={UID}; udb_uid={UID}" +# 真机 getRedeemNeedGoods 响应:已开通(tag5=1、tag6=1、tag7 为空) +REDEEM_OPENED_HEX = "0a0100c8160ce69fa5e8afa2e68890e58a9f21011830144c50016001790c0b" +# 真机 getRedeemNeedGoods 响应:未开通(tag5=0、tag6=0、tag7 列出开通套餐) +REDEEM_NOT_OPENED_HEX = ( + "0a0100c8160ce69fa5e8afa2e68890e58a9f2c301440145c6c7900040a02007f134f1200" + "765c0b260ce7b2bee88bb1e5ae9de585b830054101fb51012c610bb87c9001a90cb90cca010" + "bb8110bb82c310bb8490c5c610bb8790c8c9cacbcccdc0bf00f010b0a02007ede691200766" + "17f2616e5bc80e9809ae5ae9de585b82be7b2bee88bb1e5b88130054101fc51012d610bc2" + "70018900020a02007ede6912007f134f2200765c0b310bb841012c5001660ce7b2bee88bb1" + "e5ae9de585b87c0b0a02007ede6912007f10bc220076662f300a400150016609e7b2bee88b" + "b1e5b88170010b9ca90cb90cca010bc2110bc22c310bc2490c5c610bc2790c8c9cacbcccdc0b" + "fc0f0b" +) +# 真机订单列表里已支付的宝典订单(pay_time 与 spu 均来自真机响应) +PAID_ELITE_ORDER = { + "item_name": "精英宝典", + "order_status": 60, + "pay_time": 1791895396000, + "goods_detail": {"spu_id": "hy-8328015", "sku_id": 7756811}, +} +UNPAID_ELITE_ORDER = { + "item_name": "精英宝典", + "order_status": 10, + "pay_time": 0, + "goods_detail": {"spu_id": "hy-8328015", "sku_id": 7756811}, +} +OTHER_ORDER = { + "item_name": "电竞徽章", + "order_status": 60, + "pay_time": 1788000000000, + "goods_detail": {"spu_id": "hy-7000000", "sku_id": 1}, +} +# 真机 queryAllocation 响应里的赛季节点(精英宝典福利季丨Q4:2026-10-09 ~ 2026-12-31) +ALLOCATION_SEASON_PAYLOAD = { + "tag0": {"tag0": 200, "tag1": "成功", "tag2": ""}, + "tag1": { + "tag0": "和平精英宝典积分中心", + "tag1": "heping_baodian_credit_center_page", + }, + "tag2": [ + {"tag0": 343, "tag4": 6}, + { + "tag0": 1, + "tag2_2": { + "tag0": { + "tag0": 15, + "tag1": "精英宝典福利季", + "tag2": "精英宝典福利季丨Q4", + "tag3": 3203, + "tag5": 17096, + "tag6": 9815, + "tag10": 1791475200000, + "tag11": 1798732799000, + }, + "tag4": {"tag0": 300}, + }, + }, + ], +} + + +def _redeem(hex_text: str) -> ShopRawRsp: + return ShopRawRsp.from_payload(decode_struct_bytes(bytes.fromhex(hex_text))) + + +class _FakeOrder: + def __init__(self, payload: dict): + self._payload = payload + + def to_dict(self) -> dict: + return dict(self._payload) + + +class _FakeOrderList: + def __init__(self, orders: list[dict], total_count: int | None = None): + self.orders = [_FakeOrder(item) for item in orders] + self.totalCount = len(orders) if total_count is None else total_count + + +class _FakeBindStatus: + """只喂给 EliteMixin 用到的那几个字段(角色解析由 BindMixin 自己的用例覆盖)。""" + + def __init__(self, status: int = 200, msg: str = ""): + self.status = status + self.msg = msg + + def to_dict(self) -> dict: + return {"status": self.status, "msg": self.msg} + + +class _StubClient: + """积分中心通道的假客户端:可返回宝典状态/订单列表/赛季配置,并记录调用。""" + + def __init__(self, redeem=None, order_pages=None, season: bool = False): + self.redeem = redeem + self.order_pages = order_pages or [] + self.season_payload = ALLOCATION_SEASON_PAYLOAD if season else None + self.calls: list[tuple[str, dict]] = [] + + def get_redeem_need_goods(self, **kwargs): + self.calls.append(("get_redeem_need_goods", kwargs)) + return self.redeem + + def play_mall_query_allocation(self, **kwargs): + self.calls.append(("play_mall_query_allocation", kwargs)) + if self.season_payload is None: + return None + return ShopRawRsp.from_payload(self.season_payload) + + def query_user_order_list(self, **kwargs): + self.calls.append(("query_user_order_list", kwargs)) + offset = int(kwargs.get("offset") or 0) + index = offset // 20 + if index >= len(self.order_pages): + return None + page = self.order_pages[index] + return _FakeOrderList(page, total_count=sum(len(p) for p in self.order_pages)) + + +def _runner(monkeypatch, shop_client, activity_client=None): + runner = HuyaBatchRunner(db=None, batch_id="test-batch", task_type="query_all") + marks: list[tuple[str, str, dict | None]] = [] + logs: list[tuple[str, str]] = [] + monkeypatch.setattr( + runner, "_shop_client", lambda uid, cookie: shop_client, raising=False + ) + if activity_client is not None: + monkeypatch.setattr( + runner, + "_activity_client", + lambda uid, cookie: activity_client, + raising=False, + ) + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + raising=False, + ) + monkeypatch.setattr( + runner, + "_push_log", + lambda level, message: logs.append((level, message)), + raising=False, + ) + return runner, marks, logs + + +def test_real_redeem_bytes_report_elite_opened(): + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all") + + assert runner._elite_opened_from_redeem(_redeem(REDEEM_OPENED_HEX)) is True + assert runner._elite_opened_from_redeem(_redeem(REDEEM_NOT_OPENED_HEX)) is False + # 不是 200 或没有 tag5 时不下结论(界面显示「未查询」而不是猜一个值) + assert runner._elite_opened_from_redeem(None) is None + assert ( + runner._elite_opened_from_redeem(ShopRawRsp.from_payload({"tag0": 500})) is None + ) + + +def test_elite_open_time_uses_latest_paid_handbook_order(): + client = _StubClient( + order_pages=[[OTHER_ORDER, UNPAID_ELITE_ORDER, PAID_ELITE_ORDER]] + ) + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all") + + assert runner._resolve_elite_open_time(client, UID, COOKIE) == 1791895396000 + + +def test_elite_open_time_ignores_unpaid_handbook_order(): + """未支付的宝典订单不算开通时间(真机:order_status=10、pay_time=0)。""" + client = _StubClient(order_pages=[[UNPAID_ELITE_ORDER]]) + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all") + + assert runner._resolve_elite_open_time(client, UID, COOKIE) is None + + +def test_elite_open_time_scans_following_pages(): + later_order = {**PAID_ELITE_ORDER, "pay_time": 1792000000000} + client = _StubClient(order_pages=[[OTHER_ORDER] * 20, [later_order]]) + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all") + + assert runner._resolve_elite_open_time(client, UID, COOKIE) == 1792000000000 + offsets = [ + kwargs.get("offset") + for name, kwargs in client.calls + if name == "query_user_order_list" + ] + assert offsets == [0, 20] + + +def test_extract_elite_season_from_allocation_payload(): + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all") + + season = runner._extract_elite_season(ALLOCATION_SEASON_PAYLOAD) + + assert season == { + "name": "精英宝典福利季丨Q4", + "start_ms": 1791475200000, + "end_ms": 1798732799000, + "config_id": 15, + } + # 认不出赛季结构时不下结论(界面就不显示有效期) + assert runner._extract_elite_season({"tag0": {"tag1": "x"}}) is None + assert runner._extract_elite_season(None) is None + + +def test_query_all_fills_expire_time_from_season(monkeypatch): + shop_client = _StubClient( + redeem=_redeem(REDEEM_OPENED_HEX), + order_pages=[[PAID_ELITE_ORDER]], + season=True, + ) + runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) + monkeypatch.setattr( + runner, "_resolve_bind_status", lambda **kwargs: (None, {}), raising=False + ) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_query_all( + None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"} + ) + + status, message, result = marks[-1] + assert status == "success" + assert result["elite_expire_time"] == 1798732799 + assert result["elite_season"]["name"] == "精英宝典福利季丨Q4" + assert account.elite_expire_time == 1798732799 + assert "有效期至 2026-12-31" in message + + +def test_query_all_combines_points_elite_and_role(monkeypatch): + shop_client = _StubClient( + redeem=_redeem(REDEEM_OPENED_HEX), order_pages=[[PAID_ELITE_ORDER]] + ) + runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) + monkeypatch.setattr( + runner, + "_resolve_bind_status", + lambda **kwargs: (_FakeBindStatus(), {"bind_status_source": "outer"}), + raising=False, + ) + monkeypatch.setattr( + runner, + "_bind_role_result", + staticmethod( + lambda _status: {"role_name": "软踏日潺湲", "game_title": "和平精英"} + ), + raising=False, + ) + monkeypatch.setattr( + runner, + "_bind_change_state", + staticmethod(lambda _status: {"is_bound": True}), + raising=False, + ) + monkeypatch.setattr( + runner, + "_role_channel", + staticmethod(lambda _status: "苹果(iOS) / 手Q"), + raising=False, + ) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_query_all( + None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"} + ) + + status, message, result = marks[-1] + assert status == "success" + assert result["points"] == 280 + assert result["elite_opened"] is True + assert result["elite_open_time"] == 1791895396 + assert result["role_name"] == "软踏日潺湲" + assert result["failures"] == [] + assert account.points == 280 + assert account.elite_opened is True + assert account.elite_open_time == 1791895396 + assert account.game_name == "软踏日潺湲" + assert account.game_channel == "苹果(iOS) / 手Q" + assert account.status == "all_queried" + assert ( + "积分 280" in message + and "已开通宝典" in message + and "角色 软踏日潺湲" in message + ) + + +def test_query_all_reports_not_opened_without_order_lookup(monkeypatch): + shop_client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX)) + runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) + monkeypatch.setattr( + runner, "_resolve_bind_status", lambda **kwargs: (None, {}), raising=False + ) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_query_all( + None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"} + ) + + status, message, result = marks[-1] + # 角色接口挂了不影响宝典/积分结论 + assert status == "success" + assert result["elite_opened"] is False + assert account.elite_opened is False + assert account.elite_open_time is None + assert "未开通宝典" in message + assert "角色信息接口无响应" in message + # 未开通不查订单(省一次请求) + assert [name for name, _kwargs in shop_client.calls] == ["get_redeem_need_goods"] + + +def test_query_all_fails_when_points_center_unreachable(monkeypatch): + shop_client = _StubClient(redeem=None) + runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) + monkeypatch.setattr( + runner, "_resolve_bind_status", lambda **kwargs: (None, {}), raising=False + ) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_query_all( + None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"} + ) + + status, message, _result = marks[-1] + assert status == "failed" + assert "积分中心接口无响应" in message + + +def test_shop_client_falls_back_to_http_when_wss_init_fails(monkeypatch): + """设备态齐全但积分中心端点不回包时(wsLaunch 超时)回退 HTTP,而不是判整批失败。""" + cookie = ( + "udb_cred=cred; udb_biztoken=biz; udb_uid=1199659425268; yyuid=1199659425268; " + "sdid=sdid; udb_deviceid=w_1234567890123456789; udb_guiddata=guiddata; " + "udb_anobiztoken=anobiz" + ) + logs: list[tuple[str, str]] = [] + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all") + + def boom(uid: int, cookie_text: str, kind: str): + raise RuntimeError(f"虎牙 {kind} WSS 初始化失败") + + monkeypatch.setattr(runner, "_open_wss_session", boom, raising=False) + monkeypatch.setattr( + runner, + "_push_log", + lambda level, message: logs.append((level, message)), + raising=False, + ) + + client = runner._shop_client(1199659425268, cookie) + + assert isinstance(client, HuyaHttpClient) + assert logs and logs[-1][0] == "warning" + assert "WSS 会话不可用" in logs[-1][1] + assert "sdid" not in json.dumps(logs[-1][1]) # 日志不带 Cookie 值 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index e350d78..e63337d 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261008_0052" +HEAD_REVISION = "20261015_0053" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20261015_0053_huya_elite_open.py b/web/backend/migrations/versions/20261015_0053_huya_elite_open.py new file mode 100644 index 0000000..8970dff --- /dev/null +++ b/web/backend/migrations/versions/20261015_0053_huya_elite_open.py @@ -0,0 +1,79 @@ +"""虎牙精英宝典开通状态:`huya_accounts` 新增 elite_opened / elite_open_time / elite_expire_time + +工作台列表要能直接看到「这个号开没开宝典、什么时候开的、有效期到什么时候」, +但现在只有一个 `points` 列:开通后把积分花完和从没开通,在界面上长得一模一样。 + +真机(2026-10-15 两份抓包 + 真机对照网页)核对的来源: + +* 开通状态 —— 积分中心 ``shopMiddleUI.getRedeemNeedGoods`` 的 ``tag5``: + ``1`` = 已开通(网页显示「已开通精英宝典」),``0`` = 未开通(网页显示 + 「获取积分需先开通精英宝典」,``tag7`` 还会列出开通套餐)。 +* 开通时间 —— ``revenueWebUI.queryUserOrderList`` 里已支付的宝典订单,取最近一次。 +* 有效期 —— ``PlayMallUI.queryAllocation`` 响应里的赛季节点(``tag10``/``tag11`` + 是本赛季起止毫秒时间戳):2026-10-15 抓包为「精英宝典福利季丨Q4」, + 即 2026-10-09 ~ 2026-12-31 23:59:59;认不出赛季结构时留空。 + +两列时间都存 **Unix 秒**(Integer 放不下毫秒时间戳,且与斗鱼 `elite_expire_time` +同一约定)。 + +历史值不回填:以前没查过宝典状态的账号是 NULL(界面显示「未查询」), +下次「一键查询」会补上 —— 不回填才不会把「没查过」伪装成「未开通」。 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261015_0053" +down_revision: str | None = "20261008_0052" +branch_labels: str | None = None +depends_on: str | None = None + +TABLE = "huya_accounts" +COLUMNS = ( + ( + "elite_opened", + sa.Boolean(), + "精英宝典是否已开通(积分中心 getRedeemNeedGoods 的 tag5;NULL=未查询)", + ), + ( + "elite_open_time", + sa.Integer(), + "最近一次宝典订单的支付时间(Unix 秒)", + ), + ( + "elite_expire_time", + sa.Integer(), + "宝典有效期(Unix 秒):积分中心本赛季结束时间;认不出赛季时留空", + ), +) + + +def _column_exists(name: str) -> bool: + """可安全重跑(应用启动时会自动跑迁移)。""" + conn = op.get_bind() + count = conn.execute( + sa.text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() " + "AND table_name = :table AND column_name = :name" + ), + {"table": TABLE, "name": name}, + ).scalar() + return bool(count) + + +def upgrade() -> None: + for name, column_type, comment in COLUMNS: + if not _column_exists(name): + op.add_column( + TABLE, + sa.Column(name, column_type, nullable=True, comment=comment), + ) + + +def downgrade() -> None: + for name, _column_type, _comment in COLUMNS: + if _column_exists(name): + op.drop_column(TABLE, name) diff --git a/web/backend/models.py b/web/backend/models.py index 16b5687..7a5ed7d 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -539,6 +539,13 @@ class HuyaAccount(Base): game_name: Mapped[str] = mapped_column(String(128), default="") game_channel: Mapped[str] = mapped_column(String(64), default="") game_phone: Mapped[str] = mapped_column(String(64), default="") + # 精英宝典开通状态(积分中心 getRedeemNeedGoods 的 tag5;NULL = 还没查过)。 + # 光看积分区分不了「开通后花完」和「从没开通」,必须单独落库。 + elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + # 最近一次宝典订单的支付时间(Unix 秒;与斗鱼 elite_expire_time 同一约定)。 + elite_open_time: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 宝典有效期(Unix 秒):取积分中心页面配置里本赛季的结束时间(福利季 Q4 → 12-31)。 + elite_expire_time: Mapped[int | None] = mapped_column(Integer, nullable=True) sms_url: Mapped[str] = mapped_column(Text, default="") assigned_to: Mapped[int | None] = mapped_column( ForeignKey("users.id"), nullable=True, index=True diff --git a/web/backend/routers/huya/_common.py b/web/backend/routers/huya/_common.py index b58d1c0..7f3dc19 100644 --- a/web/backend/routers/huya/_common.py +++ b/web/backend/routers/huya/_common.py @@ -351,6 +351,9 @@ def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccou game_name=account.game_name or "", game_channel=account.game_channel or "", game_phone=account.game_phone or "", + elite_opened=account.elite_opened, + elite_open_time=account.elite_open_time, + elite_expire_time=account.elite_expire_time, sms_url=account.sms_url or "", assigned_to=account.assigned_to, assigned_username=account.assigned_user.username @@ -383,6 +386,9 @@ def _account_out_light( game_name=account.game_name or "", game_channel=account.game_channel or "", game_phone=account.game_phone or "", + elite_opened=account.elite_opened, + elite_open_time=account.elite_open_time, + elite_expire_time=account.elite_expire_time, sms_url=account.sms_url or "", assigned_to=account.assigned_to, assigned_username=account.assigned_user.username diff --git a/web/backend/schemas.py b/web/backend/schemas.py index b7ca787..d488da8 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -580,6 +580,10 @@ class HuyaAccountOut(BaseModel): game_name: str = "" game_channel: str = "" game_phone: str = "" + # 精英宝典开通状态(NULL = 未查询);有效期接口没给时为空 + elite_opened: bool | None = None + elite_open_time: int | None = None + elite_expire_time: int | None = None sms_url: str = "" assigned_to: int | None = None assigned_username: str | None = None @@ -608,6 +612,9 @@ class HuyaAccountOut(BaseModel): "game_name": self.game_name, "game_channel": self.game_channel, "game_phone": self.game_phone, + "elite_opened": self.elite_opened, + "elite_open_time": self.elite_open_time, + "elite_expire_time": self.elite_expire_time, "sms_url": self.sms_url, "assigned_to": self.assigned_to, "assigned_username": self.assigned_username, diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index 64c187a..6a4321f 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -16,6 +16,7 @@ from .huya_runner_core import ( # noqa: F401 (huya_batch_registry 供 routers HuyaBatchRunnerCore, huya_batch_registry, ) +from .huya_runner_elite import EliteMixin from .huya_runner_goods import GoodsMixin from .huya_runner_recharge import RechargeMixin from .huya_service import HUYA_CONFIG_FIELDS, ensure_huya_config, huya_config_value @@ -26,6 +27,7 @@ class HuyaBatchRunner( BindMixin, GoodsMixin, RechargeMixin, + EliteMixin, ): """批量执行虎牙任务(功能域 Mixin 聚合 + 批次调度)。""" @@ -64,6 +66,7 @@ class HuyaBatchRunner( if self.task_type not in { "query_act_tasks", "query_points", + "query_all", "get_bind_qr", "confirm_bind", "query_game_name", @@ -88,6 +91,10 @@ class HuyaBatchRunner( self._execute_query_points( worker_db, task, account, account_info, config_info ) + elif self.task_type == "query_all": + self._execute_query_all( + worker_db, task, account, account_info, config_info + ) elif self.task_type == "refresh_goods": self._execute_refresh_goods( worker_db, task, account, account_info, config_info diff --git a/web/backend/services/huya_runner_core.py b/web/backend/services/huya_runner_core.py index 847ab4f..e7311ac 100644 --- a/web/backend/services/huya_runner_core.py +++ b/web/backend/services/huya_runner_core.py @@ -108,10 +108,28 @@ class HuyaBatchRunnerCore: f"[{uid}] Cookie 缺少网页设备态({', '.join(missing)}),改用 HTTP cdnws 通道", ) return self._http_client() - session = self._open_wss_session(uid, cookie, "activity") + session = self._open_wss_or_http(uid, cookie, "activity") + if session is None: + return self._http_client() self._push_log("info", f"[{uid}] 活动链路使用 WSS 会话") return session + def _open_wss_or_http(self, uid: int, cookie: str, kind: str): + """建 WSS 会话;服务端不理会握手(wsLaunch 超时)时返回 None 让调用方回退 HTTP。 + + 2026-10-15 实测:设备态字段齐全的账号也可能被积分中心端点静默丢弃 + (wsLaunch 无响应),而同一账号走 HTTP cdnws 一切正常 —— 会话建不起来时 + 直接回退,而不是把整批任务判失败。 + """ + try: + return self._open_wss_session(uid, cookie, kind) + except Exception as exc: # noqa: BLE001 - 任何建连/初始化异常都回退 HTTP + self._push_log( + "warning", + f"[{uid}] {kind} WSS 会话不可用({type(exc).__name__}: {exc}),改用 HTTP cdnws 通道", + ) + return None + def _http_client(self): """HTTP cdnws 通道客户端(无需网页设备态,方法签名与 WSS 会话保持一致)。""" from core.huya.http_client import HuyaHttpClient @@ -153,7 +171,9 @@ class HuyaBatchRunnerCore: f"[{uid}] Cookie 缺少网页设备态({', '.join(missing)}),改用 HTTP cdnws 通道", ) return self._http_client() - session = self._open_wss_session(uid, cookie, "shop") + session = self._open_wss_or_http(uid, cookie, "shop") + if session is None: + return self._http_client() self._push_log("info", f"[{uid}] 商城链路使用 WSS 会话") return session diff --git a/web/backend/services/huya_runner_elite.py b/web/backend/services/huya_runner_elite.py new file mode 100644 index 0000000..48e9b49 --- /dev/null +++ b/web/backend/services/huya_runner_elite.py @@ -0,0 +1,336 @@ +"""虎牙任务执行器:精英宝典开通状态与「一键查询」(由 huya_runner.py 按功能域拆分)。 + +真机(2026-10-15)核对过的判定来源: + +* 宝典开通状态 —— 积分中心 ``shopMiddleUI.getRedeemNeedGoods`` 的 ``tag5``: + ``1`` = 已开通(网页显示「已开通精英宝典」),``0`` = 未开通(网页显示 + 「获取积分需先开通精英宝典」,``tag7`` 还会列出开通套餐)。 + 两个已开通账号(hy_297832664 / hy_302016799)与两个未开通账号 + (hy_260034554 / hy_274193170)实测与网页一致。 +* 开通时间 —— ``revenueWebUI.queryUserOrderList`` 里已支付的宝典订单,取最近一次支付时间。 +* 有效期 —— 商城订单(orderDetailV5)与积分中心都**不返回**到期时间,所以留空, + 界面上「有就显示」。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from sqlalchemy.orm import Session + +from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM + +from ..models import HuyaAccount, HuyaTask + +# 宝典订单识别:spu 白名单 + 订单名兜底(官方换过多次 SKU,历史订单名带赛季, +# 例如「精英宝典⎜得300积分⎜26年Q2」)。 +ELITE_HANDBOOK_SPU_IDS = frozenset( + {"hy-8328015", "hy-5879340", "hy-3845257", "hy-2947756"} +) +ELITE_HANDBOOK_NAME_KEYWORD = "精英宝典" +ELITE_ORDER_PAGE_SIZE = 20 +ELITE_ORDER_MAX_PAGES = 3 +# 赛季(福利季)节点的毫秒时间戳下限:2001-09 之后的值才认 +ELITE_SEASON_MS_FLOOR = 1_000_000_000_000 + + +class EliteMixin: + """精英宝典域:开通状态判定与一键查询。""" + + if TYPE_CHECKING: + payload: dict + + @staticmethod + def _to_int(value: Any) -> int: ... + + @staticmethod + def _format_local_time(timestamp: int) -> str: ... + + def _resolve_uid(self, account_info: dict) -> int: ... + def _shop_client(self, uid: int, cookie: str): ... + def _activity_client(self, uid: int, cookie: str): ... + def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... + + # 来自 BindMixin + @staticmethod + def _bind_role_result(bind_status) -> dict: ... + @staticmethod + def _bind_change_state(bind_status) -> dict: ... + @staticmethod + def _role_channel(bind_status) -> str: ... + def _resolve_bind_status( + self, client: Any, uid: int, cookie: str, b_act_id_int: int + ): ... + + @staticmethod + def _elite_opened_from_redeem(redeem: Any) -> bool | None: + """``getRedeemNeedGoods`` 的 tag5 → 开通状态;拿不准时返回 None(不猜)。""" + if redeem is None or getattr(redeem, "code", 0) != 200: + return None + flag = redeem.value("tag5") + if isinstance(flag, bool) or not isinstance(flag, int): + return None + return flag == 1 + + @staticmethod + def _is_elite_order(item: dict) -> bool: + detail = item.get("goods_detail") or {} + if str(detail.get("spu_id") or "") in ELITE_HANDBOOK_SPU_IDS: + return True + return ELITE_HANDBOOK_NAME_KEYWORD in str(item.get("item_name") or "") + + def _resolve_elite_open_time( + self, client: Any, uid: int, cookie: str + ) -> int | None: + """最近一次已支付宝典订单的时间(Unix 毫秒);查不到返回 None。 + + 订单列表按时间倒序分页返回,宝典订单可能落在后面,所以最多翻 + ``ELITE_ORDER_MAX_PAGES`` 页;中途任何一次失败都按「没查到」处理。 + 未支付订单(真机:order_status=10)没有 pay_time,自然不参与。 + """ + latest = 0 + offset = 0 + for _page in range(ELITE_ORDER_MAX_PAGES): + resp = client.query_user_order_list( + uid=uid, + guid="", + cookie=cookie, + offset=offset, + page_size=ELITE_ORDER_PAGE_SIZE, + order_type=1, + status=0, + ) + if resp is None: + break + orders = list(getattr(resp, "orders", None) or []) + for order in orders: + item = order.to_dict() if hasattr(order, "to_dict") else dict(order) + if not self._is_elite_order(item): + continue + pay_time = int(item.get("pay_time") or 0) + if pay_time > 0: + latest = max(latest, pay_time) + total = self._to_int(getattr(resp, "totalCount", 0)) + if len(orders) < ELITE_ORDER_PAGE_SIZE or offset + len(orders) >= total: + break + offset += len(orders) + return latest or None + + @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"]) + + 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)) + + def _apply_elite_state( + self, + account: HuyaAccount, + opened: bool | None, + open_time: int | None, + expire_time: int | None = None, + ) -> None: + """把宝典状态写到账号上;``opened`` 为 None(没查到)时不动旧值。 + + 库里存 **Unix 秒**(Integer 放不下毫秒级时间戳,且与斗鱼一致), + 方法内部一路用的是毫秒(协议原值),落库时统一除 1000。 + """ + if opened is None: + return + account.elite_opened = opened + account.elite_open_time = ( + (int(open_time) // 1000 if open_time else None) if opened else None + ) + account.elite_expire_time = ( + (int(expire_time) // 1000 if expire_time else None) if opened else None + ) + + def _execute_query_all( + self, + worker_db: Session, + task: HuyaTask, + account: HuyaAccount, + account_info: dict, + config_info: dict, + ): + """一键查询:角色绑定 + 精英宝典开通 + 积分(勾选账号批量跑)。""" + uid = self._resolve_uid(account_info) + if not uid: + self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid") + return + + cookie = account_info.get("cookie") or "" + if not cookie: + self._mark_task(worker_db, task, "failed", "账号 Cookie 为空") + return + + result: dict[str, Any] = {"uid": uid} + failures: list[str] = [] + message_parts: list[str] = [] + + # ---- 积分 + 宝典开通(积分中心同一个接口) ---- + shop_client: Any = self._shop_client(uid, cookie) + goods_num = ( + self._to_int(self.payload.get("goods_num")) or POINTS_REDEEM_GOODS_NUM + ) + redeem = shop_client.get_redeem_need_goods( + uid=uid, guid="", cookie=cookie, goods_num=goods_num + ) + points: int | None = None + opened: bool | None = None + if redeem is None: + failures.append("积分中心接口无响应") + elif redeem.code != 200: + failures.append(redeem.message or f"积分查询失败: {redeem.code}") + else: + points = self._to_int(redeem.value("tag2")) + opened = self._elite_opened_from_redeem(redeem) + result.update( + { + "points": points, + "redeem_threshold": self._to_int(redeem.value("tag3")), + "goods_num": goods_num, + "redeem": redeem.to_dict(), + } + ) + message_parts.append(f"积分 {points}") + + open_time: int | None = None + if opened: + # 已开通才去翻订单:既省一次请求,也避免给未开通账号显示旧订单时间 + open_time = self._resolve_elite_open_time(shop_client, uid, cookie) + season = ( + self._resolve_elite_season(shop_client, uid, cookie) if opened else None + ) + expire_time = self._to_int(season["end_ms"]) if season else None + if opened is not None: + self._apply_elite_state(account, opened, open_time, expire_time) + result["elite_opened"] = opened + result["elite_open_time"] = (open_time // 1000) if open_time else None + result["elite_open_time_text"] = ( + self._format_local_time(open_time // 1000) if open_time else "" + ) + if season: + result["elite_season"] = season + result["elite_expire_time"] = expire_time // 1000 + result["elite_expire_time_text"] = self._format_local_time( + expire_time // 1000 + ) + if opened: + opened_text = "已开通宝典" + if open_time: + opened_text += f"({self._format_local_time(open_time // 1000)})" + if expire_time: + opened_text += ( + f",有效期至 {self._format_local_time(expire_time // 1000)}" + ) + else: + opened_text = "未开通宝典" + message_parts.append(opened_text) + + # ---- 角色绑定(活动通道) ---- + b_act_id_int = self._to_int( + self.payload.get("bind_act_id") or config_info.get("bind_act_id") + ) + role_name = "" + if not b_act_id_int: + failures.append("未配置绑定 bActId") + else: + bind_client: Any = self._activity_client(uid, cookie) + bind_status, bind_query_result = self._resolve_bind_status( + client=bind_client, + uid=uid, + cookie=cookie, + b_act_id_int=b_act_id_int, + ) + if bind_status is None: + failures.append("角色信息接口无响应") + elif bind_status.status != 200: + failures.append( + bind_status.msg or f"角色信息查询失败: {bind_status.status}" + ) + else: + role_info = self._bind_role_result(bind_status) + role_name = role_info.get("role_name") or "" + result.update( + { + "bind_act_id": b_act_id_int, + **role_info, + **self._bind_change_state(bind_status), + **bind_query_result, + } + ) + if role_name: + account.game_name = role_name + account.game_channel = ( + self._role_channel(bind_status) or account.game_channel + ) + message_parts.append(f"角色 {role_name}") + else: + message_parts.append("未绑定角色") + + account.points = points if points is not None else account.points + account.status = "all_queried" + account.updated_at = datetime.now(UTC) + result["failures"] = failures + + fetched_anything = points is not None or role_name or opened is not None + if not fetched_anything: + self._mark_task( + worker_db, + task, + "failed", + ";".join(failures) or "一键查询全部失败", + result, + ) + return + + message = "|".join(message_parts) or "一键查询完成" + if failures: + message += f";部分失败: {';'.join(failures)}" + self._mark_task(worker_db, task, "success", message, result) diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 5d3ee3d..d885c4b 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -18,6 +18,7 @@ SUPPORTED_TASK_TYPES = { "get_bind_qr": "获取绑定二维码", "query_points": "一键查询积分", "query_game_name": "一键查询游戏名", + "query_all": "一键查询(角色+宝典+积分)", "query_exchange_records": "一键查询兑换记录", "confirm_bind": "确认绑定", "refresh_goods": "刷新商品列表", @@ -32,6 +33,7 @@ HUYA_HANDBOOK_TASK_TYPES = { "confirm_bind", "query_points", "query_game_name", + "query_all", "refresh_goods", "exchange_goods", "query_exchange_records", diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 7d4f9fc..e90cf5b 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -695,6 +695,12 @@ export interface HuyaAccountItem { game_name: string; game_channel: string; game_phone: string; + /** 精英宝典是否已开通(null = 还没查过) */ + elite_opened?: boolean | null; + /** 最近一次宝典订单支付时间(Unix 毫秒) */ + elite_open_time?: number | null; + /** 宝典有效期(Unix 毫秒;接口没给就是空) */ + elite_expire_time?: number | null; sms_url?: string; assigned_to: number | null; assigned_username: string | null; diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 0e76906..a3cad81 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -15,7 +15,7 @@ import { usePermissions } from '../hooks/usePermissions'; import { useWebSocketLogs } from '../hooks/useWebSocketLogs'; import { getErrorMessage } from '../utils/error'; import { message } from '../utils/antdMessage'; -import { formatTime } from '../utils/time'; +import { formatTime, formatUnixSeconds } from '../utils/time'; const { Text } = Typography; const SCOPE = 'elite' as const; @@ -27,7 +27,7 @@ const TASK_LABELS: Record = { query_act_tasks: '读取宝典任务', get_bind_qr: '获取绑定二维码', query_game_name: '查询游戏角色', confirm_bind: '确认绑定角色', query_points: '查询积分', refresh_goods: '刷新商品', exchange_goods: '兑换商品', query_exchange_records: '查询兑换记录', create_recharge_order: '生成开通支付码', - refresh_recharge_goods: '刷新开通商品', + refresh_recharge_goods: '刷新开通商品', query_all: '一键查询(宝典+角色+积分)', }; const TASK_COLORS: Record = { planned: 'default', pending: 'default', running: 'processing', success: 'success', @@ -39,6 +39,7 @@ const TASK_STATUS_LABELS: Record = { }; const HUYA_QUICK_ACTIONS = [ + { key: 'query_all', icon: }, { key: 'get_bind_qr', icon: }, { key: 'query_game_name', icon: }, { key: 'confirm_bind', icon: }, @@ -237,7 +238,7 @@ export default function HuyaElitePage() { const filteredAccounts = useMemo(() => { const value = search.trim().toLowerCase(); - return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name].some((v) => String(v || '').toLowerCase().includes(value))); + return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name, item.game_phone].some((v) => String(v || '').toLowerCase().includes(value))); }, [accounts, search]); const filteredGoods = useMemo(() => goods.map((item) => ({ ...item, label: `${item.name} / ${item.price ?? '-'}分 / ${item.remain_text || '动态库存'}` })), [goods]); const filteredRechargeGoods = useMemo(() => rechargeGoods.map((item) => ({ ...item, label: `${item.name || item.spu_id} / ${item.price ? `${(item.price / 100).toFixed(2)}元` : '价格动态'} / ${item.spu_id}` })).sort((a, b) => Number(a.raw?.raw_order ?? 0) - Number(b.raw?.raw_order ?? 0) || a.id - b.id), [rechargeGoods]); @@ -250,20 +251,23 @@ export default function HuyaElitePage() { }, [filteredRechargeGoods, selectedRechargeSpu]); const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length; + // 列宽按比例分配(percent),表格宽度随容器缩放,不会被拉出「奇奇怪怪」的宽度 const accountColumns: TableProps['columns'] = [ - { title: '#', width: 45, render: (_value, _row, index) => {index + 1} }, - { title: '账号', width: 180, render: (_value, row) => {row.nickname || row.username || row.uid}UID {row.uid || '-'} }, - { title: '游戏名', width: 160, render: (_value, row) => {row.game_name || 未查询}{row.game_channel && {row.game_channel}} }, - { title: '积分', dataIndex: 'points', width: 75, align: 'right', render: (value: number | null) => value ?? - }, - { title: '最近操作', width: 250, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? {TASK_STATUS_LABELS[task.status] || task.status}{TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'}{formatTime(task.finished_at || task.created_at)} : 暂无操作; } }, - { title: '二维码', width: 80, align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return -; return
{ event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? 二维码 : }
; } }, - { title: '状态', width: 90, render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return {TASK_STATUS_LABELS[status] || status || '待操作'}; } }, + { title: '#', width: '4%', align: 'center', render: (_value, _row, index) => {index + 1} }, + { title: '账号', width: '15%', ellipsis: true, render: (_value, row) => {row.nickname || row.username || row.uid}UID {row.uid || '-'} }, + { title: '手机号', width: '10%', ellipsis: true, render: (_value, row) => row.game_phone || - }, + { title: '游戏名', width: '13%', ellipsis: true, render: (_value, row) => {row.game_name || 未查询}{row.game_channel && {row.game_channel}} }, + { title: '精英宝典', width: '13%', ellipsis: true, render: (_value, row) => {row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}{row.elite_opened === true && row.elite_open_time ? {`开通 ${formatUnixSeconds(row.elite_open_time)}`} : null}{row.elite_expire_time ? {`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`} : null} }, + { title: '积分', dataIndex: 'points', width: '7%', align: 'right', render: (value: number | null) => value ?? - }, + { title: '最近操作', width: '25%', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? {TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'}{formatTime(task.finished_at || task.created_at)} : 暂无操作; } }, + { title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return -; return
{ event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? 二维码 : }
; } }, + { title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return {TASK_STATUS_LABELS[status] || status || '待操作'}; } }, ]; const operationBody = (
绑定与查询
- +
兑换
} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }
setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1040, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; + const accountsBody = <>} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }
setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; const configField = (key: Exclude, label: string) => configDraft && {label} setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} />; const contextMenuStyle = contextMenu ? { diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index f01dd2f..7e7b163 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -31,6 +31,7 @@ const FALLBACK_TASK_TYPES: Record = { confirm_bind: '确认绑定', query_points: '一键查询积分', query_game_name: '一键查询游戏名', + query_all: '一键查询(角色+宝典+积分)', query_exchange_records: '一键查询兑换记录', refresh_goods: '刷新商品列表', refresh_recharge_goods: '刷新充值商品列表', @@ -39,6 +40,7 @@ const FALLBACK_TASK_TYPES: Record = { }; const QUICK_ACTIONS = [ + { key: 'query_all', icon: }, { key: 'get_bind_qr', icon: }, { key: 'confirm_bind', icon: }, { key: 'query_points', icon: }, @@ -1136,6 +1138,19 @@ export default function HuyaTasksPage() { return -; } + // 一键查询:积分 + 宝典开通 + 角色,一处看全(明细在任务 message 里) + if (record.task_type === 'query_all') { + const tags: Array<{ text: string; color: string }> = []; + if (typeof value?.points === 'number') tags.push({ text: `积分 ${value.points}`, color: 'blue' }); + if (typeof value?.elite_opened === 'boolean') { + tags.push({ text: value.elite_opened ? '已开通宝典' : '未开通宝典', color: value.elite_opened ? 'green' : 'default' }); + } + if (typeof value?.role_name === 'string' && value.role_name) tags.push({ text: `角色 ${value.role_name}`, color: 'cyan' }); + if (tags.length) { + return {tags.map((tag) => {tag.text})}; + } + } + // 积分结果:新版走积分中心(points),旧任务记录里是活动接口的 available_score const points = typeof value?.points === 'number' ? value.points : value?.available_score; if (typeof points === 'number') {