Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26809dd116 | ||
|
|
b18e46d72d |
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 行、
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user