diff --git a/core/huya/http_client.py b/core/huya/http_client.py index a5ecce3..0a17660 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -1596,6 +1596,55 @@ def _bind_game_account_has_role(resp: Any) -> bool: return bool(getattr(game_role, "roleName", "")) +def _bind_game_account_candidate(resp: Any, source: str) -> dict[str, Any]: + """把一路 ``checkUserBindGameAccount`` 响应整理成可序列化的角色候选。 + + 2026-11-14 待绑定状态抓包确认: + - ``gid + outerActId`` 返回当前已绑定角色; + - ``gid + outerActId=空`` 返回待确认的新角色。 + + 两路都可能是 200,不能只看“有没有角色名”就认为后一路覆盖前一路。 + """ + account_data = getattr(resp, "accountData", None) + game_account = getattr(account_data, "gameAccount", None) + game_role = getattr(account_data, "gameRole", None) + role_name = str(getattr(game_role, "roleName", "") or "").strip() + plat_name = str(getattr(game_role, "platName", "") or "").strip() + area_name = str(getattr(game_role, "areaName", "") or "").strip() + return { + "source": source, + "game_title": str(getattr(resp, "gameName", "") or ""), + "role_name": role_name, + "role_id": str(getattr(game_role, "roleId", "") or ""), + "plat_name": plat_name, + "area_name": area_name, + "channel": " / ".join(part for part in (plat_name, area_name) if part), + "partition_name": str(getattr(game_role, "partitionName", "") or ""), + "game_serial": str(getattr(game_role, "gameSerial", "") or ""), + "game_account": { + "nick": str(getattr(game_account, "nick", "") or ""), + "type": str(getattr(game_account, "type", "") or ""), + "face_url": str(getattr(game_account, "faceUrl", "") or ""), + "has_open_id": bool(getattr(game_account, "openId", "")), + "has_access_token": bool(getattr(game_account, "accessToken", "")), + }, + "game_role": { + "role_id": str(getattr(game_role, "roleId", "") or ""), + "role_name": role_name, + "area_name": area_name, + "plat_name": plat_name, + "partition_name": str(getattr(game_role, "partitionName", "") or ""), + "game_serial": str(getattr(game_role, "gameSerial", "") or ""), + }, + "is_bind_account": int(getattr(account_data, "isBindAcount", 0) or 0), + "is_bind_role": int(getattr(account_data, "isBindRole", 0) or 0), + "is_need_act_check": int(getattr(account_data, "isNeedActCheck", 0) or 0), + "change_bind_time": int(getattr(account_data, "changBindTime", 0) or 0), + "change_bind_day": int(getattr(resp, "changeBindDay", 0) or 0), + "status": int(getattr(resp, "status", 0) or 0), + } + + def query_bind_game_account( client: Any, *, @@ -1608,15 +1657,21 @@ def query_bind_game_account( scene: str = "", timeout: float | None = None, ) -> tuple[Any, dict[str, Any]]: - """查询绑定游戏账号(官方活动页两条查询,返回最新角色)。 + """查询绑定游戏账号,并区分当前绑定角色与待确认角色。 - 2026-11-09 真机定论:新版活动页先查 ``gid + outerActId``,再查 - ``gid + outerActId=空``;**角色名以第二路为准**(第一路会返回旧的缓存角色)。 - 旧协议(gid=0)第二路会被服务端判 501,保持外层优先、缺角色时补查内层。 + 新版活动页会查两路: + - ``gid + outerActId``:当前已生效绑定; + - ``gid + outerActId=空``:角色选择/换绑后、点「确认绑定」前的新角色。 + + 2026-11-14 待绑定状态真机抓包中,两路同时返回 200: + 第一路是页面右上角显示的当前角色,第二路是确认弹窗里的待绑定角色。 + 因此不能再用“内层有角色就无条件覆盖”的规则。只有两路确实不同时, + 才把内层标记为 ``pending_role``;返回值优先当前绑定,首次绑定没有当前角色时 + 才回退到待确认角色。 返回 ``(status, detail)``: - - ``status``:最终选中的 ``CheckUserBindGameAccountResp``(``None`` 表示接口无响应); - - ``detail``:两路响应与来源标记,供任务结果透传(可 JSON 序列化)。 + - ``status``:当前绑定响应;没有当前绑定则回退待确认响应; + - ``detail``:两路原始响应、标准化候选和来源标记(可 JSON 序列化)。 """ def _call(outer_id: str, use_outer: int): @@ -1643,15 +1698,16 @@ def query_bind_game_account( if outer.status != 200: return outer, detail + outer_role = _bind_game_account_candidate(outer, "outer") + inner_role: dict[str, Any] | None = None chosen = outer if gid: - # 第二路(gid + outerActId=空)是最新角色源,带出角色就优先采用。 + # 第二路专门读取换绑/首次绑定后、确认前的角色候选。 inner = _call("", 0) if inner is not None: detail["inner_bind_status"] = inner.to_dict() - if inner.status == 200 and _bind_game_account_has_role(inner): - chosen = inner - detail["bind_status_source"] = "inner" + if inner.status == 200: + inner_role = _bind_game_account_candidate(inner, "inner") else: account_data = getattr(outer, "accountData", None) if not _bind_game_account_has_role(outer) and bool( @@ -1662,9 +1718,50 @@ def query_bind_game_account( inner = _call("", 0) if inner is not None: detail["inner_bind_status"] = inner.to_dict() - if inner.status == 200 and _bind_game_account_has_role(inner): - chosen = inner - detail["bind_status_source"] = "inner" + if inner.status == 200: + inner_role = _bind_game_account_candidate(inner, "inner") + + current_role = outer_role if outer_role["role_name"] else None + role_identity_changed = bool( + current_role + and inner_role + and ( + inner_role["role_name"] != current_role["role_name"] + or ( + inner_role["role_id"] + and current_role["role_id"] + and inner_role["role_id"] != current_role["role_id"] + ) + ) + ) + pending_role = ( + inner_role + if inner_role + and inner_role["role_name"] + and (not current_role or role_identity_changed) + else None + ) + if current_role: + chosen = outer + elif pending_role: + chosen = inner + detail["bind_status_source"] = "inner_pending" + + candidates = [ + candidate + for candidate in (outer_role, inner_role) + if candidate and candidate["role_name"] + ] + detail.update( + { + "outer_bind_role": outer_role, + "inner_bind_role": inner_role, + "current_role": current_role, + "pending_role": pending_role, + "role_candidates": candidates, + "has_pending_role": bool(pending_role), + } + ) detail["bind_status"] = chosen.to_dict() return chosen, detail diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 42ec2ec..2cf27ae 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -1071,11 +1071,11 @@ class TestCollabCkProbe(TestCollabBase): assert inner["is_use_outer_act_id"] == 0 assert inner["scene"] == "undefined" - def test_verify_queries_second_path_for_latest_role(self): - """外层返回旧角色时,必须取第二路(outerActId=空)的最新角色。""" + def test_verify_uses_outer_current_role_instead_of_inner_pending_role(self): + """待绑定状态取外层当前角色;内层新角色不能覆盖外部单绑定快照。""" order = self._row_with_ck(uid="1199647239697") - class StaleThenLatestClient: + class CurrentThenPendingClient: def __init__(self): self.calls: list[dict] = [] @@ -1085,7 +1085,7 @@ class TestCollabCkProbe(TestCollabBase): return FakeBindResp(nick="冰点", role="软芙乎") return FakeBindResp(nick="冰点", role="唉哟酪酪") - client = StaleThenLatestClient() + client = CurrentThenPendingClient() collab.verify_ck(self.db, self.user, order, client=client) @@ -1094,11 +1094,11 @@ class TestCollabCkProbe(TestCollabBase): assert inner["outer_act_id"] == "" assert inner["gid"] == collab.PROBE_GID == 3203 assert inner["scene"] == "undefined" - # 快照落的是第二路的最新角色,而不是外层旧角色 - assert order.bind_game_role == "唉哟酪酪" + # 2026-11-14 HAR:外层是当前绑定,内层是待确认新角色。 + assert order.bind_game_role == "软芙乎" assert order.bind_game_account == "冰点" - assert "唉哟酪酪" in order.ck_check_message - assert "软芙乎" not in order.ck_check_message + assert "软芙乎" in order.ck_check_message + assert "唉哟酪酪" not in order.ck_check_message def test_verify_marks_invalid_when_status_is_not_200(self): order = self._row_with_ck() diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py index 544a4bd..fdd128b 100644 --- a/tests/test_huya_elite_query.py +++ b/tests/test_huya_elite_query.py @@ -733,6 +733,74 @@ def test_query_all_combines_points_elite_and_role(monkeypatch): ) +def test_query_all_saves_current_role_and_reports_pending(monkeypatch): + shop_client = _StubClient( + redeem=_redeem(REDEEM_OPENED_HEX), order_pages=[[PAID_ELITE_ORDER]] + ) + runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) + current = { + "role_name": "巫师基尔斯84", + "role_id": "current-role", + "game_role": { + "role_name": "巫师基尔斯84", + "role_id": "current-role", + "plat_name": "苹果(iOS)", + "area_name": "微信", + }, + "channel": "苹果(iOS) / 微信", + "is_bind_account": 1, + "is_bind_role": 1, + "change_bind_time": 0, + "change_bind_day": 7, + "game_title": "和平精英", + } + pending = { + "role_name": "落地盒子怪cc", + "role_id": "pending-role", + "game_role": { + "role_name": "落地盒子怪cc", + "role_id": "pending-role", + "plat_name": "安卓(android)", + "area_name": "微信", + }, + "channel": "安卓(android) / 微信", + "is_bind_account": 1, + "is_bind_role": 1, + "change_bind_time": 0, + "change_bind_day": 0, + "game_title": "和平精英", + } + monkeypatch.setattr( + runner, + "_probe_bind_status", + lambda **kwargs: ( + _FakeBindStatus(), + { + "current_role": current, + "pending_role": pending, + "has_pending_role": True, + "outer_bind_role": current, + "inner_bind_role": pending, + }, + ), + 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["current_role_name"] == "巫师基尔斯84" + assert result["pending_role_name"] == "落地盒子怪cc" + assert result["pending_game_channel"] == "安卓(android) / 微信" + assert "角色 巫师基尔斯84,待确认 落地盒子怪cc" in message + assert account.game_name == "巫师基尔斯84" + assert account.game_channel == "苹果(iOS) / 微信" + + def test_query_all_reports_not_opened_and_checks_order_state(monkeypatch): shop_client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX)) runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) @@ -1085,6 +1153,91 @@ def test_inner_bind_probe_keeps_gid_and_empties_outer_act_id(monkeypatch): assert calls[1]["gid"] == 3203 +def test_bind_probe_separates_current_and_pending_roles(monkeypatch): + """待绑定状态两路都返回角色时,当前角色是外层,内层只能作为待确认候选。""" + current = _FakeBindResponse( + role_name="巫师基尔斯84", + is_need_act_check=1, + ) + current.accountData.gameRole.platName = "苹果(iOS)" + current.accountData.gameRole.areaName = "微信" + pending = _FakeBindResponse( + role_name="落地盒子怪cc", + is_need_act_check=0, + ) + pending.accountData.gameRole.platName = "安卓(android)" + pending.accountData.gameRole.areaName = "微信" + responses = [current, pending] + client = _CkProbeClient(None) + + def fake_check(**_kwargs): + return responses.pop(0) + + monkeypatch.setattr(client, "check_user_bind_game_account", fake_check) + runner, _marks = _task_runner(monkeypatch, "query_game_name") + + status, detail = runner._resolve_bind_status( + client, + UID, + COOKIE, + b_act_id_int=0, + outer_act_id="17096", + gid=3203, + is_use_outer_act_id=0, + scene="undefined", + ) + + assert status.accountData.gameRole.roleName == "巫师基尔斯84" + assert detail["current_role"]["role_name"] == "巫师基尔斯84" + assert detail["current_role"]["channel"] == "苹果(iOS) / 微信" + assert detail["pending_role"]["role_name"] == "落地盒子怪cc" + assert detail["pending_role"]["channel"] == "安卓(android) / 微信" + assert detail["has_pending_role"] is True + + +def test_query_game_name_saves_current_role_and_reports_pending(monkeypatch): + """查询角色时当前绑定写账号,待确认只进任务结果,不能覆盖 game_name。""" + current = _FakeBindResponse( + role_name="巫师基尔斯84", + is_need_act_check=1, + ) + current.accountData.gameRole.platName = "苹果(iOS)" + current.accountData.gameRole.areaName = "微信" + pending = _FakeBindResponse( + role_name="落地盒子怪cc", + is_need_act_check=0, + ) + pending.accountData.gameRole.platName = "安卓(android)" + pending.accountData.gameRole.areaName = "微信" + responses = [current, pending] + client = _CkProbeClient(None) + + def fake_check(**_kwargs): + return responses.pop(0) + + monkeypatch.setattr(client, "check_user_bind_game_account", fake_check) + runner, marks = _query_game_runner(monkeypatch, client) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_query_game_name( + None, + None, + account, + {"uid": UID, "cookie": COOKIE}, + {"bind_gid": "3203", "outer_act_id": "17096"}, + ) + + status, message, result = marks[-1] + assert status == "success" + assert message == "当前已绑定: 巫师基尔斯84;待确认: 落地盒子怪cc" + assert result["current_role_name"] == "巫师基尔斯84" + assert result["pending_role_name"] == "落地盒子怪cc" + assert result["pending_game_channel"] == "安卓(android) / 微信" + assert result["bind_ready_for_confirm"] is True + assert account.game_name == "巫师基尔斯84" + assert account.game_channel == "苹果(iOS) / 微信" + + def test_confirm_bind_uses_latest_har_protocol(monkeypatch): """确认绑定复用抓包里的 gid=3203 / bActId=0 / outerActId=17096。""" client = _BindConfirmClient() diff --git a/web/backend/services/huya_runner_bind.py b/web/backend/services/huya_runner_bind.py index 463122d..68add9a 100644 --- a/web/backend/services/huya_runner_bind.py +++ b/web/backend/services/huya_runner_bind.py @@ -67,6 +67,58 @@ class BindMixin: "game_role": game_role.to_dict(), } + def _bind_query_roles( + self, bind_status: Any, bind_query_result: dict[str, Any] + ) -> tuple[dict, dict | None, dict | None]: + """从统一查询结果中取当前绑定和待确认角色。 + + 新查询结果带 ``current_role`` / ``pending_role``;兼容旧测试桩或旧任务数据时, + 没有这两个标记就继续把 ``bind_status`` 当成唯一的当前角色。 + """ + has_role_split = ( + "current_role" in bind_query_result or "pending_role" in bind_query_result + ) + current = bind_query_result.get("current_role") + pending = bind_query_result.get("pending_role") + selected = ( + {} + if has_role_split and (current or pending) + else self._bind_role_result(bind_status) + ) + if not has_role_split: + current = selected if selected.get("role_name") else None + pending = None + return ( + selected, + current if isinstance(current, dict) else None, + pending if isinstance(pending, dict) else None, + ) + + @staticmethod + def _bind_change_state_from_role(role_info: dict | None) -> dict: + """用标准角色候选计算绑定/换绑时间状态。""" + if not role_info: + return { + "is_bound": False, + "can_change_bind": True, + "change_bind_time": 0, + "change_available_at": "", + "change_bind_day": 0, + } + is_bound = bool( + role_info.get("is_bind_account") and role_info.get("is_bind_role") + ) + change_time = int(role_info.get("change_bind_time") or 0) + now = int(datetime.now(UTC).timestamp()) + can_change = not is_bound or not change_time or change_time <= now + return { + "is_bound": is_bound, + "can_change_bind": can_change, + "change_bind_time": change_time, + "change_available_at": HuyaBatchRunnerCore._format_local_time(change_time), + "change_bind_day": int(role_info.get("change_bind_day") or 0), + } + @staticmethod def _role_channel(bind_status) -> str: game_role = bind_status.accountData.gameRole @@ -190,10 +242,27 @@ class BindMixin: ) def _apply_role_to_account(self, account: HuyaAccount, bind_status, status: str): - role_name = self._role_name(bind_status) + role_info = self._bind_role_result(bind_status) + self._apply_role_info_to_account(account, role_info, status) + + @staticmethod + def _apply_role_info_to_account( + account: HuyaAccount, role_info: dict | None, status: str + ) -> None: + """只允许“当前已绑定角色”调用这里;待确认角色不得污染账号主角色。""" + role_name = str((role_info or {}).get("role_name") or "") + game_role = (role_info or {}).get("game_role") or {} + channel = " / ".join( + str(part) + for part in ( + game_role.get("plat_name") if isinstance(game_role, dict) else "", + game_role.get("area_name") if isinstance(game_role, dict) else "", + ) + if part + ) account.status = status account.game_name = role_name or account.game_name - account.game_channel = self._role_channel(bind_status) or account.game_channel + account.game_channel = channel or account.game_channel account.updated_at = datetime.now(UTC) @staticmethod @@ -300,8 +369,33 @@ class BindMixin: previous_phase = result.get("bind_phase") ready = self._bind_ready_result(bind_status) + selected_info, current_info, pending_info = self._bind_query_roles( + bind_status, bind_query_result + ) + has_role_split = ( + "current_role" in bind_query_result + or "pending_role" in bind_query_result + ) + target_info = pending_info or ( + selected_info + if not has_role_split and selected_info.get("role_name") + else None + ) + current_role_name = str((current_info or {}).get("role_name") or "") + pending_role_name = str((target_info or {}).get("role_name") or "") + ready.update( + { + "current_role_name": current_role_name, + "current_game_role": (current_info or {}).get("game_role"), + "current_game_channel": (current_info or {}).get("channel", ""), + "pending_role_name": pending_role_name, + "pending_game_role": (target_info or {}).get("game_role"), + "pending_game_channel": (target_info or {}).get("channel", ""), + "role_name": pending_role_name, + } + ) # 角色未就绪时保留扫码阶段文案,避免状态来回跳。 - if not ready["role_name"] and previous_phase in { + if not pending_role_name and previous_phase in { "waiting_scan", "qrcode_scanned", "qrcode_completed", @@ -309,9 +403,8 @@ class BindMixin: ready["bind_phase"] = previous_phase result.update(ready) result["bind_polling"] = True - role_name = ready["role_name"] + role_name = pending_role_name if role_name: - self._apply_role_to_account(account, bind_status, "game_queried") result["bind_polling"] = False self._update_task_progress( worker_db, @@ -598,8 +691,17 @@ class BindMixin: ) return - role_info = self._bind_role_result(bind_status) - change_state = self._bind_change_state(bind_status) + selected_info, current_info, pending_info = self._bind_query_roles( + bind_status, bind_query_result + ) + role_info = current_info or pending_info or selected_info + current_role_name = str((current_info or {}).get("role_name") or "") + pending_role_name = str((pending_info or {}).get("role_name") or "") + change_state = ( + self._bind_change_state_from_role(current_info) + if current_info + else self._bind_change_state(bind_status) + ) result = { "bind_act_id": b_act_id_int, "bind_gid": protocol["bind_gid"], @@ -607,13 +709,40 @@ class BindMixin: **role_info, **change_state, **bind_query_result, - "bind_ready_for_confirm": bool(role_info["role_name"]), - "bind_phase": "role_ready" if role_info["role_name"] else "waiting_role", + "role_name": current_role_name or pending_role_name, + "current_role_name": current_role_name, + "current_game_role": (current_info or {}).get("game_role"), + "current_game_channel": (current_info or {}).get("channel", ""), + "pending_role_name": pending_role_name, + "pending_game_role": (pending_info or {}).get("game_role"), + "pending_game_channel": (pending_info or {}).get("channel", ""), + "bind_ready_for_confirm": bool(pending_role_name), + "bind_confirmed": bool(current_role_name), + "bind_phase": ( + "role_ready" + if pending_role_name + else ("confirmed" if current_role_name else "waiting_role") + ), } - role_name = role_info["role_name"] - if role_name: + if current_info: + self._apply_role_info_to_account(account, current_info, "game_queried") + elif pending_info: + # 待确认角色只供页面展示和后续确认,不写账号主角色。 + account.status = "game_not_bound" + account.updated_at = datetime.now(UTC) + elif selected_info.get("role_name"): self._apply_role_to_account(account, bind_status, "game_queried") - self._mark_task(worker_db, task, "success", f"角色: {role_name}", result) + + if current_role_name or pending_role_name: + if current_role_name and pending_role_name: + message = ( + f"当前已绑定: {current_role_name};待确认: {pending_role_name}" + ) + elif current_role_name: + message = f"角色: {current_role_name}" + else: + message = f"待确认角色: {pending_role_name}" + self._mark_task(worker_db, task, "success", message, result) return account.status = "game_not_bound" @@ -668,8 +797,11 @@ class BindMixin: ) return - role_info = self._bind_role_result(role_status) - role_name = role_info["role_name"] + selected_info, current_info, pending_info = self._bind_query_roles( + role_status, role_query_result + ) + role_info = pending_info or current_info or selected_info + role_name = str(role_info.get("role_name") or "") if not role_name: result = { "bind_act_id": b_act_id_int, @@ -706,6 +838,12 @@ class BindMixin: "confirm_result": confirm_resp.to_dict(), "before_bind_status": role_status.to_dict(), **role_query_result, + "before_current_role_name": str( + (current_info or {}).get("role_name") or "" + ), + "before_pending_role_name": str( + (pending_info or {}).get("role_name") or "" + ), } self._mark_task( worker_db, @@ -736,8 +874,9 @@ class BindMixin: "before_bind_status": role_status.to_dict(), **role_info, **role_query_result, + "bind_role_name": role_name, } - self._apply_role_to_account(account, role_status, "bind_confirmed") + self._apply_role_info_to_account(account, role_info, "bind_confirmed") self._mark_task( worker_db, task, "success", f"确认绑定: {role_name}", result ) @@ -753,23 +892,40 @@ class BindMixin: **role_info, **role_query_result, "refresh_error": refreshed_query_result, + "bind_role_name": role_name, } - self._apply_role_to_account(account, role_status, "bind_confirmed") + self._apply_role_info_to_account(account, role_info, "bind_confirmed") self._mark_task( worker_db, task, "success", f"确认绑定: {role_name}", result ) return - refreshed_confirmed = bool( - refreshed_status.accountData.isBindAcount - and refreshed_status.accountData.isBindRole + refreshed_selected, refreshed_current, refreshed_pending = ( + self._bind_query_roles(refreshed_status, refreshed_query_result) ) - refreshed_role_info = self._bind_role_result(refreshed_status) - final_status = ( - refreshed_status if refreshed_role_info["role_name"] else role_status + refreshed_confirmed = bool( + refreshed_current or refreshed_selected.get("role_name") + ) + confirmed_target = ( + refreshed_pending + if refreshed_pending + and str(refreshed_pending.get("role_name") or "") == role_name + else None + ) + confirmed_pending_role = bool(pending_info) + refreshed_current_is_target = bool( + refreshed_current + and str(refreshed_current.get("role_name") or "") == role_name ) final_role_info = ( - refreshed_role_info if refreshed_role_info["role_name"] else role_info + (refreshed_current if refreshed_current_is_target else confirmed_target) + if confirmed_pending_role + else refreshed_current + ) + final_role_info = ( + final_role_info + or (refreshed_selected if refreshed_selected.get("role_name") else None) + or role_info ) result = { "bind_act_id": b_act_id_int, @@ -782,7 +938,8 @@ class BindMixin: **role_query_result, "refresh_result": refreshed_query_result, **final_role_info, + "bind_role_name": final_role_info.get("role_name") or role_name, } - self._apply_role_to_account(account, final_status, "bind_confirmed") + self._apply_role_info_to_account(account, final_role_info, "bind_confirmed") role_name = final_role_info["role_name"] or role_name or "已绑定" self._mark_task(worker_db, task, "success", f"确认绑定: {role_name}", result) diff --git a/web/backend/services/huya_runner_elite.py b/web/backend/services/huya_runner_elite.py index 82896e6..d569697 100644 --- a/web/backend/services/huya_runner_elite.py +++ b/web/backend/services/huya_runner_elite.py @@ -74,8 +74,17 @@ class EliteMixin: def _bind_role_result(bind_status) -> dict: ... @staticmethod def _bind_change_state(bind_status) -> dict: ... + def _bind_query_roles( + self, bind_status: Any, bind_query_result: dict[str, Any] + ) -> tuple[dict, dict | None, dict | None]: ... + @staticmethod + def _bind_change_state_from_role(role_info: dict | None) -> dict: ... @staticmethod def _role_channel(bind_status) -> str: ... + @staticmethod + def _apply_role_info_to_account( + account: HuyaAccount, role_info: dict | None, status: str + ) -> None: ... def _resolve_bind_status( self, client: Any, uid: int, cookie: str, b_act_id_int: int ): ... @@ -363,7 +372,8 @@ class EliteMixin: result: dict[str, Any] = {"uid": uid} failures: list[str] = [] message_parts: list[str] = [] - role_name = "" + current_role_name = "" + pending_role_name = "" # ---- 角色绑定 / CK 有效性(先做,失效就不空跑后面两个接口) ---- protocol = self._bind_protocol(config_info, self.payload) @@ -411,21 +421,55 @@ class EliteMixin: 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 "" + selected_info, current_info, pending_info = self._bind_query_roles( + bind_status, bind_query_result + ) + role_info = current_info or pending_info or selected_info + current_role_name = str((current_info or {}).get("role_name") or "") + pending_role_name = str((pending_info or {}).get("role_name") or "") + has_role_split = ( + "current_role" in bind_query_result + or "pending_role" in bind_query_result + ) + change_state = ( + self._bind_change_state_from_role(current_info) + if has_role_split and current_info + else self._bind_change_state(bind_status) + ) result.update( { **role_info, - **self._bind_change_state(bind_status), + **change_state, **bind_query_result, + "role_name": current_role_name or pending_role_name, + "current_role_name": current_role_name, + "current_game_role": (current_info or {}).get("game_role"), + "current_game_channel": (current_info or {}).get("channel", ""), + "pending_role_name": pending_role_name, + "pending_game_role": (pending_info or {}).get("game_role"), + "pending_game_channel": (pending_info or {}).get("channel", ""), + "bind_ready_for_confirm": bool(pending_role_name), + "bind_confirmed": bool(current_role_name), } ) - if role_name: - account.game_name = role_name + if current_info and has_role_split: + self._apply_role_info_to_account( + account, current_info, account.status or "all_queried" + ) + elif selected_info.get("role_name") and not has_role_split: + account.game_name = str(selected_info.get("role_name") or "") account.game_channel = ( self._role_channel(bind_status) or account.game_channel ) - message_parts.append(f"角色 {role_name}") + + if current_role_name and pending_role_name: + message_parts.append( + f"角色 {current_role_name},待确认 {pending_role_name}" + ) + elif current_role_name: + message_parts.append(f"角色 {current_role_name}") + elif pending_role_name: + message_parts.append(f"待确认角色 {pending_role_name}") else: message_parts.append("未绑定角色") @@ -524,7 +568,11 @@ class EliteMixin: account.points = points if points is not None else account.points result["failures"] = failures - fetched_anything = points is not None or role_name or opened is not None + fetched_anything = ( + points is not None + or bool(current_role_name or pending_role_name) + or opened is not None + ) if not fetched_anything: self._mark_task( worker_db, diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 26b19b7..41a4638 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -114,6 +114,27 @@ function resultText(task: HuyaTaskItem | undefined, key: string): string { return value == null ? '' : String(value); } +function roleDisplay(task: HuyaTaskItem | undefined, row: HuyaAccountItem) { + const hasRoleSplit = Boolean( + task?.result + && ('current_role_name' in task.result || 'pending_role_name' in task.result), + ); + if (!hasRoleSplit) { + return { + currentName: row.game_name || '', + currentChannel: row.game_channel || '', + pendingName: '', + pendingChannel: '', + }; + } + return { + currentName: resultText(task, 'current_role_name'), + currentChannel: resultText(task, 'current_game_channel'), + pendingName: resultText(task, 'pending_role_name'), + pendingChannel: resultText(task, 'pending_game_channel'), + }; +} + /** 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}`; @@ -181,6 +202,14 @@ function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | un return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0]; } +function latestRoleTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | undefined { + return tasks + .filter((task) => task.account_id === accountId) + .filter((task) => ['query_game_name', 'query_all', 'get_bind_qr', 'confirm_bind'].includes(task.task_type) + || Boolean(task.result && ('current_role_name' in task.result || 'pending_role_name' in task.result))) + .sort((a, b) => b.id - a.id)[0]; +} + function taskResultRecords(task: HuyaTaskItem | null | undefined): Record[] { const records = task?.result?.records; return Array.isArray(records) @@ -784,10 +813,43 @@ export default function HuyaElitePage() { { title: '#', width: '4%', align: 'center', render: (_value, _row, index) => {index + 1} }, { title: '账号', width: '15%', align: 'center', ellipsis: true, render: (_value, row) => {row.nickname || row.username || row.uid}UID {row.uid || '-'} }, { title: '手机号', width: '10%', align: 'center', ellipsis: true, render: (_value, row) => row.game_phone || - }, - { title: '游戏名', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const expired = ckExpired(latestTask(tasks, row.id), row); return {row.game_name || {expired ? '角色未查到' : '未查询'}}{row.game_channel && {row.game_channel}}; } }, + { + title: '游戏名', + width: '20%', + align: 'center', + ellipsis: true, + render: (_value, row) => { + const task = latestTask(tasks, row.id); + const roleTask = latestRoleTask(tasks, row.id); + const expired = ckExpired(task, row); + const role = roleDisplay(roleTask, row); + const hasRoleSplit = Boolean( + roleTask?.result + && ('current_role_name' in roleTask.result || 'pending_role_name' in roleTask.result), + ); + return ( + + + {role.currentName || ( + + {expired ? '角色未查到' : hasRoleSplit ? '当前未绑定' : '未查询'} + + )} + + {role.currentChannel && {role.currentChannel}} + {role.pendingName && ( + <> + 待确认 {role.pendingName} + {role.pendingChannel && {role.pendingChannel}} + + )} + + ); + }, + }, { title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const opening = task?.result?.elite_opening === true; return {opening ? '开通中' : 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); const records = taskResultRecords(task); return task ? {TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'}{formatTime(task.finished_at || task.created_at)}{task.task_type === 'query_exchange_records' && records.length > 0 && } : 暂无操作; } }, + { title: '最近操作', width: '18%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); const records = taskResultRecords(task); return task ? {TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'}{formatTime(task.finished_at || task.created_at)}{task.task_type === 'query_exchange_records' && records.length > 0 && } : 暂无操作; } }, { 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 (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) 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 || '待操作'}; } }, ];