feat(huya): 浏览器兑换三目标直达并收窄扩展注入范围

- 操作列改为「直播间 / 精英宝典 / 电竞宝典」三个直达按钮,点哪个就按哪个目标写入登录态并打开,去掉弹窗内选择
- 新增 services/huya_exchange_targets.py 统一三个目标地址,接口支持可选 body {"target": "..."}(不传=精英宝典)
- 扩展内容脚本从 <all_urls> 收窄到后台站点白名单,service worker 校验 sender.tab.url,拒绝其它来源的注入
- 注入改为 Cookie 写完后回执(ok/written/failed/error),前端按回执提示成功或失败,不再静默
- 扩展图标兜底改用 world:'MAIN' 读取;一个 Cookie 都没写成功时不打开目标页
- 兑换材料签发写审计 huya:exchange:kit(含账号与目标)
- 控制台脚本增加 huya.com 域校验与 Secure,与扩展路径保持一致
- 修复兑换记录轮询的跨账号串扰与卸载后 setState;兑换请求加 epoch 防止过期结果覆盖弹窗
- 新增 tests/test_huya_browser_exchange.py(13 项)并同步 docs/HUYA_BROWSER_EXCHANGE.md
This commit is contained in:
yml2213
2026-09-11 10:31:46 +08:00
parent 874a95dd6f
commit 4518b726ac
14 changed files with 838 additions and 102 deletions
+14 -4
View File
@@ -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<c.length;i++){{"
f"document.cookie=c[i][0]+'='+c[i][1]+';domain={domain};path=/;expires='+e;"
f"}}location.href={target};}})();"
f"document.cookie=c[i][0]+'='+c[i][1]+';domain={domain};path=/;expires='+e+'{suffix}';"
f"}}"
f"location.href={target};"
f"}})();"
)
+39 -13
View File
@@ -10,6 +10,20 @@
- 列表按用户隔离保存(工作台 scope `browser_exchange`),与精英宝典的账号集合互不影响。
- 账号需已有 Cookie 才能执行兑换;账号的 App 协议登录由管理员在账号管理页(直链 `/huya/accounts`)完成,客服遇到无 Cookie 账号时联系管理员处理。
## 目标页三个直达按钮
操作列直接提供三个目标按钮(直播间 / 精英宝典 / 电竞宝典),点哪个就按哪个目标写入登录态并打开对应页面;接口不传 `target` 时用「精英宝典」(与历史行为一致)。
| key | 名称 | 地址 |
| --- | --- | --- |
| `live_room` | 直播间 | `https://www.huya.com/30596253` |
| `elite` | 精英宝典(默认) | `https://zt.huya.com/b02faae1/pc/index.html?sourceId=yellowcarlist&pid={room_pid}&anchorUid={room_pid}&roomid=30596253` |
| `esports` | 电竞宝典 | `https://zt.huya.com/50ea007f/pc/index.html?sourceId=yellowcarlist&pid={room_pid}&anchorUid={room_pid}&roomid=30596253` |
- `{room_pid}` 取虎牙配置的 `room_pid`;配置为空时用默认 `1199650619883`(`web/backend/huya_defaults.py`)。直播间地址与 `room_pid` 无关。
- 每次点击都会按该目标重新取一次兑换材料(登录脚本里的跳转地址跟着目标变)。
- 新增目标:在 `web/backend/services/huya_exchange_targets.py` 加常量与地址,并在 `web/backend/schemas.py` 的 `HuyaBrowserExchangeRequest.target` 正则里补上 key(`tests/test_huya_browser_exchange.py` 会校验两处不漂移)。
## 一键自动注入(推荐):安装内置扩展
项目自带 Chrome/Edge 扩展 `web/extension/huya-exchange-helper/`,安装一次后即可自动注入:
@@ -17,14 +31,17 @@
1. 打开扩展管理页:Chrome 为 `chrome://extensions`,Edge 为 `edge://extensions`。
2. 开启「开发者模式」,点击「加载已解压的扩展程序」,选择仓库目录 `web/extension/huya-exchange-helper`。
3. 刷新后台页面(扩展的内容脚本需要对当前页生效)。
- 已装过旧版扩展时,升级后必须回到 `chrome://extensions` 点一次「刷新」;否则浏览器里跑的还是旧版(旧版会注入所有网站,本次安全收窄不生效)。
之后的流程:
1. 在兑换列表点击账号的「浏览器兑换」→ 后端返回兑换材料 → 扩展自动把该账号 Cookie 写入 `.huya.com` 域并在新标签页打开已登录的兑换页。
2. 确认页面右上角账号无误后,手动完成兑换。
3. 回到后台点击「完成」。
1. 在兑换列表操作列点击目标按钮(直播间 / 精英宝典 / 电竞宝典)→ 后端返回兑换材料 → 扩展自动把该账号 Cookie 写入 `.huya.com` 域并在新标签页打开该页面;若一个 Cookie 都没写成功,扩展**不会**打开目标页(避免打开未登录或上一个账号的页面),弹窗显示失败原因。
2. 弹窗会显示本次注入结果(写入 / 失败条数);失败时按提示处理后点「注入登录态并打开」重试。
3. 确认页面右上角账号无误后,手动完成兑换,回到后台点击「完成」。
扩展只在注入时覆盖与登录态同名的旧 Cookie,其余虎牙 Cookie 不动;扩展图标点击可作为兜底(读取当前后台页签留存的材料);未检测到材料时图标会显示「无任务」角标。
扩展只在注入时覆盖与登录态同名的旧 Cookie,其余虎牙 Cookie 不动;扩展图标点击可作为兜底(读取当前**后台页签**留存的材料),未检测到材料时图标显示「无任务」角标。
内容脚本只注入后台站点白名单(`manifest.json` 的 `content_scripts.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`。若后台使用其它域名,需要把该域名加进名单再重新加载扩展——service worker 从这份名单推导允许触发注入的来源,其它站点派发的注入请求会被拒绝。
## 查询兑换记录
@@ -37,35 +54,44 @@
## 手动方式(未装扩展时的兜底)
1. 在兑换列表点击「浏览器兑换」,弹出「浏览器兑换」窗口。
2. 点击「打开兑换页面」在新标签页打开兑换活动页(地址来自配置的 `room_pid`,未配置时回落到活动页 `https://zt.huya.com/b02faae1/pc/index.html`)。
3. 点击「复制登录脚本」,在兑换页面按 `F12` 打开控制台,粘贴脚本并回车执行。Chrome/Edge 首次粘贴需先手动输入 `allow pasting` 再回车。脚本把该账号 Cookie 写入 `.huya.com` 域并自动跳转。
4. 确认登录态正确后完成兑换,回到系统点击「完成」。
1. 在兑换列表操作列点击目标按钮(直播间 / 精英宝典 / 电竞宝典)→ 未装扩展时目标页会直接在新标签页打开,并弹出「浏览器兑换」窗口给出手动兜底。
2. 在**虎牙域下的页面**按 `F12` 打开控制台,粘贴脚本并回车执行。Chrome/Edge 首次粘贴需先手动输入 `allow pasting` 再回车。脚本会先校验当前页面在 `huya.com` 域下(不满足会弹窗提示并中止),写入带 `Secure`(与扩展路径一致)的 Cookie 后跳转目标页。
3. 确认登录态正确后完成兑换,回到系统点击「完成」。
没有控制台操作习惯时,可安装 Cookie-editor 类扩展,使用「复制 Cookie 字符串」在 `.huya.com` 域下导入。
## 接口
`POST /api/huya/accounts/{account_id}/browser-exchange`(权限 `huya:task`)返回:
`POST /api/huya/accounts/{account_id}/browser-exchange`(权限 `huya:task`),可选 body `{"target": "live_room" | "elite" | "esports"}`:
```json
{
"account_id": 1,
"username": "xxx",
"target": "elite",
"targets": [
{ "key": "live_room", "label": "直播间", "url": "https://www.huya.com/30596253" },
{ "key": "elite", "label": "精英宝典", "url": "https://zt.huya.com/b02faae1/pc/index.html?..." },
{ "key": "esports", "label": "电竞宝典", "url": "https://zt.huya.com/50ea007f/pc/index.html?..." }
],
"target_url": "https://...",
"cookie_string": "yyuid=...; ...",
"cookie_script": "(function(){...})();"
}
```
- 脚本由 `core.huya.cookie_utils.browser_login_script` 生成:Cookie 以 JSON 数组内联(自动转义),逐条 `document.cookie` 写入 `domain=.huya.com; path=/`,有效期 180 天,最后跳转目标页。
- 扩展注入走同一条接口:前端解析 `cookie_string` 后通过 DOM 事件交给扩展,由 `chrome.cookies` API 写入。
- `targets` 固定返回三个可选目标(顺序:直播间、精英宝典、电竞宝典),前端据此渲染选择器;非法 `target` 由 Pydantic 正则拦下(422)。
- 脚本由 `core.huya.cookie_utils.browser_login_script` 生成:先校验 `location.hostname` 属于 `huya.com`,Cookie 以 JSON 数组内联(自动转义),逐条 `document.cookie` 写入 `domain=.huya.com; path=/; secure`,有效期 180 天,最后跳转 `target_url`。
- 扩展注入走同一条接口:前端解析 `cookie_string` 后通过 DOM 事件交给扩展,由 `chrome.cookies` API 写入,并把写入结果回执给页面(前端据此提示成功/失败,不再静默)。
- 兑换账号列表复用 `/api/huya/workbench-accounts`(`handbook_scope=browser_exchange`)。
- 每次签发都会留审计日志:`audit_logs.action = huya:exchange:kit`,`target = huya_account:{id}`,detail 含 `account_id` 与 `target`。
- 服务端不保存任何会话状态。
## 安全说明
- 接口受 `huya:task` 权限与账号可见范围约束;脚本、Cookie 字符串与扩展注入等同账号凭证,仅在授权用户点击时下发。
- 接口受 `huya:task` 权限与账号可见范围约束;脚本、Cookie 字符串与扩展注入等同账号凭证,仅在授权用户点击时下发,且逐次留痕。
- 登录态只写入操作者自己的浏览器,不会在服务器留存浏览器进程或 Profile。
- 扩展为内部工具,通过开发者模式加载;其内容脚本在所有页面注入通信桥(manifest `<all_urls>`),但只响应本后台页面派发的固定事件,且只写 `huya.com` 域的 Cookie。
- 扩展为内部工具,通过开发者模式加载;内容脚本只注入后台站点白名单,service worker 二次校验消息来源站点,且只写 `huya.com` 域的 Cookie。
- 仍存在的边界:注入请求由后台页面用 `CustomEvent` 派发,页面内其它脚本理论上可以监听或伪造同名事件(页面派发的事件 `isTrusted` 恒为 false,无法据此区分真伪);因此不要把不受信任的脚本引入后台页面。
- 扩展写入的 Cookie 非 `HttpOnly`(`chrome.cookies` 限制),虎牙域内脚本可读,这是本方案的固有取舍。
- 请勿把脚本、Cookie 或扩展转给无关人员;如需共享操作,建议使用系统的账号授权机制而非转发凭证。
+247
View File
@@ -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 "<all_urls>" 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
+6 -1
View File
@@ -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,
+21 -9
View File
@@ -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),
+6
View File
@@ -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
@@ -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
]
@@ -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, '失败');
}
@@ -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": ["<all_urls>"],
"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"
}
+51 -10
View File
@@ -1,17 +1,58 @@
// 内容脚本:在所有页面标记扩展存在,并把后台页面派发的兑换材料转发给 service worker。
// 后台页面通过 documentElement.dataset.huyaExchangeHelper === '1' 检测扩展是否可用。
document.documentElement.dataset.huyaExchangeHelper = '1';
// 内容脚本:只注入后台管理系统页面(站点白名单见 manifest.content_scripts.matches)。
// 职责:① 标记扩展可用;② 把后台页面派发的兑换材料转发给 service worker;
// ③ 把写入结果回执给页面(修复"注入失败但界面仍提示成功")。
// 安全边界:不再注入 <all_urls>,页面侧只能通过固定事件名提交材料;
// 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);
+4 -3
View File
@@ -31,6 +31,7 @@ import type {
HuyaTaskBatchResult,
HuyaTaskItem,
HuyaBrowserExchangeKit,
HuyaExchangeTargetKey,
HuyaWorkbenchScope,
MessageCountResponse,
MessageDeletedResponse,
@@ -163,7 +164,7 @@ export const huyaApi = {
api.get<HuyaTaskItem, HuyaTaskItem>(`/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<HuyaBrowserExchangeKit, HuyaBrowserExchangeKit>(`/huya/accounts/${accountId}/browser-exchange`, {}),
/** 本机浏览器兑换: 获取目标页与登录脚本, 由用户在自己浏览器的控制台执行 (target 不传 = elite) */
createBrowserExchange: (accountId: number, target?: HuyaExchangeTargetKey) =>
api.post<HuyaBrowserExchangeKit, HuyaBrowserExchangeKit>(`/huya/accounts/${accountId}/browser-exchange`, { target }),
};
+11 -2
View File
@@ -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;
@@ -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<HuyaBrowserExchangeKit | null>(null);
const [exchangeTitle, setExchangeTitle] = useState('');
const [exchangeId, setExchangeId] = useState<number | null>(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<Record<number, RecordsCell>>({});
// 查询序号:重新发起查询或关闭弹窗时自增,使进行中的轮询失效
const recordsSeqRef = useRef(0);
// 查询序号按账号隔离:重新发起该账号查询/关闭该账号弹窗/卸载时自增,使该账号进行中的轮询失效
const recordsSeqRef = useRef<Map<number, number>>(new Map());
// 在途查询账号集合:同一账号已有在途查询时不重复发起
const recordsInFlightRef = useRef<Set<number>>(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<number[]> => {
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<RecordsCell>) => {
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<HuyaAccountItem>['columns'] = [
@@ -303,29 +434,33 @@ export default function HuyaBrowserExchangePage() {
},
{
title: '操作',
width: 200,
width: 360,
render: (_value, record) => (
<Space size={6}>
<Tooltip title={record.has_cookie
? '自动写入登录态并打开兑换页面(用你自己的 Chrome/Edge 完成)'
: '该账号没有 Cookie,请联系管理员在账号管理中完成登录'}>
<Button
size="small"
type="primary"
ghost
icon={<GlobalOutlined />}
disabled={!record.has_cookie}
loading={exchangeId === record.id}
onClick={() => void handleExchange(record)}
{EXCHANGE_TARGET_ACTIONS.map((action) => (
<Tooltip
key={action.key}
title={record.has_cookie
? `写入该账号登录态并打开「${action.label}」(在你自己的 Chrome/Edge 里完成兑换)`
: '该账号没有 Cookie,请联系管理员在账号管理中完成登录'}
>
浏览器兑换
</Button>
</Tooltip>
<Button
size="small"
type="primary"
ghost
disabled={!record.has_cookie}
loading={exchangePending?.accountId === record.id && exchangePending.target === action.key}
onClick={() => void handleExchange(record, action.key)}
>
{action.label}
</Button>
</Tooltip>
))}
<Tooltip title="查询该账号的兑换记录">
<Button
size="small"
icon={<HistoryOutlined />}
loading={recordsState?.phase === 'loading' && recordsState.account.id === record.id}
loading={recordsMap[record.id]?.phase === 'loading'}
onClick={() => void queryExchangeRecords(record)}
/>
</Tooltip>
@@ -342,7 +477,7 @@ export default function HuyaBrowserExchangePage() {
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div>
<h2 style={{ margin: 0 }}>浏览器兑换</h2>
<Text type="secondary">把账号登录态写入你自己的 Chrome/Edge 后打开兑换页,兑换手动完成;客服仅可见已分配给自己的账号。</Text>
<Text type="secondary">点操作列的目标按钮即可把该账号登录态写入你自己的 Chrome/Edge 并打开对应页面,兑换手动完成;客服仅可见已分配给自己的账号。</Text>
</div>
<Space>
<Button icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>
@@ -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 ? [
<Button key="open" type="primary" icon={<GlobalOutlined />} onClick={() => openExchangeTarget(exchangeKit.target_url)}>打开兑换页面</Button>,
<Button key="done" onClick={() => { setExchangeKit(null); message.success('兑换完成后可在精英宝典任务中刷新积分'); }}>完成</Button>,
exchangeHelperReady ? (
<Button
key="inject"
type="primary"
icon={<GlobalOutlined />}
loading={exchangeInjecting}
onClick={() => void handleInject()}
>
注入登录态并打开
</Button>
) : (
<Button
key="open"
type="primary"
icon={<GlobalOutlined />}
onClick={handleOpenExchangeTarget}
>
打开目标页面
</Button>
),
<Button key="done" onClick={() => { closeExchangeModal(); message.success('兑换完成后可在对应宝典任务里刷新积分'); }}>完成</Button>,
] : null}
>
{exchangeKit && (
@@ -512,24 +666,39 @@ export default function HuyaBrowserExchangePage() {
type="success"
showIcon
message="已检测到「虎牙兑换助手」扩展"
description="点击「浏览器兑换」时已自动写入该账号登录态并在新标签页打开兑换页面。请确认页面右上角账号无误后手动完成兑换;若页面未自动打开,按下方手动步骤操作。"
description="点操作列的任意目标按钮即会写入该账号登录态并打开对应页面,结果会在这里提示;失败可直接点「注入登录态并打开」重试。"
/>
) : (
<Alert
type="warning"
showIcon
message="未检测到「虎牙兑换助手」扩展"
description="安装后可一键自动写入登录态(见 docs/HUYA_BROWSER_EXCHANGE.md)。当前请按下方手动步骤操作。"
description="安装后可一键自动写入登录态(见 docs/HUYA_BROWSER_EXCHANGE.md)。目标页面已为你打开,请按下方手动步骤操作。"
/>
)}
<div>
<Space size={8}>
<Text strong>本次目标</Text>
<Text>{currentExchangeTarget?.label || exchangeKit.target}</Text>
</Space>
<Paragraph type="secondary" style={{ marginBottom: 0, wordBreak: 'break-all' }}>
{currentExchangeTarget?.url || exchangeKit.target_url}
</Paragraph>
</div>
{injectNotice && (
<Alert type={injectNotice.type} showIcon message={injectNotice.text} />
)}
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 1:</Text>
点击下方「打开兑换页面」,在新标签页打开兑换活动页。
{exchangeHelperReady
? '若页面没有自动打开,点下方「注入登录态并打开」重新注入并打开当前目标'
: '点下方「打开目标页面」在新标签页打开当前目标'}
「{currentExchangeTarget?.label || '兑换'}」。
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 2:</Text>
点击「复制登录脚本」,在兑换页面按 <Text code>F12</Text> 打开控制台,粘贴脚本并回车执行。
Chrome/Edge 首次粘贴需先手动输入 <Text code>allow pasting</Text> 再回车。脚本会写入该账号的登录态并自动跳转。
点击「复制登录脚本」,在目标页面按 <Text code>F12</Text> 打开控制台,粘贴脚本并回车执行。
Chrome/Edge 首次粘贴需先手动输入 <Text code>allow pasting</Text> 再回车。脚本会写入该账号的登录态并自动跳转到所选目标。
</Paragraph>
<Space wrap>
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeText(exchangeKit.cookie_script, '登录脚本已复制')}>复制登录脚本</Button>
@@ -540,7 +709,7 @@ export default function HuyaBrowserExchangePage() {
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 3:</Text>
确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。
确认页面右上角登录态为目标账号,并在「{currentExchangeTarget?.label || '兑换'}」页完成兑换,回到此处点击「完成」。
</Paragraph>
</Space>
)}
+63 -5
View File
@@ -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<ExchangeInjectResult> {
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<ExchangeInjectResult>((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<unknown>).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 避免反向标签劫持) */