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:
@@ -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 值
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user