feat(huya): 工作台补手机号/精英宝典列与一键查询

- 手机号列:外部 CK 的 `----手机号` 本来就落在 huya_accounts.game_phone,
  列表一直没展示;现在单独成列,并纳入搜索
- 精英宝典列:显示「已开通/未开通/未查询」+ 开通时间 + 有效期
  · 开通状态 = 积分中心 getRedeemNeedGoods 的 tag5(真机对照网页:1=已开通,
    0=未开通,此时 tag7 会列出开通套餐),拿不准时为 NULL(显示「未查询」)
  · 开通时间 = revenueWebUI.queryUserOrderList 里已支付的宝典订单 pay_time
    (按 SPU 白名单 + 订单名含「精英宝典」识别,未支付订单不计,取最近一次)
  · 有效期 = PlayMallUI.queryAllocation 页面配置的本赛季节点 tag10/tag11
    (2026-10-15 抓包:精英宝典福利季丨Q4,2026-10-09 ~ 2026-12-31 23:59:59),
    不写死索引,递归找带 tag10/tag11 的节点
- 新任务 query_all「一键查询」:勾选账号一次跑完角色绑定 + 宝典开通 + 积分,
  按部分失败如实汇总(某个接口挂了不影响其它结论),并把宝典状态写回账号
- 迁移 20261015_0053:huya_accounts 新增 elite_opened / elite_open_time /
  elite_expire_time(时间存 Unix 秒,与斗鱼 elite_expire_time 同约定)
- 开通商品按页面「赠送指定礼物得积分」那一排对齐成 6 款(去掉页面已不用的
  旧版精英宝典 hy-5879340),列表接口只返回账号级快照并按 spu 去重
- 建 WSS 会话失败(积分中心端点静默不回包)时回退 HTTP cdnws,
  不再让整批任务判失败
- 列表列宽改按比例分配,避免宽屏下被拉出奇怪的宽度;最近操作不再重复状态
- 新增 tests/test_huya_elite_query.py(10 条);同步迁移头版本断言

