diff --git a/README.md b/README.md index d074abc..54e25e7 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,35 @@ -# 斗鱼批量登录 Web 后台 +# 直播账号运营后台 -全自动登录斗鱼账号、获取 Cookie 的 Web 管理平台。 +斗鱼 / 虎牙账号运营 Web 平台:批量登录拿 Cookie、按工作台跑活动任务、应用宝点券充值。版本见 `pyproject.toml`(当前 0.3.0)。 -## 功能特性 +## 能做什么 -- 极验 v3 滑块自动识别 -- 邮箱验证码自动获取(IMAP) -- 批量登录 + 实时日志推送(WebSocket) -- 代理 & 白名单自动管理 -- 角色权限控制(超级管理员 / 运营 / 客服) +- **斗鱼**:极验滑块自动过验、邮箱验证码、批量登录与 CK 检测/重登;精英宝典 / 电竞手册 / 和平小店三套工作台(绑定、开通、充值、兑换);鱼翅可由微信二维码或供应商直充。 +- **虎牙**:Cookie / 密码 / App / 短信登录、自动注册、设备绑定、精英宝典、浏览器人工兑换;网页运行 Cookie 与 App 原始凭据(`raw_cookie`)分离,可从原始凭据重建网页 Cookie。 +- **应用宝**:和平精英点券扫码登录、选区服角色、支付二维码。Worker 合在 Web 容器内,只监听 `127.0.0.1:8810`。 +- **权限**:超级管理员 / 运营 / 客服;可按权限点覆盖。客服只看分配给自己的账号,虎牙侧默认只开放浏览器兑换。 +- **安全**:密码、邮箱、Cookie、代理密钥等敏感字段落盘加密;操作写审计日志。 ## 项目结构 ``` live-hub-py/ -├── core/ # 核心业务逻辑 -│ ├── models.py # Account, ProxyConfig 数据类 -│ ├── douyu/ # 斗鱼登录模块 -│ │ ├── login.py # 登录流程 -│ │ ├── crypto.py # 密码加密 -│ │ ├── email_verifier.py # 邮箱验证码 -│ │ ├── proxy.py # 代理管理 -│ │ └── whitelist.py # 白名单管理 -│ └── geetest/ # 极验验证码求解 -│ ├── common/ # 公共工具(网络/加密/图像/轨迹) -│ └── v3_slide/ # v3 滑块求解器 -├── web/ # Web 后台 -│ ├── backend/ # FastAPI 后端 -│ │ ├── main.py # 入口 -│ │ ├── routers/ # API 路由 -│ │ ├── migrations/ # Alembic 数据库迁移 -│ │ └── services/ # 业务服务 -│ └── frontend/ # React + Ant Design 前端 -├── utils/ # 通用工具 -│ └── logger.py -├── data/ # 运行时数据(gitignore) -├── deploy.sh # 部署/重置工具 -└── pyproject.toml +├── core/ # 协议客户端(不经过 Web 也可单独调用) +│ ├── douyu/ # 登录、活动、鱼翅直充、代理 +│ ├── huya/ # App/Web/短信登录、WSS、设备指纹、网页 Cookie 补齐 +│ └── geetest/ # 极验 v3 滑块 +├── web/ +│ ├── backend/ # FastAPI + SQLAlchemy + Alembic +│ │ ├── routers/ # HTTP / WebSocket +│ │ ├── services/ # 批次 runner(进程内线程池) +│ │ └── migrations/ # 数据库迁移 +│ ├── frontend/ # React + Ant Design + Vite +│ └── extension/ # 虎牙浏览器兑换助手 +├── services/yyb-worker/ # 应用宝协议运行时(随主镜像部署) +├── tests/ # pytest(默认 SQLite 内存库) +├── docs/ # 协议与联调文档(结论以「虎牙 Cookie 路线状态」为准) +├── deploy.sh / dev.sh # 生产部署 / 本机热更新 +└── docker-compose.yml # 应用容器 + MySQL 8.4 ``` ## 快速开始 @@ -46,143 +39,106 @@ live-hub-py/ ./deploy.sh ``` -访问 `http://localhost:8800`,默认账号 `admin / admin123`。 +访问 `http://localhost:8800`(compose 默认把容器 8800 映到宿主机 `APP_PORT`,缺省 8000)。默认管理员 `admin / admin123`,生产环境务必改掉。 -### 应用宝充值 Worker - -应用宝充值 Worker 已合并到 Web 应用容器中,内部仅监听 `127.0.0.1:8810`,不发布额外端口。任务数据与应用数据一起保存在 `data/yyb-worker-jobs/`。 - -```bash -cd /opt/live-hub-py -python3 -c 'import secrets; print(secrets.token_urlsafe(32))' -``` - -将生成的值写入项目 `.env`: - -```dotenv -YYB_WORKER_KEY=随机密钥 -``` - -随后执行 `./deploy.sh`,它会构建并启动应用与 MySQL。Worker 不发布端口;扫码图片和支付二维码只经后台授权接口返回。服务器只需要此仓库,不需要部署 `js_reverse`。 - -本机热更新调试使用 `./dev.sh`(或 `./deploy.sh dev`)。它会直接启动一个仅绑定 `127.0.0.1:8810` 的开发 Worker,后端自动使用该地址;Worker 动态数据写入 `data/yyb-worker-dev/`,按 `Ctrl+C` 会一并停止。应用文件日志按日期写入项目的 `logs/app-YYYY-MM-DD.log`,可另开终端执行 `./dev.sh logs` 跟踪当天文件。 - -**常用命令:** +本机热更新用 `./dev.sh`(或 `./deploy.sh dev`)。它会拉起绑定 `127.0.0.1:8810` 的开发 Worker,后端自动连该地址;Worker 数据在 `data/yyb-worker-dev/`。`Ctrl+C` 会一并停掉。应用日志按日期写到 `logs/app-YYYY-MM-DD.log`,可用 `./dev.sh logs` 跟踪当天文件。 ```bash ./deploy.sh # 构建并部署 -./deploy.sh logs # 查看当天持久化应用日志(logs/app-YYYY-MM-DD.log) -./deploy.sh logs container # 查看 Docker 容器输出(含 Worker) -./deploy.sh stop # 停止服务 -./deploy.sh restart # 重启服务 +./deploy.sh logs # 当天持久化应用日志 +./deploy.sh logs container # Docker 容器输出(含 Worker) +./deploy.sh stop +./deploy.sh restart ./deploy.sh reset # 重置所有数据(需二次确认) ``` -### 敏感数据加密 +## 运行时与密钥 -账号密码、邮箱密码、Cookie、代理密钥等敏感字段会以密文形式落盘。生产环境请在 `.env` 中设置稳定的 `APP_ENCRYPTION_KEY`,并长期保留;更换该密钥会导致历史密文无法解密。 +生产是 **单个应用容器 + 内网 MySQL 8.4**。数据库不映射公网;本机 `./dev.sh` 通过 `127.0.0.1:3307` 连 compose 里的 MySQL。 -```bash -python -c "import secrets; print(secrets.token_urlsafe(32))" +在项目 `.env` 里准备: + +```dotenv +MYSQL_PASSWORD=... +MYSQL_ROOT_PASSWORD=... +JWT_SECRET_KEY=随机密钥 +APP_ENCRYPTION_KEY=随机密钥 # 敏感字段加密,更换后历史密文无法解密 +YYB_WORKER_KEY=随机密钥 # 本机回环访问 Worker ``` -### 邮件验证码服务 +生成随机密钥: -验证码读取默认使用内置的 Roundcube 服务地址;如部署自己的邮件读取服务,可在 `.env` 中设置 `MAIL_ROUNDCUBE_URL` 覆盖。 +```bash +python3 -c 'import secrets; print(secrets.token_urlsafe(32))' +``` + +可选: + +- `FISH_FIN_RECHARGE_*`:鱼翅供应商直充,只走环境变量,不入库、不回前端。 +- `MAIL_ROUNDCUBE_URL`:邮件验证码服务;不填则用代码默认 Roundcube 地址。 +- `CORS_ORIGINS`、`COOKIE_SECURE`:HTTPS 部署时按实际填写。 + +应用宝扫码图和支付二维码只经后台授权接口返回。服务器只需要本仓库,不必再部署 `js_reverse`。Worker 任务数据在 `data/yyb-worker-jobs/`。 ## 角色权限 | 角色 | 权限 | |---|---| -| 超级管理员 | 全部功能 + 用户管理 + 权限分配 | -| 运营 | 账号管理 + 批量登录 + 代理配置 + Cookie 导出 | -| 客服 | 仅查看分配给自己的账号(只显示用户名) | +| 超级管理员 | 全部功能 + 用户管理 + 权限分配 + 审计日志 | +| 运营 | 账号管理、批量登录、代理、Cookie、双平台任务、应用宝 | +| 客服 | 仅查看分配给自己的账号;虎牙只保留浏览器兑换 | -## 登录流程 +## 登录与任务流 + +斗鱼登录: ``` -1. 获取极验参数 → 滑块验证 → 自动识别缺口 -2. 获取邮箱验证 code → 发送验证邮件 -3. IMAP 获取验证码 → 提交验证码 → 获取 Cookie +获取极验参数 → 滑块识别 → 邮箱验证码 → 提交 → Cookie ``` +斗鱼工作台按 `handbook_scope`(elite / esports / peace)隔离任务和支付二维码。虎牙精英宝典走活动 WSS;缺少网页设备字段时先 `wsLaunch` 补齐,再把补齐后的 Cookie 回写账号。 + +进程重启会把内存里丢失的 pending/running 任务标成中断,避免前端被假活跃批次锁死。 + ## 运行测试 ```bash -# 全量回归(SQLite 内存库,无需 MySQL / .env 配置) uv run --group dev pytest # 或 ./dev.sh test - -# 只跑单个文件 / 用例 uv run --group dev pytest tests/test_cookie_operations.py - -# 格式化 / 仅检查格式 ./dev.sh format tests conftest.py ./dev.sh format-check tests conftest.py - -# 类型检查(当前为渐进式接入,先查看存量诊断) -./dev.sh type-check - -# 容器内回归(镜像内为构建时代码,改动后需重新构建) +./dev.sh type-check # 渐进式 Pyright docker compose --profile test run --rm test ``` -测试使用各文件自建的 SQLite 内存引擎,互不干扰;根目录 `conftest.py` -统一兜底 `DATABASE_URL` / `APP_ENCRYPTION_KEY`,防止误连开发/生产库。 -数据库迁移链的冒烟校验见 `tests/test_migrations.py`(链线性 + 脚本可 -编译 + 空库整链 `upgrade head` 后与模型元数据表/列/索引对齐;MySQL -专属 DDL 由迁移脚本内的方言防护跳过,真实执行仍以部署流程 -`docker-entrypoint.sh` / `./deploy.sh` / `./dev.sh` 的 MySQL 为准)。 -启用本地 pre-push 门禁(Ruff 格式检查 + pytest): +测试各自建 SQLite 内存库;根目录 `conftest.py` 兜底 `DATABASE_URL` / `APP_ENCRYPTION_KEY`,防止误连开发/生产库。迁移链冒烟见 `tests/test_migrations.py`。兑换、下单、支付属于写操作,默认用 mock 做契约测试,不打真实支付。 + +启用推送前门禁(Ruff + pytest): ```bash ./scripts/install-git-hooks.sh ``` -推送前自动跑全量测试,失败阻止推送。需要将 Pyright 作为推送门禁时,使用 -`TYPECHECK=1 git push`;紧急绕过可使用 `git push --no-verify` 或 -`SKIP_TESTS=1 git push`。 +需要把 Pyright 也纳入时:`TYPECHECK=1 git push`。紧急绕过:`git push --no-verify` 或 `SKIP_TESTS=1 git push`。 ## 技术栈 -- 后端:FastAPI + SQLAlchemy + SQLite + JWT +- 后端:FastAPI + SQLAlchemy + MySQL 8.4(测试用 SQLite)+ JWT - 前端:React + Ant Design + Vite -- 核心:Python + OpenCV + pycryptodome +- 核心:Python 3.12 + OpenCV + ONNX + pycryptodome + curl-cffi - 迁移:Alembic +- 部署:Docker Compose -## 许可证 +## 代理 -MIT License - -## 部署 - -### Docker 部署 - -```bash -# 一键部署(自动构建镜像 + 启动服务) -./deploy.sh - -# 重置所有数据(删除数据库/Cookie/日志,需二次确认) -./deploy.sh reset -``` - -部署完成后访问 `http://服务器IP:8800`,默认账号 `admin / admin123`。 - -**数据持久化:** 数据库和日志通过 volume 挂载到宿主机 `./data` 和 `./logs` 目录,容器重建后数据不丢失。 - -### 代理配置说明 - -在 Web 界面"代理配置"页面设置: - -1. **API 代理**:填入代理 API 地址(支持返回 JSON 格式),系统自动获取并验证代理 -2. **白名单**:如代理需要白名单,填入 UID 和 UKEY,系统自动同步出口 IP -3. **出口 IP 漂移**:移动网络出口 IP 频繁漂移时,系统会保留多个出口 IP 在白名单中 - -**代理 API JSON 格式:** +在「代理配置」页填写代理 API(JSON)和白名单。移动出口 IP 漂移时,系统会保留多个出口 IP。示例: ```json -// 正常返回 {"code":0,"success":"true","msg":"","data":[{"IP":"1.2.3.4","Port":8080}]} - -// 白名单错误 -{"code":-1,"msg":"51.请先添加白名单:1.2.3.4","data":""} ``` + +白名单错误时接口会带上当前出口 IP,系统按平台凭证自动同步。 + +## 文档 + +协议与联调笔记在 `docs/`。当前虎牙 Cookie 行为以 [虎牙 Cookie 路线状态](docs/HUYA_COOKIE路线状态.md) 为准。 diff --git a/docs/HUYA_COOKIE路线状态.md b/docs/HUYA_COOKIE路线状态.md index d67e890..b2b16b1 100644 --- a/docs/HUYA_COOKIE路线状态.md +++ b/docs/HUYA_COOKIE路线状态.md @@ -47,9 +47,9 @@ SDK/Python 输出:d07a45de567322edcd42312f38e610be ## 尚未闭合的工程项 1. 将真实 WebView/Chrome 的 `getFeature()` 结果接入登录调用,替代当前的设备画像近似值。 -2. 让活动会话补齐后的 Cookie 持久化回账号,避免原始 16 字段账号每次任务重新生成设备字段。 -3. 为 `raw_cookie` 增加重建网页 Cookie 的服务入口。 -4. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。 +2. 商城下单、支付和支付状态轮询保持单独人工验证,不纳入只读回归。 + +活动会话补齐后的 Cookie 已在任务执行器回写账号;`raw_cookie` 可通过 `POST /api/huya/cookies/{account_id}/rebuild` 重建网页 Cookie。 ## 证据入口 diff --git a/pyproject.toml b/pyproject.toml index 183a894..b1361ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ testpaths = ["tests"] [tool.ruff.lint.per-file-ignores] # FastAPI evaluates dependency metadata in route parameter defaults by design. "web/backend/**/*.py" = ["B008"] +# 虎牙路由共享层集中 re-export 依赖。 +"web/backend/routers/huya/_common.py" = ["F401"] [dependency-groups] dev = [ diff --git a/tests/test_douyu_write_path_contracts.py b/tests/test_douyu_write_path_contracts.py new file mode 100644 index 0000000..ce8ed67 --- /dev/null +++ b/tests/test_douyu_write_path_contracts.py @@ -0,0 +1,116 @@ +"""斗鱼写路径契约:锁单/支付编排走 mock 客户端,不打真实兑换。""" + +from unittest.mock import Mock + +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +from web.backend.database import Base +from web.backend.models import Account, DouyuGoodsSnapshot, DouyuTask, User +from web.backend.services.douyu_runner import DouyuBatchRunner + + +class TestDouyuWritePathContracts: + 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 = Account( + username="dyuser", + password="pw", + email="a@example.com", + email_password="em", + ) + self.session.add_all([self.user, self.account]) + self.session.commit() + self.session.add( + DouyuGoodsSnapshot( + commodity_id="bag10", + name="套装-兔兔白日梦", + raw={"name": "套装-兔兔白日梦"}, + ) + ) + self.session.commit() + + def teardown_method(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def _runner(self, task_type: str) -> DouyuBatchRunner: + return DouyuBatchRunner(db=self.session, batch_id="batch1", task_type=task_type) + + def _task(self, task_type: str, payload: dict) -> DouyuTask: + task = DouyuTask( + batch_id="batch1", + account_id=self.account.id, + task_type=task_type, + handbook_scope="elite", + status="running", + result={"payload": payload}, + created_by=self.user.id, + ) + self.session.add(task) + self.session.commit() + return task + + def test_lock_goods_records_order_without_paying(self): + task = self._task("lock_goods", {"commodity_id": "bag10", "num": 1}) + runner = self._runner("lock_goods") + client = Mock() + client.create_exchange_order.return_value = { + "order_id": "5874", + "expire_seconds": "300", + } + runner._client = Mock(return_value=client) + + runner._execute_lock_goods( + self.session, + task, + self.account, + "acf_uid=100", + {"manual_id": "G4KA4Qnz4LDp7", "rid": "9263298"}, + ) + + self.session.refresh(task) + self.session.refresh(self.account) + assert task.status == "success" + assert task.result["order_id"] == "5874" + assert self.account.bind_status == "goods_locked" + client.create_exchange_order.assert_called_once() + client.pay_exchange_order.assert_not_called() + + def test_pay_locked_order_uses_existing_order_id(self): + task = self._task( + "pay_locked_order", + {"order_id": "5874", "commodity_id": "bag10"}, + ) + runner = self._runner("pay_locked_order") + client = Mock() + client.pay_exchange_order.return_value = { + "exchange_id": "11098933", + "commodity_image": "https://example.test/item.png", + "exchange_num": 1, + } + runner._client = Mock(return_value=client) + runner._refresh_account_points = Mock(return_value={"points": 1280}) + + runner._execute_pay_locked_order( + self.session, + task, + self.account, + "acf_uid=100", + {"manual_id": "G4KA4Qnz4LDp7", "rid": "9263298"}, + ) + + self.session.refresh(task) + assert task.status == "success" + assert task.result["exchange_id"] == "11098933" + client.pay_exchange_order.assert_called_once() + client.create_exchange_order.assert_not_called() diff --git a/tests/test_huya_app_login.py b/tests/test_huya_app_login.py index 5aca4cb..b9641e3 100644 --- a/tests/test_huya_app_login.py +++ b/tests/test_huya_app_login.py @@ -508,7 +508,8 @@ class TestHuyaAppLogin: ) with patch( - "web.backend.routers.huya.login_huya_app_password", return_value=mock_res + "web.backend.routers.huya.login.login_huya_app_password", + return_value=mock_res, ): req = HuyaAppPasswordLoginRequest( username="mock_test_huya_user", diff --git a/tests/test_huya_cookie_rebuild.py b/tests/test_huya_cookie_rebuild.py new file mode 100644 index 0000000..2ab56cc --- /dev/null +++ b/tests/test_huya_cookie_rebuild.py @@ -0,0 +1,84 @@ +"""虎牙网页 Cookie 回写与 raw_cookie 重建(不发起登录或支付)。""" + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +from core.huya.web_cookie_fields import cookie_fields, missing_web_cookie_fields +from web.backend.database import Base +from web.backend.models import HuyaAccount, User +from web.backend.routers.huya.cookies import rebuild_web_cookie +from web.backend.services.huya_service import ( + persist_huya_session_cookie, + rebuild_web_cookie_from_raw, +) + +RAW_COOKIE = ( + "udb_cred=cred; udb_uid=100; yyuid=100; udb_biztoken=biz; udb_appid=5008; sdid=sdid" +) + + +class TestHuyaCookieRebuild: + 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", + username="", + cookie=RAW_COOKIE, + raw_cookie=RAW_COOKIE, + ) + 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 test_persist_writes_filled_session_cookie(self): + filled = RAW_COOKIE + "; guid=0123456789abcdef0123456789abcdef" + assert persist_huya_session_cookie( + self.session, self.account.id, RAW_COOKIE, filled + ) + self.session.refresh(self.account) + assert "guid=" in self.account.cookie + assert ( + persist_huya_session_cookie(self.session, self.account.id, filled, filled) + is False + ) + + def test_rebuild_from_raw_keeps_app_creds_and_fills_web_fields(self): + rebuilt = rebuild_web_cookie_from_raw(self.session, self.account) + fields = cookie_fields(rebuilt) + assert fields["udb_cred"] == "cred" + assert fields["udb_appid"] == "5002" + assert not missing_web_cookie_fields(rebuilt) + self.session.refresh(self.account) + assert self.account.cookie == rebuilt + assert self.account.raw_cookie == RAW_COOKIE + + def test_rebuild_without_raw_cookie_raises(self): + self.account.raw_cookie = "" + self.session.commit() + with pytest.raises(ValueError, match="原始凭据"): + rebuild_web_cookie_from_raw(self.session, self.account) + + def test_rebuild_route_returns_cookie_without_login(self): + payload = rebuild_web_cookie( + account_id=self.account.id, + db=self.session, + current=self.user, + ) + assert payload["id"] == self.account.id + assert payload["has_raw_cookie"] is True + assert payload["cookie"] + assert "udb_cred=cred" in payload["cookie"] diff --git a/tests/test_huya_selected_login_queue.py b/tests/test_huya_selected_login_queue.py new file mode 100644 index 0000000..0e3da8d --- /dev/null +++ b/tests/test_huya_selected_login_queue.py @@ -0,0 +1,154 @@ +"""虎牙多选登录:单线程、失败重试、号间间隔。""" + +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from core.huya.app_login import HuyaAppQrAuthRequiredError +from core.huya.login import HuyaCredentialError, HuyaLoginResult +from web.backend.database import Base +from web.backend.models import HuyaAccount, User +from web.backend.routers.huya.login import app_password_login_selected_accounts +from web.backend.schemas import HuyaPasswordLoginSelectedRequest + +SUCCESS_COOKIE = ( + "udb_cred=mock_cred_123; yyuid=1199666914671; " + "udb_uid=1199666914671; username=mock_test_huya_user" +) + + +def _ok_result() -> HuyaLoginResult: + return HuyaLoginResult( + success=True, + cookie=SUCCESS_COOKIE, + message="App协议登录成功", + sdid="mock_sdid_123", + ) + + +def _fail_result(message: str = "网络超时", code: str = "LOGIN_FAILED") -> HuyaLoginResult: + return HuyaLoginResult(success=False, message=message, code=code) + + +class TestSelectedAppLoginQueue: + def setup_method(self): + self.engine = create_engine("sqlite://") + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + self.admin = User(username="admin", password_hash="hash", role="super_admin") + self.session.add(self.admin) + self.session.commit() + + def teardown_method(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def _add_account(self, username: str, password: str = "pw") -> HuyaAccount: + account = HuyaAccount( + username=username, + account_password=password, + cookie="", + status="password_imported", + ) + self.session.add(account) + self.session.commit() + return account + + def _run(self, ids: list[int], **kwargs): + req = HuyaPasswordLoginSelectedRequest( + account_ids=ids, + max_attempts=kwargs.get("max_attempts", 2), + interval_seconds=kwargs.get("interval_seconds", 8), + retry_delay_seconds=kwargs.get("retry_delay_seconds", 3), + ) + return app_password_login_selected_accounts( + req=req, db=self.session, current=self.admin + ) + + def test_retries_then_succeeds(self): + account = self._add_account("hy_retry_ok") + with ( + patch("web.backend.routers.huya.login.time.sleep") as m_sleep, + patch( + "web.backend.routers.huya.login.login_huya_app_password", + side_effect=[_fail_result(), _ok_result()], + ) as m_login, + ): + resp = self._run([account.id]) + assert resp["count"] == 1 + assert resp["failed"] == 0 + assert resp["results"][0]["success"] + assert resp["results"][0]["attempts"] == 2 + assert m_login.call_count == 2 + m_sleep.assert_called_once_with(3) + + def test_fails_twice_then_moves_on(self): + first = self._add_account("hy_fail_twice") + second = self._add_account("hy_next_ok") + with ( + patch("web.backend.routers.huya.login.time.sleep") as m_sleep, + patch( + "web.backend.routers.huya.login.login_huya_app_password", + side_effect=[_fail_result(), _fail_result(), _ok_result()], + ) as m_login, + ): + resp = self._run( + [first.id, second.id], + interval_seconds=8, + retry_delay_seconds=3, + ) + assert resp["count"] == 1 + assert resp["failed"] == 1 + assert resp["results"][0]["success"] is False + assert resp["results"][0]["attempts"] == 2 + assert "已尝试 2 次" in resp["results"][0]["message"] + assert resp["results"][1]["success"] + assert m_login.call_count == 3 + assert m_sleep.call_args_list == [((3,),), ((8,),)] + + def test_qr_auth_is_not_retried(self): + account = self._add_account("hy_need_qr") + with ( + patch("web.backend.routers.huya.login.time.sleep") as m_sleep, + patch( + "web.backend.routers.huya.login.login_huya_app_password", + side_effect=HuyaAppQrAuthRequiredError("要求扫码验证"), + ) as m_login, + ): + resp = self._run([account.id]) + assert resp["failed"] == 1 + assert resp["results"][0]["attempts"] == 1 + assert m_login.call_count == 1 + m_sleep.assert_not_called() + + def test_wrong_password_is_not_retried(self): + account = self._add_account("hy_bad_pw") + with ( + patch("web.backend.routers.huya.login.time.sleep") as m_sleep, + patch( + "web.backend.routers.huya.login.login_huya_app_password", + side_effect=HuyaCredentialError("账号或密码错误"), + ) as m_login, + ): + resp = self._run([account.id]) + assert resp["failed"] == 1 + assert resp["results"][0]["attempts"] == 1 + assert m_login.call_count == 1 + m_sleep.assert_not_called() + + def test_sms_auth_result_is_not_retried(self): + account = self._add_account("hy_sms") + with ( + patch("web.backend.routers.huya.login.time.sleep") as m_sleep, + patch( + "web.backend.routers.huya.login.login_huya_app_password", + return_value=_fail_result("需要短信验证", "SMS_AUTH_REQUIRED"), + ) as m_login, + ): + resp = self._run([account.id]) + assert resp["failed"] == 1 + assert resp["results"][0]["attempts"] == 1 + assert m_login.call_count == 1 + m_sleep.assert_not_called() diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py deleted file mode 100644 index de8e9c7..0000000 --- a/web/backend/routers/huya.py +++ /dev/null @@ -1,2462 +0,0 @@ -"""虎牙基础管理路由""" - -import asyncio -import csv -import io -import shutil -import threading -from datetime import UTC, datetime -from typing import cast - -from loguru import logger - -from fastapi import ( - APIRouter, - Depends, - HTTPException, - Query, - WebSocket, - WebSocketDisconnect, -) -from fastapi.responses import StreamingResponse -from sqlalchemy import func, or_ -from sqlalchemy.orm import Session, defer, joinedload - -from core.huya import ( - HuyaAppLoginError, - HuyaAppQrAuthRequiredError, - HuyaCredentialError, - HuyaLoginError, - login_huya_app_password, - login_huya_password, - login_huya_sms, - send_huya_sms_code, -) -from core.huya.cookie_utils import ( - browser_login_script, - cookie_pairs, - normalize_huya_cookie, -) -from core.huya.device_fingerprint import account_state_dir as _fp_state_dir -from core.huya.device_profile import ( - _load_db as _load_profile_db, -) -from core.huya.device_profile import ( - _save_db as _save_profile_db, -) -from core.sms_provider import parse_sms_lines - -from ..database import SessionLocal, get_db -from ..deps import authenticate_websocket, get_current_user, require_permission -from ..models import ( - HuyaAccount, - HuyaConfig, - HuyaGoodsSnapshot, - HuyaRechargeGoodsSnapshot, - HuyaRegisterItem, - HuyaRegisterSuccessLog, - HuyaTask, - HuyaWorkbench, - HuyaWorkbenchAccount, - ProxyConfig, - User, -) -from ..permissions import user_has_permission -from ..schemas import ( - AccountAssign, - AccountBulkSelection, - AccountBulkTag, - AccountTag, - BatchAssign, - HuyaAccountOut, - HuyaAppPasswordLoginRequest, - HuyaAutoRegisterBatchOut, - HuyaAutoRegisterRequest, - HuyaAutoRegisterRetryRequest, - HuyaConfigOut, - HuyaConfigUpdate, - HuyaCookieImport, - HuyaDeviceBindingsBatchDeleteRequest, - HuyaGoodsOut, - HuyaPasswordAccountImport, - HuyaPasswordLoginRequest, - HuyaPasswordLoginSelectedRequest, - HuyaRechargeGoodsOut, - HuyaRegisterSuccessLogOut, - HuyaSmsCodeRequest, - HuyaSmsLoginRequest, - HuyaTaskBatchRequest, - HuyaTaskOut, - HuyaTestSmsUrlRequest, - HuyaWorkbenchAccountsUpdate, -) -from ..services.audit_service import record_audit -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, - apply_huya_config_defaults, - cleanup_orphan_huya_tasks, - create_planned_tasks, - ensure_huya_config, - huya_config_value, - import_huya_cookies, - import_huya_password_accounts, - save_huya_login_cookie_to_account, - upsert_huya_cookie, -) - -router = APIRouter(prefix="/api/huya", tags=["虎牙"]) - - -@router.post("/accounts/{account_id}/browser-exchange") -def create_browser_exchange_kit( - account_id: int, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """生成指定账号的本机浏览器人工兑换材料。 - - 登录态由前端引导用户在自己的 Chrome/Edge 中写入,服务端不托管浏览器会话。 - """ - _require_huya_perm(current, "huya:task") - account = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id == account_id) - .first() - ) - if account is None: - raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作") - 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" - ) - pairs = dict(cookie_pairs(normalize_huya_cookie(account.cookie))) - return { - "account_id": account.id, - "username": account.username or account.yyuid or "", - "target_url": target_url, - "cookie_string": "; ".join(f"{key}={value}" for key, value in pairs.items()), - "cookie_script": browser_login_script(pairs, target_url), - } - - -def _has_huya_perm(user: User, permission: str) -> bool: - """虎牙新权限兼容旧的 huya:account 大权限。""" - return user_has_permission(user, permission) or user_has_permission( - user, "huya:account" - ) - - -def _require_huya_perm(user: User, permission: str) -> None: - if not _has_huya_perm(user, permission): - raise HTTPException(status_code=403, detail="权限不足") - - -def _require_huya_tag_permission(user: User) -> None: - """标签权限兼容历史上使用导入权限的运营人员。""" - if not (_has_huya_perm(user, "huya:tag") or _has_huya_perm(user, "huya:import")): - raise HTTPException(status_code=403, detail="无权修改虎牙账号标签") - - -def _can_view_huya_all(user: User) -> bool: - return _has_huya_perm(user, "huya:view_all") - - -def _can_view_huya_assigned(user: User) -> bool: - return _has_huya_perm(user, "huya:view_assigned") - - -def _can_view_huya_cookie(user: User) -> bool: - return _has_huya_perm(user, "huya:cookie:view") or _has_huya_perm( - user, "huya:cookie:export" - ) - - -def _visible_huya_accounts_query(db: Session, current: User): - """返回当前用户可见的虎牙账号查询。""" - query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user)) - if _can_view_huya_all(current): - return query - if _can_view_huya_assigned(current): - return query.filter(HuyaAccount.assigned_to == current.id) - raise HTTPException(status_code=403, detail="无权查看虎牙账号") - - -def _filter_huya_accounts_query( - query, - current: User, - *, - assigned_only: bool = False, - tag: str | None = None, - has_cookie: bool = False, - search: str = "", -): - """复用虎牙账号列表筛选条件,供分页和批量操作保持一致。""" - if assigned_only and _can_view_huya_all(current): - query = query.filter(HuyaAccount.assigned_to.isnot(None)) - if tag: - query = query.filter(HuyaAccount.tag == tag) - if has_cookie: - query = query.filter(HuyaAccount.cookie != "") - search_text = (search or "").strip() - if search_text: - pattern = f"%{search_text}%" - query = query.filter( - or_( - HuyaAccount.uid.ilike(pattern), - HuyaAccount.yyuid.ilike(pattern), - HuyaAccount.username.ilike(pattern), - HuyaAccount.nickname.ilike(pattern), - HuyaAccount.tag.ilike(pattern), - HuyaAccount.game_name.ilike(pattern), - HuyaAccount.game_phone.ilike(pattern), - ) - ) - return query - - -def _selected_huya_account_ids( - db: Session, current: User, req: AccountBulkSelection -) -> list[int]: - """解析虎牙批量操作目标:当前筛选全部或显式选择的 ID。""" - base_query = _visible_huya_accounts_query(db, current) - if req.all_matching: - rows = ( - _filter_huya_accounts_query( - base_query, - current, - assigned_only=req.assigned_only, - tag=req.tag, - has_cookie=req.has_cookie, - search=req.search, - ) - .with_entities(HuyaAccount.id) - .order_by(None) - .all() - ) - return [account_id for (account_id,) in rows] - - seen = set() - requested_ids = [] - for account_id in req.account_ids: - if account_id not in seen: - seen.add(account_id) - requested_ids.append(account_id) - if not requested_ids: - return [] - rows = ( - base_query.filter(HuyaAccount.id.in_(requested_ids)) - .with_entities(HuyaAccount.id) - .order_by(None) - .all() - ) - allowed = {account_id for (account_id,) in rows} - return [account_id for account_id in requested_ids if account_id in allowed] - - -def _clear_huya_account_references(db: Session, account_ids: list[int]) -> None: - """删除账号前保留注册历史,把历史流水中的账号引用置空。""" - if not account_ids: - return - db.query(HuyaRegisterItem).filter( - HuyaRegisterItem.account_id.in_(account_ids) - ).update( - {HuyaRegisterItem.account_id: None}, - synchronize_session=False, - ) - db.query(HuyaRegisterSuccessLog).filter( - HuyaRegisterSuccessLog.account_id.in_(account_ids) - ).update( - {HuyaRegisterSuccessLog.account_id: None}, - synchronize_session=False, - ) - - -def _visible_huya_tasks_query(db: Session, current: User): - """返回当前用户可查看的虎牙任务查询。""" - query = db.query(HuyaTask).options(joinedload(HuyaTask.account)) - if _can_view_huya_all(current): - return query - if _can_view_huya_assigned(current): - return query.join(HuyaTask.account).filter( - HuyaAccount.assigned_to == current.id - ) - raise HTTPException(status_code=403, detail="无权查看虎牙任务") - - -def _huya_task_summary(query): - """按状态汇总虎牙任务,避免概览页拉完整任务列表。""" - rows = ( - query.enable_eagerloads(False) - .order_by(None) - .with_entities(HuyaTask.status, func.count(HuyaTask.id)) - .group_by(HuyaTask.status) - .all() - ) - status_counts = {status or "": count for status, count in rows} - return { - "total": sum(status_counts.values()), - "success": status_counts.get("success", 0), - "failed": sum(status_counts.get(status, 0) for status in ("failed", "error")), - "status_counts": status_counts, - } - - -def _require_huya_task_account_access( - db: Session, current: User, account_ids: list[int] -) -> None: - """确保任务只会提交到当前用户可操作的虎牙账号。""" - requested_ids = set(account_ids) - query = db.query(HuyaAccount.id).filter(HuyaAccount.id.in_(requested_ids)) - if not _can_view_huya_all(current): - if not _can_view_huya_assigned(current): - raise HTTPException(status_code=403, detail="无权操作虎牙账号") - query = query.filter(HuyaAccount.assigned_to == current.id) - allowed_ids = {account_id for (account_id,) in query.all()} - if allowed_ids != requested_ids: - raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号") - - -def _workbench_account_ids(db: Session, current: User, scope: str) -> list[int]: - query = db.query(HuyaWorkbenchAccount.account_id).filter( - HuyaWorkbenchAccount.user_id == current.id, - HuyaWorkbenchAccount.handbook_scope == scope, - ) - if not _can_view_huya_all(current): - query = query.join( - HuyaAccount, HuyaAccount.id == HuyaWorkbenchAccount.account_id - ).filter(HuyaAccount.assigned_to == current.id) - return [ - account_id - for (account_id,) in query.order_by(HuyaWorkbenchAccount.id.asc()).all() - ] - - -def _require_huya_batch_owner(db: Session, current: User, batch_id: str) -> None: - """客服只能停止或订阅自己创建的任务批次。""" - if _can_view_huya_all(current): - return - exists = ( - db.query(HuyaTask.id) - .filter(HuyaTask.batch_id == batch_id, HuyaTask.created_by == current.id) - .first() - ) - if not exists: - raise HTTPException(status_code=403, detail="无权操作该虎牙任务批次") - - -def _fmt_cookie_preview(cookie: str) -> str: - if not cookie: - return "" - return cookie[:50] + "..." if len(cookie) > 50 else cookie - - -def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccountOut: - cookie = normalize_huya_cookie(account.cookie or "") - return HuyaAccountOut( - id=account.id, - uid=account.uid or "", - yyuid=account.yyuid or "", - username=account.username or "", - has_password=bool(account.account_password), - has_cookie=bool(account.cookie), - nickname=account.nickname or "", - cookie=cookie if include_cookie else "", - cookie_preview=_fmt_cookie_preview(cookie) if include_cookie else "***", - tag=account.tag or "", - remark=account.remark or "", - status=account.status or "", - login_channel=getattr(account, "login_channel", "") or "", - points=account.points, - game_name=account.game_name or "", - game_channel=account.game_channel or "", - game_phone=account.game_phone or "", - sms_url=account.sms_url or "", - assigned_to=account.assigned_to, - assigned_username=account.assigned_user.username - if account.assigned_user - else None, - created_at=account.created_at, - updated_at=account.updated_at, - ) - - -def _account_out_light( - account: HuyaAccount, *, has_password: bool = False, has_cookie: bool = False -) -> HuyaAccountOut: - """虎牙账号列表轻量输出,不读取加密字段。""" - return HuyaAccountOut( - id=account.id, - uid=account.uid or "", - yyuid=account.yyuid or "", - username=account.username or "", - has_password=has_password, - has_cookie=has_cookie, - nickname=account.nickname or "", - cookie="", - cookie_preview="***", - tag=account.tag or "", - remark=account.remark or "", - status=account.status or "", - login_channel=getattr(account, "login_channel", "") or "", - points=account.points, - game_name=account.game_name or "", - game_channel=account.game_channel or "", - game_phone=account.game_phone or "", - sms_url=account.sms_url or "", - assigned_to=account.assigned_to, - assigned_username=account.assigned_user.username - if account.assigned_user - else None, - created_at=account.created_at, - updated_at=account.updated_at, - ) - - -def _sanitize_task_result( - result: dict | None, *, include_images: bool = False -) -> dict | None: - """列表接口默认剥离 base64 图片,避免轮询每次传 1MB+ 数据。""" - if not isinstance(result, dict): - return result - - data = dict(result) - image = data.get("mini_qrcode_image") - if isinstance(image, str) and image: - data["has_mini_qrcode"] = True - if not include_images: - data.pop("mini_qrcode_image", None) - elif data.get("has_mini_qrcode"): - data["has_mini_qrcode"] = True - return data - - -def _task_out(task: HuyaTask, *, include_images: bool = False) -> HuyaTaskOut: - account = task.account - return HuyaTaskOut( - id=task.id, - batch_id=task.batch_id, - account_id=task.account_id, - account_uid=account.uid if account else "", - account_nickname=account.nickname if account else "", - task_type=task.task_type, - handbook_scope=getattr(task, "handbook_scope", "legacy") or "legacy", - status=task.status or "", - message=task.message or "", - result=_sanitize_task_result( - task.result if isinstance(task.result, dict) else None, - include_images=include_images, - ), - created_by=task.created_by, - created_at=task.created_at, - finished_at=task.finished_at, - ) - - -def _config_out(config: HuyaConfig) -> HuyaConfigOut: - return HuyaConfigOut( - room_pid=huya_config_value("room_pid", config.room_pid), - sid=huya_config_value("sid", config.sid), - outer_act_id=huya_config_value("outer_act_id", config.outer_act_id), - bind_act_id=huya_config_value("bind_act_id", config.bind_act_id), - pay_channel=huya_config_value("pay_channel", config.pay_channel), - updated_at=config.updated_at, - ) - - -def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict: - cookie = normalize_huya_cookie(account.cookie or "") - account_name = ( - account.nickname or account.username or account.uid or str(account.id) - ) - return { - "id": account.id, - "account_id": account.id, - "account_username": account_name, - "uid": account.uid or "", - "yyuid": account.yyuid or "", - "assigned_to": account.assigned_to, - "assigned_username": account.assigned_user.username - if account.assigned_user - else None, - "created_at": account.updated_at.isoformat() if account.updated_at else None, - "cookie": cookie if include_cookie else "", - "cookie_preview": _fmt_cookie_preview(cookie) if include_cookie else "***", - } - - -def _cookie_out_light(account: HuyaAccount) -> dict: - """虎牙 Cookie 列表轻量输出,不读取完整加密 Cookie。""" - account_name = ( - account.nickname or account.username or account.uid or str(account.id) - ) - return { - "id": account.id, - "account_id": account.id, - "account_username": account_name, - "uid": account.uid or "", - "yyuid": account.yyuid or "", - "assigned_to": account.assigned_to, - "assigned_username": account.assigned_user.username - if account.assigned_user - else None, - "created_at": account.updated_at.isoformat() if account.updated_at else None, - "cookie": "", - "cookie_preview": "***", - } - - -def _huya_password_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]: - """不解密密码字段,仅判断密文字段是否非空。""" - if not account_ids: - return {} - rows = ( - db.query( - HuyaAccount.id, (HuyaAccount.account_password != "").label("has_password") - ) - .filter(HuyaAccount.id.in_(account_ids)) - .all() - ) - return {account_id: bool(has_password) for account_id, has_password in rows} - - -def _huya_cookie_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]: - if not account_ids: - return {} - rows = ( - db.query(HuyaAccount.id, (HuyaAccount.cookie != "").label("has_cookie")) - .filter(HuyaAccount.id.in_(account_ids)) - .all() - ) - return {account_id: bool(has_cookie) for account_id, has_cookie in rows} - - -@router.get("/task-types") -def task_types(current: User = Depends(require_permission("huya:task"))): - """返回当前规划的虎牙任务类型。""" - return SUPPORTED_TASK_TYPES - - -@router.get("/accounts") -def list_accounts( - assigned_only: bool = Query(False), - tag: str | None = Query(None), - has_cookie: bool = Query(False), - search: str = Query(""), - page: int | None = Query(None, ge=1), - page_size: int = Query(20, ge=1, le=200), - include_cookie: bool | None = Query(None), - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """查看虎牙 CK 账号。""" - query = _filter_huya_accounts_query( - _visible_huya_accounts_query(db, current), - current, - assigned_only=assigned_only, - tag=tag, - has_cookie=has_cookie, - search=search, - ) - - total = None - if page is not None: - total = query.order_by(None).count() - should_include_cookie = ( - _can_view_huya_cookie(current) - if include_cookie is None - else (include_cookie and _can_view_huya_cookie(current)) - ) - query = query.order_by(HuyaAccount.id.desc()) - if page is not None: - query = query.offset((page - 1) * page_size).limit(page_size) - - if not should_include_cookie: - query = query.options( - defer(HuyaAccount.cookie), defer(HuyaAccount.account_password) - ) - accounts = query.all() - if should_include_cookie: - result = [_account_out(account, include_cookie=True) for account in accounts] - else: - password_map = _huya_password_state_map( - db, [account.id for account in accounts] - ) - cookie_map = _huya_cookie_state_map(db, [account.id for account in accounts]) - result = [ - _account_out_light( - account, - has_password=password_map.get(account.id, False), - has_cookie=cookie_map.get(account.id, False), - ) - for account in accounts - ] - if page is not None: - return { - "items": result, - "total": total or 0, - "page": page, - "page_size": page_size, - } - return result - - -@router.get("/accounts/summary") -def accounts_summary( - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """虎牙账号统计,避免前端为了卡片统计拉全量账号。""" - query = _visible_huya_accounts_query(db, current) - total = query.count() - assigned_count = query.filter(HuyaAccount.assigned_to.isnot(None)).count() - tag_count = ( - query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)) - .with_entities(HuyaAccount.tag) - .distinct() - .count() - ) - password_ready_count = query.filter(HuyaAccount.account_password != "").count() - point_count = query.filter(HuyaAccount.points.isnot(None)).count() - bound_count = query.filter( - or_( - HuyaAccount.game_name != "", - HuyaAccount.game_channel != "", - HuyaAccount.game_phone != "", - ) - ).count() - return { - "total": total, - "assigned_count": assigned_count, - "unassigned_count": max(0, total - assigned_count), - "tag_count": tag_count, - "password_ready_count": password_ready_count, - "point_count": point_count, - "bound_count": bound_count, - } - - -@router.get("/accounts/ids") -def list_huya_account_ids( - search: str = "", - tag: str = "", - has_cookie: bool = False, - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """返回当前用户可见的全部账号 ID(不受分页限制),供工作台一键导入。""" - query = _filter_huya_accounts_query( - _visible_huya_accounts_query(db, current), - current, - tag=tag or None, - has_cookie=has_cookie, - search=search, - ) - rows = query.with_entities(HuyaAccount.id).all() - return {"account_ids": [row[0] for row in rows], "total": len(rows)} - - -@router.post("/accounts/import-cookies") -def import_cookies( - req: HuyaCookieImport, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """粘贴并导入虎牙 Cookie。""" - _require_huya_perm(current, "huya:import") - count, skipped = import_huya_cookies(db, req.text, req.tag) - return { - "message": f"导入/更新 {count} 条,跳过 {skipped} 条", - "success": True, - "count": count, - "skipped": skipped, - } - - -@router.post("/accounts/import-passwords") -def import_password_accounts( - req: HuyaPasswordAccountImport, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """导入虎牙账号密码,只入库不立即登录(仅允许四段格式)。""" - _require_huya_perm(current, "huya:import") - count, skipped = import_huya_password_accounts( - db, req.text, req.tag, require_four_segments=True - ) - if count == 0 and skipped > 0: - raise HTTPException( - status_code=400, - detail="导入失败:当前仅允许四段格式(账号----密码----手机号----接码链接)", - ) - return { - "message": f"成功导入/更新 {count} 个虎牙账号,跳过 {skipped} 条", - "success": True, - "count": count, - "skipped": skipped, - } - - -@router.post("/accounts/password-login") -def password_login_account( - req: HuyaPasswordLoginRequest, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """使用 Web 方式账号密码登录虎牙(旧版),成功后保存 Cookie。""" - _require_huya_perm(current, "huya:import") - try: - result = login_huya_password( - username=req.username.strip(), - password=req.password, - cookie=req.cookie.strip() or None, - ) - except HuyaCredentialError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except HuyaLoginError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException( - status_code=502, detail=f"虎牙 Web 密码登录失败: {exc}" - ) from exc - - if not result.success or not result.cookie: - raise HTTPException( - status_code=502, detail=result.message or "虎牙 Web 密码登录失败" - ) - - try: - account = upsert_huya_cookie( - db, result.cookie, tag=req.tag, username_hint=req.username - ) - if hasattr(account, "account_password") and req.password: - account.account_password = req.password - account.status = "active" - account.login_channel = "web" - db.commit() - except ValueError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return { - "message": "Web 登录成功,Cookie 已保存", - "success": True, - "account": _account_out(account), - "sdid": result.sdid, - } - - -@router.post("/accounts/app-password-login") -def app_password_login_account( - req: HuyaAppPasswordLoginRequest, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """使用 App 协议密码登录虎牙(推荐),自动过滑块并保存 Cookie。""" - _require_huya_perm(current, "huya:import") - username_clean = req.username.strip() - account_db = ( - db.query(HuyaAccount).filter(HuyaAccount.username == username_clean).first() - ) - sms_url = account_db.sms_url if account_db and account_db.sms_url else "" - try: - result = login_huya_app_password( - username=username_clean, - password=req.password, - force_new_device=req.force_new_device, - sms_url=sms_url, - ) - except HuyaAppQrAuthRequiredError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except HuyaCredentialError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except (HuyaAppLoginError, HuyaLoginError) as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException( - status_code=502, detail=f"虎牙 App 密码登录失败: {exc}" - ) from exc - - if not result.success or not result.cookie: - logger.warning( - f"[huya-app] 账号 {req.username} 密码登录未完成: {result.message} (code={result.code})" - ) - raise HTTPException( - status_code=400 - if result.code in ("SMS_AUTH_REQUIRED", "QR_AUTH_REQUIRED") - else 502, - detail=result.message or "虎牙 App 密码登录失败", - ) - - try: - account = upsert_huya_cookie( - db, - result.cookie, - tag=req.tag, - username_hint=req.username, - raw_cookie=getattr(result, "raw_cookie", ""), - ) - if hasattr(account, "account_password") and req.password: - account.account_password = req.password - account.status = "active" - account.login_channel = "app" - db.commit() - except ValueError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return { - "message": "App 协议登录成功,Cookie 已保存", - "success": True, - "account": _account_out(account), - "sdid": result.sdid, - } - - -@router.post("/accounts/sms-code") -def send_sms_code( - req: HuyaSmsCodeRequest, - current: User = Depends(get_current_user), -): - """发送虎牙短信验证码,返回提交登录所需 state。""" - _require_huya_perm(current, "huya:import") - try: - result = send_huya_sms_code( - phone=req.phone.strip(), - cookie=req.cookie.strip() or None, - ) - except HuyaLoginError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=502, detail=f"虎牙短信发码失败: {exc}") from exc - - if not result.success or not result.state: - raise HTTPException( - status_code=502, detail=result.message or "虎牙短信发码失败" - ) - - return { - "message": result.message or "短信已发送", - "success": True, - "state": result.state, - "sdid": result.sdid, - "context": result.context, - "request_id": result.request_id, - } - - -@router.post("/accounts/sms-login") -def sms_login_account( - req: HuyaSmsLoginRequest, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """提交虎牙短信验证码登录,成功后保存 Cookie。""" - _require_huya_perm(current, "huya:import") - try: - result = login_huya_sms( - authcode=req.authcode.strip(), - state=req.state.strip(), - phone=req.phone.strip(), - ) - except HuyaLoginError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=502, detail=f"虎牙短信登录失败: {exc}") from exc - - if not result.success or not result.cookie: - raise HTTPException( - status_code=502, detail=result.message or "虎牙短信登录失败" - ) - - try: - account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint="") - account.login_channel = "sms" - phone = (req.phone or "").strip() - if phone: - account.game_phone = phone - account.updated_at = datetime.now(UTC) - db.commit() - db.refresh(account) - except ValueError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - return { - "message": "登录成功,Cookie 已保存", - "success": True, - "account": _account_out(account), - "sdid": result.sdid, - } - - -@router.post("/accounts/test-sms-url") -def test_sms_url_endpoint( - req: HuyaTestSmsUrlRequest, - current: User = Depends(get_current_user), -): - """测试从指定接码链接获取验证码。""" - _require_huya_perm(current, "huya:import") - url = req.url.strip() - if not url: - raise HTTPException(status_code=400, detail="接码链接不能为空") - from core.sms_provider import SmsLine, SmsProviderClient, detect_provider - - client = SmsProviderClient(timeout=(8, 15)) - item = SmsLine( - phone=req.phone.strip(), - url=url, - provider=detect_provider(url), - raw=f"{req.phone}----{url}", - ) - poll_result = client.poll(item) - return { - "success": poll_result.has_code, - "code": poll_result.code, - "status": poll_result.status, - "message": poll_result.message - or ("成功识别验证码" if poll_result.has_code else "未识别到验证码"), - "raw": poll_result.raw[:500], - } - - -@router.post("/accounts/{account_id}/fetch-sms-code") -def fetch_account_sms_code( - account_id: int, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """通过该账号绑定的接码链接获取最新短信验证码。""" - _require_huya_perm(current, "huya:import") - account = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id == account_id) - .first() - ) - if account is None: - raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作") - if not account.sms_url: - raise HTTPException(status_code=400, detail="该账号未配置接码链接 (sms_url)") - from core.sms_provider import SmsLine, SmsProviderClient, detect_provider - - client = SmsProviderClient(timeout=(8, 15)) - item = SmsLine( - phone=account.game_phone or "", - url=account.sms_url, - provider=detect_provider(account.sms_url), - raw=f"{account.game_phone}----{account.sms_url}", - ) - poll_result = client.poll(item) - return { - "account_id": account.id, - "username": account.username or "", - "phone": account.game_phone or "", - "sms_url": account.sms_url, - "success": poll_result.has_code, - "code": poll_result.code, - "status": poll_result.status, - "message": poll_result.message - or ("成功识别验证码" if poll_result.has_code else "未识别到验证码"), - "raw": poll_result.raw[:500], - } - - -@router.post("/accounts/{account_id}/auto-sms-login") -def auto_sms_login_account( - account_id: int, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """使用该账号绑定的手机号与接码链接一键执行虎牙短信登录。""" - _require_huya_perm(current, "huya:import") - account = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id == account_id) - .first() - ) - if account is None: - raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作") - phone = (account.game_phone or "").strip() - sms_url = (account.sms_url or "").strip() - if not phone or not sms_url: - raise HTTPException(status_code=400, detail="该账号缺少手机号或接码链接") - from core.huya.auto_register import auto_register_huya_phone - from core.sms_provider import SmsLine, detect_provider - - item = SmsLine( - phone=phone, - url=sms_url, - provider=detect_provider(sms_url), - raw=f"{phone}----{sms_url}", - ) - res = auto_register_huya_phone(item, wait_seconds=90, poll_interval=2) - if not res.success or not res.cookie: - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - raise HTTPException( - status_code=502, detail=res.message or "虎牙短信接码登录失败" - ) - saved = save_huya_login_cookie_to_account( - db, - account, - res.cookie, - tag=account.tag or "", - username_hint=account.username or "", - ) - saved.status = "active" - saved.login_channel = "sms" - db.commit() - return { - "message": "短信接码登录成功,Cookie 已保存", - "success": True, - "account": _account_out(saved), - } - - -@router.post("/register/batches", response_model=HuyaAutoRegisterBatchOut) -def create_auto_register_batch( - req: HuyaAutoRegisterRequest, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """启动虎牙手机号自动注册批次。""" - _require_huya_perm(current, "huya:import") - sms_lines = parse_sms_lines(req.text) - if not sms_lines: - raise HTTPException( - status_code=400, - detail="没有识别到有效手机号,格式为:手机号----短信查询URL 或 虎牙号----密码----手机号----短信查询URL", - ) - - proxy_config = db.query(ProxyConfig).first() if req.use_proxy else None - runner = huya_register_registry.create( - sms_lines=sms_lines, - tag=req.tag.strip(), - created_by=current.id, - concurrency=req.concurrency, - wait_seconds=req.wait_seconds, - poll_interval=req.poll_interval, - password_prefix=req.password_prefix, - fixed_password=req.fixed_password, - use_proxy=req.use_proxy, - proxy_config=proxy_config, - ) - runner.mark_running("批次运行中") - thread = threading.Thread(target=runner.run, daemon=True) - thread.start() - return runner.snapshot() - - -@router.get("/register/batches", response_model=list[HuyaAutoRegisterBatchOut]) -def list_auto_register_batches( - limit: int = Query(50, ge=1, le=200), - current: User = Depends(get_current_user), -): - """列出历史自动注册批次(摘要,不含明细)。""" - _require_huya_perm(current, "huya:import") - return huya_register_registry.list_summaries(limit=limit) - - -@router.get("/register/batches/{batch_id}", response_model=HuyaAutoRegisterBatchOut) -def get_auto_register_batch( - batch_id: str, - current: User = Depends(get_current_user), -): - """查询虎牙手机号自动注册批次状态(优先内存,否则读库)。""" - _require_huya_perm(current, "huya:import") - snapshot = huya_register_registry.get_snapshot(batch_id) - if not snapshot: - raise HTTPException(status_code=404, detail="批次不存在") - return snapshot - - -@router.post("/register/batches/{batch_id}/stop") -def stop_auto_register_batch( - batch_id: str, - current: User = Depends(get_current_user), -): - """停止虎牙手机号自动注册批次。""" - _require_huya_perm(current, "huya:import") - runner = huya_register_registry.get(batch_id) - if not runner: - # 无内存 runner:若 DB 中存在且处于 running/interrupted,标记停止 - snapshot = huya_register_registry.get_snapshot(batch_id) - if not snapshot: - raise HTTPException(status_code=404, detail="批次不存在") - return {"message": "批次未在本进程运行,无需停止", "success": True} - runner.stop() - return {"message": "已发送停止信号", "success": True} - - -@router.post( - "/register/batches/{batch_id}/retry", response_model=HuyaAutoRegisterBatchOut -) -def retry_auto_register_batch( - batch_id: str, - req: HuyaAutoRegisterRetryRequest | None = None, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """继续批次:默认从停止处往下跑(跳过成功与已失败);可传 mode 改行为。""" - _require_huya_perm(current, "huya:import") - if req is None: - req = HuyaAutoRegisterRetryRequest.model_construct() - use_proxy = req.use_proxy - # 先看历史批次是否用过代理 - snapshot = huya_register_registry.get_snapshot(batch_id) - if not snapshot: - raise HTTPException(status_code=404, detail="批次不存在") - effective_proxy = ( - snapshot.get("use_proxy", False) if use_proxy is None else bool(use_proxy) - ) - proxy_config = db.query(ProxyConfig).first() if effective_proxy else None - try: - runner = huya_register_registry.retry( - batch_id, - proxy_config=proxy_config, - mode=req.mode, - concurrency=req.concurrency, - wait_seconds=req.wait_seconds, - poll_interval=req.poll_interval, - password_prefix=req.password_prefix, - fixed_password=req.fixed_password, - use_proxy=use_proxy, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except RuntimeError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - thread = threading.Thread(target=runner.run, daemon=True) - thread.start() - return runner.snapshot() - - -@router.get("/register/batches/{batch_id}/export") -def export_auto_register_batch_success( - batch_id: str, - current: User = Depends(get_current_user), -): - """导出指定批次的成功账号 txt。""" - _require_huya_perm(current, "huya:import") - snapshot = huya_register_registry.get_snapshot(batch_id) - if not snapshot: - raise HTTPException(status_code=404, detail="批次不存在") - content = export_success_logs_text(batch_id=batch_id) - if not content.strip(): - raise HTTPException(status_code=404, detail="该批次没有可导出的成功记录") - return StreamingResponse( - iter([content]), - media_type="text/plain; charset=utf-8", - headers={ - "Content-Disposition": f"attachment; filename=huya-register-{batch_id}.txt" - }, - ) - - -@router.get("/register/success-logs", response_model=list[HuyaRegisterSuccessLogOut]) -def list_register_success_logs( - batch_id: str | None = Query(None), - tag: str | None = Query(None), - limit: int = Query(200, ge=1, le=2000), - current: User = Depends(get_current_user), -): - """列出注册成功流水(换电脑也可查看)。""" - _require_huya_perm(current, "huya:import") - return list_success_logs(batch_id=batch_id, tag=tag, limit=limit) - - -@router.get("/register/success-logs/export") -def export_register_success_logs( - batch_id: str | None = Query(None), - tag: str | None = Query(None), - limit: int = Query(5000, ge=1, le=20000), - current: User = Depends(get_current_user), -): - """导出注册成功流水 txt:账号----密码----手机号----接码链接。""" - _require_huya_perm(current, "huya:import") - content = export_success_logs_text(batch_id=batch_id, tag=tag, limit=limit) - if not content.strip(): - raise HTTPException(status_code=404, detail="没有可导出的成功记录") - filename = "huya-register-success" - if batch_id: - filename += f"-{batch_id}" - if tag: - filename += f"-{tag}" - filename += ".txt" - return StreamingResponse( - iter([content]), - media_type="text/plain; charset=utf-8", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) - - -@router.post("/accounts/password-login/selected") -def password_login_selected_accounts( - req: HuyaPasswordLoginSelectedRequest, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """对已导入的虎牙账号执行密码登录。""" - _require_huya_perm(current, "huya:import") - selected_ids = _selected_huya_account_ids( - db, current, cast(AccountBulkSelection, req) - ) - if not selected_ids: - raise HTTPException(status_code=400, detail="请选择虎牙账号") - - accounts = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id.in_(selected_ids)) - .all() - ) - account_map = {account.id: account for account in accounts} - results = [] - success_count = 0 - failed_count = 0 - include_cookie = _can_view_huya_cookie(current) - - for account_id in selected_ids: - account = account_map.get(account_id) - if account is None: - failed_count += 1 - results.append( - { - "line": account_id, - "username": "", - "success": False, - "message": "账号不存在或无权登录", - } - ) - continue - - username = (account.username or "").strip() - password = (account.account_password or "").strip() - if not username or not password: - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": "该账号未导入密码", - "account": _account_out(account, include_cookie=include_cookie), - } - ) - continue - - try: - result = login_huya_password( - username=username, - password=password, - cookie=account.cookie or None, - ) - if not result.success or not result.cookie: - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": result.message or "虎牙密码登录失败", - "account": _account_out(account, include_cookie=include_cookie), - "sdid": result.sdid, - } - ) - continue - - saved = save_huya_login_cookie_to_account( - db, - account, - result.cookie, - tag=account.tag or "", - username_hint=username, - ) - # save_huya_login_cookie_to_account 内部已 commit,渠道/状态回填需再次提交, - # 否则 get_db 关闭会话时丢弃(GUI 登录渠道列会显示"仅导入")。 - saved.status = "active" - saved.login_channel = "web" - db.commit() - success_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": True, - "message": "登录成功,Cookie 已保存", - "account": _account_out(saved, include_cookie=include_cookie), - "sdid": result.sdid, - } - ) - except HuyaCredentialError as exc: - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - } - ) - except (HuyaLoginError, ValueError) as exc: - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - } - ) - except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": f"虎牙密码登录失败: {exc}", - "account": _account_out(account, include_cookie=include_cookie), - } - ) - - return { - "message": f"Web 登录完成:成功 {success_count} 条,失败 {failed_count} 条", - "success": True, - "count": success_count, - "failed": failed_count, - "results": results, - } - - -@router.post("/accounts/app-password-login/selected") -def app_password_login_selected_accounts( - req: HuyaPasswordLoginSelectedRequest, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """对已导入的虎牙账号使用 App 协议批量执行密码登录(推荐)。""" - _require_huya_perm(current, "huya:import") - selected_ids = _selected_huya_account_ids( - db, current, cast(AccountBulkSelection, req) - ) - if not selected_ids: - raise HTTPException(status_code=400, detail="请选择虎牙账号") - - accounts = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id.in_(selected_ids)) - .all() - ) - account_map = {account.id: account for account in accounts} - results = [] - success_count = 0 - failed_count = 0 - include_cookie = _can_view_huya_cookie(current) - - for account_id in selected_ids: - account = account_map.get(account_id) - if account is None: - failed_count += 1 - results.append( - { - "line": account_id, - "username": "", - "success": False, - "message": "账号不存在或无权登录", - } - ) - continue - - username = (account.username or "").strip() - password = (account.account_password or "").strip() - if not username or not password: - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": "该账号未导入密码", - "account": _account_out(account, include_cookie=include_cookie), - } - ) - continue - - sms_url = (account.sms_url or "").strip() - try: - result = login_huya_app_password( - username=username, - password=password, - force_new_device=req.force_new_device, - sms_url=sms_url, - ) - if not result.success or not result.cookie: - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - logger.warning( - f"[huya-app] 账号 {username} 批量登录未完成: {result.message} (code={result.code})" - ) - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": result.message or "虎牙 App 密码登录失败", - "account": _account_out(account, include_cookie=include_cookie), - "sdid": result.sdid, - } - ) - continue - - saved = save_huya_login_cookie_to_account( - db, - account, - result.cookie, - tag=account.tag or "", - username_hint=username, - ) - saved.status = "active" - saved.login_channel = "app" - db.commit() - success_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": True, - "message": "App 登录成功,Cookie 已保存", - "account": _account_out(saved, include_cookie=include_cookie), - "sdid": result.sdid, - } - ) - except HuyaAppQrAuthRequiredError as exc: - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - } - ) - except HuyaCredentialError as exc: - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - } - ) - except (HuyaAppLoginError, HuyaLoginError, ValueError) as exc: - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": str(exc), - "account": _account_out(account, include_cookie=include_cookie), - } - ) - except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 - account.status = "login_failed" - account.updated_at = datetime.now(UTC) - db.commit() - failed_count += 1 - results.append( - { - "line": account.id, - "username": username, - "success": False, - "message": f"虎牙 App 密码登录失败: {exc}", - "account": _account_out(account, include_cookie=include_cookie), - } - ) - - return { - "message": f"App 登录完成:成功 {success_count} 条,失败 {failed_count} 条", - "success": True, - "count": success_count, - "failed": failed_count, - "results": results, - } - - -@router.delete("/accounts/batch") -def delete_accounts_batch( - account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"), - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """批量删除虎牙 CK 账号及任务记录。""" - _require_huya_perm(current, "huya:delete") - ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] - if not ids: - raise HTTPException(status_code=400, detail="无效的账号ID") - ids = _selected_huya_account_ids(db, current, AccountBulkSelection(account_ids=ids)) - if not ids: - raise HTTPException(status_code=400, detail="请选择虎牙账号") - db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( - synchronize_session=False - ) - db.query(HuyaWorkbenchAccount).filter( - HuyaWorkbenchAccount.account_id.in_(ids) - ).delete(synchronize_session=False) - _clear_huya_account_references(db, ids) - deleted = ( - db.query(HuyaAccount) - .filter(HuyaAccount.id.in_(ids)) - .delete(synchronize_session=False) - ) - db.commit() - return { - "message": f"已删除 {deleted} 个虎牙账号", - "deleted": deleted, - "success": True, - } - - -@router.post("/accounts/batch-delete") -def delete_accounts_batch_selection( - req: AccountBulkSelection, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """按显式选择或当前筛选结果批量删除虎牙账号及任务记录。""" - _require_huya_perm(current, "huya:delete") - ids = _selected_huya_account_ids(db, current, req) - if not ids: - raise HTTPException(status_code=400, detail="请选择虎牙账号") - db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( - synchronize_session=False - ) - db.query(HuyaWorkbenchAccount).filter( - HuyaWorkbenchAccount.account_id.in_(ids) - ).delete(synchronize_session=False) - _clear_huya_account_references(db, ids) - deleted = ( - db.query(HuyaAccount) - .filter(HuyaAccount.id.in_(ids)) - .delete(synchronize_session=False) - ) - db.commit() - scope = "当前筛选下" if req.all_matching else "选中的" - return { - "message": f"已删除{scope} {deleted} 个虎牙账号", - "deleted": deleted, - "success": True, - } - - -@router.delete("/accounts/{account_id}") -def delete_account( - account_id: int, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """删除单个虎牙 CK 账号。""" - _require_huya_perm(current, "huya:delete") - account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() - if not account: - raise HTTPException(status_code=404, detail="账号不存在") - db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete( - synchronize_session=False - ) - db.query(HuyaWorkbenchAccount).filter( - HuyaWorkbenchAccount.account_id == account_id - ).delete(synchronize_session=False) - _clear_huya_account_references(db, [account_id]) - db.delete(account) - db.commit() - return {"message": "已删除", "success": True} - - -@router.put("/accounts/{account_id}/assign") -def assign_account( - account_id: int, - req: AccountAssign, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """分配单个虎牙账号给客服。""" - _require_huya_perm(current, "huya:assign") - account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() - if not account: - raise HTTPException(status_code=404, detail="账号不存在") - if req.assigned_to: - target = db.query(User).filter(User.id == req.assigned_to).first() - if not target: - raise HTTPException(status_code=404, detail="目标用户不存在") - if target.role != "support": - raise HTTPException(status_code=400, detail="只能分配给客服角色") - account.assigned_to = req.assigned_to - db.commit() - return {"message": "已分配", "success": True} - - -@router.post("/accounts/batch-assign") -def batch_assign_accounts( - req: BatchAssign, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """批量分配/取消分配虎牙账号给客服。""" - _require_huya_perm(current, "huya:assign") - if not req.account_ids: - raise HTTPException(status_code=400, detail="请选择账号") - if req.assigned_to is not None: - target = db.query(User).filter(User.id == req.assigned_to).first() - if not target: - raise HTTPException(status_code=404, detail="目标用户不存在") - if target.role != "support": - raise HTTPException(status_code=400, detail="只能分配给客服角色") - - count = ( - db.query(HuyaAccount) - .filter(HuyaAccount.id.in_(req.account_ids)) - .update( - {HuyaAccount.assigned_to: req.assigned_to}, - synchronize_session=False, - ) - ) - db.commit() - action = "分配" if req.assigned_to else "取消分配" - return { - "message": f"已批量{action} {count} 个虎牙账号", - "success": True, - "count": count, - } - - -@router.get("/accounts/assignments/summary") -def assignments_summary( - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """虎牙分配概览:每个客服分配了多少虎牙账号。""" - _require_huya_perm(current, "huya:assign") - results = ( - db.query(User.id, User.username, func.count(HuyaAccount.id).label("count")) - .outerjoin(HuyaAccount, HuyaAccount.assigned_to == User.id) - .filter(User.role == "support") - .group_by(User.id, User.username) - .order_by(func.count(HuyaAccount.id).desc()) - .all() - ) - total_unassigned = ( - db.query(func.count(HuyaAccount.id)) - .filter(HuyaAccount.assigned_to.is_(None)) - .scalar() - ) or 0 - return { - "support_users": [ - {"id": uid, "username": uname, "assigned_count": cnt} - for uid, uname, cnt in results - ], - "unassigned_count": total_unassigned, - } - - -@router.put("/accounts/{account_id}/tag") -def set_account_tag( - account_id: int, - req: AccountTag, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """设置单个虎牙账号标签。""" - _require_huya_tag_permission(current) - account = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id == account_id) - .first() - ) - if not account: - raise HTTPException(status_code=404, detail="账号不存在") - account.tag = (req.tag or "").strip() - db.commit() - return {"message": "标签已更新", "success": True} - - -@router.put("/accounts/batch-tag") -def batch_tag( - req: AccountTag, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """批量设置虎牙账号标签。""" - _require_huya_tag_permission(current) - if not req.account_ids: - raise HTTPException(status_code=400, detail="请选择账号") - tag = (req.tag or "").strip() - count = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id.in_(req.account_ids)) - .update( - {HuyaAccount.tag: tag}, - synchronize_session=False, - ) - ) - db.commit() - return { - "message": f"已为 {count} 个虎牙账号设置标签", - "success": True, - "count": count, - } - - -@router.put("/accounts/batch-tag-selection") -def batch_tag_selection( - req: AccountBulkTag, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """按显式选择或当前筛选结果批量设置虎牙账号标签。""" - _require_huya_tag_permission(current) - ids = _selected_huya_account_ids(db, current, req) - if not ids: - raise HTTPException(status_code=400, detail="请选择虎牙账号") - tag = (req.tag_value or "").strip() - count = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id.in_(ids)) - .update( - {HuyaAccount.tag: tag}, - synchronize_session=False, - ) - ) - db.commit() - scope = "当前筛选下" if req.all_matching else "选中的" - return { - "message": f"已为{scope} {count} 个虎牙账号设置标签", - "success": True, - "count": count, - } - - -@router.get("/accounts/tags/list") -def list_tags( - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """获取虎牙账号标签列表。""" - tag_query = db.query(HuyaAccount.tag) - if not _can_view_huya_all(current): - if _can_view_huya_assigned(current): - tag_query = tag_query.filter(HuyaAccount.assigned_to == current.id) - else: - raise HTTPException(status_code=403, detail="无权查看虎牙账号") - tags = ( - tag_query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)) - .distinct() - .all() - ) - return [item[0] for item in tags if item[0]] - - -@router.get("/cookies") -def list_cookies( - search: str = Query(""), - page: int | None = Query(None, ge=1), - page_size: int = Query(20, ge=1, le=200), - include_cookie: bool = Query(True), - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """查看当前用户可见的虎牙 Cookie。""" - should_include_cookie = include_cookie and _can_view_huya_cookie(current) - query = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "") - search_text = (search or "").strip() - if search_text: - pattern = f"%{search_text}%" - query = query.outerjoin(User, HuyaAccount.assigned_to == User.id).filter( - or_( - HuyaAccount.uid.ilike(pattern), - HuyaAccount.yyuid.ilike(pattern), - HuyaAccount.username.ilike(pattern), - HuyaAccount.nickname.ilike(pattern), - User.username.ilike(pattern), - ) - ) - total = None - if page is not None: - total = query.order_by(None).count() - query = query.order_by(HuyaAccount.updated_at.desc()) - if page is not None: - query = query.offset((page - 1) * page_size).limit(page_size) - if not should_include_cookie: - query = query.options( - defer(HuyaAccount.cookie), defer(HuyaAccount.account_password) - ) - accounts = query.all() - result = ( - [_cookie_out(account, include_cookie=True) for account in accounts] - if should_include_cookie - else [_cookie_out_light(account) for account in accounts] - ) - if page is not None: - return { - "items": result, - "total": total or 0, - "page": page, - "page_size": page_size, - } - return result - - -@router.get("/cookies/summary") -def cookies_summary( - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """虎牙 Cookie 统计,避免前端为了卡片统计拉全量 Cookie。""" - query = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "") - total = query.count() - assigned_count = query.filter(HuyaAccount.assigned_to.isnot(None)).count() - return { - "total": total, - "assigned_count": assigned_count, - "unassigned_count": max(0, total - assigned_count), - } - - -@router.get("/cookies/export") -def export_cookies( - format: str = "csv", - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """导出虎牙 Cookie,支持 csv 和 custom 格式。""" - _require_huya_perm(current, "huya:cookie:export") - accounts = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.cookie != "") - .order_by(HuyaAccount.updated_at.desc()) - .all() - ) - if format == "custom": - lines = [] - for account in accounts: - username = account.username or account.nickname or account.uid or "" - lines.append(f"{username}----{normalize_huya_cookie(account.cookie or '')}") - content = "\r\n".join(lines) - filename = "huya_cookies_custom.txt" - media = "text/plain" - else: - output = io.StringIO() - writer = csv.writer(output) - writer.writerow(["账号", "UID", "YYUID", "Cookie", "时间"]) - for account in accounts: - username = account.nickname or account.username or account.uid or "" - writer.writerow( - [ - username, - account.uid or "", - account.yyuid or "", - normalize_huya_cookie(account.cookie or ""), - account.updated_at.isoformat() if account.updated_at else "", - ] - ) - content = output.getvalue() - filename = "huya_cookies.csv" - media = "text/csv" - return StreamingResponse( - iter([content]), - media_type=media, - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) - - -@router.get("/cookies/{account_id}") -def get_cookie( - account_id: int, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """获取单条虎牙 Cookie 详情,供复制操作按需读取完整敏感字段。""" - account = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id == account_id, HuyaAccount.cookie != "") - .first() - ) - if not account: - raise HTTPException(status_code=404, detail="记录不存在") - return _cookie_out(account, include_cookie=_can_view_huya_cookie(current)) - - -@router.delete("/cookies/batch") -def delete_cookies_batch( - account_ids: str = "", - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """批量清除虎牙 Cookie,保留账号记录。""" - _require_huya_perm(current, "huya:cookie:export") - ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] - if not ids: - raise HTTPException(status_code=400, detail="无效的账号ID") - accounts = ( - _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(ids)).all() - ) - for account in accounts: - account.cookie = "" - account.status = "invalid" - db.commit() - return { - "message": f"已清除 {len(accounts)} 条虎牙 Cookie", - "deleted": len(accounts), - "success": True, - } - - -@router.delete("/cookies/{account_id}") -def delete_cookie( - account_id: int, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """清除单个虎牙 Cookie,保留账号记录。""" - _require_huya_perm(current, "huya:cookie:export") - account = ( - _visible_huya_accounts_query(db, current) - .filter(HuyaAccount.id == account_id) - .first() - ) - if not account: - raise HTTPException(status_code=404, detail="记录不存在") - account.cookie = "" - account.status = "invalid" - db.commit() - return {"message": "已清除", "success": True} - - -@router.get("/config", response_model=HuyaConfigOut) -def get_config( - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:config")), -): - """获取虎牙配置。""" - config = ensure_huya_config(db) - return _config_out(config) - - -@router.put("/config", response_model=HuyaConfigOut) -def update_config( - req: HuyaConfigUpdate, - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:config")), -): - """更新虎牙配置。""" - config = ensure_huya_config(db) - changed_fields = [] - for field in HUYA_CONFIG_FIELDS: - value = getattr(req, field) - if value is not None: - setattr(config, field, value.strip()) - changed_fields.append(field) - apply_huya_config_defaults(config) - config.updated_at = datetime.now(UTC) - if changed_fields: - record_audit( - db, - current, - action="recharge:huya:config", - target="huya_config", - detail={"changed_fields": changed_fields}, - ) - db.commit() - db.refresh(config) - return _config_out(config) - - -@router.get("/workbench-accounts") -def list_huya_workbench_accounts( - handbook_scope: str = Query(..., pattern="^elite$|^browser_exchange$"), - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """读取当前用户指定工作台(精英宝典/浏览器兑换)的账号集合。""" - ids = _workbench_account_ids(db, current, handbook_scope) - configured = ( - db.query(HuyaWorkbench.id) - .filter( - HuyaWorkbench.user_id == current.id, - HuyaWorkbench.handbook_scope == handbook_scope, - ) - .first() - is not None - ) - return {"account_ids": ids, "configured": configured} - - -@router.put("/workbench-accounts") -def update_huya_workbench_accounts( - req: HuyaWorkbenchAccountsUpdate, - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """覆盖当前用户指定工作台(精英宝典/浏览器兑换)的账号集合,支持跨浏览器同步。""" - ids = sorted(set(req.account_ids)) - if any(account_id < 1 for account_id in ids): - raise HTTPException(status_code=400, detail="无效的账号 ID") - if ids: - _require_huya_task_account_access(db, current, ids) - workbench = ( - db.query(HuyaWorkbench) - .filter( - HuyaWorkbench.user_id == current.id, - HuyaWorkbench.handbook_scope == req.handbook_scope, - ) - .first() - ) - if workbench is None: - db.add(HuyaWorkbench(user_id=current.id, handbook_scope=req.handbook_scope)) - else: - workbench.updated_at = datetime.now(UTC) - db.query(HuyaWorkbenchAccount).filter( - HuyaWorkbenchAccount.user_id == current.id, - HuyaWorkbenchAccount.handbook_scope == req.handbook_scope, - ).delete(synchronize_session=False) - db.add_all( - [ - HuyaWorkbenchAccount( - user_id=current.id, - handbook_scope=req.handbook_scope, - account_id=account_id, - ) - for account_id in ids - ] - ) - db.commit() - return {"account_ids": ids, "success": True} - - -@router.get("/goods", response_model=list[HuyaGoodsOut]) -def list_goods( - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """查看已缓存的虎牙商品快照。""" - rows = db.query(HuyaGoodsSnapshot).order_by(HuyaGoodsSnapshot.id.desc()).all() - latest = {} - for row in rows: - latest.setdefault((row.sid, row.product_id), row) - return list(latest.values()) - - -@router.get("/recharge-goods", response_model=list[HuyaRechargeGoodsOut]) -def list_recharge_goods( - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """查看已缓存的虎牙充值商品快照。""" - rows = ( - db.query(HuyaRechargeGoodsSnapshot) - .order_by(HuyaRechargeGoodsSnapshot.id.desc()) - .all() - ) - latest = {} - for row in rows: - latest.setdefault((row.sid, row.spu_id), row) - return list(latest.values()) - - -@router.post("/tasks/batch") -async def create_task_batch( - req: HuyaTaskBatchRequest, - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """创建虎牙任务记录并启动后台执行器。""" - if not req.account_ids: - raise HTTPException(status_code=400, detail="请选择虎牙账号") - _require_huya_task_account_access(db, current, req.account_ids) - - # 先清掉没有执行器的历史 running/pending,避免前端被假活跃批次锁死。 - # 不清理 planned:避免与刚创建的新任务产生竞态。 - cleanup_orphan_huya_tasks( - db, - active_batch_ids=huya_batch_registry.active_ids(), - statuses=("pending", "running"), - message="任务已中断(无执行器接管)", - ) - - try: - batch_id, count = create_planned_tasks( - db, - req.account_ids, - req.task_type, - current.id, - req.payload, - req.handbook_scope, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - if count == 0: - raise HTTPException(status_code=400, detail="没有有效的虎牙账号") - - if req.task_type == "create_recharge_order": - record_audit( - db, - current, - action="recharge:huya:create", - target=f"huya_batch:{batch_id}", - detail={ - "batch_id": batch_id, - "task_type": req.task_type, - "count": count, - "account_count": len(req.account_ids), - }, - ) - db.commit() - - log_queue = asyncio.Queue() - loop = asyncio.get_running_loop() - thread_db = SessionLocal() - runner = HuyaBatchRunner( - db=thread_db, - batch_id=batch_id, - task_type=req.task_type, - payload=req.payload, - log_queue=log_queue, - loop=loop, - concurrency=req.concurrency, - ) - huya_batch_registry.register(batch_id, log_queue, loop, runner) - - thread = threading.Thread(target=runner.run, daemon=True) - thread.start() - - return {"batch_id": batch_id, "count": count, "success": True} - - -@router.get("/tasks", response_model=list[HuyaTaskOut]) -def list_tasks( - batch_id: str | None = None, - handbook_scope: str | None = Query(None, pattern="^(legacy|elite)$"), - include_images: bool = Query( - False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)" - ), - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """查看虎牙任务记录。""" - query = _visible_huya_tasks_query(db, current) - if batch_id: - query = query.filter(HuyaTask.batch_id == batch_id) - if handbook_scope: - query = query.filter(HuyaTask.handbook_scope == handbook_scope) - tasks = query.order_by(HuyaTask.id.desc()).limit(300).all() - return [_task_out(task, include_images=include_images) for task in tasks] - - -@router.post("/tasks/cleanup-orphans") -def cleanup_orphan_tasks( - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """手动清理没有内存执行器接管的虎牙任务。""" - cleaned = cleanup_orphan_huya_tasks( - db, - active_batch_ids=huya_batch_registry.active_ids(), - statuses=("pending", "running"), - message="任务已中断(无执行器接管)", - ) - return { - "message": f"已清理 {cleaned} 个残留任务", - "cleaned": cleaned, - "success": True, - } - - -@router.get("/tasks/summary") -def tasks_summary( - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """虎牙任务统计。""" - return _huya_task_summary(_visible_huya_tasks_query(db, current)) - - -@router.get("/tasks/{task_id}", response_model=HuyaTaskOut) -def get_task( - task_id: int, - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """获取单条虎牙任务详情(含二维码图片)。""" - task = _visible_huya_tasks_query(db, current).filter(HuyaTask.id == task_id).first() - if not task: - raise HTTPException(status_code=404, detail="任务不存在") - return _task_out(task, include_images=True) - - -@router.post("/stop/{batch_id}") -def stop_batch( - batch_id: str, - db: Session = Depends(get_db), - current: User = Depends(require_permission("huya:task")), -): - """停止正在运行的虎牙批次。""" - _require_huya_batch_owner(db, current, batch_id) - is_recharge_batch = ( - db.query(HuyaTask.id) - .filter( - HuyaTask.batch_id == batch_id, HuyaTask.task_type == "create_recharge_order" - ) - .first() - is not None - ) - batch = huya_batch_registry.get(batch_id) - if batch: - if batch.get("finished"): - huya_batch_registry.pop(batch_id) - cleaned = cleanup_orphan_huya_tasks( - db, - batch_id=batch_id, - message="批次已结束", - ) - if cleaned: - return { - "message": f"批次已结束,已清理 {cleaned} 个残留任务", - "success": True, - } - raise HTTPException(status_code=404, detail="批次已结束") - batch["runner"].stop() - if is_recharge_batch: - record_audit( - db, - current, - action="recharge:huya:stop", - target=f"huya_batch:{batch_id}", - detail={"batch_id": batch_id, "mode": "running"}, - ) - db.commit() - return {"message": "已发送停止信号", "success": True} - - cleaned = cleanup_orphan_huya_tasks( - db, - batch_id=batch_id, - message="任务已停止(批次不存在)", - ) - if cleaned: - if is_recharge_batch: - record_audit( - db, - current, - action="recharge:huya:stop", - target=f"huya_batch:{batch_id}", - detail={ - "batch_id": batch_id, - "mode": "orphan_cleanup", - "cleaned": cleaned, - }, - ) - db.commit() - return {"message": f"已清理 {cleaned} 个残留任务", "success": True} - raise HTTPException(status_code=404, detail="批次不存在或已结束") - - -@router.websocket("/ws/{batch_id}") -async def ws_huya_logs(websocket: WebSocket, batch_id: str): - """虎牙实时日志推送通道。""" - user = authenticate_websocket(websocket) - if not user: - await websocket.close(code=1008, reason="未授权") - return - if not _has_huya_perm(user, "huya:task"): - await websocket.close(code=1008, reason="无权限") - return - db = SessionLocal() - try: - _require_huya_batch_owner(db, user, batch_id) - except HTTPException: - await websocket.close(code=1008, reason="无权访问该任务批次") - return - finally: - db.close() - await websocket.accept() - - batch = huya_batch_registry.get(batch_id) - if not batch: - await websocket.send_json({"level": "error", "message": "批次不存在或已结束"}) - await websocket.close() - return - - log_queue: asyncio.Queue = batch["log_queue"] - - try: - while True: - try: - msg = await asyncio.wait_for(log_queue.get(), timeout=30) - await websocket.send_json(msg) - if msg.get("level") == "result": - await asyncio.sleep(0.1) - break - except TimeoutError: - await websocket.send_json({"level": "heartbeat", "message": ""}) - except WebSocketDisconnect: - pass - finally: - latest = huya_batch_registry.get(batch_id) - if latest and latest.get("finished"): - huya_batch_registry.pop(batch_id) - - -# ==================== 设备绑定管理 (一号一环境, R40) ==================== -# 环境数据源: -# 1) data/huya_device_profiles.json — 账号 ↔ 设备画像 (core.huya.device_profile) -# 2) data/huya_fp_states/<账号>/ — hydevice 高信任指纹 localStorage (account_state_dir) -# 3) huya_accounts 表 — 登录渠道/账号状态 (登录成功后回填) -# 解绑 = 删除 1+2 → 下次 App 登录自动生成全新环境并重新注册签发 t2/t5。 - -_DEVICE_BINDINGS_PERMS_VIEW = ("huya:account", "huya:view_all", "huya:view_assigned") - - -def _mask_hex(value: str | None, keep: int = 12) -> str: - """设备标识脱敏展示: 只保留前 keep 位。""" - v = value or "" - return f"{v[:keep]}…({len(v)})" if len(v) > keep else v - - -@router.get("/device-bindings") -def list_device_bindings( - db: Session = Depends(get_db), current: User = Depends(get_current_user) -): - """列出所有账号 ↔ 设备环境绑定 (画像 + 指纹状态 + 账号表三方合并)。""" - if not any(user_has_permission(current, p) for p in _DEVICE_BINDINGS_PERMS_VIEW): - raise HTTPException(status_code=403, detail="无虎牙账号查看权限") - profiles = _load_profile_db() - accounts = {a.username: a for a in db.query(HuyaAccount).all() if a.username} - rows = [] - for account, prof in sorted(profiles.items()): - acct = accounts.get(account) - state_dir = _fp_state_dir(account) - last_login = prof.get("last_login") or {} - rows.append( - { - "account": account, - "vendor": prof.get("vendor", ""), - "model": prof.get("model", ""), - "screen": prof.get("screen", ""), - "fingerprint_masked": _mask_hex(prof.get("fingerprint")), - "guid32_masked": _mask_hex(prof.get("guid32")), - "hebe_count": len(prof.get("hebe") or {}), - "hdid32": prof.get("hdid", ""), # t1.t0 app 级常量, 非敏感 - "has_hydevice_state": state_dir.exists(), - "login_channel": (acct.login_channel if acct else "") or "", - "account_status": (acct.status if acct else "") or "未入库", - "bound_at": prof.get("bound_at"), - "last_login_at": last_login.get("at"), - "last_login_ok": last_login.get("ok"), - } - ) - return {"items": rows, "total": len(rows)} - - -@router.delete("/device-bindings/{account}") -def delete_device_binding( - account: str, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """解绑: 删除该账号的设备画像与 hydevice 指纹状态, 下次登录自动生成全新环境。""" - _require_huya_perm(current, "huya:import") - profiles = _load_profile_db() - if account not in profiles: - raise HTTPException(status_code=404, detail="该账号没有设备绑定记录") - profiles.pop(account) - _save_profile_db(profiles) - state_dir = _fp_state_dir(account) - removed_state = False - if state_dir.exists(): - shutil.rmtree(state_dir, ignore_errors=True) - removed_state = True - return { - "ok": True, - "account": account, - "profile_removed": True, - "fp_state_removed": removed_state, - "message": "已解绑, 下次 App 协议登录将自动生成全新设备环境并重新注册", - } - - -def _unbind_device_accounts(accounts: list[str]) -> tuple[list[str], list[str]]: - """批量解绑实现: 返回 (成功解绑账号, 无绑定记录的账号)。""" - profiles = _load_profile_db() - removed: list[str] = [] - missing: list[str] = [] - seen = set() - for account in accounts: - account = (account or "").strip() - if not account or account in seen: - continue - seen.add(account) - if account not in profiles: - missing.append(account) - continue - profiles.pop(account) - state_dir = _fp_state_dir(account) - if state_dir.exists(): - shutil.rmtree(state_dir, ignore_errors=True) - removed.append(account) - if removed: - _save_profile_db(profiles) - return removed, missing - - -@router.post("/device-bindings/batch-delete") -def delete_device_bindings( - req: HuyaDeviceBindingsBatchDeleteRequest, - db: Session = Depends(get_db), - current: User = Depends(get_current_user), -): - """批量解绑: 一次删除多个账号的设备画像与指纹状态。""" - _require_huya_perm(current, "huya:import") - removed, missing = _unbind_device_accounts(req.accounts) - if not removed: - raise HTTPException(status_code=404, detail="所选账号均没有设备绑定记录") - message = f"已批量解绑 {len(removed)} 个账号" - if missing: - message += f",{len(missing)} 个账号无绑定记录已跳过" - message += ",下次 App 协议登录将自动生成全新设备环境并重新注册" - return {"ok": True, "removed": removed, "missing": missing, "message": message} diff --git a/web/backend/routers/huya/__init__.py b/web/backend/routers/huya/__init__.py new file mode 100644 index 0000000..f58fb56 --- /dev/null +++ b/web/backend/routers/huya/__init__.py @@ -0,0 +1,102 @@ +"""虎牙 API:按账号 / 登录 / 注册 / Cookie / 工作台 / 设备拆分。""" + +from fastapi import APIRouter + +from .accounts import ( + assign_account, + batch_assign_accounts, + batch_tag, + batch_tag_selection, + create_browser_exchange_kit, + delete_account, + delete_accounts_batch, + delete_accounts_batch_selection, + import_cookies, + import_password_accounts, + list_accounts, + list_huya_account_ids, + list_tags, + set_account_tag, +) +from .accounts import router as accounts_router +from .cookies import router as cookies_router +from .devices import router as devices_router +from .login import ( + app_password_login_account, + app_password_login_selected_accounts, + auto_sms_login_account, + fetch_account_sms_code, + password_login_account, + password_login_selected_accounts, + send_sms_code, + sms_login_account, + test_sms_url_endpoint, +) +from .login import router as login_router +from .register import router as register_router +from .workbench import ( + cleanup_orphan_tasks, + create_task_batch, + get_config, + get_task, + list_goods, + list_huya_workbench_accounts, + list_recharge_goods, + list_tasks, + stop_batch, + task_types, + tasks_summary, + update_config, + update_huya_workbench_accounts, + ws_huya_logs, +) +from .workbench import router as workbench_router + +router = APIRouter(prefix="/api/huya", tags=["虎牙"]) +router.include_router(accounts_router) +router.include_router(login_router) +router.include_router(register_router) +router.include_router(cookies_router) +router.include_router(workbench_router) +router.include_router(devices_router) + +__all__ = [ + "app_password_login_account", + "app_password_login_selected_accounts", + "assign_account", + "auto_sms_login_account", + "batch_assign_accounts", + "batch_tag", + "batch_tag_selection", + "cleanup_orphan_tasks", + "create_browser_exchange_kit", + "create_task_batch", + "delete_account", + "delete_accounts_batch", + "delete_accounts_batch_selection", + "fetch_account_sms_code", + "get_config", + "get_task", + "import_cookies", + "import_password_accounts", + "list_accounts", + "list_goods", + "list_huya_account_ids", + "list_huya_workbench_accounts", + "list_recharge_goods", + "list_tags", + "list_tasks", + "password_login_account", + "password_login_selected_accounts", + "router", + "send_sms_code", + "set_account_tag", + "sms_login_account", + "stop_batch", + "task_types", + "tasks_summary", + "test_sms_url_endpoint", + "update_config", + "update_huya_workbench_accounts", + "ws_huya_logs", +] diff --git a/web/backend/routers/huya/_common.py b/web/backend/routers/huya/_common.py new file mode 100644 index 0000000..5d6bde2 --- /dev/null +++ b/web/backend/routers/huya/_common.py @@ -0,0 +1,542 @@ +"""虎牙路由共享:权限、查询、序列化。""" + +import asyncio +import csv +import io +import shutil +import threading +from datetime import UTC, datetime +from typing import cast + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + WebSocket, + WebSocketDisconnect, +) +from fastapi.responses import StreamingResponse +from loguru import logger +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session, defer, joinedload + +from core.huya import ( + HuyaAppLoginError, + HuyaAppQrAuthRequiredError, + HuyaCredentialError, + HuyaLoginError, + login_huya_app_password, + login_huya_password, + login_huya_sms, + send_huya_sms_code, +) +from core.huya.cookie_utils import ( + browser_login_script, + cookie_pairs, + normalize_huya_cookie, +) +from core.huya.device_fingerprint import account_state_dir as _fp_state_dir +from core.huya.device_profile import ( + _load_db as _load_profile_db, +) +from core.huya.device_profile import ( + _save_db as _save_profile_db, +) +from core.sms_provider import parse_sms_lines + +from ...database import SessionLocal, get_db +from ...deps import authenticate_websocket, get_current_user, require_permission +from ...models import ( + HuyaAccount, + HuyaConfig, + HuyaGoodsSnapshot, + HuyaRechargeGoodsSnapshot, + HuyaRegisterItem, + HuyaRegisterSuccessLog, + HuyaTask, + HuyaWorkbench, + HuyaWorkbenchAccount, + ProxyConfig, + User, +) +from ...permissions import user_has_permission +from ...schemas import ( + AccountAssign, + AccountBulkSelection, + AccountBulkTag, + AccountTag, + BatchAssign, + HuyaAccountOut, + HuyaAppPasswordLoginRequest, + HuyaAutoRegisterBatchOut, + HuyaAutoRegisterRequest, + HuyaAutoRegisterRetryRequest, + HuyaConfigOut, + HuyaConfigUpdate, + HuyaCookieImport, + HuyaDeviceBindingsBatchDeleteRequest, + HuyaGoodsOut, + HuyaPasswordAccountImport, + HuyaPasswordLoginRequest, + HuyaPasswordLoginSelectedRequest, + HuyaRechargeGoodsOut, + HuyaRegisterSuccessLogOut, + HuyaSmsCodeRequest, + HuyaSmsLoginRequest, + HuyaTaskBatchRequest, + HuyaTaskOut, + HuyaTestSmsUrlRequest, + HuyaWorkbenchAccountsUpdate, +) +from ...services.audit_service import record_audit +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, + apply_huya_config_defaults, + cleanup_orphan_huya_tasks, + create_planned_tasks, + ensure_huya_config, + huya_config_value, + import_huya_cookies, + import_huya_password_accounts, + rebuild_web_cookie_from_raw, + save_huya_login_cookie_to_account, + upsert_huya_cookie, +) + + +def _has_huya_perm(user: User, permission: str) -> bool: + """虎牙新权限兼容旧的 huya:account 大权限。""" + return user_has_permission(user, permission) or user_has_permission( + user, "huya:account" + ) + + +def _require_huya_perm(user: User, permission: str) -> None: + if not _has_huya_perm(user, permission): + raise HTTPException(status_code=403, detail="权限不足") + + +def _require_huya_tag_permission(user: User) -> None: + """标签权限兼容历史上使用导入权限的运营人员。""" + if not (_has_huya_perm(user, "huya:tag") or _has_huya_perm(user, "huya:import")): + raise HTTPException(status_code=403, detail="无权修改虎牙账号标签") + + +def _can_view_huya_all(user: User) -> bool: + return _has_huya_perm(user, "huya:view_all") + + +def _can_view_huya_assigned(user: User) -> bool: + return _has_huya_perm(user, "huya:view_assigned") + + +def _can_view_huya_cookie(user: User) -> bool: + return _has_huya_perm(user, "huya:cookie:view") or _has_huya_perm( + user, "huya:cookie:export" + ) + + +def _visible_huya_accounts_query(db: Session, current: User): + """返回当前用户可见的虎牙账号查询。""" + query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user)) + if _can_view_huya_all(current): + return query + if _can_view_huya_assigned(current): + return query.filter(HuyaAccount.assigned_to == current.id) + raise HTTPException(status_code=403, detail="无权查看虎牙账号") + + +def _filter_huya_accounts_query( + query, + current: User, + *, + assigned_only: bool = False, + tag: str | None = None, + has_cookie: bool = False, + search: str = "", +): + """复用虎牙账号列表筛选条件,供分页和批量操作保持一致。""" + if assigned_only and _can_view_huya_all(current): + query = query.filter(HuyaAccount.assigned_to.isnot(None)) + if tag: + query = query.filter(HuyaAccount.tag == tag) + if has_cookie: + query = query.filter(HuyaAccount.cookie != "") + search_text = (search or "").strip() + if search_text: + pattern = f"%{search_text}%" + query = query.filter( + or_( + HuyaAccount.uid.ilike(pattern), + HuyaAccount.yyuid.ilike(pattern), + HuyaAccount.username.ilike(pattern), + HuyaAccount.nickname.ilike(pattern), + HuyaAccount.tag.ilike(pattern), + HuyaAccount.game_name.ilike(pattern), + HuyaAccount.game_phone.ilike(pattern), + ) + ) + return query + + +def _selected_huya_account_ids( + db: Session, current: User, req: AccountBulkSelection +) -> list[int]: + """解析虎牙批量操作目标:当前筛选全部或显式选择的 ID。""" + base_query = _visible_huya_accounts_query(db, current) + if req.all_matching: + rows = ( + _filter_huya_accounts_query( + base_query, + current, + assigned_only=req.assigned_only, + tag=req.tag, + has_cookie=req.has_cookie, + search=req.search, + ) + .with_entities(HuyaAccount.id) + .order_by(None) + .all() + ) + return [account_id for (account_id,) in rows] + + seen = set() + requested_ids = [] + for account_id in req.account_ids: + if account_id not in seen: + seen.add(account_id) + requested_ids.append(account_id) + if not requested_ids: + return [] + rows = ( + base_query.filter(HuyaAccount.id.in_(requested_ids)) + .with_entities(HuyaAccount.id) + .order_by(None) + .all() + ) + allowed = {account_id for (account_id,) in rows} + return [account_id for account_id in requested_ids if account_id in allowed] + + +def _clear_huya_account_references(db: Session, account_ids: list[int]) -> None: + """删除账号前保留注册历史,把历史流水中的账号引用置空。""" + if not account_ids: + return + db.query(HuyaRegisterItem).filter( + HuyaRegisterItem.account_id.in_(account_ids) + ).update( + {HuyaRegisterItem.account_id: None}, + synchronize_session=False, + ) + db.query(HuyaRegisterSuccessLog).filter( + HuyaRegisterSuccessLog.account_id.in_(account_ids) + ).update( + {HuyaRegisterSuccessLog.account_id: None}, + synchronize_session=False, + ) + + +def _visible_huya_tasks_query(db: Session, current: User): + """返回当前用户可查看的虎牙任务查询。""" + query = db.query(HuyaTask).options(joinedload(HuyaTask.account)) + if _can_view_huya_all(current): + return query + if _can_view_huya_assigned(current): + return query.join(HuyaTask.account).filter( + HuyaAccount.assigned_to == current.id + ) + raise HTTPException(status_code=403, detail="无权查看虎牙任务") + + +def _huya_task_summary(query): + """按状态汇总虎牙任务,避免概览页拉完整任务列表。""" + rows = ( + query.enable_eagerloads(False) + .order_by(None) + .with_entities(HuyaTask.status, func.count(HuyaTask.id)) + .group_by(HuyaTask.status) + .all() + ) + status_counts = {status or "": count for status, count in rows} + return { + "total": sum(status_counts.values()), + "success": status_counts.get("success", 0), + "failed": sum(status_counts.get(status, 0) for status in ("failed", "error")), + "status_counts": status_counts, + } + + +def _require_huya_task_account_access( + db: Session, current: User, account_ids: list[int] +) -> None: + """确保任务只会提交到当前用户可操作的虎牙账号。""" + requested_ids = set(account_ids) + query = db.query(HuyaAccount.id).filter(HuyaAccount.id.in_(requested_ids)) + if not _can_view_huya_all(current): + if not _can_view_huya_assigned(current): + raise HTTPException(status_code=403, detail="无权操作虎牙账号") + query = query.filter(HuyaAccount.assigned_to == current.id) + allowed_ids = {account_id for (account_id,) in query.all()} + if allowed_ids != requested_ids: + raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号") + + +def _workbench_account_ids(db: Session, current: User, scope: str) -> list[int]: + query = db.query(HuyaWorkbenchAccount.account_id).filter( + HuyaWorkbenchAccount.user_id == current.id, + HuyaWorkbenchAccount.handbook_scope == scope, + ) + if not _can_view_huya_all(current): + query = query.join( + HuyaAccount, HuyaAccount.id == HuyaWorkbenchAccount.account_id + ).filter(HuyaAccount.assigned_to == current.id) + return [ + account_id + for (account_id,) in query.order_by(HuyaWorkbenchAccount.id.asc()).all() + ] + + +def _require_huya_batch_owner(db: Session, current: User, batch_id: str) -> None: + """客服只能停止或订阅自己创建的任务批次。""" + if _can_view_huya_all(current): + return + exists = ( + db.query(HuyaTask.id) + .filter(HuyaTask.batch_id == batch_id, HuyaTask.created_by == current.id) + .first() + ) + if not exists: + raise HTTPException(status_code=403, detail="无权操作该虎牙任务批次") + + +def _fmt_cookie_preview(cookie: str) -> str: + if not cookie: + return "" + return cookie[:50] + "..." if len(cookie) > 50 else cookie + + +def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccountOut: + cookie = normalize_huya_cookie(account.cookie or "") + return HuyaAccountOut( + id=account.id, + uid=account.uid or "", + yyuid=account.yyuid or "", + username=account.username or "", + has_password=bool(account.account_password), + has_cookie=bool(account.cookie), + nickname=account.nickname or "", + cookie=cookie if include_cookie else "", + cookie_preview=_fmt_cookie_preview(cookie) if include_cookie else "***", + tag=account.tag or "", + remark=account.remark or "", + status=account.status or "", + login_channel=getattr(account, "login_channel", "") or "", + points=account.points, + game_name=account.game_name or "", + game_channel=account.game_channel or "", + game_phone=account.game_phone or "", + sms_url=account.sms_url or "", + assigned_to=account.assigned_to, + assigned_username=account.assigned_user.username + if account.assigned_user + else None, + created_at=account.created_at, + updated_at=account.updated_at, + ) + + +def _account_out_light( + account: HuyaAccount, *, has_password: bool = False, has_cookie: bool = False +) -> HuyaAccountOut: + """虎牙账号列表轻量输出,不读取加密字段。""" + return HuyaAccountOut( + id=account.id, + uid=account.uid or "", + yyuid=account.yyuid or "", + username=account.username or "", + has_password=has_password, + has_cookie=has_cookie, + nickname=account.nickname or "", + cookie="", + cookie_preview="***", + tag=account.tag or "", + remark=account.remark or "", + status=account.status or "", + login_channel=getattr(account, "login_channel", "") or "", + points=account.points, + game_name=account.game_name or "", + game_channel=account.game_channel or "", + game_phone=account.game_phone or "", + sms_url=account.sms_url or "", + assigned_to=account.assigned_to, + assigned_username=account.assigned_user.username + if account.assigned_user + else None, + created_at=account.created_at, + updated_at=account.updated_at, + ) + + +def _sanitize_task_result( + result: dict | None, *, include_images: bool = False +) -> dict | None: + """列表接口默认剥离 base64 图片,避免轮询每次传 1MB+ 数据。""" + if not isinstance(result, dict): + return result + + data = dict(result) + image = data.get("mini_qrcode_image") + if isinstance(image, str) and image: + data["has_mini_qrcode"] = True + if not include_images: + data.pop("mini_qrcode_image", None) + elif data.get("has_mini_qrcode"): + data["has_mini_qrcode"] = True + return data + + +def _task_out(task: HuyaTask, *, include_images: bool = False) -> HuyaTaskOut: + account = task.account + return HuyaTaskOut( + id=task.id, + batch_id=task.batch_id, + account_id=task.account_id, + account_uid=account.uid if account else "", + account_nickname=account.nickname if account else "", + task_type=task.task_type, + handbook_scope=getattr(task, "handbook_scope", "legacy") or "legacy", + status=task.status or "", + message=task.message or "", + result=_sanitize_task_result( + task.result if isinstance(task.result, dict) else None, + include_images=include_images, + ), + created_by=task.created_by, + created_at=task.created_at, + finished_at=task.finished_at, + ) + + +def _config_out(config: HuyaConfig) -> HuyaConfigOut: + return HuyaConfigOut( + room_pid=huya_config_value("room_pid", config.room_pid), + sid=huya_config_value("sid", config.sid), + outer_act_id=huya_config_value("outer_act_id", config.outer_act_id), + bind_act_id=huya_config_value("bind_act_id", config.bind_act_id), + pay_channel=huya_config_value("pay_channel", config.pay_channel), + updated_at=config.updated_at, + ) + + +def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict: + cookie = normalize_huya_cookie(account.cookie or "") + account_name = ( + account.nickname or account.username or account.uid or str(account.id) + ) + return { + "id": account.id, + "account_id": account.id, + "account_username": account_name, + "uid": account.uid or "", + "yyuid": account.yyuid or "", + "assigned_to": account.assigned_to, + "assigned_username": account.assigned_user.username + if account.assigned_user + else None, + "created_at": account.updated_at.isoformat() if account.updated_at else None, + "cookie": cookie if include_cookie else "", + "cookie_preview": _fmt_cookie_preview(cookie) if include_cookie else "***", + "has_raw_cookie": bool(account.raw_cookie), + } + + +def _cookie_out_light(account: HuyaAccount) -> dict: + """虎牙 Cookie 列表轻量输出,不读取完整加密 Cookie。""" + account_name = ( + account.nickname or account.username or account.uid or str(account.id) + ) + return { + "id": account.id, + "account_id": account.id, + "account_username": account_name, + "uid": account.uid or "", + "yyuid": account.yyuid or "", + "assigned_to": account.assigned_to, + "assigned_username": account.assigned_user.username + if account.assigned_user + else None, + "created_at": account.updated_at.isoformat() if account.updated_at else None, + "cookie": "", + "cookie_preview": "***", + "has_raw_cookie": bool(account.raw_cookie), + } + + +def _huya_password_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]: + """不解密密码字段,仅判断密文字段是否非空。""" + if not account_ids: + return {} + rows = ( + db.query( + HuyaAccount.id, (HuyaAccount.account_password != "").label("has_password") + ) + .filter(HuyaAccount.id.in_(account_ids)) + .all() + ) + return {account_id: bool(has_password) for account_id, has_password in rows} + + +def _huya_cookie_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]: + if not account_ids: + return {} + rows = ( + db.query(HuyaAccount.id, (HuyaAccount.cookie != "").label("has_cookie")) + .filter(HuyaAccount.id.in_(account_ids)) + .all() + ) + return {account_id: bool(has_cookie) for account_id, has_cookie in rows} + + +_DEVICE_BINDINGS_PERMS_VIEW = ("huya:account", "huya:view_all", "huya:view_assigned") + + +def _mask_hex(value: str | None, keep: int = 12) -> str: + """设备标识脱敏展示: 只保留前 keep 位。""" + v = value or "" + return f"{v[:keep]}…({len(v)})" if len(v) > keep else v + + +def _unbind_device_accounts(accounts: list[str]) -> tuple[list[str], list[str]]: + """批量解绑实现: 返回 (成功解绑账号, 无绑定记录的账号)。""" + profiles = _load_profile_db() + removed: list[str] = [] + missing: list[str] = [] + seen = set() + for account in accounts: + account = (account or "").strip() + if not account or account in seen: + continue + seen.add(account) + if account not in profiles: + missing.append(account) + continue + profiles.pop(account) + state_dir = _fp_state_dir(account) + if state_dir.exists(): + shutil.rmtree(state_dir, ignore_errors=True) + removed.append(account) + if removed: + _save_profile_db(profiles) + return removed, missing + + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/web/backend/routers/huya/accounts.py b/web/backend/routers/huya/accounts.py new file mode 100644 index 0000000..f448384 --- /dev/null +++ b/web/backend/routers/huya/accounts.py @@ -0,0 +1,478 @@ +"""虎牙账号:列表、导入、分配、标签、浏览器兑换材料。""" + +from fastapi import APIRouter + +from ._common import * + +router = APIRouter() + + +@router.post("/accounts/{account_id}/browser-exchange") +def create_browser_exchange_kit( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """生成指定账号的本机浏览器人工兑换材料。 + + 登录态由前端引导用户在自己的 Chrome/Edge 中写入,服务端不托管浏览器会话。 + """ + _require_huya_perm(current, "huya:task") + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id) + .first() + ) + if account is None: + raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作") + 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" + ) + pairs = dict(cookie_pairs(normalize_huya_cookie(account.cookie))) + return { + "account_id": account.id, + "username": account.username or account.yyuid or "", + "target_url": target_url, + "cookie_string": "; ".join(f"{key}={value}" for key, value in pairs.items()), + "cookie_script": browser_login_script(pairs, target_url), + } + + +@router.get("/accounts") +def list_accounts( + assigned_only: bool = Query(False), + tag: str | None = Query(None), + has_cookie: bool = Query(False), + search: str = Query(""), + page: int | None = Query(None, ge=1), + page_size: int = Query(20, ge=1, le=200), + include_cookie: bool | None = Query(None), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """查看虎牙 CK 账号。""" + query = _filter_huya_accounts_query( + _visible_huya_accounts_query(db, current), + current, + assigned_only=assigned_only, + tag=tag, + has_cookie=has_cookie, + search=search, + ) + + total = None + if page is not None: + total = query.order_by(None).count() + should_include_cookie = ( + _can_view_huya_cookie(current) + if include_cookie is None + else (include_cookie and _can_view_huya_cookie(current)) + ) + query = query.order_by(HuyaAccount.id.desc()) + if page is not None: + query = query.offset((page - 1) * page_size).limit(page_size) + + if not should_include_cookie: + query = query.options( + defer(HuyaAccount.cookie), defer(HuyaAccount.account_password) + ) + accounts = query.all() + if should_include_cookie: + result = [_account_out(account, include_cookie=True) for account in accounts] + else: + password_map = _huya_password_state_map( + db, [account.id for account in accounts] + ) + cookie_map = _huya_cookie_state_map(db, [account.id for account in accounts]) + result = [ + _account_out_light( + account, + has_password=password_map.get(account.id, False), + has_cookie=cookie_map.get(account.id, False), + ) + for account in accounts + ] + if page is not None: + return { + "items": result, + "total": total or 0, + "page": page, + "page_size": page_size, + } + return result + + +@router.get("/accounts/summary") +def accounts_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """虎牙账号统计,避免前端为了卡片统计拉全量账号。""" + query = _visible_huya_accounts_query(db, current) + total = query.count() + assigned_count = query.filter(HuyaAccount.assigned_to.isnot(None)).count() + tag_count = ( + query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)) + .with_entities(HuyaAccount.tag) + .distinct() + .count() + ) + password_ready_count = query.filter(HuyaAccount.account_password != "").count() + point_count = query.filter(HuyaAccount.points.isnot(None)).count() + bound_count = query.filter( + or_( + HuyaAccount.game_name != "", + HuyaAccount.game_channel != "", + HuyaAccount.game_phone != "", + ) + ).count() + return { + "total": total, + "assigned_count": assigned_count, + "unassigned_count": max(0, total - assigned_count), + "tag_count": tag_count, + "password_ready_count": password_ready_count, + "point_count": point_count, + "bound_count": bound_count, + } + + +@router.get("/accounts/ids") +def list_huya_account_ids( + search: str = "", + tag: str = "", + has_cookie: bool = False, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """返回当前用户可见的全部账号 ID(不受分页限制),供工作台导入与批量登录排队。""" + query = _filter_huya_accounts_query( + _visible_huya_accounts_query(db, current), + current, + tag=tag or None, + has_cookie=has_cookie, + search=search, + ) + rows = query.with_entities(HuyaAccount.id).all() + return {"account_ids": [row[0] for row in rows], "total": len(rows)} + + +@router.post("/accounts/import-cookies") +def import_cookies( + req: HuyaCookieImport, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """粘贴并导入虎牙 Cookie。""" + _require_huya_perm(current, "huya:import") + count, skipped = import_huya_cookies(db, req.text, req.tag) + return { + "message": f"导入/更新 {count} 条,跳过 {skipped} 条", + "success": True, + "count": count, + "skipped": skipped, + } + + +@router.post("/accounts/import-passwords") +def import_password_accounts( + req: HuyaPasswordAccountImport, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """导入虎牙账号密码,只入库不立即登录(仅允许四段格式)。""" + _require_huya_perm(current, "huya:import") + count, skipped = import_huya_password_accounts( + db, req.text, req.tag, require_four_segments=True + ) + if count == 0 and skipped > 0: + raise HTTPException( + status_code=400, + detail="导入失败:当前仅允许四段格式(账号----密码----手机号----接码链接)", + ) + return { + "message": f"成功导入/更新 {count} 个虎牙账号,跳过 {skipped} 条", + "success": True, + "count": count, + "skipped": skipped, + } + + +@router.delete("/accounts/batch") +def delete_accounts_batch( + account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """批量删除虎牙 CK 账号及任务记录。""" + _require_huya_perm(current, "huya:delete") + ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] + if not ids: + raise HTTPException(status_code=400, detail="无效的账号ID") + ids = _selected_huya_account_ids(db, current, AccountBulkSelection(account_ids=ids)) + if not ids: + raise HTTPException(status_code=400, detail="请选择虎牙账号") + db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( + synchronize_session=False + ) + db.query(HuyaWorkbenchAccount).filter( + HuyaWorkbenchAccount.account_id.in_(ids) + ).delete(synchronize_session=False) + _clear_huya_account_references(db, ids) + deleted = ( + db.query(HuyaAccount) + .filter(HuyaAccount.id.in_(ids)) + .delete(synchronize_session=False) + ) + db.commit() + return { + "message": f"已删除 {deleted} 个虎牙账号", + "deleted": deleted, + "success": True, + } + + +@router.post("/accounts/batch-delete") +def delete_accounts_batch_selection( + req: AccountBulkSelection, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """按显式选择或当前筛选结果批量删除虎牙账号及任务记录。""" + _require_huya_perm(current, "huya:delete") + ids = _selected_huya_account_ids(db, current, req) + if not ids: + raise HTTPException(status_code=400, detail="请选择虎牙账号") + db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( + synchronize_session=False + ) + db.query(HuyaWorkbenchAccount).filter( + HuyaWorkbenchAccount.account_id.in_(ids) + ).delete(synchronize_session=False) + _clear_huya_account_references(db, ids) + deleted = ( + db.query(HuyaAccount) + .filter(HuyaAccount.id.in_(ids)) + .delete(synchronize_session=False) + ) + db.commit() + scope = "当前筛选下" if req.all_matching else "选中的" + return { + "message": f"已删除{scope} {deleted} 个虎牙账号", + "deleted": deleted, + "success": True, + } + + +@router.delete("/accounts/{account_id}") +def delete_account( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """删除单个虎牙 CK 账号。""" + _require_huya_perm(current, "huya:delete") + account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete( + synchronize_session=False + ) + db.query(HuyaWorkbenchAccount).filter( + HuyaWorkbenchAccount.account_id == account_id + ).delete(synchronize_session=False) + _clear_huya_account_references(db, [account_id]) + db.delete(account) + db.commit() + return {"message": "已删除", "success": True} + + +@router.put("/accounts/{account_id}/assign") +def assign_account( + account_id: int, + req: AccountAssign, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """分配单个虎牙账号给客服。""" + _require_huya_perm(current, "huya:assign") + account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + if req.assigned_to: + target = db.query(User).filter(User.id == req.assigned_to).first() + if not target: + raise HTTPException(status_code=404, detail="目标用户不存在") + if target.role != "support": + raise HTTPException(status_code=400, detail="只能分配给客服角色") + account.assigned_to = req.assigned_to + db.commit() + return {"message": "已分配", "success": True} + + +@router.post("/accounts/batch-assign") +def batch_assign_accounts( + req: BatchAssign, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """批量分配/取消分配虎牙账号给客服。""" + _require_huya_perm(current, "huya:assign") + if not req.account_ids: + raise HTTPException(status_code=400, detail="请选择账号") + if req.assigned_to is not None: + target = db.query(User).filter(User.id == req.assigned_to).first() + if not target: + raise HTTPException(status_code=404, detail="目标用户不存在") + if target.role != "support": + raise HTTPException(status_code=400, detail="只能分配给客服角色") + + count = ( + db.query(HuyaAccount) + .filter(HuyaAccount.id.in_(req.account_ids)) + .update( + {HuyaAccount.assigned_to: req.assigned_to}, + synchronize_session=False, + ) + ) + db.commit() + action = "分配" if req.assigned_to else "取消分配" + return { + "message": f"已批量{action} {count} 个虎牙账号", + "success": True, + "count": count, + } + + +@router.get("/accounts/assignments/summary") +def assignments_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """虎牙分配概览:每个客服分配了多少虎牙账号。""" + _require_huya_perm(current, "huya:assign") + results = ( + db.query(User.id, User.username, func.count(HuyaAccount.id).label("count")) + .outerjoin(HuyaAccount, HuyaAccount.assigned_to == User.id) + .filter(User.role == "support") + .group_by(User.id, User.username) + .order_by(func.count(HuyaAccount.id).desc()) + .all() + ) + total_unassigned = ( + db.query(func.count(HuyaAccount.id)) + .filter(HuyaAccount.assigned_to.is_(None)) + .scalar() + ) or 0 + return { + "support_users": [ + {"id": uid, "username": uname, "assigned_count": cnt} + for uid, uname, cnt in results + ], + "unassigned_count": total_unassigned, + } + + +@router.put("/accounts/{account_id}/tag") +def set_account_tag( + account_id: int, + req: AccountTag, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """设置单个虎牙账号标签。""" + _require_huya_tag_permission(current) + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id) + .first() + ) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + account.tag = (req.tag or "").strip() + db.commit() + return {"message": "标签已更新", "success": True} + + +@router.put("/accounts/batch-tag") +def batch_tag( + req: AccountTag, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """批量设置虎牙账号标签。""" + _require_huya_tag_permission(current) + if not req.account_ids: + raise HTTPException(status_code=400, detail="请选择账号") + tag = (req.tag or "").strip() + count = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id.in_(req.account_ids)) + .update( + {HuyaAccount.tag: tag}, + synchronize_session=False, + ) + ) + db.commit() + return { + "message": f"已为 {count} 个虎牙账号设置标签", + "success": True, + "count": count, + } + + +@router.put("/accounts/batch-tag-selection") +def batch_tag_selection( + req: AccountBulkTag, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """按显式选择或当前筛选结果批量设置虎牙账号标签。""" + _require_huya_tag_permission(current) + ids = _selected_huya_account_ids(db, current, req) + if not ids: + raise HTTPException(status_code=400, detail="请选择虎牙账号") + tag = (req.tag_value or "").strip() + count = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id.in_(ids)) + .update( + {HuyaAccount.tag: tag}, + synchronize_session=False, + ) + ) + db.commit() + scope = "当前筛选下" if req.all_matching else "选中的" + return { + "message": f"已为{scope} {count} 个虎牙账号设置标签", + "success": True, + "count": count, + } + + +@router.get("/accounts/tags/list") +def list_tags( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """获取虎牙账号标签列表。""" + tag_query = db.query(HuyaAccount.tag) + if not _can_view_huya_all(current): + if _can_view_huya_assigned(current): + tag_query = tag_query.filter(HuyaAccount.assigned_to == current.id) + else: + raise HTTPException(status_code=403, detail="无权查看虎牙账号") + tags = ( + tag_query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)) + .distinct() + .all() + ) + return [item[0] for item in tags if item[0]] diff --git a/web/backend/routers/huya/cookies.py b/web/backend/routers/huya/cookies.py new file mode 100644 index 0000000..0de3d15 --- /dev/null +++ b/web/backend/routers/huya/cookies.py @@ -0,0 +1,218 @@ +"""虎牙 Cookie 查看、导出与清除。""" + +from fastapi import APIRouter + +from ._common import * + +router = APIRouter() + + +@router.get("/cookies") +def list_cookies( + search: str = Query(""), + page: int | None = Query(None, ge=1), + page_size: int = Query(20, ge=1, le=200), + include_cookie: bool = Query(True), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """查看当前用户可见的虎牙 Cookie。""" + should_include_cookie = include_cookie and _can_view_huya_cookie(current) + query = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "") + search_text = (search or "").strip() + if search_text: + pattern = f"%{search_text}%" + query = query.outerjoin(User, HuyaAccount.assigned_to == User.id).filter( + or_( + HuyaAccount.uid.ilike(pattern), + HuyaAccount.yyuid.ilike(pattern), + HuyaAccount.username.ilike(pattern), + HuyaAccount.nickname.ilike(pattern), + User.username.ilike(pattern), + ) + ) + total = None + if page is not None: + total = query.order_by(None).count() + query = query.order_by(HuyaAccount.updated_at.desc()) + if page is not None: + query = query.offset((page - 1) * page_size).limit(page_size) + if not should_include_cookie: + query = query.options( + defer(HuyaAccount.cookie), defer(HuyaAccount.account_password) + ) + accounts = query.all() + result = ( + [_cookie_out(account, include_cookie=True) for account in accounts] + if should_include_cookie + else [_cookie_out_light(account) for account in accounts] + ) + if page is not None: + return { + "items": result, + "total": total or 0, + "page": page, + "page_size": page_size, + } + return result + + +@router.get("/cookies/summary") +def cookies_summary( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """虎牙 Cookie 统计,避免前端为了卡片统计拉全量 Cookie。""" + query = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "") + total = query.count() + assigned_count = query.filter(HuyaAccount.assigned_to.isnot(None)).count() + return { + "total": total, + "assigned_count": assigned_count, + "unassigned_count": max(0, total - assigned_count), + } + + +@router.get("/cookies/export") +def export_cookies( + format: str = "csv", + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """导出虎牙 Cookie,支持 csv 和 custom 格式。""" + _require_huya_perm(current, "huya:cookie:export") + accounts = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.cookie != "") + .order_by(HuyaAccount.updated_at.desc()) + .all() + ) + if format == "custom": + lines = [] + for account in accounts: + username = account.username or account.nickname or account.uid or "" + lines.append(f"{username}----{normalize_huya_cookie(account.cookie or '')}") + content = "\r\n".join(lines) + filename = "huya_cookies_custom.txt" + media = "text/plain" + else: + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["账号", "UID", "YYUID", "Cookie", "时间"]) + for account in accounts: + username = account.nickname or account.username or account.uid or "" + writer.writerow( + [ + username, + account.uid or "", + account.yyuid or "", + normalize_huya_cookie(account.cookie or ""), + account.updated_at.isoformat() if account.updated_at else "", + ] + ) + content = output.getvalue() + filename = "huya_cookies.csv" + media = "text/csv" + return StreamingResponse( + iter([content]), + media_type=media, + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@router.get("/cookies/{account_id}") +def get_cookie( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """获取单条虎牙 Cookie 详情,供复制操作按需读取完整敏感字段。""" + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id, HuyaAccount.cookie != "") + .first() + ) + if not account: + raise HTTPException(status_code=404, detail="记录不存在") + return _cookie_out(account, include_cookie=_can_view_huya_cookie(current)) + + +@router.delete("/cookies/batch") +def delete_cookies_batch( + account_ids: str = "", + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """批量清除虎牙 Cookie,保留账号记录。""" + _require_huya_perm(current, "huya:cookie:export") + ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] + if not ids: + raise HTTPException(status_code=400, detail="无效的账号ID") + accounts = ( + _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(ids)).all() + ) + for account in accounts: + account.cookie = "" + account.status = "invalid" + db.commit() + return { + "message": f"已清除 {len(accounts)} 条虎牙 Cookie", + "deleted": len(accounts), + "success": True, + } + + +@router.delete("/cookies/{account_id}") +def delete_cookie( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """清除单个虎牙 Cookie,保留账号记录。""" + _require_huya_perm(current, "huya:cookie:export") + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id) + .first() + ) + if not account: + raise HTTPException(status_code=404, detail="记录不存在") + account.cookie = "" + account.status = "invalid" + db.commit() + return {"message": "已清除", "success": True} + + +@router.post("/cookies/{account_id}/rebuild") +def rebuild_web_cookie( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """从 App 原始凭据重建网页运行 Cookie,不发起登录或支付。""" + if not ( + _has_huya_perm(current, "huya:import") + or _has_huya_perm(current, "huya:task") + or _has_huya_perm(current, "huya:cookie:export") + ): + raise HTTPException(status_code=403, detail="无权重建虎牙 Cookie") + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id) + .first() + ) + if not account: + raise HTTPException(status_code=404, detail="记录不存在") + try: + rebuild_web_cookie_from_raw(db, account) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + record_audit( + db, + current, + action="huya:cookie:rebuild", + target=f"huya_account:{account.id}", + detail={"account_id": account.id}, + ) + db.commit() + return _cookie_out(account, include_cookie=_can_view_huya_cookie(current)) diff --git a/web/backend/routers/huya/devices.py b/web/backend/routers/huya/devices.py new file mode 100644 index 0000000..c01874b --- /dev/null +++ b/web/backend/routers/huya/devices.py @@ -0,0 +1,87 @@ +"""虎牙设备绑定:画像与指纹状态解绑。""" + +from fastapi import APIRouter + +from ._common import * + +router = APIRouter() + + +@router.get("/device-bindings") +def list_device_bindings( + db: Session = Depends(get_db), current: User = Depends(get_current_user) +): + """列出所有账号 ↔ 设备环境绑定 (画像 + 指纹状态 + 账号表三方合并)。""" + if not any(user_has_permission(current, p) for p in _DEVICE_BINDINGS_PERMS_VIEW): + raise HTTPException(status_code=403, detail="无虎牙账号查看权限") + profiles = _load_profile_db() + accounts = {a.username: a for a in db.query(HuyaAccount).all() if a.username} + rows = [] + for account, prof in sorted(profiles.items()): + acct = accounts.get(account) + state_dir = _fp_state_dir(account) + last_login = prof.get("last_login") or {} + rows.append( + { + "account": account, + "vendor": prof.get("vendor", ""), + "model": prof.get("model", ""), + "screen": prof.get("screen", ""), + "fingerprint_masked": _mask_hex(prof.get("fingerprint")), + "guid32_masked": _mask_hex(prof.get("guid32")), + "hebe_count": len(prof.get("hebe") or {}), + "hdid32": prof.get("hdid", ""), # t1.t0 app 级常量, 非敏感 + "has_hydevice_state": state_dir.exists(), + "login_channel": (acct.login_channel if acct else "") or "", + "account_status": (acct.status if acct else "") or "未入库", + "bound_at": prof.get("bound_at"), + "last_login_at": last_login.get("at"), + "last_login_ok": last_login.get("ok"), + } + ) + return {"items": rows, "total": len(rows)} + + +@router.delete("/device-bindings/{account}") +def delete_device_binding( + account: str, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """解绑: 删除该账号的设备画像与 hydevice 指纹状态, 下次登录自动生成全新环境。""" + _require_huya_perm(current, "huya:import") + profiles = _load_profile_db() + if account not in profiles: + raise HTTPException(status_code=404, detail="该账号没有设备绑定记录") + profiles.pop(account) + _save_profile_db(profiles) + state_dir = _fp_state_dir(account) + removed_state = False + if state_dir.exists(): + shutil.rmtree(state_dir, ignore_errors=True) + removed_state = True + return { + "ok": True, + "account": account, + "profile_removed": True, + "fp_state_removed": removed_state, + "message": "已解绑, 下次 App 协议登录将自动生成全新设备环境并重新注册", + } + + +@router.post("/device-bindings/batch-delete") +def delete_device_bindings( + req: HuyaDeviceBindingsBatchDeleteRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """批量解绑: 一次删除多个账号的设备画像与指纹状态。""" + _require_huya_perm(current, "huya:import") + removed, missing = _unbind_device_accounts(req.accounts) + if not removed: + raise HTTPException(status_code=404, detail="所选账号均没有设备绑定记录") + message = f"已批量解绑 {len(removed)} 个账号" + if missing: + message += f",{len(missing)} 个账号无绑定记录已跳过" + message += ",下次 App 协议登录将自动生成全新设备环境并重新注册" + return {"ok": True, "removed": removed, "missing": missing, "message": message} diff --git a/web/backend/routers/huya/login.py b/web/backend/routers/huya/login.py new file mode 100644 index 0000000..6875082 --- /dev/null +++ b/web/backend/routers/huya/login.py @@ -0,0 +1,679 @@ +"""虎牙登录:Web / App / 短信及批量选中登录。""" + +import time + +from fastapi import APIRouter + +from ._common import * + +router = APIRouter() + + +@router.post("/accounts/password-login") +def password_login_account( + req: HuyaPasswordLoginRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """使用 Web 方式账号密码登录虎牙(旧版),成功后保存 Cookie。""" + _require_huya_perm(current, "huya:import") + try: + result = login_huya_password( + username=req.username.strip(), + password=req.password, + cookie=req.cookie.strip() or None, + ) + except HuyaCredentialError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HuyaLoginError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status_code=502, detail=f"虎牙 Web 密码登录失败: {exc}" + ) from exc + + if not result.success or not result.cookie: + raise HTTPException( + status_code=502, detail=result.message or "虎牙 Web 密码登录失败" + ) + + try: + account = upsert_huya_cookie( + db, result.cookie, tag=req.tag, username_hint=req.username + ) + if hasattr(account, "account_password") and req.password: + account.account_password = req.password + account.status = "active" + account.login_channel = "web" + db.commit() + except ValueError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return { + "message": "Web 登录成功,Cookie 已保存", + "success": True, + "account": _account_out(account), + "sdid": result.sdid, + } + + +@router.post("/accounts/app-password-login") +def app_password_login_account( + req: HuyaAppPasswordLoginRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """使用 App 协议密码登录虎牙(推荐),自动过滑块并保存 Cookie。""" + _require_huya_perm(current, "huya:import") + username_clean = req.username.strip() + account_db = ( + db.query(HuyaAccount).filter(HuyaAccount.username == username_clean).first() + ) + sms_url = account_db.sms_url if account_db and account_db.sms_url else "" + try: + result = login_huya_app_password( + username=username_clean, + password=req.password, + force_new_device=req.force_new_device, + sms_url=sms_url, + ) + except HuyaAppQrAuthRequiredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HuyaCredentialError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except (HuyaAppLoginError, HuyaLoginError) as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status_code=502, detail=f"虎牙 App 密码登录失败: {exc}" + ) from exc + + if not result.success or not result.cookie: + logger.warning( + f"[huya-app] 账号 {req.username} 密码登录未完成: {result.message} (code={result.code})" + ) + raise HTTPException( + status_code=400 + if result.code in ("SMS_AUTH_REQUIRED", "QR_AUTH_REQUIRED") + else 502, + detail=result.message or "虎牙 App 密码登录失败", + ) + + try: + account = upsert_huya_cookie( + db, + result.cookie, + tag=req.tag, + username_hint=req.username, + raw_cookie=getattr(result, "raw_cookie", ""), + ) + if hasattr(account, "account_password") and req.password: + account.account_password = req.password + account.status = "active" + account.login_channel = "app" + db.commit() + except ValueError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return { + "message": "App 协议登录成功,Cookie 已保存", + "success": True, + "account": _account_out(account), + "sdid": result.sdid, + } + + +@router.post("/accounts/sms-code") +def send_sms_code( + req: HuyaSmsCodeRequest, + current: User = Depends(get_current_user), +): + """发送虎牙短信验证码,返回提交登录所需 state。""" + _require_huya_perm(current, "huya:import") + try: + result = send_huya_sms_code( + phone=req.phone.strip(), + cookie=req.cookie.strip() or None, + ) + except HuyaLoginError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"虎牙短信发码失败: {exc}") from exc + + if not result.success or not result.state: + raise HTTPException( + status_code=502, detail=result.message or "虎牙短信发码失败" + ) + + return { + "message": result.message or "短信已发送", + "success": True, + "state": result.state, + "sdid": result.sdid, + "context": result.context, + "request_id": result.request_id, + } + + +@router.post("/accounts/sms-login") +def sms_login_account( + req: HuyaSmsLoginRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """提交虎牙短信验证码登录,成功后保存 Cookie。""" + _require_huya_perm(current, "huya:import") + try: + result = login_huya_sms( + authcode=req.authcode.strip(), + state=req.state.strip(), + phone=req.phone.strip(), + ) + except HuyaLoginError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"虎牙短信登录失败: {exc}") from exc + + if not result.success or not result.cookie: + raise HTTPException( + status_code=502, detail=result.message or "虎牙短信登录失败" + ) + + try: + account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint="") + account.login_channel = "sms" + phone = (req.phone or "").strip() + if phone: + account.game_phone = phone + account.updated_at = datetime.now(UTC) + db.commit() + db.refresh(account) + except ValueError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + return { + "message": "登录成功,Cookie 已保存", + "success": True, + "account": _account_out(account), + "sdid": result.sdid, + } + + +@router.post("/accounts/test-sms-url") +def test_sms_url_endpoint( + req: HuyaTestSmsUrlRequest, + current: User = Depends(get_current_user), +): + """测试从指定接码链接获取验证码。""" + _require_huya_perm(current, "huya:import") + url = req.url.strip() + if not url: + raise HTTPException(status_code=400, detail="接码链接不能为空") + from core.sms_provider import SmsLine, SmsProviderClient, detect_provider + + client = SmsProviderClient(timeout=(8, 15)) + item = SmsLine( + phone=req.phone.strip(), + url=url, + provider=detect_provider(url), + raw=f"{req.phone}----{url}", + ) + poll_result = client.poll(item) + return { + "success": poll_result.has_code, + "code": poll_result.code, + "status": poll_result.status, + "message": poll_result.message + or ("成功识别验证码" if poll_result.has_code else "未识别到验证码"), + "raw": poll_result.raw[:500], + } + + +@router.post("/accounts/{account_id}/fetch-sms-code") +def fetch_account_sms_code( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """通过该账号绑定的接码链接获取最新短信验证码。""" + _require_huya_perm(current, "huya:import") + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id) + .first() + ) + if account is None: + raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作") + if not account.sms_url: + raise HTTPException(status_code=400, detail="该账号未配置接码链接 (sms_url)") + from core.sms_provider import SmsLine, SmsProviderClient, detect_provider + + client = SmsProviderClient(timeout=(8, 15)) + item = SmsLine( + phone=account.game_phone or "", + url=account.sms_url, + provider=detect_provider(account.sms_url), + raw=f"{account.game_phone}----{account.sms_url}", + ) + poll_result = client.poll(item) + return { + "account_id": account.id, + "username": account.username or "", + "phone": account.game_phone or "", + "sms_url": account.sms_url, + "success": poll_result.has_code, + "code": poll_result.code, + "status": poll_result.status, + "message": poll_result.message + or ("成功识别验证码" if poll_result.has_code else "未识别到验证码"), + "raw": poll_result.raw[:500], + } + + +@router.post("/accounts/{account_id}/auto-sms-login") +def auto_sms_login_account( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """使用该账号绑定的手机号与接码链接一键执行虎牙短信登录。""" + _require_huya_perm(current, "huya:import") + account = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id == account_id) + .first() + ) + if account is None: + raise HTTPException(status_code=404, detail="虎牙账号不存在或无权操作") + phone = (account.game_phone or "").strip() + sms_url = (account.sms_url or "").strip() + if not phone or not sms_url: + raise HTTPException(status_code=400, detail="该账号缺少手机号或接码链接") + from core.huya.auto_register import auto_register_huya_phone + from core.sms_provider import SmsLine, detect_provider + + item = SmsLine( + phone=phone, + url=sms_url, + provider=detect_provider(sms_url), + raw=f"{phone}----{sms_url}", + ) + res = auto_register_huya_phone(item, wait_seconds=90, poll_interval=2) + if not res.success or not res.cookie: + account.status = "login_failed" + account.updated_at = datetime.now(UTC) + db.commit() + raise HTTPException( + status_code=502, detail=res.message or "虎牙短信接码登录失败" + ) + saved = save_huya_login_cookie_to_account( + db, + account, + res.cookie, + tag=account.tag or "", + username_hint=account.username or "", + ) + saved.status = "active" + saved.login_channel = "sms" + db.commit() + return { + "message": "短信接码登录成功,Cookie 已保存", + "success": True, + "account": _account_out(saved), + } + + +_NON_RETRYABLE_LOGIN_CODES = frozenset({"QR_AUTH_REQUIRED", "SMS_AUTH_REQUIRED"}) + + +def _sleep_seconds(seconds: float) -> None: + if seconds and seconds > 0: + time.sleep(seconds) + + +def _is_retryable_login_code(code: object) -> bool: + return str(code or "") not in _NON_RETRYABLE_LOGIN_CODES + + +def _login_fail_item( + *, + account_id: int, + username: str, + message: str, + account=None, + include_cookie: bool = False, + sdid: str = "", +) -> dict: + item: dict = { + "line": account_id, + "username": username, + "success": False, + "message": message, + } + if account is not None: + item["account"] = _account_out(account, include_cookie=include_cookie) + if sdid: + item["sdid"] = sdid + return item + + +def _mark_account_login_failed(db: Session, account) -> None: + account.status = "login_failed" + account.updated_at = datetime.now(UTC) + db.commit() + + +def _attempt_web_password_login( + account, *, include_cookie: bool, db: Session +) -> tuple[dict, bool]: + username = (account.username or "").strip() + try: + result = login_huya_password( + username=username, + password=(account.account_password or "").strip(), + cookie=account.cookie or None, + ) + if not result.success or not result.cookie: + _mark_account_login_failed(db, account) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=result.message or "虎牙密码登录失败", + account=account, + include_cookie=include_cookie, + sdid=result.sdid, + ), + _is_retryable_login_code(result.code), + ) + saved = save_huya_login_cookie_to_account( + db, + account, + result.cookie, + tag=account.tag or "", + username_hint=username, + ) + saved.status = "active" + saved.login_channel = "web" + db.commit() + return ( + { + "line": account.id, + "username": username, + "success": True, + "message": "登录成功,Cookie 已保存", + "account": _account_out(saved, include_cookie=include_cookie), + "sdid": result.sdid, + }, + False, + ) + except HuyaCredentialError as exc: + _mark_account_login_failed(db, account) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=str(exc), + account=account, + include_cookie=include_cookie, + ), + False, + ) + except (HuyaLoginError, ValueError) as exc: + _mark_account_login_failed(db, account) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=str(exc), + account=account, + include_cookie=include_cookie, + ), + True, + ) + except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 + _mark_account_login_failed(db, account) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=f"虎牙密码登录失败: {exc}", + account=account, + include_cookie=include_cookie, + ), + True, + ) + + +def _attempt_app_password_login( + account, *, force_new_device: bool, include_cookie: bool, db: Session +) -> tuple[dict, bool]: + username = (account.username or "").strip() + sms_url = (account.sms_url or "").strip() + try: + result = login_huya_app_password( + username=username, + password=(account.account_password or "").strip(), + force_new_device=force_new_device, + sms_url=sms_url, + ) + if not result.success or not result.cookie: + _mark_account_login_failed(db, account) + logger.warning( + f"[huya-app] 账号 {username} 登录未完成: {result.message} (code={result.code})" + ) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=result.message or "虎牙 App 密码登录失败", + account=account, + include_cookie=include_cookie, + sdid=result.sdid, + ), + _is_retryable_login_code(result.code), + ) + saved = save_huya_login_cookie_to_account( + db, + account, + result.cookie, + tag=account.tag or "", + username_hint=username, + ) + saved.status = "active" + saved.login_channel = "app" + db.commit() + return ( + { + "line": account.id, + "username": username, + "success": True, + "message": "App 登录成功,Cookie 已保存", + "account": _account_out(saved, include_cookie=include_cookie), + "sdid": result.sdid, + }, + False, + ) + except HuyaAppQrAuthRequiredError as exc: + _mark_account_login_failed(db, account) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=str(exc), + account=account, + include_cookie=include_cookie, + ), + False, + ) + except HuyaCredentialError as exc: + _mark_account_login_failed(db, account) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=str(exc), + account=account, + include_cookie=include_cookie, + ), + False, + ) + except (HuyaAppLoginError, HuyaLoginError, ValueError) as exc: + _mark_account_login_failed(db, account) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=str(exc), + account=account, + include_cookie=include_cookie, + ), + True, + ) + except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 + _mark_account_login_failed(db, account) + return ( + _login_fail_item( + account_id=account.id, + username=username, + message=f"虎牙 App 密码登录失败: {exc}", + account=account, + include_cookie=include_cookie, + ), + True, + ) + + +def _run_selected_password_logins( + *, + channel: str, + req: HuyaPasswordLoginSelectedRequest, + db: Session, + current: User, +) -> dict: + """单线程逐个登录:同一时间只登一个号,失败最多重试,号与号之间留间隔。""" + _require_huya_perm(current, "huya:import") + selected_ids = _selected_huya_account_ids( + db, current, cast(AccountBulkSelection, req) + ) + if not selected_ids: + raise HTTPException(status_code=400, detail="请选择虎牙账号") + + accounts = ( + _visible_huya_accounts_query(db, current) + .filter(HuyaAccount.id.in_(selected_ids)) + .all() + ) + account_map = {account.id: account for account in accounts} + include_cookie = _can_view_huya_cookie(current) + results: list[dict] = [] + success_count = 0 + failed_count = 0 + max_attempts = req.max_attempts + interval_seconds = req.interval_seconds + retry_delay_seconds = req.retry_delay_seconds + label = "App" if channel == "app" else "Web" + + for index, account_id in enumerate(selected_ids): + if index > 0: + _sleep_seconds(interval_seconds) + + account = account_map.get(account_id) + if account is None: + failed_count += 1 + results.append( + _login_fail_item( + account_id=account_id, + username="", + message="账号不存在或无权登录", + ) + ) + continue + + username = (account.username or "").strip() + password = (account.account_password or "").strip() + if not username or not password: + failed_count += 1 + results.append( + _login_fail_item( + account_id=account.id, + username=username, + message="该账号未导入密码", + account=account, + include_cookie=include_cookie, + ) + ) + continue + + last_item: dict | None = None + used_attempts = 0 + for attempt in range(1, max_attempts + 1): + if attempt > 1: + _sleep_seconds(retry_delay_seconds) + used_attempts = attempt + if channel == "app": + last_item, retryable = _attempt_app_password_login( + account, + force_new_device=req.force_new_device, + include_cookie=include_cookie, + db=db, + ) + else: + last_item, retryable = _attempt_web_password_login( + account, + include_cookie=include_cookie, + db=db, + ) + if last_item["success"] or not retryable: + break + if attempt < max_attempts: + logger.warning( + f"[huya-{channel}] 账号 {username} 第 {attempt}/{max_attempts} 次失败," + f"{retry_delay_seconds}s 后重试: {last_item['message']}" + ) + + assert last_item is not None + last_item["attempts"] = used_attempts + if last_item["success"]: + success_count += 1 + if used_attempts > 1: + last_item["message"] = ( + f"第 {used_attempts} 次尝试成功。" + last_item["message"] + ) + else: + failed_count += 1 + if used_attempts > 1: + last_item["message"] = ( + f"已尝试 {used_attempts} 次:{last_item['message']}" + ) + results.append(last_item) + + return { + "message": f"{label} 登录完成:成功 {success_count} 条,失败 {failed_count} 条", + "success": True, + "count": success_count, + "failed": failed_count, + "results": results, + } + + +@router.post("/accounts/password-login/selected") +def password_login_selected_accounts( + req: HuyaPasswordLoginSelectedRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """对已导入的虎牙账号执行 Web 密码登录(单线程,失败可重试)。""" + return _run_selected_password_logins( + channel="web", req=req, db=db, current=current + ) + + +@router.post("/accounts/app-password-login/selected") +def app_password_login_selected_accounts( + req: HuyaPasswordLoginSelectedRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """对已导入的虎牙账号使用 App 协议逐个登录(推荐,单线程)。""" + return _run_selected_password_logins( + channel="app", req=req, db=db, current=current + ) diff --git a/web/backend/routers/huya/register.py b/web/backend/routers/huya/register.py new file mode 100644 index 0000000..049d596 --- /dev/null +++ b/web/backend/routers/huya/register.py @@ -0,0 +1,184 @@ +"""虎牙自动注册批次与成功流水。""" + +from fastapi import APIRouter + +from ._common import * + +router = APIRouter() + + +@router.post("/register/batches", response_model=HuyaAutoRegisterBatchOut) +def create_auto_register_batch( + req: HuyaAutoRegisterRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """启动虎牙手机号自动注册批次。""" + _require_huya_perm(current, "huya:import") + sms_lines = parse_sms_lines(req.text) + if not sms_lines: + raise HTTPException( + status_code=400, + detail="没有识别到有效手机号,格式为:手机号----短信查询URL 或 虎牙号----密码----手机号----短信查询URL", + ) + + proxy_config = db.query(ProxyConfig).first() if req.use_proxy else None + runner = huya_register_registry.create( + sms_lines=sms_lines, + tag=req.tag.strip(), + created_by=current.id, + concurrency=req.concurrency, + wait_seconds=req.wait_seconds, + poll_interval=req.poll_interval, + password_prefix=req.password_prefix, + fixed_password=req.fixed_password, + use_proxy=req.use_proxy, + proxy_config=proxy_config, + ) + runner.mark_running("批次运行中") + thread = threading.Thread(target=runner.run, daemon=True) + thread.start() + return runner.snapshot() + + +@router.get("/register/batches", response_model=list[HuyaAutoRegisterBatchOut]) +def list_auto_register_batches( + limit: int = Query(50, ge=1, le=200), + current: User = Depends(get_current_user), +): + """列出历史自动注册批次(摘要,不含明细)。""" + _require_huya_perm(current, "huya:import") + return huya_register_registry.list_summaries(limit=limit) + + +@router.get("/register/batches/{batch_id}", response_model=HuyaAutoRegisterBatchOut) +def get_auto_register_batch( + batch_id: str, + current: User = Depends(get_current_user), +): + """查询虎牙手机号自动注册批次状态(优先内存,否则读库)。""" + _require_huya_perm(current, "huya:import") + snapshot = huya_register_registry.get_snapshot(batch_id) + if not snapshot: + raise HTTPException(status_code=404, detail="批次不存在") + return snapshot + + +@router.post("/register/batches/{batch_id}/stop") +def stop_auto_register_batch( + batch_id: str, + current: User = Depends(get_current_user), +): + """停止虎牙手机号自动注册批次。""" + _require_huya_perm(current, "huya:import") + runner = huya_register_registry.get(batch_id) + if not runner: + # 无内存 runner:若 DB 中存在且处于 running/interrupted,标记停止 + snapshot = huya_register_registry.get_snapshot(batch_id) + if not snapshot: + raise HTTPException(status_code=404, detail="批次不存在") + return {"message": "批次未在本进程运行,无需停止", "success": True} + runner.stop() + return {"message": "已发送停止信号", "success": True} + + +@router.post( + "/register/batches/{batch_id}/retry", response_model=HuyaAutoRegisterBatchOut +) +def retry_auto_register_batch( + batch_id: str, + req: HuyaAutoRegisterRetryRequest | None = None, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """继续批次:默认从停止处往下跑(跳过成功与已失败);可传 mode 改行为。""" + _require_huya_perm(current, "huya:import") + if req is None: + req = HuyaAutoRegisterRetryRequest.model_construct() + use_proxy = req.use_proxy + # 先看历史批次是否用过代理 + snapshot = huya_register_registry.get_snapshot(batch_id) + if not snapshot: + raise HTTPException(status_code=404, detail="批次不存在") + effective_proxy = ( + snapshot.get("use_proxy", False) if use_proxy is None else bool(use_proxy) + ) + proxy_config = db.query(ProxyConfig).first() if effective_proxy else None + try: + runner = huya_register_registry.retry( + batch_id, + proxy_config=proxy_config, + mode=req.mode, + concurrency=req.concurrency, + wait_seconds=req.wait_seconds, + poll_interval=req.poll_interval, + password_prefix=req.password_prefix, + fixed_password=req.fixed_password, + use_proxy=use_proxy, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + thread = threading.Thread(target=runner.run, daemon=True) + thread.start() + return runner.snapshot() + + +@router.get("/register/batches/{batch_id}/export") +def export_auto_register_batch_success( + batch_id: str, + current: User = Depends(get_current_user), +): + """导出指定批次的成功账号 txt。""" + _require_huya_perm(current, "huya:import") + snapshot = huya_register_registry.get_snapshot(batch_id) + if not snapshot: + raise HTTPException(status_code=404, detail="批次不存在") + content = export_success_logs_text(batch_id=batch_id) + if not content.strip(): + raise HTTPException(status_code=404, detail="该批次没有可导出的成功记录") + return StreamingResponse( + iter([content]), + media_type="text/plain; charset=utf-8", + headers={ + "Content-Disposition": f"attachment; filename=huya-register-{batch_id}.txt" + }, + ) + + +@router.get("/register/success-logs", response_model=list[HuyaRegisterSuccessLogOut]) +def list_register_success_logs( + batch_id: str | None = Query(None), + tag: str | None = Query(None), + limit: int = Query(200, ge=1, le=2000), + current: User = Depends(get_current_user), +): + """列出注册成功流水(换电脑也可查看)。""" + _require_huya_perm(current, "huya:import") + return list_success_logs(batch_id=batch_id, tag=tag, limit=limit) + + +@router.get("/register/success-logs/export") +def export_register_success_logs( + batch_id: str | None = Query(None), + tag: str | None = Query(None), + limit: int = Query(5000, ge=1, le=20000), + current: User = Depends(get_current_user), +): + """导出注册成功流水 txt:账号----密码----手机号----接码链接。""" + _require_huya_perm(current, "huya:import") + content = export_success_logs_text(batch_id=batch_id, tag=tag, limit=limit) + if not content.strip(): + raise HTTPException(status_code=404, detail="没有可导出的成功记录") + filename = "huya-register-success" + if batch_id: + filename += f"-{batch_id}" + if tag: + filename += f"-{tag}" + filename += ".txt" + return StreamingResponse( + iter([content]), + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) diff --git a/web/backend/routers/huya/workbench.py b/web/backend/routers/huya/workbench.py new file mode 100644 index 0000000..3469a0e --- /dev/null +++ b/web/backend/routers/huya/workbench.py @@ -0,0 +1,386 @@ +"""虎牙工作台:配置、商品快照、任务批次与日志。""" + +from fastapi import APIRouter + +from ._common import * + +router = APIRouter() + + +@router.get("/task-types") +def task_types(current: User = Depends(require_permission("huya:task"))): + """返回当前规划的虎牙任务类型。""" + return SUPPORTED_TASK_TYPES + + +@router.get("/config", response_model=HuyaConfigOut) +def get_config( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:config")), +): + """获取虎牙配置。""" + config = ensure_huya_config(db) + return _config_out(config) + + +@router.put("/config", response_model=HuyaConfigOut) +def update_config( + req: HuyaConfigUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:config")), +): + """更新虎牙配置。""" + config = ensure_huya_config(db) + changed_fields = [] + for field in HUYA_CONFIG_FIELDS: + value = getattr(req, field) + if value is not None: + setattr(config, field, value.strip()) + changed_fields.append(field) + apply_huya_config_defaults(config) + config.updated_at = datetime.now(UTC) + if changed_fields: + record_audit( + db, + current, + action="recharge:huya:config", + target="huya_config", + detail={"changed_fields": changed_fields}, + ) + db.commit() + db.refresh(config) + return _config_out(config) + + +@router.get("/workbench-accounts") +def list_huya_workbench_accounts( + handbook_scope: str = Query(..., pattern="^elite$|^browser_exchange$"), + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """读取当前用户指定工作台(精英宝典/浏览器兑换)的账号集合。""" + ids = _workbench_account_ids(db, current, handbook_scope) + configured = ( + db.query(HuyaWorkbench.id) + .filter( + HuyaWorkbench.user_id == current.id, + HuyaWorkbench.handbook_scope == handbook_scope, + ) + .first() + is not None + ) + return {"account_ids": ids, "configured": configured} + + +@router.put("/workbench-accounts") +def update_huya_workbench_accounts( + req: HuyaWorkbenchAccountsUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """覆盖当前用户指定工作台(精英宝典/浏览器兑换)的账号集合,支持跨浏览器同步。""" + ids = sorted(set(req.account_ids)) + if any(account_id < 1 for account_id in ids): + raise HTTPException(status_code=400, detail="无效的账号 ID") + if ids: + _require_huya_task_account_access(db, current, ids) + workbench = ( + db.query(HuyaWorkbench) + .filter( + HuyaWorkbench.user_id == current.id, + HuyaWorkbench.handbook_scope == req.handbook_scope, + ) + .first() + ) + if workbench is None: + db.add(HuyaWorkbench(user_id=current.id, handbook_scope=req.handbook_scope)) + else: + workbench.updated_at = datetime.now(UTC) + db.query(HuyaWorkbenchAccount).filter( + HuyaWorkbenchAccount.user_id == current.id, + HuyaWorkbenchAccount.handbook_scope == req.handbook_scope, + ).delete(synchronize_session=False) + db.add_all( + [ + HuyaWorkbenchAccount( + user_id=current.id, + handbook_scope=req.handbook_scope, + account_id=account_id, + ) + for account_id in ids + ] + ) + db.commit() + return {"account_ids": ids, "success": True} + + +@router.get("/goods", response_model=list[HuyaGoodsOut]) +def list_goods( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """查看已缓存的虎牙商品快照。""" + rows = db.query(HuyaGoodsSnapshot).order_by(HuyaGoodsSnapshot.id.desc()).all() + latest = {} + for row in rows: + latest.setdefault((row.sid, row.product_id), row) + return list(latest.values()) + + +@router.get("/recharge-goods", response_model=list[HuyaRechargeGoodsOut]) +def list_recharge_goods( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """查看已缓存的虎牙充值商品快照。""" + rows = ( + db.query(HuyaRechargeGoodsSnapshot) + .order_by(HuyaRechargeGoodsSnapshot.id.desc()) + .all() + ) + latest = {} + for row in rows: + latest.setdefault((row.sid, row.spu_id), row) + return list(latest.values()) + + +@router.post("/tasks/batch") +async def create_task_batch( + req: HuyaTaskBatchRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """创建虎牙任务记录并启动后台执行器。""" + if not req.account_ids: + raise HTTPException(status_code=400, detail="请选择虎牙账号") + _require_huya_task_account_access(db, current, req.account_ids) + + # 先清掉没有执行器的历史 running/pending,避免前端被假活跃批次锁死。 + # 不清理 planned:避免与刚创建的新任务产生竞态。 + cleanup_orphan_huya_tasks( + db, + active_batch_ids=huya_batch_registry.active_ids(), + statuses=("pending", "running"), + message="任务已中断(无执行器接管)", + ) + + try: + batch_id, count = create_planned_tasks( + db, + req.account_ids, + req.task_type, + current.id, + req.payload, + req.handbook_scope, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if count == 0: + raise HTTPException(status_code=400, detail="没有有效的虎牙账号") + + if req.task_type == "create_recharge_order": + record_audit( + db, + current, + action="recharge:huya:create", + target=f"huya_batch:{batch_id}", + detail={ + "batch_id": batch_id, + "task_type": req.task_type, + "count": count, + "account_count": len(req.account_ids), + }, + ) + db.commit() + + log_queue = asyncio.Queue() + loop = asyncio.get_running_loop() + thread_db = SessionLocal() + runner = HuyaBatchRunner( + db=thread_db, + batch_id=batch_id, + task_type=req.task_type, + payload=req.payload, + log_queue=log_queue, + loop=loop, + concurrency=req.concurrency, + ) + huya_batch_registry.register(batch_id, log_queue, loop, runner) + + thread = threading.Thread(target=runner.run, daemon=True) + thread.start() + + return {"batch_id": batch_id, "count": count, "success": True} + + +@router.get("/tasks", response_model=list[HuyaTaskOut]) +def list_tasks( + batch_id: str | None = None, + handbook_scope: str | None = Query(None, pattern="^(legacy|elite)$"), + include_images: bool = Query( + False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)" + ), + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """查看虎牙任务记录。""" + query = _visible_huya_tasks_query(db, current) + if batch_id: + query = query.filter(HuyaTask.batch_id == batch_id) + if handbook_scope: + query = query.filter(HuyaTask.handbook_scope == handbook_scope) + tasks = query.order_by(HuyaTask.id.desc()).limit(300).all() + return [_task_out(task, include_images=include_images) for task in tasks] + + +@router.post("/tasks/cleanup-orphans") +def cleanup_orphan_tasks( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """手动清理没有内存执行器接管的虎牙任务。""" + cleaned = cleanup_orphan_huya_tasks( + db, + active_batch_ids=huya_batch_registry.active_ids(), + statuses=("pending", "running"), + message="任务已中断(无执行器接管)", + ) + return { + "message": f"已清理 {cleaned} 个残留任务", + "cleaned": cleaned, + "success": True, + } + + +@router.get("/tasks/summary") +def tasks_summary( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """虎牙任务统计。""" + return _huya_task_summary(_visible_huya_tasks_query(db, current)) + + +@router.get("/tasks/{task_id}", response_model=HuyaTaskOut) +def get_task( + task_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """获取单条虎牙任务详情(含二维码图片)。""" + task = _visible_huya_tasks_query(db, current).filter(HuyaTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return _task_out(task, include_images=True) + + +@router.post("/stop/{batch_id}") +def stop_batch( + batch_id: str, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """停止正在运行的虎牙批次。""" + _require_huya_batch_owner(db, current, batch_id) + is_recharge_batch = ( + db.query(HuyaTask.id) + .filter( + HuyaTask.batch_id == batch_id, HuyaTask.task_type == "create_recharge_order" + ) + .first() + is not None + ) + batch = huya_batch_registry.get(batch_id) + if batch: + if batch.get("finished"): + huya_batch_registry.pop(batch_id) + cleaned = cleanup_orphan_huya_tasks( + db, + batch_id=batch_id, + message="批次已结束", + ) + if cleaned: + return { + "message": f"批次已结束,已清理 {cleaned} 个残留任务", + "success": True, + } + raise HTTPException(status_code=404, detail="批次已结束") + batch["runner"].stop() + if is_recharge_batch: + record_audit( + db, + current, + action="recharge:huya:stop", + target=f"huya_batch:{batch_id}", + detail={"batch_id": batch_id, "mode": "running"}, + ) + db.commit() + return {"message": "已发送停止信号", "success": True} + + cleaned = cleanup_orphan_huya_tasks( + db, + batch_id=batch_id, + message="任务已停止(批次不存在)", + ) + if cleaned: + if is_recharge_batch: + record_audit( + db, + current, + action="recharge:huya:stop", + target=f"huya_batch:{batch_id}", + detail={ + "batch_id": batch_id, + "mode": "orphan_cleanup", + "cleaned": cleaned, + }, + ) + db.commit() + return {"message": f"已清理 {cleaned} 个残留任务", "success": True} + raise HTTPException(status_code=404, detail="批次不存在或已结束") + + +@router.websocket("/ws/{batch_id}") +async def ws_huya_logs(websocket: WebSocket, batch_id: str): + """虎牙实时日志推送通道。""" + user = authenticate_websocket(websocket) + if not user: + await websocket.close(code=1008, reason="未授权") + return + if not _has_huya_perm(user, "huya:task"): + await websocket.close(code=1008, reason="无权限") + return + db = SessionLocal() + try: + _require_huya_batch_owner(db, user, batch_id) + except HTTPException: + await websocket.close(code=1008, reason="无权访问该任务批次") + return + finally: + db.close() + await websocket.accept() + + batch = huya_batch_registry.get(batch_id) + if not batch: + await websocket.send_json({"level": "error", "message": "批次不存在或已结束"}) + await websocket.close() + return + + log_queue: asyncio.Queue = batch["log_queue"] + + try: + while True: + try: + msg = await asyncio.wait_for(log_queue.get(), timeout=30) + await websocket.send_json(msg) + if msg.get("level") == "result": + await asyncio.sleep(0.1) + break + except TimeoutError: + await websocket.send_json({"level": "heartbeat", "message": ""}) + except WebSocketDisconnect: + pass + finally: + latest = huya_batch_registry.get(batch_id) + if latest and latest.get("finished"): + huya_batch_registry.pop(batch_id) diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 57d9ec0..d85521f 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -421,6 +421,9 @@ class HuyaPasswordLoginSelectedRequest(BaseModel): assigned_only: bool = False has_cookie: bool = False force_new_device: bool = False + max_attempts: int = Field(2, ge=1, le=3) + interval_seconds: float = Field(8, ge=0, le=60) + retry_delay_seconds: float = Field(3, ge=0, le=30) class HuyaDeviceBindingsBatchDeleteRequest(BaseModel): diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index b470bec..64c187a 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -43,6 +43,8 @@ class HuyaBatchRunner( if not task or not account: return + self._wss_sessions.account_id = account.id + if self._stop.is_set(): self._mark_task(worker_db, task, "stopped", "任务已停止") return @@ -127,6 +129,7 @@ class HuyaBatchRunner( self._mark_task(worker_db, task, "error", f"执行异常: {exc}") self._push_log("error", f"[{current}] {name} 执行异常: {exc}") finally: + self._wss_sessions.account_id = None self._close_wss_sessions() worker_db.close() diff --git a/web/backend/services/huya_runner_core.py b/web/backend/services/huya_runner_core.py index 553aeb9..2ddda68 100644 --- a/web/backend/services/huya_runner_core.py +++ b/web/backend/services/huya_runner_core.py @@ -62,8 +62,30 @@ class HuyaBatchRunnerCore: logger=lambda message: self._push_log("info", f"[{uid}] {message}"), ) self._session_bucket().append(session) + self._persist_session_cookie(uid, cookie, session.cookie) return session + def _persist_session_cookie( + self, uid: int, original_cookie: str, filled_cookie: str + ): + """活动会话补齐网页字段后回写账号,避免下次任务重新生成设备态。""" + account_id = getattr(self._wss_sessions, "account_id", None) + if not account_id: + return + from ..database import SessionLocal + from .huya_service import persist_huya_session_cookie + + db = SessionLocal() + try: + if persist_huya_session_cookie( + db, int(account_id), original_cookie, filled_cookie + ): + self._push_log("info", f"[{uid}] 已回写活动会话补齐后的网页 Cookie") + except Exception: # noqa: BLE001 回写失败不能打断任务 + logger.debug("[huya] 回写会话 Cookie 失败") + finally: + db.close() + def _session_bucket(self) -> list[Any]: bucket = getattr(self._wss_sessions, "items", None) if bucket is None: diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index e577681..59bc3ee 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -369,6 +369,74 @@ def import_huya_cookies(db: Session, text: str, tag: str = "") -> tuple[int, int return created_or_updated, skipped +def persist_huya_session_cookie( + db: Session, + account_id: int, + original_cookie: str, + filled_cookie: str, +) -> bool: + """把活动会话补齐后的网页 Cookie 回写到账号。 + + 原始登录态只有 App 字段时,wsLaunch 会补齐 guid 等网页设备字段; + 不回写则下次任务又会重新生成设备态。 + """ + filled = normalize_huya_cookie(filled_cookie or "") + original = normalize_huya_cookie(original_cookie or "") + if not filled or filled == original: + return False + account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() + if account is None: + return False + if normalize_huya_cookie(account.cookie or "") == filled: + return False + account.cookie = filled + account.updated_at = datetime.now(UTC) + db.commit() + return True + + +def rebuild_web_cookie_from_raw(db: Session, account: HuyaAccount) -> str: + """用 App 原始凭据重建网页运行 Cookie,不发起登录或支付。""" + raw = normalize_huya_cookie(account.raw_cookie or "") + if not raw: + raise ValueError("该账号没有 App 原始凭据,无法重建网页 Cookie") + + from core.huya.cookie_utils import cookie_pairs + from core.huya.device_profile import _load_db, canonical_account_key + from core.huya.web_cookie_fields import ( + fill_web_cookie_fields, + qimei_feature_from_device_profile, + ) + + uid = 0 + for candidate in (account.uid, account.yyuid, cookie_value(raw, "udb_uid")): + text = str(candidate or "").strip() + if text.isdigit(): + uid = int(text) + break + + feature = None + username = (account.username or "").strip() + if username: + profiles = _load_db() + profile = profiles.get(canonical_account_key(username)) or profiles.get( + username + ) + if profile: + feature = qimei_feature_from_device_profile(profile) + + filled = fill_web_cookie_fields(raw, guid="", uid=uid, qimei_feature=feature) + pairs = dict(cookie_pairs(filled)) + pairs["udb_appid"] = "5002" + rebuilt = "; ".join(f"{key}={value}" for key, value in pairs.items()) + account.cookie = rebuilt + account.status = "login_success" + account.updated_at = datetime.now(UTC) + db.commit() + db.refresh(account) + return account.cookie + + def ensure_huya_config(db: Session) -> HuyaConfig: """获取单条虎牙配置,不存在则创建。""" config = db.query(HuyaConfig).first() diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index fcd947a..9a4f782 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -65,11 +65,19 @@ export const huyaApi = { passwordLogin: (data: HuyaPasswordLoginRequest) => api.post('/huya/accounts/password-login', data), passwordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) => - api.post('/huya/accounts/password-login/selected', data), + api.post( + '/huya/accounts/password-login/selected', + data, + { timeout: 240000 }, + ), appPasswordLogin: (data: HuyaAppPasswordLoginRequest) => api.post('/huya/accounts/app-password-login', data), appPasswordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) => - api.post('/huya/accounts/app-password-login/selected', data), + api.post( + '/huya/accounts/app-password-login/selected', + data, + { timeout: 240000 }, + ), smsCode: (data: HuyaSmsCodeRequest) => api.post('/huya/accounts/sms-code', data, { timeout: 120000 }), smsLogin: (data: HuyaSmsLoginRequest) => @@ -125,6 +133,8 @@ export const huyaApi = { deleteCookie: (id: number) => api.delete(`/huya/cookies/${id}`), deleteCookies: (accountIds: number[]) => api.delete('/huya/cookies/batch', { params: { account_ids: accountIds.join(',') } }), + rebuildWebCookie: (accountId: number) => + api.post(`/huya/cookies/${accountId}/rebuild`), getConfig: () => api.get('/huya/config'), updateConfig: (data: Partial) => api.put('/huya/config', data), listGoods: () => api.get('/huya/goods'), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 57ac55a..1192c1b 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -658,6 +658,7 @@ export interface HuyaPasswordLoginBatchItem { message: string; account?: HuyaAccountItem; sdid?: string; + attempts?: number; } export interface HuyaPasswordLoginBatchResult extends MessageCountResponse { @@ -677,6 +678,9 @@ export interface HuyaPasswordLoginSelectedRequest { assigned_only?: boolean; has_cookie?: boolean; force_new_device?: boolean; + max_attempts?: number; + interval_seconds?: number; + retry_delay_seconds?: number; } export interface HuyaCookieItem { @@ -690,6 +694,7 @@ export interface HuyaCookieItem { created_at: string | null; cookie: string; cookie_preview: string; + has_raw_cookie?: boolean; } export interface HuyaConfig { diff --git a/web/frontend/src/pages/DouyuTasksPage.tsx b/web/frontend/src/pages/DouyuTasksPage.tsx index 72a35ab..5d4502d 100644 --- a/web/frontend/src/pages/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/DouyuTasksPage.tsx @@ -1,2752 +1,2 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - Alert, Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, theme, -} from 'antd'; -import type { TableProps } from 'antd'; -import { - CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, GiftOutlined, - ImportOutlined, QrcodeOutlined, ReloadOutlined, - SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, -} from '@ant-design/icons'; -import { - accountApi, - douyuApi, - type DouyuConfig, - type DouyuGoodsItem, - type DouyuTaskAccountItem, - type DouyuTaskItem, -} from '../api/modules'; -import ExchangeResultImage from '../components/ExchangeResultImage'; -import QrActions from '../components/QrActions'; -import { usePermissions } from '../hooks/usePermissions'; -import { useWebSocketLogs } from '../hooks/useWebSocketLogs'; -import { - clipboardImageSupported, - copyImageToClipboard, - exchangeTaskSucceeded, - getExchangeImage, -} from '../utils/exchangeImage'; -import { getErrorMessage } from '../utils/error'; -import { formatTime } from '../utils/time'; -import { message } from '../utils/antdMessage'; - -const { Text } = Typography; - -type HandbookKind = 'elite' | 'esports' | 'peace'; - -const TASK_STATUS_COLORS: Record = { - planned: 'default', - pending: 'default', - running: 'processing', - success: 'success', - failed: 'error', - error: 'error', - stopped: 'warning', -}; - -const TASK_STATUS_LABELS: Record = { - planned: '已计划', - pending: '等待中', - running: '执行中', - success: '成功', - failed: '失败', - error: '异常', - stopped: '已停止', -}; - -const ELITE_QUICK_ACTIONS = [ - { key: 'get_bind_qr', icon: }, - { key: 'confirm_bind', icon: }, - { key: 'create_elite_qr', icon: }, - { key: 'query_points', icon: }, - { key: 'query_game_name', icon: }, - { key: 'query_change_bind_time', icon: }, - { key: 'query_limited_goods', icon: }, - { key: 'query_gold_balance', icon: }, - { key: 'lock_goods', icon: }, - { key: 'exchange_goods', icon: }, - { key: 'create_gold_qr', icon: }, - { key: 'donate_elite_gift', icon: }, -]; - -const ESPORTS_QUICK_ACTIONS = [ - { key: 'prepare_esports_bind', icon: }, - { key: 'query_esports_game_name', icon: }, - { key: 'create_esports_qr', icon: }, - { key: 'query_esports_points', icon: }, - { key: 'query_change_bind_time', icon: }, - { key: 'query_limited_goods', icon: }, - { key: 'query_gold_balance', icon: }, - { key: 'refresh_esports_goods', icon: }, - { key: 'exchange_esports_goods', icon: }, - { key: 'create_gold_qr', icon: }, - { key: 'donate_esports_chicken_gift', icon: }, - { key: 'donate_esports_firework_gift', icon: }, -]; - -const PEACE_QUICK_ACTIONS = [ - { key: 'get_xpd_bind_qr', icon: }, - { key: 'query_xpd_bind_info', icon: }, - { key: 'confirm_xpd_bind', icon: }, - { key: 'query_xpd_role', icon: }, - { key: 'query_xpd_balance', icon: }, - { key: 'query_xpd_fragments', icon: }, - { key: 'query_xpd_purchase_records', icon: }, - { key: 'refresh_xpd_goods', icon: }, - { key: 'exchange_xpd_goods', icon: }, -]; - -const ELITE_TASK_TYPES = new Set([ - 'get_bind_qr', - 'confirm_bind', - 'create_elite_qr', - 'create_gold_qr', - 'donate_elite_gift', - 'query_points', - 'lock_goods', - 'pay_locked_order', - 'exchange_goods', - 'query_game_name', - 'query_change_bind_time', - 'query_limited_goods', - 'query_gold_balance', - 'refresh_goods', - 'query_exchange_records', - 'prefetch_csrf_token', -]); -const ESPORTS_TASK_TYPES = new Set([ - 'prepare_esports_bind', - 'get_esports_bind_qr', - 'query_esports_game_name', - 'confirm_esports_bind', - 'create_esports_qr', - 'query_esports_points', - 'query_gold_balance', - 'query_change_bind_time', - 'query_limited_goods', - 'refresh_esports_goods', - 'exchange_esports_goods', - 'create_gold_qr', - 'donate_esports_chicken_gift', - 'donate_esports_firework_gift', -]); -const PEACE_TASK_TYPES = new Set([ - 'get_xpd_bind_qr', - 'query_xpd_bind_info', - 'confirm_xpd_bind', - 'query_xpd_role', - 'refresh_xpd_goods', - 'query_xpd_balance', - 'query_xpd_fragments', - 'query_xpd_purchase_records', - 'exchange_xpd_goods', -]); -const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount'; -const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count'; -const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode'; -// v2 避开旧版页面切换时写入其他工作台的共享账号列表。 -const DOUYU_WORKBENCH_IDS_STORAGE_KEY = (kind: HandbookKind) => `douyu_task_workbench_ids_v2_${kind}`; -const CONFIRM_FAIL_TIP_SECONDS = 6; - -function resultText(result: Record | null | undefined, key: string): string { - const value = result?.[key]; - return typeof value === 'string' ? value : ''; -} - -function taskPayloadText(task: DouyuTaskItem, key: string): string { - const payload = task.result?.payload; - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return ''; - return resultText(payload as Record, key); -} - -function resultFlag(result: Record | null | undefined, key: string): boolean { - const value = result?.[key]; - if (value === true) return true; - if (value === false || value == null) return false; - const text = String(value).trim().toLowerCase(); - return text === '1' || text === 'true' || text === 'yes'; -} - -// 提取任务结果中的二维码 URL:绑定/电竞绑定用 url,支付类用 pay_url -function taskQrUrl(task: DouyuTaskItem | null | undefined): string { - if (!task) return ''; - if (task.task_type === 'get_bind_qr') return resultText(task.result, 'url'); - if (task.task_type === 'get_xpd_bind_qr') return resultText(task.result, 'url'); - if (['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) { - return resultText(task.result, 'pay_url'); - } - if (['prepare_esports_bind', 'get_esports_bind_qr'].includes(task.task_type)) { - return resultText(task.result, 'url'); - } - return ''; -} - -function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean { - if (!task) return false; - if (task.task_type === 'get_xpd_bind_qr') { - if (resultFlag(task.result, 'xpd_bound') || resultFlag(task.result, 'bind_confirmed')) return false; - // 轮询识别到角色后停在待确认(xpd_pending_confirm),有角色名即可确认绑定 - return Boolean(resultText(task.result, 'role_name')); - } - if (task.task_type !== 'get_bind_qr') return false; - if (task.result?.bind_ready_for_confirm === true) return true; - const roleName = resultText(task.result, 'role_name'); - if (!roleName) return false; - // 已确认绑定的角色不能再次直接确认 - if (resultFlag(task.result, 'bind_confirmed') || resultFlag(task.result, 'is_bound_act')) { - return false; - } - return true; -} - -function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean { - if (!task) return false; - if (!['get_bind_qr', 'get_xpd_bind_qr'].includes(task.task_type)) return false; - // 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板 - return Boolean(resultText(task.result, 'url')) - || bindReadyForConfirm(task) - || resultFlag(task.result, 'esports_bound'); -} - -function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean { - return Boolean( - task - && ['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type) - && resultText(task.result, 'pay_url'), - ); -} - -function paymentArrived(task: DouyuTaskItem | null | undefined): boolean { - if (!task || !['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) return false; - if (task.status !== 'success') return false; - return task.result?.gold_recharged === true - || task.result?.elite_opened === true - || task.result?.esports_opened === true; -} - -function bindPhaseText(phase: string, polling: boolean): string { - if (phase === 'role_ready') return '已识别角色,待确认'; - if (phase === 'change_waiting') return '换绑冷却中'; - if (phase === 'role_timeout') return '未检测到角色'; - if (phase === 'stopped') return '任务已停止'; - if (phase === 'confirmed') return '已绑定'; - if (phase === 'confirm_failed') return '确认未生效'; - if (polling || phase === 'waiting_scan') return '等待扫码绑定'; - return phase || '等待绑定'; -} - -function goodsLabel(item: DouyuGoodsItem): string { - const stock = item.raw?.count; - const stockText = - stock != null && Number.isFinite(Number(stock)) ? ` / 库存${stock}` : ''; - return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}${stockText}`; -} - -// 道聚城 iPrice 为 10 倍点券单位(如 6800=680 点券),仅展示层换算,兑换请求仍传原值。 -const XPD_POINT_PRICE_SCALE = 10; - -function xpdPointPrice(item: DouyuGoodsItem): number { - const xpd = item as DouyuGoodsItem & { price?: number | null }; - const raw = item.raw?.raw as Record | undefined; - const rawPrice = Number(xpd.price ?? raw?.iPrice); - if (!Number.isFinite(rawPrice) || rawPrice <= 0) return 0; - return Math.round(rawPrice / XPD_POINT_PRICE_SCALE); -} - -function xpdGoodsLabel(item: DouyuGoodsItem): string { - const xpd = item as DouyuGoodsItem & { price?: number | null; goods_left?: number | null }; - const raw = item.raw?.raw as Record | undefined; - const pointPrice = xpdPointPrice(item); - const fragmentPrice = Number(raw?.iJb2Price); - const prices = [ - Number.isFinite(pointPrice) && pointPrice > 0 ? `${pointPrice}点券` : '', - Number.isFinite(fragmentPrice) && fragmentPrice > 0 ? `${fragmentPrice}碎片` : '', - ].filter(Boolean); - const stock = xpd.goods_left; - const stockText = stock != null && Number.isFinite(Number(stock)) - ? (Number(stock) < 0 ? '库存未公开' : `库存${stock}`) - : '库存未知'; - return `${item.name || item.commodity_id}${prices.length ? ` / ${prices.join(' 或 ')}` : ' / 暂不可兑换'} / ${stockText}`; -} - -function xpdGoodsPaymentOptions(item: DouyuGoodsItem | undefined): Array<{ value: '1' | '5'; label: string }> { - if (!item) return []; - const raw = item.raw?.raw as Record | undefined; - const pointPrice = xpdPointPrice(item); - const fragmentPrice = Number(raw?.iJb2Price); - return [ - Number.isFinite(pointPrice) && pointPrice > 0 - ? { value: '1' as const, label: `使用点券兑换(${pointPrice}点券)` } - : null, - Number.isFinite(fragmentPrice) && fragmentPrice > 0 - ? { value: '5' as const, label: `使用扭蛋碎片兑换(${fragmentPrice}碎片)` } - : null, - ].filter((option): option is { value: '1' | '5'; label: string } => option !== null); -} - -function formatWaitSeconds(seconds: number | null | undefined): string { - if (seconds == null || Number.isNaN(Number(seconds))) return ''; - const total = Math.max(0, Math.floor(Number(seconds))); - if (total <= 0) return '可换绑'; - const days = Math.floor(total / 86400); - const hours = Math.floor((total % 86400) / 3600); - const minutes = Math.floor((total % 3600) / 60); - const secs = total % 60; - if (days > 0) return `${days}天${hours}小时${minutes}分`; - if (hours > 0) return `${hours}小时${minutes}分${secs}秒`; - return `${minutes}分${secs}秒`; -} - -function esportsRebindStatus(canChangeTime: number | null | undefined): { - available: boolean; - text: string; -} | null { - if (canChangeTime == null || Number.isNaN(Number(canChangeTime))) return null; - const timestamp = Math.floor(Number(canChangeTime)); - if (timestamp <= 0 || timestamp <= Math.floor(Date.now() / 1000)) { - return { available: true, text: '可换绑' }; - } - return { - available: false, - text: new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false }), - }; -} - -function resultNumber(result: Record | null | undefined, key: string): number | null { - const value = result?.[key]; - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim()) { - const n = Number(value); - return Number.isFinite(n) ? n : null; - } - return null; -} - -function limitedGoodsName(item: unknown): string { - if (!item || typeof item !== 'object') return ''; - const record = item as Record; - const value = record.commodityName ?? record.name; - return typeof value === 'string' ? value : ''; -} - -function limitedGoodsTaskText(task: DouyuTaskItem | null | undefined): string { - if (!task) return ''; - if (task.message?.trim()) return task.message.trim(); - const count = resultNumber(task.result, 'limited_count'); - if (count == null) return ''; - if (count <= 0) return '无限制商品'; - const goods = task.result?.limited_goods; - const names = Array.isArray(goods) - ? goods.map(limitedGoodsName).filter(Boolean).slice(0, 5) - : []; - return names.length ? `限兑 ${count} 个: ${names.join(', ')}` : `限兑 ${count} 个`; -} - -function savedPositiveInteger(key: string, fallback = 1): number { - const value = Number(localStorage.getItem(key)); - return Number.isInteger(value) && value >= 1 ? value : fallback; -} - -export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) { - const isPeaceHandbook = handbook === 'peace'; - const isEsportsHandbook = handbook === 'esports'; - const handbookTitle = isPeaceHandbook - ? '和平小店工作台' - : (isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台'); - const handbookDescription = isPeaceHandbook - ? '和平小店绑定角色、商品与点券余额查询' - : (isEsportsHandbook - ? '电竞手册角色绑定与开通任务' - : '精英宝典绑定、开通、积分与兑换任务'); - const activeTaskTypes = isPeaceHandbook - ? PEACE_TASK_TYPES - : (isEsportsHandbook ? ESPORTS_TASK_TYPES : ELITE_TASK_TYPES); - const quickActions = isPeaceHandbook - ? PEACE_QUICK_ACTIONS - : (isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS); - const { can } = usePermissions(); - const { token } = theme.useToken(); - const [accounts, setAccounts] = useState([]); - // 工作台账号归属由服务端按用户保存;localStorage 仅用于首次升级迁移旧浏览器数据。 - const [workbenchIds, setWorkbenchIds] = useState([]); - const [workbenchReady, setWorkbenchReady] = useState(false); - const [selectedIds, setSelectedIds] = useState([]); - const [goods, setGoods] = useState([]); - const [tasks, setTasks] = useState([]); - const [taskTypes, setTaskTypes] = useState>({}); - const [config, setConfig] = useState(null); - const [goldRechargeChannel, setGoldRechargeChannel] = useState(null); - const [loading, setLoading] = useState(false); - const [runningBatchIds, setRunningBatchIds] = useState>(new Set()); - const [configOpen, setConfigOpen] = useState(false); - const [concurrency, setConcurrency] = useState(3); - const [goldAmount, setGoldAmount] = useState(() => savedPositiveInteger(DOUYU_GOLD_AMOUNT_STORAGE_KEY)); - const [giftCount, setGiftCount] = useState(() => savedPositiveInteger(DOUYU_GIFT_COUNT_STORAGE_KEY)); - const [selectedGoodsId, setSelectedGoodsId] = useState(''); - const [xpdPayType, setXpdPayType] = useState<'1' | '5'>('1'); - const [accountSearch, setAccountSearch] = useState(''); - const [layoutMode, setLayoutMode] = useState<'stack' | 'split'>(() => { - const v = localStorage.getItem(DOUYU_LAYOUT_MODE_STORAGE_KEY); - return v === 'split' ? 'split' : 'stack'; - }); - const accountTableAreaRef = useRef(null); - const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(360); - const [accountPageSize, setAccountPageSize] = useState(() => { - const v = Number(localStorage.getItem('douyu_task_account_page_size')); - return [10, 20, 50, 100].includes(v) ? v : 20; - }); - const [accountPage, setAccountPage] = useState(1); - const [accountTotal, setAccountTotal] = useState(0); - const tasksLoadingRef = useRef(false); - - const [importOpen, setImportOpen] = useState(false); - const [importPool, setImportPool] = useState([]); - const [importSearch, setImportSearch] = useState(''); - const [importTag, setImportTag] = useState(''); - const [importTags, setImportTags] = useState([]); - const [importSelectedIds, setImportSelectedIds] = useState([]); - const [importLoading, setImportLoading] = useState(false); - const [importAllLoading, setImportAllLoading] = useState(false); - const [importPage, setImportPage] = useState(1); - const [importTotal, setImportTotal] = useState(0); - const importPageSize = 20; - - // 绑定/支付弹窗以 task 为唯一数据源;二维码弹窗支持多账号并行(Tabs 切换) - const [qrTaskIds, setQrTaskIds] = useState([]); - const [activeQrTaskId, setActiveQrTaskId] = useState(null); - const [esportsBindTask, setEsportsBindTask] = useState(null); - const [payTask, setPayTask] = useState(null); - const qrCanvasWrapRef = useRef(null); - const esportsQrCanvasWrapRef = useRef(null); - const payQrCanvasWrapRef = useRef(null); - const autoOpenedQrTaskIds = useRef>(new Set()); - const autoOpenedEsportsBindTaskIds = useRef>(new Set()); - const autoOpenedPayTaskIds = useRef>(new Set()); - const autoCopiedExchangeTaskIds = useRef>(new Set()); - const autoNotifiedConfirmFailedTaskIds = useRef>(new Set()); - const [confirmFailTip, setConfirmFailTip] = useState<{ taskId: number; message: string } | null>(null); - const [confirmFailCountdown, setConfirmFailCountdown] = useState(0); - const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null); - const [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState(null); - const [nowSeconds, setNowSeconds] = useState(0); - const locallyStartedBatchIds = useRef>(new Set()); - const autoOpenQrReady = useRef(false); - const autoOpenEsportsBindReady = useRef(false); - const autoOpenPayReady = useRef(false); - - const logs = useWebSocketLogs(); - const canConfig = can('douyu:config'); - useEffect(() => { - const updateNow = () => setNowSeconds(Math.floor(Date.now() / 1000)); - updateNow(); - const timer = window.setInterval(updateNow, 1000); - return () => window.clearInterval(timer); - }, []); - const latestQueryGameTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (task.task_type !== 'query_game_name') continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [tasks]); - - const latestConfirmBindTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (task.task_type !== 'confirm_bind') continue; - if (!task.result) continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [tasks]); - - const latestXpdQueryTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (!['query_xpd_bind_info', 'query_xpd_role'].includes(task.task_type)) continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [tasks]); - - const latestConfirmXpdBindTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (task.task_type !== 'confirm_xpd_bind') continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [tasks]); - - const latestConfirmBindFailureByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (task.task_type !== 'confirm_bind') continue; - if (!['failed', 'error'].includes(task.status)) continue; - if (!(task.message || '').includes('待绑定游戏账号')) continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [tasks]); - - const latestEsportsStateTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (!['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind'] - .includes(task.task_type) || !task.result) continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [tasks]); - - const visibleTasks = useMemo( - () => tasks.filter((task) => activeTaskTypes.has(task.task_type)), - [activeTaskTypes, tasks], - ); - - // qrTask 由 activeQrTaskId 从最新 tasks 中派生,切换 Tab 即切换关注账号 - const qrTask = useMemo( - () => (activeQrTaskId == null ? null : tasks.find((t) => t.id === activeQrTaskId) || null), - [tasks, activeQrTaskId], - ); - - // 换绑时间:优先最近一次查询/拦截/确认成功结果,回退账号落库字段 - const latestChangeWaitByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - const taskTypes = isEsportsHandbook - ? ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind'] - : ['query_change_bind_time', 'get_bind_qr', 'confirm_bind']; - if (!taskTypes.includes(task.task_type)) continue; - if (!['success', 'failed'].includes(task.status)) continue; - if ( - !isEsportsHandbook - && task.task_type === 'confirm_bind' - && ( - task.status !== 'success' - || !( - resultFlag(task.result, 'bind_confirmed') - || resultFlag(task.result, 'is_bound_act') - ) - ) - ) continue; - const wait = resultNumber(task.result, 'change_role_wait_time'); - const canChangeTime = resultNumber(task.result, 'can_change_time'); - // get_bind_qr 只有冷却拦截时才有 wait;无 wait 时跳过,避免覆盖 - if (!isEsportsHandbook && task.task_type === 'get_bind_qr' && wait == null) continue; - const existing = map.get(task.account_id); - if (existing && existing.taskId > task.id) continue; - const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null; - const text = esportsStatus?.text || resultText(task.result, 'change_role_wait_text') || formatWaitSeconds(wait); - map.set(task.account_id, { wait, canChangeTime, text, taskId: task.id }); - } - return map; - }, [isEsportsHandbook, tasks]); - - const latestLimitedGoodsTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (task.task_type !== 'query_limited_goods') continue; - if (!['success', 'failed', 'error'].includes(task.status)) continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [tasks]); - - // 每个账号最近一条任务(当前手册类型),用于账号表"最近结果"列快速查看二维码 - const latestTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of visibleTasks) { - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [visibleTasks]); - - // 每个账号最近一条兑换成功任务,用于"兑换图片"列 - const latestExchangeTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of visibleTasks) { - if (!exchangeTaskSucceeded(task)) continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [visibleTasks]); - - const latestLockedTaskByAccount = useMemo(() => { - const paidOrders = new Set( - visibleTasks - .filter((task) => task.task_type === 'pay_locked_order' && task.status === 'success') - .map((task) => `${task.account_id}:${resultText(task.result, 'order_id')}`) - .filter(Boolean), - ); - const map = new Map(); - for (const task of visibleTasks) { - if (task.task_type !== 'lock_goods' || task.status !== 'success') continue; - const orderId = resultText(task.result, 'order_id'); - if (!orderId || paidOrders.has(`${task.account_id}:${orderId}`)) continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [visibleTasks]); - - const latestXpdPurchaseRecordsTaskByAccount = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - if (task.task_type !== 'query_xpd_purchase_records') continue; - const previous = map.get(task.account_id); - if (!previous || task.id > previous.id) map.set(task.account_id, task); - } - return map; - }, [tasks]); - - const loadData = useCallback(async () => { - setLoading(true); - try { - const [accResult, goodsResult, taskResult, cfgResult, typeResult, rechargeChannelResult] = await Promise.allSettled([ - workbenchIds.length > 0 - ? douyuApi.listAccountsPaged({ - ids: workbenchIds.join(','), - search: accountSearch.trim() || undefined, - page: accountPage, - page_size: accountPageSize, - }) - : Promise.resolve({ items: [] as DouyuTaskAccountItem[], total: 0, page: accountPage, page_size: accountPageSize }), - isPeaceHandbook - ? douyuApi.listPeaceGoods() - : (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()), - douyuApi.listTasks(handbook), - canConfig ? douyuApi.getConfig() : Promise.resolve(null), - douyuApi.taskTypes(), - douyuApi.getRechargeChannel(), - ]); - if (accResult.status === 'fulfilled') { - setAccounts(accResult.value.items); - setAccountTotal(accResult.value.total); - if (accountPage > 1 && accResult.value.items.length === 0) { - setAccountPage(Math.max(1, Math.ceil(accResult.value.total / accountPageSize))); - } - } - if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value); - if (taskResult.status === 'fulfilled') { - setTasks(taskResult.value); - // 首次加载只压制历史的复制/失败提示。二维码只会因当前页面创建的批次自动打开。 - if (!autoOpenQrReady.current) { - for (const task of taskResult.value) { - // 历史兑换成功任务不触发自动复制,避免页面加载时打扰 - if (exchangeTaskSucceeded(task)) autoCopiedExchangeTaskIds.current.add(task.id); - // 历史确认绑定失败任务不重复弹提示(只提示一次) - if ( - task.task_type === 'confirm_bind' - && ['failed', 'error'].includes(task.status) - && (task.message || '').includes('待绑定游戏账号') - ) { - autoNotifiedConfirmFailedTaskIds.current.add(task.id); - } - } - autoOpenQrReady.current = true; - autoOpenEsportsBindReady.current = true; - autoOpenPayReady.current = true; - } - } - if (cfgResult.status === 'fulfilled' && cfgResult.value) setConfig(cfgResult.value); - if (typeResult.status === 'fulfilled') setTaskTypes(typeResult.value); - if (rechargeChannelResult.status === 'fulfilled') { - setGoldRechargeChannel(rechargeChannelResult.value.gold_recharge_channel); - } - } finally { - setLoading(false); - } - }, [accountPage, accountPageSize, accountSearch, canConfig, handbook, isEsportsHandbook, isPeaceHandbook, workbenchIds]); - - const loadTasks = useCallback(async () => { - if (tasksLoadingRef.current) return; - tasksLoadingRef.current = true; - try { - const data = await douyuApi.listTasks(handbook); - setTasks(data); - if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) { - // 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住 - setRunningBatchIds(new Set()); - } - } catch { - // 轮询失败不打扰操作 - } finally { - tasksLoadingRef.current = false; - } - }, [handbook]); - - useEffect(() => { loadData(); }, [loadData]); - - useEffect(() => { - let cancelled = false; - void douyuApi.listWorkbenchAccounts(handbook) - .then(async ({ account_ids, configured }) => { - if (cancelled) return; - if (configured) { - setWorkbenchIds(account_ids); - localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)); - return; - } - // 仅在服务端尚未保存时迁移当前浏览器的旧工作台列表,避免覆盖其他设备的配置。 - const legacy = (localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)) || '') - .split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1); - if (legacy.length === 0) return; - const saved = await douyuApi.updateWorkbenchAccounts(handbook, legacy); - if (!cancelled) { - setWorkbenchIds(saved.account_ids); - localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)); - } - }) - .catch((error) => { - if (!cancelled) message.error(`加载工作台账号失败: ${getErrorMessage(error)}`); - }) - .finally(() => { - if (!cancelled) setWorkbenchReady(true); - }); - return () => { cancelled = true; }; - }, [handbook]); - - useEffect(() => { - localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode); - }, [layoutMode]); - - // 监听账号表格区域高度变化,动态计算 scroll.y 实现表体内部滚动 - useEffect(() => { - const node = accountTableAreaRef.current; - if (!node) return; - const update = () => setAccountTableAreaHeight(Math.max(200, Math.floor(node.getBoundingClientRect().height))); - update(); - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); - } - const observer = new ResizeObserver(update); - observer.observe(node); - return () => observer.disconnect(); - }, [layoutMode]); - - useEffect(() => { - localStorage.setItem(DOUYU_GOLD_AMOUNT_STORAGE_KEY, String(goldAmount)); - }, [goldAmount]); - - useEffect(() => { - localStorage.setItem(DOUYU_GIFT_COUNT_STORAGE_KEY, String(giftCount)); - }, [giftCount]); - - const hasActiveTasks = useMemo( - () => visibleTasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)), - [visibleTasks], - ); - - // 正在执行任务的账号集合,用于按账号维度禁用,避免同一账号并发跑不同类型任务 - const runningAccountIds = useMemo(() => { - const set = new Set(); - for (const task of visibleTasks) { - if (['pending', 'running', 'planned'].includes(task.status)) { - set.add(task.account_id); - } - } - return set; - }, [visibleTasks]); - - // 从后端任务派生正在运行的批次 ID(刷新页面后仍可恢复,用于显示停止按钮) - const activeBatchIds = useMemo(() => { - const set = new Set(); - for (const task of visibleTasks) { - if (['pending', 'running', 'planned'].includes(task.status) && task.batch_id) { - set.add(task.batch_id); - } - } - return set; - }, [visibleTasks]); - - const selectedHasRunning = selectedIds.some((id) => runningAccountIds.has(id)); - - // 任务状态由 WS(level=task) 即时推送;轮询仅作兜底(多标签页/断线重连), - // 有活跃任务或 WS 连接时 5 秒一次,空闲 30 秒轻量刷新 - useEffect(() => { - const intervalMs = hasActiveTasks || logs.connected || runningBatchIds.size > 0 ? 5000 : 30000; - const timer = setInterval(loadTasks, intervalMs); - return () => clearInterval(timer); - }, [hasActiveTasks, loadTasks, logs.connected, runningBatchIds]); - - const openQrTask = useCallback((task: DouyuTaskItem, activate = true) => { - autoOpenedQrTaskIds.current.add(task.id); - setQrTaskIds((prev) => (prev.includes(task.id) ? prev : [...prev, task.id])); - if (activate) setActiveQrTaskId(task.id); - // 绑定二维码与支付/电竞面板互斥,避免两个 Modal 叠加混淆 - setEsportsBindTask(null); - setPayTask(null); - }, []); - - const closeQrTask = useCallback((taskId: number) => { - setQrTaskIds((prev) => { - const next = prev.filter((id) => id !== taskId); - setActiveQrTaskId((cur) => (cur === taskId ? (next.length > 0 ? next[next.length - 1] : null) : cur)); - return next; - }); - }, []); - - const closeAllQrTasks = useCallback(() => { - setQrTaskIds([]); - setActiveQrTaskId(null); - }, []); - - const openEsportsBindTask = useCallback((task: DouyuTaskItem) => { - autoOpenedEsportsBindTaskIds.current.add(task.id); - closeAllQrTasks(); - setPayTask(null); - setEsportsBindTask(task); - }, [closeAllQrTasks]); - - const openPayTask = useCallback((task: DouyuTaskItem) => { - autoOpenedPayTaskIds.current.add(task.id); - closeAllQrTasks(); - setEsportsBindTask(null); - setPayTask(task); - }, [closeAllQrTasks]); - - // 自动弹出最新绑定二维码(running 有 url 即可);多账号并行,不互斥 - useEffect(() => { - if (!autoOpenQrReady.current) return; - const candidates = visibleTasks - .filter((task) => ( - locallyStartedBatchIds.current.has(task.batch_id) - && hasBindQrcode(task) - && !autoOpenedQrTaskIds.current.has(task.id) - )) - .sort((a, b) => b.id - a.id); - if (candidates.length === 0) return; - // 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的 - for (const candidate of candidates) openQrTask(candidate, false); - setActiveQrTaskId((cur) => cur ?? candidates[0].id); - }, [openQrTask, visibleTasks]); - - // 电竞手册先查状态再打开独立绑定面板,不把切换角色二维码当作主流程。 - useEffect(() => { - if (!autoOpenEsportsBindReady.current || esportsBindTask) return; - const nextTask = visibleTasks - .filter((task) => ( - locallyStartedBatchIds.current.has(task.batch_id) - && task.task_type === 'prepare_esports_bind' - && task.result?.esports_bind_dialog === true - && !autoOpenedEsportsBindTaskIds.current.has(task.id) - )) - .sort((a, b) => b.id - a.id)[0]; - if (nextTask) openEsportsBindTask(nextTask); - }, [esportsBindTask, openEsportsBindTask, visibleTasks]); - - // 自动弹出支付二维码 - useEffect(() => { - if (!autoOpenPayReady.current || payTask) return; - const nextPayTask = visibleTasks - .filter((task) => ( - locallyStartedBatchIds.current.has(task.batch_id) - && hasPaymentQrcode(task) - && !paymentArrived(task) - && !autoOpenedPayTaskIds.current.has(task.id) - )) - .sort((a, b) => b.id - a.id)[0]; - if (nextPayTask) openPayTask(nextPayTask); - }, [openPayTask, payTask, visibleTasks]); - - // 兑换成功后自动生成结果图片并复制到剪贴板(批量时最后完成的覆盖剪贴板) - useEffect(() => { - if (!autoOpenQrReady.current) return; - const pending = visibleTasks - .filter((task) => exchangeTaskSucceeded(task) && !autoCopiedExchangeTaskIds.current.has(task.id)) - .sort((a, b) => a.id - b.id); - if (pending.length === 0) return; - for (const task of pending) autoCopiedExchangeTaskIds.current.add(task.id); - const latest = pending[pending.length - 1]; - void getExchangeImage(latest) - .then(({ blob }) => copyImageToClipboard(blob)) - .then( - () => message.success(`兑换图片已复制到剪贴板:${latest.account_nickname || latest.account_username || `#${latest.account_id}`}`), - (error) => { - if (clipboardImageSupported()) { - message.warning('兑换图片已生成,复制到剪贴板失败'); - } else { - message.info('当前环境不支持复制图片,请在兑换图片列点击查看'); - } - console.warn('复制兑换图片失败', error); - }, - ); - }, [visibleTasks]); - - // 确认绑定失败自动弹提示(页面中央弹窗,自动消失,一次性去重) - useEffect(() => { - if (!autoOpenQrReady.current) return; - const failedTasks = visibleTasks - .filter((task) => ( - task.task_type === 'confirm_bind' - && ['failed', 'error'].includes(task.status) - && (task.message || '').includes('待绑定游戏账号') - && !autoNotifiedConfirmFailedTaskIds.current.has(task.id) - )) - .sort((a, b) => b.id - a.id); - if (failedTasks.length === 0) return; - for (const task of failedTasks) autoNotifiedConfirmFailedTaskIds.current.add(task.id); - const latest = failedTasks[0]; - setConfirmFailTip({ taskId: latest.id, message: latest.message }); - setQrTaskIds((prev) => { - const next = prev.filter((id) => { - const task = visibleTasks.find((item) => item.id === id); - return !task || task.account_id !== latest.account_id; - }); - setActiveQrTaskId((cur) => (cur != null && !next.includes(cur) ? (next[next.length - 1] ?? null) : cur)); - return next; - }); - }, [visibleTasks]); - - // 弹窗倒计时自动关闭 - useEffect(() => { - if (!confirmFailTip) return; - setConfirmFailCountdown(CONFIRM_FAIL_TIP_SECONDS); - const timer = setInterval(() => { - setConfirmFailCountdown((cur) => { - if (cur <= 1) { - clearInterval(timer); - setConfirmFailTip(null); - return 0; - } - return cur - 1; - }); - }, 1000); - return () => clearInterval(timer); - }, [confirmFailTip]); - - const copyExchangePreview = async () => { - if (!exchangePreview) return; - try { - const { blob } = await getExchangeImage(exchangePreview.task); - await copyImageToClipboard(blob); - message.success('已复制到剪贴板'); - } catch (error) { - message.error(getErrorMessage(error)); - } - }; - - // qrTask 已由 useMemo 从 tasks 派生,切换 Tab / 后端 progress 写入后自动更新,无需手动跟随 - - useEffect(() => { - if (!esportsBindTask) return; - const latest = tasks.find((task) => task.id === esportsBindTask.id); - if (latest && latest !== esportsBindTask) setEsportsBindTask(latest); - }, [esportsBindTask, tasks]); - - useEffect(() => { - if (!payTask) return; - const latest = tasks.find((task) => task.id === payTask.id); - if (!latest) return; - if (paymentArrived(latest)) { - setPayTask(null); - return; - } - if (latest !== payTask) setPayTask(latest); - }, [payTask, tasks]); - - // 多批次并发:不再用全局 busy 标志阻塞 UI,刷新频率直接用各子条件判断 - - const selectedXpdGoods = useMemo( - () => goods.find((item) => item.commodity_id === selectedGoodsId), - [goods, selectedGoodsId], - ); - const xpdPaymentOptions = useMemo( - () => xpdGoodsPaymentOptions(selectedXpdGoods), - [selectedXpdGoods], - ); - const selectedXpdGoodsSoldOut = (() => { - const stock = (selectedXpdGoods as (DouyuGoodsItem & { goods_left?: number | null }) | undefined)?.goods_left; - return stock != null && Number.isFinite(Number(stock)) && Number(stock) === 0; - })(); - - useEffect(() => { - if (xpdPaymentOptions.length === 0) return; - if (!xpdPaymentOptions.some((option) => option.value === xpdPayType)) { - setXpdPayType(xpdPaymentOptions[0].value); - } - }, [xpdPayType, xpdPaymentOptions]); - - const startTask = async ( - taskType: string, - accountIds?: number[], - extraPayload: Record = {}, - ) => { - const ids = accountIds || selectedIds; - if (ids.length === 0) { - message.warning('请先选择账号'); - return; - } - // 多批次并发:不再拦截,允许在已有批次运行时继续创建新批次 - if (['lock_goods', 'exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType) && !selectedGoodsId) { - message.warning('请先选择兑换商品'); - return; - } - if (taskType === 'exchange_xpd_goods') { - if (selectedXpdGoodsSoldOut) { - message.warning('该商品库存不足,请刷新商品列表后重试'); - return; - } - if (!xpdPaymentOptions.some((option) => option.value === xpdPayType)) { - message.warning('该商品不支持当前兑换方式'); - return; - } - } - const payload: Record = { ...extraPayload }; - if (['lock_goods', 'exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType)) { - payload.commodity_id = selectedGoodsId; - } - if (taskType === 'exchange_xpd_goods') payload.pay_type = xpdPayType; - if (taskType === 'create_gold_qr') payload.amount = goldAmount; - if (['donate_elite_gift', 'donate_esports_chicken_gift', 'donate_esports_firework_gift'].includes(taskType)) { - payload.gift_count = giftCount; - } - try { - const result = await douyuApi.createTasks({ - account_ids: ids, - task_type: taskType, - handbook_scope: handbook, - concurrency, - payload, - }); - // 只有本页面创建的批次才允许主动弹二维码,防止其他工作台/浏览器的任务打扰当前操作。 - locallyStartedBatchIds.current.add(result.batch_id); - setRunningBatchIds((prev) => new Set(prev).add(result.batch_id)); - logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, { - clear: true, - onResult: () => { - setRunningBatchIds((prev) => { - const next = new Set(prev); - next.delete(result.batch_id); - return next; - }); - void loadData(); - }, - onTask: (rawTask) => { - const task = rawTask as unknown as DouyuTaskItem; - if (task.handbook_scope !== handbook) return; - // 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致) - setTasks((prev) => { - if (prev.some((t) => t.id === task.id)) { - return prev.map((t) => (t.id === task.id ? task : t)); - } - return [task, ...prev].slice(0, 100); - }); - }, - }); - message.success(`已创建 ${result.count} 个任务`); - setTimeout(() => { void loadTasks(); }, 400); - } catch (error) { - message.error(getErrorMessage(error)); - } - }; - - const stopBatch = async () => { - const ids = Array.from(new Set([...runningBatchIds, ...activeBatchIds])); - if (ids.length === 0) return; - try { - await Promise.all(ids.map((id) => douyuApi.stopBatch(id))); - message.success('已停止'); - } catch (error) { - message.error(getErrorMessage(error)); - } - }; - - const saveConfig = async (values: DouyuConfig) => { - try { - const saved = await douyuApi.updateConfig(values); - setConfig(saved); - setGoldRechargeChannel(saved.gold_recharge_channel); - setConfigOpen(false); - message.success('配置已保存'); - } catch (error) { - message.error(getErrorMessage(error)); - } - }; - - // ---- Import ---- - const loadImportPool = useCallback(async () => { - setImportLoading(true); - try { - // 候选池 = 账号库按搜索/标签过滤,剔除已导入工作台的账号 - const result = await douyuApi.listAccountsPaged({ - search: importSearch.trim() || undefined, - tag: importTag || undefined, - page: importPage, - page_size: importPageSize, - }); - setImportPool(result.items.filter((a) => !workbenchIds.includes(a.id))); - setImportTotal(result.total); - } finally { - setImportLoading(false); - } - }, [importPage, importSearch, importTag, workbenchIds]); - useEffect(() => { if (importOpen) void loadImportPool(); }, [importOpen, loadImportPool]); - useEffect(() => { - if (!importOpen) return; - accountApi.listTags().then(setImportTags).catch(() => {}); - }, [importOpen]); - // 切换搜索/标签时清空已选,避免计数与可见行不一致 - useEffect(() => { - setImportPage(1); - setImportSelectedIds([]); - }, [importSearch, importTag]); - useEffect(() => { setImportSelectedIds([]); }, [importPage]); - - const saveWorkbenchIds = async (nextIds: number[]) => { - const saved = await douyuApi.updateWorkbenchAccounts(handbook, nextIds); - setWorkbenchIds(saved.account_ids); - return saved.account_ids; - }; - - const importAccounts = async (ids: number[]) => { - const toImport = importPool.filter((a) => ids.includes(a.id)); - const nextIds = [...new Set([...workbenchIds, ...toImport.map((a) => a.id)])]; - try { - await saveWorkbenchIds(nextIds); - } catch (error) { - message.error(getErrorMessage(error)); - return; - } - setAccounts((prev) => { - const existing = new Set(prev.map((a) => a.id)); - return [...prev, ...toImport.filter((a) => !existing.has(a.id))]; - }); - setImportSelectedIds([]); - setImportOpen(false); - message.success(`已导入 ${toImport.length} 个账号`); - }; - - const importAllAccounts = async () => { - setImportAllLoading(true); - try { - const result = await douyuApi.listAccountIds({ - search: importSearch.trim() || undefined, - tag: importTag || undefined, - }); - const imported = result.account_ids.filter((id) => !workbenchIds.includes(id)); - if (imported.length === 0) { - message.info('当前筛选条件下没有可新增的账号'); - return; - } - await saveWorkbenchIds([...new Set([...workbenchIds, ...imported])]); - setImportSelectedIds([]); - setImportOpen(false); - message.success(`已一键导入 ${imported.length} 个账号`); - } catch (error) { - message.error(getErrorMessage(error)); - } finally { - setImportAllLoading(false); - } - }; - - const removeSelected = async () => { - const sel = new Set(selectedIds); - try { - await saveWorkbenchIds(workbenchIds.filter((id) => !sel.has(id))); - } catch (error) { - message.error(getErrorMessage(error)); - return; - } - setAccounts((prev) => prev.filter((a) => !sel.has(a.id))); - setSelectedIds([]); - message.success(`已移出 ${sel.size} 个账号`); - }; - - // ---- Context menu ---- - const [contextMenu, setContextMenu] = useState<{ - accountIds: number[]; x: number; y: number; - } | null>(null); - - const handleRowContextMenu = (record: DouyuTaskAccountItem, event: React.MouseEvent) => { - event.preventDefault(); - const ids = selectedIds.includes(record.id) ? selectedIds : [record.id]; - setContextMenu({ accountIds: ids, x: event.clientX, y: event.clientY }); - if (!selectedIds.includes(record.id)) setSelectedIds([record.id]); - }; - - useEffect(() => { - if (!contextMenu) return; - const close = () => setContextMenu(null); - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') close(); - }; - window.addEventListener('click', close); - window.addEventListener('scroll', close, true); - window.addEventListener('resize', close); - window.addEventListener('keydown', closeOnEscape); - return () => { - window.removeEventListener('click', close); - window.removeEventListener('scroll', close, true); - window.removeEventListener('resize', close); - window.removeEventListener('keydown', closeOnEscape); - }; - }, [contextMenu]); - - const contextMenuStyle = contextMenu ? { - position: 'fixed' as const, - left: Math.max(8, Math.min(contextMenu.x, window.innerWidth - 220)), - top: Math.max(8, Math.min(contextMenu.y, window.innerHeight - 390)), - zIndex: 1050, - } : { display: 'none' }; - - const runContextAction = (taskType: string) => { - if (!contextMenu) return; - setContextMenu(null); - void startTask(taskType, contextMenu.accountIds); - }; - - // ---- 电竞手册绑定面板 ---- - const esportsRelatedTasks = esportsBindTask - ? tasks - .filter((task) => ( - task.account_id === esportsBindTask.account_id - && task.id >= esportsBindTask.id - && ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind'] - .includes(task.task_type) - )) - .sort((a, b) => b.id - a.id) - : []; - const esportsLatestStateTask = esportsRelatedTasks.find((task) => task.result) || esportsBindTask; - const esportsLatestRoleTask = esportsRelatedTasks.find( - (task) => task.task_type === 'query_esports_game_name', - ) || null; - const esportsLatestQrTask = esportsRelatedTasks.find( - (task) => Boolean(resultText(task.result, 'url')), - ) || null; - const esportsConfirmRunning = esportsRelatedTasks.some( - (task) => task.task_type === 'confirm_esports_bind' && ['planned', 'pending', 'running'].includes(task.status), - ); - const esportsRoleQueryRunning = Boolean( - esportsLatestRoleTask && ['planned', 'pending', 'running'].includes(esportsLatestRoleTask.status), - ); - const esportsResult = esportsLatestStateTask?.result || esportsBindTask?.result || null; - const esportsBound = resultFlag(esportsResult, 'esports_bound') || resultFlag(esportsResult, 'bind_confirmed'); - const esportsRoleName = resultText(esportsResult, 'role_name'); - const esportsAreaName = resultText(esportsResult, 'area_name'); - const esportsPlatName = resultText(esportsResult, 'plat_name'); - const esportsRoleLine = [esportsPlatName, esportsAreaName].filter(Boolean).join(' / '); - const esportsCanChangeRole = Boolean(esportsBindTask && !esportsConfirmRunning); - const esportsCanChangeTime = resultNumber(esportsResult, 'can_change_time'); - const esportsRebind = esportsRebindStatus(esportsCanChangeTime); - const esportsQrUrl = resultText(esportsLatestQrTask?.result, 'url'); - const esportsAccountName = esportsBindTask - ? ( - esportsBindTask.account_nickname - || esportsBindTask.account_username - || esportsBindTask.account_uid - || `#${esportsBindTask.account_id}` - ) - : ''; - const esportsStatusText = esportsBound - ? '电竞手册已绑定' - : esportsConfirmRunning - ? '正在完成电竞手册绑定' - : esportsRoleQueryRunning - ? '正在查询最新角色' - : esportsLatestStateTask?.message || '加载电竞手册绑定状态'; - const esportsCanConfirm = Boolean( - esportsBindTask - && !esportsBound - && esportsRoleName - && !esportsConfirmRunning, - ); - - const queryEsportsRole = () => { - if (!esportsBindTask || esportsRoleQueryRunning) return; - void startTask('query_esports_game_name', [esportsBindTask.account_id]); - }; - - const switchEsportsRole = () => { - if (!esportsBindTask || !esportsCanChangeRole) return; - void startTask('get_esports_bind_qr', [esportsBindTask.account_id]); - }; - - const confirmEsportsBindFromDialog = () => { - if (!esportsBindTask || !esportsCanConfirm) { - message.warning('请先确认当前角色'); - return; - } - void startTask('confirm_esports_bind', [esportsBindTask.account_id]); - }; - - // ---- QR panel derived state ---- - const qrResult = qrTask?.result || null; - const qrUrl = resultText(qrResult, 'url'); - const qrBindPhase = resultText(qrResult, 'bind_phase'); - const qrAutoPolling = qrTask?.status === 'running' && qrResult?.bind_polling === true; - const isXpdBindTask = qrTask?.task_type === 'get_xpd_bind_qr'; - const qrQueryTask = qrTask - ? (isXpdBindTask - ? (latestXpdQueryTaskByAccount.get(qrTask.account_id) || null) - : (latestQueryGameTaskByAccount.get(qrTask.account_id) || null)) - : null; - const qrQueryResult = qrTask && qrQueryTask && qrQueryTask.id > qrTask.id ? qrQueryTask.result : null; - const qrQueryRunning = Boolean(qrQueryTask && qrTask && qrQueryTask.id > qrTask.id && qrQueryTask.status === 'running'); - const qrXpdConfirmTask = isXpdBindTask && qrTask - ? (latestConfirmXpdBindTaskByAccount.get(qrTask.account_id) || null) - : null; - const qrXpdBound = Boolean(qrXpdConfirmTask && qrXpdConfirmTask.status === 'success'); - const qrXpdConfirmRunning = Boolean(qrXpdConfirmTask && ['planned', 'pending', 'running'].includes(qrXpdConfirmTask.status)); - const qrQueryRoleName = resultText(qrQueryResult, 'role_name'); - const qrQueryPendingRoleName = resultText(qrQueryResult, 'pending_role_name'); - const qrQueryIsBoundAct = resultFlag(qrQueryResult, 'is_bound_act') || resultFlag(qrQueryResult, 'bind_confirmed'); - const qrQueryPending = !isXpdBindTask && Boolean( - qrQueryResult - && ( - qrQueryPendingRoleName - || ( - qrQueryRoleName - && ( - qrQueryResult.bind_ready_for_confirm === true - || (!qrQueryIsBoundAct) - ) - ) - ), - ); - const qrBindReady = (bindReadyForConfirm(qrTask) || qrQueryPending) && !qrXpdBound; - const qrRoleSourceResult = qrQueryPending - ? qrQueryResult - : ((bindReadyForConfirm(qrTask) || qrXpdBound) ? qrResult : null); - // 待确认角色优先用 pending_* 字段(已绑定角色之外的扫码新角色) - const qrRoleName = resultText(qrRoleSourceResult, 'pending_role_name') || resultText(qrRoleSourceResult, 'role_name'); - const qrAreaName = resultText(qrRoleSourceResult, 'pending_area_name') || resultText(qrRoleSourceResult, 'area_name'); - const qrPlatName = resultText(qrRoleSourceResult, 'pending_plat_name') || resultText(qrRoleSourceResult, 'plat_name'); - const qrCurrentRoleName = isXpdBindTask - ? resultText(qrResult, 'before_role_name') - : (qrQueryRoleName - || resultText(qrResult, 'current_role_name') - || ( - resultFlag(qrResult, 'is_bound_act') - ? resultText(qrResult, 'role_name') - : '' - ) - || (qrQueryIsBoundAct ? qrQueryRoleName : '')); - const qrCurrentAreaName = isXpdBindTask - ? resultText(qrResult, 'before_area_name') - : (resultText(qrQueryResult, 'area_name') - || resultText(qrResult, 'current_area_name') - || ( - resultFlag(qrResult, 'is_bound_act') - ? resultText(qrResult, 'area_name') - : '' - ) - || (qrQueryIsBoundAct ? resultText(qrQueryResult, 'area_name') : '')); - const qrCurrentPlatName = isXpdBindTask - ? resultText(qrResult, 'before_plat_name') - : (resultText(qrQueryResult, 'plat_name') - || resultText(qrResult, 'current_plat_name') - || ( - resultFlag(qrResult, 'is_bound_act') - ? resultText(qrResult, 'plat_name') - : '' - ) - || (qrQueryIsBoundAct ? resultText(qrQueryResult, 'plat_name') : '')); - const qrRoleLine = qrBindReady - ? [qrPlatName, qrAreaName, qrRoleName].filter(Boolean).join(' - ') - : ''; - const qrBindSummary = resultText(qrResult, 'bind_summary') || resultText(qrQueryResult, 'bind_summary'); - const qrStatusText = qrXpdBound - ? '绑定成功' - : (isXpdBindTask && qrBindReady) - ? '已识别角色,待确认' - : (isXpdBindTask && qrTask?.status === 'failed') - ? '未检测到绑定' - : (isXpdBindTask && qrTask?.status === 'running') - ? '等待扫码绑定' - : qrXpdConfirmRunning - ? '正在确认绑定' - : qrBindReady - ? '已识别角色,待确认' - : qrQueryRunning - ? '查询角色中' - : qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.status === 'success' && qrQueryIsBoundAct - ? '查询结果:仍是当前绑定角色' - : bindPhaseText(qrBindPhase, qrAutoPolling); - const qrAccountName = qrTask - ? (qrTask.account_nickname || qrTask.account_username || qrTask.account_uid || `#${qrTask.account_id}`) - : ''; - - const confirmQrBind = () => { - if (!qrTask || !qrBindReady) { - message.warning('请先扫码识别待确认角色'); - return; - } - const accountId = qrTask.account_id; - const taskId = qrTask.id; - closeQrTask(taskId); - void startTask(isXpdBindTask ? 'confirm_xpd_bind' : 'confirm_bind', [accountId]); - }; - - const queryQrRole = () => { - if (!qrTask) return; - if (qrAutoPolling) { - message.info('正在自动识别角色,请稍候'); - return; - } - void startTask(isXpdBindTask ? 'query_xpd_bind_info' : 'query_game_name', [qrTask.account_id]); - }; - - // Account table - const accountColumns: TableProps['columns'] = [ - { - title: '#', width: 45, - render: (_, __, index) => {index + 1}, - }, - { - title: '账号', dataIndex: 'username', width: 150, - render: (_, record) => ( - - {record.nickname || record.username} - UID {record.uid || '-'} - - ), - }, - { - title: '游戏名', dataIndex: 'game_name', width: 170, - render: (_, record) => { - if (isPeaceHandbook) { - return record.xpd_game_name - ? {record.xpd_game_name} - : 未查; - } - const queryTask = isEsportsHandbook - ? latestEsportsStateTaskByAccount.get(record.id) || null - : latestQueryGameTaskByAccount.get(record.id) || null; - const confirmTask = isEsportsHandbook ? null : latestConfirmBindTaskByAccount.get(record.id) || null; - const confirmSucceeded = Boolean( - confirmTask - && confirmTask.status === 'success' - && ( - resultFlag(confirmTask.result, 'bind_confirmed') - || resultFlag(confirmTask.result, 'is_bound_act') - ), - ); - const stateTask = ( - confirmSucceeded - && (!queryTask || confirmTask!.id > queryTask.id) - ) ? confirmTask : queryTask; - const confirmFailureTask = latestConfirmBindFailureByAccount.get(record.id); - const clearPending = Boolean( - !isEsportsHandbook - && confirmFailureTask - && stateTask - && confirmFailureTask.id > stateTask.id, - ); - const stateResult = stateTask?.result || null; - const stateRoleConfirmed = resultFlag(stateResult, 'is_bound_act') || resultFlag(stateResult, 'bind_confirmed'); - const stateRoleName = clearPending && !stateRoleConfirmed ? '' : resultText(stateResult, 'role_name'); - // role_name = 当前已生效绑定角色;pending_role_name = 扫码后待确认的新角色 - const roleName = stateRoleName - || (isEsportsHandbook ? record.esports_game_name : record.game_name); - const stateChannel = clearPending && !stateRoleConfirmed - ? '' - : [resultText(stateResult, 'area_name'), resultText(stateResult, 'plat_name')] - .filter(Boolean) - .join(' / '); - const pendingRoleName = clearPending || confirmSucceeded ? '' : resultText(stateResult, 'pending_role_name'); - const channel = stateChannel || (isEsportsHandbook ? record.esports_game_channel : record.game_channel); - const pendingChannel = clearPending || confirmSucceeded ? '' : [resultText(stateResult, 'pending_area_name'), resultText(stateResult, 'pending_plat_name')] - .filter(Boolean) - .join(' / '); - if (!roleName && !pendingRoleName) return 未查; - return ( - - {roleName ? ( - - - {pendingRoleName ? `当前: ${roleName}` : roleName} - - {channel ? ( - - {channel} - - ) : null} - - ) : null} - {pendingRoleName ? ( - - - 待确认: {pendingRoleName} - - {pendingChannel ? ( - - {pendingChannel} - - ) : null} - - ) : null} - - ); - }, - }, - { - title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'), - dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'), - width: 80, - render: (v) => v ?? -, - }, - // 和平小店无鱼翅/限制兑换/换绑时间概念(可无限制换绑),展示扭蛋碎片 - ...(isPeaceHandbook - ? [{ - title: '扭蛋碎片', dataIndex: 'xpd_fragments', width: 90, - render: (v: number | null) => v ?? -, - }] as NonNullable['columns']> - : [ - { - title: '鱼翅', dataIndex: 'gold_balance', width: 70, - render: (v: number | null) => v ?? -, - }, - { - title: '限制兑换', dataIndex: 'exchange_balance', width: 150, ellipsis: true, - render: (_, record) => { - const latestLimitedTask = latestLimitedGoodsTaskByAccount.get(record.id); - const text = limitedGoodsTaskText(latestLimitedTask); - if (!text) return 未查; - if (latestLimitedTask?.status !== 'success') { - return {text}; - } - const count = resultNumber(latestLimitedTask.result, 'limited_count'); - if (count === 0 || text === '无限制商品') return 无限制商品; - return {text}; - }, - }, - { - title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130, - render: (_, record) => { - const fromTask = latestChangeWaitByAccount.get(record.id); - const canChangeTime = fromTask?.canChangeTime ?? ( - isEsportsHandbook ? record.esports_can_change_time : null - ); - const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null; - if (esportsStatus) { - return esportsStatus.available - ? 可换绑 - : ( - - 暂不可换绑 - {esportsStatus.text} - - ); - } - const wait = fromTask?.wait ?? ( - isEsportsHandbook ? record.esports_change_role_wait_time : record.change_role_wait_time - ); - const text = fromTask?.text || formatWaitSeconds(wait); - if (wait == null && !text) { - return 未查; - } - if (wait != null && wait <= 0) { - return 可换绑; - } - if (wait != null && wait > 0) { - return ( - - 冷却中 - {text || formatWaitSeconds(wait)} - - ); - } - // 接口没返回数字但有文案 - if (text === '可换绑') return 可换绑; - return {text || '-'}; - }, - sorter: (a, b) => { - const aw = isEsportsHandbook - ? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1 - : latestChangeWaitByAccount.get(a.id)?.wait - ?? (isEsportsHandbook ? a.esports_change_role_wait_time : a.change_role_wait_time) ?? -1; - const bw = isEsportsHandbook - ? latestChangeWaitByAccount.get(b.id)?.canChangeTime ?? b.esports_can_change_time ?? -1 - : latestChangeWaitByAccount.get(b.id)?.wait - ?? (isEsportsHandbook ? b.esports_change_role_wait_time : b.change_role_wait_time) ?? -1; - return aw - bw; - }, - }, - ] as NonNullable['columns']>), - { - title: '最近操作', width: 220, - render: (_, record) => { - const latest = latestTaskByAccount.get(record.id); - if (!latest) return 暂无操作; - return ( - - - - {TASK_STATUS_LABELS[latest.status] || latest.status} - - - {taskTypes[latest.task_type] || latest.task_type} - - - {latest.message ? ( - - {latest.message} - - ) : null} - - {formatTime(latest.finished_at || latest.created_at)} - - - ); - }, - }, - { - title: '二维码', width: 76, align: 'center', - render: (_, record) => { - const task = latestTaskByAccount.get(record.id); - const url = taskQrUrl(task); - if (!url) return null; - const openTask = () => { - if (!task) return; - if (hasBindQrcode(task)) openQrTask(task); - else if (hasPaymentQrcode(task)) openPayTask(task); - else if (task.task_type === 'get_xpd_bind_qr') openQrTask(task); - else openEsportsBindTask(task); - }; - return ( -
- -
- ); - }, - }, - { - title: '兑换图片', width: 80, align: 'center', - render: (_, record) => { - const task = latestExchangeTaskByAccount.get(record.id); - if (!task) return -; - return ( - setExchangePreview({ task: t, url })} - /> - ); - }, - }, - ...(isPeaceHandbook ? [{ - title: '兑换记录', width: 100, align: 'center' as const, - render: (_: unknown, record: DouyuTaskAccountItem) => { - const task = latestXpdPurchaseRecordsTaskByAccount.get(record.id); - const running = task && ['planned', 'pending', 'running'].includes(task.status); - return ( - - ); - }, - }] : []), - ...(!isPeaceHandbook && !isEsportsHandbook ? [{ - title: '锁单', width: 150, align: 'center' as const, - render: (_: unknown, record: DouyuTaskAccountItem) => { - const task = latestLockedTaskByAccount.get(record.id); - if (!task) return -; - const orderId = resultText(task.result, 'order_id'); - const commodityId = resultText(task.result, 'commodity_id'); - const expiresAt = resultNumber(task.result, 'expires_at'); - const expired = expiresAt != null && nowSeconds > 0 && expiresAt <= nowSeconds; - const paymentTask = visibleTasks.find((item) => ( - item.account_id === record.id - && item.task_type === 'pay_locked_order' - && taskPayloadText(item, 'order_id') === orderId - && ['planned', 'pending', 'running'].includes(item.status) - )); - return ( - - {orderId} - - - ); - }, - }] : []), - ]; - - const [configFormValues, setConfigFormValues] = useState(null); - useEffect(() => { - if (configOpen && config) setConfigFormValues({ ...config }); - }, [configOpen, config]); - - const payUrl = resultText(payTask?.result, 'pay_url'); - const goldAmountLabel = '充值金额'; - const isRechargeChannelLoading = goldRechargeChannel === null; - const isSupplierGoldRecharge = goldRechargeChannel === 'supplier_api'; - const goldRechargeChannelLabel = isRechargeChannelLoading - ? '加载中' - : (isSupplierGoldRecharge ? '供应商 API 直充' : '微信扫码'); - const goldRechargeButtonLabel = isRechargeChannelLoading - ? '加载中' - : (isSupplierGoldRecharge ? 'API直充鱼翅' : '扫码充值鱼翅'); - const payAccountName = payTask - ? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`) - : ''; - const selectedText = selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '请先勾选账号'; - const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]); - const actionDisabled = (taskType: string) => { - if (selectedIds.length === 0) return true; - if (taskType === 'exchange_xpd_goods') { - return !selectedGoodsId - || selectedXpdGoodsSoldOut - || !xpdPaymentOptions.some((option) => option.value === xpdPayType); - } - if (['lock_goods', 'exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId; - // 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段) - return selectedHasRunning; - }; - const renderActionButton = (taskType: string, type: 'default' | 'primary' = 'default') => { - const item = actionByKey.get(taskType); - return ( - - ); - }; - const sectionStyle = { - border: `1px solid ${token.colorBorderSecondary}`, - borderRadius: 6, - padding: 8, - background: token.colorBgContainer, - }; - const sectionTitleStyle = { - display: 'flex', - alignItems: 'center', - gap: 6, - marginBottom: 8, - fontWeight: 600, - }; - const actionGridStyle = { - display: 'grid', - gridTemplateColumns: '1fr 1fr', - gap: 6, - }; - const compactOperationGridStyle = { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', - gap: 8, - }; - const operationExtra = ( - - {selectedText} - - - setConcurrency(v || 1)} - style={{ width: 70 }} - /> - - - ); - const operationBody = ( - <> - {isPeaceHandbook ? ( -
-
-
- - 小店查询 -
-
- {renderActionButton('get_xpd_bind_qr', 'primary')} - {renderActionButton('query_xpd_bind_info', 'primary')} - {renderActionButton('confirm_xpd_bind', 'primary')} - {renderActionButton('query_xpd_role', 'primary')} - {renderActionButton('query_xpd_balance', 'primary')} - {renderActionButton('query_xpd_fragments', 'primary')} -
-
-
-
- - 小店商品 -
- - option.value === xpdPayType) ? xpdPayType : undefined} - onChange={setXpdPayType} - options={xpdPaymentOptions} - placeholder={selectedGoodsId ? '该商品暂无可用兑换方式' : '先选择商品'} - disabled={!selectedGoodsId || xpdPaymentOptions.length === 0} - style={{ width: '100%' }} - /> -
- {renderActionButton('refresh_xpd_goods')} - {renderActionButton('exchange_xpd_goods', 'primary')} -
-
-
-
- ) : isEsportsHandbook ? ( -
-
-
- - 开通手册 -
- {renderActionButton('create_esports_qr', 'primary')} -
-
-
- - 皮肤兑换 -
- - ({ value: item.commodity_id, label: goodsLabel(item) }))} - placeholder="选择兑换商品" - showSearch - optionFilterProp="label" - style={{ width: '100%' }} - /> -
- {renderActionButton('refresh_goods')} - {renderActionButton('lock_goods')} - {renderActionButton('exchange_goods', 'primary')} -
-
-
- -
-
- - 开通、充值与送礼 -
- - {renderActionButton('create_elite_qr', 'primary')} - {`当前渠道:${goldRechargeChannelLabel}`} - - setGoldAmount(v || 1)} - addonBefore={goldAmountLabel} - style={{ width: '100%' }} - /> - - - - setGiftCount(v || 1)} - style={{ width: '100%' }} - /> - - - -
-
- )} - - 勾选账号后可批量执行;账号行右键可执行绑定与查询动作。 - - - ); - const accountsBody = ( - <> - - - } - value={accountSearch} - onChange={(e) => { - setAccountSearch(e.target.value); - setAccountPage(1); - }} - style={{ width: 200 }} allowClear - /> - - - - {selectedIds.length > 0 && ( - - )} - - -
- setSelectedIds(keys.map(Number)), - }} - columns={accountColumns} - dataSource={accounts} - pagination={{ - current: accountPage, - pageSize: accountPageSize, - total: accountTotal, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - size: 'small', - showLessItems: true, - showTotal: (total) => `共 ${total} 条`, - onChange: (page, size) => { - setAccountPage(size === accountPageSize ? page : 1); - if (size !== accountPageSize) { - setAccountPageSize(size); - localStorage.setItem('douyu_task_account_page_size', String(size)); - } - }, - }} - tableLayout="fixed" - scroll={{ x: 1280, y: Math.max(120, accountTableAreaHeight - 80) }} - onRow={(record) => ({ - onContextMenu: (e) => handleRowContextMenu(record, e), - style: { cursor: 'context-menu' }, - title: '右键打开账号动作', - })} - /> - - - ); - // 任务记录表:表头固定、表体内部滚动、分页器固定显示在底部 - const taskRecordTableStyle = ` - .douyu-task-record-table { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - } - .douyu-task-record-table .ant-spin-nested-loading, - .douyu-task-record-table .ant-spin-container { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - } - .douyu-task-record-table .ant-table { - flex: 1; - min-height: 0; - font-size: 12px; - display: flex; - flex-direction: column; - } - .douyu-task-record-table .ant-table-container { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - } - .douyu-task-record-table .ant-table-header { - flex-shrink: 0; - } - .douyu-task-record-table .ant-table-body { - flex: 1; - min-height: 0; - overflow: auto !important; - } - .douyu-task-record-table .ant-table-thead > tr > th, - .douyu-task-record-table .ant-table-tbody > tr > td { - padding: 4px 8px; - line-height: 20px; - } - .douyu-task-record-table .ant-typography { - font-size: 12px; - } - .douyu-task-record-table .ant-tag { - font-size: 11px; - line-height: 18px; - margin-inline-end: 0; - padding-inline: 5px; - } - .douyu-task-record-table .ant-pagination { - flex-shrink: 0; - margin: 0; - padding: 6px 4px 2px; - border-top: 1px solid ${token.colorBorderSecondary}; - } - `; - - const xpdPurchaseTask = xpdPurchaseRecordsAccountId == null - ? null - : latestXpdPurchaseRecordsTaskByAccount.get(xpdPurchaseRecordsAccountId) || null; - const xpdPurchaseRecords = Array.isArray(xpdPurchaseTask?.result?.records) - ? xpdPurchaseTask.result.records.filter( - (item): item is Record => Boolean(item && typeof item === 'object'), - ) - : []; - const xpdPurchaseAccount = accounts.find((account) => account.id === xpdPurchaseRecordsAccountId); - - return ( -
- - {/* 头部 */} - -
-

{handbookTitle}

- {handbookDescription} -
- - - - {canConfig && } - {(activeBatchIds.size > 0 || runningBatchIds.size > 0) && } - -
- - {/* 操作工具栏 + 账号表格(支持上下/左右布局切换) */} - {layoutMode === 'split' ? ( -
- 已选 {selectedIds.length}/{accountTotal}} - style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} - styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }} - > - {accountsBody} - - - {operationBody} - -
- ) : ( - <> - - {operationBody} - - 已选 {selectedIds.length}/{accountTotal}} - style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} - styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }} - > - {accountsBody} - - - )} - - {/* 右键菜单 */} - {contextMenu && ( -
e.stopPropagation()}> - 1 ? '批量动作' : '账号动作'} - styles={{ body: { padding: 4 } }} - style={{ width: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }} - > - - {quickActions.map((item) => ( - - ))} - - -
- )} - - {/* Import modal */} - setImportOpen(false)} - footer={null} - width={700} - > -
- - } - value={importSearch} - onChange={(e) => { - setImportSearch(e.target.value); - setImportPage(1); - }} - style={{ width: 220 }} - /> -
setImportSelectedIds(keys.map(Number)) }} - dataSource={importPool} - columns={[ - { title: '昵称', dataIndex: 'nickname', render: (_, r) => r.nickname || r.username }, - { title: 'UID', dataIndex: 'uid' }, - { title: '标签', dataIndex: 'tag', width: 110, render: (v: string) => (v ? {v} : '-') }, - { - title: '游戏名', - dataIndex: isPeaceHandbook ? 'xpd_game_name' : (isEsportsHandbook ? 'esports_game_name' : 'game_name'), - render: (v) => v || '-', - }, - { - title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'), - dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'), - render: (v) => v ?? '-', - }, - ]} - pagination={{ - current: importPage, - pageSize: importPageSize, - total: importTotal, - size: 'small', - showLessItems: true, - showTotal: (total) => `共 ${total} 条`, - onChange: setImportPage, - }} - locale={{ emptyText: '没有可导入的账号' }} - /> - - - {/* Config modal */} - setConfigOpen(false)} - onOk={() => configFormValues && saveConfig(configFormValues)} - okText="保存" cancelText="取消" - width={600} - > - {configFormValues && ( - - {!isPeaceHandbook && ( - <> - - 鱼翅充值渠道 - setConfigFormValues((prev) => prev ? { - ...prev, - [key]: e.target.value, - } : prev)} - /> - - ))} - - )} - - )} - {(isPeaceHandbook ? [ - { label: '小店活动代号', key: 'xpd_act_alias' }, - { label: '道聚城活动 ID', key: 'xpd_act_id' }, - { label: '房间 ID', key: 'xpd_rid' }, - ] : isEsportsHandbook ? [ - { label: '电竞手册 manualID', key: 'esports_manual_id' }, - { label: '电竞手册活动', key: 'esports_act_alias' }, - { label: '房间 ID', key: 'room_id' }, - { label: '冠军鸡腿礼物 ID', key: 'esports_chicken_gift_id' }, - { label: '冠军鸡腿皮肤 ID', key: 'esports_chicken_skin_id' }, - { label: '冠军烟花礼物 ID', key: 'esports_firework_gift_id' }, - { label: '冠军烟花皮肤 ID', key: 'esports_firework_skin_id' }, - ] : [ - { label: 'manualID', key: 'manual_id' }, - { label: 'RID', key: 'rid' }, - { label: '绑定二维码活动', key: 'bind_act_alias' }, - { label: '确认绑定活动', key: 'confirm_act_alias' }, - { label: '查询绑定活动(最新)', key: 'legacy_act_alias' }, - { label: '房间 ID', key: 'room_id' }, - { label: '精英令礼物 ID', key: 'gift_id' }, - { label: '皮肤 ID', key: 'skin_id' }, - ]).map(({ label, key }) => ( - - {label} - setConfigFormValues((prev) => prev ? { ...prev, [key]: e.target.value } : prev)} - /> - - ))} - {(isPeaceHandbook ? [] : isEsportsHandbook ? [ - { label: '电竞手册金额(分)', key: 'esports_amount' }, - ] : [ - { label: '宝典金额(分)', key: 'elite_amount' }, - { label: '鱼翅支付方式', key: 'gold_pay_type' }, - ]).map(({ label, key }) => ( - - {label} - setConfigFormValues((prev) => prev ? { ...prev, [key]: v ?? 0 } : prev)} - /> - - ))} - - )} - - - {/* 电竞手册绑定弹窗:活动绑定与角色选择是独立流程。 */} - - - 绑定电竞手册角色 - - } - open={!!esportsBindTask} - onCancel={() => { - setEsportsBindTask(null); - }} - footer={null} - centered - width={440} - > - {esportsBindTask && ( - - {esportsAccountName ? ( - {esportsAccountName} - ) : null} - - {esportsStatusText} - - - {esportsRoleName ? ( -
- 当前游戏角色 - {esportsRoleName} - {esportsRoleLine ? ( - {esportsRoleLine} - ) : null} -
- ) : ( - 当前未检测到游戏角色,请先切换角色。 - )} - - {esportsRebind && esportsRoleName ? ( - - {esportsRebind.available ? '当前可换绑' : `可换绑时间:${esportsRebind.text}`} - - ) : null} - - {esportsQrUrl ? ( -
-
- -
- - 请在腾讯页面选择角色,完成后查询最新角色。 - -
- ) : null} - - - - - - - -
- )} -
- - {/* 精英宝典绑定二维码弹窗。 */} - - - - {qrBindReady ? '已识别新角色' : qrStatusText} - {qrTaskIds.length > 1 && ` (${qrTaskIds.length})`} - - - } - open={qrTaskIds.length > 0} - onCancel={() => closeAllQrTasks()} - footer={null} - centered - width={420} - > - {qrTaskIds.length > 1 && ( - - {qrTaskIds.map((id) => { - const t = tasks.find((x) => x.id === id); - if (!t) return null; - const name = t.account_nickname || t.account_username || t.account_uid || `#${t.account_id}`; - return ( - { e.preventDefault(); closeQrTask(id); }} - onClick={() => setActiveQrTaskId(id)} - > - {name} - - ); - })} - - )} - {qrTask && ( -
- {qrAccountName && ( - {qrAccountName} - )} - - - {qrStatusText} - - {qrUrl ? ( -
- -
- ) : qrBindReady ? ( - 绑定二维码暂未返回 - ) : qrTask?.status === 'running' ? ( - 二维码生成中... - ) : ( - 本次未生成二维码 - )} - - {qrBindReady || qrXpdBound ? ( - <> - - {qrRoleName} - - {qrRoleLine && {qrRoleLine}} - - ) : qrCurrentRoleName ? ( - <> - - 当前绑定: {qrCurrentRoleName} - - {(qrCurrentAreaName || qrCurrentPlatName) && ( - - {[qrCurrentPlatName, qrCurrentAreaName].filter(Boolean).join(' - ')} - - )} - - ) : null} - {qrBindSummary ? ( - - {qrBindSummary} - - ) : null} - {qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.message ? ( - - 最近查询: {qrQueryTask.message} - - ) : null} - - - - -
-
- )} -
- - {/* Pay QR Modal */} - - - 支付二维码 - - } - open={!!payTask} - onCancel={() => setPayTask(null)} - footer={null} - centered - width={400} - > - {payTask && ( -
- {payAccountName && ( - {payAccountName} - )} - - {payUrl ? ( -
- -
- ) : ( - 支付码生成中... - )} - - {payTask.message || '请扫码支付'} -
-
- )} -
- - {/* 和平小店兑换记录 Modal */} - - - 和平小店兑换记录 - - } - open={xpdPurchaseRecordsAccountId != null} - onCancel={() => setXpdPurchaseRecordsAccountId(null)} - footer={null} - width={980} - centered - > - - {xpdPurchaseAccount?.nickname || xpdPurchaseAccount?.username || `#${xpdPurchaseRecordsAccountId}`} - {xpdPurchaseTask?.result?.total != null ? `,共 ${String(xpdPurchaseTask.result.total)} 条` : ''} - - {xpdPurchaseTask && ['failed', 'error'].includes(xpdPurchaseTask.status) ? ( - - ) : ( -
String(record.serial || `${record.buy_time || ''}-${index}`)} - size="small" - loading={!xpdPurchaseTask || ['planned', 'pending', 'running'].includes(xpdPurchaseTask.status)} - dataSource={xpdPurchaseRecords} - pagination={{ pageSize: 10, showLessItems: true }} - scroll={{ x: 850 }} - columns={[ - { - title: '商品', - dataIndex: 'goods_name', - width: 240, - render: (value: unknown) => String(value || '-'), - }, - { - title: '兑换时间', - dataIndex: 'buy_time', - width: 170, - render: (value: unknown) => String(value || '-'), - }, - { - title: '支付时间', - dataIndex: 'pay_time', - width: 170, - render: (value: unknown) => String(value || '-'), - }, - { - title: '价格', - width: 90, - render: (_: unknown, record: Record) => - record.pay_amount ?? record.price ?? '-', - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (value: unknown) => ( - - {String(value) === '3' ? '已完成' : String(value || '未知')} - - ), - }, - { - title: '角色', - dataIndex: 'role_name', - width: 150, - ellipsis: true, - render: (value: unknown) => String(value || '-'), - }, - { - title: '订单号', - dataIndex: 'serial', - width: 250, - ellipsis: true, - render: (value: unknown) => String(value || '-'), - }, - ]} - locale={{ emptyText: xpdPurchaseTask ? '暂无兑换记录' : '正在查询兑换记录...' }} - /> - )} - - - {/* 兑换结果图片预览 Modal */} - - - 兑换结果图片 - - } - open={!!exchangePreview} - onCancel={() => setExchangePreview(null)} - footer={ - - - - - } - centered - width={340} - > - {exchangePreview && ( - - 兑换结果 - - {exchangePreview.task.account_nickname - || exchangePreview.task.account_username - || `#${exchangePreview.task.account_id}`} - - - )} - - - {/* 确认绑定失败提示 Modal(居中,自动消失) */} - 绑定失败提示} - open={Boolean(confirmFailTip)} - onCancel={() => setConfirmFailTip(null)} - footer={null} - centered - closable={false} - width={420} - > -
- -
- {confirmFailTip?.message || ''} -
- - {confirmFailCountdown} 秒后自动关闭 - -
-
- - ); -} +export { default } from './douyu-tasks/DouyuTasksPage'; +export type { HandbookKind } from './douyu-tasks/config'; diff --git a/web/frontend/src/pages/HuyaAccountsPage.tsx b/web/frontend/src/pages/HuyaAccountsPage.tsx index e6e8448..2d383fa 100644 --- a/web/frontend/src/pages/HuyaAccountsPage.tsx +++ b/web/frontend/src/pages/HuyaAccountsPage.tsx @@ -1,12 +1,14 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Avatar, Button, Card, Input, + InputNumber, Modal, Popconfirm, + Progress, Select, Space, Table, @@ -81,6 +83,40 @@ const STATUS_COLORS: Record = { exchange_record: 'purple', }; +const HUYA_LOGIN_INTERVAL_KEY = 'huya_login_interval_seconds'; +const HUYA_LOGIN_DEFAULT_INTERVAL = 8; +const HUYA_LOGIN_MAX_ATTEMPTS = 2; + +function readLoginIntervalSeconds(): number { + const stored = Number(localStorage.getItem(HUYA_LOGIN_INTERVAL_KEY)); + if (!Number.isFinite(stored)) return HUYA_LOGIN_DEFAULT_INTERVAL; + return Math.min(30, Math.max(0, stored)); +} + +function sleepWithCountdown( + totalMs: number, + aborted: () => boolean, + onTick: (secondsLeft: number) => void, +): Promise { + return new Promise((resolve) => { + const started = Date.now(); + const tick = () => { + if (aborted()) { + resolve(); + return; + } + const left = Math.max(0, totalMs - (Date.now() - started)); + onTick(Math.ceil(left / 1000)); + if (left <= 0) { + resolve(); + return; + } + window.setTimeout(tick, Math.min(250, left)); + }; + tick(); + }); +} + function copyToClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { return navigator.clipboard.writeText(text); @@ -123,6 +159,16 @@ export default function HuyaAccountsPage() { const [appPasswordLogging, setAppPasswordLogging] = useState(false); const [loginModalTitle, setLoginModalTitle] = useState('虎牙 App 协议登录结果 (推荐)'); const [passwordLoginResults, setPasswordLoginResults] = useState([]); + const [loginQueuePhase, setLoginQueuePhase] = useState<'idle' | 'login' | 'wait' | 'done' | 'stopped'>('idle'); + const [loginQueueIndex, setLoginQueueIndex] = useState(0); + const [loginQueueTotal, setLoginQueueTotal] = useState(0); + const [loginQueueUsername, setLoginQueueUsername] = useState(''); + const [loginWaitLeft, setLoginWaitLeft] = useState(0); + const [loginIntervalSeconds, setLoginIntervalSeconds] = useState(readLoginIntervalSeconds); + const loginAbortRef = useRef(false); + const loginRunningRef = useRef(false); + const loginChannelRef = useRef<'app' | 'web'>('app'); + const loginIntervalRef = useRef(loginIntervalSeconds); const [smsLoginOpen, setSmsLoginOpen] = useState(false); const [smsPhone, setSmsPhone] = useState(''); const [smsCode, setSmsCode] = useState(''); @@ -239,6 +285,10 @@ export default function HuyaAccountsPage() { setSelectedAllMatching(false); }; + useEffect(() => { + loginIntervalRef.current = loginIntervalSeconds; + }, [loginIntervalSeconds]); + useEffect(() => { clearSelection(); }, [searchText, tagFilter]); @@ -383,48 +433,216 @@ export default function HuyaAccountsPage() { } }; + const resolveLoginAccountIds = async (): Promise => { + if (selectedAllMatching) { + const data = await huyaApi.listAccountIds({ + search: searchText.trim() || undefined, + tag: tagFilter || undefined, + }); + return data.account_ids; + } + return selectedRowKeys.map((key) => Number(key)); + }; + + const runLoginQueue = async ( + ids: number[], + channel: 'app' | 'web', + options?: { append?: boolean }, + ) => { + if (ids.length === 0) { + message.warning('没有可登录的账号'); + return; + } + if (loginRunningRef.current) { + message.warning('已有登录队列在执行'); + return; + } + + loginAbortRef.current = false; + loginRunningRef.current = true; + loginChannelRef.current = channel; + const channelLabel = channel === 'app' ? 'App 协议' : 'Web'; + if (channel === 'app') { + setAppPasswordLogging(true); + } else { + setPasswordLogging(true); + } + setLoginQueueTotal(ids.length); + setLoginQueueIndex(0); + setLoginQueueUsername(''); + setLoginWaitLeft(0); + setLoginQueuePhase('login'); + setLoginModalTitle(`${channelLabel}登录中`); + if (!options?.append) { + setPasswordLoginResults([]); + } + setPasswordLoginResultOpen(true); + + const usernameById = new Map( + accounts.map((account) => [ + account.id, + account.username || account.nickname || String(account.id), + ]), + ); + let successCount = 0; + let failedCount = 0; + + for (let index = 0; index < ids.length; index += 1) { + if (loginAbortRef.current) { + const remaining = ids.slice(index).map((accountId) => ({ + line: accountId, + username: usernameById.get(accountId) || '', + success: false, + message: '队列已停止,未登录', + })); + setPasswordLoginResults((prev) => [...prev, ...remaining]); + failedCount += remaining.length; + setLoginQueuePhase('stopped'); + break; + } + + const accountId = ids[index]; + setLoginQueueIndex(index + 1); + setLoginQueueUsername(usernameById.get(accountId) || `#${accountId}`); + setLoginQueuePhase('login'); + + try { + const payload = { + account_ids: [accountId], + all_matching: false, + search: '', + tag: '', + max_attempts: HUYA_LOGIN_MAX_ATTEMPTS, + interval_seconds: 0, + }; + const result = channel === 'app' + ? await huyaApi.appPasswordLoginSelected(payload) + : await huyaApi.passwordLoginSelected(payload); + const item = result.results?.[0] || { + line: accountId, + username: usernameById.get(accountId) || '', + success: false, + message: result.message || '登录失败', + }; + if (item.success) successCount += 1; + else failedCount += 1; + setPasswordLoginResults((prev) => [...prev, item]); + } catch (e: unknown) { + failedCount += 1; + setPasswordLoginResults((prev) => [ + ...prev, + { + line: accountId, + username: usernameById.get(accountId) || '', + success: false, + message: getErrorMessage(e), + }, + ]); + } + + const intervalSec = loginIntervalRef.current; + if (index < ids.length - 1 && !loginAbortRef.current && intervalSec > 0) { + setLoginQueuePhase('wait'); + await sleepWithCountdown( + intervalSec * 1000, + () => loginAbortRef.current, + setLoginWaitLeft, + ); + setLoginWaitLeft(0); + } + } + + if (!loginAbortRef.current) { + setLoginQueuePhase('done'); + } + loginRunningRef.current = false; + setAppPasswordLogging(false); + setPasswordLogging(false); + loadAccounts(); + loadTags(); + loadSummary(); + + if (loginAbortRef.current) { + message.warning(`${channelLabel}登录已停止:成功 ${successCount},失败/未执行 ${failedCount}`); + } else if (failedCount > 0) { + message.warning(`${channelLabel}登录完成:成功 ${successCount},失败 ${failedCount}`); + } else { + message.success(`${channelLabel}登录完成:成功 ${successCount} 条`); + } + }; + + const handleStopLoginQueue = () => { + if (!loginRunningRef.current) return; + loginAbortRef.current = true; + message.info('将在当前账号结束后停止队列'); + }; + + const handleRetryFailedLogins = async () => { + const failedIds = passwordLoginResults + .filter((item) => !item.success) + .map((item) => Number(item.line)) + .filter((id) => Number.isFinite(id) && id > 0); + if (failedIds.length === 0) { + message.warning('没有失败的账号可重试'); + return; + } + setPasswordLoginResults((prev) => prev.filter((item) => item.success)); + await runLoginQueue(failedIds, loginChannelRef.current, { append: true }); + }; + const handleAppLoginSelected = async () => { if (selectedCount === 0) { message.warning('请先选择虎牙账号'); return; } - setAppPasswordLogging(true); try { - const result = await huyaApi.appPasswordLoginSelected({ - ...selectionPayload, - }); - setPasswordLoginResults(result.results || []); - setLoginModalTitle('虎牙 App 协议登录结果 (推荐)'); - setPasswordLoginResultOpen(true); - if (result.failed > 0) { - message.warning(result.message); - } else { - message.success(result.message || 'App 登录成功,Cookie 已保存'); - } - loadAccounts(); - loadTags(); - loadSummary(); + const ids = await resolveLoginAccountIds(); + await runLoginQueue(ids, 'app'); } catch (e: unknown) { message.error(getErrorMessage(e)); - } finally { - setAppPasswordLogging(false); } }; // 行内单账号登录: 复用批量端点, 只传该行 id (需已导入密码) const [rowLoginKey, setRowLoginKey] = useState(''); const handleRowLogin = async (record: HuyaAccountItem, channel: 'app' | 'web') => { + if (loginRunningRef.current) { + message.warning('已有登录队列在执行,请等待完成或先停止'); + return; + } setRowLoginKey(`${channel}-${record.id}`); + loginChannelRef.current = channel; try { - const payload = { account_ids: [record.id], all_matching: false, search: '', tag: '' }; const result = channel === 'app' - ? await huyaApi.appPasswordLoginSelected(payload) - : await huyaApi.passwordLoginSelected(payload); + ? await huyaApi.appPasswordLoginSelected({ + account_ids: [record.id], + all_matching: false, + search: '', + tag: '', + max_attempts: HUYA_LOGIN_MAX_ATTEMPTS, + interval_seconds: 0, + }) + : await huyaApi.passwordLoginSelected({ + account_ids: [record.id], + all_matching: false, + search: '', + tag: '', + max_attempts: HUYA_LOGIN_MAX_ATTEMPTS, + interval_seconds: 0, + }); setPasswordLoginResults(result.results || []); + setLoginQueuePhase('done'); + setLoginQueueIndex(1); + setLoginQueueTotal(1); + setLoginQueueUsername(record.username || record.nickname || String(record.id)); setLoginModalTitle(channel === 'app' ? 'App 协议登录结果' : 'Web 旧版登录结果'); setPasswordLoginResultOpen(true); - const ok = (result.results || []).some((r) => r.success); - if (ok) { message.success('登录成功,Cookie 已保存'); } else { message.warning(result.message || '登录失败'); } + const ok = (result.results || []).some((item) => item.success); + if (ok) { + message.success('登录成功,Cookie 已保存'); + } else { + message.warning(result.message || '登录失败'); + } loadAccounts(); loadSummary(); } catch (e: unknown) { @@ -474,26 +692,11 @@ export default function HuyaAccountsPage() { message.warning('请先选择虎牙账号'); return; } - setPasswordLogging(true); try { - const result = await huyaApi.passwordLoginSelected({ - ...selectionPayload, - }); - setPasswordLoginResults(result.results || []); - setLoginModalTitle('虎牙 Web 密码登录结果 (旧版)'); - setPasswordLoginResultOpen(true); - if (result.failed > 0) { - message.warning(result.message); - } else { - message.success(result.message || 'Web 登录成功,Cookie 已保存'); - } - loadAccounts(); - loadTags(); - loadSummary(); + const ids = await resolveLoginAccountIds(); + await runLoginQueue(ids, 'web'); } catch (e: unknown) { message.error(getErrorMessage(e)); - } finally { - setPasswordLogging(false); } }; @@ -579,6 +782,13 @@ export default function HuyaAccountsPage() { {success ? '成功' : '失败'} ), }, + { + title: '尝试', + dataIndex: 'attempts', + width: 70, + align: 'center', + render: (value?: number) => value || '-', + }, { title: '保存账号', width: 160, @@ -897,6 +1107,7 @@ export default function HuyaAccountsPage() { type="primary" icon={} loading={rowLoginKey === `app-${record.id}`} + disabled={Boolean(rowLoginKey) || appPasswordLogging || passwordLogging} onClick={() => handleRowLogin(record, 'app')} /> @@ -917,6 +1128,7 @@ export default function HuyaAccountsPage() { size="small" icon={} loading={rowLoginKey === `web-${record.id}`} + disabled={Boolean(rowLoginKey) || appPasswordLogging || passwordLogging} onClick={() => handleRowLogin(record, 'web')} /> @@ -1421,25 +1633,31 @@ export default function HuyaAccountsPage() { {canImport && ( - + + + )} {canImport && ( - + + + )} {canTag && ( + ) : null, + (loginQueuePhase === 'done' || loginQueuePhase === 'stopped') + && passwordLoginResults.some((item) => !item.success) ? ( + + ) : null, + , - ]} + ].filter(Boolean)} + maskClosable={loginQueuePhase !== 'login' && loginQueuePhase !== 'wait'} + closable={loginQueuePhase !== 'login' && loginQueuePhase !== 'wait'} width={860} > + + 号间间隔(秒) + { + const next = Math.min(30, Math.max(0, Number(value ?? HUYA_LOGIN_DEFAULT_INTERVAL))); + setLoginIntervalSeconds(next); + localStorage.setItem(HUYA_LOGIN_INTERVAL_KEY, String(next)); + }} + style={{ width: 88 }} + /> + + 成功 {passwordLoginResults.filter((item) => item.success).length} · 失败 {passwordLoginResults.filter((item) => !item.success).length} + + + {(loginQueuePhase === 'login' || loginQueuePhase === 'wait') && loginQueueTotal > 0 && ( + + )} {passwordLoginResults.length > 0 && (
`${record.line}-${record.username || 'empty'}`} + rowKey={(record, index) => `${record.line}-${record.username || 'empty'}-${index}`} size="small" pagination={false} scroll={{ x: 720, y: 260 }} diff --git a/web/frontend/src/pages/HuyaCookiePage.tsx b/web/frontend/src/pages/HuyaCookiePage.tsx index a0ae527..fefb732 100644 --- a/web/frontend/src/pages/HuyaCookiePage.tsx +++ b/web/frontend/src/pages/HuyaCookiePage.tsx @@ -3,7 +3,7 @@ import { Button, Card, Col, Dropdown, Input, Popconfirm, Row, Space, Statistic, Table, Tag, theme, Typography, } from 'antd'; import { message } from '../utils/antdMessage'; -import { CopyOutlined, DeleteOutlined, DownloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { CopyOutlined, DeleteOutlined, DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { huyaApi, type BasicSummary, type HuyaCookieItem } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTime } from '../utils/time'; @@ -52,6 +52,7 @@ export default function HuyaCookiePage() { const canManage = can('huya:account'); const canView = can('huya:cookie:view') || can('huya:cookie:export') || canManage; const canExport = can('huya:cookie:export') || canManage; + const canRebuild = can('huya:import') || can('huya:task') || canExport; const loadCookies = useCallback(async () => { setLoading(true); @@ -145,6 +146,16 @@ export default function HuyaCookiePage() { } }; + const handleRebuild = async (record: HuyaCookieItem) => { + try { + await huyaApi.rebuildWebCookie(record.account_id); + message.success(`已从原始凭据重建 ${record.account_username} 的网页 Cookie`); + loadCookies(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + }; + const handleDeleteSelected = async () => { if (selectedRowKeys.length === 0) { message.warning('请先选择要清除的 Cookie'); @@ -201,7 +212,7 @@ export default function HuyaCookiePage() { }, { title: '操作', - width: 130, + width: 210, align: 'center' as const, fixed: 'right' as const, render: (_: unknown, record: HuyaCookieItem) => ( @@ -209,6 +220,15 @@ export default function HuyaCookiePage() { + {canRebuild && record.has_raw_cookie && ( + handleRebuild(record)} + > + + + )} {canExport && ( handleDelete(record.id)}> + ); + }, + }] : []), + ...(!isPeaceHandbook && !isEsportsHandbook ? [{ + title: '锁单', width: 150, align: 'center' as const, + render: (_: unknown, record: DouyuTaskAccountItem) => { + const task = latestLockedTaskByAccount.get(record.id); + if (!task) return -; + const orderId = resultText(task.result, 'order_id'); + const commodityId = resultText(task.result, 'commodity_id'); + const expiresAt = resultNumber(task.result, 'expires_at'); + const expired = expiresAt != null && nowSeconds > 0 && expiresAt <= nowSeconds; + const paymentTask = visibleTasks.find((item) => ( + item.account_id === record.id + && item.task_type === 'pay_locked_order' + && taskPayloadText(item, 'order_id') === orderId + && ['planned', 'pending', 'running'].includes(item.status) + )); + return ( + + {orderId} + + + ); + }, + }] : []), + ]; + + const [configFormValues, setConfigFormValues] = useState(null); + useEffect(() => { + if (configOpen && config) setConfigFormValues({ ...config }); + }, [configOpen, config]); + + const payUrl = resultText(payTask?.result, 'pay_url'); + const goldAmountLabel = '充值金额'; + const isRechargeChannelLoading = goldRechargeChannel === null; + const isSupplierGoldRecharge = goldRechargeChannel === 'supplier_api'; + const goldRechargeChannelLabel = isRechargeChannelLoading + ? '加载中' + : (isSupplierGoldRecharge ? '供应商 API 直充' : '微信扫码'); + const goldRechargeButtonLabel = isRechargeChannelLoading + ? '加载中' + : (isSupplierGoldRecharge ? 'API直充鱼翅' : '扫码充值鱼翅'); + const payAccountName = payTask + ? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`) + : ''; + const selectedText = selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '请先勾选账号'; + const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]); + const actionDisabled = (taskType: string) => { + if (selectedIds.length === 0) return true; + if (taskType === 'exchange_xpd_goods') { + return !selectedGoodsId + || selectedXpdGoodsSoldOut + || !xpdPaymentOptions.some((option) => option.value === xpdPayType); + } + if (['lock_goods', 'exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId; + // 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段) + return selectedHasRunning; + }; + const renderActionButton = (taskType: string, type: 'default' | 'primary' = 'default') => { + const item = actionByKey.get(taskType); + return ( + + ); + }; + const sectionStyle = { + border: `1px solid ${token.colorBorderSecondary}`, + borderRadius: 6, + padding: 8, + background: token.colorBgContainer, + }; + const sectionTitleStyle = { + display: 'flex', + alignItems: 'center', + gap: 6, + marginBottom: 8, + fontWeight: 600, + }; + const actionGridStyle = { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: 6, + }; + const compactOperationGridStyle = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', + gap: 8, + }; + const operationExtra = ( + + {selectedText} + + + setConcurrency(v || 1)} + style={{ width: 70 }} + /> + + + ); + const operationBody = ( + <> + {isPeaceHandbook ? ( +
+
+
+ + 小店查询 +
+
+ {renderActionButton('get_xpd_bind_qr', 'primary')} + {renderActionButton('query_xpd_bind_info', 'primary')} + {renderActionButton('confirm_xpd_bind', 'primary')} + {renderActionButton('query_xpd_role', 'primary')} + {renderActionButton('query_xpd_balance', 'primary')} + {renderActionButton('query_xpd_fragments', 'primary')} +
+
+
+
+ + 小店商品 +
+ + option.value === xpdPayType) ? xpdPayType : undefined} + onChange={setXpdPayType} + options={xpdPaymentOptions} + placeholder={selectedGoodsId ? '该商品暂无可用兑换方式' : '先选择商品'} + disabled={!selectedGoodsId || xpdPaymentOptions.length === 0} + style={{ width: '100%' }} + /> +
+ {renderActionButton('refresh_xpd_goods')} + {renderActionButton('exchange_xpd_goods', 'primary')} +
+
+
+
+ ) : isEsportsHandbook ? ( +
+
+
+ + 开通手册 +
+ {renderActionButton('create_esports_qr', 'primary')} +
+
+
+ + 皮肤兑换 +
+ + ({ value: item.commodity_id, label: goodsLabel(item) }))} + placeholder="选择兑换商品" + showSearch + optionFilterProp="label" + style={{ width: '100%' }} + /> +
+ {renderActionButton('refresh_goods')} + {renderActionButton('lock_goods')} + {renderActionButton('exchange_goods', 'primary')} +
+
+
+ +
+
+ + 开通、充值与送礼 +
+ + {renderActionButton('create_elite_qr', 'primary')} + {`当前渠道:${goldRechargeChannelLabel}`} + + setGoldAmount(v || 1)} + addonBefore={goldAmountLabel} + style={{ width: '100%' }} + /> + + + + setGiftCount(v || 1)} + style={{ width: '100%' }} + /> + + + +
+
+ )} + + 勾选账号后可批量执行;账号行右键可执行绑定与查询动作。 + + + ); + const accountsBody = ( + <> + + + } + value={accountSearch} + onChange={(e) => { + setAccountSearch(e.target.value); + setAccountPage(1); + }} + style={{ width: 200 }} allowClear + /> + + + + {selectedIds.length > 0 && ( + + )} + + +
+
setSelectedIds(keys.map(Number)), + }} + columns={accountColumns} + dataSource={accounts} + pagination={{ + current: accountPage, + pageSize: accountPageSize, + total: accountTotal, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + size: 'small', + showLessItems: true, + showTotal: (total) => `共 ${total} 条`, + onChange: (page, size) => { + setAccountPage(size === accountPageSize ? page : 1); + if (size !== accountPageSize) { + setAccountPageSize(size); + localStorage.setItem('douyu_task_account_page_size', String(size)); + } + }, + }} + tableLayout="fixed" + scroll={{ x: 1280, y: Math.max(120, accountTableAreaHeight - 80) }} + onRow={(record) => ({ + onContextMenu: (e) => handleRowContextMenu(record, e), + style: { cursor: 'context-menu' }, + title: '右键打开账号动作', + })} + /> + + + ); + // 任务记录表:表头固定、表体内部滚动、分页器固定显示在底部 + const taskRecordTableStyle = ` + .douyu-task-record-table { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + .douyu-task-record-table .ant-spin-nested-loading, + .douyu-task-record-table .ant-spin-container { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + .douyu-task-record-table .ant-table { + flex: 1; + min-height: 0; + font-size: 12px; + display: flex; + flex-direction: column; + } + .douyu-task-record-table .ant-table-container { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + .douyu-task-record-table .ant-table-header { + flex-shrink: 0; + } + .douyu-task-record-table .ant-table-body { + flex: 1; + min-height: 0; + overflow: auto !important; + } + .douyu-task-record-table .ant-table-thead > tr > th, + .douyu-task-record-table .ant-table-tbody > tr > td { + padding: 4px 8px; + line-height: 20px; + } + .douyu-task-record-table .ant-typography { + font-size: 12px; + } + .douyu-task-record-table .ant-tag { + font-size: 11px; + line-height: 18px; + margin-inline-end: 0; + padding-inline: 5px; + } + .douyu-task-record-table .ant-pagination { + flex-shrink: 0; + margin: 0; + padding: 6px 4px 2px; + border-top: 1px solid ${token.colorBorderSecondary}; + } + `; + + const xpdPurchaseTask = xpdPurchaseRecordsAccountId == null + ? null + : latestXpdPurchaseRecordsTaskByAccount.get(xpdPurchaseRecordsAccountId) || null; + const xpdPurchaseRecords = Array.isArray(xpdPurchaseTask?.result?.records) + ? xpdPurchaseTask.result.records.filter( + (item): item is Record => Boolean(item && typeof item === 'object'), + ) + : []; + const xpdPurchaseAccount = accounts.find((account) => account.id === xpdPurchaseRecordsAccountId); + + return ( +
+ + {/* 头部 */} + +
+

{handbookTitle}

+ {handbookDescription} +
+ + + + {canConfig && } + {(activeBatchIds.size > 0 || runningBatchIds.size > 0) && } + +
+ + {/* 操作工具栏 + 账号表格(支持上下/左右布局切换) */} + {layoutMode === 'split' ? ( +
+ 已选 {selectedIds.length}/{accountTotal}} + style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} + styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }} + > + {accountsBody} + + + {operationBody} + +
+ ) : ( + <> + + {operationBody} + + 已选 {selectedIds.length}/{accountTotal}} + style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} + styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }} + > + {accountsBody} + + + )} + + {/* 右键菜单 */} + {contextMenu && ( +
e.stopPropagation()}> + 1 ? '批量动作' : '账号动作'} + styles={{ body: { padding: 4 } }} + style={{ width: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }} + > + + {quickActions.map((item) => ( + + ))} + + +
+ )} + + {/* Import modal */} + setImportOpen(false)} + footer={null} + width={700} + > +
+ + } + value={importSearch} + onChange={(e) => { + setImportSearch(e.target.value); + setImportPage(1); + }} + style={{ width: 220 }} + /> +
setImportSelectedIds(keys.map(Number)) }} + dataSource={importPool} + columns={[ + { title: '昵称', dataIndex: 'nickname', render: (_, r) => r.nickname || r.username }, + { title: 'UID', dataIndex: 'uid' }, + { title: '标签', dataIndex: 'tag', width: 110, render: (v: string) => (v ? {v} : '-') }, + { + title: '游戏名', + dataIndex: isPeaceHandbook ? 'xpd_game_name' : (isEsportsHandbook ? 'esports_game_name' : 'game_name'), + render: (v) => v || '-', + }, + { + title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'), + dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'), + render: (v) => v ?? '-', + }, + ]} + pagination={{ + current: importPage, + pageSize: importPageSize, + total: importTotal, + size: 'small', + showLessItems: true, + showTotal: (total) => `共 ${total} 条`, + onChange: setImportPage, + }} + locale={{ emptyText: '没有可导入的账号' }} + /> + + + {/* Config modal */} + setConfigOpen(false)} + onOk={() => configFormValues && saveConfig(configFormValues)} + okText="保存" cancelText="取消" + width={600} + > + {configFormValues && ( + + {!isPeaceHandbook && ( + <> + + 鱼翅充值渠道 + setConfigFormValues((prev) => prev ? { + ...prev, + [key]: e.target.value, + } : prev)} + /> + + ))} + + )} + + )} + {(isPeaceHandbook ? [ + { label: '小店活动代号', key: 'xpd_act_alias' }, + { label: '道聚城活动 ID', key: 'xpd_act_id' }, + { label: '房间 ID', key: 'xpd_rid' }, + ] : isEsportsHandbook ? [ + { label: '电竞手册 manualID', key: 'esports_manual_id' }, + { label: '电竞手册活动', key: 'esports_act_alias' }, + { label: '房间 ID', key: 'room_id' }, + { label: '冠军鸡腿礼物 ID', key: 'esports_chicken_gift_id' }, + { label: '冠军鸡腿皮肤 ID', key: 'esports_chicken_skin_id' }, + { label: '冠军烟花礼物 ID', key: 'esports_firework_gift_id' }, + { label: '冠军烟花皮肤 ID', key: 'esports_firework_skin_id' }, + ] : [ + { label: 'manualID', key: 'manual_id' }, + { label: 'RID', key: 'rid' }, + { label: '绑定二维码活动', key: 'bind_act_alias' }, + { label: '确认绑定活动', key: 'confirm_act_alias' }, + { label: '查询绑定活动(最新)', key: 'legacy_act_alias' }, + { label: '房间 ID', key: 'room_id' }, + { label: '精英令礼物 ID', key: 'gift_id' }, + { label: '皮肤 ID', key: 'skin_id' }, + ]).map(({ label, key }) => ( + + {label} + setConfigFormValues((prev) => prev ? { ...prev, [key]: e.target.value } : prev)} + /> + + ))} + {(isPeaceHandbook ? [] : isEsportsHandbook ? [ + { label: '电竞手册金额(分)', key: 'esports_amount' }, + ] : [ + { label: '宝典金额(分)', key: 'elite_amount' }, + { label: '鱼翅支付方式', key: 'gold_pay_type' }, + ]).map(({ label, key }) => ( + + {label} + setConfigFormValues((prev) => prev ? { ...prev, [key]: v ?? 0 } : prev)} + /> + + ))} + + )} + + + {/* 电竞手册绑定弹窗:活动绑定与角色选择是独立流程。 */} + + + 绑定电竞手册角色 + + } + open={!!esportsBindTask} + onCancel={() => { + setEsportsBindTask(null); + }} + footer={null} + centered + width={440} + > + {esportsBindTask && ( + + {esportsAccountName ? ( + {esportsAccountName} + ) : null} + + {esportsStatusText} + + + {esportsRoleName ? ( +
+ 当前游戏角色 + {esportsRoleName} + {esportsRoleLine ? ( + {esportsRoleLine} + ) : null} +
+ ) : ( + 当前未检测到游戏角色,请先切换角色。 + )} + + {esportsRebind && esportsRoleName ? ( + + {esportsRebind.available ? '当前可换绑' : `可换绑时间:${esportsRebind.text}`} + + ) : null} + + {esportsQrUrl ? ( +
+
+ +
+ + 请在腾讯页面选择角色,完成后查询最新角色。 + +
+ ) : null} + + + + + + + +
+ )} +
+ + {/* 精英宝典绑定二维码弹窗。 */} + + + + {qrBindReady ? '已识别新角色' : qrStatusText} + {qrTaskIds.length > 1 && ` (${qrTaskIds.length})`} + + + } + open={qrTaskIds.length > 0} + onCancel={() => closeAllQrTasks()} + footer={null} + centered + width={420} + > + {qrTaskIds.length > 1 && ( + + {qrTaskIds.map((id) => { + const t = tasks.find((x) => x.id === id); + if (!t) return null; + const name = t.account_nickname || t.account_username || t.account_uid || `#${t.account_id}`; + return ( + { e.preventDefault(); closeQrTask(id); }} + onClick={() => setActiveQrTaskId(id)} + > + {name} + + ); + })} + + )} + {qrTask && ( +
+ {qrAccountName && ( + {qrAccountName} + )} + + + {qrStatusText} + + {qrUrl ? ( +
+ +
+ ) : qrBindReady ? ( + 绑定二维码暂未返回 + ) : qrTask?.status === 'running' ? ( + 二维码生成中... + ) : ( + 本次未生成二维码 + )} + + {qrBindReady || qrXpdBound ? ( + <> + + {qrRoleName} + + {qrRoleLine && {qrRoleLine}} + + ) : qrCurrentRoleName ? ( + <> + + 当前绑定: {qrCurrentRoleName} + + {(qrCurrentAreaName || qrCurrentPlatName) && ( + + {[qrCurrentPlatName, qrCurrentAreaName].filter(Boolean).join(' - ')} + + )} + + ) : null} + {qrBindSummary ? ( + + {qrBindSummary} + + ) : null} + {qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.message ? ( + + 最近查询: {qrQueryTask.message} + + ) : null} + + + + +
+
+ )} +
+ + {/* Pay QR Modal */} + + + 支付二维码 + + } + open={!!payTask} + onCancel={() => setPayTask(null)} + footer={null} + centered + width={400} + > + {payTask && ( +
+ {payAccountName && ( + {payAccountName} + )} + + {payUrl ? ( +
+ +
+ ) : ( + 支付码生成中... + )} + + {payTask.message || '请扫码支付'} +
+
+ )} +
+ + {/* 和平小店兑换记录 Modal */} + + + 和平小店兑换记录 + + } + open={xpdPurchaseRecordsAccountId != null} + onCancel={() => setXpdPurchaseRecordsAccountId(null)} + footer={null} + width={980} + centered + > + + {xpdPurchaseAccount?.nickname || xpdPurchaseAccount?.username || `#${xpdPurchaseRecordsAccountId}`} + {xpdPurchaseTask?.result?.total != null ? `,共 ${String(xpdPurchaseTask.result.total)} 条` : ''} + + {xpdPurchaseTask && ['failed', 'error'].includes(xpdPurchaseTask.status) ? ( + + ) : ( +
String(record.serial || `${record.buy_time || ''}-${index}`)} + size="small" + loading={!xpdPurchaseTask || ['planned', 'pending', 'running'].includes(xpdPurchaseTask.status)} + dataSource={xpdPurchaseRecords} + pagination={{ pageSize: 10, showLessItems: true }} + scroll={{ x: 850 }} + columns={[ + { + title: '商品', + dataIndex: 'goods_name', + width: 240, + render: (value: unknown) => String(value || '-'), + }, + { + title: '兑换时间', + dataIndex: 'buy_time', + width: 170, + render: (value: unknown) => String(value || '-'), + }, + { + title: '支付时间', + dataIndex: 'pay_time', + width: 170, + render: (value: unknown) => String(value || '-'), + }, + { + title: '价格', + width: 90, + render: (_: unknown, record: Record) => + record.pay_amount ?? record.price ?? '-', + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: unknown) => ( + + {String(value) === '3' ? '已完成' : String(value || '未知')} + + ), + }, + { + title: '角色', + dataIndex: 'role_name', + width: 150, + ellipsis: true, + render: (value: unknown) => String(value || '-'), + }, + { + title: '订单号', + dataIndex: 'serial', + width: 250, + ellipsis: true, + render: (value: unknown) => String(value || '-'), + }, + ]} + locale={{ emptyText: xpdPurchaseTask ? '暂无兑换记录' : '正在查询兑换记录...' }} + /> + )} + + + {/* 兑换结果图片预览 Modal */} + + + 兑换结果图片 + + } + open={!!exchangePreview} + onCancel={() => setExchangePreview(null)} + footer={ + + + + + } + centered + width={340} + > + {exchangePreview && ( + + 兑换结果 + + {exchangePreview.task.account_nickname + || exchangePreview.task.account_username + || `#${exchangePreview.task.account_id}`} + + + )} + + + {/* 确认绑定失败提示 Modal(居中,自动消失) */} + 绑定失败提示} + open={Boolean(confirmFailTip)} + onCancel={() => setConfirmFailTip(null)} + footer={null} + centered + closable={false} + width={420} + > +
+ +
+ {confirmFailTip?.message || ''} +
+ + {confirmFailCountdown} 秒后自动关闭 + +
+
+ + ); +} diff --git a/web/frontend/src/pages/douyu-tasks/config.tsx b/web/frontend/src/pages/douyu-tasks/config.tsx new file mode 100644 index 0000000..2af4a6f --- /dev/null +++ b/web/frontend/src/pages/douyu-tasks/config.tsx @@ -0,0 +1,349 @@ +import type { ReactNode } from 'react'; +import { + CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined, + QrcodeOutlined, ReloadOutlined, SearchOutlined, ShoppingOutlined, +} from '@ant-design/icons'; +import type { DouyuGoodsItem, DouyuTaskItem } from '../../api/modules'; + +export type HandbookKind = 'elite' | 'esports' | 'peace'; + +export const TASK_STATUS_COLORS: Record = { + planned: 'default', + pending: 'default', + running: 'processing', + success: 'success', + failed: 'error', + error: 'error', + stopped: 'warning', +}; + +export const TASK_STATUS_LABELS: Record = { + planned: '已计划', + pending: '等待中', + running: '执行中', + success: '成功', + failed: '失败', + error: '异常', + stopped: '已停止', +}; + +export const ELITE_QUICK_ACTIONS = [ + { key: 'get_bind_qr', icon: }, + { key: 'confirm_bind', icon: }, + { key: 'create_elite_qr', icon: }, + { key: 'query_points', icon: }, + { key: 'query_game_name', icon: }, + { key: 'query_change_bind_time', icon: }, + { key: 'query_limited_goods', icon: }, + { key: 'query_gold_balance', icon: }, + { key: 'lock_goods', icon: }, + { key: 'exchange_goods', icon: }, + { key: 'create_gold_qr', icon: }, + { key: 'donate_elite_gift', icon: }, +]; + +export const ESPORTS_QUICK_ACTIONS = [ + { key: 'prepare_esports_bind', icon: }, + { key: 'query_esports_game_name', icon: }, + { key: 'create_esports_qr', icon: }, + { key: 'query_esports_points', icon: }, + { key: 'query_change_bind_time', icon: }, + { key: 'query_limited_goods', icon: }, + { key: 'query_gold_balance', icon: }, + { key: 'refresh_esports_goods', icon: }, + { key: 'exchange_esports_goods', icon: }, + { key: 'create_gold_qr', icon: }, + { key: 'donate_esports_chicken_gift', icon: }, + { key: 'donate_esports_firework_gift', icon: }, +]; + +export const PEACE_QUICK_ACTIONS = [ + { key: 'get_xpd_bind_qr', icon: }, + { key: 'query_xpd_bind_info', icon: }, + { key: 'confirm_xpd_bind', icon: }, + { key: 'query_xpd_role', icon: }, + { key: 'query_xpd_balance', icon: }, + { key: 'query_xpd_fragments', icon: }, + { key: 'query_xpd_purchase_records', icon: }, + { key: 'refresh_xpd_goods', icon: }, + { key: 'exchange_xpd_goods', icon: }, +]; + +export const ELITE_TASK_TYPES = new Set([ + 'get_bind_qr', + 'confirm_bind', + 'create_elite_qr', + 'create_gold_qr', + 'donate_elite_gift', + 'query_points', + 'lock_goods', + 'pay_locked_order', + 'exchange_goods', + 'query_game_name', + 'query_change_bind_time', + 'query_limited_goods', + 'query_gold_balance', + 'refresh_goods', + 'query_exchange_records', + 'prefetch_csrf_token', +]); +export const ESPORTS_TASK_TYPES = new Set([ + 'prepare_esports_bind', + 'get_esports_bind_qr', + 'query_esports_game_name', + 'confirm_esports_bind', + 'create_esports_qr', + 'query_esports_points', + 'query_gold_balance', + 'query_change_bind_time', + 'query_limited_goods', + 'refresh_esports_goods', + 'exchange_esports_goods', + 'create_gold_qr', + 'donate_esports_chicken_gift', + 'donate_esports_firework_gift', +]); +export const PEACE_TASK_TYPES = new Set([ + 'get_xpd_bind_qr', + 'query_xpd_bind_info', + 'confirm_xpd_bind', + 'query_xpd_role', + 'refresh_xpd_goods', + 'query_xpd_balance', + 'query_xpd_fragments', + 'query_xpd_purchase_records', + 'exchange_xpd_goods', +]); +export const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount'; +export const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count'; +export const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode'; +// v2 避开旧版页面切换时写入其他工作台的共享账号列表。 +export const DOUYU_WORKBENCH_IDS_STORAGE_KEY = (kind: HandbookKind) => `douyu_task_workbench_ids_v2_${kind}`; +export const CONFIRM_FAIL_TIP_SECONDS = 6; + +export function resultText(result: Record | null | undefined, key: string): string { + const value = result?.[key]; + return typeof value === 'string' ? value : ''; +} + +export function taskPayloadText(task: DouyuTaskItem, key: string): string { + const payload = task.result?.payload; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return ''; + return resultText(payload as Record, key); +} + +export function resultFlag(result: Record | null | undefined, key: string): boolean { + const value = result?.[key]; + if (value === true) return true; + if (value === false || value == null) return false; + const text = String(value).trim().toLowerCase(); + return text === '1' || text === 'true' || text === 'yes'; +} + +// 提取任务结果中的二维码 URL:绑定/电竞绑定用 url,支付类用 pay_url +export function taskQrUrl(task: DouyuTaskItem | null | undefined): string { + if (!task) return ''; + if (task.task_type === 'get_bind_qr') return resultText(task.result, 'url'); + if (task.task_type === 'get_xpd_bind_qr') return resultText(task.result, 'url'); + if (['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) { + return resultText(task.result, 'pay_url'); + } + if (['prepare_esports_bind', 'get_esports_bind_qr'].includes(task.task_type)) { + return resultText(task.result, 'url'); + } + return ''; +} + +export function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean { + if (!task) return false; + if (task.task_type === 'get_xpd_bind_qr') { + if (resultFlag(task.result, 'xpd_bound') || resultFlag(task.result, 'bind_confirmed')) return false; + // 轮询识别到角色后停在待确认(xpd_pending_confirm),有角色名即可确认绑定 + return Boolean(resultText(task.result, 'role_name')); + } + if (task.task_type !== 'get_bind_qr') return false; + if (task.result?.bind_ready_for_confirm === true) return true; + const roleName = resultText(task.result, 'role_name'); + if (!roleName) return false; + // 已确认绑定的角色不能再次直接确认 + if (resultFlag(task.result, 'bind_confirmed') || resultFlag(task.result, 'is_bound_act')) { + return false; + } + return true; +} + +export function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean { + if (!task) return false; + if (!['get_bind_qr', 'get_xpd_bind_qr'].includes(task.task_type)) return false; + // 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板 + return Boolean(resultText(task.result, 'url')) + || bindReadyForConfirm(task) + || resultFlag(task.result, 'esports_bound'); +} + +export function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean { + return Boolean( + task + && ['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type) + && resultText(task.result, 'pay_url'), + ); +} + +export function paymentArrived(task: DouyuTaskItem | null | undefined): boolean { + if (!task || !['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) return false; + if (task.status !== 'success') return false; + return task.result?.gold_recharged === true + || task.result?.elite_opened === true + || task.result?.esports_opened === true; +} + +export function bindPhaseText(phase: string, polling: boolean): string { + if (phase === 'role_ready') return '已识别角色,待确认'; + if (phase === 'change_waiting') return '换绑冷却中'; + if (phase === 'role_timeout') return '未检测到角色'; + if (phase === 'stopped') return '任务已停止'; + if (phase === 'confirmed') return '已绑定'; + if (phase === 'confirm_failed') return '确认未生效'; + if (polling || phase === 'waiting_scan') return '等待扫码绑定'; + return phase || '等待绑定'; +} + +export function goodsLabel(item: DouyuGoodsItem): string { + const stock = item.raw?.count; + const stockText = + stock != null && Number.isFinite(Number(stock)) ? ` / 库存${stock}` : ''; + return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}${stockText}`; +} + +// 道聚城 iPrice 为 10 倍点券单位(如 6800=680 点券),仅展示层换算,兑换请求仍传原值。 +export const XPD_POINT_PRICE_SCALE = 10; + +export function xpdPointPrice(item: DouyuGoodsItem): number { + const xpd = item as DouyuGoodsItem & { price?: number | null }; + const raw = item.raw?.raw as Record | undefined; + const rawPrice = Number(xpd.price ?? raw?.iPrice); + if (!Number.isFinite(rawPrice) || rawPrice <= 0) return 0; + return Math.round(rawPrice / XPD_POINT_PRICE_SCALE); +} + +export function xpdGoodsLabel(item: DouyuGoodsItem): string { + const xpd = item as DouyuGoodsItem & { price?: number | null; goods_left?: number | null }; + const raw = item.raw?.raw as Record | undefined; + const pointPrice = xpdPointPrice(item); + const fragmentPrice = Number(raw?.iJb2Price); + const prices = [ + Number.isFinite(pointPrice) && pointPrice > 0 ? `${pointPrice}点券` : '', + Number.isFinite(fragmentPrice) && fragmentPrice > 0 ? `${fragmentPrice}碎片` : '', + ].filter(Boolean); + const stock = xpd.goods_left; + const stockText = stock != null && Number.isFinite(Number(stock)) + ? (Number(stock) < 0 ? '库存未公开' : `库存${stock}`) + : '库存未知'; + return `${item.name || item.commodity_id}${prices.length ? ` / ${prices.join(' 或 ')}` : ' / 暂不可兑换'} / ${stockText}`; +} + +export function xpdGoodsPaymentOptions(item: DouyuGoodsItem | undefined): Array<{ value: '1' | '5'; label: string }> { + if (!item) return []; + const raw = item.raw?.raw as Record | undefined; + const pointPrice = xpdPointPrice(item); + const fragmentPrice = Number(raw?.iJb2Price); + return [ + Number.isFinite(pointPrice) && pointPrice > 0 + ? { value: '1' as const, label: `使用点券兑换(${pointPrice}点券)` } + : null, + Number.isFinite(fragmentPrice) && fragmentPrice > 0 + ? { value: '5' as const, label: `使用扭蛋碎片兑换(${fragmentPrice}碎片)` } + : null, + ].filter((option): option is { value: '1' | '5'; label: string } => option !== null); +} + +export function formatWaitSeconds(seconds: number | null | undefined): string { + if (seconds == null || Number.isNaN(Number(seconds))) return ''; + const total = Math.max(0, Math.floor(Number(seconds))); + if (total <= 0) return '可换绑'; + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (days > 0) return `${days}天${hours}小时${minutes}分`; + if (hours > 0) return `${hours}小时${minutes}分${secs}秒`; + return `${minutes}分${secs}秒`; +} + +export function esportsRebindStatus(canChangeTime: number | null | undefined): { + available: boolean; + text: string; +} | null { + if (canChangeTime == null || Number.isNaN(Number(canChangeTime))) return null; + const timestamp = Math.floor(Number(canChangeTime)); + if (timestamp <= 0 || timestamp <= Math.floor(Date.now() / 1000)) { + return { available: true, text: '可换绑' }; + } + return { + available: false, + text: new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false }), + }; +} + +export function resultNumber(result: Record | null | undefined, key: string): number | null { + const value = result?.[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +export function limitedGoodsName(item: unknown): string { + if (!item || typeof item !== 'object') return ''; + const record = item as Record; + const value = record.commodityName ?? record.name; + return typeof value === 'string' ? value : ''; +} + +export function limitedGoodsTaskText(task: DouyuTaskItem | null | undefined): string { + if (!task) return ''; + if (task.message?.trim()) return task.message.trim(); + const count = resultNumber(task.result, 'limited_count'); + if (count == null) return ''; + if (count <= 0) return '无限制商品'; + const goods = task.result?.limited_goods; + const names = Array.isArray(goods) + ? goods.map(limitedGoodsName).filter(Boolean).slice(0, 5) + : []; + return names.length ? `限兑 ${count} 个: ${names.join(', ')}` : `限兑 ${count} 个`; +} + +export function savedPositiveInteger(key: string, fallback = 1): number { + const value = Number(localStorage.getItem(key)); + return Number.isInteger(value) && value >= 1 ? value : fallback; +} + + +export const HANDBOOK_META: Record; + quickActions: Array<{ key: string; icon: ReactNode }>; +}> = { + elite: { + title: '精英宝典工作台', + description: '精英宝典绑定、开通、积分与兑换任务', + taskTypes: ELITE_TASK_TYPES, + quickActions: ELITE_QUICK_ACTIONS, + }, + esports: { + title: '电竞手册工作台', + description: '电竞手册角色绑定与开通任务', + taskTypes: ESPORTS_TASK_TYPES, + quickActions: ESPORTS_QUICK_ACTIONS, + }, + peace: { + title: '和平小店工作台', + description: '和平小店绑定角色、商品与点券余额查询', + taskTypes: PEACE_TASK_TYPES, + quickActions: PEACE_QUICK_ACTIONS, + }, +}; +