fix(huya): 区分当前绑定与待确认角色

This commit is contained in:
yml
2026-11-14 09:33:51 +08:00
parent 269a8d1b11
commit 5b117e7641
6 changed files with 572 additions and 55 deletions
+110 -13
View File
@@ -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
+8 -8
View File
@@ -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()
+153
View File
@@ -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()
+181 -24
View File
@@ -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)
+56 -8
View File
@@ -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,
+64 -2
View File
@@ -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<string, unknown>[] {
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) => <Text type="secondary">{index + 1}</Text> },
{ title: '账号', width: '15%', align: 'center', ellipsis: true, render: (_value, row) => <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Text strong ellipsis title={row.nickname || row.username || row.uid}>{row.nickname || row.username || row.uid}</Text><Text type="secondary" style={{ fontSize: 12 }} ellipsis title={row.username}>UID {row.uid || '-'}</Text></Space> },
{ title: '手机号', width: '10%', align: 'center', ellipsis: true, render: (_value, row) => row.game_phone || <Text type="secondary">-</Text> },
{ title: '游戏名', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const expired = ckExpired(latestTask(tasks, row.id), row); return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type={expired ? 'danger' : 'secondary'}>{expired ? '角色未查到' : '未查询'}</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space>; } },
{
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 (
<Space direction="vertical" align="center" size={1} style={{ width: '100%' }}>
<Text ellipsis title={role.currentName || role.currentChannel}>
{role.currentName || (
<Text type={expired ? 'danger' : 'secondary'}>
{expired ? '角色未查到' : hasRoleSplit ? '当前未绑定' : '未查询'}
</Text>
)}
</Text>
{role.currentChannel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{role.currentChannel}</Text>}
{role.pendingName && (
<>
<Tag color="processing" style={{ marginInlineEnd: 0 }}>待确认 {role.pendingName}</Tag>
{role.pendingChannel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{role.pendingChannel}</Text>}
</>
)}
</Space>
);
},
},
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const opening = task?.result?.elite_opening === true; return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={opening ? 'processing' : row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{opening ? '开通中' : row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space>; } },
{ title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
{ 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 ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text>{task.task_type === 'query_exchange_records' && records.length > 0 && <Button size="small" type="link" onClick={(event) => { event.stopPropagation(); setRecordsTask(task); }}>查看记录</Button>}</Space> : <Text type="secondary">暂无操作</Text>; } },
{ 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 ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text>{task.task_type === 'query_exchange_records' && records.length > 0 && <Button size="small" type="link" onClick={(event) => { event.stopPropagation(); setRecordsTask(task); }}>查看记录</Button>}</Space> : <Text type="secondary">暂无操作</Text>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; 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 <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { 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 ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
{ title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (ckExpired(task, row)) return <Tooltip title={`${resultText(task, 'ck_expired_reason') || '角色信息查不到'} —— 请重新登录或重新导入 Cookie`}><Tag color="error">CK 失效</Tag></Tooltip>; const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
];