真机验证:#65 已开通(10-13 20:41:45,有效期 12-31)、#70 新开通
(10-15 16:37:37)判定与订单/网页一致
This commit is contained in:
yml2213
2026-10-15 16:53:30 +08:00
parent 7ca0f7ee28
commit 73536d62c8
13 changed files with 883 additions and 15 deletions
+379
View File
@@ -0,0 +1,379 @@
"""虎牙精英宝典开通状态与「一键查询」(2026-10-15 真机实测)。
判定来源与真机结论:
* 宝典开通状态 = 积分中心 ``shopMiddleUI.getRedeemNeedGoods`` 的 ``tag5``。
下面固化的两段响应字节分别来自已开通账号(hy_302016799)与未开通账号
(hy_260034554),与网页显示的「已开通精英宝典 / 获取积分需先开通精英宝典」
一致。
* 开通时间 = ``revenueWebUI.queryUserOrderList`` 里已支付的宝典订单。
"""
import json
from core.huya.frame_decoder import decode_struct_bytes
from core.huya.http_client import HuyaHttpClient
from core.huya.shop_structs import ShopRawRsp
from web.backend.models import HuyaAccount
from web.backend.services.huya_runner import HuyaBatchRunner
UID = 1199668595203
COOKIE = f"yyuid={UID}; udb_uid={UID}"
# 真机 getRedeemNeedGoods 响应:已开通(tag5=1、tag6=1、tag7 为空)
REDEEM_OPENED_HEX = "0a0100c8160ce69fa5e8afa2e68890e58a9f21011830144c50016001790c0b"
# 真机 getRedeemNeedGoods 响应:未开通(tag5=0、tag6=0、tag7 列出开通套餐)
REDEEM_NOT_OPENED_HEX = (
"0a0100c8160ce69fa5e8afa2e68890e58a9f2c301440145c6c7900040a02007f134f1200"
"765c0b260ce7b2bee88bb1e5ae9de585b830054101fb51012c610bb87c9001a90cb90cca010"
"bb8110bb82c310bb8490c5c610bb8790c8c9cacbcccdc0bf00f010b0a02007ede691200766"
"17f2616e5bc80e9809ae5ae9de585b82be7b2bee88bb1e5b88130054101fc51012d610bc2"
"70018900020a02007ede6912007f134f2200765c0b310bb841012c5001660ce7b2bee88bb1"
"e5ae9de585b87c0b0a02007ede6912007f10bc220076662f300a400150016609e7b2bee88b"
"b1e5b88170010b9ca90cb90cca010bc2110bc22c310bc2490c5c610bc2790c8c9cacbcccdc0b"
"fc0f0b"
)
# 真机订单列表里已支付的宝典订单(pay_time 与 spu 均来自真机响应)
PAID_ELITE_ORDER = {
"item_name": "精英宝典",
"order_status": 60,
"pay_time": 1791895396000,
"goods_detail": {"spu_id": "hy-8328015", "sku_id": 7756811},
}
UNPAID_ELITE_ORDER = {
"item_name": "精英宝典",
"order_status": 10,
"pay_time": 0,
"goods_detail": {"spu_id": "hy-8328015", "sku_id": 7756811},
}
OTHER_ORDER = {
"item_name": "电竞徽章",
"order_status": 60,
"pay_time": 1788000000000,
"goods_detail": {"spu_id": "hy-7000000", "sku_id": 1},
}
# 真机 queryAllocation 响应里的赛季节点(精英宝典福利季丨Q4:2026-10-09 ~ 2026-12-31)
ALLOCATION_SEASON_PAYLOAD = {
"tag0": {"tag0": 200, "tag1": "成功", "tag2": ""},
"tag1": {
"tag0": "和平精英宝典积分中心",
"tag1": "heping_baodian_credit_center_page",
},
"tag2": [
{"tag0": 343, "tag4": 6},
{
"tag0": 1,
"tag2_2": {
"tag0": {
"tag0": 15,
"tag1": "精英宝典福利季",
"tag2": "精英宝典福利季丨Q4",
"tag3": 3203,
"tag5": 17096,
"tag6": 9815,
"tag10": 1791475200000,
"tag11": 1798732799000,
},
"tag4": {"tag0": 300},
},
},
],
}
def _redeem(hex_text: str) -> ShopRawRsp:
return ShopRawRsp.from_payload(decode_struct_bytes(bytes.fromhex(hex_text)))
class _FakeOrder:
def __init__(self, payload: dict):
self._payload = payload
def to_dict(self) -> dict:
return dict(self._payload)
class _FakeOrderList:
def __init__(self, orders: list[dict], total_count: int | None = None):
self.orders = [_FakeOrder(item) for item in orders]
self.totalCount = len(orders) if total_count is None else total_count
class _FakeBindStatus:
"""只喂给 EliteMixin 用到的那几个字段(角色解析由 BindMixin 自己的用例覆盖)。"""
def __init__(self, status: int = 200, msg: str = ""):
self.status = status
self.msg = msg
def to_dict(self) -> dict:
return {"status": self.status, "msg": self.msg}
class _StubClient:
"""积分中心通道的假客户端:可返回宝典状态/订单列表/赛季配置,并记录调用。"""
def __init__(self, redeem=None, order_pages=None, season: bool = False):
self.redeem = redeem
self.order_pages = order_pages or []
self.season_payload = ALLOCATION_SEASON_PAYLOAD if season else None
self.calls: list[tuple[str, dict]] = []
def get_redeem_need_goods(self, **kwargs):
self.calls.append(("get_redeem_need_goods", kwargs))
return self.redeem
def play_mall_query_allocation(self, **kwargs):
self.calls.append(("play_mall_query_allocation", kwargs))
if self.season_payload is None:
return None
return ShopRawRsp.from_payload(self.season_payload)
def query_user_order_list(self, **kwargs):
self.calls.append(("query_user_order_list", kwargs))
offset = int(kwargs.get("offset") or 0)
index = offset // 20
if index >= len(self.order_pages):
return None
page = self.order_pages[index]
return _FakeOrderList(page, total_count=sum(len(p) for p in self.order_pages))
def _runner(monkeypatch, shop_client, activity_client=None):
runner = HuyaBatchRunner(db=None, batch_id="test-batch", task_type="query_all")
marks: list[tuple[str, str, dict | None]] = []
logs: list[tuple[str, str]] = []
monkeypatch.setattr(
runner, "_shop_client", lambda uid, cookie: shop_client, raising=False
)
if activity_client is not None:
monkeypatch.setattr(
runner,
"_activity_client",
lambda uid, cookie: activity_client,
raising=False,
)
monkeypatch.setattr(
runner,
"_mark_task",
lambda _db, _task, status, message, result=None: marks.append(
(status, message, result)
),
raising=False,
)
monkeypatch.setattr(
runner,
"_push_log",
lambda level, message: logs.append((level, message)),
raising=False,
)
return runner, marks, logs
def test_real_redeem_bytes_report_elite_opened():
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all")
assert runner._elite_opened_from_redeem(_redeem(REDEEM_OPENED_HEX)) is True
assert runner._elite_opened_from_redeem(_redeem(REDEEM_NOT_OPENED_HEX)) is False
# 不是 200 或没有 tag5 时不下结论(界面显示「未查询」而不是猜一个值)
assert runner._elite_opened_from_redeem(None) is None
assert (
runner._elite_opened_from_redeem(ShopRawRsp.from_payload({"tag0": 500})) is None
)
def test_elite_open_time_uses_latest_paid_handbook_order():
client = _StubClient(
order_pages=[[OTHER_ORDER, UNPAID_ELITE_ORDER, PAID_ELITE_ORDER]]
)
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all")
assert runner._resolve_elite_open_time(client, UID, COOKIE) == 1791895396000
def test_elite_open_time_ignores_unpaid_handbook_order():
"""未支付的宝典订单不算开通时间(真机:order_status=10、pay_time=0)。"""
client = _StubClient(order_pages=[[UNPAID_ELITE_ORDER]])
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all")
assert runner._resolve_elite_open_time(client, UID, COOKIE) is None
def test_elite_open_time_scans_following_pages():
later_order = {**PAID_ELITE_ORDER, "pay_time": 1792000000000}
client = _StubClient(order_pages=[[OTHER_ORDER] * 20, [later_order]])
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all")
assert runner._resolve_elite_open_time(client, UID, COOKIE) == 1792000000000
offsets = [
kwargs.get("offset")
for name, kwargs in client.calls
if name == "query_user_order_list"
]
assert offsets == [0, 20]
def test_extract_elite_season_from_allocation_payload():
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all")
season = runner._extract_elite_season(ALLOCATION_SEASON_PAYLOAD)
assert season == {
"name": "精英宝典福利季丨Q4",
"start_ms": 1791475200000,
"end_ms": 1798732799000,
"config_id": 15,
}
# 认不出赛季结构时不下结论(界面就不显示有效期)
assert runner._extract_elite_season({"tag0": {"tag1": "x"}}) is None
assert runner._extract_elite_season(None) is None
def test_query_all_fills_expire_time_from_season(monkeypatch):
shop_client = _StubClient(
redeem=_redeem(REDEEM_OPENED_HEX),
order_pages=[[PAID_ELITE_ORDER]],
season=True,
)
runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object())
monkeypatch.setattr(
runner, "_resolve_bind_status", lambda **kwargs: (None, {}), raising=False
)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_all(
None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"}
)
status, message, result = marks[-1]
assert status == "success"
assert result["elite_expire_time"] == 1798732799
assert result["elite_season"]["name"] == "精英宝典福利季丨Q4"
assert account.elite_expire_time == 1798732799
assert "有效期至 2026-12-31" in message
def test_query_all_combines_points_elite_and_role(monkeypatch):
shop_client = _StubClient(
redeem=_redeem(REDEEM_OPENED_HEX), order_pages=[[PAID_ELITE_ORDER]]
)
runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object())
monkeypatch.setattr(
runner,
"_resolve_bind_status",
lambda **kwargs: (_FakeBindStatus(), {"bind_status_source": "outer"}),
raising=False,
)
monkeypatch.setattr(
runner,
"_bind_role_result",
staticmethod(
lambda _status: {"role_name": "软踏日潺湲", "game_title": "和平精英"}
),
raising=False,
)
monkeypatch.setattr(
runner,
"_bind_change_state",
staticmethod(lambda _status: {"is_bound": True}),
raising=False,
)
monkeypatch.setattr(
runner,
"_role_channel",
staticmethod(lambda _status: "苹果(iOS) / 手Q"),
raising=False,
)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_all(
None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"}
)
status, message, result = marks[-1]
assert status == "success"
assert result["points"] == 280
assert result["elite_opened"] is True
assert result["elite_open_time"] == 1791895396
assert result["role_name"] == "软踏日潺湲"
assert result["failures"] == []
assert account.points == 280
assert account.elite_opened is True
assert account.elite_open_time == 1791895396
assert account.game_name == "软踏日潺湲"
assert account.game_channel == "苹果(iOS) / 手Q"
assert account.status == "all_queried"
assert (
"积分 280" in message
and "已开通宝典" in message
and "角色 软踏日潺湲" in message
)
def test_query_all_reports_not_opened_without_order_lookup(monkeypatch):
shop_client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX))
runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object())
monkeypatch.setattr(
runner, "_resolve_bind_status", lambda **kwargs: (None, {}), raising=False
)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_all(
None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"}
)
status, message, result = marks[-1]
# 角色接口挂了不影响宝典/积分结论
assert status == "success"
assert result["elite_opened"] is False
assert account.elite_opened is False
assert account.elite_open_time is None
assert "未开通宝典" in message
assert "角色信息接口无响应" in message
# 未开通不查订单(省一次请求)
assert [name for name, _kwargs in shop_client.calls] == ["get_redeem_need_goods"]
def test_query_all_fails_when_points_center_unreachable(monkeypatch):
shop_client = _StubClient(redeem=None)
runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object())
monkeypatch.setattr(
runner, "_resolve_bind_status", lambda **kwargs: (None, {}), raising=False
)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_all(
None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"}
)
status, message, _result = marks[-1]
assert status == "failed"
assert "积分中心接口无响应" in message
def test_shop_client_falls_back_to_http_when_wss_init_fails(monkeypatch):
"""设备态齐全但积分中心端点不回包时(wsLaunch 超时)回退 HTTP,而不是判整批失败。"""
cookie = (
"udb_cred=cred; udb_biztoken=biz; udb_uid=1199659425268; yyuid=1199659425268; "
"sdid=sdid; udb_deviceid=w_1234567890123456789; udb_guiddata=guiddata; "
"udb_anobiztoken=anobiz"
)
logs: list[tuple[str, str]] = []
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all")
def boom(uid: int, cookie_text: str, kind: str):
raise RuntimeError(f"虎牙 {kind} WSS 初始化失败")
monkeypatch.setattr(runner, "_open_wss_session", boom, raising=False)
monkeypatch.setattr(
runner,
"_push_log",
lambda level, message: logs.append((level, message)),
raising=False,
)
client = runner._shop_client(1199659425268, cookie)
assert isinstance(client, HuyaHttpClient)
assert logs and logs[-1][0] == "warning"
assert "WSS 会话不可用" in logs[-1][1]
assert "sdid" not in json.dumps(logs[-1][1]) # 日志不带 Cookie 值
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261008_0052"
HEAD_REVISION = "20261015_0053"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
@@ -0,0 +1,79 @@
"""虎牙精英宝典开通状态:`huya_accounts` 新增 elite_opened / elite_open_time / elite_expire_time
工作台列表要能直接看到「这个号开没开宝典、什么时候开的、有效期到什么时候」,
但现在只有一个 `points` 列:开通后把积分花完和从没开通,在界面上长得一模一样。
真机(2026-10-15 两份抓包 + 真机对照网页)核对的来源:
* 开通状态 —— 积分中心 ``shopMiddleUI.getRedeemNeedGoods`` 的 ``tag5``:
``1`` = 已开通(网页显示「已开通精英宝典」),``0`` = 未开通(网页显示
「获取积分需先开通精英宝典」,``tag7`` 还会列出开通套餐)。
* 开通时间 —— ``revenueWebUI.queryUserOrderList`` 里已支付的宝典订单,取最近一次。
* 有效期 —— ``PlayMallUI.queryAllocation`` 响应里的赛季节点(``tag10``/``tag11``
是本赛季起止毫秒时间戳):2026-10-15 抓包为「精英宝典福利季丨Q4」,
即 2026-10-09 ~ 2026-12-31 23:59:59;认不出赛季结构时留空。
两列时间都存 **Unix 秒**(Integer 放不下毫秒时间戳,且与斗鱼 `elite_expire_time`
同一约定)。
历史值不回填:以前没查过宝典状态的账号是 NULL(界面显示「未查询」),
下次「一键查询」会补上 —— 不回填才不会把「没查过」伪装成「未开通」。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "20261015_0053"
down_revision: str | None = "20261008_0052"
branch_labels: str | None = None
depends_on: str | None = None
TABLE = "huya_accounts"
COLUMNS = (
(
"elite_opened",
sa.Boolean(),
"精英宝典是否已开通(积分中心 getRedeemNeedGoods 的 tag5;NULL=未查询)",
),
(
"elite_open_time",
sa.Integer(),
"最近一次宝典订单的支付时间(Unix 秒)",
),
(
"elite_expire_time",
sa.Integer(),
"宝典有效期(Unix 秒):积分中心本赛季结束时间;认不出赛季时留空",
),
)
def _column_exists(name: str) -> bool:
"""可安全重跑(应用启动时会自动跑迁移)。"""
conn = op.get_bind()
count = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() "
"AND table_name = :table AND column_name = :name"
),
{"table": TABLE, "name": name},
).scalar()
return bool(count)
def upgrade() -> None:
for name, column_type, comment in COLUMNS:
if not _column_exists(name):
op.add_column(
TABLE,
sa.Column(name, column_type, nullable=True, comment=comment),
)
def downgrade() -> None:
for name, _column_type, _comment in COLUMNS:
if _column_exists(name):
op.drop_column(TABLE, name)
+7
View File
@@ -539,6 +539,13 @@ class HuyaAccount(Base):
game_name: Mapped[str] = mapped_column(String(128), default="")
game_channel: Mapped[str] = mapped_column(String(64), default="")
game_phone: Mapped[str] = mapped_column(String(64), default="")
# 精英宝典开通状态(积分中心 getRedeemNeedGoods 的 tag5;NULL = 还没查过)。
# 光看积分区分不了「开通后花完」和「从没开通」,必须单独落库。
elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# 最近一次宝典订单的支付时间(Unix 秒;与斗鱼 elite_expire_time 同一约定)。
elite_open_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 宝典有效期(Unix 秒):取积分中心页面配置里本赛季的结束时间(福利季 Q4 → 12-31)。
elite_expire_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
sms_url: Mapped[str] = mapped_column(Text, default="")
assigned_to: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
+6
View File
@@ -351,6 +351,9 @@ def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccou
game_name=account.game_name or "",
game_channel=account.game_channel or "",
game_phone=account.game_phone or "",
elite_opened=account.elite_opened,
elite_open_time=account.elite_open_time,
elite_expire_time=account.elite_expire_time,
sms_url=account.sms_url or "",
assigned_to=account.assigned_to,
assigned_username=account.assigned_user.username
@@ -383,6 +386,9 @@ def _account_out_light(
game_name=account.game_name or "",
game_channel=account.game_channel or "",
game_phone=account.game_phone or "",
elite_opened=account.elite_opened,
elite_open_time=account.elite_open_time,
elite_expire_time=account.elite_expire_time,
sms_url=account.sms_url or "",
assigned_to=account.assigned_to,
assigned_username=account.assigned_user.username
+7
View File
@@ -580,6 +580,10 @@ class HuyaAccountOut(BaseModel):
game_name: str = ""
game_channel: str = ""
game_phone: str = ""
# 精英宝典开通状态(NULL = 未查询);有效期接口没给时为空
elite_opened: bool | None = None
elite_open_time: int | None = None
elite_expire_time: int | None = None
sms_url: str = ""
assigned_to: int | None = None
assigned_username: str | None = None
@@ -608,6 +612,9 @@ class HuyaAccountOut(BaseModel):
"game_name": self.game_name,
"game_channel": self.game_channel,
"game_phone": self.game_phone,
"elite_opened": self.elite_opened,
"elite_open_time": self.elite_open_time,
"elite_expire_time": self.elite_expire_time,
"sms_url": self.sms_url,
"assigned_to": self.assigned_to,
"assigned_username": self.assigned_username,
+7
View File
@@ -16,6 +16,7 @@ from .huya_runner_core import ( # noqa: F401 (huya_batch_registry 供 routers
HuyaBatchRunnerCore,
huya_batch_registry,
)
from .huya_runner_elite import EliteMixin
from .huya_runner_goods import GoodsMixin
from .huya_runner_recharge import RechargeMixin
from .huya_service import HUYA_CONFIG_FIELDS, ensure_huya_config, huya_config_value
@@ -26,6 +27,7 @@ class HuyaBatchRunner(
BindMixin,
GoodsMixin,
RechargeMixin,
EliteMixin,
):
"""批量执行虎牙任务(功能域 Mixin 聚合 + 批次调度)。"""
@@ -64,6 +66,7 @@ class HuyaBatchRunner(
if self.task_type not in {
"query_act_tasks",
"query_points",
"query_all",
"get_bind_qr",
"confirm_bind",
"query_game_name",
@@ -88,6 +91,10 @@ class HuyaBatchRunner(
self._execute_query_points(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "query_all":
self._execute_query_all(
worker_db, task, account, account_info, config_info
)
elif self.task_type == "refresh_goods":
self._execute_refresh_goods(
worker_db, task, account, account_info, config_info
+22 -2
View File
@@ -108,10 +108,28 @@ class HuyaBatchRunnerCore:
f"[{uid}] Cookie 缺少网页设备态({', '.join(missing)}),改用 HTTP cdnws 通道",
)
return self._http_client()
session = self._open_wss_session(uid, cookie, "activity")
session = self._open_wss_or_http(uid, cookie, "activity")
if session is None:
return self._http_client()
self._push_log("info", f"[{uid}] 活动链路使用 WSS 会话")
return session
def _open_wss_or_http(self, uid: int, cookie: str, kind: str):
"""建 WSS 会话;服务端不理会握手(wsLaunch 超时)时返回 None 让调用方回退 HTTP。
2026-10-15 实测:设备态字段齐全的账号也可能被积分中心端点静默丢弃
(wsLaunch 无响应),而同一账号走 HTTP cdnws 一切正常 —— 会话建不起来时
直接回退,而不是把整批任务判失败。
"""
try:
return self._open_wss_session(uid, cookie, kind)
except Exception as exc: # noqa: BLE001 - 任何建连/初始化异常都回退 HTTP
self._push_log(
"warning",
f"[{uid}] {kind} WSS 会话不可用({type(exc).__name__}: {exc}),改用 HTTP cdnws 通道",
)
return None
def _http_client(self):
"""HTTP cdnws 通道客户端(无需网页设备态,方法签名与 WSS 会话保持一致)。"""
from core.huya.http_client import HuyaHttpClient
@@ -153,7 +171,9 @@ class HuyaBatchRunnerCore:
f"[{uid}] Cookie 缺少网页设备态({', '.join(missing)}),改用 HTTP cdnws 通道",
)
return self._http_client()
session = self._open_wss_session(uid, cookie, "shop")
session = self._open_wss_or_http(uid, cookie, "shop")
if session is None:
return self._http_client()
self._push_log("info", f"[{uid}] 商城链路使用 WSS 会话")
return session
+336
View File
@@ -0,0 +1,336 @@
"""虎牙任务执行器:精英宝典开通状态与「一键查询」(由 huya_runner.py 按功能域拆分)。
真机(2026-10-15)核对过的判定来源:
* 宝典开通状态 —— 积分中心 ``shopMiddleUI.getRedeemNeedGoods`` 的 ``tag5``:
``1`` = 已开通(网页显示「已开通精英宝典」),``0`` = 未开通(网页显示
「获取积分需先开通精英宝典」,``tag7`` 还会列出开通套餐)。
两个已开通账号(hy_297832664 / hy_302016799)与两个未开通账号
(hy_260034554 / hy_274193170)实测与网页一致。
* 开通时间 —— ``revenueWebUI.queryUserOrderList`` 里已支付的宝典订单,取最近一次支付时间。
* 有效期 —— 商城订单(orderDetailV5)与积分中心都**不返回**到期时间,所以留空,
界面上「有就显示」。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM
from ..models import HuyaAccount, HuyaTask
# 宝典订单识别:spu 白名单 + 订单名兜底(官方换过多次 SKU,历史订单名带赛季,
# 例如「精英宝典⎜得300积分⎜26年Q2」)。
ELITE_HANDBOOK_SPU_IDS = frozenset(
{"hy-8328015", "hy-5879340", "hy-3845257", "hy-2947756"}
)
ELITE_HANDBOOK_NAME_KEYWORD = "精英宝典"
ELITE_ORDER_PAGE_SIZE = 20
ELITE_ORDER_MAX_PAGES = 3
# 赛季(福利季)节点的毫秒时间戳下限:2001-09 之后的值才认
ELITE_SEASON_MS_FLOOR = 1_000_000_000_000
class EliteMixin:
"""精英宝典域:开通状态判定与一键查询。"""
if TYPE_CHECKING:
payload: dict
@staticmethod
def _to_int(value: Any) -> int: ...
@staticmethod
def _format_local_time(timestamp: int) -> str: ...
def _resolve_uid(self, account_info: dict) -> int: ...
def _shop_client(self, uid: int, cookie: str): ...
def _activity_client(self, uid: int, cookie: str): ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
# 来自 BindMixin
@staticmethod
def _bind_role_result(bind_status) -> dict: ...
@staticmethod
def _bind_change_state(bind_status) -> dict: ...
@staticmethod
def _role_channel(bind_status) -> str: ...
def _resolve_bind_status(
self, client: Any, uid: int, cookie: str, b_act_id_int: int
): ...
@staticmethod
def _elite_opened_from_redeem(redeem: Any) -> bool | None:
"""``getRedeemNeedGoods`` 的 tag5 → 开通状态;拿不准时返回 None(不猜)。"""
if redeem is None or getattr(redeem, "code", 0) != 200:
return None
flag = redeem.value("tag5")
if isinstance(flag, bool) or not isinstance(flag, int):
return None
return flag == 1
@staticmethod
def _is_elite_order(item: dict) -> bool:
detail = item.get("goods_detail") or {}
if str(detail.get("spu_id") or "") in ELITE_HANDBOOK_SPU_IDS:
return True
return ELITE_HANDBOOK_NAME_KEYWORD in str(item.get("item_name") or "")
def _resolve_elite_open_time(
self, client: Any, uid: int, cookie: str
) -> int | None:
"""最近一次已支付宝典订单的时间(Unix 毫秒);查不到返回 None。
订单列表按时间倒序分页返回,宝典订单可能落在后面,所以最多翻
``ELITE_ORDER_MAX_PAGES`` 页;中途任何一次失败都按「没查到」处理。
未支付订单(真机:order_status=10)没有 pay_time,自然不参与。
"""
latest = 0
offset = 0
for _page in range(ELITE_ORDER_MAX_PAGES):
resp = client.query_user_order_list(
uid=uid,
guid="",
cookie=cookie,
offset=offset,
page_size=ELITE_ORDER_PAGE_SIZE,
order_type=1,
status=0,
)
if resp is None:
break
orders = list(getattr(resp, "orders", None) or [])
for order in orders:
item = order.to_dict() if hasattr(order, "to_dict") else dict(order)
if not self._is_elite_order(item):
continue
pay_time = int(item.get("pay_time") or 0)
if pay_time > 0:
latest = max(latest, pay_time)
total = self._to_int(getattr(resp, "totalCount", 0))
if len(orders) < ELITE_ORDER_PAGE_SIZE or offset + len(orders) >= total:
break
offset += len(orders)
return latest or None
@classmethod
def _walk_season_nodes(cls, node: Any, found: list[dict]) -> None:
"""递归找「赛季」节点:同时带 tag10/tag11 毫秒起止时间的结构。"""
if isinstance(node, dict):
start = node.get("tag10")
end = node.get("tag11")
if (
isinstance(start, int)
and isinstance(end, int)
and start >= ELITE_SEASON_MS_FLOOR
and end > start
):
found.append(
{
"name": str(node.get("tag2") or node.get("tag1") or ""),
"start_ms": start,
"end_ms": end,
"config_id": node.get("tag0")
if isinstance(node.get("tag0"), int)
else None,
}
)
for value in node.values():
cls._walk_season_nodes(value, found)
elif isinstance(node, list):
for value in node:
cls._walk_season_nodes(value, found)
@classmethod
def _extract_elite_season(cls, payload: Any) -> dict | None:
"""从积分中心页面配置里取宝典赛季(= 有效期)信息。
真机(2026-10-15 抓包)实证:``PlayMallUI.queryAllocation`` 响应里
``tag2[1].tag2_2.tag0`` 是本赛季配置 —— tag1/tag2 = 「精英宝典福利季」/
「精英宝典福利季丨Q4」,tag10/tag11 = 赛季起止毫秒时间戳
(1791475200000 → 2026-10-09 00:00,1798732799000 → 2026-12-31 23:59:59)。
这里不写死索引,按「带 tag10/tag11 的节点」找,取结束时间最晚的那个。
"""
found: list[dict] = []
cls._walk_season_nodes(payload, found)
if not found:
return None
return max(found, key=lambda item: item["end_ms"])
def _resolve_elite_season(self, client: Any, uid: int, cookie: str) -> dict | None:
"""查询积分中心页面配置并解析本赛季信息;查不到返回 None。"""
resp = client.play_mall_query_allocation(uid=uid, guid="", cookie=cookie)
if resp is None or getattr(resp, "code", 0) != 200:
return None
return self._extract_elite_season(getattr(resp, "payload", None))
def _apply_elite_state(
self,
account: HuyaAccount,
opened: bool | None,
open_time: int | None,
expire_time: int | None = None,
) -> None:
"""把宝典状态写到账号上;``opened`` 为 None(没查到)时不动旧值。
库里存 **Unix 秒**(Integer 放不下毫秒级时间戳,且与斗鱼一致),
方法内部一路用的是毫秒(协议原值),落库时统一除 1000。
"""
if opened is None:
return
account.elite_opened = opened
account.elite_open_time = (
(int(open_time) // 1000 if open_time else None) if opened else None
)
account.elite_expire_time = (
(int(expire_time) // 1000 if expire_time else None) if opened else None
)
def _execute_query_all(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
"""一键查询:角色绑定 + 精英宝典开通 + 积分(勾选账号批量跑)。"""
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
return
cookie = account_info.get("cookie") or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
result: dict[str, Any] = {"uid": uid}
failures: list[str] = []
message_parts: list[str] = []
# ---- 积分 + 宝典开通(积分中心同一个接口) ----
shop_client: Any = self._shop_client(uid, cookie)
goods_num = (
self._to_int(self.payload.get("goods_num")) or POINTS_REDEEM_GOODS_NUM
)
redeem = shop_client.get_redeem_need_goods(
uid=uid, guid="", cookie=cookie, goods_num=goods_num
)
points: int | None = None
opened: bool | None = None
if redeem is None:
failures.append("积分中心接口无响应")
elif redeem.code != 200:
failures.append(redeem.message or f"积分查询失败: {redeem.code}")
else:
points = self._to_int(redeem.value("tag2"))
opened = self._elite_opened_from_redeem(redeem)
result.update(
{
"points": points,
"redeem_threshold": self._to_int(redeem.value("tag3")),
"goods_num": goods_num,
"redeem": redeem.to_dict(),
}
)
message_parts.append(f"积分 {points}")
open_time: int | None = None
if opened:
# 已开通才去翻订单:既省一次请求,也避免给未开通账号显示旧订单时间
open_time = self._resolve_elite_open_time(shop_client, uid, cookie)
season = (
self._resolve_elite_season(shop_client, uid, cookie) if opened else None
)
expire_time = self._to_int(season["end_ms"]) if season else None
if opened is not None:
self._apply_elite_state(account, opened, open_time, expire_time)
result["elite_opened"] = opened
result["elite_open_time"] = (open_time // 1000) if open_time else None
result["elite_open_time_text"] = (
self._format_local_time(open_time // 1000) if open_time else ""
)
if season:
result["elite_season"] = season
result["elite_expire_time"] = expire_time // 1000
result["elite_expire_time_text"] = self._format_local_time(
expire_time // 1000
)
if opened:
opened_text = "已开通宝典"
if open_time:
opened_text += f"({self._format_local_time(open_time // 1000)})"
if expire_time:
opened_text += (
f",有效期至 {self._format_local_time(expire_time // 1000)}"
)
else:
opened_text = "未开通宝典"
message_parts.append(opened_text)
# ---- 角色绑定(活动通道) ----
b_act_id_int = self._to_int(
self.payload.get("bind_act_id") or config_info.get("bind_act_id")
)
role_name = ""
if not b_act_id_int:
failures.append("未配置绑定 bActId")
else:
bind_client: Any = self._activity_client(uid, cookie)
bind_status, bind_query_result = self._resolve_bind_status(
client=bind_client,
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
)
if bind_status is None:
failures.append("角色信息接口无响应")
elif bind_status.status != 200:
failures.append(
bind_status.msg or f"角色信息查询失败: {bind_status.status}"
)
else:
role_info = self._bind_role_result(bind_status)
role_name = role_info.get("role_name") or ""
result.update(
{
"bind_act_id": b_act_id_int,
**role_info,
**self._bind_change_state(bind_status),
**bind_query_result,
}
)
if role_name:
account.game_name = role_name
account.game_channel = (
self._role_channel(bind_status) or account.game_channel
)
message_parts.append(f"角色 {role_name}")
else:
message_parts.append("未绑定角色")
account.points = points if points is not None else account.points
account.status = "all_queried"
account.updated_at = datetime.now(UTC)
result["failures"] = failures
fetched_anything = points is not None or role_name or opened is not None
if not fetched_anything:
self._mark_task(
worker_db,
task,
"failed",
";".join(failures) or "一键查询全部失败",
result,
)
return
message = "|".join(message_parts) or "一键查询完成"
if failures:
message += f";部分失败: {';'.join(failures)}"
self._mark_task(worker_db, task, "success", message, result)
+2
View File
@@ -18,6 +18,7 @@ SUPPORTED_TASK_TYPES = {
"get_bind_qr": "获取绑定二维码",
"query_points": "一键查询积分",
"query_game_name": "一键查询游戏名",
"query_all": "一键查询(角色+宝典+积分)",
"query_exchange_records": "一键查询兑换记录",
"confirm_bind": "确认绑定",
"refresh_goods": "刷新商品列表",
@@ -32,6 +33,7 @@ HUYA_HANDBOOK_TASK_TYPES = {
"confirm_bind",
"query_points",
"query_game_name",
"query_all",
"refresh_goods",
"exchange_goods",
"query_exchange_records",
+6
View File
@@ -695,6 +695,12 @@ export interface HuyaAccountItem {
game_name: string;
game_channel: string;
game_phone: string;
/** 精英宝典是否已开通(null = 还没查过) */
elite_opened?: boolean | null;
/** 最近一次宝典订单支付时间(Unix 毫秒) */
elite_open_time?: number | null;
/** 宝典有效期(Unix 毫秒;接口没给就是空) */
elite_expire_time?: number | null;
sms_url?: string;
assigned_to: number | null;
assigned_username: string | null;
+16 -12
View File
@@ -15,7 +15,7 @@ import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
import { formatTime, formatUnixSeconds } from '../utils/time';
const { Text } = Typography;
const SCOPE = 'elite' as const;
@@ -27,7 +27,7 @@ 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: '生成开通支付码',
refresh_recharge_goods: '刷新开通商品',
refresh_recharge_goods: '刷新开通商品', query_all: '一键查询(宝典+角色+积分)',
};
const TASK_COLORS: Record<string, string> = {
planned: 'default', pending: 'default', running: 'processing', success: 'success',
@@ -39,6 +39,7 @@ const TASK_STATUS_LABELS: Record<string, string> = {
};
const HUYA_QUICK_ACTIONS = [
{ key: 'query_all', icon: <SearchOutlined /> },
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'query_game_name', icon: <SearchOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
@@ -237,7 +238,7 @@ export default function HuyaElitePage() {
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)));
return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name, item.game_phone].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 filteredRechargeGoods = useMemo(() => rechargeGoods.map((item) => ({ ...item, label: `${item.name || item.spu_id} / ${item.price ? `${(item.price / 100).toFixed(2)}元` : '价格动态'} / ${item.spu_id}` })).sort((a, b) => Number(a.raw?.raw_order ?? 0) - Number(b.raw?.raw_order ?? 0) || a.id - b.id), [rechargeGoods]);
@@ -250,20 +251,23 @@ export default function HuyaElitePage() {
}, [filteredRechargeGoods, selectedRechargeSpu]);
const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length;
// 列宽按比例分配(percent),表格宽度随容器缩放,不会被拉出「奇奇怪怪」的宽度
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
{ title: '#', width: 45, render: (_value, _row, index) => <Text type="secondary">{index + 1}</Text> },
{ title: '账号', width: 180, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Text strong ellipsis title={row.nickname || row.username || row.uid}>{row.nickname || row.username || row.uid}</Text><Text type="secondary" style={{ fontSize: 12 }} ellipsis title={row.username}>UID {row.uid || '-'}</Text></Space> },
{ title: '游戏名', width: 160, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type="secondary">未查询</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space> },
{ title: '积分', dataIndex: 'points', width: 75, align: 'right', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
{ title: '最近操作', width: 250, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? <Space direction="vertical" size={1} style={{ width: '100%' }}><Space size={4}><Tag color={TASK_COLORS[task.status] || 'default'}>{TASK_STATUS_LABELS[task.status] || task.status}</Tag><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text></Space><Text type="secondary" ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
{ title: '二维码', width: 80, align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} bordered={false} />}</div>; } },
{ 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>; } },
{ title: '#', width: '4%', align: 'center', render: (_value, _row, index) => <Text type="secondary">{index + 1}</Text> },
{ title: '账号', width: '15%', ellipsis: true, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Text strong ellipsis title={row.nickname || row.username || row.uid}>{row.nickname || row.username || row.uid}</Text><Text type="secondary" style={{ fontSize: 12 }} ellipsis title={row.username}>UID {row.uid || '-'}</Text></Space> },
{ title: '手机号', width: '10%', ellipsis: true, render: (_value, row) => row.game_phone || <Text type="secondary">-</Text> },
{ title: '游戏名', width: '13%', ellipsis: true, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type="secondary">未查询</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space> },
{ title: '精英宝典', width: '13%', ellipsis: true, render: (_value, row) => <Space direction="vertical" size={0} style={{ width: '100%' }}><Tag color={row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space> },
{ title: '积分', dataIndex: 'points', width: '7%', align: 'right', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
{ title: '最近操作', width: '25%', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? <Space direction="vertical" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type="secondary" ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 44, height: 44, objectFit: 'contain' }} /> : <QRCode value={url} size={44} bordered={false} />}</div>; } },
{ title: '状态', width: '7%', align: 'center', 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>
<Button size="small" type="primary" icon={<SearchOutlined />} onClick={() => void startTask('query_all')} disabled={starting || !selectedIds.length}>一键查询</Button><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>
@@ -272,7 +276,7 @@ export default function HuyaElitePage() {
</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>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</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} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1040, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></div></>;
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>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</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} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} /></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>;
const contextMenuStyle = contextMenu ? {
+15
View File
@@ -31,6 +31,7 @@ const FALLBACK_TASK_TYPES: Record<string, string> = {
confirm_bind: '确认绑定',
query_points: '一键查询积分',
query_game_name: '一键查询游戏名',
query_all: '一键查询(角色+宝典+积分)',
query_exchange_records: '一键查询兑换记录',
refresh_goods: '刷新商品列表',
refresh_recharge_goods: '刷新充值商品列表',
@@ -39,6 +40,7 @@ const FALLBACK_TASK_TYPES: Record<string, string> = {
};
const QUICK_ACTIONS = [
{ key: 'query_all', icon: <SearchOutlined /> },
{ key: 'get_bind_qr', icon: <LinkOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_points', icon: <SearchOutlined /> },
@@ -1136,6 +1138,19 @@ export default function HuyaTasksPage() {
return <Text type="secondary">-</Text>;
}
// 一键查询:积分 + 宝典开通 + 角色,一处看全(明细在任务 message 里)
if (record.task_type === 'query_all') {
const tags: Array<{ text: string; color: string }> = [];
if (typeof value?.points === 'number') tags.push({ text: `积分 ${value.points}`, color: 'blue' });
if (typeof value?.elite_opened === 'boolean') {
tags.push({ text: value.elite_opened ? '已开通宝典' : '未开通宝典', color: value.elite_opened ? 'green' : 'default' });
}
if (typeof value?.role_name === 'string' && value.role_name) tags.push({ text: `角色 ${value.role_name}`, color: 'cyan' });
if (tags.length) {
return <Space size={6} wrap>{tags.map((tag) => <Tag key={tag.text} color={tag.color}>{tag.text}</Tag>)}</Space>;
}
}
// 积分结果:新版走积分中心(points),旧任务记录里是活动接口的 available_score
const points = typeof value?.points === 'number' ? value.points : value?.available_score;
if (typeof points === 'number') {