diff --git a/core/huya/cookie_utils.py b/core/huya/cookie_utils.py index 8691e7c..3ed69aa 100644 --- a/core/huya/cookie_utils.py +++ b/core/huya/cookie_utils.py @@ -76,21 +76,31 @@ def browser_login_script( *, domain: str = ".huya.com", ttl_days: int = 180, + secure: bool = True, ) -> str: """生成在用户自己浏览器控制台执行的登录脚本。 脚本把 Cookie 写入指定域后跳转目标页;Cookie 值以 JSON 数组内联, - 保证引号、反斜杠等字符安全转义。 + 保证引号、反斜杠等字符安全转义。属性与扩展注入保持一致(默认 Secure), + 并先校验当前页面确实在 huya.com 域下——否则 document.cookie 会静默失效, + 操作者会以为已登录。 """ payload = json.dumps( [[key, value] for key, value in pairs.items()], ensure_ascii=False ) target = json.dumps(target_url, ensure_ascii=False) ttl_ms = max(1, ttl_days) * 86_400_000 + suffix = ";secure" if secure else "" return ( - f"(function(){{var c={payload};" + f"(function(){{" + f"if(!/(^|\\.)huya\\.com$/i.test(location.hostname)){{" + f"alert('请在 huya.com 域下的页面执行本脚本,当前页面:'+location.hostname);" + f"return;}}" + f"var c={payload};" f"var e=new Date(Date.now()+{ttl_ms}).toUTCString();" f"for(var i=0;i`),但只响应本后台页面派发的固定事件,且只写 `huya.com` 域的 Cookie。 +- 扩展为内部工具,通过开发者模式加载;内容脚本只注入后台站点白名单,service worker 二次校验消息来源站点,且只写 `huya.com` 域的 Cookie。 +- 仍存在的边界:注入请求由后台页面用 `CustomEvent` 派发,页面内其它脚本理论上可以监听或伪造同名事件(页面派发的事件 `isTrusted` 恒为 false,无法据此区分真伪);因此不要把不受信任的脚本引入后台页面。 +- 扩展写入的 Cookie 非 `HttpOnly`(`chrome.cookies` 限制),虎牙域内脚本可读,这是本方案的固有取舍。 - 请勿把脚本、Cookie 或扩展转给无关人员;如需共享操作,建议使用系统的账号授权机制而非转发凭证。 diff --git a/tests/test_huya_browser_exchange.py b/tests/test_huya_browser_exchange.py new file mode 100644 index 0000000..62d9e78 --- /dev/null +++ b/tests/test_huya_browser_exchange.py @@ -0,0 +1,247 @@ +"""虎牙「浏览器兑换」:目标页三选一、凭证签发审计与扩展注入边界。""" + +import json +import re +from pathlib import Path + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +from web.backend.database import Base +from web.backend.huya_defaults import HUYA_DEFAULT_ROOM_PID +from web.backend.models import AuditLog, HuyaAccount, HuyaConfig, User +from web.backend.routers.huya import create_browser_exchange_kit +from web.backend.schemas import HuyaBrowserExchangeRequest +from web.backend.services.huya_exchange_targets import ( + EXCHANGE_TARGET_KEYS, + exchange_target_url, +) +from web.backend.services.huya_runner_bind import HUYA_BIND_ROOM_ID + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXTENSION_DIR = REPO_ROOT / "web" / "extension" / "huya-exchange-helper" + + +class TestHuyaBrowserExchangeKit: + def setup_method(self): + self.engine = create_engine("sqlite://") + + @event.listens_for(self.engine, "connect") + def enable_foreign_keys(connection, _): + connection.execute("PRAGMA foreign_keys=ON") + + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + self.user = User(username="operator", password_hash="hash", role="super_admin") + self.account = HuyaAccount( + uid="100", yyuid="100", cookie="yyuid=100; udb_uid=100" + ) + self.session.add_all([self.user, self.account]) + self.session.commit() + + def teardown_method(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def _kit(self, **kwargs): + return create_browser_exchange_kit( + self.account.id, db=self.session, current=self.user, **kwargs + ) + + def test_default_target_is_elite_and_three_options_are_returned(self): + kit = self._kit() + assert kit["target"] == "elite" + assert [option["key"] for option in kit["targets"]] == [ + "live_room", + "elite", + "esports", + ] + assert [option["label"] for option in kit["targets"]] == [ + "直播间", + "精英宝典", + "电竞宝典", + ] + # 默认目标地址=精英宝典(沿用历史行为:绑定/充值活动的 zt 活动页) + assert kit["target_url"].startswith( + "https://zt.huya.com/b02faae1/pc/index.html?" + ) + assert f"pid={HUYA_DEFAULT_ROOM_PID}" in kit["target_url"] + assert kit["cookie_string"] == "yyuid=100; udb_uid=100" + assert kit["cookie_script"].endswith("})();") + + def test_each_target_resolves_to_its_own_page(self): + live_room = exchange_target_url("live_room", HUYA_DEFAULT_ROOM_PID) + esports = exchange_target_url("esports", HUYA_DEFAULT_ROOM_PID) + + assert live_room == f"https://www.huya.com/{HUYA_BIND_ROOM_ID}" + assert esports.startswith("https://zt.huya.com/50ea007f/pc/index.html?") + assert f"roomid={HUYA_BIND_ROOM_ID}" in esports + # 三个目标必须互不相同,否则选择器没有意义 + urls = { + exchange_target_url(key, HUYA_DEFAULT_ROOM_PID) + for key in EXCHANGE_TARGET_KEYS + } + assert len(urls) == len(EXCHANGE_TARGET_KEYS) + + for key in EXCHANGE_TARGET_KEYS: + kit = self._kit(req=HuyaBrowserExchangeRequest(target=key)) + assert kit["target"] == key + expected = exchange_target_url(key, HUYA_DEFAULT_ROOM_PID) + assert kit["target_url"] == expected + # 控制台脚本里的跳转地址必须与所选目标一致 + assert json.dumps(expected, ensure_ascii=False) in kit["cookie_script"] + assert [option["url"] for option in kit["targets"]].count(expected) == 1 + + def test_schema_rejects_unknown_target(self): + with pytest.raises(ValidationError): + HuyaBrowserExchangeRequest(target="unknown") + for key in EXCHANGE_TARGET_KEYS: + assert HuyaBrowserExchangeRequest(target=key).target == key + # 三个 key 都在 schemas 的正则白名单里(两处常量不许漂移) + assert HuyaBrowserExchangeRequest().target is None + + def test_kit_issuance_is_audited_with_target(self): + self._kit(req=HuyaBrowserExchangeRequest(target="esports")) + rows = self.session.query(AuditLog).all() + assert len(rows) == 1 + entry = rows[0] + assert entry.action == "huya:exchange:kit" + assert entry.target == f"huya_account:{self.account.id}" + assert entry.user_id == self.user.id + assert json.loads(entry.detail) == { + "account_id": self.account.id, + "target": "esports", + } + + def test_account_without_cookie_is_rejected(self): + self.account.cookie = "" + self.session.commit() + with pytest.raises(HTTPException) as excinfo: + self._kit() + assert excinfo.value.status_code == 400 + + def test_support_user_only_reaches_assigned_accounts(self): + support = User(username="cs", password_hash="hash", role="support") + foreign = HuyaAccount( + uid="200", yyuid="200", cookie="yyuid=200", assigned_to=self.user.id + ) + assigned = HuyaAccount( + uid="300", yyuid="300", cookie="yyuid=300", assigned_to=None + ) + self.session.add_all([support, foreign, assigned]) + self.session.commit() + assigned.assigned_to = support.id + self.session.commit() + + # 客服只能兑换分配给自己的账号,他人账号按"不存在"处理 + with pytest.raises(HTTPException) as excinfo: + create_browser_exchange_kit(foreign.id, db=self.session, current=support) + assert excinfo.value.status_code == 404 + + kit = create_browser_exchange_kit(assigned.id, db=self.session, current=support) + assert kit["cookie_string"] == "yyuid=300" + + def test_user_without_task_permission_is_rejected(self): + guest = User(username="guest", password_hash="hash", role="guest") + self.session.add(guest) + self.session.commit() + with pytest.raises(HTTPException) as excinfo: + create_browser_exchange_kit(self.account.id, db=self.session, current=guest) + assert excinfo.value.status_code == 403 + # 被拒时既不签发凭证,也不留"已签发"审计 + assert self.session.query(AuditLog).count() == 0 + + def test_missing_account_returns_404(self): + with pytest.raises(HTTPException) as excinfo: + create_browser_exchange_kit(999999, db=self.session, current=self.user) + assert excinfo.value.status_code == 404 + + def test_kit_issuance_commits_audit_row(self): + self._kit() + # 未提交的审计行会停在 session.new:这条断言钉住 record_audit 之后确实 commit + assert not self.session.new, "审计日志未提交(缺少 db.commit())" + assert self.session.query(AuditLog).count() == 1 + + def test_room_pid_config_changes_zt_urls_but_not_live_room(self): + self.session.add(HuyaConfig(room_pid="9527")) + self.session.commit() + assert ( + "pid=9527" + in self._kit(req=HuyaBrowserExchangeRequest(target="esports"))["target_url"] + ) + assert ( + self._kit(req=HuyaBrowserExchangeRequest(target="live_room"))["target_url"] + == f"https://www.huya.com/{HUYA_BIND_ROOM_ID}" + ) + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_extension_content_script_whitelist_is_narrow_and_parseable(): + """内容脚本白名单必须收窄到后台站点,且每个模式都能被 background.js 解析成来源。""" + manifest = json.loads(_read(EXTENSION_DIR / "manifest.json")) + matches = [ + pattern for entry in manifest["content_scripts"] for pattern in entry["matches"] + ] + background = _read(EXTENSION_DIR / "background.js") + + assert matches, "内容脚本白名单不能为空" + assert "" not in matches + for pattern in matches: + # background.js 用 pattern.replace(/\/\*$/, '/') + new URL(...).origin 推导允许来源, + # 所以每个模式都必须是「具体源 + /*」,主机里不能有通配(通配会被 new URL 抛错并静默丢弃) + assert pattern.endswith("/*"), pattern + origin = pattern[: -len("/*")] + assert re.fullmatch(r"https?://[a-z0-9.-]+(:\d+)?", origin), pattern + # 内容脚本只需在后台页面生效:注入走 chrome.cookies,虎牙侧不需要内容脚本 + assert "huya.com" not in origin + assert "originAllowed" in background + + +def test_inject_ack_contract_is_paired_across_three_layers(): + """注入回执链路(页面 ↔ 内容脚本 ↔ service worker)的字段必须成对,改名即失败。""" + page = _read(REPO_ROOT / "web" / "frontend" / "src" / "utils" / "huyaExchange.ts") + relay = _read(EXTENSION_DIR / "relay.js") + background = _read(EXTENSION_DIR / "background.js") + + # 页面 → 内容脚本:事件名与参数名 + assert "huya-exchange-kit" in page + assert "huya-exchange-kit" in relay + assert "request_id: requestId" in page + assert "kit.request_id" in relay + # 内容脚本 → 页面:回执事件名与字段 + assert "huya-exchange-result" in page + assert "huya-exchange-result" in relay + assert "request_id: requestId" in relay + assert "parsed.request_id !== requestId" in page + assert "Boolean(result.ok)" in relay + assert "parsed.ok === true" in page + assert "result.written" in relay + assert "parsed.written" in page + # service worker → 内容脚本:延迟回执(等真正写完)与真实统计 + assert "return true;" in background + assert "written > 0 && failed === 0" in background + assert "failedNames.push" in background + # 来源校验是本次安全修复的核心,不能被删掉 + assert "originAllowed" in background + assert "sender.tab.url" in background + # 兜底路径同样要来源校验,且失败不再静默 + assert "world: 'MAIN'" in background + assert "badge(tab.id, '失败')" in background + assert "badge(tab.id, '非后台页')" in background + # 一个都没写成功时不得打开目标页 + assert "written > 0 && kit && kit.target_url" in background + # 只写 huya 域 Cookie + assert ".huya.com" in background + + +def test_console_script_guards_domain_and_expiry(): + script = _read(REPO_ROOT / "core" / "huya" / "cookie_utils.py") + assert "location.hostname" in script + assert "ttl_days: int = 180" in script diff --git a/web/backend/routers/huya/_common.py b/web/backend/routers/huya/_common.py index 5d6bde2..58006b4 100644 --- a/web/backend/routers/huya/_common.py +++ b/web/backend/routers/huya/_common.py @@ -72,6 +72,7 @@ from ...schemas import ( HuyaAutoRegisterBatchOut, HuyaAutoRegisterRequest, HuyaAutoRegisterRetryRequest, + HuyaBrowserExchangeRequest, HuyaConfigOut, HuyaConfigUpdate, HuyaCookieImport, @@ -90,13 +91,17 @@ from ...schemas import ( HuyaWorkbenchAccountsUpdate, ) from ...services.audit_service import record_audit +from ...services.huya_exchange_targets import ( + DEFAULT_EXCHANGE_TARGET, + exchange_target_options, + exchange_target_url, +) from ...services.huya_register_runner import ( export_success_logs_text, huya_register_registry, list_success_logs, ) from ...services.huya_runner import HuyaBatchRunner, huya_batch_registry -from ...services.huya_runner_bind import BindMixin from ...services.huya_service import ( HUYA_CONFIG_FIELDS, SUPPORTED_TASK_TYPES, diff --git a/web/backend/routers/huya/accounts.py b/web/backend/routers/huya/accounts.py index f448384..5754cac 100644 --- a/web/backend/routers/huya/accounts.py +++ b/web/backend/routers/huya/accounts.py @@ -10,12 +10,14 @@ router = APIRouter() @router.post("/accounts/{account_id}/browser-exchange") def create_browser_exchange_kit( account_id: int, + req: HuyaBrowserExchangeRequest | None = None, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """生成指定账号的本机浏览器人工兑换材料。 - 登录态由前端引导用户在自己的 Chrome/Edge 中写入,服务端不托管浏览器会话。 + 登录态由前端引导用户在自己的 Chrome/Edge 中写入,服务端不托管浏览器会话; + `target` 选择要打开的目标页(直播间/精英宝典/电竞宝典),不传则用默认目标。 """ _require_huya_perm(current, "huya:task") account = ( @@ -28,18 +30,28 @@ def create_browser_exchange_kit( if not account.cookie: raise HTTPException(status_code=400, detail="该账号没有可用 Cookie,请先登录") config = ensure_huya_config(db) - target_url = ( - BindMixin._bind_redirect_url( - { - "room_pid": huya_config_value("room_pid", config.room_pid), - } - ) - or "https://zt.huya.com/b02faae1/pc/index.html" - ) + room_pid = huya_config_value("room_pid", config.room_pid) + target = req.target if req and req.target else DEFAULT_EXCHANGE_TARGET + targets = exchange_target_options(room_pid) + try: + target_url = exchange_target_url(target, room_pid) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc pairs = dict(cookie_pairs(normalize_huya_cookie(account.cookie))) + # 兑换材料等同账号凭证:逐次留痕,记录谁、何时、取走哪个账号、准备打开哪个页面 + record_audit( + db, + current, + action="huya:exchange:kit", + target=f"huya_account:{account.id}", + detail={"account_id": account.id, "target": target}, + ) + db.commit() return { "account_id": account.id, "username": account.username or account.yyuid or "", + "target": target, + "targets": targets, "target_url": target_url, "cookie_string": "; ".join(f"{key}={value}" for key, value in pairs.items()), "cookie_script": browser_login_script(pairs, target_url), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index d85521f..e013975 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -528,6 +528,12 @@ class HuyaWorkbenchAccountsUpdate(BaseModel): account_ids: list[int] = Field(default_factory=list) +class HuyaBrowserExchangeRequest(BaseModel): + """浏览器兑换的目标页选择(常量见 services/huya_exchange_targets.py)。""" + + target: str | None = Field(None, pattern="^(live_room|elite|esports)$") + + class HuyaTaskOut(BaseModel): id: int batch_id: str diff --git a/web/backend/services/huya_exchange_targets.py b/web/backend/services/huya_exchange_targets.py new file mode 100644 index 0000000..0e4fd16 --- /dev/null +++ b/web/backend/services/huya_exchange_targets.py @@ -0,0 +1,76 @@ +"""虎牙浏览器兑换的可选目标页:直播间 / 精英宝典 / 电竞宝典。 + +人工兑换时把账号登录态写入操作者自己的浏览器,再打开其中一个目标页: +- 直播间:直接进房间,适合在房间内完成的活动操作; +- 精英宝典:沿用绑定/充值活动的 zt 活动页(历史行为); +- 电竞宝典:同一活动的另一个 zt 活动页,仅 uuid 不同。 + +三个地址都由 pid(`HuyaConfig.room_pid`)与固定活动常量拼出,前端只传目标 key。 +""" + +from __future__ import annotations + +from .huya_runner_bind import HUYA_BIND_ROOM_ID, HUYA_BIND_ZT_UUID +from .huya_runner_core import HUYA_RECHARGE_SOURCE_ID + +EXCHANGE_TARGET_LIVE_ROOM = "live_room" +EXCHANGE_TARGET_ELITE = "elite" +EXCHANGE_TARGET_ESPORTS = "esports" + +#: 未指定目标时使用的默认页(保持历史行为:精英宝典) +DEFAULT_EXCHANGE_TARGET = EXCHANGE_TARGET_ELITE + +#: 精英宝典复用绑定/充值活动 uuid;电竞宝典为独立的 zt 活动 uuid +HUYA_ELITE_ZT_UUID = HUYA_BIND_ZT_UUID +HUYA_ESPORTS_ZT_UUID = "50ea007f" + +#: 选择器展示顺序 +EXCHANGE_TARGET_KEYS: tuple[str, ...] = ( + EXCHANGE_TARGET_LIVE_ROOM, + EXCHANGE_TARGET_ELITE, + EXCHANGE_TARGET_ESPORTS, +) + +EXCHANGE_TARGET_LABELS: dict[str, str] = { + EXCHANGE_TARGET_LIVE_ROOM: "直播间", + EXCHANGE_TARGET_ELITE: "精英宝典", + EXCHANGE_TARGET_ESPORTS: "电竞宝典", +} + +#: 供 Pydantic 校验使用的正则 +EXCHANGE_TARGET_PATTERN = "^(live_room|elite|esports)$" + + +def _zt_exchange_url(zt_uuid: str, room_pid: str) -> str: + """拼 zt 活动页地址,参数与绑定/充值跳转保持一致。""" + return ( + f"https://zt.huya.com/{zt_uuid}/pc/index.html" + f"?sourceId={HUYA_RECHARGE_SOURCE_ID}" + f"&pid={room_pid}" + f"&anchorUid={room_pid}" + f"&roomid={HUYA_BIND_ROOM_ID}" + ) + + +def exchange_target_url(key: str, room_pid: str) -> str: + """返回指定目标页地址;pid 由调用方(路由)归一化,空值在这里会拼出无 pid 的地址。""" + pid = str(room_pid or "").strip() + if key == EXCHANGE_TARGET_LIVE_ROOM: + return f"https://www.huya.com/{HUYA_BIND_ROOM_ID}" + if key == EXCHANGE_TARGET_ELITE: + return _zt_exchange_url(HUYA_ELITE_ZT_UUID, pid) + if key == EXCHANGE_TARGET_ESPORTS: + return _zt_exchange_url(HUYA_ESPORTS_ZT_UUID, pid) + raise ValueError(f"不支持的兑换目标:{key}") + + +def exchange_target_options(room_pid: str) -> list[dict[str, str]]: + """返回三个可选目标页,顺序固定,供前端选择器渲染。""" + return [ + { + "key": key, + "label": EXCHANGE_TARGET_LABELS[key], + "url": exchange_target_url(key, room_pid), + } + for key in EXCHANGE_TARGET_KEYS + ] diff --git a/web/extension/huya-exchange-helper/background.js b/web/extension/huya-exchange-helper/background.js index f90267e..2422be1 100644 --- a/web/extension/huya-exchange-helper/background.js +++ b/web/extension/huya-exchange-helper/background.js @@ -1,8 +1,30 @@ // 虎牙兑换助手后台:接收后台页面 relay 转发的登录材料, -// 覆盖写入 huya.com 同名 Cookie 后打开已登录的兑换页。 +// 覆盖写入 huya.com 同名 Cookie 后打开所选目标页(直播间/精英宝典/电竞宝典)。 const HUYA_COOKIE_URL = 'https://www.huya.com/'; const TTL_SECONDS = 180 * 86400; +// 允许触发注入的来源站点:直接从 manifest 内容脚本白名单推导,避免两处配置漂移。 +const ALLOWED_ORIGINS = (() => { + const manifest = chrome.runtime.getManifest(); + const patterns = (manifest.content_scripts || []).flatMap((entry) => entry.matches || []); + const origins = new Set(); + for (const pattern of patterns) { + try { + origins.add(new URL(pattern.replace(/\/\*$/, '/')).origin); + } catch (e) { /* 形如 *://*.example.com/* 的通配主机无法解析,忽略 */ } + } + return origins; +})(); + +function originAllowed(url) { + if (!url) return false; + try { + return ALLOWED_ORIGINS.has(new URL(url).origin); + } catch (e) { + return false; + } +} + function badge(tabId, text) { if (!tabId) return; chrome.action.setBadgeText({ tabId, text }); @@ -25,6 +47,8 @@ async function injectKit(kit) { } catch (e) { /* 单个失败不阻断 */ } } const expirationDate = Math.floor(Date.now() / 1000) + TTL_SECONDS; + let written = 0; + const failedNames = []; for (const [name, value] of cookies) { if (!name) continue; try { @@ -37,41 +61,86 @@ async function injectKit(kit) { secure: true, expirationDate, }); - } catch (e) { /* 单个失败不阻断 */ } + written += 1; + } catch (e) { + failedNames.push(String(name)); + } } - if (kit && kit.target_url) { + // 一个都没写进去时不要打开目标页:否则操作者在新标签页里看到的是未登录或上一个账号 + if (written > 0 && kit && kit.target_url) { chrome.tabs.create({ url: kit.target_url }); } + const failed = failedNames.length; + return { + ok: written > 0 && failed === 0, + written, + failed, + error: failed + ? `有 ${failed} 个 Cookie 写入失败:${failedNames.slice(0, 5).join('、')}` + : written + ? '' + : '没有可写入的 Cookie,请重新获取兑换材料', + }; } chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg && msg.type === 'inject' && msg.kit) { - injectKit(msg.kit).then(() => badge(sender.tab && sender.tab.id, 'OK')); - sendResponse({ ok: true }); + if (!msg || msg.type !== 'inject' || !msg.kit) return undefined; + const tab = sender && sender.tab; + if (!originAllowed(tab && tab.url)) { + // 非后台站点派发的注入请求一律拒绝,页面脚本无法伪造 sender.tab.url + sendResponse({ + ok: false, + written: 0, + failed: 0, + error: '来源未授权:仅允许在后台管理页面触发注入', + }); + return undefined; } - return undefined; + injectKit(msg.kit) + .then((result) => { + badge(tab && tab.id, result.ok ? 'OK' : '失败'); + sendResponse(result); + }) + .catch((error) => { + badge(tab && tab.id, '失败'); + sendResponse({ + ok: false, + written: 0, + failed: 0, + error: String((error && error.message) || error), + }); + }); + return true; // 等 Cookie 真正写完再回执,页面才能显示真实结果 }); // 兜底:后台页面把材料存到 window.__huyaExchangeKit,点击扩展图标时读取。 +// 该变量由页面主世界写入,必须用 world: 'MAIN' 读取(默认 isolated world 读不到)。 chrome.action.onClicked.addListener(async (tab) => { if (!tab || !tab.id) return; + if (!originAllowed(tab.url)) { + badge(tab.id, '非后台页'); + return; + } let payload = null; try { const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, + world: 'MAIN', func: () => window.__huyaExchangeKit || null, }); payload = result ? result.result : null; } catch (e) { - return; // 无权访问当前页面 + // 注入失败(无权限、或浏览器不支持 world: 'MAIN')不再静默 + badge(tab.id, '失败'); + return; } if (!payload) { badge(tab.id, '无任务'); return; } try { - await injectKit(JSON.parse(payload)); - badge(tab.id, 'OK'); + const result = await injectKit(JSON.parse(payload)); + badge(tab.id, result.ok ? 'OK' : '失败'); } catch (e) { badge(tab.id, '失败'); } diff --git a/web/extension/huya-exchange-helper/manifest.json b/web/extension/huya-exchange-helper/manifest.json index 6e1521a..513a087 100644 --- a/web/extension/huya-exchange-helper/manifest.json +++ b/web/extension/huya-exchange-helper/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "虎牙兑换助手", - "version": "1.0.0", + "version": "1.1.0", "description": "配合直播后台使用:一键把所选虎牙账号的登录态写入本机浏览器(仅 huya.com 域),兑换操作仍由用户手动完成。", "permissions": ["cookies", "scripting", "activeTab"], "host_permissions": ["*://*.huya.com/*"], @@ -10,7 +10,14 @@ }, "content_scripts": [ { - "matches": [""], + "matches": [ + "https://live.khhao.com/*", + "https://www.u499731.nyat.app/*", + "http://localhost:5174/*", + "http://127.0.0.1:5174/*", + "http://localhost:8800/*", + "http://127.0.0.1:8800/*" + ], "js": ["relay.js"], "run_at": "document_start" } diff --git a/web/extension/huya-exchange-helper/relay.js b/web/extension/huya-exchange-helper/relay.js index afb17f2..04ef93b 100644 --- a/web/extension/huya-exchange-helper/relay.js +++ b/web/extension/huya-exchange-helper/relay.js @@ -1,17 +1,58 @@ -// 内容脚本:在所有页面标记扩展存在,并把后台页面派发的兑换材料转发给 service worker。 -// 后台页面通过 documentElement.dataset.huyaExchangeHelper === '1' 检测扩展是否可用。 -document.documentElement.dataset.huyaExchangeHelper = '1'; +// 内容脚本:只注入后台管理系统页面(站点白名单见 manifest.content_scripts.matches)。 +// 职责:① 标记扩展可用;② 把后台页面派发的兑换材料转发给 service worker; +// ③ 把写入结果回执给页面(修复"注入失败但界面仍提示成功")。 +// 安全边界:不再注入 ,页面侧只能通过固定事件名提交材料; +// service worker 仍会二次校验消息来源站点,Cookie 只写入 .huya.com 域。 +// document_start 阶段可能拿到非 HTML 文档(XML/图片等)而没有 documentElement, +// 这里判空,避免异常连带杀死后面的监听注册(会导致自动注入永久失效)。 +if (document.documentElement) { + document.documentElement.dataset.huyaExchangeHelper = '1'; +} + +function dispatchResult(payload) { + try { + window.dispatchEvent( + new CustomEvent('huya-exchange-result', { detail: JSON.stringify(payload) }) + ); + } catch (e) { /* 页面已卸载等场景忽略 */ } +} window.addEventListener('huya-exchange-kit', (event) => { + // 后台页面用页面脚本派发的 CustomEvent,isTrusted 恒为 false, + // 所以这里不能校验 isTrusted;拦截点是 manifest 站点白名单 + service worker 的来源校验。 const detail = event.detail; if (typeof detail !== 'string') return; + let kit; try { - const kit = JSON.parse(detail); - if (kit && kit.target_url && Array.isArray(kit.cookies)) { - const sending = chrome.runtime.sendMessage({ type: 'inject', kit }); - if (sending && typeof sending.catch === 'function') { - sending.catch(() => { /* 扩展上下文失效等场景忽略 */ }); + kit = JSON.parse(detail); + } catch (e) { + return; // 忽略非法消息 + } + if (!kit || !kit.target_url || !Array.isArray(kit.cookies)) return; + const requestId = typeof kit.request_id === 'string' ? kit.request_id : ''; + try { + chrome.runtime.sendMessage({ type: 'inject', kit }, (response) => { + const lastError = chrome.runtime.lastError ? chrome.runtime.lastError.message : ''; + if (lastError) { + dispatchResult({ request_id: requestId, ok: false, written: 0, failed: 0, error: lastError }); + return; } - } - } catch (e) { /* 忽略非法消息 */ } + const result = response || {}; + dispatchResult({ + request_id: requestId, + ok: Boolean(result.ok), + written: Number(result.written) || 0, + failed: Number(result.failed) || 0, + error: typeof result.error === 'string' ? result.error : '', + }); + }); + } catch (e) { + dispatchResult({ + request_id: requestId, + ok: false, + written: 0, + failed: 0, + error: '扩展上下文失效,请在扩展管理页重新加载扩展', + }); + } }, true); diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 9a4f782..d7c8558 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -31,6 +31,7 @@ import type { HuyaTaskBatchResult, HuyaTaskItem, HuyaBrowserExchangeKit, + HuyaExchangeTargetKey, HuyaWorkbenchScope, MessageCountResponse, MessageDeletedResponse, @@ -163,7 +164,7 @@ export const huyaApi = { api.get(`/huya/tasks/${taskId}`), cleanupOrphans: () => api.post<{ message: string; cleaned: number; success: boolean }, { message: string; cleaned: number; success: boolean }>('/huya/tasks/cleanup-orphans'), - /** 本机浏览器兑换: 获取目标页与登录脚本, 由用户在自己浏览器的控制台执行 */ - createBrowserExchange: (accountId: number) => - api.post(`/huya/accounts/${accountId}/browser-exchange`, {}), + /** 本机浏览器兑换: 获取目标页与登录脚本, 由用户在自己浏览器的控制台执行 (target 不传 = elite) */ + createBrowserExchange: (accountId: number, target?: HuyaExchangeTargetKey) => + api.post(`/huya/accounts/${accountId}/browser-exchange`, { target }), }; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 1192c1b..685c4ee 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -471,15 +471,24 @@ export interface HuyaFetchSmsCodeResult { success: boolean; message: string; } - - /** 工作台账号集合范围: elite=精英宝典 / browser_exchange=浏览器兑换 */ export type HuyaWorkbenchScope = 'elite' | 'browser_exchange'; +/** 浏览器兑换目标页: live_room=直播间 / elite=精英宝典 / esports=电竞宝典 */ +export type HuyaExchangeTargetKey = 'live_room' | 'elite' | 'esports'; + +export interface HuyaExchangeTargetOption { + key: HuyaExchangeTargetKey; + label: string; + url: string; +} + /** 本机浏览器人工兑换材料 (登录脚本在用户自己的 Chrome/Edge 控制台执行) */ export interface HuyaBrowserExchangeKit { account_id: number; username: string; + target: HuyaExchangeTargetKey; + targets: HuyaExchangeTargetOption[]; target_url: string; cookie_string: string; cookie_script: string; diff --git a/web/frontend/src/pages/HuyaBrowserExchangePage.tsx b/web/frontend/src/pages/HuyaBrowserExchangePage.tsx index e64d0ff..41a3343 100644 --- a/web/frontend/src/pages/HuyaBrowserExchangePage.tsx +++ b/web/frontend/src/pages/HuyaBrowserExchangePage.tsx @@ -11,16 +11,24 @@ import { type HuyaAccountItem, type HuyaBrowserExchangeKit, type HuyaExchangeRecord, + type HuyaExchangeTargetKey, } from '../api/modules'; import { getErrorMessage } from '../utils/error'; import { message } from '../utils/antdMessage'; import { copyTextToClipboard } from '../utils/clipboard'; -import { huyaExchangeHelperReady, openExchangeTarget, requestExtensionInject } from '../utils/huyaExchange'; +import { huyaExchangeHelperReady, openExchangeTarget, requestExtensionInject, type ExchangeInjectResult } from '../utils/huyaExchange'; const { Text, Paragraph } = Typography; const SCOPE = 'browser_exchange' as const; const IMPORT_PAGE_SIZE = 20; +/** 操作列的三个直达目标:点哪个就按哪个目标写入登录态并打开 */ +const EXCHANGE_TARGET_ACTIONS: { key: HuyaExchangeTargetKey; label: string }[] = [ + { key: 'live_room', label: '直播间' }, + { key: 'elite', label: '精英宝典' }, + { key: 'esports', label: '电竞宝典' }, +]; + /** 表格"兑换记录"列的单元格状态(按账号缓存最近一次查询结果) */ interface RecordsCell { phase: 'loading' | 'done' | 'error'; @@ -45,7 +53,10 @@ export default function HuyaBrowserExchangePage() { const [importAllLoading, setImportAllLoading] = useState(false); const [exchangeKit, setExchangeKit] = useState(null); const [exchangeTitle, setExchangeTitle] = useState(''); - const [exchangeId, setExchangeId] = useState(null); + // 操作列正在打开的目标(账号 + 目标),用于按钮 loading + const [exchangePending, setExchangePending] = useState<{ accountId: number; target: HuyaExchangeTargetKey } | null>(null); + const [exchangeInjecting, setExchangeInjecting] = useState(false); + const [injectNotice, setInjectNotice] = useState<{ type: 'success' | 'info' | 'warning' | 'error'; text: string } | null>(null); const [recordsState, setRecordsState] = useState<{ account: HuyaAccountItem; phase: 'loading' | 'done' | 'error'; @@ -53,8 +64,18 @@ export default function HuyaBrowserExchangePage() { errorMsg: string; } | null>(null); const [recordsMap, setRecordsMap] = useState>({}); - // 查询序号:重新发起查询或关闭弹窗时自增,使进行中的轮询失效 - const recordsSeqRef = useRef(0); + // 查询序号按账号隔离:重新发起该账号查询/关闭该账号弹窗/卸载时自增,使该账号进行中的轮询失效 + const recordsSeqRef = useRef>(new Map()); + // 在途查询账号集合:同一账号已有在途查询时不重复发起 + const recordsInFlightRef = useRef>(new Set()); + // 组件挂载标记:卸载后中断所有在途轮询且不再 setState + const mountedRef = useRef(true); + + const bumpRecordsSeq = (accountId: number): number => { + const next = (recordsSeqRef.current.get(accountId) ?? 0) + 1; + recordsSeqRef.current.set(accountId, next); + return next; + }; const load = useCallback(async () => { setLoading(true); @@ -111,6 +132,20 @@ export default function HuyaBrowserExchangePage() { }, [importSearch, importTag]); useEffect(() => { setImportSelectedIds([]); }, [importPage]); + // 卸载时中断所有在途兑换记录轮询,并标记为已卸载(轮询回调不再 setState) + useEffect(() => { + mountedRef.current = true; + const seqRef = recordsSeqRef.current; + const inFlightRef = recordsInFlightRef.current; + return () => { + mountedRef.current = false; + for (const [accountId, seq] of seqRef) { + seqRef.set(accountId, seq + 1); + } + inFlightRef.clear(); + }; + }, []); + const persistAccounts = async (ids: number[]): Promise => { const saved = await huyaApi.updateWorkbenchAccounts(ids, SCOPE); const byId = new Map(pool.map((item) => [item.id, item])); @@ -168,17 +203,59 @@ export default function HuyaBrowserExchangePage() { } }; - const handleExchange = async (record: HuyaAccountItem) => { - setExchangeId(record.id); + // 兑换请求/注入的代号:换账号、换目标、关弹窗都会自增,用于丢弃过期结果 + const exchangeEpochRef = useRef(0); + + /** 按扩展回执给出可见结果(替代原静默失败);文案带目标名,操作者可自证 */ + const applyInjectResult = (result: ExchangeInjectResult, kit: HuyaBrowserExchangeKit) => { + const label = kit.targets.find((item) => item.key === kit.target)?.label || kit.target; + if (result.ok) { + setInjectNotice({ type: 'success', text: `已注入登录态并打开「${label}」` }); + message.success(`已注入登录态并打开「${label}」`); + return; + } + const tip = result.error || `部分 Cookie 写入失败(${result.failed} 个)`; + setInjectNotice({ type: result.error ? 'error' : 'warning', text: tip }); + if (result.error) message.error(tip); + else message.warning(tip); + }; + + /** 注入登录态并展示回执:点行时的自动注入与弹窗按钮共用;代号变化即丢弃过期回执 */ + const injectKitWithNotice = async (kit: HuyaBrowserExchangeKit, epoch: number) => { + setExchangeInjecting(true); try { - const kit = await huyaApi.createBrowserExchange(record.id); - requestExtensionInject(kit); + const result = await requestExtensionInject(kit); + if (epoch !== exchangeEpochRef.current) return; + applyInjectResult(result, kit); + } finally { + if (epoch === exchangeEpochRef.current) setExchangeInjecting(false); + } + }; + + /** 操作列三个直达按钮:按所选目标取兑换材料 → 注入并打开(未装扩展则直接打开目标页) */ + const handleExchange = async (record: HuyaAccountItem, target: HuyaExchangeTargetKey) => { + const epoch = ++exchangeEpochRef.current; + setExchangePending({ accountId: record.id, target }); + try { + const kit = await huyaApi.createBrowserExchange(record.id, target); + if (epoch !== exchangeEpochRef.current) return; // 已点了其它账号/目标,丢弃本次结果 + setInjectNotice(null); setExchangeKit(kit); setExchangeTitle(record.nickname || record.username || record.yyuid || `账号#${record.id}`); + if (huyaExchangeHelperReady()) { + // 扩展会写入登录态并自己打开标签页,这里不要再 window.open,避免开两个 + await injectKitWithNotice(kit, epoch); + } else { + openExchangeTarget(kit.target_url); + message.info('目标页面已打开,请复制登录脚本并在该页面控制台执行'); + } } catch (error) { - message.error(getErrorMessage(error)); + if (epoch === exchangeEpochRef.current) message.error(getErrorMessage(error)); } finally { - setExchangeId(null); + // 只清自己的 loading,避免后点的按钮被先前请求清掉 + setExchangePending((prev) => ( + prev && prev.accountId === record.id && prev.target === target ? null : prev + )); } }; @@ -192,9 +269,34 @@ export default function HuyaBrowserExchangePage() { }; const queryExchangeRecords = async (record: HuyaAccountItem) => { - const seq = ++recordsSeqRef.current; - const alive = () => seq === recordsSeqRef.current; + // 同一账号已有在途查询时不重复发起 + if (recordsInFlightRef.current.has(record.id)) { + message.info('该账号的兑换记录正在查询中,请稍候'); + return; + } + recordsInFlightRef.current.add(record.id); + const seq = bumpRecordsSeq(record.id); + const alive = () => seq === (recordsSeqRef.current.get(record.id) ?? 0); + const previousCell = recordsMap[record.id]; + /** 被中断时回退单元格:有上次结果则保留,否则回到「未查询」,不永久卡 loading */ + const restoreAfterAbort = () => { + if (!mountedRef.current) return; + setRecordsMap((prev) => { + if (previousCell) return { ...prev, [record.id]: previousCell }; + const next = { ...prev }; + delete next[record.id]; + return next; + }); + setRecordsState((prev) => { + if (!prev || prev.account.id !== record.id) return prev; + if (previousCell && previousCell.phase === 'done') { + return { account: record, phase: 'done', records: previousCell.records, errorMsg: '' }; + } + return null; + }); + }; const patch = (update: Partial) => { + if (!alive() || !mountedRef.current) return; setRecordsMap((prev) => { const current = prev[record.id]; const merged: RecordsCell = { @@ -223,6 +325,7 @@ export default function HuyaBrowserExchangePage() { return; } const tasks = await huyaApi.listTasks(batch.batch_id, 'elite'); + if (!alive()) return; const task = tasks.find((item) => item.account_id === record.id); if (!task) continue; if (task.status === 'success') { @@ -236,22 +339,50 @@ export default function HuyaBrowserExchangePage() { } } } catch (error) { - if (alive()) patch({ phase: 'error', errorMsg: getErrorMessage(error) }); + patch({ phase: 'error', errorMsg: getErrorMessage(error) }); + } finally { + recordsInFlightRef.current.delete(record.id); + if (!alive()) restoreAfterAbort(); } }; const closeRecordsModal = () => { - recordsSeqRef.current += 1; + // 只中断当前账号的轮询,不影响其它账号 + if (recordsState) bumpRecordsSeq(recordsState.account.id); setRecordsState(null); }; - /** 点击表格单元格时用缓存结果打开详情弹窗(不重新查询) */ + /** 点击表格单元格时用缓存结果打开详情弹窗(不重新查询,也不中断其它在途查询) */ const openCachedRecords = (record: HuyaAccountItem, cell: RecordsCell) => { if (cell.phase !== 'done') return; - recordsSeqRef.current += 1; setRecordsState({ account: record, phase: 'done', records: cell.records, errorMsg: '' }); }; + const closeExchangeModal = () => { + exchangeEpochRef.current += 1; // 使在途请求与注入回执失效 + setExchangeKit(null); + setInjectNotice(null); + setExchangeInjecting(false); + }; + + /** 弹窗内手动重新注入(切换目标后走这里) */ + const handleInject = async () => { + const kit = exchangeKit; + if (!kit) return; + await injectKitWithNotice(kit, ++exchangeEpochRef.current); + }; + + /** 扩展不可用时:只打开目标页,提示复制登录脚本手动执行 */ + const handleOpenExchangeTarget = () => { + if (!exchangeKit) return; + openExchangeTarget(exchangeKit.target_url); + message.info('已打开目标页面,请复制登录脚本并在该页面控制台执行'); + }; + + const currentExchangeTarget = exchangeKit + ? exchangeKit.targets.find((item) => item.key === exchangeKit.target) || null + : null; + const exchangeHelperReady = huyaExchangeHelperReady(); const columns: TableProps['columns'] = [ @@ -303,29 +434,33 @@ export default function HuyaBrowserExchangePage() { }, { title: '操作', - width: 200, + width: 360, render: (_value, record) => ( - - - + + + ))} @@ -356,7 +491,7 @@ export default function HuyaBrowserExchangePage() { showIcon style={{ marginBottom: 12 }} message="未检测到「虎牙兑换助手」扩展" - description="安装后点「浏览器兑换」即可自动写入登录态并打开兑换页(见 docs/HUYA_BROWSER_EXCHANGE.md,开发者模式加载 web/extension/huya-exchange-helper 并刷新本页);未安装时可走兑换弹窗里的手动脚本流程。" + description="安装后点操作列的任意目标按钮即可自动写入登录态并打开对应页面(见 docs/HUYA_BROWSER_EXCHANGE.md,开发者模式加载 web/extension/huya-exchange-helper 并刷新本页);未安装时会直接打开目标页面,走弹窗里的手动脚本流程。" /> )} @@ -499,10 +634,29 @@ export default function HuyaBrowserExchangePage() { open={exchangeKit !== null} width={720} centered - onCancel={() => setExchangeKit(null)} + onCancel={closeExchangeModal} footer={exchangeKit ? [ - , - , + exchangeHelperReady ? ( + + ) : ( + + ), + , ] : null} > {exchangeKit && ( @@ -512,24 +666,39 @@ export default function HuyaBrowserExchangePage() { type="success" showIcon message="已检测到「虎牙兑换助手」扩展" - description="点击「浏览器兑换」时已自动写入该账号登录态并在新标签页打开兑换页面。请确认页面右上角账号无误后手动完成兑换;若页面未自动打开,按下方手动步骤操作。" + description="点操作列的任意目标按钮即会写入该账号登录态并打开对应页面,结果会在这里提示;失败可直接点「注入登录态并打开」重试。" /> ) : ( )} +
+ + 本次目标 + {currentExchangeTarget?.label || exchangeKit.target} + + + {currentExchangeTarget?.url || exchangeKit.target_url} + +
+ {injectNotice && ( + + )} 手动步骤 1: - 点击下方「打开兑换页面」,在新标签页打开兑换活动页。 + {exchangeHelperReady + ? '若页面没有自动打开,点下方「注入登录态并打开」重新注入并打开当前目标' + : '点下方「打开目标页面」在新标签页打开当前目标'} + 「{currentExchangeTarget?.label || '兑换'}」。 手动步骤 2: - 点击「复制登录脚本」,在兑换页面按 F12 打开控制台,粘贴脚本并回车执行。 - Chrome/Edge 首次粘贴需先手动输入 allow pasting 再回车。脚本会写入该账号的登录态并自动跳转。 + 点击「复制登录脚本」,在目标页面按 F12 打开控制台,粘贴脚本并回车执行。 + Chrome/Edge 首次粘贴需先手动输入 allow pasting 再回车。脚本会写入该账号的登录态并自动跳转到所选目标。 @@ -540,7 +709,7 @@ export default function HuyaBrowserExchangePage() { 手动步骤 3: - 确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。 + 确认页面右上角登录态为目标账号,并在「{currentExchangeTarget?.label || '兑换'}」页完成兑换,回到此处点击「完成」。 )} diff --git a/web/frontend/src/utils/huyaExchange.ts b/web/frontend/src/utils/huyaExchange.ts index c20668b..3406344 100644 --- a/web/frontend/src/utils/huyaExchange.ts +++ b/web/frontend/src/utils/huyaExchange.ts @@ -2,6 +2,14 @@ import type { HuyaBrowserExchangeKit } from '../api/modules'; +/** 扩展注入回执 (huya-exchange-result) */ +export interface ExchangeInjectResult { + ok: boolean; + written: number; + failed: number; + error: string; +} + /** 解析后端 Cookie 字符串为 [name, value] 数组,供兑换扩展注入 */ export function parseCookieString(cookieString: string): [string, string][] { return cookieString @@ -14,16 +22,66 @@ export function parseCookieString(cookieString: string): [string, string][] { }); } -/** 内置扩展的内容脚本会在所有页面打上此标记 */ +/** 内置扩展的内容脚本会在后台站点白名单页面(见 manifest matches)打上此标记 */ export function huyaExchangeHelperReady(): boolean { return document.documentElement.dataset.huyaExchangeHelper === '1'; } -/** 请求内置扩展自动注入登录态;同时留存到 window 供扩展图标点击兜底读取 */ -export function requestExtensionInject(kit: HuyaBrowserExchangeKit): void { - const payload = JSON.stringify({ target_url: kit.target_url, cookies: parseCookieString(kit.cookie_string) }); +function createRequestId(): string { + return `hx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * 请求内置扩展自动注入登录态并打开目标页,等待扩展回执(4 秒超时)。 + * 同时留存到 window 供扩展图标点击兜底读取。 + * 注意:事件 detail 必须是 JSON 字符串(跨 world 传递限制),不要改成对象。 + */ +export function requestExtensionInject(kit: HuyaBrowserExchangeKit): Promise { + const requestId = createRequestId(); + const payload = JSON.stringify({ + request_id: requestId, + target_url: kit.target_url, + cookies: parseCookieString(kit.cookie_string), + }); (window as unknown as { __huyaExchangeKit?: string }).__huyaExchangeKit = payload; - window.dispatchEvent(new CustomEvent('huya-exchange-kit', { detail: payload })); + + return new Promise((resolve) => { + let timer = 0; + let settled = false; + + function finish(result: ExchangeInjectResult): void { + if (settled) return; + settled = true; + window.clearTimeout(timer); + window.removeEventListener('huya-exchange-result', onResult); + resolve(result); + } + + function onResult(event: Event): void { + const detail = (event as CustomEvent).detail; + if (typeof detail !== 'string') return; + let parsed: { request_id?: string; ok?: boolean; written?: number; failed?: number; error?: string }; + try { + parsed = JSON.parse(detail) as typeof parsed; + } catch { + return; + } + if (!parsed || parsed.request_id !== requestId) return; + finish({ + ok: parsed.ok === true, + written: typeof parsed.written === 'number' ? parsed.written : 0, + failed: typeof parsed.failed === 'number' ? parsed.failed : 0, + error: typeof parsed.error === 'string' ? parsed.error : '', + }); + } + + timer = window.setTimeout(() => { + finish({ ok: false, written: 0, failed: 0, error: '扩展未响应(未安装或安装后未刷新本页)' }); + }, 4000); + + window.addEventListener('huya-exchange-result', onResult); + window.dispatchEvent(new CustomEvent('huya-exchange-kit', { detail: payload })); + }); } /** 在新标签页打开兑换页面(noopener 避免反向标签劫持) */