21 changed files with 1498 additions and 139 deletions
+11
View File
@@ -1351,6 +1351,9 @@ class HuyaHttpClient:
cookie: str,
source_id: str = "yellowcarlist",
page_code: str = "heping_baodian_credit_center_page",
scene: int = 7,
page_id: int = 0,
waterfall_tab_id: int | str | None = None,
timeout: float = 15.0,
*,
deep: bool = False,
@@ -1366,6 +1369,14 @@ class HuyaHttpClient:
req.userId = self._build_shop_user(uid, cookie)
req.pageCode = page_code
req.appInfo.sourceId = source_id
req.appInfo.scene = int(scene or 7)
if waterfall_tab_id not in (None, ""):
req.blank7 = json.dumps(
{"waterfallTabId": str(waterfall_tab_id)},
separators=(",", ":"),
)
req.flag9 = int(page_id or 0)
req.flags11 = []
resp = self.call_rpc(
"PlayMallUI",
"queryAllocation",
+50
View File
@@ -1264,6 +1264,56 @@ def parse_points_center_gifts(payload: dict | None) -> list[dict]:
return gifts
def extract_points_center_tabs(payload: dict | None) -> list[dict]:
"""从 ``queryAllocation`` 基础响应里提取页签定义。
真机(2026-11-11 HAR)结构::
payload.tag2[0].tag0 # pageId,例如 343
payload.tag2[0].tag5 # SIMPLE_LIST,内容是分类/页签 LIST
→ tag0 waterfallTabId,例如 431 / 433
→ tag1 页签名,例如 精英专享 / 限量返场
→ tag7 商品数
基础请求只返回第一个页签的商品;其余页签必须带着这里的 pageId +
waterfallTabId 再查一次。因此页签 ID 由响应动态发现,不写死活动值。
"""
if not isinstance(payload, dict):
return []
result: list[dict] = []
seen: set[tuple[int, int]] = set()
for section in payload.get("tag2") or []:
if not isinstance(section, dict):
continue
page_id = _as_int(section.get("tag0"))
blob = section.get("tag5")
if page_id is None or not isinstance(blob, (bytes, bytearray)):
continue
categories = decode_value_bytes(bytes(blob), max_depth=40, keep_bytes=True)
if not isinstance(categories, list):
continue
for tab in categories:
if not isinstance(tab, dict):
continue
tab_id = _as_int(tab.get("tag0"))
if tab_id is None:
continue
key = (page_id, tab_id)
if key in seen:
continue
seen.add(key)
result.append(
{
"page_id": page_id,
"tab_id": tab_id,
"name": _as_text(tab.get("tag1"))[:64],
"count": _as_int(tab.get("tag7")),
}
)
return result
class PlayMallAppInfo(TafStruct):
"""PlayMallUI 系接口的应用信息。
+16 -2
View File
@@ -16,6 +16,7 @@
"""
import asyncio
import json
import random
import re
from collections.abc import Callable
@@ -1213,6 +1214,7 @@ class HuyaWssClient:
page_size: int = 10,
order_type: int = 1,
status: int = 0,
timeout: float = 15.0,
):
from .shop_structs import QueryUserOrderListReq, QueryUserOrderListRsp
@@ -1228,7 +1230,7 @@ class HuyaWssClient:
"queryUserOrderList",
req,
QueryUserOrderListRsp,
timeout=15.0,
timeout=timeout,
)
async def order_detail(
@@ -1239,6 +1241,7 @@ class HuyaWssClient:
order_id: int,
source_id: str = "yellowcarlist",
app_scene: int = SHOP_APP_SCENE,
timeout: float = 15.0,
):
"""按订单号查询商城订单详情,匹配 9.1 支付后轮询。"""
from .shop_structs import OrderDetailReq, OrderDetailRsp
@@ -1248,7 +1251,7 @@ class HuyaWssClient:
req.shopAppInfo = self._build_shop_app(source_id, app_scene)
req.orderId = int(order_id or 0)
return await self.call_rpc(
"shopMiddleUI", "orderDetailV5", req, OrderDetailRsp, timeout=15.0
"shopMiddleUI", "orderDetailV5", req, OrderDetailRsp, timeout=timeout
)
async def create_order(
@@ -1451,6 +1454,9 @@ class HuyaWssClient:
cookie: str,
source_id: str = "yellowcarlist",
page_code: str = POINTS_PAGE_CODE,
scene: int = SHOP_APP_SCENE,
page_id: int = 0,
waterfall_tab_id: int | str | None = None,
*,
deep: bool = False,
):
@@ -1464,7 +1470,15 @@ class HuyaWssClient:
req = PlayMallQueryAllocationReq()
req.userId = self._build_biz_user(uid, cookie)
req.appInfo = self._build_play_mall_app(source_id)
req.appInfo.scene = int(scene or SHOP_APP_SCENE)
req.pageCode = page_code
if waterfall_tab_id not in (None, ""):
req.blank7 = json.dumps(
{"waterfallTabId": str(waterfall_tab_id)},
separators=(",", ":"),
)
req.flag9 = int(page_id or 0)
req.flags11 = []
if not deep:
return await self.call_rpc_raw("PlayMallUI", "queryAllocation", req)
body = await self.call_rpc("PlayMallUI", "queryAllocation", req, None)
+13 -1
View File
@@ -1,6 +1,6 @@
# 虎牙精英宝典联调指南
更新时间:2026-09-02
更新时间:2026-11-11
> 当前 Cookie 字段状态见 [HUYA_COOKIE路线状态.md](HUYA_COOKIE路线状态.md)。
@@ -37,6 +37,18 @@
4. 兑换成功后再次执行 `query_points` 和 `query_exchange_records`,核对余额扣减与新增记录。
5. 充值/开通链路单独联调:先 `refresh_recharge_goods`,再只生成支付码 `create_recharge_order`。支付确认不要和查询任务混在同一批次。
### 2026-11-11 订单状态补充
- `revenueWebUI.queryUserOrderList` 是订单查询主入口;返回的宝典订单:
`order_status=10` = 待支付,`pay_time>0`/已支付状态 = 已付款,`order_status=90` = 已取消。
- WSS 的 `queryUserOrderList` / `orderDetailV5` 必须保留 `timeout` 参数;
支付监听统一传该参数,缺了会在支付码生成后直接
`unexpected keyword argument 'timeout'`。
- “已付款但积分中心 `getRedeemNeedGoods.tag5` 仍为 0”不能直接判成未开通:
24 小时交付窗口内显示“开通中”,超过窗口仍为 0 才回落到未开通。
- 待支付旧单继续走复用支付;已取消订单不能复用。订单详情里的“还剩 N 分钟取消订单”
只是官方倒计时,不是开通状态。
## 如何判定传输通道
执行器只检查字段是否存在,不记录字段值:
+31 -10
View File
@@ -249,7 +249,9 @@ incomplete ─资料齐全且 CK 有效─▶ to_recharge ─充值核账─▶
6. **回退必须显式声明**:目标阶段序号 < 当前时必须 `rollback=true`(界面勾「回退」),
会清掉后面阶段的时间戳,审计里 `rollback=true` 留痕。
7. **`failed` / `void` 是旁路**:流程内任意状态都能进(不用勾回退);从这两态回到流程内
**必须** `rollback=true`。`void` 不可逆(只能编辑或新建),`failed` 可回流程内重试。
**必须** `rollback=true`。`void` 默认不可逆;确认属于误作废时,**超级管理员**可传
`force=true` + 必填原因强制恢复,审计记录操作人、原因和原 `void_reason`。
强制恢复只跳过“作废不可逆”限制,不绕过资料完整性与核账校验;`failed` 可回流程内重试。
8. **`recharged` / `points_error` 不能人工直写**:`set_status()` 只有核账流程传
`allow_recharge=True` 才接受这两个目标;普通状态接口和批量改状态都会返回
`recharge_check_required`。`points_error` 补足/调整积分后重新核账即可回到 `recharged`
@@ -341,7 +343,8 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
- 单条新增:表单里贴 CK(支持 `纯CK`、`账号----密码----CK` 两种形态,
复用 `huya_service.parse_huya_cookie_line`,它是纯函数、不依赖账号表)
→ 解析出 `uid/yyuid/hy_username` → 落表,`ck_status=unverified`。
→ 解析出 `uid/yyuid/hy_username` → 只要带了 CK 就自动跑一次只读探针:
成功写绑定快照与虎牙昵称,失败如实写 `invalid` 和原因,但草稿单仍允许创建。
- 批量导入:Excel/文本粘贴,见 §9.3。逐行解析,**部分失败不整体失败**,
返回 `{created, updated, skipped, errors[]}`(每条错误的行号 + 原因)。
- 重复 uid:如果 uid 已在本表 → 视为"换 CK"(更新 cookie 并记审计),
@@ -355,7 +358,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
| 候选接口 | 有效 CK | 篡改/失效 | 能否当探针 |
| --- | --- | --- | --- |
| `get_redeem_need_goods`(积分中心) | 200 | 单改 `udb_cred`、甚至删光 `udb_` 字段仍 200 | ❌ 不校验凭据(tag7 是公共数据) |
| `get_grow_level_user_info`(能拿虎牙昵称) | 成功 | 单改 `udb_cred` 仍成功 | ❌ 不看凭据 |
| `get_grow_level_user_info` / `get_user_profile_batch`(能拿虎牙昵称) | 成功 | 单改 `udb_cred` 仍可能成功 | ❌ 不能单独判有效;只在绑定探针通过后做展示补全 |
| `query_user_score` / `get_act_user_task_detail` / `get_user_prize_records` | — | — | ❌ 接口已下线(603「活动已结束」/ 601「活动已下线」) |
| **`check_user_bind_game_account`** | `status=200` + 真实绑定数据 | **裸 cookie(无凭据)+ 真 uid → `None`;真 CK + 别人 uid → `None`** | ✅ **用它** |
@@ -370,8 +373,10 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
- 成功时**覆盖写入绑定快照**:`bind_game_account` / `bind_game_role` / `bind_game_channel`
+ `bind_snapshot_at`(这几列探针独占,就是"现在到底绑了哪个号、哪个角色")。
覆盖前把旧值记进 `huya:collab:probe_snapshot` 审计;人工填的列(含 `bind_douyu_account`)**一律不碰**。
- **不写 `nickname`**:按 §3.1 那是"虎牙号昵称",这个探针给不出(能给出昵称的接口都不认凭据)
——硬写就是假数据。
- **`nickname` 单独补读**:绑定探针 `status=200` 后,再调 `huyauserui.getUserProfileBatch`
取该 uid 的 `nick`。这是展示辅助,不参与有效/失效判定;昵称接口失败时 CK 仍保持有效,
失败原因写进 `ck_check_message`。绝不能拿 `gameAccount.nick` 冒充,两者分别是
“绑定游戏账号昵称”和“虎牙平台账号昵称”。
- 一次批量里单条失败不影响其他条(AGENTS §2:单项失败不要整体失败,但要如实暴露)。
没有 CK 的行**保持 `unverified`**,不谎报成 `invalid`。
@@ -411,9 +416,14 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
`gifts_message`,不影响 CK 的有效性结论)。商品积分取 `tag75.tag0.tag2`,与积分中心
页面显示一致;不再依赖活动 SID。旧 `webActUI.getActPrizeList` 保留给精英宝典工作台的
兑换链路,本模块不再调用;
- **全部页签自动合并**(2026-11-11 HAR):基础请求只返回第一个页签,页签定义在
`tag2[].tag5` 解出的分类列表里;后端自动用 `pageId + waterfallTabId` 补拉其余页签。
当前最新场次实测为「精英专享 18 + 限量返场 21 = 39」。活动参数可在「虎牙配置」里改
`points_page_code / points_source_id / points_scene`,自动发现失败时还可手工覆盖
`points_page_id / points_tab_ids`;
- **礼包名默认空白**(2026-10-18 用户要求):不再自动带上次选的礼包,避免没注意时建错单;
想少点两下用「常用礼包」快捷按钮;
- **礼包列表缓存**:前端 localStorage(`huya.collab.gifts.v2`,存 `{at, gifts}`)—— 一个活动期间礼包清单基本不变,
- **礼包列表缓存**:前端 localStorage(`huya.collab.gifts.v3`,存 `{at, gifts}`)—— 一个活动期间礼包清单基本不变,
贴 CK 时**不再每次都去虎牙拉**(`with_gifts=false`),要用最新库存点「强制刷新礼包」;
缓存时间摆在礼包名下方,商品清单变了就点一次强制刷新;
- **探针缓存**:同一份 CK 在 `PROBE_CACHE_TTL_SECONDS`(90 秒)内复用结论 ——
@@ -757,10 +767,10 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
1. **探针判据必须是「非 None 且 `status == 200`」**:真机上「裸 cookie + 真 uid」与
「真 CK + 别人 uid」都返回 `None`,只有两者都对才拿到 200——这是唯一能区分有效/失效的
信号组合。反向验证(把判据改成「有数据就算成功」)会让 3 个用例变红。
2. **探针能补的字段只有 `bind_douyu_account` / `bind_douyu_uid`**:返回的是**绑定在该虎牙号
上的游戏账号**(账号昵称 / 角色名 / 角色 id)。`nickname` 按 §3.1 是"虎牙号昵称",
本探针给不出(能给出昵称的 `get_grow_level_user_info` 不校验凭据,当不了探针),
**不能拿游戏账号昵称冒充**。原稿 §5.2 写的"顺带补 nickname"据此修正。
2. **游戏账号与虎牙昵称是两套字段**:绑定探针返回的是**绑定在该虎牙号上的游戏账号**
(`gameAccount.nick` / 角色名 / 角色 id);`nickname` 按 §3.1 是"虎牙平台账号昵称"。
现在的做法是绑定探针确认 CK 有效后,再用 `get_user_profile_batch` 单独补 `nickname`;
昵称失败不影响 CK 结论,且**不能拿游戏账号昵称冒充**。
3. **绑定快照由探针独占(2026-10-16 当天修订)**:最早实现是"只补空值写
`bind_douyu_account`",用户明确要求"展示绑定在该虎牙号上的游戏账号、**绑定角色重要**、
之前那套不要了"之后,改成探针**覆盖写入** `bind_game_account`/`bind_game_role`/
@@ -829,6 +839,17 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
6. **记忆里的礼包不在本次列表里**:清空并提示「上次选的「xx」不在这次拉到的礼包列表里,
请重新点选」(拉取失败时**不**清,避免把人卡死)。
### 11.4.1 2026-11-11 礼包页签自动发现
1. `queryAllocation` 基础响应里的 `tag5` 同时包含当前页签商品和其他页签元数据
(`tag0=tabId`、`tag1=名称`、`tag7=商品数`)。后端据此自动补拉,`433` 这类 ID
随活动变化时不需要改代码。
2. 新增 `huya_config.points_page_code / points_source_id / points_scene /
points_page_id / points_tab_ids`:前三个控制请求入口;后两个留空时自动发现,
填了才覆盖,接口小改版可直接在页面配置,不必重新发版。
3. 缓存键升级为 v3。v2 缓存只有「精英专享」18 个商品,不换键会让部署后的页面继续
使用旧列表。
### 11.5 2026-10-16 第二轮页面复核(用户点着看又提的)
1. **弹窗放大了**:新建协作单 `width` 520→**920**、表单 `size` 回到 `middle`、CK 输入框 3→5 行、
+390 -7
View File
@@ -30,7 +30,15 @@ from web.backend.models import (
)
from web.backend.permissions import ROLE_PERMISSIONS
from web.backend.routers.huya import collab as collab_api
from web.backend.schemas import HuyaExternalOrderListOut, HuyaExternalOrderUpdate
from web.backend.schemas import (
HuyaCollabCkPreviewOut,
HuyaConfigOut,
HuyaExternalOrderCreate,
HuyaExternalOrderListOut,
HuyaExternalOrderOut,
HuyaExternalOrderStatusBatchRequest,
HuyaExternalOrderUpdate,
)
from web.backend.services import huya_collab_service as collab
from web.backend.services.huya_collab_events import collab_events
@@ -437,6 +445,64 @@ class TestCollabStatusMachine(TestCollabBase):
collab.set_status(self.db, self.user, order, "incomplete", rollback=True)
assert excinfo.value.code == "status_from_void"
def test_super_admin_can_force_recover_void_order(self):
"""作废默认不可逆;超级管理员可凭原因强制恢复,并保留审计证据。"""
order = self._bind_ready(self._create(order_no="VOID-FORCE-1"))
collab.set_status(self.db, self.user, order, "void", reason="客户退款")
admin = User(username="force-admin", password_hash="h", role="super_admin")
self.db.add(admin)
self.db.commit()
collab.set_status(
self.db,
admin,
order,
collab.STATUS_TO_RECHARGE,
reason="误作废,客户要求继续处理",
force=True,
)
assert order.status == collab.STATUS_TO_RECHARGE
assert order.void_reason == ""
audit = [
row for row in self._audits(order) if row.action == "huya:collab:status"
][-1]
detail = json.loads(audit.detail)
assert detail["force"] is True
assert detail["force_reason"] == "误作废,客户要求继续处理"
assert detail["previous_void_reason"] == "客户退款"
def test_force_recover_void_requires_super_admin_and_reason(self):
order = self._bind_ready(self._create(order_no="VOID-FORCE-2"))
collab.set_status(self.db, self.user, order, "void", reason="客户退款")
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(
self.db,
self.user,
order,
collab.STATUS_TO_RECHARGE,
reason="普通运营尝试恢复",
force=True,
)
assert excinfo.value.code == "force_admin_required"
assert excinfo.value.status_code == 403
admin = User(username="force-admin-2", password_hash="h", role="super_admin")
self.db.add(admin)
self.db.commit()
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(
self.db,
admin,
order,
collab.STATUS_TO_RECHARGE,
force=True,
)
assert excinfo.value.code == "field_required"
assert "强制恢复原因" in excinfo.value.detail
assert order.status == "void"
def test_same_status_is_noop(self):
order = self._bind_ready(self._create())
before = len(self._audits(order))
@@ -493,8 +559,10 @@ class TestCollabCkLifecycle(TestCollabBase):
def test_pasting_new_ck_resets_verification(self):
order = self._create(ck_text=fake_ck("1001"))
collab.set_ck_status(self.db, self.user, order, "valid")
order.nickname = "旧虎牙昵称"
collab.set_ck(self.db, self.user, order, fake_ck("1002"))
assert order.uid == "1002"
assert order.nickname == ""
assert order.ck_status == "unverified"
assert order.ck_checked_at is None
@@ -804,14 +872,20 @@ def points_center_blob(
stock: int = 39,
remaining: int | None = None,
category: str = "礼包",
category_id: int = 431,
extra_tabs: list[dict] | None = None,
) -> bytes:
"""构造 queryAllocation 的 ``tag5`` 最小商品列表(TAF LIST)。"""
os = TafOutputStream()
os.write_head(0, TafType.LIST)
os.write_int32(0, 1)
extra_tabs = list(extra_tabs or [])
os.write_int32(0, 1 + len(extra_tabs))
# 分类
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_int32(0, category_id)
os.write_string(1, category)
os.write_int32(3, 2)
os.write_int32(5, 0)
os.write_head(6, TafType.LIST)
os.write_int32(0, 1)
# 商品
@@ -841,7 +915,16 @@ def points_center_blob(
os.write_struct_end() # tag0
os.write_struct_end() # tag1
os.write_struct_end() # 商品
os.write_int32(7, 1)
os.write_struct_end() # 分类
for tab in extra_tabs:
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_int32(0, int(tab.get("tab_id") or 0))
os.write_string(1, str(tab.get("name") or ""))
os.write_int32(3, 2)
os.write_int32(5, 0)
os.write_int32(7, int(tab.get("count") or 0))
os.write_struct_end()
return os.get_bytes()
@@ -881,14 +964,22 @@ class FakeProbeClient:
"""离线探针客户端:按脚本依次给出 ``None`` / 异常 / 响应,并记录调用参数。"""
def __init__(
self, *outcomes, gifts: list | None = None, points_gifts: list | None = None
self,
*outcomes,
gifts: list | None = None,
points_gifts: list | None = None,
profile_nick: str | None = None,
profile_outcome: object | None = None,
):
self.outcomes = list(outcomes)
self.gift_outcomes = list(gifts or [])
self.point_outcomes = list(points_gifts or [])
self.profile_nick = profile_nick
self.profile_outcome = profile_outcome
self.calls: list[dict] = []
self.gift_calls: list[dict] = []
self.points_calls: list[dict] = []
self.profile_calls: list[dict] = []
self._current_bind_outcome: object | None = None
def check_user_bind_game_account(self, **kwargs):
@@ -926,6 +1017,19 @@ class FakeProbeClient:
raise outcome
return outcome
def get_user_profile_batch(self, **kwargs):
self.profile_calls.append(kwargs)
if self.profile_outcome is not None:
if isinstance(self.profile_outcome, Exception):
raise self.profile_outcome
return self.profile_outcome
if self.profile_nick is None:
return None
target_uid = int((kwargs.get("target_uids") or [0])[0] or 0)
return SimpleNamespace(
profiles=[SimpleNamespace(uid=target_uid, nick=self.profile_nick)]
)
class TestCollabCkProbe(TestCollabBase):
"""S5:只读探针。判据(真机对照 2026-10-16)= 响应非 None 且 status == 200。"""
@@ -942,7 +1046,8 @@ class TestCollabCkProbe(TestCollabBase):
role_id="1234567",
plat="手Q",
area="苹果(iOS)",
)
),
profile_nick="暖阳冰笑月",
)
result = collab.verify_ck(self.db, self.user, order, client=client)
@@ -952,9 +1057,31 @@ class TestCollabCkProbe(TestCollabBase):
assert order.ck_checked_at is not None
assert "随遇而安" in order.ck_check_message
assert "吃鸡199706" in order.ck_check_message
assert order.nickname == "暖阳冰笑月"
assert result["nickname"] == "暖阳冰笑月"
assert "暖阳冰笑月" in order.ck_check_message
assert client.profile_calls[0]["target_uids"] == [1199647239697]
assert order.version == 1
assert "huya:collab:ck_status" in [row.action for row in self._audits(order)]
def test_profile_failure_does_not_invalidate_cookie(self):
"""虎牙昵称是展示辅助:资料接口失败不能把已经通过的 CK 主结论判失效。"""
order = self._row_with_ck()
client = FakeProbeClient(
FakeBindResp(nick="7", role="小凛饼干_"),
profile_outcome=RuntimeError("profile boom"),
)
result = collab.verify_ck(self.db, self.user, order, client=client)
assert result["ok"] is True
assert order.ck_status == "valid"
assert order.bind_game_account == "7"
assert order.bind_game_role == "小凛饼干_"
assert order.nickname == ""
assert "虎牙昵称未读到" in order.ck_check_message
assert "RuntimeError" in order.ck_check_message
def test_verify_uses_this_rows_ck_and_the_default_activity(self):
"""探针必须拿**本行的** CK 去问默认活动,不能借用别的行/别的活动。"""
order = self._row_with_ck(uid="1199647239697")
@@ -1081,7 +1208,7 @@ class TestCollabCkProbe(TestCollabBase):
"bind_game_role": "吃鸡199706",
"bind_game_channel": "手Q · 苹果(iOS)",
}
# 人工填的列一个都不碰;nickname 是「虎牙号昵称」,这个探针给不出
# 人工填的列一个都不碰;这台假客户端没给虎牙资料,昵称保持空
assert order.bind_douyu_account == ""
assert order.nickname == ""
assert "huya:collab:probe_snapshot" in [
@@ -1332,7 +1459,8 @@ class TestCollabCkPreview(TestCollabBase):
client = FakeProbeClient(
FakeBindResp(
nick="随遇而安", role="吃鸡199706", plat="手Q", area="苹果(iOS)"
)
),
profile_nick="暖阳冰笑月",
)
preview = collab.preview_ck(fake_ck("1199647239697"), client=client)
@@ -1341,7 +1469,11 @@ class TestCollabCkPreview(TestCollabBase):
assert preview["has_role"] is True
assert preview["uid"] == "1199647239697"
assert preview["hy_username"] == "hy_1199647239697"
assert preview["nickname"] == "暖阳冰笑月"
assert preview["bind_game_role"] == "吃鸡199706"
# 接口响应模型必须真的序列化昵称,避免 Pydantic 静默丢字段。
dumped = HuyaCollabCkPreviewOut.model_validate(preview).model_dump()
assert dumped["nickname"] == "暖阳冰笑月"
assert preview["gifts"] == [
{
"product_id": "hy-1001",
@@ -1455,6 +1587,100 @@ class TestCollabGifts(TestCollabBase):
]
assert message == "已拉取 1 个礼包(积分中心)"
def test_points_center_gift_fetch_loads_all_discovered_tabs(self):
"""基础响应只给第一个页签;其余页签按 pageId + tabId 自动补拉。"""
base_payload = {
"tag2": [
{
"tag0": 343,
"tag5": points_center_blob(
name="基础礼包",
product_id="hy-1001",
points=200,
category="基础礼包",
extra_tabs=[{"tab_id": 433, "name": "限量返场", "count": 1}],
),
}
]
}
tab_payload = {
"tag2": [
{
"tag0": 343,
"tag5": points_center_blob(
name="套装-甜橙豚豚",
product_id="hy-9400194",
points=988,
category="限量返场",
category_id=433,
),
}
]
}
client = FakeProbeClient(
points_gifts=[
FakePointsCenterResp(payload=base_payload),
FakePointsCenterResp(payload=tab_payload),
]
)
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=client,
page_code="custom_page",
source_id="custom_source",
scene=8,
)
assert [gift["name"] for gift in gifts] == ["基础礼包", "套装-甜橙豚豚"]
assert client.points_calls[0]["page_code"] == "custom_page"
assert client.points_calls[0]["source_id"] == "custom_source"
assert client.points_calls[0]["scene"] == 8
assert "waterfall_tab_id" not in client.points_calls[0]
assert client.points_calls[1]["page_id"] == 343
assert client.points_calls[1]["waterfall_tab_id"] == 433
assert "2 个页签" in message
def test_points_center_tab_override_uses_configured_ids(self):
base_payload = {
"tag2": [
{
"tag0": 343,
"tag5": points_center_blob(name="基础礼包", product_id="hy-1001"),
}
]
}
tab_payload = {
"tag2": [
{
"tag0": 343,
"tag5": points_center_blob(
name="手工页签礼包", product_id="hy-2002"
),
}
]
}
client = FakeProbeClient(
points_gifts=[
FakePointsCenterResp(payload=base_payload),
FakePointsCenterResp(payload=tab_payload),
]
)
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=client,
page_id=343,
tab_ids=[999],
)
assert [gift["name"] for gift in gifts] == ["基础礼包", "手工页签礼包"]
assert client.points_calls[1]["page_id"] == 343
assert client.points_calls[1]["waterfall_tab_id"] == 999
assert "2 个页签" in message
def test_points_center_gift_fetch_reports_failure(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
@@ -1531,6 +1757,41 @@ class TestCollabGifts(TestCollabBase):
self.db.commit()
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID
def test_resolve_points_center_config_reads_manual_overrides(self):
self.db.add(
HuyaConfig(
points_page_code="custom_page",
points_source_id="custom_source",
points_scene="8",
points_page_id="343",
points_tab_ids="431, 433,999",
)
)
self.db.commit()
assert collab.resolve_points_center_config(self.db) == {
"page_code": "custom_page",
"source_id": "custom_source",
"scene": 8,
"page_id": 343,
"tab_ids": [431, 433, 999],
}
def test_config_out_serializes_points_center_fields(self):
dumped = HuyaConfigOut(
points_page_code="custom_page",
points_source_id="custom_source",
points_scene="8",
points_page_id="343",
points_tab_ids="431,433",
).model_dump()
assert dumped["points_page_code"] == "custom_page"
assert dumped["points_source_id"] == "custom_source"
assert dumped["points_scene"] == "8"
assert dumped["points_page_id"] == "343"
assert dumped["points_tab_ids"] == "431,433"
def test_preview_ignores_legacy_sid_and_uses_points_center(self):
self.db.add(HuyaConfig(sid="2277"))
self.db.commit()
@@ -1670,7 +1931,8 @@ class TestCollabCreateStrict(TestCollabBase):
client=FakeProbeClient(
FakeBindResp(
nick="随遇而安", role="吃鸡199706", plat="手Q", area="苹果(iOS)"
)
),
profile_nick="暖阳冰笑月",
),
)
@@ -1679,6 +1941,7 @@ class TestCollabCreateStrict(TestCollabBase):
assert order.bind_game_account == "随遇而安"
assert order.bind_game_role == "吃鸡199706"
assert order.bind_game_channel == "手Q · 苹果(iOS)"
assert order.nickname == "暖阳冰笑月"
assert order.bind_snapshot_at is not None
assert order.gift_name == "不朽伊人大礼包"
assert order.home_images == [self.HOME_IMAGE, "collab/2026/10/16/home02.png"]
@@ -1718,6 +1981,79 @@ class TestCollabCreateStrict(TestCollabBase):
assert '"draft"' in detail and "true" in detail
assert collab.summary(self.db)["by_status"]["incomplete"] == 1
def test_draft_with_ck_auto_fills_snapshot_and_nickname(self):
"""草稿单带了 CK 也自动验证,不用建完再手动点一次「验证 CK」。"""
order = collab.create_order(
self.db,
self.user,
order_no="DRAFT-AUTO-1",
shop="佳佳",
ck_text=fake_ck("1199661291272"),
draft=True,
auto_verify_ck=True,
client=FakeProbeClient(
FakeBindResp(nick="7", role="小凛饼干_", plat="苹果(iOS)", area="手Q"),
profile_nick="暖阳冰笑月",
),
)
assert order.status == collab.STATUS_INCOMPLETE
assert order.ck_status == "valid"
assert order.ck_checked_at is not None
assert order.bind_game_account == "7"
assert order.bind_game_role == "小凛饼干_"
assert order.bind_game_channel == "苹果(iOS) · 手Q"
assert order.nickname == "暖阳冰笑月"
assert order.bind_snapshot_at is not None
def test_draft_with_bad_ck_still_creates_and_exposes_failure(self):
"""自动探针失败不阻止草稿建单,但必须落成「已失效」并写明原因。"""
order = collab.create_order(
self.db,
self.user,
order_no="DRAFT-AUTO-BAD",
shop="佳佳",
ck_text=fake_ck("1199661291272"),
draft=True,
auto_verify_ck=True,
client=FakeProbeClient(FakeBindResp(status=401, msg="登录态失效")),
)
assert order.status == collab.STATUS_INCOMPLETE
assert order.ck_status == "invalid"
assert order.ck_checked_at is not None
assert "401" in order.ck_check_message
assert order.bind_game_account == ""
assert order.bind_game_role == ""
assert order.nickname == ""
def test_create_route_auto_verifies_draft_ck(self, monkeypatch):
"""新建接口是用户实际入口:草稿单带 CK 也必须自动补全并返回新字段。"""
client = FakeProbeClient(
FakeBindResp(nick="7", role="小凛饼干_", plat="苹果(iOS)", area="手Q"),
profile_nick="暖阳冰笑月",
)
monkeypatch.setattr(collab, "_build_probe_client", lambda: client)
out = collab_api.create_external_order(
HuyaExternalOrderCreate(
order_no="DRAFT-ROUTE-AUTO",
shop="佳佳",
ck_text=fake_ck("1199661291272"),
draft=True,
),
db=self.db,
current=self.user,
)
assert out["ck_status"] == "valid"
assert out["bind_game_account"] == "7"
assert out["bind_game_role"] == "小凛饼干_"
assert out["nickname"] == "暖阳冰笑月"
dumped = HuyaExternalOrderOut.model_validate(out).model_dump()
assert dumped["nickname"] == "暖阳冰笑月"
assert dumped["bind_game_role"] == "小凛饼干_"
def test_incomplete_cannot_advance_until_customer_info_complete(self):
order = collab.create_order(
self.db, self.user, order_no="DRAFT-2", shop="佳佳", draft=True
@@ -2426,6 +2762,53 @@ class TestCollabStatusBatch(TestCollabBase):
assert accepted["updated"] == 1
assert order.status == collab.STATUS_TO_RECHARGE
def test_batch_force_recovers_void_order_for_super_admin(self):
order = self._bind_ready(self._create(order_no="BATCH-VOID-FORCE"))
collab.set_status(self.db, self.user, order, "void", reason="误操作")
admin = User(
username="batch-force-admin", password_hash="h", role="super_admin"
)
self.db.add(admin)
self.db.commit()
result = collab.set_status_batch(
self.db,
admin,
[order],
collab.STATUS_TO_RECHARGE,
reason="批量恢复误作废订单",
force=True,
)
assert result["updated"] == 1
assert result["failed"] == 0
assert order.status == collab.STATUS_TO_RECHARGE
def test_batch_status_route_serializes_force_and_recovers_void(self):
"""批量接口必须真正带上 force,不能只在服务层测试里生效。"""
order = self._bind_ready(self._create(order_no="BATCH-ROUTE-FORCE"))
collab.set_status(self.db, self.user, order, "void", reason="误操作")
admin = User(
username="batch-route-admin", password_hash="h", role="super_admin"
)
self.db.add(admin)
self.db.commit()
req = HuyaExternalOrderStatusBatchRequest(
ids=[order.id],
status=collab.STATUS_TO_RECHARGE,
reason="接口层强制恢复",
force=True,
)
assert req.model_dump()["force"] is True
out = collab_api.batch_update_external_order_status(
req, db=self.db, current=admin
)
assert out["updated"] == 1
assert out["failed"] == 0
assert order.status == collab.STATUS_TO_RECHARGE
def test_batch_rejects_empty_and_too_many(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status_batch(self.db, self.user, [], "scripted")
+65 -5
View File
@@ -10,6 +10,7 @@
"""
import json
import time
from types import SimpleNamespace
from core.huya.frame_decoder import decode_struct_bytes
@@ -396,6 +397,7 @@ def test_refresh_elite_open_after_payment_retries_until_opened():
def test_refresh_elite_open_after_payment_gives_up_after_attempts():
"""支付已确认但权益未到账:不能再写死「未开通」,必须标成「开通中」。"""
client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX))
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="create_recharge_order")
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
@@ -412,11 +414,66 @@ def test_refresh_elite_open_after_payment_gives_up_after_attempts():
)
assert client.redeem_calls == 2 # 重试到上限
assert state["elite_opened"] is False
assert account.elite_opened is False
assert state["elite_opened"] is None
assert state["elite_opening"] is True
assert state["elite_status_label"] == "开通中"
assert account.elite_opened is None
assert account.elite_open_time is None
def test_query_all_marks_recent_paid_elite_order_as_opening(monkeypatch):
"""已支付但 redeem 还是 0:订单列表证明已付款,应显示「开通中」。"""
now_ms = int(time.time() * 1000)
opening_order = {
**PAID_ELITE_ORDER,
"order_id": "12001621",
"pay_time": now_ms - 60_000,
"create_time": now_ms - 120_000,
}
shop_client = _StubClient(
redeem=_redeem(REDEEM_NOT_OPENED_HEX),
order_pages=[[opening_order]],
season=True,
)
runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object())
monkeypatch.setattr(
runner,
"_probe_bind_status",
lambda **kwargs: (_FakeBindStatus(status=601, msg="活动已下线"), {}),
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_opening"] is True
assert result["elite_status"] == "opening"
assert result["elite_status_label"] == "开通中"
assert result["elite_order"]["order_id"] == "12001621"
assert account.elite_opened is None
assert "开通中宝典" in message
def test_elite_order_state_labels_pending_and_cancelled_orders():
runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all")
assert (
runner._elite_order_state(
{**UNPAID_ELITE_ORDER, "order_id": "1"}, opened=False
)["status"]
== "pending_payment"
)
assert (
runner._elite_order_state(
{**PAID_ELITE_ORDER, "order_status": 90, "pay_time": 0}, opened=False
)["status"]
== "cancelled"
)
def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch):
"""支付成功后自动确认开通状态:任务结果与账号字段都带上宝典状态。"""
@@ -676,7 +733,7 @@ def test_query_all_combines_points_elite_and_role(monkeypatch):
)
def test_query_all_reports_not_opened_without_order_lookup(monkeypatch):
def test_query_all_reports_not_opened_and_checks_order_state(monkeypatch):
shop_client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX))
runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object())
monkeypatch.setattr(
@@ -699,8 +756,11 @@ def test_query_all_reports_not_opened_without_order_lookup(monkeypatch):
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"]
# 未开通也要查最近订单:否则无法区分「待支付 / 开通中 / 已取消」
assert [name for name, _kwargs in shop_client.calls] == [
"get_redeem_need_goods",
"query_user_order_list",
]
def test_query_all_fails_when_points_center_unreachable(monkeypatch):
+56
View File
@@ -253,6 +253,47 @@ def test_pay_order_submit_v5_callback_points_page():
asyncio.run(run())
def test_order_poll_rpcs_accept_timeout_keyword():
"""支付监听统一传 timeout;WSS 方法缺这个参数会在生成二维码后立刻崩。"""
async def run():
client = _points_client()
await client.query_user_order_list(
uid=UID,
guid=GUID,
cookie=COOKIE,
offset=0,
page_size=20,
order_type=1,
status=0,
timeout=10.0,
)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"revenueWebUI",
"queryUserOrderList",
)
assert decoded["tReq"]["tag2"] == 1
assert decoded["tReq"]["tag3"] == 20
await client.order_detail(
uid=UID,
guid=GUID,
cookie=COOKIE,
order_id=12001621,
timeout=10.0,
)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"shopMiddleUI",
"orderDetailV5",
)
assert decoded["tReq"]["tag2"] == 12001621
asyncio.run(run())
def test_new_points_rpcs_match_capture():
async def run():
client = _points_client()
@@ -307,6 +348,21 @@ def test_new_points_rpcs_match_capture():
assert t_req["tag1"]["tag6"] == "yellowcarlist"
assert t_req["tag2"] == "heping_baodian_credit_center_page"
await client.play_mall_query_allocation(
uid=UID,
guid=GUID,
cookie=COOKIE,
scene=8,
page_id=343,
waterfall_tab_id=433,
)
decoded = client.ws.last_request()
t_req = decoded["tReq"]
assert t_req["tag1"]["tag5"] == 8
assert t_req["tag7"] == '{"waterfallTabId":"433"}'
assert t_req["tag9"] == 343
assert t_req["tag11"] == []
await client.play_mall_get_my_promotion(
uid=UID,
guid=GUID,
+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 = "20261109_0075"
HEAD_REVISION = "20261111_0076"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
+9
View File
@@ -7,6 +7,9 @@ HUYA_DEFAULT_OUTER_ACT_ID = "17096"
HUYA_DEFAULT_BIND_GID = "3203"
HUYA_DEFAULT_BIND_ACT_ID = "9271"
HUYA_DEFAULT_PAY_CHANNEL = "Zfb"
HUYA_DEFAULT_POINTS_PAGE_CODE = "heping_baodian_credit_center_page"
HUYA_DEFAULT_POINTS_SOURCE_ID = "yellowcarlist"
HUYA_DEFAULT_POINTS_SCENE = "7"
HUYA_CONFIG_DEFAULTS = {
"room_pid": HUYA_DEFAULT_ROOM_PID,
@@ -15,6 +18,12 @@ HUYA_CONFIG_DEFAULTS = {
"bind_gid": HUYA_DEFAULT_BIND_GID,
"bind_act_id": HUYA_DEFAULT_BIND_ACT_ID,
"pay_channel": HUYA_DEFAULT_PAY_CHANNEL,
"points_page_code": HUYA_DEFAULT_POINTS_PAGE_CODE,
"points_source_id": HUYA_DEFAULT_POINTS_SOURCE_ID,
"points_scene": HUYA_DEFAULT_POINTS_SCENE,
# 空 = 从 queryAllocation 响应的 tag0_2 自动发现。
"points_page_id": "",
"points_tab_ids": "",
}
HUYA_CONFIG_FIELDS = tuple(HUYA_CONFIG_DEFAULTS)
@@ -0,0 +1,55 @@
"""虎牙积分中心活动参数改为可配置。
礼包页签会随活动调整;pageCode/sourceId/scene 及可选 pageId/tabIds
放进 ``huya_config``,避免每次换活动都改代码。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261111_0076"
down_revision: str | None = "20261109_0075"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "huya_config"
COLUMNS: tuple[tuple[str, sa.String, str], ...] = (
("points_page_code", sa.String(length=128), "heping_baodian_credit_center_page"),
("points_source_id", sa.String(length=64), "yellowcarlist"),
("points_scene", sa.String(length=16), "7"),
("points_page_id", sa.String(length=32), ""),
("points_tab_ids", sa.String(length=256), ""),
)
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table(TABLE):
return
existing = {column["name"] for column in inspector.get_columns(TABLE)}
for name, column_type, default in COLUMNS:
if name in existing:
continue
op.add_column(
TABLE,
sa.Column(
name,
column_type,
nullable=False,
server_default=default,
),
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table(TABLE):
return
existing = {column["name"] for column in inspector.get_columns(TABLE)}
for name, _column_type, _default in reversed(COLUMNS):
if name in existing:
op.drop_column(TABLE, name)
+26
View File
@@ -26,6 +26,9 @@ from .huya_defaults import (
HUYA_DEFAULT_BIND_GID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_PAY_CHANNEL,
HUYA_DEFAULT_POINTS_PAGE_CODE,
HUYA_DEFAULT_POINTS_SCENE,
HUYA_DEFAULT_POINTS_SOURCE_ID,
HUYA_DEFAULT_ROOM_PID,
HUYA_DEFAULT_SID,
)
@@ -1377,6 +1380,29 @@ class HuyaConfig(Base):
pay_channel: Mapped[str] = mapped_column(
String(16), default=HUYA_DEFAULT_PAY_CHANNEL
)
# 积分中心 queryAllocation:活动换页时通常只需改这里,不用改代码。
points_page_code: Mapped[str] = mapped_column(
String(128),
default=HUYA_DEFAULT_POINTS_PAGE_CODE,
server_default=HUYA_DEFAULT_POINTS_PAGE_CODE,
)
points_source_id: Mapped[str] = mapped_column(
String(64),
default=HUYA_DEFAULT_POINTS_SOURCE_ID,
server_default=HUYA_DEFAULT_POINTS_SOURCE_ID,
)
points_scene: Mapped[str] = mapped_column(
String(16),
default=HUYA_DEFAULT_POINTS_SCENE,
server_default=HUYA_DEFAULT_POINTS_SCENE,
)
# 空 = 从响应自动发现;手动填写可作为接口改版时的兜底。
points_page_id: Mapped[str] = mapped_column(
String(32), default="", server_default=""
)
points_tab_ids: Mapped[str] = mapped_column(
String(256), default="", server_default=""
)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
+5
View File
@@ -456,6 +456,11 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut:
bind_gid=huya_config_value("bind_gid", config.bind_gid),
bind_act_id=huya_config_value("bind_act_id", config.bind_act_id),
pay_channel=huya_config_value("pay_channel", config.pay_channel),
points_page_code=huya_config_value("points_page_code", config.points_page_code),
points_source_id=huya_config_value("points_source_id", config.points_source_id),
points_scene=huya_config_value("points_scene", config.points_scene),
points_page_id=huya_config_value("points_page_id", config.points_page_id),
points_tab_ids=huya_config_value("points_tab_ids", config.points_tab_ids),
updated_at=config.updated_at,
)
+7 -3
View File
@@ -385,7 +385,7 @@ def create_external_order(
"""新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。
``draft=true``:先建单后补资料 —— 订单号 + 店铺即可,状态进「待补资料」,
CK / 礼包名 / 主页截图稍后在详情里补(不跑探针)。
CK / 礼包名 / 主页截图稍后在详情里补;带了 CK 仍会自动跑一次只读探针。
``require_verified``(默认 true):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK /
礼包名 / 主页截图必填,且 CK 必须探针通并读到绑定角色,否则 400
@@ -405,6 +405,7 @@ def create_external_order(
home_images=req.home_images,
remark=req.remark,
require_verified=req.require_verified,
auto_verify_ck=True,
draft=req.draft,
)
except collab.CollabError as exc:
@@ -443,14 +444,15 @@ def preview_external_order_ck(
新建弹窗据此决定能不能提交:``ok`` 且 ``has_role`` 才允许
(服务端在创建时还会再校验一次,见 ``POST /external-orders``)。
礼包用**工作台配置里的活动 SID**(``huya_config.sid``)拉:SID 就是活动 ID,
换活动就得换它,写死会出现「接口 200 但礼包列表是空的」(真机 2026-10-16)。
礼包用积分中心 ``queryAllocation`` 拉取;pageCode/sourceId/scene 与可选页签覆盖
来自虎牙配置,页签默认从响应自动发现并合并全部页签。
"""
try:
preview = collab.preview_ck(
req.ck_text,
with_gifts=req.with_gifts,
sid=collab.resolve_gifts_sid(db),
points_config=collab.resolve_points_center_config(db),
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@@ -502,6 +504,7 @@ def batch_update_external_order_status(
req.status,
reason=req.reason,
rollback=req.rollback,
force=req.force,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@@ -767,6 +770,7 @@ def update_external_order_status(
req.status,
reason=req.reason,
rollback=req.rollback,
force=req.force,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
+25 -1
View File
@@ -10,6 +10,9 @@ from .huya_defaults import (
HUYA_DEFAULT_BIND_GID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_PAY_CHANNEL,
HUYA_DEFAULT_POINTS_PAGE_CODE,
HUYA_DEFAULT_POINTS_SCENE,
HUYA_DEFAULT_POINTS_SOURCE_ID,
HUYA_DEFAULT_ROOM_PID,
HUYA_DEFAULT_SID,
)
@@ -1158,6 +1161,11 @@ class HuyaConfigOut(BaseModel):
bind_gid: str = HUYA_DEFAULT_BIND_GID
bind_act_id: str = HUYA_DEFAULT_BIND_ACT_ID
pay_channel: str = HUYA_DEFAULT_PAY_CHANNEL
points_page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE
points_source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID
points_scene: str = HUYA_DEFAULT_POINTS_SCENE
points_page_id: str = ""
points_tab_ids: str = ""
updated_at: datetime | None = None
@model_serializer
@@ -1169,6 +1177,11 @@ class HuyaConfigOut(BaseModel):
"bind_gid": self.bind_gid,
"bind_act_id": self.bind_act_id,
"pay_channel": self.pay_channel,
"points_page_code": self.points_page_code,
"points_source_id": self.points_source_id,
"points_scene": self.points_scene,
"points_page_id": self.points_page_id,
"points_tab_ids": self.points_tab_ids,
"updated_at": _isoformat(self.updated_at),
}
@@ -1180,6 +1193,11 @@ class HuyaConfigUpdate(BaseModel):
bind_gid: str | None = None
bind_act_id: str | None = None
pay_channel: str | None = None
points_page_code: str | None = None
points_source_id: str | None = None
points_scene: str | None = None
points_page_id: str | None = None
points_tab_ids: str | None = None
class HuyaTaskBatchRequest(BaseModel):
@@ -1727,6 +1745,7 @@ class HuyaCollabCkPreviewOut(BaseModel):
uid: str = ""
yyuid: str = ""
hy_username: str = ""
nickname: str = ""
ck_status: str = "unverified"
bind_game_account: str = ""
bind_game_role: str = ""
@@ -1745,7 +1764,7 @@ class HuyaExternalOrderCreate(BaseModel):
礼包名 / **主页截图**必填,且 CK 必须探针通并读到绑定角色;粘贴导入不走这条路。
``draft``(默认关):先建单后补资料 —— 只登记订单号 + 店铺,状态进「待补资料」,
CK / 礼包名 / 主页截图都可以稍后在详情里补(2026-10-17 用户需求)。
CK / 礼包名 / 主页截图都可以稍后在详情里补;带了 CK 仍会自动跑一次只读探针。
"""
order_no: str = Field(..., min_length=1, max_length=64)
@@ -1785,6 +1804,8 @@ class HuyaExternalOrderStatusRequest(BaseModel):
reason: str = Field("", max_length=256)
# 目标不是「向前一步」时必须显式置 True,避免误点把状态改回去。
rollback: bool = False
# 仅超级管理员可用:从「已作废」强制恢复,必须同时填写 reason。
force: bool = False
expected_version: int | None = None
@@ -1858,6 +1879,7 @@ class HuyaExternalOrderVerifyItemOut(BaseModel):
ok: bool = False
ck_status: str = ""
message: str = ""
nickname: str = ""
binding: dict[str, str] = Field(default_factory=dict)
@@ -1878,6 +1900,8 @@ class HuyaExternalOrderStatusBatchRequest(BaseModel):
reason: str = Field("", max_length=256)
# 目标不是「向前」时必须显式置 True(和单行接口一致,避免误点)
rollback: bool = False
# 仅超级管理员可用;混合批次里只对 void 行生效,必须同时填写 reason。
force: bool = False
class HuyaExternalOrderStatusBatchItemOut(BaseModel):
+305 -44
View File
@@ -35,6 +35,9 @@ from core.huya.http_client import query_bind_game_account
from ..huya_defaults import (
HUYA_DEFAULT_BIND_GID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_POINTS_PAGE_CODE,
HUYA_DEFAULT_POINTS_SCENE,
HUYA_DEFAULT_POINTS_SOURCE_ID,
HUYA_DEFAULT_SID,
)
from ..models import (
@@ -48,7 +51,11 @@ from ..models import (
from .audit_service import record_audit
from .file_service import build_file_url
from .file_storage import normalize_object_key
from .huya_service import build_web_cookie_from_raw, parse_huya_cookie_line
from .huya_service import (
build_web_cookie_from_raw,
huya_config_value,
parse_huya_cookie_line,
)
# --------------------------------------------------------------------------
# 业务状态机(一单卡在哪一步)
@@ -217,6 +224,7 @@ FIELD_LIMITS = {
"remark": 512,
"void_reason": 256,
"archive_reason": 256,
"force_reason": 256,
}
FIELD_LABELS = {
@@ -230,6 +238,7 @@ FIELD_LABELS = {
"required_points": "礼包所需积分",
"remark": "备注",
"void_reason": "作废原因",
"force_reason": "强制恢复原因",
"archive_reason": "归档备注",
}
@@ -457,6 +466,8 @@ def _apply_ck(order: HuyaExternalOrder, cookie_text: str) -> dict:
order.uid = str(parsed.get("uid") or "")
order.yyuid = str(parsed.get("yyuid") or "")
order.hy_username = str(parsed.get("username") or "")[:128]
# 换 CK 后旧昵称不再可信;等下一次验证时由虎牙用户资料接口重新补全。
order.nickname = ""
order.cookie = cookie
# 粘贴的若是 App 原始凭据(没有网页设备字段),顺手留一份 raw_cookie,
# 以后被风控标记时能直接「重建」而不必重新要 CK。
@@ -618,6 +629,7 @@ def set_status(
*,
reason: str = "",
rollback: bool = False,
force: bool = False,
allow_recharge: bool = False,
commit: bool = True,
) -> dict:
@@ -625,6 +637,9 @@ def set_status(
``recharged`` / ``points_error`` 只能由充值核账流程写入(``allow_recharge=True``);
普通人工状态接口不允许绕过核账。
``force=True`` 仅用于超级管理员把 ``void`` 恢复到流程内:必须填写原因,
只跳过“作废不可逆”限制,不绕过资料完整性与核账校验。
"""
if target not in ORDER_STATUSES:
raise CollabError(f"未知状态:{target}", code="status_unknown")
@@ -638,9 +653,24 @@ def set_status(
return {}
from_status = order.status
force_applied = False
force_reason = ""
if force:
if current is None or current.role != "super_admin":
raise CollabError(
"只有超级管理员可以强制恢复已作废的订单",
status_code=403,
code="force_admin_required",
)
if from_status == STATUS_VOID:
force_reason = clean_text(reason, field="force_reason", required=True)
force_applied = True
forward = is_forward_status(from_status, target)
if not forward:
if not rollback:
if force_applied:
pass
elif not rollback:
next_status = NEXT_STATUS.get(from_status)
hint = (
f"要往前的话下一步是「{ORDER_STATUS_LABELS[next_status]}」(也可以直接跳到更后面的状态)"
@@ -652,7 +682,7 @@ def set_status(
f"「{ORDER_STATUS_LABELS[target]}」({hint});确实要回退请显式勾选「回退」",
code="status_not_forward",
)
if from_status == STATUS_VOID:
elif from_status == STATUS_VOID:
raise CollabError(
"已作废的单子不能改回流程内状态,请编辑或新建",
code="status_from_void",
@@ -668,6 +698,7 @@ def set_status(
_assert_can_enter_to_recharge(order)
now = _now()
previous_void_reason = order.void_reason or ""
if target == STATUS_VOID:
order.void_reason = clean_text(reason, field="void_reason", required=True)
else:
@@ -689,6 +720,9 @@ def set_status(
"to": target,
"rollback": bool(not forward),
"reason": order.void_reason,
"force": force_applied,
"force_reason": force_reason,
"previous_void_reason": previous_void_reason if force_applied else "",
"order_no": order.order_no,
"allow_recharge": bool(allow_recharge),
},
@@ -707,6 +741,7 @@ def set_status_batch(
*,
reason: str = "",
rollback: bool = False,
force: bool = False,
) -> dict:
"""批量改状态:勾选多行一次切到同一个状态(部分失败不影响其它行)。
@@ -740,6 +775,7 @@ def set_status_batch(
target,
reason=reason,
rollback=rollback,
force=force,
commit=False,
)
except CollabError as exc:
@@ -850,6 +886,7 @@ def _resolve_group_required_points(
int(source.uid),
str(source.cookie),
client=client,
**resolve_points_center_config(db),
)
if not catalog:
return [order.order_no for order in missing], message
@@ -1469,6 +1506,8 @@ class CkProbeResult:
ok: bool
message: str
snapshot: dict[str, str] = dataclasses.field(default_factory=dict)
nickname: str = ""
nickname_error: str = ""
def _build_probe_client() -> Any:
@@ -1488,9 +1527,9 @@ def _probe_snapshot(resp: Any) -> dict[str, str]:
"""探针读到的**绑定对象**:绑定在该虎牙号上的游戏账号 + 角色。
2026-10-16 真机确认:``check_user_bind_game_account`` 返回的是**游戏账号**
(账号昵称 / 角色名 / 平台·区服),**不是虎牙号自己的昵称** —— 虎牙昵称要
`get_grow_level_user_info`,可那个接口不认凭据、当不了探针,所以 ``nickname``
一个字段都不碰(那是「虎牙号昵称」,硬写就是假数据)。
(账号昵称 / 角色名 / 平台·区服),**不是虎牙号自己的昵称**。本函数只组装游戏
绑定快照;虎牙平台昵称由 :func:`_probe_profile_nickname` 在绑定探针通过后补读,
绝不能拿 ``gameAccount.nick`` 冒充。
这几个列由探针**独占并覆盖写入**(``bind_snapshot_at`` 记读取时间):
"这单现在到底绑了哪个号、哪个角色"只有这里可信(人工填的是自由文本)。
@@ -1548,6 +1587,44 @@ def _probe_success_message(resp: Any) -> str:
return (f"有效:{detail}" if detail else "有效")[:_CK_MESSAGE_LIMIT]
def _probe_profile_nickname(client: Any, *, uid: int, cookie: str) -> tuple[str, str]:
"""在绑定探针成功后补读虎牙平台昵称。
这是**展示辅助**,不参与 CK 有效性判断:``check_user_bind_game_account``
已经确认凭据有效;用户资料接口失败时保留“CK 有效”,只把失败原因带回 UI。
不用 ``gameAccount.nick`` 顶替,两者语义不同(游戏账号 vs 虎牙平台账号)。
"""
method = getattr(client, "get_user_profile_batch", None)
if method is None:
return "", ""
try:
resp = method(
uid=uid,
cookie=cookie,
target_uids=[uid],
timeout=PROBE_TIMEOUT_SECONDS,
)
except Exception as exc: # noqa: BLE001 昵称失败不能影响 CK 主结论
logger.debug(f"[huya-collab] 虎牙昵称查询异常:{type(exc).__name__}: {exc}")
return "", f"{type(exc).__name__}: {_clip(exc)}"
if resp is None:
return "", "虎牙昵称接口没有返回数据"
profiles = list(getattr(resp, "profiles", None) or [])
if not profiles:
return "", "虎牙昵称接口没有返回用户资料"
profile = next(
(item for item in profiles if int(getattr(item, "uid", 0) or 0) == uid),
None,
)
if profile is None:
return "", "虎牙昵称接口返回的 uid 不匹配"
nickname = str(getattr(profile, "nick", "") or "").strip()
if not nickname:
return "", "虎牙昵称接口没有返回昵称"
return _clip(nickname, 128), ""
def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeResult:
"""只读探针:这份 CK 还能不能过(不写库、不发敏感动作)。
@@ -1599,7 +1676,21 @@ def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeR
msg = _clip(getattr(resp, "msg", ""))
suffix = f":{msg}" if msg else ""
return CkProbeResult(False, f"探针返回 status={status}{suffix}")
result = CkProbeResult(True, _probe_success_message(resp), _probe_snapshot(resp))
nickname, nickname_error = _probe_profile_nickname(
probe_client, uid=uid, cookie=cookie
)
message = _probe_success_message(resp)
if nickname:
message = f"{message};虎牙昵称 {nickname}"[:_CK_MESSAGE_LIMIT]
elif nickname_error:
message = f"{message};虎牙昵称未读到:{nickname_error}"[:_CK_MESSAGE_LIMIT]
result = CkProbeResult(
True,
message,
_probe_snapshot(resp),
nickname=nickname,
nickname_error=nickname_error,
)
if cache_key:
_probe_cache[cache_key] = (time.time(), result)
return result
@@ -1628,6 +1719,49 @@ def resolve_gifts_sid(db: Session) -> int:
return value or GIFTS_SID
def resolve_points_center_config(db: Session) -> dict[str, Any]:
"""积分中心活动参数:优先读虎牙配置,空值/脏值回落到默认。
页签 ID 默认留空,由 ``queryAllocation`` 响应自动发现;``points_page_id``
与 ``points_tab_ids`` 只作为接口改版、自动发现失败时的人工覆盖。
"""
row = db.query(HuyaConfig).first()
def value(field: str, default: str) -> str:
return (
huya_config_value(
field, getattr(row, field, None) if row is not None else None
)
or default
)
try:
scene = int(value("points_scene", HUYA_DEFAULT_POINTS_SCENE))
except (TypeError, ValueError):
scene = int(HUYA_DEFAULT_POINTS_SCENE)
page_id_text = value("points_page_id", "")
try:
page_id = int(page_id_text) if page_id_text else None
except ValueError:
page_id = None
tab_ids: list[int] = []
for part in re.split(r"[,,\s]+", value("points_tab_ids", "")):
if part.isdigit():
tab_id = int(part)
if tab_id not in tab_ids:
tab_ids.append(tab_id)
return {
"page_code": value("points_page_code", HUYA_DEFAULT_POINTS_PAGE_CODE),
"source_id": value("points_source_id", HUYA_DEFAULT_POINTS_SOURCE_ID),
"scene": scene,
"page_id": page_id,
"tab_ids": tab_ids or None,
}
def fetch_activity_gifts(
uid: int,
cookie: str,
@@ -1692,52 +1826,149 @@ def fetch_points_center_gifts(
cookie: str,
*,
client: Any | None = None,
page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE,
source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID,
scene: int = int(HUYA_DEFAULT_POINTS_SCENE),
page_id: int | None = None,
tab_ids: Sequence[int] | None = None,
) -> tuple[list[dict], str]:
"""用这份 CK 拉**积分中心商品**(``PlayMallUI.queryAllocation``,只读)。
2026-10-19 用户拍板:外部单协作台账改用这个接口,不再调用
``webActUI.getActPrizeList``。商品积分取 ``tag75.tag0.tag2``,
与积分中心页面显示一致;旧接口保留给精英宝典工作台的兑换链路。
2026-11-11 HAR 确认:基础请求只返回第一个页签的商品,其余页签定义在
``tag2[].tag0_2``,需要带 ``tag9=pageId`` 与
``tag7={"waterfallTabId":"tabId"}`` 再查。这里默认自动发现并拉全,
``page_id`` / ``tab_ids`` 仅作为配置覆盖。
"""
try:
gift_client = client or _build_probe_client()
resp = gift_client.play_mall_query_allocation(
uid=int(uid),
guid="",
cookie=cookie,
deep=True,
)
except Exception as exc: # noqa: BLE001 拉礼包失败不该让整个预览 500
logger.debug(f"[huya-collab] 拉取积分中心商品异常:{type(exc).__name__}: {exc}")
return [], f"拉取礼包失败:{type(exc).__name__}: {_clip(exc)}"
from core.huya.shop_structs import (
extract_points_center_tabs,
parse_points_center_gifts,
)
if resp is None:
return [], "拉取礼包失败:积分中心接口没有返回数据"
gift_client = client or _build_probe_client()
code = getattr(resp, "code", None)
if code is None:
code = getattr(resp, "status", 0)
code = int(code or 0)
if code != 200:
msg = _clip(getattr(resp, "message", "") or getattr(resp, "msg", ""))
return [], f"拉取礼包失败:code={code}{(':' + msg) if msg else ''}"
payload = getattr(resp, "payload", None)
if payload is None and hasattr(resp, "to_dict"):
def call(
tab_id: int | None = None, tab_page_id: int | None = None
) -> tuple[dict | None, str]:
kwargs: dict[str, Any] = {
"uid": int(uid),
"guid": "",
"cookie": cookie,
"page_code": page_code,
"source_id": source_id,
"scene": scene,
"deep": True,
}
if tab_id is not None:
kwargs["page_id"] = int(page_id or tab_page_id or 0)
kwargs["waterfall_tab_id"] = int(tab_id)
try:
payload = (resp.to_dict() or {}).get("payload")
except Exception: # noqa: BLE001 第三方响应结构异常时按空处理
payload = None
resp = gift_client.play_mall_query_allocation(**kwargs)
except Exception as exc: # noqa: BLE001 单个页签失败不该带崩整批
logger.debug(
f"[huya-collab] 拉取积分中心商品异常:{type(exc).__name__}: {exc}"
)
return None, f"{type(exc).__name__}: {_clip(exc)}"
from core.huya.shop_structs import parse_points_center_gifts
if resp is None:
return None, "积分中心接口没有返回数据"
gifts = parse_points_center_gifts(payload)[:GIFTS_LIMIT]
code = getattr(resp, "code", None)
if code is None:
code = getattr(resp, "status", 0)
code = int(code or 0)
if code != 200:
msg = _clip(getattr(resp, "message", "") or getattr(resp, "msg", ""))
return None, f"code={code}{(':' + msg) if msg else ''}"
payload = getattr(resp, "payload", None)
if payload is None and hasattr(resp, "to_dict"):
try:
payload = (resp.to_dict() or {}).get("payload")
except Exception: # noqa: BLE001 第三方响应结构异常时按空处理
payload = None
return payload if isinstance(payload, dict) else None, ""
payload, error = call()
if error:
return [], f"拉取礼包失败:{error}"
gifts = parse_points_center_gifts(payload)
tabs = extract_points_center_tabs(payload)
if not gifts and not tabs:
return [], (
"积分中心没返回商品:接口通了但列表是空的,"
"检查账号是否仍有积分中心访问权限,或稍后重试"
)
discovered = {(int(tab["page_id"]), int(tab["tab_id"])): tab for tab in tabs}
loaded_categories = {str(gift.get("category_name") or "").strip() for gift in gifts}
tab_errors: list[str] = []
if tab_ids:
targets: list[dict] = []
for tab_id in tab_ids:
matched = next(
(
tab
for (tab_page_id, tab_id_value), tab in discovered.items()
if tab_id_value == int(tab_id)
and (page_id is None or tab_page_id == int(page_id))
),
None,
)
if matched is not None:
targets.append(matched)
elif page_id is not None:
targets.append(
{
"page_id": int(page_id),
"tab_id": int(tab_id),
"name": f"页签 {tab_id}",
}
)
else:
tab_errors.append(f"页签 {tab_id}:响应里没发现,且未配置 pageId")
else:
targets = [
tab
for tab in tabs
if str(tab.get("name") or "").strip() not in loaded_categories
]
seen_products = {
str(gift.get("product_id") or "") for gift in gifts if gift.get("product_id")
}
loaded_tabs = len(loaded_categories) if gifts else 0
for tab in targets:
target_page_id = int(page_id or tab["page_id"])
tab_payload, tab_error = call(int(tab["tab_id"]), target_page_id)
if tab_error:
tab_errors.append(f"{tab.get('name') or tab['tab_id']}:{tab_error}")
continue
tab_gifts = parse_points_center_gifts(tab_payload)
added = 0
for gift in tab_gifts:
product_id = str(gift.get("product_id") or "")
if product_id and product_id not in seen_products:
seen_products.add(product_id)
gifts.append(gift)
added += 1
if added or tab_gifts:
loaded_tabs += 1
gifts = gifts[:GIFTS_LIMIT]
if not gifts:
return [], (
"积分中心没返回商品:接口通了但列表是空的,"
"检查账号是否有积分中心访问权限,或稍后重试"
"检查账号是否仍有积分中心访问权限,或稍后重试"
)
return gifts, f"已拉取 {len(gifts)} 个礼包(积分中心)"
suffix = f",{loaded_tabs} 个页签" if loaded_tabs > 1 else ""
if tab_errors:
suffix += f";部分页签未加载:{';'.join(tab_errors[:3])}"
return gifts, f"已拉取 {len(gifts)} 个礼包(积分中心{suffix})"
# --------------------------------------------------------------------------
@@ -2290,6 +2521,7 @@ def preview_ck(
*,
with_gifts: bool = True,
sid: int | None = None,
points_config: Mapping[str, Any] | None = None,
client: Any | None = None,
) -> dict:
"""**只读预览**一份待提交的 CK:解析 + 探针(+ 顺带拉积分中心礼包),一行都不写库。
@@ -2313,7 +2545,10 @@ def preview_ck(
gifts_message = ""
if with_gifts and result.ok and uid_text:
gifts, gifts_message = fetch_points_center_gifts(
int(uid_text), cookie, client=client
int(uid_text),
cookie,
client=client,
**(dict(points_config) if points_config else {}),
)
return {
"ok": result.ok,
@@ -2321,6 +2556,7 @@ def preview_ck(
"uid": uid_text,
"yyuid": str(parsed.get("yyuid") or ""),
"hy_username": str(parsed.get("username") or ""),
"nickname": result.nickname,
"ck_status": CK_VALID if result.ok else CK_INVALID,
"bind_game_account": result.snapshot.get("bind_game_account", ""),
"bind_game_role": result.snapshot.get("bind_game_role", ""),
@@ -2361,16 +2597,20 @@ def verify_ck(
field_name: str(getattr(order, field_name) or "")
for field_name in PROBE_SNAPSHOT_FIELDS
}
nickname_before = str(order.nickname or "")
if result.ok:
before = dict(binding)
for field_name in PROBE_SNAPSHOT_FIELDS:
setattr(order, field_name, result.snapshot.get(field_name, ""))
if result.nickname:
order.nickname = result.nickname
order.bind_snapshot_at = _now()
binding = {
field_name: str(getattr(order, field_name) or "")
for field_name in PROBE_SNAPSHOT_FIELDS
}
if binding != before:
nickname_after = str(order.nickname or "")
if binding != before or nickname_after != nickname_before:
# 快照变了(或第一次读到)才记一条,别每次验证都刷两行审计。
record_audit(
db,
@@ -2381,6 +2621,8 @@ def verify_ck(
"order_no": order.order_no,
"before": before,
"after": binding,
"nickname_before": nickname_before,
"nickname_after": nickname_after,
},
)
@@ -2397,6 +2639,7 @@ def verify_ck(
"ck_status": target,
"message": result.message,
"binding": binding,
"nickname": str(order.nickname or ""),
}
@@ -2408,6 +2651,7 @@ def _probe_row_error(order: HuyaExternalOrder, message: str) -> dict:
"ok": False,
"ck_status": order.ck_status,
"message": _clip(message, _CK_MESSAGE_LIMIT),
"nickname": order.nickname or "",
"binding": {
field_name: str(getattr(order, field_name) or "")
for field_name in PROBE_SNAPSHOT_FIELDS
@@ -3167,6 +3411,7 @@ def create_order(
home_images: Any = None,
remark: str = "",
require_verified: bool = False,
auto_verify_ck: bool = False,
draft: bool = False,
client: Any | None = None,
commit: bool = True,
@@ -3178,7 +3423,8 @@ def create_order(
``draft=True``(**先建单后补资料**):只要求订单号,店铺也允许先空着;
状态直接进「待补资料」,CK / 礼包名 / 主页截图都可以等客户资料到了再补。
这条路径不跑探针、不做严格校验(2026-10-17 用户需求)。
这条路径不做严格校验;但只要带了 CK,就会顺手跑一次只读探针并写入绑定快照,
探针失败也不阻止建单。
``require_verified=True``(**人工新建表单用**,粘贴导入不传)时按"人工必填项"严格校验:
**订单编号 / 店铺 / CK / 礼包名 / 主页截图**都不许空,且 CK 必须探针通、
@@ -3227,7 +3473,6 @@ def create_order(
# 同一个虎牙号允许挂多单(2026-10-16 放开 uid 唯一性):只记复用痕迹,不拦
uid_reused = [item.order_no for item in uid_used_orders(db, order.uid)]
probe_result: CkProbeResult | None = None
if require_verified:
missing: list[str] = []
if not order.shop:
@@ -3242,8 +3487,14 @@ def create_order(
raise CollabError(
f"下面这几项必须填:{'、'.join(missing)}", code="create_field_required"
)
# 先探针再落库:读不到绑定角色就不许建(临时对象,不会留下半行数据)。
# 只要带了 CK 就先探一次:严格建单失败会阻断;草稿/导入等宽松路径则保留探针
# 结果,但绝不因为外部接口失败而阻止建单。这样新行不需要再手动点一次「验证 CK」。
probe_result: CkProbeResult | None = None
if ck_provided and (require_verified or auto_verify_ck):
probe_result = probe_ck(order, client=client)
if require_verified and probe_result is not None:
role = probe_result.snapshot.get("bind_game_role", "")
if not probe_result.ok or not role:
raise CollabError(
@@ -3251,8 +3502,12 @@ def create_order(
+ ("(探针通了但读不到绑定角色)" if probe_result.ok else ""),
code="ck_not_verified",
)
if probe_result is not None and probe_result.ok:
for field_name in PROBE_SNAPSHOT_FIELDS:
setattr(order, field_name, probe_result.snapshot.get(field_name, ""))
if probe_result.nickname:
order.nickname = probe_result.nickname
order.bind_snapshot_at = _now()
if order.shop:
@@ -3281,16 +3536,22 @@ def create_order(
"required_points": order.required_points,
"bind_game_account": order.bind_game_account or "",
"bind_game_role": order.bind_game_role or "",
"nickname": order.nickname or "",
"home_images": len(order.home_images or []),
"draft": bool(draft),
# 这个虎牙号还挂在哪些单上(允许同一份 CK 服务多单,留个痕)
"uid_reused_in": uid_reused,
},
)
if probe_result is not None and probe_result.ok:
if probe_result is not None:
# 结论仍然走 set_ck_status 这个唯一写入口(审计与 ck_checked_at 不会漂)。
set_ck_status(
db, current, order, CK_VALID, message=probe_result.message, commit=False
db,
current,
order,
CK_VALID if probe_result.ok else CK_INVALID,
message=probe_result.message,
commit=False,
)
_maybe_advance_to_recharge(db, current, order)
if commit:
+151 -8
View File
@@ -39,6 +39,12 @@ ELITE_HANDBOOK_SPU_IDS = frozenset(
ELITE_HANDBOOK_NAME_KEYWORD = "精英宝典"
ELITE_ORDER_PAGE_SIZE = 20
ELITE_ORDER_MAX_PAGES = 3
# 已支付但 redeem 还没翻成已开通时,在这个窗口内视为「开通中」而不是「未开通」。
ELITE_OPENING_WINDOW_MS = 24 * 60 * 60 * 1000
# 官方订单状态:10=待支付、1000=尾款待支付;90=已取消。
ELITE_PENDING_ORDER_STATUSES = frozenset({10, 1000})
ELITE_CANCELLED_ORDER_STATUSES = frozenset({90, 1001})
ELITE_PAID_ORDER_STATUSES = frozenset({20, 30, 40, 50, 55, 60, 70, 80})
# 赛季(福利季)节点的毫秒时间戳下限:2001-09 之后的值才认
ELITE_SEASON_MS_FLOOR = 1_000_000_000_000
@@ -126,6 +132,79 @@ class EliteMixin:
offset += len(orders)
return latest or None
def _latest_elite_order(self, client: Any, uid: int, cookie: str) -> dict | None:
"""最近一张宝典订单(不要求已支付),用于识别开通中/待支付/已取消。"""
latest: dict | None = None
latest_key = -1
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
key = int(item.get("create_time") or 0)
if key >= latest_key:
latest = item
latest_key = key
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
@staticmethod
def _elite_order_state(
order: dict | None, *, opened: bool | None, now_ms: int | None = None
) -> dict[str, Any]:
"""把「redeem 开通位 + 最近订单」解释成可展示状态。
订单状态 10 是待支付,不代表开通中;只有已支付/有 pay_time 且权益尚未到账,
才归为「开通中」。超出交付窗口后仍没开通,不再继续误导成开通中。
"""
if opened is True:
return {"status": "opened", "label": "已开通"}
if not order:
return {"status": "unopened", "label": "未开通"}
status = int(order.get("order_status") or 0)
pay_time = int(order.get("pay_time") or 0)
current_ms = int(now_ms if now_ms is not None else time.time() * 1000)
paid = pay_time > 0 or status in ELITE_PAID_ORDER_STATUSES
observed_ms = pay_time or int(order.get("create_time") or 0)
if paid and (
observed_ms <= 0 or current_ms - observed_ms <= ELITE_OPENING_WINDOW_MS
):
return {
"status": "opening",
"label": "开通中",
"order": order,
}
if status in ELITE_PENDING_ORDER_STATUSES:
return {
"status": "pending_payment",
"label": "待支付",
"order": order,
}
if status in ELITE_CANCELLED_ORDER_STATUSES:
return {
"status": "cancelled",
"label": "订单已取消",
"order": order,
}
return {"status": "unopened", "label": "未开通", "order": order}
@classmethod
def _walk_season_nodes(cls, node: Any, found: list[dict]) -> None:
"""递归找「赛季」节点:同时带 tag10/tag11 毫秒起止时间的结构。"""
@@ -210,9 +289,38 @@ class EliteMixin:
if redeem is not None and getattr(redeem, "code", 0) == 200:
state["redeem"] = redeem.to_dict()
state["redeem_points"] = self._to_int(redeem.value("tag2"))
latest_order = self._latest_elite_order(client, uid, cookie)
elite_state = self._elite_order_state(
latest_order,
opened=opened,
now_ms=pay_time_ms,
)
if latest_order:
state["elite_order"] = latest_order
def mark_opening() -> None:
state.update(
{
"elite_opened": None,
"elite_opening": True,
"elite_status": "opening",
"elite_status_label": "开通中",
}
)
if pay_time_ms:
state["elite_order_paid_time"] = int(pay_time_ms) // 1000
if opened is None:
if pay_time_ms or elite_state["status"] == "opening":
mark_opening()
return state
if not opened:
opening = bool(pay_time_ms) or elite_state["status"] == "opening"
if opening:
mark_opening()
return state
open_time: int | None = pay_time_ms or None
if opened and not open_time:
open_time = self._resolve_elite_open_time(client, uid, cookie)
@@ -220,10 +328,17 @@ class EliteMixin:
expire_time = self._to_int(season["end_ms"]) if season else None
self._apply_elite_state(account, opened, open_time, expire_time)
state["elite_opened"] = opened
state["elite_open_time"] = (open_time // 1000) if open_time else None
state["elite_open_time_text"] = (
self._format_local_time(open_time // 1000) if open_time else ""
state.update(
{
"elite_opened": opened,
"elite_opening": False,
"elite_status": elite_state["status"],
"elite_status_label": elite_state["label"],
"elite_open_time": (open_time // 1000) if open_time else None,
"elite_open_time_text": (
self._format_local_time(open_time // 1000) if open_time else ""
),
}
)
if season:
state["elite_season"] = season
@@ -379,17 +494,32 @@ class EliteMixin:
)
message_parts.append(f"积分 {points}")
latest_order = (
self._latest_elite_order(shop_client, uid, cookie)
if opened is not None
else None
)
elite_state = self._elite_order_state(latest_order, opened=opened)
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
opening = elite_state["status"] == "opening"
if not opening:
self._apply_elite_state(account, opened, open_time, expire_time)
result["elite_opened"] = None if opening else opened
result["elite_opening"] = opening
result["elite_status"] = elite_state["status"]
result["elite_status_label"] = elite_state["label"]
if latest_order:
result["elite_order"] = latest_order
paid_at = int(latest_order.get("pay_time") or 0)
if paid_at:
result["elite_order_paid_time"] = paid_at // 1000
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 ""
@@ -408,8 +538,21 @@ class EliteMixin:
opened_text += (
f",有效期至 {self._format_local_time(expire_time // 1000)}"
)
elif opening:
order_id = (latest_order or {}).get("order_id") or ""
opened_text = "开通中宝典"
if order_id:
opened_text += f"(订单 #{order_id},等待权益到账)"
else:
opened_text = "未开通宝典"
order_id = (latest_order or {}).get("order_id") or ""
if elite_state["status"] == "pending_payment":
opened_text = (
f"宝典订单 #{order_id} 待支付" if order_id else "宝典待支付"
)
elif elite_state["status"] == "cancelled":
opened_text = "未开通宝典(最近订单已取消)"
else:
opened_text = "未开通宝典"
message_parts.append(opened_text)
account.points = points if points is not None else account.points
+17 -1
View File
@@ -1187,6 +1187,16 @@ export interface HuyaConfig {
bind_gid: string;
bind_act_id: string;
pay_channel: string;
/** 积分中心 queryAllocation 页面码 */
points_page_code: string;
/** 积分中心 sourceId */
points_source_id: string;
/** 积分中心 scene,默认 7 */
points_scene: string;
/** 可选覆盖:页签 pageId;留空自动从响应发现 */
points_page_id: string;
/** 可选覆盖:页签 ID,逗号分隔;留空自动拉全部页签 */
points_tab_ids: string;
updated_at: string | null;
}
@@ -1471,7 +1481,7 @@ export interface HuyaCollabOrder {
uid: string;
yyuid: string;
hy_username: string;
/** 虎牙号昵称(探针给不出,暂时为空) */
/** 虎牙平台账号昵称(绑定探针通过后由用户资料接口补读) */
nickname: string;
has_cookie: boolean;
has_raw_cookie: boolean;
@@ -1662,6 +1672,8 @@ export interface HuyaCollabStatusBatchRequest {
reason?: string;
/** 目标不是「向前」时必须显式 true */
rollback?: boolean;
/** 仅超级管理员:批次里的 void 行强制恢复,必须填写 reason */
force?: boolean;
}
/** 批量改状态里的一行结论 */
@@ -1861,6 +1873,8 @@ export interface HuyaCollabStatusRequest {
status: HuyaCollabStatus;
reason?: string;
rollback?: boolean;
/** 仅超级管理员:从「已作废」强制恢复,必须填写 reason */
force?: boolean;
expected_version?: number;
}
@@ -1954,6 +1968,8 @@ export interface HuyaCollabCkPreview {
uid: string;
yyuid: string;
hy_username: string;
/** 虎牙平台账号昵称(CK 验证时补读;不是绑定游戏账号昵称) */
nickname: string;
ck_status: HuyaCollabCkStatus;
bind_game_account: string;
bind_game_role: string;
+231 -52
View File
@@ -17,7 +17,7 @@ import type { TableProps } from 'antd';
import {
AuditOutlined, CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
FileAddOutlined, FileExcelOutlined, GiftOutlined, InboxOutlined, LinkOutlined, PlusOutlined, ReloadOutlined,
SafetyCertificateOutlined, ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
SafetyCertificateOutlined, SettingOutlined, ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
@@ -38,6 +38,7 @@ import {
type HuyaCollabShop,
type HuyaCollabStatus,
type HuyaCollabSummary,
type HuyaConfig,
} from '../api/modules';
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
@@ -165,8 +166,8 @@ const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_ima
const IMPORT_PREVIEW_ROWS = 20;
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
// v2 = 2026-10-19 切到积分中心 queryAllocation 后换键,避免继续用旧 getActPrizeList 的缓存。
const GIFTS_CACHE_KEY = 'huya.collab.gifts.v2';
// v3 = 2026-11-11 起自动合并积分中心全部页签,换键避免继续用只含「精英专享」的旧缓存。
const GIFTS_CACHE_KEY = 'huya.collab.gifts.v3';
const RECHARGE_THRESHOLD_KEY = 'huya.collab.rechargeThreshold';
const RECHARGE_THRESHOLD_DEFAULT = 100;
@@ -504,6 +505,7 @@ export default function HuyaCollabPage() {
const navigate = useNavigate();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
const isAdmin = user?.role === 'super_admin';
// 单行复制与批量导出共用这个权限点(默认运营和客服都有)
const canExportCk = can('huya:collab:ck_export');
@@ -522,6 +524,10 @@ export default function HuyaCollabPage() {
const [frequentGifts, setFrequentGifts] = useState<HuyaCollabFrequentGift[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [giftModalOpen, setGiftModalOpen] = useState(false);
const [pointsConfigOpen, setPointsConfigOpen] = useState(false);
const [pointsConfigLoading, setPointsConfigLoading] = useState(false);
const [pointsConfigSaving, setPointsConfigSaving] = useState(false);
const [pointsConfigForm] = Form.useForm<HuyaConfig>();
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({
include_void: false,
archive_scope: 'active',
@@ -532,6 +538,8 @@ export default function HuyaCollabPage() {
const [realtimePending, setRealtimePending] = useState(false);
const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const selectedHasVoid = selectedIds.length > 0
&& rows.some((row) => selectedIds.includes(row.id) && row.status === 'void');
// 点顶部统计卡筛选后滚到列表,避免用户还要自己往下找
const listAnchorRef = useRef<HTMLDivElement | null>(null);
@@ -581,7 +589,7 @@ export default function HuyaCollabPage() {
const [ckTarget, setCkTarget] = useState<{ order: HuyaCollabOrder; text: string } | null>(null);
// 单行状态弹窗:set=人工往前切(可跳步)/ rollback=回退 / void=作废
const [statusAction, setStatusAction] = useState<{
order: HuyaCollabOrder; mode: 'void' | 'rollback' | 'set'; target?: HuyaCollabStatus;
order: HuyaCollabOrder; mode: 'void' | 'rollback' | 'set' | 'force'; target?: HuyaCollabStatus;
} | null>(null);
const [statusReason, setStatusReason] = useState('');
const [rollbackTarget, setRollbackTarget] = useState<HuyaCollabStatus>('to_recharge');
@@ -590,6 +598,7 @@ export default function HuyaCollabPage() {
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('to_recharge');
const [batchStatusReason, setBatchStatusReason] = useState('');
const [batchStatusRollback, setBatchStatusRollback] = useState(false);
const [batchStatusForce, setBatchStatusForce] = useState(false);
// 充值核账弹窗:按「绑定账号 + 绑定角色」整组核账,阈值默认 100
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeIds, setRechargeIds] = useState<number[]>([]);
@@ -661,11 +670,43 @@ export default function HuyaCollabPage() {
useEffect(() => { void load(); }, [load]);
const openPointsConfig = async () => {
setPointsConfigOpen(true);
setPointsConfigLoading(true);
try {
pointsConfigForm.setFieldsValue(await huyaApi.getConfig());
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setPointsConfigLoading(false);
}
};
const savePointsConfig = async () => {
setPointsConfigSaving(true);
try {
const values = await pointsConfigForm.validateFields();
await huyaApi.updateConfig({
points_page_code: values.points_page_code,
points_source_id: values.points_source_id,
points_scene: values.points_scene,
points_page_id: values.points_page_id,
points_tab_ids: values.points_tab_ids,
});
message.success('积分中心配置已保存,下次刷新礼包时生效');
setPointsConfigOpen(false);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setPointsConfigSaving(false);
}
};
const realtimeEnabled = can('huya:collab:view');
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
const realtimeSyncEnabled = realtimeEnabled && !realtimePaused;
const collabModalOpen = Boolean(
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen || giftModalOpen,
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen || giftModalOpen || pointsConfigOpen,
);
const toggleRealtimeSync = (nextEnabled: boolean) => {
@@ -746,7 +787,7 @@ export default function HuyaCollabPage() {
const emptyPreview = useCallback(
(message: string): HuyaCollabCkPreview => ({
ok: false, message, uid: '', yyuid: '', hy_username: '', ck_status: 'invalid',
ok: false, message, uid: '', yyuid: '', hy_username: '', nickname: '', ck_status: 'invalid',
bind_game_account: '', bind_game_role: '', bind_game_channel: '',
has_role: false, gifts: [], gifts_message: '', uid_used_by: [],
}),
@@ -768,7 +809,9 @@ export default function HuyaCollabPage() {
writeGiftCache(preview.gifts);
}
if (preview.ok && preview.has_role) {
message.success(`CK 有效:绑定角色 ${preview.bind_game_role}`);
message.success(
`CK 有效:${preview.nickname || '虎牙昵称未读到'} · 角色 ${preview.bind_game_role}`,
);
// 「记住的礼包」如果不在实时列表、也不在标准字典里就清掉重选。
const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current;
const available = new Set([
@@ -962,13 +1005,18 @@ export default function HuyaCollabPage() {
openRecharge(selectedIds);
return;
}
if (batchStatusForce && !batchStatusReason.trim()) {
message.warning('强制恢复已作废订单必须填写原因');
return;
}
setBusy(true);
try {
const result = await huyaApi.batchUpdateCollabOrderStatus({
ids: selectedIds,
status: batchStatusTarget,
reason: batchStatusReason,
rollback: batchStatusRollback,
rollback: batchStatusForce ? false : batchStatusRollback,
force: batchStatusForce,
});
const failures = result.results.filter((item) => !item.ok);
if (failures.length === 0) {
@@ -1320,6 +1368,7 @@ export default function HuyaCollabPage() {
* - `void`:作废(必须填原因);
* - `rollback`:回退到选定状态(`rollback=true`,会清掉后面阶段的时间戳);
* - `set`:人工往前切,允许跳步(如 待补资料 → 已上脚本),不用勾回退。
* - `force`:超级管理员从「已作废」强制恢复(必须填原因)。
*/
const submitStatusAction = async () => {
if (!statusAction) return;
@@ -1336,17 +1385,24 @@ export default function HuyaCollabPage() {
message.warning('作废必须填原因');
return;
}
if (mode === 'force' && !statusReason.trim()) {
message.warning('强制恢复已作废订单必须填写原因');
return;
}
setBusy(true);
try {
applyUpdated(await huyaApi.updateCollabOrderStatus(order.id, {
status: nextStatus,
reason: statusReason,
rollback: mode === 'rollback',
force: mode === 'force',
expected_version: order.version,
}));
message.success(
mode === 'void'
? '已作废'
: mode === 'force'
? '已强制恢复'
: mode === 'rollback'
? '已回退'
: `已改为「${STATUS_META[nextStatus].label}」`,
@@ -2043,14 +2099,29 @@ export default function HuyaCollabPage() {
),
},
{
title: '绑定账号 / 角色', width: 210,
title: '游戏账号 / 角色 / 虎牙昵称', width: 280,
...sortProps('bind'),
render: (_, row) => (
<Space direction="vertical" size={0}>
<Text strong>{row.bind_game_account || <Text type="secondary">未读到</Text>}</Text>
{row.bind_game_role
? <Tag color="green" style={{ marginTop: 2 }}>角色 {row.bind_game_role}</Tag>
: <Text type="secondary" style={{ fontSize: 12 }}>未读到绑定角色</Text>}
<Space direction="vertical" size={1} style={{ width: '100%' }}>
<Space size={4} wrap={false} style={{ maxWidth: '100%' }}>
<Text strong ellipsis style={{ maxWidth: 112 }}>
{row.bind_game_account || '游戏账号未读到'}
</Text>
<Text type="secondary">/</Text>
<Text
type={row.bind_game_role ? undefined : 'secondary'}
ellipsis
style={{
color: row.bind_game_role ? '#389e0d' : undefined,
maxWidth: 140,
}}
>
{row.bind_game_role ? `角色 ${row.bind_game_role}` : '角色未读到'}
</Text>
</Space>
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
虎牙昵称:{row.nickname || '未读取(重新验证 CK 后补全)'}
</Text>
{row.bind_game_channel && (
<Text type="secondary" style={{ fontSize: 12 }}>{row.bind_game_channel}</Text>
)}
@@ -2293,9 +2364,15 @@ export default function HuyaCollabPage() {
{ key: 'rebuild', label: '从 App 原始凭据重建', icon: <ReloadOutlined />, disabled: !row.has_raw_cookie },
{ key: 'disable', label: row.ck_status === 'disabled' ? '恢复这份 CK' : '停用这份 CK', icon: <StopOutlined /> },
{ type: 'divider' },
{ key: 'set', label: '改状态…', icon: <CheckCircleOutlined /> },
{ key: 'rollback', label: '回退状态…', icon: <UndoOutlined /> },
{ key: 'void', label: '作废…', icon: <StopOutlined />, danger: true },
...(row.status === 'void'
? (isAdmin
? [{ key: 'force', label: '管理员强制恢复…', icon: <UndoOutlined />, danger: true }]
: [])
: [
{ key: 'set', label: '改状态…', icon: <CheckCircleOutlined /> },
{ key: 'rollback', label: '回退状态…', icon: <UndoOutlined /> },
{ key: 'void', label: '作废…', icon: <StopOutlined />, danger: true },
]),
...(row.status === 'done'
? [
{ type: 'divider' as const },
@@ -2323,6 +2400,11 @@ export default function HuyaCollabPage() {
setStatusReason('');
setStatusAction({ order: row, mode: 'rollback' });
}
if (key === 'force') {
setRollbackTarget('to_recharge');
setStatusReason('');
setStatusAction({ order: row, mode: 'force' });
}
if (key === 'void') {
setStatusReason('');
setStatusAction({ order: row, mode: 'void' });
@@ -2463,7 +2545,7 @@ export default function HuyaCollabPage() {
value={filters.gift_name || undefined}
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, gift_name: value })); }} />
<Input.Search allowClear style={{ width: 340 }}
placeholder="搜订单号 / 店铺 / 绑定账号 / 角色 / 虎牙号 / 礼包 / 备注"
placeholder="搜订单号 / 店铺 / 游戏账号 / 角色 / 虎牙昵称 / 礼包 / 备注"
onSearch={(value) => { setPage(1); setFilters((prev) => ({ ...prev, keyword: value })); }} />
<Space size={4}>
<Text type="secondary">显示已作废</Text>
@@ -2526,6 +2608,7 @@ export default function HuyaCollabPage() {
setBatchStatusTarget(key as HuyaCollabStatus);
setBatchStatusReason('');
setBatchStatusRollback(false);
setBatchStatusForce(false);
setBatchStatusOpen(true);
},
}}
@@ -2579,6 +2662,11 @@ export default function HuyaCollabPage() {
{canManage && !isArchiveView && (
<Button icon={<GiftOutlined />} onClick={() => setGiftModalOpen(true)}>礼包管理</Button>
)}
{canManage && !isArchiveView && (
<Button icon={<SettingOutlined />} onClick={() => void openPointsConfig()}>
积分中心配置
</Button>
)}
{canEdit && !isArchiveView && (
<Button
type="primary"
@@ -2723,7 +2811,7 @@ export default function HuyaCollabPage() {
<Descriptions size="small" column={2} bordered
items={[
{ key: 'uid', label: '虎牙号', children: detail.uid || '-' },
{ key: 'username', label: '登录名 / 昵称', children: `${detail.hy_username || '-'} / ${detail.nickname || '-'}` },
{ key: 'username', label: '登录名 / 虎牙昵称', children: `${detail.hy_username || '-'} / ${detail.nickname || '-'}` },
{ key: 'ck', label: 'CK 状态',
children: (
<Space>
@@ -2906,30 +2994,43 @@ export default function HuyaCollabPage() {
{canEdit && !detail.is_archived && (
<Space wrap>
{detail.next_status && (
<Button icon={<CheckCircleOutlined />} disabled={busy}
onClick={() => void advance(detail)}>
标记为「{STATUS_META[detail.next_status].label}」
</Button>
)}
<Button icon={<SwapOutlined />} disabled={busy}
onClick={() => {
// 默认给「下一步」,终点态(抢购成功 / 已作废 / 抢购失败)退回当前状态,让人自己挑
setRollbackTarget(detail.next_status ?? detail.status);
setStatusReason('');
setStatusAction({ order: detail, mode: 'set' });
}}>
改状态…
</Button>
<Button icon={<UndoOutlined />} disabled={busy}
onClick={() => { setRollbackTarget('to_recharge'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
回退状态
</Button>
{detail.status !== 'void' && (
<Button danger icon={<StopOutlined />} disabled={busy}
onClick={() => { setStatusReason(''); setStatusAction({ order: detail, mode: 'void' }); }}>
作废
</Button>
{detail.status === 'void' ? (
isAdmin && (
<Button danger icon={<UndoOutlined />} disabled={busy}
onClick={() => {
setRollbackTarget('to_recharge');
setStatusReason('');
setStatusAction({ order: detail, mode: 'force' });
}}>
管理员强制恢复…
</Button>
)
) : (
<>
{detail.next_status && (
<Button icon={<CheckCircleOutlined />} disabled={busy}
onClick={() => void advance(detail)}>
标记为「{STATUS_META[detail.next_status].label}」
</Button>
)}
<Button icon={<SwapOutlined />} disabled={busy}
onClick={() => {
// 默认给「下一步」,终点态退回当前状态,让人自己挑
setRollbackTarget(detail.next_status ?? detail.status);
setStatusReason('');
setStatusAction({ order: detail, mode: 'set' });
}}>
改状态…
</Button>
<Button icon={<UndoOutlined />} disabled={busy}
onClick={() => { setRollbackTarget('to_recharge'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
回退状态
</Button>
<Button danger icon={<StopOutlined />} disabled={busy}
onClick={() => { setStatusReason(''); setStatusAction({ order: detail, mode: 'void' }); }}>
作废
</Button>
</>
)}
</Space>
)}
@@ -3126,13 +3227,14 @@ export default function HuyaCollabPage() {
<Alert
type="success"
showIcon
message={`CK 有效 · 绑定角色 ${ckPreview.bind_game_role}`}
message={`CK 有效 · ${ckPreview.nickname || '虎牙昵称未读到'} · 角色 ${ckPreview.bind_game_role}`}
description={
<>
<div>
{`订单编号里的账号:${ckPreview.bind_game_account || '-'}`
{`游戏账号:${ckPreview.bind_game_account || '-'}`
+ `(${ckPreview.bind_game_channel || '-'})`
+ `;虎牙号 ${ckPreview.uid}`
+ `;虎牙昵称:${ckPreview.nickname || '-'}`
+ `;虎牙号:${ckPreview.uid}`
+ (giftSourceText ? `;${giftSourceText}` : '')}
</div>
{ckPreview.uid_used_by.length > 0 && (
@@ -3482,18 +3584,32 @@ export default function HuyaCollabPage() {
<Modal
title={statusAction?.mode === 'void'
? `作废订单 ${statusAction.order.order_no}`
: statusAction?.mode === 'set'
? `改状态:订单 ${statusAction.order.order_no}`
: `回退订单 ${statusAction?.order.order_no ?? ''}`}
: statusAction?.mode === 'force'
? `管理员强制恢复订单 ${statusAction.order.order_no}`
: statusAction?.mode === 'set'
? `改状态:订单 ${statusAction.order.order_no}`
: `回退订单 ${statusAction?.order.order_no ?? ''}`}
open={!!statusAction}
onCancel={() => setStatusAction(null)}
onOk={() => void submitStatusAction()}
confirmLoading={busy}
okButtonProps={{ danger: statusAction?.mode === 'void' }}
okText={statusAction?.mode === 'void' ? '作废' : statusAction?.mode === 'set' ? '改状态' : '回退'}
okButtonProps={{ danger: statusAction?.mode === 'void' || statusAction?.mode === 'force' }}
okText={statusAction?.mode === 'void'
? '作废'
: statusAction?.mode === 'force'
? '强制恢复'
: statusAction?.mode === 'set' ? '改状态' : '回退'}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{statusAction?.mode === 'force' && (
<Alert
type="warning"
showIcon
message="仅在确认属于误作废时使用"
description="强制恢复只跳过「已作废不可逆」限制;资料完整性、待充值条件等校验仍会执行。操作人和原因都会写入审计。"
/>
)}
{statusAction && statusAction.mode !== 'void' && (
<Select
style={{ width: '100%' }}
@@ -3506,6 +3622,8 @@ export default function HuyaCollabPage() {
placeholder={
statusAction?.mode === 'void'
? '作废原因(必填)'
: statusAction?.mode === 'force'
? '强制恢复原因(必填)'
: rollbackTarget === 'void'
? '作废原因(必填)'
: statusAction?.mode === 'rollback'
@@ -3521,6 +3639,11 @@ export default function HuyaCollabPage() {
{statusAction?.mode === 'rollback' && (
<Text type="secondary">回退会清掉后面阶段的时间戳,并在审计里留痕。</Text>
)}
{statusAction?.mode === 'force' && (
<Text type="secondary">
恢复后原「作废原因」会保留在审计时间线里;该操作仅限超级管理员。
</Text>
)}
</Space>
</Modal>
@@ -3574,10 +3697,17 @@ export default function HuyaCollabPage() {
/>
<Input.TextArea rows={2} value={batchStatusReason}
onChange={(event) => setBatchStatusReason(event.target.value)}
placeholder={batchStatusTarget === 'void' ? '作废原因(必填)' : '改状态说明(可选)'} />
placeholder={batchStatusTarget === 'void'
? '作废原因(必填)'
: batchStatusForce ? '强制恢复原因(必填)' : '改状态说明(可选)'} />
<Checkbox checked={batchStatusRollback} onChange={(event) => setBatchStatusRollback(event.target.checked)}>
回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待充值)
</Checkbox>
{isAdmin && selectedHasVoid && (
<Checkbox checked={batchStatusForce} onChange={(event) => setBatchStatusForce(event.target.checked)}>
强制恢复所选批次中的「已作废」订单(仅超级管理员,必须填写原因)
</Checkbox>
)}
<Text type="secondary">
逐行独立提交:某一行前置校验没过(例如资料不全进不了「待充值」)只跳过那一行,
结果会在弹窗里逐条列出来。一次最多 50 行。
@@ -3637,6 +3767,55 @@ export default function HuyaCollabPage() {
</Space>
</Modal>
<Modal
title="积分中心配置"
open={pointsConfigOpen}
onCancel={() => setPointsConfigOpen(false)}
onOk={() => void savePointsConfig()}
confirmLoading={pointsConfigSaving}
okText="保存"
width={680}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="页签默认自动发现,通常只需在活动入口变化时改前三个参数"
description="保存后回到新建/补资料里点一次「强制刷新礼包」即可拉取最新商品;pageId 和页签 IDs 留空表示自动。"
/>
<Form form={pointsConfigForm} layout="vertical" disabled={pointsConfigLoading}>
<Row gutter={12}>
<Col span={12}>
<Form.Item label="pageCode" name="points_page_code">
<Input placeholder="heping_baodian_credit_center_page" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="sourceId" name="points_source_id">
<Input placeholder="yellowcarlist" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="scene" name="points_scene">
<Input placeholder="7" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="pageId(可选)" name="points_page_id">
<Input placeholder="自动发现,如 343" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="页签 IDs(可选)" name="points_tab_ids">
<Input placeholder="自动拉全部,如 431,433" />
</Form.Item>
</Col>
</Row>
</Form>
</Space>
</Modal>
<HuyaCollabShopModal
open={shopModalOpen}
shops={shops}
+9 -4
View File
@@ -115,7 +115,11 @@ function miniQrcodeSrc(image: string): string {
/** 开通支付码是否还需要扫码:已支付或已确认开通就不用再展示了 */
function needsScanPay(task: HuyaTaskItem): boolean {
const result = task.result || {};
return !(result.payment_paid === true || result.elite_opened === true);
return !(
result.payment_paid === true
|| result.elite_opened === true
|| result.elite_opening === true
);
}
/**
@@ -128,6 +132,7 @@ function needsScanPay(task: HuyaTaskItem): boolean {
function qrDoneLabel(task: HuyaTaskItem): string | null {
if (task.task_type !== 'create_recharge_order') return null;
if (needsScanPay(task)) return null;
if (task.result?.elite_opening === true) return '开通中';
return task.result?.elite_opened === true ? '已开通' : '已支付';
}
@@ -538,7 +543,7 @@ export default function HuyaElitePage() {
{ title: '账号', width: '15%', align: 'center', ellipsis: true, render: (_value, row) => <Space direction="vertical" align="center" 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%', align: 'center', ellipsis: true, render: (_value, row) => row.game_phone || <Text type="secondary">-</Text> },
{ title: '游戏名', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const expired = ckExpired(latestTask(tasks, row.id), row); return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type={expired ? 'danger' : 'secondary'}>{expired ? '角色未查到' : '未查询'}</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space>; } },
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => <Space direction="vertical" align="center" 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: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const opening = task?.result?.elite_opening === true; return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={opening ? 'processing' : row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{opening ? '开通中' : 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: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : '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); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
@@ -612,7 +617,7 @@ export default function HuyaElitePage() {
<Text type="secondary" style={{ fontSize: 12 }}>已完成(二维码已收起)</Text>
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{payDoneTasks.map((task) => (
<Tag key={task.id} color={task.result?.elite_opened === true ? 'success' : 'blue'}>{payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : resultText(task, 'payment_status_label') || '已支付'}</Tag>
<Tag key={task.id} color={task.result?.elite_opened === true ? 'success' : task.result?.elite_opening === true ? 'processing' : 'blue'}>{payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : task.result?.elite_opening === true ? '开通中' : resultText(task, 'payment_status_label') || '已支付'}</Tag>
))}
</div>
</div>}
@@ -627,7 +632,7 @@ export default function HuyaElitePage() {
</div>}
</Modal>
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={560}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('bind_gid', '绑定 gid')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}{configField('points_page_code', '积分中心 pageCode')}{configField('points_source_id', '积分中心 sourceId')}{configField('points_scene', '积分中心 scene')}{configField('points_page_id', '页签 pageId(可选)')}{configField('points_tab_ids', '页签 IDs(可选)')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
</div>;
}
+25
View File
@@ -1846,6 +1846,31 @@ export default function HuyaTasksPage() {
<Input placeholder="9271" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="积分中心 pageCode" name="points_page_code">
<Input placeholder="heping_baodian_credit_center_page" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="积分中心 sourceId" name="points_source_id">
<Input placeholder="yellowcarlist" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="积分中心 scene" name="points_scene">
<Input placeholder="7" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="页签 pageId(可选)" name="points_page_id">
<Input placeholder="留空自动发现,如 343" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="页签 IDs(可选,逗号分隔)" name="points_tab_ids">
<Input placeholder="留空自动拉全部,如 431,433" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="支付渠道" name="pay_channel">
<Select