From b18e46d72d77fe715b31507077ca6e54dfafe24a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 11 Nov 2026 12:40:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=99=8E=E7=89=99=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E5=8D=95=E5=B1=95=E7=A4=BA=E3=80=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=8E=E7=A4=BC=E5=8C=85=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/huya/http_client.py | 11 + core/huya/shop_structs.py | 50 +++ core/huya/wss_client.py | 12 + docs/设计-虎牙外部CK协作台账.md | 41 +- tests/test_huya_collab_orders.py | 397 +++++++++++++++++- tests/test_huya_points_shop_protocol.py | 15 + tests/test_migrations.py | 2 +- web/backend/huya_defaults.py | 9 + ...20261111_0076_huya_points_center_config.py | 55 +++ web/backend/models.py | 26 ++ web/backend/routers/huya/_common.py | 5 + web/backend/routers/huya/collab.py | 10 +- web/backend/schemas.py | 26 +- web/backend/services/huya_collab_service.py | 349 +++++++++++++-- web/frontend/src/api/types.ts | 18 +- web/frontend/src/pages/HuyaCollabPage.tsx | 283 ++++++++++--- web/frontend/src/pages/HuyaElitePage.tsx | 2 +- web/frontend/src/pages/HuyaTasksPage.tsx | 25 ++ 18 files changed, 1216 insertions(+), 120 deletions(-) create mode 100644 web/backend/migrations/versions/20261111_0076_huya_points_center_config.py diff --git a/core/huya/http_client.py b/core/huya/http_client.py index 83c82e1..27b59cf 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -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", diff --git a/core/huya/shop_structs.py b/core/huya/shop_structs.py index dbb242a..f39e013 100644 --- a/core/huya/shop_structs.py +++ b/core/huya/shop_structs.py @@ -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 系接口的应用信息。 diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py index 4138ab8..d24612b 100644 --- a/core/huya/wss_client.py +++ b/core/huya/wss_client.py @@ -16,6 +16,7 @@ """ import asyncio +import json import random import re from collections.abc import Callable @@ -1451,6 +1452,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 +1468,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) diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 6e9a204..38f1b15 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -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 行、 diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 65a5597..57e4283 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -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") diff --git a/tests/test_huya_points_shop_protocol.py b/tests/test_huya_points_shop_protocol.py index 66b2d04..c3fc27a 100644 --- a/tests/test_huya_points_shop_protocol.py +++ b/tests/test_huya_points_shop_protocol.py @@ -307,6 +307,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, diff --git a/tests/test_migrations.py b/tests/test_migrations.py index debf972..5ec9197 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = ( diff --git a/web/backend/huya_defaults.py b/web/backend/huya_defaults.py index 9a873ec..2a76ac0 100644 --- a/web/backend/huya_defaults.py +++ b/web/backend/huya_defaults.py @@ -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) diff --git a/web/backend/migrations/versions/20261111_0076_huya_points_center_config.py b/web/backend/migrations/versions/20261111_0076_huya_points_center_config.py new file mode 100644 index 0000000..b15c308 --- /dev/null +++ b/web/backend/migrations/versions/20261111_0076_huya_points_center_config.py @@ -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) diff --git a/web/backend/models.py b/web/backend/models.py index 061f784..80be309 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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 ) diff --git a/web/backend/routers/huya/_common.py b/web/backend/routers/huya/_common.py index 54a8bf8..f6b5af4 100644 --- a/web/backend/routers/huya/_common.py +++ b/web/backend/routers/huya/_common.py @@ -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, ) diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 7f6d9d4..6ece702 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -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 diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 42603c7..b42d19a 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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): diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 12a856d..ca50227 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -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: diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 92eb62b..6437752 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 0b87a95..c13e833 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -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([]); 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(); const [filters, setFilters] = useState({ 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([]); + const selectedHasVoid = selectedIds.length > 0 + && rows.some((row) => selectedIds.includes(row.id) && row.status === 'void'); // 点顶部统计卡筛选后滚到列表,避免用户还要自己往下找 const listAnchorRef = useRef(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('to_recharge'); @@ -590,6 +598,7 @@ export default function HuyaCollabPage() { const [batchStatusTarget, setBatchStatusTarget] = useState('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([]); @@ -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) => ( - - {row.bind_game_account || 未读到} - {row.bind_game_role - ? 角色 {row.bind_game_role} - : 未读到绑定角色} + + + + {row.bind_game_account || '游戏账号未读到'} + + / + + {row.bind_game_role ? `角色 ${row.bind_game_role}` : '角色未读到'} + + + + 虎牙昵称:{row.nickname || '未读取(重新验证 CK 后补全)'} + {row.bind_game_channel && ( {row.bind_game_channel} )} @@ -2293,9 +2364,15 @@ export default function HuyaCollabPage() { { key: 'rebuild', label: '从 App 原始凭据重建', icon: , disabled: !row.has_raw_cookie }, { key: 'disable', label: row.ck_status === 'disabled' ? '恢复这份 CK' : '停用这份 CK', icon: }, { type: 'divider' }, - { key: 'set', label: '改状态…', icon: }, - { key: 'rollback', label: '回退状态…', icon: }, - { key: 'void', label: '作废…', icon: , danger: true }, + ...(row.status === 'void' + ? (isAdmin + ? [{ key: 'force', label: '管理员强制恢复…', icon: , danger: true }] + : []) + : [ + { key: 'set', label: '改状态…', icon: }, + { key: 'rollback', label: '回退状态…', icon: }, + { key: 'void', label: '作废…', icon: , 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 })); }} /> { setPage(1); setFilters((prev) => ({ ...prev, keyword: value })); }} /> 显示已作废 @@ -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 && ( )} + {canManage && !isArchiveView && ( + + )} {canEdit && !isArchiveView && ( - )} - - - {detail.status !== 'void' && ( - + {detail.status === 'void' ? ( + isAdmin && ( + + ) + ) : ( + <> + {detail.next_status && ( + + )} + + + + )} )} @@ -3126,13 +3227,14 @@ export default function HuyaCollabPage() {
- {`订单编号里的账号:${ckPreview.bind_game_account || '-'}` + {`游戏账号:${ckPreview.bind_game_account || '-'}` + `(${ckPreview.bind_game_channel || '-'})` - + `;虎牙号 ${ckPreview.uid}` + + `;虎牙昵称:${ckPreview.nickname || '-'}` + + `;虎牙号:${ckPreview.uid}` + (giftSourceText ? `;${giftSourceText}` : '')}
{ckPreview.uid_used_by.length > 0 && ( @@ -3482,18 +3584,32 @@ export default function HuyaCollabPage() { 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 > + {statusAction?.mode === 'force' && ( + + )} {statusAction && statusAction.mode !== 'void' && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + } 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="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /> + + + + + + + + + + + + + + + + + + + + + + + + +