diff --git a/.gitignore b/.gitignore index 4017d4a..db553c7 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ work/ analysis/ artifacts/ + + +hars/ \ No newline at end of file diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py index dfff5db..d02ccfa 100644 --- a/tests/test_huya_elite_query.py +++ b/tests/test_huya_elite_query.py @@ -18,6 +18,7 @@ from core.huya.shop_structs import ShopRawRsp from web.backend.models import HuyaAccount from web.backend.services.huya_pay_link import PayTargets, pay_qr_path from web.backend.services.huya_runner import HuyaBatchRunner +from web.backend.services.huya_runner_bind import BindMixin UID = 1199668595203 COOKIE = f"yyuid={UID}; udb_uid={UID}" @@ -155,6 +156,7 @@ class _FakeBindResponse: self.status = status self.msg = msg self.gameName = "和平精英" + self.gameAuthScene = "" self.changeBindDay = 7 self.accountData = _FakeBindAccountData(nick, role_name) @@ -787,8 +789,10 @@ def test_query_game_name_uses_new_probe_params_and_saves_role(monkeypatch): "uid": UID, "cookie": COOKIE, "b_act_id": 9271, - "is_use_outer_act_id": 1, + "gid": 0, "outer_act_id": "17096", + "is_use_outer_act_id": 1, + "scene": "", "timeout": 15.0, } ] @@ -862,3 +866,180 @@ def test_shop_client_falls_back_to_http_when_wss_init_fails(monkeypatch): assert logs and logs[-1][0] == "warning" assert "WSS 会话不可用" in logs[-1][1] assert "sdid" not in json.dumps(logs[-1][1]) # 日志不带 Cookie 值 + + +class _FakeConfirmResponse: + 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 _BindConfirmClient: + def __init__(self): + self.check_calls: list[dict] = [] + self.confirm_calls: list[dict] = [] + + def check_user_bind_game_account(self, **kwargs): + self.check_calls.append(kwargs) + return _FakeBindResponse() + + def confirm_bind_act_account(self, **kwargs): + self.confirm_calls.append(kwargs) + return _FakeConfirmResponse() + + +class _FakeLiveLink: + def __init__(self): + self.status = 200 + self.msg = "" + self.livelinkParam = { + "actId": 17096, + "code": "code", + "gameIdList": "cjm", + "livePlatId": "huya", + "sig": "sig", + "t": "t", + } + + def to_log_dict(self) -> dict: + return {"status": self.status} + + +class _BindQrClient: + def __init__(self): + self.check_calls: list[dict] = [] + self.live_link_calls: list[dict] = [] + self.qrcode_calls: list[str] = [] + + def check_user_bind_game_account(self, **kwargs): + self.check_calls.append(kwargs) + return _FakeBindResponse() + + def get_live_link_param(self, **kwargs): + self.live_link_calls.append(kwargs) + return _FakeLiveLink() + + def get_user_profile_batch(self, **kwargs): + return None + + def build_bind_urls(self, _param, _b_act_id, **kwargs): + return {"qr_url": "https://livelink.example/qr"} + + def get_livelink_mini_qrcode(self, url): + self.qrcode_calls.append(url) + return { + "mini_qrcode_image": "base64-image", + "qrcode_token": "token", + } + + +def _task_runner(monkeypatch, task_type: str): + runner = HuyaBatchRunner(db=None, batch_id="test-batch", task_type=task_type) + marks: list[tuple[str, str, dict | None]] = [] + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + raising=False, + ) + monkeypatch.setattr( + runner, + "_update_task_progress", + lambda *_args, **_kwargs: None, + raising=False, + ) + return runner, marks + + +def test_bind_protocol_prefers_configured_gid_and_keeps_legacy_fallback(): + """精英宝典绑定可配置 gid;旧配置没填 gid 时保持 9271 协议。""" + assert BindMixin._bind_protocol({"bind_gid": "3203", "outer_act_id": "17096"}) == { + "bind_gid": "3203", + "b_act_id": 0, + "gid": 3203, + "outer_act_id": "17096", + "is_use_outer_act_id": 0, + "scene": "undefined", + } + assert BindMixin._bind_protocol({"bind_act_id": "9271"}) == { + "bind_gid": "", + "b_act_id": 9271, + "gid": 0, + "outer_act_id": "", + "is_use_outer_act_id": 1, + "scene": "", + } + + +def test_confirm_bind_uses_latest_har_protocol(monkeypatch): + """确认绑定复用抓包里的 gid=3203 / bActId=0 / outerActId=17096。""" + client = _BindConfirmClient() + runner, marks = _task_runner(monkeypatch, "confirm_bind") + monkeypatch.setattr(runner, "_activity_client", lambda uid, cookie: client) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_confirm_bind( + None, + None, + account, + {"uid": UID, "cookie": COOKIE}, + {"bind_gid": "3203", "outer_act_id": "17096"}, + ) + + assert marks[-1][0] == "success" + assert client.confirm_calls == [ + { + "uid": UID, + "cookie": COOKIE, + "b_act_id": 0, + "gid": 3203, + "outer_act_id": "17096", + } + ] + assert all( + call["b_act_id"] == 0 + and call["gid"] == 3203 + and call["outer_act_id"] == "17096" + and call["is_use_outer_act_id"] == 0 + and call["scene"] == "undefined" + for call in client.check_calls + ) + + +def test_get_bind_qr_uses_latest_har_protocol(monkeypatch): + """获取二维码的完整链路与 2026-11 抓包参数一致。""" + client = _BindQrClient() + runner, marks = _task_runner(monkeypatch, "get_bind_qr") + monkeypatch.setattr(runner, "_activity_client", lambda uid, cookie: client) + runner._stop.set() + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_get_bind_qr( + None, + None, + account, + {"uid": UID, "cookie": COOKIE}, + {"bind_gid": "3203", "outer_act_id": "17096"}, + ) + + assert marks[-1][2]["gid"] == 3203 + assert marks[-1][2]["bind_gid"] == "3203" + assert client.live_link_calls == [ + { + "uid": UID, + "cookie": COOKIE, + "b_act_id": 0, + "gid": 3203, + "outer_act_id": 17096, + "game_auth_scene": "", + } + ] + assert client.check_calls[0]["b_act_id"] == 0 + assert client.check_calls[0]["gid"] == 3203 + assert client.check_calls[0]["is_use_outer_act_id"] == 0 + assert client.check_calls[0]["scene"] == "undefined" diff --git a/tests/test_huya_points_query.py b/tests/test_huya_points_query.py index edd6906..4e7f89f 100644 --- a/tests/test_huya_points_query.py +++ b/tests/test_huya_points_query.py @@ -258,7 +258,10 @@ def test_query_points_uses_http_and_prechecks_ck_with_new_probe(monkeypatch): "uid": UID, "cookie": COOKIE, "b_act_id_int": 9271, - "outer_act_id": "17096", + "outer_act_id": "", + "gid": 0, + "is_use_outer_act_id": 1, + "scene": "", } ] assert marks[-1][0] == "success" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 741e9ae..debf972 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 = "20261108_0074" +HEAD_REVISION = "20261109_0075" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/huya_defaults.py b/web/backend/huya_defaults.py index 25643eb..9a873ec 100644 --- a/web/backend/huya_defaults.py +++ b/web/backend/huya_defaults.py @@ -3,6 +3,8 @@ HUYA_DEFAULT_ROOM_PID = "1199650619883" HUYA_DEFAULT_SID = "2203" HUYA_DEFAULT_OUTER_ACT_ID = "17096" +# 2026-11-09 最新活动页抓包:绑定走 gid=3203、bActId=0。 +HUYA_DEFAULT_BIND_GID = "3203" HUYA_DEFAULT_BIND_ACT_ID = "9271" HUYA_DEFAULT_PAY_CHANNEL = "Zfb" @@ -10,6 +12,7 @@ HUYA_CONFIG_DEFAULTS = { "room_pid": HUYA_DEFAULT_ROOM_PID, "sid": HUYA_DEFAULT_SID, "outer_act_id": HUYA_DEFAULT_OUTER_ACT_ID, + "bind_gid": HUYA_DEFAULT_BIND_GID, "bind_act_id": HUYA_DEFAULT_BIND_ACT_ID, "pay_channel": HUYA_DEFAULT_PAY_CHANNEL, } diff --git a/web/backend/migrations/versions/20261109_0075_huya_bind_gid.py b/web/backend/migrations/versions/20261109_0075_huya_bind_gid.py new file mode 100644 index 0000000..9c74c07 --- /dev/null +++ b/web/backend/migrations/versions/20261109_0075_huya_bind_gid.py @@ -0,0 +1,39 @@ +"""虎牙绑定协议增加 gid 配置。""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261109_0075" +down_revision: str | None = "20261108_0074" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table("huya_config"): + return + columns = {column["name"] for column in inspector.get_columns("huya_config")} + if "bind_gid" not in columns: + op.add_column( + "huya_config", + sa.Column( + "bind_gid", + sa.String(length=32), + nullable=False, + server_default="3203", + ), + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table("huya_config"): + return + columns = {column["name"] for column in inspector.get_columns("huya_config")} + if "bind_gid" in columns: + op.drop_column("huya_config", "bind_gid") diff --git a/web/backend/models.py b/web/backend/models.py index 271e7f7..061f784 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -23,6 +23,7 @@ from .crypto_storage import EncryptedText from .database import Base, UTCDateTime from .huya_defaults import ( HUYA_DEFAULT_BIND_ACT_ID, + HUYA_DEFAULT_BIND_GID, HUYA_DEFAULT_OUTER_ACT_ID, HUYA_DEFAULT_PAY_CHANNEL, HUYA_DEFAULT_ROOM_PID, @@ -1369,6 +1370,7 @@ class HuyaConfig(Base): outer_act_id: Mapped[str] = mapped_column( String(32), default=HUYA_DEFAULT_OUTER_ACT_ID ) + bind_gid: Mapped[str] = mapped_column(String(32), default=HUYA_DEFAULT_BIND_GID) bind_act_id: Mapped[str] = mapped_column( String(32), default=HUYA_DEFAULT_BIND_ACT_ID ) diff --git a/web/backend/routers/huya/_common.py b/web/backend/routers/huya/_common.py index 51bf0bc..54a8bf8 100644 --- a/web/backend/routers/huya/_common.py +++ b/web/backend/routers/huya/_common.py @@ -453,6 +453,7 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut: room_pid=huya_config_value("room_pid", config.room_pid), sid=huya_config_value("sid", config.sid), outer_act_id=huya_config_value("outer_act_id", config.outer_act_id), + bind_gid=huya_config_value("bind_gid", config.bind_gid), bind_act_id=huya_config_value("bind_act_id", config.bind_act_id), pay_channel=huya_config_value("pay_channel", config.pay_channel), updated_at=config.updated_at, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 56de00d..42603c7 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_serializer from .huya_defaults import ( HUYA_DEFAULT_BIND_ACT_ID, + HUYA_DEFAULT_BIND_GID, HUYA_DEFAULT_OUTER_ACT_ID, HUYA_DEFAULT_PAY_CHANNEL, HUYA_DEFAULT_ROOM_PID, @@ -1154,6 +1155,7 @@ class HuyaConfigOut(BaseModel): room_pid: str = HUYA_DEFAULT_ROOM_PID sid: str = HUYA_DEFAULT_SID outer_act_id: str = HUYA_DEFAULT_OUTER_ACT_ID + bind_gid: str = HUYA_DEFAULT_BIND_GID bind_act_id: str = HUYA_DEFAULT_BIND_ACT_ID pay_channel: str = HUYA_DEFAULT_PAY_CHANNEL updated_at: datetime | None = None @@ -1164,6 +1166,7 @@ class HuyaConfigOut(BaseModel): "room_pid": self.room_pid, "sid": self.sid, "outer_act_id": self.outer_act_id, + "bind_gid": self.bind_gid, "bind_act_id": self.bind_act_id, "pay_channel": self.pay_channel, "updated_at": _isoformat(self.updated_at), @@ -1174,6 +1177,7 @@ class HuyaConfigUpdate(BaseModel): room_pid: str | None = None sid: str | None = None outer_act_id: str | None = None + bind_gid: str | None = None bind_act_id: str | None = None pay_channel: str | None = None diff --git a/web/backend/services/huya_runner_bind.py b/web/backend/services/huya_runner_bind.py index e6dde3e..cf9fad2 100644 --- a/web/backend/services/huya_runner_bind.py +++ b/web/backend/services/huya_runner_bind.py @@ -101,6 +101,41 @@ class BindMixin: "bind_phase": "role_ready" if role_info["role_name"] else "waiting_role", } + @staticmethod + def _bind_protocol(config_info: dict, payload: dict | None = None) -> dict: + """读取绑定协议参数;有 gid 时用 2026-11 抓包协议,否则回退旧协议。""" + payload = payload or {} + bind_gid_text = str( + payload.get("bind_gid") or config_info.get("bind_gid") or "" + ).strip() + bind_gid = HuyaBatchRunnerCore._to_int(bind_gid_text) + outer_act_id = str( + payload.get("outer_act_id") + or config_info.get("outer_act_id") + or HUYA_DEFAULT_OUTER_ACT_ID + ).strip() + if bind_gid and outer_act_id: + return { + "bind_gid": bind_gid_text, + "b_act_id": 0, + "gid": bind_gid, + "outer_act_id": outer_act_id, + "is_use_outer_act_id": 0, + "scene": "undefined", + } + b_act_id_text = str( + payload.get("bind_act_id") or config_info.get("bind_act_id") or "" + ).strip() + return { + "bind_gid": "", + "b_act_id": HuyaBatchRunnerCore._to_int(b_act_id_text), + "gid": 0, + # 旧协议最初不传 outerActId;需要旧路径的地方按原逻辑显式补默认值。 + "outer_act_id": "", + "is_use_outer_act_id": 1, + "scene": "", + } + def _resolve_bind_status( self, client: Any, @@ -110,16 +145,20 @@ class BindMixin: *, outer_act_id: str = "", timeout: float | None = None, + gid: int = 0, + is_use_outer_act_id: int = 1, + scene: str = "", ): """按活动页逻辑解析绑定状态,优先返回含角色的状态。""" probe_kwargs: dict[str, Any] = { "uid": uid, "cookie": cookie, "b_act_id": b_act_id_int, - "is_use_outer_act_id": 1, + "gid": gid, + "outer_act_id": outer_act_id, + "is_use_outer_act_id": is_use_outer_act_id, + "scene": scene, } - if outer_act_id: - probe_kwargs["outer_act_id"] = outer_act_id if timeout is not None: probe_kwargs["timeout"] = timeout outer_status = client.check_user_bind_game_account(**probe_kwargs) @@ -135,10 +174,16 @@ class BindMixin: chosen_status = outer_status account_data = outer_status.accountData - should_check_inner = not self._has_bind_role(outer_status) and bool( - account_data.isNeedActCheck - or not account_data.isBindAcount - or not account_data.isBindRole + # 最新抓包协议只用外层查询(gid + outerActId)。旧的内层兜底会带上 + # gid=0/outerActId=空,被服务端判 501「参数错误」,仅在旧协议下保留。 + should_check_inner = ( + not gid + and not self._has_bind_role(outer_status) + and bool( + account_data.isNeedActCheck + or not account_data.isBindAcount + or not account_data.isBindRole + ) ) if should_check_inner: inner_status = client.check_user_bind_game_account( @@ -162,6 +207,9 @@ class BindMixin: cookie: str, b_act_id_int: int, outer_act_id: str = "", + gid: int = 0, + is_use_outer_act_id: int = 1, + scene: str = "", ): """用外部单协作的新 CK 探针查角色。 @@ -177,6 +225,9 @@ class BindMixin: b_act_id_int, outer_act_id=outer_act_id or HUYA_DEFAULT_OUTER_ACT_ID, timeout=HUYA_CK_PROBE_TIMEOUT_SECONDS, + gid=gid, + is_use_outer_act_id=is_use_outer_act_id, + scene=scene, ) def _apply_role_to_account(self, account: HuyaAccount, bind_status, status: str): @@ -209,6 +260,11 @@ class BindMixin: cookie: str, b_act_id_int: int, result: dict, + *, + gid: int = 0, + outer_act_id: str = "", + is_use_outer_act_id: int = 1, + scene: str = "", ) -> tuple[str, dict]: """生成二维码后轮询扫码状态与角色同步,直到识别到角色、超时或停止。""" deadline = time.monotonic() + HUYA_BIND_ROLE_POLL_SECONDS @@ -258,6 +314,10 @@ class BindMixin: uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, + outer_act_id=outer_act_id, + gid=gid, + is_use_outer_act_id=is_use_outer_act_id, + scene=scene, ) if bind_status is None: continue @@ -328,20 +388,12 @@ class BindMixin: account_info: dict, config_info: dict, ): - b_act_id = str( - self.payload.get("bind_act_id") or config_info.get("bind_act_id") or "" - ).strip() - if not b_act_id: + protocol = self._bind_protocol(config_info, self.payload) + b_act_id_int = protocol["b_act_id"] + if not protocol["bind_gid"] and not b_act_id_int: self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId") return - b_act_id_int = self._to_int(b_act_id) - if not b_act_id_int: - self._mark_task( - worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}" - ) - return - uid = self._resolve_uid(account_info) if not uid: self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid") @@ -358,6 +410,10 @@ class BindMixin: uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, + outer_act_id=protocol["outer_act_id"], + gid=protocol["gid"], + is_use_outer_act_id=protocol["is_use_outer_act_id"], + scene=protocol["scene"], ) if bind_status is None: self._mark_task(worker_db, task, "error", "虎牙绑定状态接口无响应") @@ -383,6 +439,8 @@ class BindMixin: available_at = change_state["change_available_at"] result = { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], **role_info, **change_state, "bind_status": bind_status.to_dict(), @@ -400,6 +458,8 @@ class BindMixin: uid=uid, cookie=cookie, b_act_id=b_act_id_int, + gid=protocol["gid"], + outer_act_id=int(protocol["outer_act_id"] or 0), game_auth_scene=bind_status.gameAuthScene, ) if live_link is None: @@ -450,6 +510,8 @@ class BindMixin: result = { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], "mini_qrcode_image": mini_qrcode["mini_qrcode_image"], "qrcode_token": mini_qrcode.get("qrcode_token") or "", **bind_query_result, @@ -483,6 +545,10 @@ class BindMixin: cookie=cookie, b_act_id_int=b_act_id_int, result=result, + gid=protocol["gid"], + outer_act_id=protocol["outer_act_id"], + is_use_outer_act_id=protocol["is_use_outer_act_id"], + scene=protocol["scene"], ) if role_name: self._mark_task( @@ -514,20 +580,12 @@ class BindMixin: account_info: dict, config_info: dict, ): - b_act_id = str( - self.payload.get("bind_act_id") or config_info.get("bind_act_id") or "" - ).strip() - if not b_act_id: + protocol = self._bind_protocol(config_info, self.payload) + b_act_id_int = protocol["b_act_id"] + if not protocol["bind_gid"] and not b_act_id_int: self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId") return - b_act_id_int = self._to_int(b_act_id) - if not b_act_id_int: - self._mark_task( - worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}" - ) - return - uid = self._resolve_uid(account_info) if not uid: self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid") @@ -538,16 +596,14 @@ class BindMixin: self._mark_task(worker_db, task, "failed", "账号 Cookie 为空") return - outer_act_id = str( - self.payload.get("outer_act_id") - or config_info.get("outer_act_id") - or HUYA_DEFAULT_OUTER_ACT_ID - ).strip() bind_status, bind_query_result = self._probe_bind_status( uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, - outer_act_id=outer_act_id, + outer_act_id=protocol["outer_act_id"], + gid=protocol["gid"], + is_use_outer_act_id=protocol["is_use_outer_act_id"], + scene=protocol["scene"], ) if bind_status is None: # 新探针没有回包 = 这份 CK 已经不认了,和外部单协作的判定一致。 @@ -560,6 +616,8 @@ class BindMixin: "虎牙角色信息接口无响应(CK 已失效,请重新登录或重新导入 Cookie)", { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], "ck_expired": True, "ck_expired_reason": "角色信息接口无响应", }, @@ -572,7 +630,12 @@ class BindMixin: task, "failed", bind_status.msg or f"虎牙角色信息查询失败: {bind_status.status}", - {"bind_act_id": b_act_id_int, **bind_query_result}, + { + "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], + **bind_query_result, + }, ) return @@ -580,6 +643,8 @@ class BindMixin: change_state = self._bind_change_state(bind_status) result = { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], **role_info, **change_state, **bind_query_result, @@ -604,20 +669,12 @@ class BindMixin: account_info: dict, config_info: dict, ): - b_act_id = str( - self.payload.get("bind_act_id") or config_info.get("bind_act_id") or "" - ).strip() - if not b_act_id: + protocol = self._bind_protocol(config_info, self.payload) + b_act_id_int = protocol["b_act_id"] + if not protocol["bind_gid"] and not b_act_id_int: self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId") return - b_act_id_int = self._to_int(b_act_id) - if not b_act_id_int: - self._mark_task( - worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}" - ) - return - uid = self._resolve_uid(account_info) if not uid: self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid") @@ -634,6 +691,10 @@ class BindMixin: uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, + outer_act_id=protocol["outer_act_id"], + gid=protocol["gid"], + is_use_outer_act_id=protocol["is_use_outer_act_id"], + scene=protocol["scene"], ) if role_status is None: self._mark_task(worker_db, task, "error", "虎牙绑定角色查询接口无响应") @@ -653,6 +714,8 @@ class BindMixin: if not role_name: result = { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], "bind_confirmed": False, **role_query_result, } @@ -669,6 +732,8 @@ class BindMixin: uid=uid, cookie=cookie, b_act_id=b_act_id_int, + gid=protocol["gid"], + outer_act_id=protocol["outer_act_id"], ) if confirm_resp is None: self._mark_task(worker_db, task, "error", "虎牙确认绑定接口无响应") @@ -676,6 +741,8 @@ class BindMixin: if confirm_resp.status != 200: result = { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], "bind_confirmed": False, "confirm_result": confirm_resp.to_dict(), "before_bind_status": role_status.to_dict(), @@ -695,10 +762,16 @@ class BindMixin: uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, + outer_act_id=protocol["outer_act_id"], + gid=protocol["gid"], + is_use_outer_act_id=protocol["is_use_outer_act_id"], + scene=protocol["scene"], ) if refreshed_status is None: result = { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], "bind_confirmed": True, "confirm_result": confirm_resp.to_dict(), "before_bind_status": role_status.to_dict(), @@ -713,6 +786,8 @@ class BindMixin: if refreshed_status.status != 200: result = { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], "bind_confirmed": True, "confirm_result": confirm_resp.to_dict(), "before_bind_status": role_status.to_dict(), @@ -739,6 +814,8 @@ class BindMixin: ) result = { "bind_act_id": b_act_id_int, + "bind_gid": protocol["bind_gid"], + "gid": protocol["gid"], "bind_confirmed": True, "refreshed_is_bound": refreshed_confirmed, "confirm_result": confirm_resp.to_dict(), diff --git a/web/backend/services/huya_runner_core.py b/web/backend/services/huya_runner_core.py index e7311ac..50b0766 100644 --- a/web/backend/services/huya_runner_core.py +++ b/web/backend/services/huya_runner_core.py @@ -228,7 +228,11 @@ class HuyaBatchRunnerCore: def _format_local_time(timestamp: int) -> str: if not timestamp: return "" - return datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%d %H:%M:%S") + return ( + datetime.fromtimestamp(timestamp, UTC) + .astimezone() + .strftime("%Y-%m-%d %H:%M:%S") + ) @staticmethod def _parse_scheduled_time(value) -> datetime | None: diff --git a/web/backend/services/huya_runner_elite.py b/web/backend/services/huya_runner_elite.py index 5fd1a5f..be9e623 100644 --- a/web/backend/services/huya_runner_elite.py +++ b/web/backend/services/huya_runner_elite.py @@ -285,23 +285,20 @@ class EliteMixin: role_name = "" # ---- 角色绑定 / CK 有效性(先做,失效就不空跑后面两个接口) ---- - b_act_id_int = self._to_int( - self.payload.get("bind_act_id") or config_info.get("bind_act_id") - ) - if not b_act_id_int: + protocol = self._bind_protocol(config_info, self.payload) + b_act_id_int = protocol["b_act_id"] + if not protocol["bind_gid"] and not b_act_id_int: failures.append("未配置绑定 bActId") result["ck_valid"] = False else: - outer_act_id = str( - self.payload.get("outer_act_id") - or config_info.get("outer_act_id") - or "" - ).strip() bind_status, bind_query_result = self._probe_bind_status( uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, - outer_act_id=outer_act_id, + outer_act_id=protocol["outer_act_id"], + gid=protocol["gid"], + is_use_outer_act_id=protocol["is_use_outer_act_id"], + scene=protocol["scene"], ) if bind_status is None: account.status = "ck_expired" diff --git a/web/backend/services/huya_runner_goods.py b/web/backend/services/huya_runner_goods.py index ad5180d..883ae2f 100644 --- a/web/backend/services/huya_runner_goods.py +++ b/web/backend/services/huya_runner_goods.py @@ -10,7 +10,6 @@ from sqlalchemy.orm import Session from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM -from ..huya_defaults import HUYA_DEFAULT_OUTER_ACT_ID from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask # 积分查询超时:HTTP 通道正常几百毫秒,10 秒足够;失败时别让整批任务长时间挂住。 @@ -123,20 +122,17 @@ class GoodsMixin: # ---- 先验 CK:没有回包就立即判失效,不再空跑积分与旧接口 ---- ck_valid = False - b_act_id_int = self._to_int( - self.payload.get("bind_act_id") or config_info.get("bind_act_id") - ) - if b_act_id_int: - outer_act_id = str( - self.payload.get("outer_act_id") - or config_info.get("outer_act_id") - or HUYA_DEFAULT_OUTER_ACT_ID - ).strip() + protocol = self._bind_protocol(config_info, self.payload) + b_act_id_int = protocol["b_act_id"] + if protocol["bind_gid"] or b_act_id_int: bind_status, _bind_query_result = self._probe_bind_status( uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, - outer_act_id=outer_act_id, + outer_act_id=protocol["outer_act_id"], + gid=protocol["gid"], + is_use_outer_act_id=protocol["is_use_outer_act_id"], + scene=protocol["scene"], ) if bind_status is None: account.status = "ck_expired" diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index c8b13a7..92eb62b 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1184,6 +1184,7 @@ export interface HuyaConfig { room_pid: string; sid: string; outer_act_id: string; + bind_gid: string; bind_act_id: string; pay_channel: string; updated_at: string | null; diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 6adecd7..000690c 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { MouseEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { - Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, + Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Spin, Table, Tag, Tooltip, Typography, } from 'antd'; import type { TableProps } from 'antd'; import { @@ -107,6 +107,11 @@ function resultText(task: HuyaTaskItem | undefined, key: string): string { return value == null ? '' : String(value); } +/** mini_qrcode_image 可能存裸 base64,也可能存 livelink 返回的 data URI;统一成 img src。 */ +function miniQrcodeSrc(image: string): string { + return image.startsWith('data:image/') ? image : `data:image/png;base64,${image}`; +} + /** 开通支付码是否还需要扫码:已支付或已确认开通就不用再展示了 */ function needsScanPay(task: HuyaTaskItem): boolean { const result = task.result || {}; @@ -203,6 +208,9 @@ export default function HuyaElitePage() { const [importTags, setImportTags] = useState([]); const [importSelected, setImportSelected] = useState([]); const [qrTask, setQrTask] = useState(null); + const [qrTaskLoading, setQrTaskLoading] = useState(false); + // 小程序码图片独立保存:任务轮询只会更新轻量 tasks,不允许覆盖这张大图。 + const [qrImage, setQrImage] = useState(''); // 开通支付码:多选账号时可以一次生成多个码,弹窗里逐个展示(按批次收集) const [payBatchIds, setPayBatchIds] = useState([]); const [payOpen, setPayOpen] = useState(false); @@ -216,6 +224,8 @@ 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 qrTaskFetchId = useRef(null); + const qrImageCacheRef = useRef>(new Map()); const logs = useWebSocketLogs(); const load = useCallback(async () => { @@ -368,10 +378,31 @@ export default function HuyaElitePage() { onTask: (raw) => { const next = raw as unknown as HuyaTaskItem; setTasks((prev) => [next, ...prev.filter((item) => item.id !== next.id)].slice(0, 300)); - const result = next.result || {}; + const result = (next.result || {}) as Record; // 支付弹窗只在「生成开通支付码」和点账号行时打开:多选时每个码都会回包, // 这里再自动弹会把用户刚关掉的弹窗反复顶出来(码会在弹窗里自动补齐)。 - if (next.task_type === 'get_bind_qr' && (result.mini_qrcode_image || result.bind_redirect_url)) setQrTask(next); + if (next.task_type === 'get_bind_qr') { + if (result.mini_qrcode_image) { + const directImage = String(result.mini_qrcode_image); + qrImageCacheRef.current.set(next.id, directImage); + setQrImage(directImage); + setQrTask(next); + } else if (result.has_mini_qrcode && qrTaskFetchId.current !== next.id) { + // WS 只送 has_mini_qrcode,真实小程序码体积大,必须按详情接口取。 + qrTaskFetchId.current = next.id; + setQrImage(''); + setQrTaskLoading(true); + setQrTask(next); + void huyaApi.getTask(next.id).then((detail) => { + const image = resultText(detail, 'mini_qrcode_image'); + if (image) { + qrImageCacheRef.current.set(detail.id, image); + setQrImage(image); + } + setQrTask((current) => (current && current.id === detail.id ? detail : current)); + }).catch(() => {}).finally(() => setQrTaskLoading(false)); + } else if (result.bind_redirect_url) setQrTask(next); + } }, onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void load(); }, }); @@ -420,8 +451,27 @@ export default function HuyaElitePage() { }; const openQrTask = async (task: HuyaTaskItem) => { + const currentImage = resultText(task, 'mini_qrcode_image'); + const cachedImage = currentImage || qrImageCacheRef.current.get(task.id) || ''; + // 图片是独立 state,轮询/任务对象刷新不会把它清掉。 + setQrImage(cachedImage); setQrTask(task); - try { setQrTask(await huyaApi.getTask(task.id)); } catch { /* 使用列表结果 */ } + if (currentImage) { + qrImageCacheRef.current.set(task.id, currentImage); + return; + } + if (cachedImage) return; + if (task.result?.has_mini_qrcode !== true) return; + setQrTaskLoading(true); + try { + const detail = await huyaApi.getTask(task.id); + const image = resultText(detail, 'mini_qrcode_image'); + if (image) { + qrImageCacheRef.current.set(detail.id, image); + setQrImage((current) => current || image); + } + setQrTask((current) => (current && current.id === detail.id ? detail : current)); + } catch { /* 使用列表结果 */ } finally { setQrTaskLoading(false); } }; const filteredAccounts = useMemo(() => { @@ -491,7 +541,7 @@ export default function HuyaElitePage() { { title: '精英宝典', width: '13%', align: 'center', 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: 'center', render: (value: number | null) => value ?? - }, { title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); 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); if (!task) return -; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return -; const doneLabel = qrDoneLabel(task); if (doneLabel) return {doneLabel}; return
{ event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? 二维码 : }
; } }, + { title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return -; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return -; return
{ event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码">
; } if (!url && !image) return -; const doneLabel = qrDoneLabel(task); if (doneLabel) return {doneLabel}; return
{ event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? 二维码 : }
; } }, { title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (ckExpired(task, row)) return CK 失效; const status = task?.status || row.status || ''; return {TASK_STATUS_LABELS[status] || status || '待操作'}; } }, ]; @@ -524,7 +574,7 @@ export default function HuyaElitePage() { {layoutMode === 'split' ?
已选 {selectedIds.length}/{accounts.length}} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}
: <>并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}已选 {selectedIds.length}/{accounts.length}} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}} 兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。 {contextMenu &&
event.stopPropagation()}> 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}>{HUYA_QUICK_ACTIONS.map((item) => )}
} - setQrTask(null)} centered>{qrTask &&
{resultText(qrTask, 'mini_qrcode_image') ? 绑定二维码 : }

{qrTask.message || qrTask.status}

}
+ { setQrTask(null); setQrImage(''); }} centered>{qrTask &&
{qrImage ? 绑定二维码 : qrTask.result?.has_mini_qrcode === true ?
{qrTaskLoading ? : 正在获取小程序码…}
: }

{qrTask.message || qrTask.status}

}
-
{resultText(task, 'mini_qrcode_image') ? 支付码 : }
+
{resultText(task, 'mini_qrcode_image') ? 支付码 : }
{payTaskLabel(task)}
UID {task.account_uid || '-'}
{[resultText(task, 'product_name') || '精英宝典', Number(resultText(task, 'count')) > 1 ? `×${resultText(task, 'count')}` : '', resultText(task, 'amount_text')].filter(Boolean).join(' ')}
@@ -577,7 +627,7 @@ export default function HuyaElitePage() { }
setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /> + + + + +