虎牙精英宝典
This commit is contained in:
@@ -33,3 +33,35 @@ Additional checks:
|
|||||||
.venv/bin/python -m ruff check core/huya tests/test_huya_app_login.py -> All checks passed, exit 0
|
.venv/bin/python -m ruff check core/huya tests/test_huya_app_login.py -> All checks passed, exit 0
|
||||||
git diff --check -> exit 0
|
git diff --check -> exit 0
|
||||||
bash -n dev.sh deploy.sh -> exit 0
|
bash -n dev.sh deploy.sh -> exit 0
|
||||||
|
|
||||||
|
Analysis round (2026-08-31):
|
||||||
|
changed branch/field: Huya elite handbook HAR analysis document (protocol flow, field matrix, implementation gaps)
|
||||||
|
analysis artifact: /Users/yml/codes/live-hub-py/docs/HUYA_精英宝典-8.31-PY重新分析.md
|
||||||
|
client artifact: /Users/yml/codes/live-hub-py/scripts/huya_elite_client.py
|
||||||
|
MODIFIED_FILE: /Users/yml/codes/live-hub-py/artifacts/har-to-client/MODIFIED_FILE
|
||||||
|
DIFF_FILE: /Users/yml/codes/live-hub-py/artifacts/har-to-client/DIFF_FILE
|
||||||
|
VERIFICATION.txt: /Users/yml/codes/live-hub-py/artifacts/har-to-client/VERIFICATION.txt
|
||||||
|
ROLLBACK.sh: /Users/yml/codes/live-hub-py/artifacts/har-to-client/ROLLBACK.sh
|
||||||
|
source HAR SHA-256: bcb1e830bfc368c9029f85f90458b708dcf086993a79b833e8e544c65e5c679f; f8d13a837c477aa4dc55b27df6828ffb7840bbdcccf7c078f840164d62a00e07
|
||||||
|
|
||||||
|
BASELINE exact command:
|
||||||
|
set -e; BASE=$(mktemp -d /tmp/live-hub-py-baseline.XXXXXX); git archive HEAD | tar -x -C "$BASE"; (cd "$BASE" && /Users/yml/codes/live-hub-py/.venv/bin/pytest -q tests/test_huya_app_login.py tests/test_huya_dfp_register.py)
|
||||||
|
BASELINE literal output/result:
|
||||||
|
28 passed, 1 warning in 0.71s
|
||||||
|
BASELINE exit status: 0
|
||||||
|
|
||||||
|
MODIFIED exact command:
|
||||||
|
uv run pytest -q
|
||||||
|
MODIFIED literal output/result:
|
||||||
|
116 passed, 1 warning in 1.67s
|
||||||
|
MODIFIED exit status: 0
|
||||||
|
|
||||||
|
ROLLBACK exact command:
|
||||||
|
set -e; SRC=$(mktemp /tmp/rollback-source.XXXXXX); TGT=$(mktemp /tmp/rollback-target.XXXXXX); cp MODIFIED_FILE "$SRC"; printf '\\ncorrupt\\n' >> "$TGT"; ./ROLLBACK.sh "$SRC" "$TGT"; cmp -s "$SRC" "$TGT"
|
||||||
|
ROLLBACK literal output/result:
|
||||||
|
rollback_cmp=restored; source_sha256=e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1; target_sha256=e15f95ce30ca0e0faa6e3c8c6cdf4725e4984434df0f39cf237ee0b7197375b1
|
||||||
|
ROLLBACK exit status: 0
|
||||||
|
restored behavior/status: independent target copy equals MODIFIED_FILE byte-for-byte; MODIFIED_FILE remains changed.
|
||||||
|
|
||||||
|
Validation command:
|
||||||
|
uv run python scripts/validate.py -> file not found (exit status 2; no validator exists in this repository)
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# 虎牙精英宝典 HAR 重新分析(Python)
|
||||||
|
|
||||||
|
## 1. 样本与结论
|
||||||
|
|
||||||
|
| HAR | SHA-256 | entries | 结论 |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `手机扫码登录-pc直播间-1.har` | `bcb1e830bfc368c9029f85f90458b708dcf086993a79b833e8e544c65e5c679f` | 1947 | 扫码登录、PC 直播间初始化、登录态建立 |
|
||||||
|
| `pc直播间-精英宝典1.har` | `f8d13a837c477aa4dc55b27df6828ffb7840bbdcccf7c078f840164d62a00e07` | 580 | 精英宝典初始化、绑定、任务、奖品、积分、记录、商城前置 |
|
||||||
|
|
||||||
|
页面地址为 `https://zt.huya.com/b02faae1/pc/index.html`。活动固定业务参数为 `sid=2203`、`actId=25135`、绑定活动 `bActId=9271`、任务模块 `moduleId=20051`、游戏 `cjm`、组件 `gid/componentId=3203`。
|
||||||
|
|
||||||
|
核心结论:协议是 WSS 二进制通道承载的 WUP/TAF RPC,HTTP `cdnws.api.huya.com` 可作为现有 Python 客户端的兜底。样本没有捕获积分兑换成功请求、兑换详情响应或支付完成闭环,因此这些字段不能从 HAR 推测。
|
||||||
|
|
||||||
|
## 2. 传输层与帧解码
|
||||||
|
|
||||||
|
- 活动页主通道:`wss://wsapi.huya.com/?baseinfo=<动态值>`。
|
||||||
|
- 直播间/绑定入口还使用 `wss://d35bf35c-ws.va.huya.com/?baseinfo=<动态值>`。
|
||||||
|
- HTTP 兜底:`POST https://cdnws.api.huya.com/?baseinfo=<动态值>`,请求体为 WUP 包。
|
||||||
|
- HAR 的业务消息在 entry 的 `_webSocketMessages`,不是普通 HTTP entry body。
|
||||||
|
- WebSocket 帧前 6 字节是通道头(command、sequence),后续 body 再提取 WUP;WUP body 从 `0x10 0x03` 字段开始。仓库的 `WssMessage.decode()`、`frame_decoder._extract_wup()` 和 `WupResponse.decode()` 可以复用。
|
||||||
|
- `launch.wsLaunch`、`mobileui.getConfig` 完成连接初始化;业务 WUP `requestId` 在同一会话中递增,不能写死 HAR 值。
|
||||||
|
|
||||||
|
## 3. 请求链
|
||||||
|
|
||||||
|
精英宝典 WebSocket entry 75 的可复现顺序如下:
|
||||||
|
|
||||||
|
1. `launch.wsLaunch` -> `mobileui.getConfig`。
|
||||||
|
2. `GameBizUI.getQiWeiRelationByUid`(页面辅助信息)。
|
||||||
|
3. `webActUI.checkUserBindGameAccount(bActId=9271)`,返回和平精英、微信/QQ 平台、角色绑定状态。
|
||||||
|
4. 并行查询:`getActPrizeList(sid=2203)`、`getActInfo(actId=25135)`、`getActTaskDetail(actId=25135)`。
|
||||||
|
5. `getActUserTaskDetail(userId, actId=25135)`、`getUserScore(sid=2203)`。
|
||||||
|
6. 绑定流程:再次检查绑定 -> `huyauserui.getUserProfileBatch` -> `getLiveLinkParam(bActId=9271)` -> `confirmBindActAccount(bActId=9271)` -> 再次检查绑定。
|
||||||
|
7. 记录页:`getUserPrizeRecords(sid=2203)` -> `getEntityPrizeFieldMap` -> `getModuleAddress(moduleId=20051)`。
|
||||||
|
8. 商城前置:`shopMiddleUI.getGoodsInfoV5`;确认页还调用 `getSupplierInfoV5`、`listPayChannelV5`、`checkHyProtocolV5`、`PlayMallUI.getMyPromotion`、`calcOrderPromotion`。
|
||||||
|
9. 页面会重复刷新 `getActPrizeList` 与 `getUserScore`;轮询间隔由工作台自行配置,建议 10-30 秒,写操作后只做一次短轮询。
|
||||||
|
|
||||||
|
第一份 HAR 的扫码登录链为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /web/middle/2.6/.../https/...
|
||||||
|
POST /qrLgn/getQrId
|
||||||
|
GET /qrLgn/getQrImg?k=<qrId>&appId=5002
|
||||||
|
POST /qrLgn/tryQrLogin # stage=0 -> stage=1 -> success
|
||||||
|
POST /web/cookie/verify
|
||||||
|
```
|
||||||
|
|
||||||
|
成功登录后得到的 UID、Cookie、`baseinfo` 必须传入同一个 WSS/HTTP 会话。二维码 ID、requestId、context、biztoken、guid、`udb_cred`、`sdid`、时间戳和签名均为动态值,报告不固化样本机密值。
|
||||||
|
|
||||||
|
### 参数分类
|
||||||
|
|
||||||
|
| 参数 | 类型 | Python 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sid/actId/bActId/moduleId/gid` | 业务参数 | 配置项,默认值只对应本次页面 |
|
||||||
|
| `uid`、游戏账号/角色 | 用户输入或前序响应 | 从当前登录态或绑定响应读取 |
|
||||||
|
| Cookie、`baseinfo`、WSS session | Token/Session | `HuyaHttpClient` 运行时构造并保持 |
|
||||||
|
| `qrId/requestId/context/traceId` | 随机/请求序号 | 登录和 RPC 运行时生成 |
|
||||||
|
| `t/code/sig`、`biztoken`、`udb_cred`、`sdid` | 动态签名/认证 | 只转发前序响应或 Cookie;算法未闭合,不重算 |
|
||||||
|
| `startTime/endTime/leftNum/isCanExchange` | 服务端状态 | 每次查询覆盖本地快照,不写死 |
|
||||||
|
|
||||||
|
HTTP 请求保留 `User-Agent`、`Origin`、`Referer`、`Content-Type: application/octet-stream` 和 `Accept-Language`;`Host`、`Content-Length`、`Connection` 由 HTTP 库生成。WSS 的 `baseinfo` query 和二进制 body 由会话客户端生成,不复制浏览器历史头。
|
||||||
|
|
||||||
|
## 4. 业务响应复核
|
||||||
|
|
||||||
|
### 活动信息
|
||||||
|
|
||||||
|
`getActInfo`:`status=200`,`actId=25135`,名称“活动+购买任务”,时间戳 `1782662400..1790783999`,任务模块 `20051`,外部任务活动 `17096`,游戏 `cjm`,组件 `3203`。`17096` 与绑定用的 `9271` 是不同字段,代码中必须分开命名。
|
||||||
|
|
||||||
|
### 奖品列表
|
||||||
|
|
||||||
|
`getActPrizeList` 在 entry 75 的多次响应均返回 **29 项**、2 个分类:`91=限量返场`(11 项)和 `83=精英专享`(18 项)。示例:
|
||||||
|
|
||||||
|
| pid | 名称 | 积分 | 分类 |
|
||||||
|
|---:|---|---:|---:|
|
||||||
|
| 13161 | 破片手榴弹-粉粉猫爪大礼包 | 3600 | 91 |
|
||||||
|
| 13163 | 烟雾弹-猫咪绅士大礼包 | 3600 | 91 |
|
||||||
|
| 13167 | 套装-幸运萌趣趣 | 1980 | 91 |
|
||||||
|
| 13165 | 套装-奇妙萌趣趣 | 688 | 91 |
|
||||||
|
| 13169 | 套装-幸福萌趣趣 | 1680 | 91 |
|
||||||
|
| 12873 | 套装-青涩年华 | 488 | 83 |
|
||||||
|
| 12867 | 套装-雪山精英 | 600 | 83 |
|
||||||
|
| 12879 | 套装-浪漫波比 | 1280 | 83 |
|
||||||
|
| 12865 | 幸运币礼包(大) | 900 | 83 |
|
||||||
|
|
||||||
|
完整结构还包含 `id/sid/name/score/type/img/sort/frequency/frequencyLimit/num/status/percent/newScore/tags/leftNum/isShowNum/usedNum/goodType/detailImg/detailDesc/whiteUser/commonPrizeId/updateTime/isCanExchange/exchangeStartTime/exchangeEndTime/startTimeSlot/endTimeSlot/isTodayLimit/isUserLimit`。库存、排序和可兑换状态在重复轮询中变化,不能硬编码;工作台应以最新响应覆盖快照。
|
||||||
|
|
||||||
|
### 任务与用户进度
|
||||||
|
|
||||||
|
`getActTaskDetail` 返回 6 个任务:
|
||||||
|
|
||||||
|
- 购买精英令 *100,奖励 10000 分,SPU `hy-5874270`。
|
||||||
|
- 购买精英令 *10,奖励 1000 分,SPU `hy-5874269`。
|
||||||
|
- 购买精英令 *1,奖励 100 分,SPU `hy-5879346`。
|
||||||
|
- 购买精英徽章,奖励 10 分,SPU `hy-5872824`。
|
||||||
|
- 每天观看和平精英直播 10 分钟,奖励 2 分,任务类型 56。
|
||||||
|
- 订阅和平精英福利提醒,奖励 5 分,任务类型 68。
|
||||||
|
|
||||||
|
`getActUserTaskDetail` 的每项至少有 `actId/taskId/taskStatus/prizeStatus/taskValue/taskCount/prizeCount/subTaskDetail`,它是展示任务进度、完成和领取状态的来源。
|
||||||
|
|
||||||
|
### 积分
|
||||||
|
|
||||||
|
响应字段顺序为 `status/msg/gainScore/usedScore/newGainScore/newUsedScore/score/isOpenExchangeTip`。样本值为 `4200,4200,4200,4200,0,0`;页面逻辑优先使用 `max(0, newGainScore-newUsedScore)`,所以当时可用积分为 **0**,不能把任意一个累计字段直接当余额。
|
||||||
|
|
||||||
|
### 绑定、记录与地址
|
||||||
|
|
||||||
|
- 绑定响应显示游戏“和平精英”,账号平台可为微信或 QQ,`isBindAccount`、`isBindRole`、`isNeedActCheck` 和 `changeBindTime` 随状态变化。
|
||||||
|
- `getLiveLinkParam` 返回 `actId=17096`、`gameIdList=cjm`、`livePlatId=huya`、动态 `t/code/sig`。样本不足以闭合 `code/sig` 算法,不实现猜测签名。
|
||||||
|
- `confirmBindActAccount` 样本为 `status=200,msg=请求成功`。
|
||||||
|
- `getUserPrizeRecords` 返回 2 条历史记录,字段包括 `id/name/score/createTime/status/type/isReal/newScore/account/code/commonPrizeType/commonPrizeStatus/orderId/img/goodType/prizeId/recordId/commonPrizeExchangeStatus/commonPrizeId/prizeExtraParam/ymd/commonPrizeMsg`。
|
||||||
|
- `getEntityPrizeFieldMap` 给出实体奖品字段映射:模块 1 为姓名/手机/地址,模块 38 为 QQ,模块 39 为姓名/手机;`getModuleAddress(moduleId=20051)` 返回 `status=201,msg=找不到地址`。
|
||||||
|
|
||||||
|
## 5. Python 实现边界
|
||||||
|
|
||||||
|
仓库现有 `core/huya/http_client.py` 已实现 WUP/TAF、会话 Cookie、积分、奖品列表、记录、任务、绑定、直播链接和商城方法;新增示例见 [`scripts/huya_elite_client.py`](../scripts/huya_elite_client.py)。示例不读取 HAR,运行时从环境变量读取 UID/Cookie,调用顺序为:活动信息/任务/用户任务/绑定/奖品/积分/记录,写操作前重新查询奖品详情。
|
||||||
|
|
||||||
|
推荐状态机:
|
||||||
|
|
||||||
|
```text
|
||||||
|
登录态 -> getActInfo/getActTaskDetail
|
||||||
|
-> checkBind/getActPrizeList/getActUserTaskDetail/getUserScore
|
||||||
|
-> (用户确认后) getActPrizeDetail -> scoreExchangePrize
|
||||||
|
-> getUserScore/getActPrizeList/getUserPrizeRecords 回查
|
||||||
|
```
|
||||||
|
|
||||||
|
兑换必须二次校验 `isCanExchange`、活动时间、库存、频次、今日/用户限制和可用积分。`getActPrizeDetail`、`scoreExchangePrize` 成功响应以及地址写入尚未出现在样本中,客户端应把它们作为显式待补接口,而不是猜测字段。
|
||||||
|
|
||||||
|
## 6. 尚缺证据与补抓清单
|
||||||
|
|
||||||
|
当前 HAR 未闭合:`getActPrizeDetail` 响应、`scoreExchangePrize` 成功请求/响应、`addModuleAddress`、`createOrderV5`、`payOrderSubmitV5`、支付完成后的订单状态和宝典开通字段。需要重新抓“点击兑换”和“点击开通宝典至支付完成”的完整操作,并保留 Initiator、页面 JavaScript 和同一登录态。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from web.backend.database import Base
|
||||||
|
from web.backend.models import HuyaAccount, HuyaWorkbench, HuyaWorkbenchAccount, User
|
||||||
|
from web.backend.routers.huya import (
|
||||||
|
delete_account,
|
||||||
|
list_huya_workbench_accounts,
|
||||||
|
list_tasks,
|
||||||
|
update_huya_workbench_accounts,
|
||||||
|
)
|
||||||
|
from web.backend.schemas import HuyaTaskBatchRequest, HuyaWorkbenchAccountsUpdate
|
||||||
|
from web.backend.services.huya_service import create_planned_tasks
|
||||||
|
|
||||||
|
|
||||||
|
class TestHuyaWorkbenchScope:
|
||||||
|
def setup_method(self):
|
||||||
|
self.engine = create_engine("sqlite://")
|
||||||
|
|
||||||
|
@event.listens_for(self.engine, "connect")
|
||||||
|
def enable_foreign_keys(connection, _):
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
|
self.user = User(username="operator", password_hash="hash", role="super_admin")
|
||||||
|
self.account = HuyaAccount(uid="100", yyuid="100", cookie="yyuid=100; udb_uid=100")
|
||||||
|
self.session.add_all([self.user, self.account])
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_accounts_and_tasks_are_scoped(self):
|
||||||
|
update_huya_workbench_accounts(
|
||||||
|
HuyaWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[self.account.id]),
|
||||||
|
db=self.session,
|
||||||
|
current=self.user,
|
||||||
|
)
|
||||||
|
result = list_huya_workbench_accounts("elite", db=self.session, current=self.user)
|
||||||
|
assert result == {"account_ids": [self.account.id], "configured": True}
|
||||||
|
assert self.session.query(HuyaWorkbench).count() == 1
|
||||||
|
|
||||||
|
elite_batch, count = create_planned_tasks(
|
||||||
|
self.session,
|
||||||
|
[self.account.id],
|
||||||
|
"query_act_tasks",
|
||||||
|
self.user.id,
|
||||||
|
handbook_scope="elite",
|
||||||
|
)
|
||||||
|
assert count == 1
|
||||||
|
tasks = list_tasks(
|
||||||
|
handbook_scope="elite",
|
||||||
|
include_images=False,
|
||||||
|
db=self.session,
|
||||||
|
current=self.user,
|
||||||
|
)
|
||||||
|
assert [task.batch_id for task in tasks] == [elite_batch]
|
||||||
|
assert tasks[0].handbook_scope == "elite"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="无效的工作台"):
|
||||||
|
create_planned_tasks(
|
||||||
|
self.session,
|
||||||
|
[self.account.id],
|
||||||
|
"query_points",
|
||||||
|
self.user.id,
|
||||||
|
handbook_scope="invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_account_delete_cleans_workbench_membership(self):
|
||||||
|
self.session.add(
|
||||||
|
HuyaWorkbenchAccount(
|
||||||
|
user_id=self.user.id,
|
||||||
|
handbook_scope="elite",
|
||||||
|
account_id=self.account.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
result = delete_account(self.account.id, db=self.session, current=self.user)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert self.session.query(HuyaAccount).count() == 0
|
||||||
|
assert self.session.query(HuyaWorkbenchAccount).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_huya_batch_schema_defaults_to_legacy():
|
||||||
|
request = HuyaTaskBatchRequest(account_ids=[1], task_type="query_points")
|
||||||
|
assert request.handbook_scope == "legacy"
|
||||||
@@ -22,7 +22,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
|||||||
PROJECT_ROOT = TESTS_DIR.parent
|
PROJECT_ROOT = TESTS_DIR.parent
|
||||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||||
|
|
||||||
HEAD_REVISION = "20260830_0031"
|
HEAD_REVISION = "20260831_0032"
|
||||||
|
|
||||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||||
EXTRA_INDEXES = (
|
EXTRA_INDEXES = (
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""增加虎牙精英宝典工作台范围与账号集合。"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260831_0032"
|
||||||
|
down_revision: str | None = "20260830_0031"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(bind, table: str) -> set[str]:
|
||||||
|
return {item["name"] for item in sa.inspect(bind).get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if "handbook_scope" not in _columns(bind, "huya_tasks"):
|
||||||
|
op.add_column(
|
||||||
|
"huya_tasks",
|
||||||
|
sa.Column("handbook_scope", sa.String(length=16), nullable=False, server_default="legacy"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_huya_tasks_handbook_scope", "huya_tasks", ["handbook_scope"])
|
||||||
|
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if not inspector.has_table("huya_workbench_accounts"):
|
||||||
|
op.create_table(
|
||||||
|
"huya_workbench_accounts",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("account_id", sa.Integer(), sa.ForeignKey("huya_accounts.id"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_huya_workbench_account"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_huya_workbench_accounts_user_id", "huya_workbench_accounts", ["user_id"])
|
||||||
|
op.create_index("ix_huya_workbench_accounts_handbook_scope", "huya_workbench_accounts", ["handbook_scope"])
|
||||||
|
op.create_index("ix_huya_workbench_accounts_account_id", "huya_workbench_accounts", ["account_id"])
|
||||||
|
|
||||||
|
if not inspector.has_table("huya_workbenches"):
|
||||||
|
op.create_table(
|
||||||
|
"huya_workbenches",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_huya_workbench"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_huya_workbenches_user_id", "huya_workbenches", ["user_id"])
|
||||||
|
op.create_index("ix_huya_workbenches_handbook_scope", "huya_workbenches", ["handbook_scope"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if inspector.has_table("huya_workbenches"):
|
||||||
|
op.drop_table("huya_workbenches")
|
||||||
|
if inspector.has_table("huya_workbench_accounts"):
|
||||||
|
op.drop_table("huya_workbench_accounts")
|
||||||
|
if "handbook_scope" in _columns(bind, "huya_tasks"):
|
||||||
|
op.drop_index("ix_huya_tasks_handbook_scope", table_name="huya_tasks")
|
||||||
|
op.drop_column("huya_tasks", "handbook_scope")
|
||||||
@@ -498,6 +498,10 @@ class HuyaTask(Base):
|
|||||||
ForeignKey("huya_accounts.id"), nullable=False
|
ForeignKey("huya_accounts.id"), nullable=False
|
||||||
)
|
)
|
||||||
task_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
task_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
# 任务归属工作台;legacy 保留旧虎牙任务,elite 隔离精英宝典任务。
|
||||||
|
handbook_scope: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="legacy", index=True
|
||||||
|
)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
message: Mapped[str] = mapped_column(String(512), default="")
|
message: Mapped[str] = mapped_column(String(512), default="")
|
||||||
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
@@ -508,6 +512,42 @@ class HuyaTask(Base):
|
|||||||
account: Mapped[HuyaAccount] = relationship("HuyaAccount", back_populates="tasks")
|
account: Mapped[HuyaAccount] = relationship("HuyaAccount", back_populates="tasks")
|
||||||
|
|
||||||
|
|
||||||
|
class HuyaWorkbenchAccount(Base):
|
||||||
|
"""用户在虎牙指定工作台中启用的账号。"""
|
||||||
|
|
||||||
|
__tablename__ = "huya_workbench_accounts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
handbook_scope: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||||
|
account_id: Mapped[int] = mapped_column(ForeignKey("huya_accounts.id"), nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_id", "handbook_scope", "account_id",
|
||||||
|
name="uq_huya_workbench_account",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HuyaWorkbench(Base):
|
||||||
|
"""工作台配置哨兵,令空账号集合也能跨浏览器同步。"""
|
||||||
|
|
||||||
|
__tablename__ = "huya_workbenches"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
handbook_scope: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=_utcnow, onupdate=_utcnow
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "handbook_scope", name="uq_huya_workbench"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HuyaConfig(Base):
|
class HuyaConfig(Base):
|
||||||
"""虎牙业务配置"""
|
"""虎牙业务配置"""
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ from ..models import (
|
|||||||
HuyaRegisterItem,
|
HuyaRegisterItem,
|
||||||
HuyaRegisterSuccessLog,
|
HuyaRegisterSuccessLog,
|
||||||
HuyaTask,
|
HuyaTask,
|
||||||
|
HuyaWorkbench,
|
||||||
|
HuyaWorkbenchAccount,
|
||||||
ProxyConfig,
|
ProxyConfig,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
@@ -79,6 +81,7 @@ from ..schemas import (
|
|||||||
HuyaSmsLoginRequest,
|
HuyaSmsLoginRequest,
|
||||||
HuyaTaskBatchRequest,
|
HuyaTaskBatchRequest,
|
||||||
HuyaTaskOut,
|
HuyaTaskOut,
|
||||||
|
HuyaWorkbenchAccountsUpdate,
|
||||||
)
|
)
|
||||||
from ..services.audit_service import record_audit
|
from ..services.audit_service import record_audit
|
||||||
from ..services.huya_register_runner import (
|
from ..services.huya_register_runner import (
|
||||||
@@ -281,6 +284,18 @@ def _require_huya_task_account_access(
|
|||||||
raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号")
|
raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号")
|
||||||
|
|
||||||
|
|
||||||
|
def _elite_workbench_account_ids(db: Session, current: User) -> list[int]:
|
||||||
|
query = db.query(HuyaWorkbenchAccount.account_id).filter(
|
||||||
|
HuyaWorkbenchAccount.user_id == current.id,
|
||||||
|
HuyaWorkbenchAccount.handbook_scope == "elite",
|
||||||
|
)
|
||||||
|
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:
|
def _require_huya_batch_owner(db: Session, current: User, batch_id: str) -> None:
|
||||||
"""客服只能停止或订阅自己创建的任务批次。"""
|
"""客服只能停止或订阅自己创建的任务批次。"""
|
||||||
if _can_view_huya_all(current):
|
if _can_view_huya_all(current):
|
||||||
@@ -385,6 +400,7 @@ def _task_out(task: HuyaTask, *, include_images: bool = False) -> HuyaTaskOut:
|
|||||||
account_uid=account.uid if account else "",
|
account_uid=account.uid if account else "",
|
||||||
account_nickname=account.nickname if account else "",
|
account_nickname=account.nickname if account else "",
|
||||||
task_type=task.task_type,
|
task_type=task.task_type,
|
||||||
|
handbook_scope=getattr(task, "handbook_scope", "legacy") or "legacy",
|
||||||
status=task.status or "",
|
status=task.status or "",
|
||||||
message=task.message or "",
|
message=task.message or "",
|
||||||
result=_sanitize_task_result(
|
result=_sanitize_task_result(
|
||||||
@@ -1282,6 +1298,9 @@ def delete_accounts_batch(
|
|||||||
db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete(
|
db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete(
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
|
db.query(HuyaWorkbenchAccount).filter(
|
||||||
|
HuyaWorkbenchAccount.account_id.in_(ids)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
_clear_huya_account_references(db, ids)
|
_clear_huya_account_references(db, ids)
|
||||||
deleted = (
|
deleted = (
|
||||||
db.query(HuyaAccount)
|
db.query(HuyaAccount)
|
||||||
@@ -1310,6 +1329,9 @@ def delete_accounts_batch_selection(
|
|||||||
db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete(
|
db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete(
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
|
db.query(HuyaWorkbenchAccount).filter(
|
||||||
|
HuyaWorkbenchAccount.account_id.in_(ids)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
_clear_huya_account_references(db, ids)
|
_clear_huya_account_references(db, ids)
|
||||||
deleted = (
|
deleted = (
|
||||||
db.query(HuyaAccount)
|
db.query(HuyaAccount)
|
||||||
@@ -1339,6 +1361,9 @@ def delete_account(
|
|||||||
db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete(
|
db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete(
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
|
db.query(HuyaWorkbenchAccount).filter(
|
||||||
|
HuyaWorkbenchAccount.account_id == account_id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
_clear_huya_account_references(db, [account_id])
|
_clear_huya_account_references(db, [account_id])
|
||||||
db.delete(account)
|
db.delete(account)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -1743,6 +1768,55 @@ def update_config(
|
|||||||
return _config_out(config)
|
return _config_out(config)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workbench-accounts")
|
||||||
|
def list_huya_workbench_accounts(
|
||||||
|
handbook_scope: str = Query(..., pattern="^elite$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("huya:task")),
|
||||||
|
):
|
||||||
|
"""读取当前用户的虎牙精英宝典账号集合。"""
|
||||||
|
ids = _elite_workbench_account_ids(db, current)
|
||||||
|
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])
|
@router.get("/goods", response_model=list[HuyaGoodsOut])
|
||||||
def list_goods(
|
def list_goods(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -1794,6 +1868,7 @@ async def create_task_batch(
|
|||||||
req.task_type,
|
req.task_type,
|
||||||
current.id,
|
current.id,
|
||||||
req.payload,
|
req.payload,
|
||||||
|
req.handbook_scope,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
@@ -1838,6 +1913,7 @@ async def create_task_batch(
|
|||||||
@router.get("/tasks", response_model=list[HuyaTaskOut])
|
@router.get("/tasks", response_model=list[HuyaTaskOut])
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
|
handbook_scope: str | None = Query(None, pattern="^(legacy|elite)$"),
|
||||||
include_images: bool = Query(
|
include_images: bool = Query(
|
||||||
False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)"
|
False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)"
|
||||||
),
|
),
|
||||||
@@ -1848,6 +1924,8 @@ def list_tasks(
|
|||||||
query = _visible_huya_tasks_query(db, current)
|
query = _visible_huya_tasks_query(db, current)
|
||||||
if batch_id:
|
if batch_id:
|
||||||
query = query.filter(HuyaTask.batch_id == 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()
|
tasks = query.order_by(HuyaTask.id.desc()).limit(300).all()
|
||||||
return [_task_out(task, include_images=include_images) for task in tasks]
|
return [_task_out(task, include_images=include_images) for task in tasks]
|
||||||
|
|
||||||
|
|||||||
@@ -503,10 +503,16 @@ class HuyaConfigUpdate(BaseModel):
|
|||||||
class HuyaTaskBatchRequest(BaseModel):
|
class HuyaTaskBatchRequest(BaseModel):
|
||||||
account_ids: list[int]
|
account_ids: list[int]
|
||||||
task_type: str
|
task_type: str
|
||||||
|
handbook_scope: str = Field("legacy", pattern="^(legacy|elite)$")
|
||||||
concurrency: int = 3
|
concurrency: int = 3
|
||||||
payload: dict[str, Any] = Field(default_factory=dict)
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class HuyaWorkbenchAccountsUpdate(BaseModel):
|
||||||
|
handbook_scope: str = Field(..., pattern="^elite$")
|
||||||
|
account_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class HuyaTaskOut(BaseModel):
|
class HuyaTaskOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
batch_id: str
|
batch_id: str
|
||||||
@@ -514,6 +520,7 @@ class HuyaTaskOut(BaseModel):
|
|||||||
account_uid: str = ""
|
account_uid: str = ""
|
||||||
account_nickname: str = ""
|
account_nickname: str = ""
|
||||||
task_type: str
|
task_type: str
|
||||||
|
handbook_scope: str = "legacy"
|
||||||
status: str
|
status: str
|
||||||
message: str = ""
|
message: str = ""
|
||||||
result: dict[str, Any] | None = None
|
result: dict[str, Any] | None = None
|
||||||
@@ -532,6 +539,7 @@ class HuyaTaskOut(BaseModel):
|
|||||||
"account_uid": self.account_uid,
|
"account_uid": self.account_uid,
|
||||||
"account_nickname": self.account_nickname,
|
"account_nickname": self.account_nickname,
|
||||||
"task_type": self.task_type,
|
"task_type": self.task_type,
|
||||||
|
"handbook_scope": self.handbook_scope,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
"result": self.result,
|
"result": self.result,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class HuyaBatchRunner(
|
|||||||
self._push_log("info", f"[{current}/{total}] 开始虎牙任务: {name}")
|
self._push_log("info", f"[{current}/{total}] 开始虎牙任务: {name}")
|
||||||
|
|
||||||
if self.task_type not in {
|
if self.task_type not in {
|
||||||
|
"query_act_tasks",
|
||||||
"query_points",
|
"query_points",
|
||||||
"get_bind_qr",
|
"get_bind_qr",
|
||||||
"confirm_bind",
|
"confirm_bind",
|
||||||
@@ -77,7 +78,11 @@ class HuyaBatchRunner(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.task_type == "query_points":
|
if self.task_type == "query_act_tasks":
|
||||||
|
self._execute_query_act_tasks(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
|
elif self.task_type == "query_points":
|
||||||
self._execute_query_points(
|
self._execute_query_points(
|
||||||
worker_db, task, account, account_info, config_info
|
worker_db, task, account, account_info, config_info
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,52 @@ class GoodsMixin:
|
|||||||
def _wait_until(self, when: datetime, uid: int) -> bool: ...
|
def _wait_until(self, when: datetime, uid: int) -> bool: ...
|
||||||
def _parse_scheduled_time(self, value: Any) -> datetime | None: ...
|
def _parse_scheduled_time(self, value: Any) -> datetime | None: ...
|
||||||
|
|
||||||
|
def _execute_query_act_tasks(
|
||||||
|
self,
|
||||||
|
worker_db: Session,
|
||||||
|
task: HuyaTask,
|
||||||
|
account: HuyaAccount,
|
||||||
|
account_info: dict,
|
||||||
|
config_info: dict,
|
||||||
|
):
|
||||||
|
"""读取精英宝典任务详情,供专用工作台展示购买/观看任务。"""
|
||||||
|
uid = self._resolve_uid(account_info)
|
||||||
|
cookie = account_info.get("cookie") or ""
|
||||||
|
if not uid or not cookie:
|
||||||
|
self._mark_task(worker_db, task, "failed", "账号 UID 或 Cookie 为空")
|
||||||
|
return
|
||||||
|
act_id = self._to_int(self.payload.get("act_id") or 25135)
|
||||||
|
if not act_id:
|
||||||
|
self._mark_task(worker_db, task, "failed", "精英宝典活动 ID 无效")
|
||||||
|
return
|
||||||
|
client: Any = HuyaHttpClient(
|
||||||
|
logger=lambda msg: self._push_log("info", f"[{uid}] {msg}")
|
||||||
|
)
|
||||||
|
response = client.get_act_task_detail(uid=uid, cookie=cookie, act_id=act_id)
|
||||||
|
if response is None:
|
||||||
|
self._mark_task(worker_db, task, "error", "虎牙活动任务接口无响应")
|
||||||
|
return
|
||||||
|
result = response.to_dict()
|
||||||
|
result["act_id"] = act_id
|
||||||
|
if response.status != 200:
|
||||||
|
self._mark_task(
|
||||||
|
worker_db,
|
||||||
|
task,
|
||||||
|
"failed",
|
||||||
|
response.msg or f"虎牙活动任务查询失败: {response.status}",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
account.status = "tasks_queried"
|
||||||
|
account.updated_at = datetime.now(UTC)
|
||||||
|
self._mark_task(
|
||||||
|
worker_db,
|
||||||
|
task,
|
||||||
|
"success",
|
||||||
|
f"已读取 {len(result.get('tasks', []))} 项精英宝典任务",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
def _execute_query_points(
|
def _execute_query_points(
|
||||||
self,
|
self,
|
||||||
worker_db: Session,
|
worker_db: Session,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ..huya_defaults import HUYA_CONFIG_DEFAULTS, HUYA_CONFIG_FIELDS
|
|||||||
from ..models import HuyaAccount, HuyaConfig, HuyaTask
|
from ..models import HuyaAccount, HuyaConfig, HuyaTask
|
||||||
|
|
||||||
SUPPORTED_TASK_TYPES = {
|
SUPPORTED_TASK_TYPES = {
|
||||||
|
"query_act_tasks": "查询精英宝典任务",
|
||||||
"get_bind_qr": "获取绑定二维码",
|
"get_bind_qr": "获取绑定二维码",
|
||||||
"query_points": "一键查询积分",
|
"query_points": "一键查询积分",
|
||||||
"query_game_name": "一键查询游戏名",
|
"query_game_name": "一键查询游戏名",
|
||||||
@@ -25,6 +26,19 @@ SUPPORTED_TASK_TYPES = {
|
|||||||
"create_recharge_order": "生成支付二维码",
|
"create_recharge_order": "生成支付二维码",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HUYA_HANDBOOK_TASK_TYPES = {
|
||||||
|
"query_act_tasks",
|
||||||
|
"get_bind_qr",
|
||||||
|
"confirm_bind",
|
||||||
|
"query_points",
|
||||||
|
"query_game_name",
|
||||||
|
"refresh_goods",
|
||||||
|
"exchange_goods",
|
||||||
|
"query_exchange_records",
|
||||||
|
"refresh_recharge_goods",
|
||||||
|
"create_recharge_order",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def huya_config_value(field: str, value: str | None) -> str:
|
def huya_config_value(field: str, value: str | None) -> str:
|
||||||
"""读取配置值;空值自动回退到当前活动默认配置。"""
|
"""读取配置值;空值自动回退到当前活动默认配置。"""
|
||||||
@@ -371,10 +385,15 @@ def create_planned_tasks(
|
|||||||
task_type: str,
|
task_type: str,
|
||||||
created_by: int,
|
created_by: int,
|
||||||
payload: dict | None = None,
|
payload: dict | None = None,
|
||||||
|
handbook_scope: str = "legacy",
|
||||||
) -> tuple[str, int]:
|
) -> tuple[str, int]:
|
||||||
"""创建虎牙任务记录,等待后台执行器消费。"""
|
"""创建虎牙任务记录,等待后台执行器消费。"""
|
||||||
if task_type not in SUPPORTED_TASK_TYPES:
|
if task_type not in SUPPORTED_TASK_TYPES:
|
||||||
raise ValueError("不支持的任务类型")
|
raise ValueError("不支持的任务类型")
|
||||||
|
if handbook_scope not in {"legacy", "elite"}:
|
||||||
|
raise ValueError("无效的工作台")
|
||||||
|
if handbook_scope == "elite" and task_type not in HUYA_HANDBOOK_TASK_TYPES:
|
||||||
|
raise ValueError("该任务不属于精英宝典工作台")
|
||||||
|
|
||||||
batch_id = uuid.uuid4().hex[:12]
|
batch_id = uuid.uuid4().hex[:12]
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
@@ -392,6 +411,7 @@ def create_planned_tasks(
|
|||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
task_type=task_type,
|
task_type=task_type,
|
||||||
|
handbook_scope=handbook_scope,
|
||||||
status="planned",
|
status="planned",
|
||||||
message="任务已创建,等待执行",
|
message="任务已创建,等待执行",
|
||||||
result={"payload": payload} if payload else None,
|
result={"payload": payload} if payload else None,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
|||||||
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
|
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
|
||||||
const HuyaDeviceBindingsPage = lazy(() => import('./pages/HuyaDeviceBindingsPage'));
|
const HuyaDeviceBindingsPage = lazy(() => import('./pages/HuyaDeviceBindingsPage'));
|
||||||
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
|
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
|
||||||
|
const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage'));
|
||||||
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
|
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
|
||||||
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
|
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
|
||||||
|
|
||||||
@@ -94,6 +95,7 @@ function AppContent() {
|
|||||||
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
|
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
|
||||||
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
|
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
|
||||||
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
|
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
|
||||||
|
<Route path="huya/elite" element={lazyRoute(<HuyaElitePage />)} />
|
||||||
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
|
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
|
||||||
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
||||||
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
||||||
|
|||||||
@@ -118,12 +118,24 @@ export const huyaApi = {
|
|||||||
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
|
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
|
||||||
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
|
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
|
||||||
listRechargeGoods: () => api.get<HuyaRechargeGoodsItem[], HuyaRechargeGoodsItem[]>('/huya/recharge-goods'),
|
listRechargeGoods: () => api.get<HuyaRechargeGoodsItem[], HuyaRechargeGoodsItem[]>('/huya/recharge-goods'),
|
||||||
|
listWorkbenchAccounts: (scope: 'elite' = 'elite') =>
|
||||||
|
api.get<{ account_ids: number[]; configured: boolean }, { account_ids: number[]; configured: boolean }>(
|
||||||
|
'/huya/workbench-accounts', { params: { handbook_scope: scope } },
|
||||||
|
),
|
||||||
|
updateWorkbenchAccounts: (accountIds: number[], scope: 'elite' = 'elite') =>
|
||||||
|
api.put<{ account_ids: number[]; success: boolean }, { account_ids: number[]; success: boolean }>(
|
||||||
|
'/huya/workbench-accounts', { handbook_scope: scope, account_ids: accountIds },
|
||||||
|
),
|
||||||
createTasks: (data: HuyaTaskBatchRequest) =>
|
createTasks: (data: HuyaTaskBatchRequest) =>
|
||||||
api.post<HuyaTaskBatchResult, HuyaTaskBatchResult>('/huya/tasks/batch', data),
|
api.post<HuyaTaskBatchResult, HuyaTaskBatchResult>('/huya/tasks/batch', data),
|
||||||
listTasks: (batchId?: string) =>
|
listTasks: (batchId?: string, scope?: 'legacy' | 'elite') =>
|
||||||
api.get<HuyaTaskItem[], HuyaTaskItem[]>('/huya/tasks', {
|
api.get<HuyaTaskItem[], HuyaTaskItem[]>('/huya/tasks', {
|
||||||
// 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。
|
// 列表轮询默认不带 base64 小程序码,避免每次 1MB+ 流量。
|
||||||
params: batchId ? { batch_id: batchId, include_images: false } : { include_images: false },
|
params: {
|
||||||
|
...(batchId ? { batch_id: batchId } : {}),
|
||||||
|
...(scope ? { handbook_scope: scope } : {}),
|
||||||
|
include_images: false,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/huya/tasks/summary'),
|
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/huya/tasks/summary'),
|
||||||
getTask: (taskId: number) =>
|
getTask: (taskId: number) =>
|
||||||
|
|||||||
@@ -682,6 +682,7 @@ export interface HuyaConfig {
|
|||||||
export interface HuyaTaskBatchRequest {
|
export interface HuyaTaskBatchRequest {
|
||||||
account_ids: number[];
|
account_ids: number[];
|
||||||
task_type: string;
|
task_type: string;
|
||||||
|
handbook_scope?: 'legacy' | 'elite';
|
||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -699,6 +700,7 @@ export interface HuyaTaskItem {
|
|||||||
account_uid: string;
|
account_uid: string;
|
||||||
account_nickname: string;
|
account_nickname: string;
|
||||||
task_type: string;
|
task_type: string;
|
||||||
|
handbook_scope: 'legacy' | 'elite';
|
||||||
status: string;
|
status: string;
|
||||||
message: string;
|
message: string;
|
||||||
result: Record<string, unknown> | null;
|
result: Record<string, unknown> | null;
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
|||||||
huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
|
huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
|
||||||
}
|
}
|
||||||
if (can('huya:task')) {
|
if (can('huya:task')) {
|
||||||
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
|
huyaItems.push({ key: '/huya/elite', label: '精英宝典', icon: <BookOutlined /> });
|
||||||
|
huyaItems.push({ key: '/huya/tasks', label: '旧版任务台', icon: <ShoppingCartOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canAny(['yyb:session', 'yyb:history'])) {
|
if (canAny(['yyb:session', 'yyb:history'])) {
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { TableProps } from 'antd';
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined, ColumnWidthOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
|
||||||
|
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaTaskItem } from '../api/modules';
|
||||||
|
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
||||||
|
import { getErrorMessage } from '../utils/error';
|
||||||
|
import { message } from '../utils/antdMessage';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const SCOPE = 'elite' as const;
|
||||||
|
const ACT_ID = 25135;
|
||||||
|
const DEFAULT_SID = '2203';
|
||||||
|
const LAYOUT_KEY = 'huya_elite_layout_mode';
|
||||||
|
|
||||||
|
const TASK_LABELS: Record<string, string> = {
|
||||||
|
query_act_tasks: '读取宝典任务', get_bind_qr: '获取绑定二维码', query_game_name: '查询游戏角色',
|
||||||
|
confirm_bind: '确认绑定角色', query_points: '查询积分', refresh_goods: '刷新商品',
|
||||||
|
exchange_goods: '兑换商品', query_exchange_records: '查询兑换记录', create_recharge_order: '生成开通支付码',
|
||||||
|
};
|
||||||
|
const TASK_COLORS: Record<string, string> = {
|
||||||
|
planned: 'default', pending: 'default', running: 'processing', success: 'success',
|
||||||
|
failed: 'error', error: 'error', stopped: 'warning',
|
||||||
|
};
|
||||||
|
const TASK_STATUS_LABELS: Record<string, string> = {
|
||||||
|
planned: '已计划', pending: '等待中', running: '执行中', success: '成功',
|
||||||
|
failed: '失败', error: '异常', stopped: '已停止',
|
||||||
|
};
|
||||||
|
|
||||||
|
function savedInterval(): number {
|
||||||
|
const value = Number(localStorage.getItem('huya_elite_refresh_interval'));
|
||||||
|
return Number.isInteger(value) && value >= 5 && value <= 60 ? value : 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultText(task: HuyaTaskItem | undefined, key: string): string {
|
||||||
|
const value = task?.result?.[key];
|
||||||
|
return value == null ? '' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | undefined {
|
||||||
|
return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HuyaElitePage() {
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const canConfig = can('huya:config');
|
||||||
|
const [pool, setPool] = useState<HuyaAccountItem[]>([]);
|
||||||
|
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
|
||||||
|
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
|
||||||
|
const [selectedGoodsId, setSelectedGoodsId] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [concurrency, setConcurrency] = useState(3);
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState(savedInterval);
|
||||||
|
const [layoutMode, setLayoutMode] = useState<'split' | 'stack'>(() => localStorage.getItem(LAYOUT_KEY) === 'stack' ? 'stack' : 'split');
|
||||||
|
const [activeBatches, setActiveBatches] = useState<string[]>([]);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [importSearch, setImportSearch] = useState('');
|
||||||
|
const [importTag, setImportTag] = useState('');
|
||||||
|
const [importTags, setImportTags] = useState<string[]>([]);
|
||||||
|
const [importSelected, setImportSelected] = useState<number[]>([]);
|
||||||
|
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
|
||||||
|
const [config, setConfig] = useState<HuyaConfig | null>(null);
|
||||||
|
const [configDraft, setConfigDraft] = useState<HuyaConfig | null>(null);
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
|
const [configLoading, setConfigLoading] = useState(false);
|
||||||
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const tasksLoading = useRef(false);
|
||||||
|
const logs = useWebSocketLogs();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [poolResult, workbenchResult, goodsResult, taskResult] = await Promise.all([
|
||||||
|
huyaApi.listAccounts({ include_cookie: false }), huyaApi.listWorkbenchAccounts(SCOPE),
|
||||||
|
huyaApi.listGoods(), huyaApi.listTasks(undefined, SCOPE),
|
||||||
|
]);
|
||||||
|
setPool(poolResult);
|
||||||
|
const byId = new Map(poolResult.map((item) => [item.id, item]));
|
||||||
|
let storedIds: number[] = workbenchResult.account_ids;
|
||||||
|
if (!workbenchResult.configured) {
|
||||||
|
try { storedIds = JSON.parse(localStorage.getItem('huya_elite_workbench_account_ids') || '[]') as number[]; } catch { storedIds = []; }
|
||||||
|
}
|
||||||
|
setAccounts(storedIds.map((id) => byId.get(Number(id))).filter((item): item is HuyaAccountItem => Boolean(item)));
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => storedIds.includes(id)));
|
||||||
|
setGoods(goodsResult);
|
||||||
|
setTasks(taskResult);
|
||||||
|
} catch (error) { message.error(getErrorMessage(error)); } finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTasks = useCallback(async () => {
|
||||||
|
if (tasksLoading.current) return;
|
||||||
|
tasksLoading.current = true;
|
||||||
|
try { setTasks(await huyaApi.listTasks(undefined, SCOPE)); } catch { /* 下一轮刷新 */ } finally { tasksLoading.current = false; }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
useEffect(() => { if (importOpen) void huyaApi.listTags().then(setImportTags).catch(() => {}); }, [importOpen]);
|
||||||
|
useEffect(() => {
|
||||||
|
const active = tasks.some((task) => ['planned', 'pending', 'running'].includes(task.status));
|
||||||
|
const timer = window.setInterval(() => { void loadTasks(); }, (active ? 3 : refreshInterval) * 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [loadTasks, refreshInterval, tasks]);
|
||||||
|
|
||||||
|
const saveAccounts = async (ids: number[]) => {
|
||||||
|
const saved = await huyaApi.updateWorkbenchAccounts(ids, SCOPE);
|
||||||
|
localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(saved.account_ids));
|
||||||
|
const next = saved.account_ids.map((id) => pool.find((item) => item.id === id)).filter((item): item is HuyaAccountItem => Boolean(item));
|
||||||
|
setAccounts(next);
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => saved.account_ids.includes(id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTask = async (taskType: string) => {
|
||||||
|
if (!selectedIds.length) { message.warning('请先勾选账号'); return; }
|
||||||
|
if (taskType === 'exchange_goods' && !selectedGoodsId) { message.warning('请先选择兑换商品'); return; }
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
const payload = taskType === 'exchange_goods'
|
||||||
|
? { sid: Number(config?.sid || DEFAULT_SID), product_id: Number(selectedGoodsId), act_id: ACT_ID }
|
||||||
|
: taskType === 'query_act_tasks' ? { act_id: ACT_ID } : {};
|
||||||
|
const created = await huyaApi.createTasks({ account_ids: selectedIds, task_type: taskType, handbook_scope: SCOPE, concurrency, payload });
|
||||||
|
setActiveBatches((prev) => [...new Set([...prev, created.batch_id])]);
|
||||||
|
logs.connectBatch(created.batch_id, `/api/huya/ws/${created.batch_id}`, {
|
||||||
|
clear: false,
|
||||||
|
onTask: (raw) => setTasks((prev) => [raw as unknown as HuyaTaskItem, ...prev.filter((item) => item.id !== Number((raw as { id?: number }).id))].slice(0, 300)),
|
||||||
|
onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void loadTasks(); },
|
||||||
|
});
|
||||||
|
message.success(`已创建 ${created.count} 个任务`);
|
||||||
|
window.setTimeout(() => { void loadTasks(); }, 400);
|
||||||
|
} catch (error) { message.error(getErrorMessage(error)); } finally { setStarting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettings = async () => {
|
||||||
|
if (!canConfig) return;
|
||||||
|
setConfigOpen(true);
|
||||||
|
if (config) { setConfigDraft({ ...config }); return; }
|
||||||
|
setConfigLoading(true);
|
||||||
|
try { const current = await huyaApi.getConfig(); setConfig(current); setConfigDraft({ ...current }); }
|
||||||
|
catch (error) { setConfigOpen(false); message.error(getErrorMessage(error)); }
|
||||||
|
finally { setConfigLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
if (!configDraft) return;
|
||||||
|
setConfigSaving(true);
|
||||||
|
try { const saved = await huyaApi.updateConfig(configDraft); setConfig(saved); setConfigDraft({ ...saved }); setConfigOpen(false); message.success('配置已保存'); }
|
||||||
|
catch (error) { message.error(getErrorMessage(error)); } finally { setConfigSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
try { await Promise.all(activeBatches.map((batchId) => huyaApi.stopBatch(batchId))); setActiveBatches([]); message.success('已停止当前批次'); }
|
||||||
|
catch (error) { message.error(getErrorMessage(error)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const openQrTask = async (task: HuyaTaskItem) => {
|
||||||
|
setQrTask(task);
|
||||||
|
try { setQrTask(await huyaApi.getTask(task.id)); } catch { /* 使用列表结果 */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAccounts = useMemo(() => {
|
||||||
|
const value = search.trim().toLowerCase();
|
||||||
|
return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name].some((v) => String(v || '').toLowerCase().includes(value)));
|
||||||
|
}, [accounts, search]);
|
||||||
|
const filteredGoods = useMemo(() => goods.map((item) => ({ ...item, label: `${item.name} / ${item.price ?? '-'}分 / ${item.remain_text || '动态库存'}` })), [goods]);
|
||||||
|
const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length;
|
||||||
|
|
||||||
|
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
|
||||||
|
{ title: '#', width: 45, render: (_value, _row, index) => <Text type="secondary">{index + 1}</Text> },
|
||||||
|
{ title: '账号', width: 190, render: (_value, row) => <Space direction="vertical" size={0}><Text strong ellipsis>{row.nickname || row.username || row.uid}</Text><Text type="secondary" style={{ fontSize: 12 }}>UID {row.uid || '-'}</Text></Space> },
|
||||||
|
{ title: '游戏名', width: 180, render: (_value, row) => row.game_name || row.game_channel || <Text type="secondary">未查询</Text> },
|
||||||
|
{ title: '积分', dataIndex: 'points', width: 80, render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
|
||||||
|
{ title: '最近操作', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? <Space direction="vertical" size={0}><Text>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type="secondary" ellipsis>{task.message || '-'}</Text></Space> : <Text type="secondary">暂无</Text>; } },
|
||||||
|
{ title: '状态', width: 90, render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const operationBody = (
|
||||||
|
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||||
|
<div className="huya-operation-section"><div className="huya-operation-title"><QrcodeOutlined /><span>绑定与查询</span></div><div className="huya-action-grid">
|
||||||
|
<Button size="small" icon={<QrcodeOutlined />} onClick={() => void startTask('get_bind_qr')} disabled={starting || !selectedIds.length}>绑定二维码</Button><Button size="small" icon={<SearchOutlined />} onClick={() => void startTask('query_game_name')} disabled={starting || !selectedIds.length}>查询角色</Button><Button size="small" icon={<CheckCircleOutlined />} onClick={() => void startTask('confirm_bind')} disabled={starting || !selectedIds.length}>确认绑定</Button><Button size="small" icon={<SearchOutlined />} onClick={() => void startTask('query_points')} disabled={starting || !selectedIds.length}>刷新积分</Button><Button size="small" onClick={() => void startTask('query_act_tasks')} disabled={starting || !selectedIds.length}>宝典任务</Button><Button size="small" onClick={() => void startTask('query_exchange_records')} disabled={starting || !selectedIds.length}>兑换记录</Button>
|
||||||
|
</div></div>
|
||||||
|
<div className="huya-operation-section"><div className="huya-operation-title"><ShoppingOutlined /><span>兑换</span></div><Select size="small" showSearch optionFilterProp="label" value={selectedGoodsId || undefined} onChange={setSelectedGoodsId} options={filteredGoods.map((item) => ({ value: item.product_id, label: item.label }))} placeholder="选择兑换商品" style={{ width: '100%' }} /><div className="huya-action-grid"><Button size="small" icon={<ReloadOutlined />} onClick={() => void startTask('refresh_goods')} disabled={starting || !selectedIds.length}>刷新商品</Button><Button size="small" type="primary" icon={<ShoppingOutlined />} onClick={() => void startTask('exchange_goods')} disabled={starting || !selectedIds.length || !selectedGoodsId}>兑换商品</Button></div></div>
|
||||||
|
<div className="huya-operation-section"><div className="huya-operation-title"><GiftOutlined /><span>开通宝典</span></div><Button block size="small" icon={<QrcodeOutlined />} onClick={() => void startTask('create_recharge_order')} disabled={starting || !selectedIds.length}>生成开通支付码</Button></div>
|
||||||
|
<RealtimeLogPanel logs={logs.logs} connected={logs.connected} title="实时日志" emptyText="暂无任务日志" height={150} collapsible defaultVisible={false} />
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 760, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task?.task_type === 'get_bind_qr') void openQrTask(task); } })} /></div></>;
|
||||||
|
|
||||||
|
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
|
||||||
|
|
||||||
|
return <div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}><style>{`.huya-operation-section{border:1px solid rgba(128,128,128,.22);border-radius:6px;padding:8px}.huya-operation-title{display:flex;gap:6px;align-items:center;font-weight:600;margin-bottom:8px}.huya-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.huya-account-table{flex:1;min-height:0;overflow:hidden}.huya-account-table .ant-table-wrapper,.huya-account-table .ant-spin-nested-loading,.huya-account-table .ant-spin-container{height:100%}`}</style>
|
||||||
|
<Space style={{ justifyContent: 'space-between', width: '100%', marginBottom: 8, flexShrink: 0 }}><div><h2 style={{ margin: 0 }}>虎牙精英宝典工作台</h2><Text type="secondary">绑定、开通、积分与兑换任务</Text></div><Space><Tooltip title={layoutMode === 'split' ? '切换为上下布局' : '切换为左右布局'}><Button icon={<ColumnWidthOutlined />} onClick={() => { const next = layoutMode === 'split' ? 'stack' : 'split'; setLayoutMode(next); localStorage.setItem(LAYOUT_KEY, next); }} /></Tooltip><Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>刷新</Button>{canConfig && <Button icon={<SettingOutlined />} onClick={() => void openSettings()}>设置</Button>}{!!activeBatches.length && <Button danger icon={<StopOutlined />} onClick={() => void stop()}>停止</Button>}</Space></Space>
|
||||||
|
{layoutMode === 'split' ? <div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} 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}</Card><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}</Card></div> : <><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}</Card><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} 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}</Card></>}
|
||||||
|
<Text type="secondary" style={{ fontSize: 12, marginTop: 6 }}>兑换任务 {exchangeCount} 条;勾选账号后可批量执行。</Text>
|
||||||
|
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
|
||||||
|
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
|
||||||
|
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user