diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py index 70a1372..6680e0f 100644 --- a/tests/test_huya_elite_query.py +++ b/tests/test_huya_elite_query.py @@ -514,9 +514,63 @@ def test_query_all_fails_when_points_center_unreachable(monkeypatch): None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"} ) - status, message, _result = marks[-1] + status, message, result = marks[-1] assert status == "failed" assert "积分中心接口无响应" in message + # 两条通道都没回包:CK 失效标记跟着失败结果一起落库(前端整行标红) + assert result["ck_expired"] is True + assert account.status == "ck_expired" + + +def test_query_all_flags_ck_expired_when_role_lookup_has_no_response(monkeypatch): + """角色接口整包没有回包 = 这个 CK 已经不认了(真机:外部导入的过期 CK 就这表现)。 + + 真机日志(logs/app-2026-10-15.log,hy_297838517)里 + ``webActUI.checkUserBindGameAccount`` 的响应只有 ``newdata keys: ['']``(没有 tRsp), + ``_resolve_bind_status`` 因此返回 None;积分中心走另一条通道却还能返回 200, + 所以不能凭「积分查到了」就当成功,必须打 ck_expired 让页面标红。 + """ + shop_client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX)) + runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) + monkeypatch.setattr( + runner, "_resolve_bind_status", lambda **kwargs: (None, {}), raising=False + ) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_query_all( + None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"} + ) + + status, message, result = marks[-1] + # 宝典/积分查到了,任务本身不算失败(结果照样展示),但 CK 失效标记必须带上 + assert status == "success" + assert result["ck_expired"] is True + assert result["ck_expired_reason"] == "角色信息接口无响应" + assert account.status == "ck_expired" + assert "角色信息接口无响应" in message + assert "疑似 CK 已失效" in message + + +def test_query_all_keeps_business_error_out_of_ck_expired(monkeypatch): + """接口有回包、只是业务码非 200(例如活动已下线)不算 CK 失效,避免整批误标红。""" + shop_client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX)) + runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) + monkeypatch.setattr( + runner, + "_resolve_bind_status", + lambda **kwargs: (_FakeBindStatus(status=601, msg="活动已下线"), {}), + 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 result.get("ck_expired") is None + assert account.status == "all_queried" + assert "活动已下线" in message def test_shop_client_falls_back_to_http_when_wss_init_fails(monkeypatch): diff --git a/web/backend/services/huya_runner_elite.py b/web/backend/services/huya_runner_elite.py index cc66c52..e9bb88b 100644 --- a/web/backend/services/huya_runner_elite.py +++ b/web/backend/services/huya_runner_elite.py @@ -10,6 +10,13 @@ * 开通时间 —— ``revenueWebUI.queryUserOrderList`` 里已支付的宝典订单,取最近一次支付时间。 * 有效期 —— 商城订单(orderDetailV5)与积分中心都**不返回**到期时间,所以留空, 界面上「有就显示」。 +* CK 失效 —— 「一键查询」里角色走活动通道(``webActUI.checkUserBindGameAccount``), + 真机(2026-10-15,外部导入的过期 CK)表现为**整个请求没有 tRsp 回包** + (日志里 ``响应 newdata keys: ['']``),于是 ``_resolve_bind_status`` 返回 None。 + 这种情况按「CK 已失效」处理:任务 result 打 ``ck_expired`` 标记、账号状态置 + ``ck_expired``,前端把该行标红提示重新登录/重新导入 Cookie。 + 积分中心走另一条通道(baseinfo 里的 cookie 校验更松),过期 CK 仍可能返回 200, + 所以不能拿积分能不能查来判 CK 有效性。 """ from __future__ import annotations @@ -335,6 +342,9 @@ class EliteMixin: self.payload.get("bind_act_id") or config_info.get("bind_act_id") ) role_name = "" + # 角色查不到 = 这个 CK 已经不认了(模块头里有真机依据),整行要标红提醒 + ck_expired = False + ck_expired_reason = "" if not b_act_id_int: failures.append("未配置绑定 bActId") else: @@ -346,7 +356,9 @@ class EliteMixin: b_act_id_int=b_act_id_int, ) if bind_status is None: - failures.append("角色信息接口无响应") + ck_expired = True + ck_expired_reason = "角色信息接口无响应" + failures.append(ck_expired_reason) elif bind_status.status != 200: failures.append( bind_status.msg or f"角色信息查询失败: {bind_status.status}" @@ -372,9 +384,14 @@ class EliteMixin: message_parts.append("未绑定角色") account.points = points if points is not None else account.points - account.status = "all_queried" + # CK 失效时账号状态单独标出来(前端「状态」列据此显示红色「CK 失效」) + account.status = "ck_expired" if ck_expired else "all_queried" account.updated_at = datetime.now(UTC) result["failures"] = failures + if ck_expired: + result["ck_expired"] = True + result["ck_expired_reason"] = ck_expired_reason + ck_hint = ";疑似 CK 已失效,请重新登录或重新导入 Cookie" if ck_expired else "" fetched_anything = points is not None or role_name or opened is not None if not fetched_anything: @@ -382,7 +399,7 @@ class EliteMixin: worker_db, task, "failed", - ";".join(failures) or "一键查询全部失败", + (";".join(failures) or "一键查询全部失败") + ck_hint, result, ) return @@ -390,4 +407,4 @@ class EliteMixin: message = "|".join(message_parts) or "一键查询完成" if failures: message += f";部分失败: {';'.join(failures)}" - self._mark_task(worker_db, task, "success", message, result) + self._mark_task(worker_db, task, "success", message + ck_hint, result) diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 25c8179..0128c5c 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -101,6 +101,16 @@ function isLoopbackUrl(url: string): boolean { return /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:|\/|$)/i.test(url); } +/** + * CK 失效:一键查询里角色走活动通道,外部导入的 CK 过期后这个接口直接没有回包, + * 后端会打 `result.ck_expired` 标记并把账号状态置为 `ck_expired`(见 + * backend/services/huya_runner_elite.py 模块头)。这种行必须红色提醒重新登录/重新导入, + * 不能因为积分、宝典查到了就显示绿色「成功」。 + */ +function ckExpired(task: HuyaTaskItem | undefined, row: HuyaAccountItem): boolean { + return task?.result?.ck_expired === true || row.status === 'ck_expired'; +} + function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | undefined { return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0]; } @@ -329,12 +339,12 @@ 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) => {row.game_name || 未查询}{row.game_channel && {row.game_channel}} }, + { 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: '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); return task ? {TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'}{formatTime(task.finished_at || task.created_at)} : 暂无操作; } }, + { 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: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return {TASK_STATUS_LABELS[status] || status || '待操作'}; } }, + { 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 || '待操作'}; } }, ]; const operationBody = ( diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 7e7b163..93ef959 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -94,6 +94,7 @@ const ACCOUNT_STATUS_LABELS: Record = { bind_confirmed: '已绑定', goods_exchanged: '已兑换', recharge_order_created: '待支付', + ck_expired: 'CK 失效', }; const ACCOUNT_STATUS_COLORS: Record = { @@ -112,6 +113,7 @@ const ACCOUNT_STATUS_COLORS: Record = { bind_confirmed: 'success', goods_exchanged: 'success', recharge_order_created: 'processing', + ck_expired: 'error', }; const HUYA_WORKBENCH_ACCOUNT_IDS_KEY = 'huya_task_workbench_account_ids';