feat: 重做协作台账状态机并支持充值核账

This commit is contained in:
yml2213
2026-10-19 16:06:02 +08:00
parent 781b27e63a
commit 3a6649a054
15 changed files with 2330 additions and 363 deletions
+96 -14
View File
@@ -41,8 +41,15 @@ def _strip_tail(body: bytes) -> bytes:
return body
def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object:
"""递归解码单个 TAF 值"""
def _decode_taf_value(
ins: TafInputStream,
dtype: int,
depth: int = 0,
*,
max_depth: int = 5,
keep_bytes: bool = False,
) -> object:
"""递归解码单个 TAF 值。"""
if dtype == TafType.ZERO:
return 0
if dtype in (TafType.INT8, TafType.INT16, TafType.INT32, TafType.INT64):
@@ -66,9 +73,21 @@ def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object
m = {}
for _ in range(cnt):
_, kt = ins.read_head()
k = _decode_taf_value(ins, kt, depth + 1)
k = _decode_taf_value(
ins,
kt,
depth + 1,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
_, vt = ins.read_head()
v = _decode_taf_value(ins, vt, depth + 1)
v = _decode_taf_value(
ins,
vt,
depth + 1,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
m[k] = v
return m
if dtype == TafType.LIST:
@@ -76,19 +95,40 @@ def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object
items = []
for _ in range(cnt):
_, it = ins.read_head()
items.append(_decode_taf_value(ins, it, depth + 1))
items.append(
_decode_taf_value(
ins,
it,
depth + 1,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
)
return items
if dtype == TafType.SIMPLE_LIST:
ins.read_head() # element type INT8
ln = ins._read_int_len()
if keep_bytes:
return ins.buf.read(ln)
return f"<bytes {ln}B>"
if dtype == TafType.STRUCT_BEGIN:
return _decode_taf_struct(ins, depth + 1)
return _decode_taf_struct(
ins,
depth + 1,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
return f"<0x{dtype:02x}>"
def _decode_taf_struct(ins: TafInputStream, depth: int = 0) -> dict:
"""解码一个 TAF 结构体 (已消费 STRUCT_BEGIN) → 有序字典"""
def _decode_taf_struct(
ins: TafInputStream,
depth: int = 0,
*,
max_depth: int = 5,
keep_bytes: bool = False,
) -> dict:
"""解码一个 TAF 结构体 (已消费 STRUCT_BEGIN) → 有序字典。"""
fields = {}
while True:
try:
@@ -99,10 +139,17 @@ def _decode_taf_struct(ins: TafInputStream, depth: int = 0) -> dict:
ins.read_head()
break
ins.read_head()
# 限制递归深度
if depth < 5:
# 默认只解 5 层,避免日志/通用接口被超深结构拖垮;
# queryAllocation 这类需要完整商品字段的调用方显式传 max_depth。
if depth < max_depth:
try:
val = _decode_taf_value(ins, dtype, depth)
val = _decode_taf_value(
ins,
dtype,
depth,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
val = f"<decode_err:0x{dtype:02x}>"
else:
@@ -215,11 +262,16 @@ def _decode_wup_body(body: bytes) -> dict:
return result
def decode_struct_bytes(data: bytes) -> dict:
def decode_struct_bytes(
data: bytes,
*,
max_depth: int = 5,
keep_bytes: bool = False,
) -> dict:
"""把裸结构体字节解成通用 dict(``tagN`` → 值);非结构体或解析失败返回 {}。
WSS 与 HTTP 两条通道都要用「未逐字段建模」的通用解码,统一放在这里,
避免两边各写一份。
避免两边各写一份。``max_depth`` 默认保持 5 层;需要完整商品字段时显式传大值。
"""
if not isinstance(data, bytes) or not data:
return {}
@@ -229,11 +281,41 @@ def decode_struct_bytes(data: bytes) -> dict:
if dtype != TafType.STRUCT_BEGIN:
return {}
ins.read_head()
return _decode_taf_struct(ins)
return _decode_taf_struct(
ins,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return {}
def decode_value_bytes(
data: bytes,
*,
max_depth: int = 40,
keep_bytes: bool = True,
) -> object:
"""把裸 TAF 值(LIST / STRUCT / MAP 等,含 tag+type 头)解成通用对象。
``queryAllocation`` 的商品列表放在 SIMPLE_LIST 里,内容本身又是一个 TAF LIST;
这里专门给这种嵌套结构用,默认保留字节、放宽深度。
"""
if not isinstance(data, bytes) or not data:
return None
ins = TafInputStream(data)
try:
_tag, dtype = ins.read_head()
return _decode_taf_value(
ins,
dtype,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
return None
def _truncate(obj, max_str=80):
"""截断过长的值,使日志紧凑"""
if isinstance(obj, str):
+29 -4
View File
@@ -1239,12 +1239,25 @@ class HuyaHttpClient:
# ============================================================
@staticmethod
def _raw_from_wup(wup_resp: WupResponse):
def _raw_from_wup(
wup_resp: WupResponse,
*,
max_depth: int = 5,
keep_bytes: bool = False,
):
"""已解码的 WUP 响应 → ShopRawRsp(未逐字段建模的接口用)。"""
from .shop_structs import ShopRawRsp
data = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp")
payload = decode_struct_bytes(data) if isinstance(data, bytes) else {}
payload = (
decode_struct_bytes(
data,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
if isinstance(data, bytes)
else {}
)
return ShopRawRsp.from_payload(payload)
def get_supplier_info(
@@ -1339,8 +1352,14 @@ class HuyaHttpClient:
source_id: str = "yellowcarlist",
page_code: str = "heping_baodian_credit_center_page",
timeout: float = 15.0,
*,
deep: bool = False,
):
"""PlayMallUI.queryAllocation(积分中心配额查询)。"""
"""PlayMallUI.queryAllocation(积分中心配额查询)。
``deep=True`` 时保留 ``tag2[].tag5`` 的原始字节并放宽解码深度,
供 :func:`core.huya.shop_structs.parse_points_center_gifts` 解析商品列表。
"""
from .shop_structs import PlayMallQueryAllocationReq
req = PlayMallQueryAllocationReq()
@@ -1357,7 +1376,13 @@ class HuyaHttpClient:
cookie,
timeout=timeout,
)
return self._raw_from_wup(resp) if isinstance(resp, WupResponse) else None
if not isinstance(resp, WupResponse):
return None
return self._raw_from_wup(
resp,
max_depth=40 if deep else 5,
keep_bytes=deep,
)
def play_mall_get_my_promotion(
self,
+102
View File
@@ -7,6 +7,7 @@
from typing import Any
from .frame_decoder import decode_value_bytes
from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType
@@ -1150,6 +1151,107 @@ class ShopRawRsp:
return {"code": self.code, "message": self.message, "payload": self.payload}
def _as_int(value: Any) -> int | None:
"""尽力把 TAF 解出来的数字转成 int;空值/脏值返回 None。"""
if value is None or isinstance(value, bool):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _as_text(value: Any) -> str:
return str(value).strip() if value is not None else ""
def parse_points_center_gifts(payload: dict | None) -> list[dict]:
"""解析 ``PlayMallUI.queryAllocation`` 响应里的积分中心商品。
真机(2026-10-19 HAR)结构::
payload.tag2[0].tag5 # SIMPLE_LIST,内容是 TAF LIST
→ 分类 tag6
→ 商品 tag1.tag0
→ tag0 SPU(hy-xxxx)
→ tag2 名称
→ tag4 分类名
→ tag5 图标
→ tag9/tag10 金额(分,不是积分)
→ tag13 库存
→ tag75.tag0.tag2 积分
→ tag34.tag26 积分(重复字段,兜底)
注意:``tag13`` 在这里是库存,不是 ``ActPrizeItem.price`` 的那个 tag13。
"""
if not isinstance(payload, dict):
return []
gifts: list[dict] = []
seen: set[str] = set()
for section in payload.get("tag2") or []:
if not isinstance(section, dict):
continue
blob = section.get("tag5")
if 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 category in categories:
if not isinstance(category, dict):
continue
category_name = _as_text(category.get("tag1"))
for good in category.get("tag6") or []:
if not isinstance(good, dict):
continue
info = good.get("tag1")
basic = info.get("tag0") if isinstance(info, dict) else None
if not isinstance(basic, dict):
continue
spu = _as_text(basic.get("tag0"))
name = _as_text(basic.get("tag2"))
if not spu or not name:
continue
if spu in seen:
continue
seen.add(spu)
score_box = basic.get("tag75")
score_info = (
score_box.get("tag0") if isinstance(score_box, dict) else None
)
points = (
_as_int(score_info.get("tag2"))
if isinstance(score_info, dict)
else None
)
if points is None:
game_box = basic.get("tag34")
points = (
_as_int(game_box.get("tag26"))
if isinstance(game_box, dict)
else None
)
stock = _as_int(basic.get("tag13"))
gifts.append(
{
"product_id": spu,
"name": name[:256],
"price": points,
"remain_text": f"剩{stock}" if stock is not None else "",
"stock": stock,
"score": points,
"category_name": (_as_text(basic.get("tag4")) or category_name)[
:64
],
"icon": _as_text(basic.get("tag5"))[:512],
}
)
return gifts
class PlayMallAppInfo(TafStruct):
"""PlayMallUI 系接口的应用信息。
+46 -6
View File
@@ -817,7 +817,12 @@ class HuyaWssClient:
await self.ws.send(msg.encode())
@staticmethod
def _decode_rsp_dict(body: bytes) -> dict:
def _decode_rsp_dict(
body: bytes,
*,
max_depth: int = 5,
keep_bytes: bool = False,
) -> dict:
"""把 RPC 响应的 tRsp 解成通用 dict(未建模的接口用它取字段)。"""
try:
wup_resp = WupResponse()
@@ -825,14 +830,34 @@ class HuyaWssClient:
except Exception: # noqa: BLE001 响应解析失败按空结构处理
return {}
data = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp")
return decode_struct_bytes(data) if isinstance(data, bytes) else {}
return (
decode_struct_bytes(
data,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
if isinstance(data, bytes)
else {}
)
@classmethod
def _raw_rsp_from_body(cls, body: bytes):
def _raw_rsp_from_body(
cls,
body: bytes,
*,
max_depth: int = 5,
keep_bytes: bool = False,
):
"""响应 → ShopRawRsp(code/message + 通用解码结构)。"""
from .shop_structs import ShopRawRsp
return ShopRawRsp.from_payload(cls._decode_rsp_dict(body))
return ShopRawRsp.from_payload(
cls._decode_rsp_dict(
body,
max_depth=max_depth,
keep_bytes=keep_bytes,
)
)
async def call_rpc_raw(
self,
@@ -1385,15 +1410,30 @@ class HuyaWssClient:
cookie: str,
source_id: str = "yellowcarlist",
page_code: str = POINTS_PAGE_CODE,
*,
deep: bool = False,
):
"""PlayMallUI.queryAllocation — 积分中心页面配额查询。"""
"""PlayMallUI.queryAllocation — 积分中心页面配额查询。
``deep=True`` 时保留 ``tag2[].tag5`` 原始字节并放宽解码深度,
供 ``parse_points_center_gifts`` 解析完整商品列表。
"""
from .shop_structs import PlayMallQueryAllocationReq
req = PlayMallQueryAllocationReq()
req.userId = self._build_biz_user(uid, cookie)
req.appInfo = self._build_play_mall_app(source_id)
req.pageCode = page_code
return await self.call_rpc_raw("PlayMallUI", "queryAllocation", req)
if not deep:
return await self.call_rpc_raw("PlayMallUI", "queryAllocation", req)
body = await self.call_rpc("PlayMallUI", "queryAllocation", req, None)
if body is None:
return None
return self._raw_rsp_from_body(
body,
max_depth=40,
keep_bytes=True,
)
async def play_mall_get_my_promotion(
self,
+121 -55
View File
@@ -43,7 +43,7 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
1. 外部 CK 的 CRUD:新增(粘贴 CK)、编辑、删除,解析出 uid/昵称;
2. CK 全生命周期:`unverified → valid → invalid`,支持重贴、从 `raw_cookie` 重建、人工停用;
3. 业务全生命周期:`pending → bound → recharged → scripted → done`,旁路 `void`;
3. 业务全生命周期:`incomplete → to_recharge → recharged → scripted → done`,核账异常 `points_error`,旁路 `failed` / `void`;
4. 多人在同一张表上协作:共享视图 + 负责人 + 字段级审计 + 并发保护;
5. 两个图片槽位(绑定图 / 主页账号图)走 OSS,行内可预览。
@@ -97,12 +97,21 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
| `bind_images` | JSON | 绑定图 object_key 数组(E 列) |
| `home_images` | JSON | 主页账号图 object_key 数组(F 列) |
| `gift_name` | varchar(128) | 礼包名(G 列,原表头写的"备注") |
| `required_points` | int nullable | 该单礼包所需积分快照(选中礼包时从 `queryAllocation` 带出,可手工修正) |
| `remark` | varchar(512) | 自由备注 |
| `status` | varchar(16) index | 业务状态(§4.1) |
| `bound_at` | datetime | 绑定完成时间 |
| `recharged_at` | datetime | 充值完成时间 |
| `bound_at` | datetime | 资料齐全、进入「待充值」的时间(列名沿用历史) |
| `recharged_at` | datetime | 充值核账通过时间 |
| `scripted_at` | datetime | 上脚本时间 |
| `done_at` | datetime | 抢购成功时间 |
| `points_checked_at` | datetime | 最近一次充值核账时间 |
| `points_balance` | int nullable | 核账时该组账号积分合计 |
| `points_required_total` | int nullable | 核账时该组礼包所需积分合计 |
| `points_surplus` | int nullable | 结余 = 积分合计 - 礼包合计 |
| `points_threshold` | int nullable | 本次核账使用的允许结余阈值 |
| `points_check_reason` | varchar(16) | `ok` / `shortage` / `excess` / `error` |
| `points_check_message` | varchar(512) | 给人看的核账结论 |
| `points_check_detail` | JSON nullable | 同组订单、各 uid 余额、缺口/多余等明细 |
| `void_reason` | varchar(256) | 作废原因(`status=void` 时必填) |
| `archived_at` | datetime index | **归档时间**;非空 = 已归档(可见性状态,不是业务状态,§4.4) |
| `archived_by` | int FK `users.id` | 归档人 |
@@ -203,54 +212,74 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
### 4.1 业务状态机(一行的进度)
```
incomplete ─资料齐全─▶ pending ─人工确认─▶ bound ─充值─▶ recharged ─上脚本─▶ scripted ─抢购成功─▶ done
│ │ │ │ │ │
└──────────────────┴─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ failed(抢购失败)
│ │ │ │ │ │
└──────────────────┴─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ void(作废)
incomplete ─资料齐全且 CK 有效─▶ to_recharge ─充值核账─▶ recharged ─上脚本─▶ scripted ─抢购成功─▶ done
▲ │ │
└────────关键资料被清空──────────┘ ├─▶ points_error(积分不足 / 多出超过阈值)
└─▶ failed(抢购失败)
任意流程内状态 ───────────────────────────────────────────────▶ void(作废)
```
| 状态 | 含义 | 进入条件 | 谁可触发 |
| --- | --- | --- | --- |
| `incomplete` | **待补资料** | 新建时只填订单号 + 店铺、CK / 礼包 / 主页截图留空即可(点一次「新建」自动进这里,2026-10-17) | 人工 |
| `pending` | 待处理 | 完整新建 / 上传导入后的默认态;或「待补资料」补齐 CK + 礼包名 + 主页截图后进入 | — / 人工 |
| `bound` | **已确认绑定** | 人工核对「探针读到的绑定角色」与「主页截图」一致后手动切 | 人工(单行 / 批量) |
| `recharged` | 已充值 | 充值完成后手动切(可跳步);写 `recharged_at` | 人工 |
| `scripted` | 已上脚本 | 脚本上完后手动切(可跳步);写 `scripted_at` | 人工 |
| `incomplete` | **待补资料** | 新建 / 粘贴导入的默认态;资料被清空时也会自动退回 | 人工 / 系统 |
| `to_recharge` | **待充值** | 外部 CK、绑定账号、绑定角色、礼包名、至少 1 张主页截图齐全,**且 CK 有效**;由服务层自动推进 | 系统自动 |
| `recharged` | 已充值 | **只能**通过充值核账(`POST /external-orders/recharge`):整组积分结余在 `[0, 阈值]`;写 `recharged_at` | 系统核账 |
| `points_error` | **积分异常** | 同上,但整组结余 `< 0`(不足)或 `> 阈值`(多出);写 `points_check_*` 快照 | 系统核账 |
| `scripted` | 已上脚本 | 人工切换(可跳步);写 `scripted_at` | 人工 |
| `done` | **抢购成功** | 人工切;**或**「已上脚本」的单查积分/兑换记录命中礼包后自动切(§5.7) | 人工 / 系统判定 |
| `failed` | **抢购失败** | 人工切;**或**「已上脚本」的单查兑换记录**没有**这个礼包后自动切(§5.7) | 人工 / 系统判定 |
| `void` | 已作废 | 任意流程内状态可进;`void_reason` 必填 | 人工 |
**规则**(2026-10-16 用户拍板的口径,旧版"只能一步一步往前"作废):
**规则**(2026-10-19 用户拍板的新口径):
1. **人工切换允许往前跳步**:目标阶段序号 ≥ 当前即放行(`is_forward_status()`)。
实际场景就是会跳(没记充值就直接上脚本),逼人一步一步点只会逼出假数据。
跳步会把沿途的 `*_at` 时间戳**补齐**(`_sync_stage_timestamps()`),
否则会出现"已上脚本但没有充值时间"的自相矛盾。
2. **回退必须显式声明**:目标阶段序号 < 当前时必须 `rollback=true`(界面勾「回退」),
会**清掉后面阶段的时间戳**(`scripted → pending` 之后 `bound_at/recharged_at/scripted_at` 全为 null),
审计里 `rollback=true` 留痕。
3. **`failed` / `void` 是旁路**:流程内任意状态都能进(不用勾回退);
从这两态回到流程内**必须** `rollback=true`。`void` 不可逆(只能编辑或新建),
`failed` 可回流程内(人工确认后重试)。
4. **作废 / 失败不动流程时间戳**:`void` / `failed` 保留原来的 `scripted_at` 等 ——
那是"什么时候上的脚本"的证据,与"结果成没成"无关。
5. **进入 `bound` 的前置校验放在服务层**(`_assert_can_enter_bound()`):
**CK + 绑定角色(探针)+ 主页截图**三样缺一不可,缺什么在错误里写清(400)。
绑定图(`bind_images`)与自由文本「绑定斗鱼号」**不再**参与这门校验
—— 绑定关系现在由探针读,绑定图已停用(§11.3)。
6. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是
"什么时候做过"的证据,`status` 是"现在卡在哪"。两者都留,
才能既显示"已充值/已上脚本"这种组合,也能显示"只充了没上脚本"的中间态。
7. **离开「待补资料」要求资料齐全**(`_assert_can_leave_incomplete()`):
外部 CK / 礼包名 / 主页截图三样缺一不可,缺什么在错误里写清(400)。
作废 / 抢购失败是旁路,不受这条限制;新建草稿单不跑探针、不做严格校验。
1. **不再有人工 `pending` / `bound`**:客户资料齐全 + CK 有效后自动进 `to_recharge`。
旧数据里这两个值由迁移 `20261019_0061` 转成 `to_recharge`(资料齐全)或 `incomplete`。
2. **自动推进**(`_maybe_advance_to_recharge()`):创建、编辑、补图、验证 CK 后都会检查
资料完整性;满足条件时从 `incomplete` 自动切到 `to_recharge`,审计写
`huya:collab:status_auto`(`reason=data_complete`)。
3. **自动退回**(`_maybe_demote_missing_data()`):`to_recharge` / `points_error` 的关键资料
(CK、绑定账号、绑定角色、礼包名、主页截图)被清空时,自动退回 `incomplete`,
审计 `reason=data_missing`。**CK 失效本身不触发退回**:业务上可能只是凭据过期,
充值核账会强制重验并阻止误判。
4. **换 CK 不回退业务状态**:`set_ck()` 只把 `ck_status` 打回 `unverified`;等用户点
「充值核账」时强制重验。若探针读到的 `bind_game_account` 变了,整组核账报错,
不会把订单误落到旧账号组。
5. **人工切换允许往前跳步**:目标阶段序号 ≥ 当前即放行(`is_forward_status()`)。
跳步会把沿途的 `*_at` 时间戳**补齐**(`_sync_stage_timestamps()`)。
6. **回退必须显式声明**:目标阶段序号 < 当前时必须 `rollback=true`(界面勾「回退」),
会清掉后面阶段的时间戳,审计里 `rollback=true` 留痕。
7. **`failed` / `void` 是旁路**:流程内任意状态都能进(不用勾回退);从这两态回到流程内
**必须** `rollback=true`。`void` 不可逆(只能编辑或新建),`failed` 可回流程内重试。
8. **`recharged` / `points_error` 不能人工直写**:`set_status()` 只有核账流程传
`allow_recharge=True` 才接受这两个目标;普通状态接口和批量改状态都会返回
`recharge_check_required`。`points_error` 补足/调整积分后重新核账即可回到 `recharged`
或 `to_recharge`。
9. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是
"什么时候做过"的证据,`status` 是"现在卡在哪"。两者都留。
10. **离开「待补资料」要求资料齐全**(`_assert_can_leave_incomplete()`):
外部 CK / 绑定账号 / 绑定角色 / 礼包名 / 主页截图缺一不可;作废 / 抢购失败是旁路,
不受这条限制。
**充值核账(整组,`check_recharge_groups()`)**:
- 分组键 = **精确的 `bind_game_account`**;组内只统计 `to_recharge / recharged /
points_error`,`incomplete / scripted / done / failed / void` 不参与;
- 同一 uid 只验一次 CK、只查一次积分;组内多个 uid 的积分**合计**;
- `必充积分 = Σ required_points`;`结余 = 账号积分合计 - 必充积分`;
- `结余 < 0` → 整组 `points_error`(`reason=shortage`);
`结余 > 阈值` → 整组 `points_error`(`reason=excess`);其余 → 整组 `recharged`;
- 阈值默认 `100`,前端可改并记忆;范围 `0..1_000_000`;
- CK 失效 / 积分查询失败 / 缺少 `required_points` 时**整组保持原状态**并逐组报错;
- 每个组内订单都写同一份快照:`points_checked_at / points_balance /
points_required_total / points_surplus / points_threshold / points_check_reason /
points_check_message / points_check_detail`,审计动作 `huya:collab:recharge_check`。
**批量改状态**(`set_status_batch()`,接口 `POST /external-orders/status/batch`):
- 勾选多行一次切到**同一个**目标状态;**逐行 SAVEPOINT 隔离**,
某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只记在该行的 `message` 里,
不影响其它行(同 §5.1 导入的做法);一次上限 `PROBE_BATCH_MAX=50`;
- 勾选多行一次切到**同一个**目标状态;**逐行 SAVEPOINT 隔离**,某一行前置校验没过
(例如资料不全进不了「待充值」)只记在该行的 `message` 里,不影响其它行;
一次上限 `PROBE_BATCH_MAX=50`;
- 批量接口**不接受** `recharged` / `points_error`;要落这两个状态请用充值核账接口;
- 单行与批量走**同一个** `set_status()`,规则不会漂移;错误信息统一带上
"要往前的话下一步是 X(也可以直接跳到更后面的状态)"。
@@ -270,8 +299,9 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid
| `invalid` | 探针失败(CK 过期/被踢) | "验证 CK"动作失败,`ck_check_message` 记原因 |
| `disabled` | 人工停用(不想再用这个号) | `POST /api/huya/external-orders/{id}/ck/status`(**只能**改成 `disabled`/`unverified`,`valid`/`invalid` 归探针) |
**与业务状态解耦**:CK 失效**不自动**把业务行改成作废——已经绑定/充值的单子,
CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红色标签),由人决定怎么处理。
**与业务状态解耦**:CK 失效**不自动**把业务行改回待补资料或作废 —— 已经待充值/已充值的单子,
凭据过期不等于业务资料缺失。列表上只做**显式提示**(红色标签);充值核账会强制重验 CK,
失败时整组保持原状态并报错,由人决定换 CK 还是停用。
### 4.3 防漂移(学 `docs/设计-账号全生命周期.md` §3.4.1 的教训)
@@ -366,6 +396,7 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| 店铺 | ✅ | **大按钮点选**;没有就点「新店」自定义,自动进字典 |
| 外部 CK | ✅ | 贴进来即调 `POST /ck/preview` 预览 |
| 礼包名 | ✅ | 从**礼包列表**里点选(= 用户购买的物品);列表平时用浏览器缓存,要更新点「强制刷新礼包」 |
| 礼包所需积分 | — | 选中礼包时从积分中心价格自动带出,也可手工修正;核账时按 `Σ required_points` 计算 |
| 主页截图 | ✅ | 至少 1 张,第一张作封面;绑定图**不要了**(绑了谁由探针读,见 §11.5) |
| 负责人 | — | 默认 = 当前登录账号,不用选 |
| 绑定斗鱼号 / 备注 | — | 备注可选;**绑定斗鱼号已从界面下线**(老数据在详情里只读展示) |
@@ -374,14 +405,15 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
(`ck_not_verified`)。所有校验都在落库**之前**做完,失败不留半行 —— 前端只是即时反馈,
服务端才是最终关口(AGENTS §2)。
- **礼包来源**:`webActUI.getActPrizeList`(活动可兑换商品,只读、不落库;拉失败只提示
`gifts_message`,不影响 CK 的有效性结论)。**SID 必须用工作台配置里的那个**
(`huya_config.sid`,走 `resolve_gifts_sid(db)`)—— 见 §11.4 的真机定位:
SID 就是活动 ID、会随活动轮换,写死默认值会出现「接口 200 但礼包列表空白」;
- **记住最近选的礼包**:前端 localStorage(`huya.collab.lastGift`),同一个礼包连开几单不用重复选;
- **礼包列表缓存**:前端 localStorage(`huya.collab.gifts`,存 `{at, gifts}`)—— 一个活动期间礼包清单基本不变,
- **礼包来源**:`PlayMallUI.queryAllocation`(积分中心商品,只读、不落库;拉失败只提示
`gifts_message`,不影响 CK 的有效性结论)。商品积分取 `tag75.tag0.tag2`,与积分中心
页面显示一致;不再依赖活动 SID。旧 `webActUI.getActPrizeList` 保留给精英宝典工作台的
兑换链路,本模块不再调用;
- **礼包名默认空白**(2026-10-18 用户要求):不再自动带上次选的礼包,避免没注意时建错单;
想少点两下用「常用礼包」快捷按钮;
- **礼包列表缓存**:前端 localStorage(`huya.collab.gifts.v2`,存 `{at, gifts}`)—— 一个活动期间礼包清单基本不变,
贴 CK 时**不再每次都去虎牙拉**(`with_gifts=false`),要用最新库存点「强制刷新礼包」;
缓存时间摆在礼包名下方,活动换了(SID 变了)就点一次强制刷新;
缓存时间摆在礼包名下方,商品清单变了就点一次强制刷新;
- **探针缓存**:同一份 CK 在 `PROBE_CACHE_TTL_SECONDS`(90 秒)内复用结论 ——
"预览一次 + 提交校验一次"只打一次虎牙,别为了校验白白翻倍请求;
- 粘贴导入**不受**这些必填项约束(Excel 里本来就可能没有 CK),它走 `require_verified=false`。
@@ -486,7 +518,7 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
### 7.1 共享视图 + 负责人
- 一张表,**所有登录用户看到同一份数据**(默认不按人过滤);
- 默认排序:`待处理优先,再看 updated_at 倒序`;
- 默认排序:按创建序号 `seq` 升序(老单在上、新单追加在后);表头可按订单号/店铺/负责人/状态/礼包/更新时间等白名单字段排序;
- "我的单"是一个筛选器(`owner_id = 我`),不是权限边界。
### 7.2 并发保护(解决 Excel 覆盖痛点)
@@ -539,7 +571,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| POST | `/api/huya/external-orders/{id}/verify` | `huya:collab:edit` | 验证这一行的 CK(只读探针,§5.2) |
| POST | `/api/huya/external-orders/ck/preview` | `huya:collab:edit` | 粘贴 CK 后的即时预览(探针 + 拉活动礼包,**只读不写库**) |
| POST | `/api/huya/external-orders/ck/verify` | `huya:collab:edit` | 批量验证 CK(探针,body `{ids: [...]}`,最多 50 条) |
| POST | `/api/huya/external-orders/status/batch` | `huya:collab:edit` | 勾选多行切成同一个状态(逐行 SAVEPOINT 隔离,最多 50 条;§4.1) |
| POST | `/api/huya/external-orders/status/batch` | `huya:collab:edit` | 勾选多行切成同一个状态(逐行 SAVEPOINT 隔离,最多 50 条;**不接受** `recharged` / `points_error`;§4.1) |
| POST | `/api/huya/external-orders/recharge` | `huya:collab:edit` | **充值核账**:`{ids, threshold}`,按 `bind_game_account` 整组验证 CK + 积分,整组落 `recharged` / `points_error`(§4.1) |
| POST | `/api/huya/external-orders/archive` | `huya:collab:edit` | 归档抢购成功订单:`ids` 单条/勾选,或 `archive_filtered=true` + 筛选条件按当前筛选批量;可带 `reason`,`expected_count` 校验数量(§4.4) |
| POST | `/api/huya/external-orders/unarchive` | `huya:collab:edit` | 取消归档(`{ids: [...]}`),恢复回活跃台账 |
| POST | `/api/huya/external-orders/activity/query` | `huya:collab:edit` | 勾选多行查积分 + 兑换记录(串行带间隔,每行写一条生命周期快照;§5.7) |
@@ -578,14 +611,17 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
- **图片列**:缩略图 + 张数,点击开大图(`Image.PreviewGroup`);
- **状态**:**只展示当前状态 Tag**;行内不再放「→ 下一步」快捷按钮(2026-10-19),推进统一走详情抽屉或批量改状态;
- **CK 状态**:Tag + 一键"验证";
- **工具栏批量动作**(都要先勾选):批量验证 CK / 批量改状态 / 批量查积分·兑换 / 批量归档(仅全部选中行都是活跃 `done`)/ **导出 CK**(勾选几行导几行,写剪贴板;§11.8);
- **工具栏批量动作**(都要先勾选):批量验证 CK / 批量改状态 / **充值核账** / 批量查积分·兑换 / 批量归档(仅全部选中行都是活跃 `done`)/ **导出 CK**(勾选几行导几行,写剪贴板;§11.8);
- **归档入口**(2026-10-19,§4.4):筛选行右侧「查看归档」开关 + 已归档总数角标;活跃视图提供「批量归档」和(状态筛选为 `done` 时)「归档当前筛选结果(N)」;归档视图提供「批量取消归档」,写操作全部隐藏;
- **顶部工具区固定两行**(2026-10-16 用户反馈「不要选中后两行」):
第一行永远筛选 = 店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、店铺、绑定账号、角色、虎牙号、礼包、备注)/ 显示已作废 / 查看归档;
第二行永远动作 = 刷新 + 上面那些批量动作 + 粘贴导入 / 店铺管理 / 新建。
**不要**用「筛选 + 动作放在同一个 `wrap` 里靠 `space-between` 排」那种写法 ——
不勾选时两组挤成一行、勾选后按钮多了「(N)」就被顶到第二行,列表跟着往下跳;
- 页头统计条:`全部 N / 待补资料 N / 待处理 N / 已确认绑定 N / 已充值 N / 已上脚本 N / 抢购成功 N / 抢购失败 N / CK 失效 N`,右上角是「实时同步」开关(可手动暂停;暂停后不建 WebSocket、不轮询,只能手动刷新);已归档总数显示在筛选行的「查看归档」开关旁(`done` / `failed` 的展示名 2026-10-16 由「已完成」/「兑换失败」改成「抢购成功」/「抢购失败」,枚举值与库里数据不变)。
- 页头统计条:`全部 N / 待补资料 N / 待充值 N / 已充值 N / 已上脚本 N / 抢购成功 N / 抢购失败 N / 积分异常 N / CK 失效 N`。
**卡片可点**:点状态卡筛到该状态、点「CK 失效」只筛 CK 状态、点「全部」清空两种筛选;
当前筛选对应的卡片高亮,并自动滚到列表。右上角是「实时同步」开关(可手动暂停;暂停后不建 WebSocket、
不轮询,只能手动刷新);已归档总数显示在筛选行的「查看归档」开关旁。
> 交互细节(2026-10-16 用户拍板):**店铺是点选按钮组**(列表里点单元格弹出同一组按钮,
> 筛选条里也是),不是下拉;**新建时不用选负责人**——默认就是当前登录账号
@@ -641,7 +677,7 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| — | 店铺怎么填(2026-10-16 用户拍板) | **点选按钮组**(不是下拉);允许"新店"自定义输入,后端自动登记进字典 |
| — | 负责人默认值(同日拍板) | **默认 = 当前登录账号**,新建时不用选;要转单在列表/详情里改 |
| — | 新建必填项(同日拍板) | **订单编号 / 店铺 / CK / 礼包名**必填;CK 必须探针通且读到绑定角色 |
| — | 礼包来源(同日拍板) | 用这份 CK 现拉活动礼包(`getActPrizeList`),点选 + 记住上次选择 |
| — | 礼包来源(2026-10-19 调整) | `PlayMallUI.queryAllocation` 积分中心商品,点选 + 记住上次选择;旧 `getActPrizeList` 保留给精英宝典工作台 |
> 决定 7 的后果:**本模块不 import 任何 `huya_tasks` / runner 代码**。
> 以后要做「一键送进工作台」,那是一个**显式的**新动作(会创建 `HuyaAccount` 并写
@@ -766,7 +802,10 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
(`web&1.0&huya` 与我们的 `webh5&...` 结果一样),解析器也不是原因
(`ActPrizeItem` 的 tag 映射与 huya-go 实测一致,`to_dict()["goods"]` 正常)。
处理:新增 `resolve_gifts_sid(db)` —— **优先读工作台配置 `huya_config.sid`**,
> 2026-10-19 起协作台账已切换到 `PlayMallUI.queryAllocation`,不再依赖 SID;
> 下面这段是旧 `getActPrizeList` 的排查记录,保留给精英宝典工作台参考。
处理:新增 `resolve_gifts_sid(db)` —— **优先读工作台配置 `huya_config.sid`**,
读不到(没有配置行 / 不是数字)才回落到 `GIFTS_SID`;接口层
`POST /external-orders/ck/preview` 注入 `db` 后把它传给 `preview_ck(sid=...)`。
配置行不存在时**不建行**(预览是只读动作)。空列表的结论也改成人话:
@@ -835,6 +874,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
### 11.7 2026-10-16 第四轮:状态机新口径 + 账号全生命周期
> 历史口径:本节的 `bound`(已确认绑定)已在 2026-10-19 第九轮删除,见 §11.12;保留原文用于追溯当时决策。
用户原话(这一轮的全部需求):
"业务状态刷新一下,目前上传后都属于待处理"、
"手动确认绑定角色信息和主页截图相同后,可以手动切换到已确认绑定"、
@@ -981,6 +1022,31 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
**验收口径**:默认列表/统计不含归档行;归档视图能按原有关键词与筛选搜到;归档行改任何东西都提示先取消归档;
取消归档后回到原位置(业务状态未变),归档备注仍能在审计时间线里查到。
### 11.12 2026-10-19 第九轮:状态机重做 + 按绑定账号充值核账
需求原文:「去掉待处理和已确认绑定;客户资料齐了自动待充值;点已充值要服务端校验 CK 和积分;
按 bind_game_account 整组核账,整组落已充值 / 积分异常;积分不足或多出阈值都算异常;阈值可改默认 100」。
| 面 | 落地 |
| --- | --- |
| 状态 | 删除 `pending` / `bound`;新增 `to_recharge`(待充值)、`points_error`(积分异常);`incomplete` 成为唯一入口 |
| 自动推进 | `_maybe_advance_to_recharge()`:CK 有效 + 绑定账号 + 绑定角色 + 礼包名 + ≥1 张主页截图 → 自动 `incomplete → to_recharge`;创建/编辑/补图/验证 CK 都触发 |
| 自动退回 | `_maybe_demote_missing_data()`:`to_recharge` / `points_error` 关键资料被清空 → 自动退回 `incomplete`;CK 失效不触发 |
| 核账 | `check_recharge_groups()` + `POST /external-orders/recharge`:按精确 `bind_game_account` 分组;只统计 `to_recharge/recharged/points_error`;同 uid 只验一次 CK、只查一次积分;多 uid 积分合计 |
| 判定 | `结余 = 积分合计 - Σ required_points`;`<0` → `points_error/shortage`;`>阈值` → `points_error/excess`;其余 → `recharged`;CK 失效/积分失败/积分缺失整组保持原状态 |
| 字段 | 新增 `required_points` + `points_checked_at/points_balance/points_required_total/points_surplus/points_threshold/points_check_reason/points_check_message/points_check_detail` |
| 迁移 | `20261019_0061`:加列、`status` 默认改 `incomplete`、旧 `pending/bound` 按资料是否齐全转 `to_recharge` 或 `incomplete`;降级把 `to_recharge → bound`、`points_error → recharged` |
| 礼包来源 | 改用 `PlayMallUI.queryAllocation`(`tag75.tag0.tag2` 取积分,`tag13` 库存),不再依赖活动 SID;旧 `webActUI.getActPrizeList` 仅保留给精英宝典工作台 |
| 前端 | 统计卡改为 全部/待补资料/待充值/已充值/已上脚本/抢购成功/抢购失败/积分异常/CK 失效,**可点击筛选并高亮**;工具栏新增「充值核账」;新建/补资料/详情增加「礼包所需积分」,选礼包自动带出;核账弹窗阈值默认 100 |
| 审计 | 核账逐单写 `huya:collab:recharge_check`;自动推进/退回写 `huya:collab:status_auto`;主操作(create/update/image)先落审计,再追加自动状态,时间线因果顺序不再倒置 |
**三条边界**:
1. **不要用 uid 当分组键**:同一买家可能用多份 CK。分组只认探针读到的 `bind_game_account`;
`bind_game_account` 为空的行保持/退回 `incomplete`,绝不回落到 uid。
2. **不要让人工直写 `recharged` / `points_error`**:这两个状态必须由核账产生,否则积分快照与状态会各说各话。
3. **换 CK 不等于资料缺失**:`set_ck()` 只重置 `ck_status`;核账会强制重验,若绑定账号变了整组报错,不会误落旧组。
---
## 12. 风险
@@ -992,7 +1058,7 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| CK 被导出后外传(导出到剪贴板 = 凭据出门) | 独立权限点 `huya:collab:ck_export`(默认只给运营)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8) |
| 积分 / 兑换记录查询被风控 | 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7) |
| 自动改状态误判(接口抖动 → 判成"抢购失败") | 三态判定:取不到数据一律 `unknown` 且不动状态;只有 `scripted` 的单才自动改;每次查询留快照,人工可回退(`rollback`) |
| 活动换 ID(SID 过期)→ 礼包列表空白 | 礼包 SID 一律读 `huya_config.sid`(工作台「虎牙配置」可改,见 §11.4);空列表时结论直接提示去改 SID |
| 积分中心 `queryAllocation` 拉不到 / 返回空 → 礼包列表空白 | 只影响「拉礼包」,不影响 CK 有效性;提示用户稍后点「强制刷新礼包」。旧 `getActPrizeList` 的 SID 过期问题只保留给精英宝典工作台(§11.4) |
| 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` |
| 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 |
| 分支 | 按 AGENTS §1,新功能应从 `main` 开 `feat/*`;开工前需确认当前分支与库迁移版本对齐 |
+592 -76
View File
@@ -14,6 +14,7 @@ from types import SimpleNamespace
import pytest
from core.huya.taf_protocol import TafOutputStream, TafType
from tests.support.db import get_test_engine, make_test_session
from web.backend.huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
@@ -84,11 +85,25 @@ class TestCollabBase:
)
def _bind_ready(self, order: HuyaExternalOrder) -> HuyaExternalOrder:
"""把一行推到「可以标记为已确认绑定」的状态。
"""把一行补到「待充值」:CK 有效 + 绑定账号/角色 + 礼包 + 主页截图。
2026-10-16 口径:进 `bound` 需要 **探针读到的绑定角色 + 主页截图**(人工核对这两样一致),
绑定图与「绑定斗鱼号」都不再要求。
2026-10-19 新口径:资料齐全且 CK 有效后由服务层自动推进到 `to_recharge`,
不再存在 `pending` / `bound` 两个人工状态。
"""
if not order.gift_name:
collab.update_order(
self.db,
self.user,
order,
{"gift_name": "不朽伊人大礼包", "required_points": 8000},
)
collab.set_images(
self.db,
self.user,
order,
"home_images",
["collab/2026/10/16/home01.png"],
)
collab.set_ck(self.db, self.user, order, fake_ck("1199647239697"))
collab.verify_ck(
self.db,
@@ -100,13 +115,6 @@ class TestCollabBase:
)
),
)
collab.set_images(
self.db,
self.user,
order,
"home_images",
["collab/2026/10/16/home01.png"],
)
return order
@@ -117,7 +125,7 @@ class TestCollabCrud(TestCollabBase):
assert order.uid == "1199647239697"
assert order.yyuid == "1199647239697"
assert order.hy_username == "hy_1199647239697"
assert order.status == "pending"
assert order.status == collab.STATUS_INCOMPLETE
assert order.ck_status == "unverified"
assert order.version == 0
assert order.created_by == self.user.id
@@ -266,10 +274,11 @@ class TestCollabCrud(TestCollabBase):
class TestCollabStatusMachine(TestCollabBase):
def test_forward_jump_is_allowed_but_backward_needs_rollback(self):
"""2026-10-16 口径:人工切换允许**往前跳步**,回退必须显式 rollback。"""
"""人工切换允许**往前跳步**,回退必须显式 rollback。"""
order = self._bind_ready(self._create())
assert order.status == collab.STATUS_TO_RECHARGE
# pending → scripted(跳过 已确认绑定/已充值)是允许的
# 待充值 → 已上脚本(跳过「已充值」)是允许的
collab.set_status(self.db, self.user, order, "scripted")
assert order.status == "scripted"
# 跳步时中间阶段的时间戳补上,别让时间线断档
@@ -278,21 +287,24 @@ class TestCollabStatusMachine(TestCollabBase):
assert order.scripted_at is not None
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, "recharged")
collab.set_status(self.db, self.user, order, "to_recharge")
assert excinfo.value.code == "status_not_forward"
assert order.status == "scripted"
def test_bound_requires_ck_probe_role_and_home_screenshot(self):
def test_to_recharge_requires_complete_customer_info_and_valid_ck(self):
order = self._create()
# 空单:直接往前会被资料完整性挡住,错误信息必须逐项说清
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, "bound")
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
message = excinfo.value.detail
# 错误信息必须说清缺什么,否则客服只能猜
assert "CK" in message
assert "外部 CK" in message
assert "绑定账号" in message
assert "绑定角色" in message
assert "礼包名" in message
assert "主页截图" in message
# 只贴 CK + 读到角色,但没传主页截图 → 还是不让进
# 只贴 CK + 读到角色,但没礼包 / 主页截图 → 仍然停在待补资料
collab.set_ck(self.db, self.user, order, fake_ck("1199647001234"))
collab.verify_ck(
self.db,
@@ -300,30 +312,66 @@ class TestCollabStatusMachine(TestCollabBase):
order,
client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")),
)
assert order.status == collab.STATUS_INCOMPLETE
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, "bound")
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
assert "礼包名" in excinfo.value.detail
assert "主页截图" in excinfo.value.detail
assert "绑定图" not in excinfo.value.detail # 绑定图已停用,不该再被要求
# 资料补齐后由 set_images 自动推进(不要求用户再点一次状态)
collab.update_order(
self.db,
self.user,
order,
{"gift_name": "不朽伊人大礼包", "required_points": 8000},
)
assert order.status == collab.STATUS_INCOMPLETE
collab.set_images(
self.db, self.user, order, "home_images", ["collab/2026/10/16/home01.png"]
)
collab.set_status(self.db, self.user, order, "bound")
assert order.status == "bound"
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bound_at is not None
def test_auto_advance_waits_for_valid_ck(self):
order = self._create(
order_no="AUTO-CK",
gift_name="不朽伊人大礼包",
required_points=8000,
home_images=["collab/2026/10/16/home01.png"],
)
collab.set_ck(self.db, self.user, order, fake_ck("1199647001234"))
# 只贴 CK 不验证:资料看似齐全,但 CK 仍是 unverified,不能进待充值。
# 绑定快照通常由探针给;这里直接塞进 ORM 对象,单独验证「CK 必须有效」这条。
order.bind_game_account = "微信用户"
order.bind_game_role = "吃鸡199706"
self.db.commit()
assert order.ck_status == "unverified"
assert order.status == collab.STATUS_INCOMPLETE
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
assert "CK 验证有效" in excinfo.value.detail
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")),
)
assert order.status == collab.STATUS_TO_RECHARGE
def test_forward_flow_sets_timestamps(self):
order = self._bind_ready(self._create())
assert order.status == collab.STATUS_TO_RECHARGE
collab.set_status(self.db, self.user, order, "bound")
assert order.status == "bound"
assert order.bound_at is not None
collab.set_status(self.db, self.user, order, "recharged")
collab.set_status(
self.db, self.user, order, collab.STATUS_RECHARGED, allow_recharge=True
)
collab.set_status(self.db, self.user, order, "scripted")
collab.set_status(self.db, self.user, order, "done")
assert order.status == "done"
assert order.bound_at is not None
assert order.recharged_at is not None
assert order.scripted_at is not None
assert order.done_at is not None
@@ -332,25 +380,44 @@ class TestCollabStatusMachine(TestCollabBase):
"""设计文档 §4.3 防漂移:状态写入必须留痕,漏一条就是 bug。"""
order = self._bind_ready(self._create())
before = len(self._audits(order))
for target in ("bound", "recharged", "scripted", "done"):
collab.set_status(
self.db, self.user, order, collab.STATUS_RECHARGED, allow_recharge=True
)
for target in ("scripted", "done"):
collab.set_status(self.db, self.user, order, target)
after = self._audits(order)
assert len(after) - before == 4
assert len(after) - before == 3
status_audits = [row for row in after if row.action == "huya:collab:status"]
assert len(status_audits) == 4
assert len(status_audits) == 3
def test_rollback_requires_flag_and_clears_later_timestamps(self):
order = self._bind_ready(self._create())
for target in ("bound", "recharged", "scripted", "done"):
collab.set_status(
self.db, self.user, order, collab.STATUS_RECHARGED, allow_recharge=True
)
for target in ("scripted", "done"):
collab.set_status(self.db, self.user, order, target)
assert order.scripted_at is not None
# 不给 rollback 标记 → 拒
with pytest.raises(collab.CollabError):
collab.set_status(self.db, self.user, order, "recharged")
collab.set_status(
self.db,
self.user,
order,
collab.STATUS_RECHARGED,
allow_recharge=True,
)
collab.set_status(self.db, self.user, order, "recharged", rollback=True)
assert order.status == "recharged"
collab.set_status(
self.db,
self.user,
order,
collab.STATUS_RECHARGED,
rollback=True,
allow_recharge=True,
)
assert order.status == collab.STATUS_RECHARGED
# 回退后后面阶段的时间戳必须清掉,否则状态与时间戳自相矛盾
assert order.scripted_at is None
assert order.done_at is None
@@ -367,16 +434,27 @@ class TestCollabStatusMachine(TestCollabBase):
assert order.void_reason == "客户退款"
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, "pending", rollback=True)
collab.set_status(self.db, self.user, order, "incomplete", rollback=True)
assert excinfo.value.code == "status_from_void"
def test_same_status_is_noop(self):
order = self._bind_ready(self._create())
before = len(self._audits(order))
assert collab.set_status(self.db, self.user, order, "pending") == {}
assert (
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
== {}
)
# 幂等点击不该刷出审计噪声
assert len(self._audits(order)) == before
def test_direct_recharged_and_points_error_require_recharge_flow(self):
order = self._bind_ready(self._create())
for target in (collab.STATUS_RECHARGED, collab.STATUS_POINTS_ERROR):
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, target)
assert excinfo.value.code == "recharge_check_required"
assert order.status == collab.STATUS_TO_RECHARGE
def test_done_has_no_next_status(self):
order = self._create()
collab.set_status(self.db, self.user, order, "void", reason="x")
@@ -711,14 +789,91 @@ class FakeGiftResp:
return {"status": self.status, "msg": self.msg, "goods": self._goods}
def points_center_blob(
*,
name: str = "不朽伊人大礼包",
product_id: str = "hy-1001",
points: int = 200,
stock: int = 39,
category: str = "礼包",
) -> bytes:
"""构造 queryAllocation 的 ``tag5`` 最小商品列表(TAF LIST)。"""
os = TafOutputStream()
os.write_head(0, TafType.LIST)
os.write_int32(0, 1)
# 分类
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_string(1, category)
os.write_head(6, TafType.LIST)
os.write_int32(0, 1)
# 商品
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_head(1, TafType.STRUCT_BEGIN)
# 商品基础信息 tag0
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_string(0, product_id)
os.write_string(2, name)
os.write_string(4, category)
os.write_string(5, "")
os.write_int64(9, points * 10)
os.write_int64(13, stock)
# 积分 tag75.tag0.tag2
os.write_head(75, TafType.STRUCT_BEGIN)
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_int64(2, points)
os.write_struct_end()
os.write_struct_end()
os.write_struct_end() # tag0
os.write_struct_end() # tag1
os.write_struct_end() # 商品
os.write_struct_end() # 分类
return os.get_bytes()
class FakePointsCenterResp:
"""合成一份 ``PlayMallUI.queryAllocation`` 响应。"""
def __init__(
self,
*,
code: int = 200,
message: str = "成功",
payload: dict | None = None,
goods: list | None = None,
):
if payload is None:
if goods is None:
goods = [
{
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"points": 200,
"stock": 39,
"category": "礼包",
}
]
blobs = [points_center_blob(**item) for item in goods]
payload = {"tag2": [{"tag5": blob} for blob in blobs]}
self.code = code
self.message = message
self.payload = payload
def to_dict(self) -> dict:
return {"code": self.code, "message": self.message, "payload": self.payload}
class FakeProbeClient:
"""离线探针客户端:按脚本依次给出 ``None`` / 异常 / 响应,并记录调用参数。"""
def __init__(self, *outcomes, gifts: list | None = None):
def __init__(
self, *outcomes, gifts: list | None = None, points_gifts: list | None = None
):
self.outcomes = list(outcomes)
self.gift_outcomes = list(gifts or [])
self.point_outcomes = list(points_gifts or [])
self.calls: list[dict] = []
self.gift_calls: list[dict] = []
self.points_calls: list[dict] = []
def check_user_bind_game_account(self, **kwargs):
self.calls.append(kwargs)
@@ -736,6 +891,17 @@ class FakeProbeClient:
raise outcome
return outcome
def play_mall_query_allocation(self, **kwargs):
self.points_calls.append(kwargs)
outcome = (
self.point_outcomes.pop(0)
if self.point_outcomes
else FakePointsCenterResp()
)
if isinstance(outcome, Exception):
raise outcome
return outcome
class TestCollabCkProbe(TestCollabBase):
"""S5:只读探针。判据(真机对照 2026-10-16)= 响应非 None 且 status == 200。"""
@@ -900,9 +1066,15 @@ class TestCollabCkProbe(TestCollabBase):
assert detail["before"]["bind_game_role"] == "旧角色"
assert detail["after"]["bind_game_role"] == "新角色"
def test_binding_role_from_probe_satisfies_the_bound_precondition(self):
"""探针读到绑定角色 + 有主页截图 → 可以进「已确认绑定」(绑定斗鱼号/绑定图都不再要求)。"""
def test_binding_role_from_probe_satisfies_the_recharge_precondition(self):
"""探针读到绑定角色 + 礼包 + 主页截图 → 自动进入「待充值」。"""
order = self._row_with_ck()
collab.update_order(
self.db,
self.user,
order,
{"gift_name": "不朽伊人大礼包", "required_points": 8000},
)
collab.set_images(
self.db,
self.user,
@@ -919,9 +1091,7 @@ class TestCollabCkProbe(TestCollabBase):
assert order.bind_douyu_account == ""
assert order.bind_images == []
collab.set_status(self.db, self.user, order, "bound")
assert order.status == "bound"
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bound_at is not None
def test_verify_never_touches_manually_entered_columns(self):
@@ -983,17 +1153,17 @@ class TestCollabCkProbe(TestCollabBase):
assert order.ck_status == "unverified"
def test_verify_never_touches_business_status(self):
"""CK 失效不改业务状态:已绑定的单子业务上可能仍然有效(设计文档 §4.2)。"""
"""CK 失效不改业务状态:待充值的单子业务上仍然要等人处理。"""
order = self._row_with_ck()
self._bind_ready(order)
collab.set_status(self.db, self.user, order, "bound")
assert order.status == collab.STATUS_TO_RECHARGE
collab.verify_ck(
self.db, self.user, order, client=FakeProbeClient(FakeBindResp(status=401))
)
assert order.ck_status == "invalid"
assert order.status == "bound"
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bound_at is not None
def test_verify_never_creates_huya_account(self):
@@ -1125,15 +1295,17 @@ class TestCollabCkPreview(TestCollabBase):
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"price": 200,
"remain_text": "39%",
"stock": None,
"score": None,
"remain_text": "剩39",
"stock": 39,
"score": 200,
"category_name": "礼包",
"icon": "",
}
]
assert "已拉取 1 个礼包" in preview["gifts_message"]
assert client.gift_calls[0]["sid"] == collab.GIFTS_SID
assert "已拉取 1 个礼包(积分中心)" in preview["gifts_message"]
assert client.points_calls[0]["uid"] == 1199647239697
assert client.points_calls[0]["deep"] is True
assert client.gift_calls == []
def test_preview_of_invalid_ck_does_not_fetch_gifts(self):
client = FakeProbeClient(None)
@@ -1143,7 +1315,7 @@ class TestCollabCkPreview(TestCollabBase):
assert preview["ok"] is False
assert preview["has_role"] is False
assert preview["gifts"] == []
assert client.gift_calls == [] # CK 都没通,不去拉礼包
assert client.points_calls == [] # CK 都没通,不去拉礼包
def test_preview_of_probe_without_role_is_not_submittable(self):
"""探针通了但读不到绑定角色 → 不允许提交(has_role=False)。"""
@@ -1208,6 +1380,66 @@ class TestCollabGifts(TestCollabBase):
assert gifts == []
assert "SID=2203" in message and "工作台" in message
def test_points_center_gift_fetch_parses_fields(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(),
)
assert gifts == [
{
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"price": 200,
"remain_text": "剩39",
"stock": 39,
"score": 200,
"category_name": "礼包",
"icon": "",
}
]
assert message == "已拉取 1 个礼包(积分中心)"
def test_points_center_gift_fetch_reports_failure(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(
points_gifts=[FakePointsCenterResp(code=500, message="接口炸了")]
),
)
assert gifts == []
assert "拉取礼包失败" in message and "500" in message
def test_points_center_gift_fetch_survives_exception(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(points_gifts=[RuntimeError("boom")]),
)
assert gifts == []
assert "RuntimeError" in message
def test_points_center_gift_fetch_empty_list(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(points_gifts=[FakePointsCenterResp(goods=[])]),
)
assert gifts == []
assert "积分中心" in message and "空的" in message
def test_points_center_parser_reads_tag75_points(self):
from core.huya.shop_structs import parse_points_center_gifts
gifts = parse_points_center_gifts(
{"tag2": [{"tag5": points_center_blob(points=888, stock=7)}]}
)
assert gifts[0]["price"] == 888
assert gifts[0]["score"] == 888
assert gifts[0]["stock"] == 7
def test_resolve_gifts_sid_prefers_workbench_config(self):
self.db.add(HuyaConfig(sid="2277"))
self.db.commit()
@@ -1226,18 +1458,19 @@ class TestCollabGifts(TestCollabBase):
self.db.commit()
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID
def test_preview_uses_configured_sid(self):
def test_preview_ignores_legacy_sid_and_uses_points_center(self):
self.db.add(HuyaConfig(sid="2277"))
self.db.commit()
client = FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706"))
preview = collab.preview_ck(fake_ck("1199647239697"), sid=2277, client=client)
assert client.gift_calls[0]["sid"] == 2277
assert client.points_calls
assert "sid" not in client.points_calls[0]
assert preview["has_role"] is True
def test_preview_endpoint_passes_configured_sid(self, monkeypatch):
"""接口层必须把工作台配置的 SID 传下去,别再退回写死默认值。"""
def test_preview_endpoint_uses_points_center_source(self, monkeypatch):
"""接口层仍可带旧 SID 配置,但预览实际走积分中心 queryAllocation。"""
from web.backend.routers.huya import collab as collab_router
from web.backend.schemas import HuyaCollabCkPreviewRequest
@@ -1253,7 +1486,7 @@ class TestCollabGifts(TestCollabBase):
)
# 直接调路由函数时没走 pydantic 出参,拿到的就是 dict
assert client.gift_calls[0]["sid"] == 2277
assert client.points_calls
assert out["has_role"] is True
@@ -1418,7 +1651,7 @@ class TestCollabCreateStrict(TestCollabBase):
)
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, collab.STATUS_PENDING)
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
assert excinfo.value.code == "incomplete_precondition"
assert "外部 CK" in excinfo.value.detail
@@ -1433,14 +1666,20 @@ class TestCollabCreateStrict(TestCollabBase):
order_no="DRAFT-3",
shop="佳佳",
gift_name="不朽伊人大礼包",
required_points=8000,
home_images=[self.HOME_IMAGE],
draft=True,
)
collab.set_ck(self.db, self.user, order, fake_ck("1199647239697"))
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")),
)
collab.set_status(self.db, self.user, order, collab.STATUS_PENDING)
assert order.status == collab.STATUS_PENDING
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bound_at is not None
assert order.void_reason == ""
@@ -1761,6 +2000,261 @@ class FakeActivityClient:
return self.records if self.records is not None else FakeRecordsResp()
class FakeRechargeClient(FakeProbeClient, FakeActivityClient):
"""充值核账用:同一轮里既要跑绑定探针,又要查积分/礼包目录。
``scores`` 按 uid 调用顺序出队;不传就所有 uid 用同一个 ``score``。
"""
def __init__(
self,
*outcomes,
score: object = None,
scores: list[object] | None = None,
points_gifts: list[object] | None = None,
):
FakeProbeClient.__init__(self, *outcomes, points_gifts=points_gifts)
FakeActivityClient.__init__(self, score=score)
self.scores = list(scores or [])
def query_user_score(self, **kwargs):
if not self.scores:
return super().query_user_score(**kwargs)
self.score_calls.append(kwargs)
outcome = self.scores.pop(0)
if isinstance(outcome, Exception):
raise outcome
return outcome
class TestCollabRecharge(TestCollabBase):
"""充值核账:按 bind_game_account 整组校验 CK + 积分,整组落状态。"""
BIND = "同一个买家"
ROLE = "角色A"
HOME_IMAGE = "collab/2026/10/19/home01.png"
def _ready(
self,
order_no: str,
uid: str,
*,
gift_name: str,
required_points: int | None,
role: str = ROLE,
) -> HuyaExternalOrder:
order = self._create(
order_no=order_no,
ck_text=fake_ck(uid),
gift_name=gift_name,
required_points=required_points,
home_images=[self.HOME_IMAGE],
)
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(
FakeBindResp(nick=self.BIND, role=role, plat="苹果(iOS)", area="微信")
),
)
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bind_game_account == self.BIND
return order
@staticmethod
def _score(value: int) -> FakeScoreResp:
return FakeScoreResp(gained=max(0, value), used=0)
def _check(self, orders, client, *, threshold: int = 100):
return collab.check_recharge_groups(
self.db,
self.user,
orders,
threshold=threshold,
client=client,
)
def test_success_recharges_whole_group_and_snapshots_every_order(self):
first = self._ready("RC-1", "1001", gift_name="礼包A", required_points=200)
second = self._ready("RC-2", "1001", gift_name="礼包B", required_points=300)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(550),
)
result = self._check([first, second], client)
assert result["updated"] == 2 and result["failed"] == 0
assert len(result["groups"]) == 1
group = result["groups"][0]
assert group["status"] == collab.STATUS_RECHARGED
assert group["balance"] == 550
assert group["required_total"] == 500
assert group["surplus"] == 50
assert first.status == second.status == collab.STATUS_RECHARGED
for order in (first, second):
assert order.points_checked_at is not None
assert order.points_balance == 550
assert order.points_required_total == 500
assert order.points_surplus == 50
assert order.points_threshold == 100
assert order.points_check_reason == "ok"
assert order.points_check_detail["group_key"] == self.BIND
# 同一 uid 只验一次 CK、只查一次积分
assert len(client.calls) == 1
assert len(client.score_calls) == 1
assert "huya:collab:recharge_check" in [
row.action for row in self._audits(first)
]
def test_threshold_boundary_is_allowed(self):
order = self._ready("RC-3", "1002", gift_name="礼包A", required_points=500)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(600),
)
result = self._check([order], client, threshold=100)
assert result["groups"][0]["surplus"] == 100
assert order.status == collab.STATUS_RECHARGED
assert order.points_check_reason == "ok"
def test_shortage_marks_whole_group_points_error(self):
first = self._ready("RC-4", "1003", gift_name="礼包A", required_points=200)
second = self._ready("RC-5", "1003", gift_name="礼包B", required_points=300)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(400),
)
result = self._check([first, second], client)
assert result["groups"][0]["reason"] == "shortage"
assert result["groups"][0]["surplus"] == -100
assert first.status == second.status == collab.STATUS_POINTS_ERROR
assert "还差 100 分" in first.points_check_message
assert first.points_check_reason == second.points_check_reason == "shortage"
def test_excess_marks_whole_group_points_error(self):
order = self._ready("RC-6", "1004", gift_name="礼包A", required_points=500)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(650),
)
result = self._check([order], client, threshold=100)
assert result["groups"][0]["reason"] == "excess"
assert result["groups"][0]["surplus"] == 150
assert order.status == collab.STATUS_POINTS_ERROR
assert "多出 150 分" in order.points_check_message
def test_multiple_uids_are_summed_and_each_queried_once(self):
first = self._ready("RC-7", "1005", gift_name="礼包A", required_points=300)
second = self._ready("RC-8", "1006", gift_name="礼包B", required_points=300)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role="角色A"),
FakeBindResp(nick=self.BIND, role="角色A"),
scores=[self._score(300), self._score(400)],
)
result = self._check([first, second], client)
assert result["groups"][0]["balance"] == 700
assert result["groups"][0]["surplus"] == 100
assert first.status == second.status == collab.STATUS_RECHARGED
assert [call["uid"] for call in client.score_calls] == [1005, 1006]
def test_ck_invalid_keeps_group_status_unchanged(self):
order = self._ready("RC-9", "1007", gift_name="礼包A", required_points=200)
client = FakeRechargeClient(
FakeBindResp(status=401, msg="登录态过期"),
score=self._score(200),
)
result = self._check([order], client)
assert result["failed"] == 1
assert result["groups"][0]["status"] == ""
assert "CK 验证失败" in result["groups"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
assert order.points_checked_at is None
def test_points_query_failure_keeps_group_status_unchanged(self):
order = self._ready("RC-10", "1008", gift_name="礼包A", required_points=200)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=RuntimeError("boom"),
)
result = self._check([order], client)
assert result["failed"] == 1
assert "积分查询失败" in result["groups"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
assert order.points_checked_at is None
def test_missing_required_points_can_be_resolved_from_gift_catalog(self):
order = self._ready(
"RC-11", "1009", gift_name="不朽伊人大礼包", required_points=None
)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(200),
points_gifts=[FakePointsCenterResp()],
)
result = self._check([order], client)
assert order.required_points == 200
assert result["groups"][0]["required_total"] == 200
assert result["groups"][0]["surplus"] == 0
assert order.status == collab.STATUS_RECHARGED
assert len(client.points_calls) == 1
def test_unresolved_required_points_keeps_status_unchanged(self):
order = self._ready(
"RC-12", "1010", gift_name="目录里没有的礼包", required_points=None
)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(200),
points_gifts=[FakePointsCenterResp()],
)
result = self._check([order], client)
assert result["failed"] == 1
assert "缺少礼包所需积分" in result["groups"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
assert order.required_points is None
def test_group_order_without_cookie_blocks_whole_group(self):
order = self._ready("RC-14", "1012", gift_name="礼包A", required_points=200)
order.cookie = ""
self.db.commit()
result = self._check([order], FakeRechargeClient())
assert result["failed"] == 1
assert "组内订单缺少 CK" in result["groups"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
def test_missing_bind_account_is_demoted_to_incomplete(self):
order = self._ready("RC-13", "1011", gift_name="礼包A", required_points=200)
order.bind_game_account = ""
self.db.commit()
result = self._check([order], FakeRechargeClient())
assert result["updated"] == 0
assert result["failed"] == 1
assert "缺少绑定账号" in result["failures"][0]["message"]
assert order.status == collab.STATUS_INCOMPLETE
class TestCollabStatusBatch(TestCollabBase):
"""勾选多行一次改状态:部分失败不影响其它行(2026-10-16 用户要的批量切换)。"""
@@ -1780,39 +2274,56 @@ class TestCollabStatusBatch(TestCollabBase):
not_ready = self._create(order_no="BATCH-4") # 没 CK / 没角色 / 没主页截图
result = collab.set_status_batch(
self.db, self.user, [ready, not_ready], "bound"
self.db, self.user, [ready, not_ready], "scripted"
)
assert result["updated"] == 1 and result["failed"] == 1
by_no = {item["order_no"]: item for item in result["results"]}
assert by_no["BATCH-3"]["ok"] is True
assert by_no["BATCH-4"]["ok"] is False
assert "主页截图" in by_no["BATCH-4"]["message"]
assert ready.status == "bound"
assert not_ready.status == "pending"
assert "外部 CK" in by_no["BATCH-4"]["message"]
assert ready.status == "scripted"
assert not_ready.status == collab.STATUS_INCOMPLETE
def test_batch_rejects_reconcile_statuses(self):
order = self._bind_ready(self._create(order_no="BATCH-5"))
for target in (collab.STATUS_RECHARGED, collab.STATUS_POINTS_ERROR):
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status_batch(self.db, self.user, [order], target)
assert excinfo.value.code == "recharge_check_required"
assert order.status == collab.STATUS_TO_RECHARGE
def test_batch_backward_needs_rollback_flag(self):
order = self._bind_ready(self._create(order_no="BATCH-5"))
order = self._bind_ready(self._create(order_no="BATCH-6"))
collab.set_status(self.db, self.user, order, "scripted")
rejected = collab.set_status_batch(self.db, self.user, [order], "recharged")
rejected = collab.set_status_batch(
self.db, self.user, [order], collab.STATUS_TO_RECHARGE
)
assert rejected["failed"] == 1
assert order.status == "scripted"
accepted = collab.set_status_batch(
self.db, self.user, [order], "recharged", rollback=True
self.db,
self.user,
[order],
collab.STATUS_TO_RECHARGE,
rollback=True,
)
assert accepted["updated"] == 1
assert order.status == "recharged"
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, [], "bound")
collab.set_status_batch(self.db, self.user, [], "scripted")
assert excinfo.value.code == "status_batch_empty"
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status_batch(
self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1), "bound"
self.db,
self.user,
[None] * (collab.PROBE_BATCH_MAX + 1),
"scripted",
)
assert excinfo.value.code == "status_batch_too_large"
@@ -1879,7 +2390,7 @@ class TestCollabArchive(TestCollabBase):
self.db,
self.user,
order,
collab.STATUS_PENDING,
collab.STATUS_INCOMPLETE,
rollback=True,
)
assert excinfo.value.code == "order_archived"
@@ -1948,8 +2459,14 @@ class TestCollabActivityLifecycle(TestCollabBase):
*,
gift_name: str = "不朽伊人大礼包",
) -> HuyaExternalOrder:
order = self._bind_ready(self._create(order_no=order_no, gift_name=gift_name))
# 离开「待补资料」要求礼包名非空;这里先用占位礼包推进到已上脚本,
# 再按需清空礼包名,模拟「上脚本后人工把礼包删了」的脏数据。
order = self._bind_ready(
self._create(order_no=order_no, gift_name=gift_name or "占位礼包")
)
collab.set_status(self.db, self.user, order, "scripted")
if not gift_name:
collab.update_order(self.db, self.user, order, {"gift_name": ""})
return order
def _metrics(self, order: HuyaExternalOrder) -> list:
@@ -2006,8 +2523,7 @@ class TestCollabActivityLifecycle(TestCollabBase):
order = self._bind_ready(
self._create(order_no="ACT-3", gift_name="不朽伊人大礼包")
)
collab.set_status(self.db, self.user, order, "bound")
assert order.status == "bound"
assert order.status == collab.STATUS_TO_RECHARGE
result = collab.query_order_activity(
self.db, self.user, order, client=FakeActivityClient()
@@ -2015,7 +2531,7 @@ class TestCollabActivityLifecycle(TestCollabBase):
assert result["verdict"] == collab.VERDICT_SUCCESS
assert result["auto_status"] == ""
assert order.status == "bound"
assert order.status == collab.STATUS_TO_RECHARGE
assert "只有「已上脚本」" in result["message"]
def test_unknown_verdict_when_interface_fails(self):
@@ -2076,7 +2592,7 @@ class TestCollabActivityLifecycle(TestCollabBase):
assert result["success"] == 1 and result["unknown"] == 1
assert result["auto_changed"] == 1
assert first.status == "done"
assert broken.status == "pending"
assert broken.status == collab.STATUS_INCOMPLETE
assert any("还没有 CK" in item["message"] for item in result["results"])
def test_metrics_timeline_is_newest_first(self):
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261019_0060"
HEAD_REVISION = "20261019_0061"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
@@ -0,0 +1,137 @@
"""虎牙协作台账:待充值 / 积分异常 + 礼包积分快照。
2026-10-19 用户拍板:
- 删除 pending(待处理)/ bound(已确认绑定);
- 资料齐全且 CK 有效自动进 to_recharge(待充值);
- 充值完成按同一 bind_game_account 整组核账,结余为负或超过阈值进
points_error(积分异常)。
同时新增 required_points 与最近一次核账快照字段。可重复执行:逐列检查。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261019_0061"
down_revision: str | None = "20261019_0060"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "huya_external_orders"
def _columns(bind) -> set[str]:
return {column["name"] for column in sa.inspect(bind).get_columns(TABLE)}
def upgrade() -> None:
bind = op.get_bind()
if TABLE not in set(sa.inspect(bind).get_table_names()):
return
columns = _columns(bind)
additions = {
"required_points": sa.Column("required_points", sa.Integer(), nullable=True),
"points_checked_at": sa.Column(
"points_checked_at", sa.DateTime(), nullable=True
),
"points_balance": sa.Column("points_balance", sa.Integer(), nullable=True),
"points_required_total": sa.Column(
"points_required_total", sa.Integer(), nullable=True
),
"points_surplus": sa.Column("points_surplus", sa.Integer(), nullable=True),
"points_threshold": sa.Column("points_threshold", sa.Integer(), nullable=True),
"points_check_reason": sa.Column(
"points_check_reason",
sa.String(length=16),
nullable=False,
server_default="",
),
"points_check_message": sa.Column(
"points_check_message",
sa.String(length=512),
nullable=False,
server_default="",
),
"points_check_detail": sa.Column(
"points_check_detail", sa.JSON(), nullable=True
),
}
for name, column in additions.items():
if name not in columns:
op.add_column(TABLE, column)
op.alter_column(
TABLE,
"status",
existing_type=sa.String(length=16),
existing_nullable=False,
server_default="incomplete",
)
# 旧 pending/bound:资料齐全的转待充值,其余退回待补资料。
bind.execute(
sa.text(
f"""
UPDATE {TABLE}
SET status = 'to_recharge'
WHERE status IN ('pending', 'bound')
AND cookie IS NOT NULL AND cookie <> ''
AND ck_status = 'valid'
AND bind_game_account IS NOT NULL AND bind_game_account <> ''
AND bind_game_role IS NOT NULL AND bind_game_role <> ''
AND gift_name IS NOT NULL AND gift_name <> ''
AND home_images IS NOT NULL
AND JSON_LENGTH(home_images) > 0
"""
)
)
bind.execute(
sa.text(
f"""
UPDATE {TABLE}
SET status = 'incomplete'
WHERE status IN ('pending', 'bound')
"""
)
)
def downgrade() -> None:
bind = op.get_bind()
if TABLE not in set(sa.inspect(bind).get_table_names()):
return
# 新状态尽量映射回旧口径;历史 pending 不恢复。
bind.execute(
sa.text(f"UPDATE {TABLE} SET status = 'bound' WHERE status = 'to_recharge'")
)
bind.execute(
sa.text(
f"UPDATE {TABLE} SET status = 'recharged' WHERE status = 'points_error'"
)
)
op.alter_column(
TABLE,
"status",
existing_type=sa.String(length=16),
existing_nullable=False,
server_default="pending",
)
columns = _columns(bind)
for column in (
"points_check_detail",
"points_check_message",
"points_check_reason",
"points_threshold",
"points_surplus",
"points_required_total",
"points_balance",
"points_checked_at",
"required_points",
):
if column in columns:
op.drop_column(TABLE, column)
+21 -1
View File
@@ -1157,11 +1157,14 @@ class HuyaExternalOrder(Base):
# ---- Excel G 列(表头写的「备注」,实际是礼包名)+ 自由备注 ----
gift_name: Mapped[str] = mapped_column(String(128), default="", server_default="")
# 购买该礼包所需积分快照(来自积分中心 queryAllocation 的 tag75.tag0.tag2)。
# 积分会随活动/商品变化,必须在下单时存一份,不能每次按礼包名现查。
required_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
remark: Mapped[str] = mapped_column(String(512), default="", server_default="")
# ---- 业务进度 ----
status: Mapped[str] = mapped_column(
String(16), default="pending", server_default="pending", index=True
String(16), default="incomplete", server_default="incomplete", index=True
)
bound_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
recharged_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
@@ -1169,6 +1172,23 @@ class HuyaExternalOrder(Base):
done_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
void_reason: Mapped[str] = mapped_column(String(256), default="", server_default="")
# ---- 充值核账快照(按 bind_game_account 整组计算)----
points_checked_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
points_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_threshold: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_check_reason: Mapped[str] = mapped_column(
String(16), default="", server_default=""
)
points_check_message: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
# 组内订单、各 CK 余额、缺口/多余等明细(JSON,只做展示与审计)
points_check_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# ---- 归档(与业务状态正交:归档后仍是「抢购成功」,只是默认不出现在主台账)----
# NULL = 未归档;软隐藏不删数据,取消归档即可恢复。
archived_at: Mapped[datetime | None] = mapped_column(
+26 -2
View File
@@ -57,6 +57,8 @@ from ...schemas import (
HuyaExternalOrderListOut,
HuyaExternalOrderOut,
HuyaExternalOrderOwnerOut,
HuyaExternalOrderRechargeOut,
HuyaExternalOrderRechargeRequest,
HuyaExternalOrderStatusBatchOut,
HuyaExternalOrderStatusBatchRequest,
HuyaExternalOrderStatusRequest,
@@ -317,6 +319,7 @@ def create_external_order(
ck_text=req.ck_text,
bind_douyu_account=req.bind_douyu_account,
gift_name=req.gift_name,
required_points=req.required_points,
home_images=req.home_images,
remark=req.remark,
require_verified=req.require_verified,
@@ -374,6 +377,27 @@ def preview_external_order_ck(
return preview
@router.post("/external-orders/recharge", response_model=HuyaExternalOrderRechargeOut)
def recharge_external_orders(
req: HuyaExternalOrderRechargeRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""充值核账:按绑定账号整组校验 CK + 积分,整组落「已充值 / 积分异常」。"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
result = collab.check_recharge_groups(
db,
current,
orders,
threshold=req.threshold,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="orders.recharge")
return result
@router.post(
"/external-orders/status/batch", response_model=HuyaExternalOrderStatusBatchOut
)
@@ -384,8 +408,8 @@ def batch_update_external_order_status(
):
"""勾选多行一次改业务状态(单个也走这里)。
人工切换允许**往前跳步**(待处理 → 已上脚本),回退要带 ``rollback=true``;
某一行前置校验没过(如没主页截图不能进「已确认绑定」)只记在这一条的 ``message`` 里。
人工切换允许**往前跳步**(待补资料 → 已上脚本),回退要带 ``rollback=true``;
「已充值 / 积分异常」不走这里,必须用 ``POST /external-orders/recharge`` 整组核账。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
+56 -2
View File
@@ -1141,7 +1141,7 @@ class HuyaCollabBuyerOrderOut(BaseModel):
shop: str = ""
gift_name: str = ""
uid: str = ""
status: str = "pending"
status: str = "incomplete"
status_label: str = ""
created_at: datetime | None = None
@@ -1183,6 +1183,7 @@ class HuyaExternalOrderCreate(BaseModel):
ck_text: str = ""
bind_douyu_account: str = Field("", max_length=128)
gift_name: str = Field("", max_length=128)
required_points: int | None = Field(None, ge=0)
# 主页截图(object_key 数组,第一张作封面)。绑定图不再收:绑定关系由探针自动读,
# 人工截图没有意义(2026-10-16 用户拍板)。
home_images: list[str] = Field(default_factory=list)
@@ -1198,6 +1199,7 @@ class HuyaExternalOrderUpdate(BaseModel):
bind_douyu_account: str | None = Field(None, max_length=128)
bind_douyu_uid: str | None = Field(None, max_length=32)
gift_name: str | None = Field(None, max_length=128)
required_points: int | None = Field(None, ge=0)
remark: str | None = Field(None, max_length=512)
# 乐观锁:客户端拿到的版本号。不一致说明别人先改了 → 409。
expected_version: int | None = None
@@ -1325,6 +1327,49 @@ class HuyaExternalOrderStatusBatchOut(BaseModel):
results: list[HuyaExternalOrderStatusBatchItemOut] = Field(default_factory=list)
class HuyaExternalOrderRechargeRequest(BaseModel):
"""充值核账请求:按绑定账号整组核账,阈值默认 100。"""
ids: list[int] = Field(default_factory=list)
threshold: int = Field(100, ge=0, le=1_000_000)
class HuyaExternalOrderRechargeOrderOut(BaseModel):
id: int
order_no: str = ""
status: str = ""
status_label: str = ""
class HuyaExternalOrderRechargeGroupOut(BaseModel):
group_key: str = ""
status: str = ""
status_label: str = ""
balance: int | None = None
required_total: int | None = None
surplus: int | None = None
threshold: int = 100
reason: str = ""
message: str = ""
order_ids: list[int] = Field(default_factory=list)
orders: list[HuyaExternalOrderRechargeOrderOut] = Field(default_factory=list)
class HuyaExternalOrderRechargeFailureOut(BaseModel):
id: int
order_no: str = ""
message: str = ""
class HuyaExternalOrderRechargeOut(BaseModel):
total: int
updated: int
failed: int
threshold: int = 100
groups: list[HuyaExternalOrderRechargeGroupOut] = Field(default_factory=list)
failures: list[HuyaExternalOrderRechargeFailureOut] = Field(default_factory=list)
class HuyaExternalOrderArchiveItemOut(BaseModel):
"""归档/取消归档里的一行结论。"""
@@ -1534,15 +1579,24 @@ class HuyaExternalOrderOut(BaseModel):
home_image_thumb_urls: list[str] = Field(default_factory=list)
gift_name: str = ""
required_points: int | None = None
remark: str = ""
status: str = "pending"
status: str = "incomplete"
status_label: str = ""
next_status: str | None = None
bound_at: datetime | None = None
recharged_at: datetime | None = None
scripted_at: datetime | None = None
done_at: datetime | None = None
points_checked_at: datetime | None = None
points_balance: int | None = None
points_required_total: int | None = None
points_surplus: int | None = None
points_threshold: int | None = None
points_check_reason: str = ""
points_check_message: str = ""
points_check_detail: dict[str, Any] | None = None
void_reason: str = ""
# 归档是可见性状态,不影响上面的业务状态。
File diff suppressed because it is too large Load Diff
+8
View File
@@ -52,6 +52,8 @@ import type {
HuyaCollabOrderQuery,
HuyaCollabOrderUpdate,
HuyaCollabOwner,
HuyaCollabRechargeRequest,
HuyaCollabRechargeResult,
HuyaCollabShop,
HuyaCollabShopCreate,
HuyaCollabShopUpdate,
@@ -284,6 +286,12 @@ export const huyaApi = {
'/huya/external-orders/status/batch',
data,
),
/** 充值核账:按绑定账号整组校验 CK + 积分,整组落「已充值 / 积分异常」 */
rechargeCollabOrders: (data: HuyaCollabRechargeRequest) =>
api.post<HuyaCollabRechargeResult, HuyaCollabRechargeResult>(
'/huya/external-orders/recharge',
data,
),
/** 归档抢购成功的订单(可带备注;archive_filtered 时按当前筛选批量归档) */
archiveCollabOrders: (data: HuyaCollabArchiveRequest) =>
api.post<HuyaCollabArchiveResult, HuyaCollabArchiveResult>(
+60 -3
View File
@@ -1202,11 +1202,13 @@ export interface AccountExchangeRecords {
export type HuyaCollabStatus =
/** 只登记了订单号 + 店铺,CK / 礼包 / 主页截图等客户资料还没到 */
| 'incomplete'
| 'pending'
| 'bound'
/** 资料齐全且 CK 有效;等充值 */
| 'to_recharge'
| 'recharged'
| 'scripted'
| 'done'
/** 充值完成但积分不足或超出阈值(可重新核账恢复) */
| 'points_error'
/** 旁路终态:抢购后查兑换记录没查到这一单的礼包(人工确认后可作废或回退重试) */
| 'failed'
| 'void';
@@ -1252,6 +1254,8 @@ export interface HuyaCollabOrder {
bind_image_thumb_urls: string[];
home_image_thumb_urls: string[];
gift_name: string;
/** 购买该礼包所需积分快照(来自积分中心 queryAllocation) */
required_points: number | null;
remark: string;
status: HuyaCollabStatus;
status_label: string;
@@ -1260,6 +1264,14 @@ export interface HuyaCollabOrder {
recharged_at: string | null;
scripted_at: string | null;
done_at: string | null;
points_checked_at: string | null;
points_balance: number | null;
points_required_total: number | null;
points_surplus: number | null;
points_threshold: number | null;
points_check_reason: string;
points_check_message: string;
points_check_detail: Record<string, unknown> | null;
void_reason: string;
/** 归档是可见性状态,不改业务状态;归档行只读 */
is_archived: boolean;
@@ -1430,6 +1442,49 @@ export interface HuyaCollabStatusBatchResult {
results: HuyaCollabStatusBatchItem[];
}
/** 充值核账:按绑定账号整组核账 */
export interface HuyaCollabRechargeRequest {
ids: number[];
/** 允许结余上限,默认 100 */
threshold: number;
}
export interface HuyaCollabRechargeOrder {
id: number;
order_no: string;
status: HuyaCollabStatus;
status_label: string;
}
export interface HuyaCollabRechargeGroup {
group_key: string;
status: HuyaCollabStatus | '';
status_label: string;
balance: number | null;
required_total: number | null;
surplus: number | null;
threshold: number;
reason: string;
message: string;
order_ids: number[];
orders: HuyaCollabRechargeOrder[];
}
export interface HuyaCollabRechargeFailure {
id: number;
order_no: string;
message: string;
}
export interface HuyaCollabRechargeResult {
total: number;
updated: number;
failed: number;
threshold: number;
groups: HuyaCollabRechargeGroup[];
failures: HuyaCollabRechargeFailure[];
}
/** 归档请求:ids 为空且 archive_filtered=true 时按筛选范围归档 */
export interface HuyaCollabArchiveRequest {
ids?: number[];
@@ -1540,6 +1595,7 @@ export interface HuyaCollabOrderCreate {
ck_text?: string;
bind_douyu_account?: string;
gift_name?: string;
required_points?: number | null;
/** 主页截图(object_key 数组,第一张作封面);严格模式下必填至少 1 张 */
home_images?: string[];
remark?: string;
@@ -1556,6 +1612,7 @@ export interface HuyaCollabOrderUpdate {
bind_douyu_account?: string;
bind_douyu_uid?: string;
gift_name?: string;
required_points?: number | null;
remark?: string;
expected_version?: number;
}
@@ -1627,7 +1684,7 @@ export interface FileUploadResult {
size: number;
}
/** 活动礼包(用一份有效 CK 现拉) */
/** 积分中心商品(用一份有效 CK 现拉;PlayMallUI.queryAllocation) */
export interface HuyaCollabGift {
product_id: string;
name: string;
+322 -91
View File
@@ -9,14 +9,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, App, Badge, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
Empty, Form, Image, Input, InputNumber, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
Switch, Table, Tag, Timeline, Tooltip, Typography, Upload,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileAddOutlined,
FileExcelOutlined, GiftOutlined, InboxOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined,
ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
AuditOutlined, CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
FileAddOutlined, FileExcelOutlined, GiftOutlined, InboxOutlined, LinkOutlined, PlusOutlined, ReloadOutlined,
SafetyCertificateOutlined, ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
@@ -70,13 +70,14 @@ function readRealtimePaused(): boolean {
const STATUS_META: Record<HuyaCollabStatus, { label: string; color: string }> = {
// 只登记了订单号 + 店铺,客户资料(CK / 礼包 / 主页截图)到了再补
incomplete: { label: '待补资料', color: 'gold' },
pending: { label: '待处理', color: 'default' },
// 人工核对过「探针读到的绑定角色」与「主页截图」一致
bound: { label: '已确认绑定', color: 'blue' },
// 资料齐全且 CK 有效;等充值
to_recharge: { label: '待充值', color: 'orange' },
recharged: { label: '已充值', color: 'geekblue' },
scripted: { label: '已上脚本', color: 'purple' },
// 抢购成功(枚举值仍是 done):从「已上脚本」查到兑换记录 → 自动进这一步
done: { label: '抢购成功', color: 'success' },
// 充值完成但积分不足/超出阈值
points_error: { label: '积分异常', color: 'volcano' },
// 旁路终态:抢购后查兑换记录没查到这一单的礼包(枚举值仍是 failed)
failed: { label: '抢购失败', color: 'red' },
void: { label: '已作废', color: 'error' },
@@ -92,9 +93,8 @@ const VERDICT_META: Record<string, { label: string; color: string }> = {
/** 「改状态」能选的目标(顺序 = 业务流程顺序,回退要显式勾选)
* 「待补资料」只作为新建起点,不放进「改状态」:它只能通过「回退状态」进入。 */
const STATUS_ORDER_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'pending', label: '待处理' },
{ value: 'bound', label: '已确认绑定' },
{ value: 'recharged', label: '已充值' },
{ value: 'to_recharge', label: '待充值' },
{ value: 'recharged', label: '已充值(核账)' },
{ value: 'scripted', label: '已上脚本' },
{ value: 'done', label: '抢购成功' },
{ value: 'failed', label: '抢购失败' },
@@ -104,9 +104,8 @@ const STATUS_ORDER_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
/** 「回退」能选的目标:流程链(含「待补资料」;要进「已作废 / 抢购失败」走「改状态」或「作废」入口) */
const FLOW_STATUS_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'incomplete', label: '待补资料' },
{ value: 'pending', label: '待处理' },
{ value: 'bound', label: '已确认绑定' },
{ value: 'recharged', label: '已充值' },
{ value: 'to_recharge', label: '待充值' },
{ value: 'recharged', label: '已充值(核账)' },
{ value: 'scripted', label: '已上脚本' },
{ value: 'done', label: '抢购成功' },
];
@@ -163,8 +162,21 @@ const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_ima
const IMPORT_PREVIEW_ROWS = 20;
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
// 要更新就点「强制刷新礼包」(活动换 SID 后必点一次)。
const GIFTS_CACHE_KEY = 'huya.collab.gifts';
// v2 = 2026-10-19 切到积分中心 queryAllocation 后换键,避免继续用旧 getActPrizeList 的缓存。
const GIFTS_CACHE_KEY = 'huya.collab.gifts.v2';
const RECHARGE_THRESHOLD_KEY = 'huya.collab.rechargeThreshold';
const RECHARGE_THRESHOLD_DEFAULT = 100;
function readRechargeThreshold(): number {
try {
const raw = window.localStorage.getItem(RECHARGE_THRESHOLD_KEY);
if (raw === null || raw === '') return RECHARGE_THRESHOLD_DEFAULT;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : RECHARGE_THRESHOLD_DEFAULT;
} catch {
return RECHARGE_THRESHOLD_DEFAULT;
}
}
const MAX_IMAGES_PER_SLOT = 20;
/** 读礼包缓存(读不到 / 解析坏 / 空列表都当没缓存) */
@@ -201,6 +213,7 @@ interface CreateDraft {
shop: string;
ck_text: string;
gift_name: string;
required_points: number | null;
remark: string;
home_images: string[];
saved_at: number;
@@ -229,6 +242,7 @@ function readCreateDraft(userId?: number | null): CreateDraft | null {
shop: typeof parsed.shop === 'string' ? parsed.shop : '',
ck_text: typeof parsed.ck_text === 'string' ? parsed.ck_text : '',
gift_name: typeof parsed.gift_name === 'string' ? parsed.gift_name : '',
required_points: typeof parsed.required_points === 'number' ? parsed.required_points : null,
remark: typeof parsed.remark === 'string' ? parsed.remark : '',
home_images: Array.isArray(parsed.home_images)
? parsed.home_images.filter((item): item is string => typeof item === 'string')
@@ -287,6 +301,7 @@ interface SupplementDraft {
shop: string;
ck_text: string;
gift_name: string;
required_points: number | null;
remark: string;
home_images: string[];
saved_at: number;
@@ -296,6 +311,7 @@ interface SupplementFormValues {
shop: string;
ck_text?: string;
gift_name?: string;
required_points?: number | null;
remark?: string;
home_images?: string[];
}
@@ -320,6 +336,7 @@ function readSupplementDraft(userId: number | null | undefined, orderId: number)
shop: typeof parsed.shop === 'string' ? parsed.shop : '',
ck_text: typeof parsed.ck_text === 'string' ? parsed.ck_text : '',
gift_name: typeof parsed.gift_name === 'string' ? parsed.gift_name : '',
required_points: typeof parsed.required_points === 'number' ? parsed.required_points : null,
remark: typeof parsed.remark === 'string' ? parsed.remark : '',
home_images: Array.isArray(parsed.home_images)
? parsed.home_images.filter((item): item is string => typeof item === 'string')
@@ -510,6 +527,8 @@ export default function HuyaCollabPage() {
const [realtimePending, setRealtimePending] = useState(false);
const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// 点顶部统计卡筛选后滚到列表,避免用户还要自己往下找
const listAnchorRef = useRef<HTMLDivElement | null>(null);
const [detail, setDetail] = useState<HuyaCollabOrder | null>(null);
const [timeline, setTimeline] = useState<HuyaCollabAuditItem[]>([]);
@@ -560,12 +579,16 @@ export default function HuyaCollabPage() {
order: HuyaCollabOrder; mode: 'void' | 'rollback' | 'set'; target?: HuyaCollabStatus;
} | null>(null);
const [statusReason, setStatusReason] = useState('');
const [rollbackTarget, setRollbackTarget] = useState<HuyaCollabStatus>('pending');
const [rollbackTarget, setRollbackTarget] = useState<HuyaCollabStatus>('to_recharge');
// 批量改状态(勾选多行一次切)
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('bound');
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('to_recharge');
const [batchStatusReason, setBatchStatusReason] = useState('');
const [batchStatusRollback, setBatchStatusRollback] = useState(false);
// 充值核账弹窗:按 bind_game_account 整组核账,阈值默认 100
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeIds, setRechargeIds] = useState<number[]>([]);
const [rechargeThreshold, setRechargeThreshold] = useState(readRechargeThreshold);
// 归档弹窗:ids=null 表示按当前筛选结果批量归档
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveIds, setArchiveIds] = useState<number[] | null>(null);
@@ -634,7 +657,7 @@ export default function HuyaCollabPage() {
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
const realtimeSyncEnabled = realtimeEnabled && !realtimePaused;
const collabModalOpen = Boolean(
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen,
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen,
);
const toggleRealtimeSync = (nextEnabled: boolean) => {
@@ -905,6 +928,11 @@ export default function HuyaCollabPage() {
/** 勾选多行一次改状态(往前可跳步;回退要在弹窗里勾「回退」) */
const submitBatchStatus = async () => {
if (batchStatusTarget === 'recharged') {
setBatchStatusOpen(false);
openRecharge(selectedIds);
return;
}
setBusy(true);
try {
const result = await huyaApi.batchUpdateCollabOrderStatus({
@@ -1095,6 +1123,7 @@ export default function HuyaCollabPage() {
shop: row.shop,
owner_id: row.owner_id,
gift_name: row.gift_name,
required_points: row.required_points,
remark: row.remark,
});
setTimeline([]);
@@ -1172,8 +1201,75 @@ export default function HuyaCollabPage() {
}
};
const openRecharge = (ids: number[]) => {
if (ids.length === 0) return;
setRechargeIds(ids);
setRechargeThreshold(readRechargeThreshold());
setRechargeOpen(true);
};
const submitRecharge = async () => {
if (rechargeIds.length === 0) return;
setBusy(true);
try {
const threshold = Math.max(0, Number(rechargeThreshold) || 0);
try {
window.localStorage.setItem(RECHARGE_THRESHOLD_KEY, String(threshold));
} catch {
// 隐私模式 / 配额满:记不住阈值不影响本次核账
}
const result = await huyaApi.rechargeCollabOrders({
ids: rechargeIds,
threshold,
});
const lines = result.groups.map((group) => ({
key: group.group_key,
ok: Boolean(group.status),
text: group.status
? `${group.group_key}:${group.message}`
: `${group.group_key}:${group.message}`,
}));
if (lines.length === 0) {
message.warning('没有可核账的订单');
} else {
modal.info({
title: `核账完成:更新 ${result.updated} 条,失败 ${result.failed} 条`,
width: 680,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{lines.map((line) => (
<Text key={line.key} type={line.ok ? undefined : 'danger'}>
{line.text}
</Text>
))}
{result.failures.map((item) => (
<Text key={`failure-${item.id}`} type="danger">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
setRechargeOpen(false);
setSelectedIds([]);
if (detail && rechargeIds.includes(detail.id)) {
void openDetail(detail);
}
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const advance = async (row: HuyaCollabOrder) => {
if (!row.next_status) return;
if (row.next_status === 'recharged') {
openRecharge([row.id]);
return;
}
setBusy(true);
try {
applyUpdated(await huyaApi.updateCollabOrderStatus(row.id, {
@@ -1194,12 +1290,18 @@ export default function HuyaCollabPage() {
* 单行状态弹窗的提交(三种模式共用):
* - `void`:作废(必须填原因);
* - `rollback`:回退到选定状态(`rollback=true`,会清掉后面阶段的时间戳);
* - `set`:人工往前切,允许跳步(如 待处理 → 已上脚本),不用勾回退。
* - `set`:人工往前切,允许跳步(如 待补资料 → 已上脚本),不用勾回退。
*/
const submitStatusAction = async () => {
if (!statusAction) return;
const { order, mode, target } = statusAction;
const nextStatus: HuyaCollabStatus = mode === 'void' ? 'void' : (target ?? rollbackTarget);
if (nextStatus === 'recharged') {
setStatusAction(null);
setStatusReason('');
openRecharge([order.id]);
return;
}
// 服务层对「作废」一律要求原因(不管从哪个入口进来的)
if (nextStatus === 'void' && !statusReason.trim()) {
message.warning('作废必须填原因');
@@ -1317,6 +1419,7 @@ export default function HuyaCollabPage() {
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
gift_name: String(values.gift_name ?? ''),
required_points: typeof values.required_points === 'number' ? values.required_points : null,
remark: String(values.remark ?? ''),
home_images: images,
};
@@ -1324,6 +1427,7 @@ export default function HuyaCollabPage() {
payload.ck_text.trim()
|| payload.shop !== target.shop
|| payload.gift_name !== target.gift_name
|| payload.required_points !== target.required_points
|| payload.remark !== target.remark
|| payload.home_images.join('\n') !== target.home_images.join('\n'),
);
@@ -1353,6 +1457,7 @@ export default function HuyaCollabPage() {
shop: row.shop,
ck_text: '',
gift_name: row.gift_name,
required_points: row.required_points,
remark: row.remark,
home_images: row.home_images,
};
@@ -1389,6 +1494,7 @@ export default function HuyaCollabPage() {
shop: supplementTarget.shop,
ck_text: '',
gift_name: supplementTarget.gift_name,
required_points: supplementTarget.required_points,
remark: supplementTarget.remark,
home_images: supplementTarget.home_images,
});
@@ -1396,7 +1502,7 @@ export default function HuyaCollabPage() {
/**
* 保存「补资料」:字段 / 新 CK / 截图分别走各自接口(每个都有乐观锁),
* 新 CK 保存后立即跑一次只读探针;用户点「保存并进入待处理」时再校验资料完整性。
* 新 CK 保存后立即跑一次只读探针;用户点「保存并进入待充值」时再校验资料完整性。
*/
const submitSupplement = async (markPending = false) => {
if (!supplementTarget) return;
@@ -1412,9 +1518,11 @@ export default function HuyaCollabPage() {
const payload: HuyaCollabOrderUpdate = {};
const shop = String(values.shop ?? '').trim();
const giftName = String(values.gift_name ?? '').trim();
const requiredPoints = typeof values.required_points === 'number' ? values.required_points : null;
const remark = String(values.remark ?? '');
if (shop !== current.shop) payload.shop = shop;
if (giftName !== current.gift_name) payload.gift_name = giftName;
if (requiredPoints !== current.required_points) payload.required_points = requiredPoints;
if (remark !== current.remark) payload.remark = remark;
if (Object.keys(payload).length > 0) {
current = await huyaApi.updateCollabOrder(current.id, {
@@ -1442,7 +1550,7 @@ export default function HuyaCollabPage() {
});
}
// 没有换 CK、但准备进「待处理」时,先跑一次只读探针;避免拿一份没验证的旧 CK 往前推
// 没有换 CK、但准备检查资料是否齐全时,先跑一次只读探针;避免拿一份没验证的旧 CK 往前推
if (markPending && !ckText && current.has_cookie && current.ck_status !== 'valid') {
current = await huyaApi.verifyCollabOrder(current.id);
}
@@ -1453,16 +1561,10 @@ export default function HuyaCollabPage() {
supplementForm.setFieldValue('ck_text', '');
void load();
message.warning(
'资料已保存,但这份 CK 还没验证通过,暂时不能进入「待处理」;请先补 / 换一份有效 CK',
'资料已保存,但这份 CK 还没验证通过,暂时不能进入「待充值」;请先补 / 换一份有效 CK',
);
return;
}
if (markPending) {
current = await huyaApi.updateCollabOrderStatus(current.id, {
status: 'pending',
expected_version: current.version,
});
}
applyUpdated(current);
clearSupplementDraft(user?.id, supplementTarget.id);
@@ -1471,7 +1573,9 @@ export default function HuyaCollabPage() {
setSupplementTarget(null);
message.success(
markPending
? `已补齐资料,${current.order_no} 进入「待处理」`
? current.status === 'to_recharge'
? `资料已补齐,${current.order_no} 已进入「待充值」`
: `资料已保存;绑定账号/角色、礼包、主页截图齐全后会自动进入「待充值」`
: `已保存 ${current.order_no} 的资料`,
);
void load();
@@ -1541,6 +1645,7 @@ export default function HuyaCollabPage() {
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
gift_name: String(values.gift_name ?? ''),
required_points: typeof values.required_points === 'number' ? values.required_points : null,
remark: String(values.remark ?? ''),
home_images: Array.isArray(values.home_images)
? (values.home_images as unknown[]).filter((item): item is string => typeof item === 'string')
@@ -1600,6 +1705,7 @@ export default function HuyaCollabPage() {
const orderNo = String(values.order_no ?? '').trim();
const ckText = String(values.ck_text ?? '').trim();
const giftName = String(values.gift_name ?? '').trim();
const requiredPoints = typeof values.required_points === 'number' ? values.required_points : null;
const homeImages = (values.home_images as string[] | undefined) ?? [];
const missing: string[] = [];
if (!ckText) missing.push('外部 CK');
@@ -1613,6 +1719,7 @@ export default function HuyaCollabPage() {
ck_text: ckText,
// 「绑定斗鱼号」不再录入:绑了谁由探针读(bind_game_*),人工那一列已下线
gift_name: giftName,
required_points: requiredPoints,
home_images: homeImages,
remark: String(values.remark ?? ''),
draft,
@@ -1627,7 +1734,7 @@ export default function HuyaCollabPage() {
// 首次建草稿单给一次明确提示:以后直接点「新建」就行,不用先勾任何东西
message.info(
`已按「待补资料」创建 ${created.order_no}:${reason}。`
+ '之后在详情里补齐,再点「标记为待处理」即可。',
+ '之后在详情里补齐,资料齐全并验证 CK 后会自动进入「待充值」。',
8,
);
} else {
@@ -1685,6 +1792,32 @@ export default function HuyaCollabPage() {
// 筛选里的店铺也是点选(含停用店铺:历史订单还在用它们)
const finished = summary ? (summary.by_status.done ?? 0) : 0;
/** 顶部统计卡点一下 = 筛到对应状态,并滚回列表顶部。 */
const scrollToList = () => {
window.setTimeout(() => {
listAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
};
const applyStatusCard = (status?: HuyaCollabStatus) => {
setPage(1);
setFilters((prev) => ({ ...prev, status, ck_status: undefined }));
scrollToList();
};
const applyCkInvalidCard = () => {
setPage(1);
setFilters((prev) => ({ ...prev, status: undefined, ck_status: 'invalid' }));
scrollToList();
};
const statusCardActive = (status: HuyaCollabStatus) =>
filters.status === status && !filters.ck_status;
const allCardActive = !filters.status && !filters.ck_status;
const ckInvalidCardActive = filters.ck_status === 'invalid';
const cardStyle = (active: boolean) => ({
cursor: 'pointer',
borderColor: active ? '#1677ff' : undefined,
boxShadow: active ? '0 0 0 2px rgba(22, 119, 255, 0.16)' : undefined,
});
// 礼包来源提示:这次真去虎牙拉了就说拉了,用的是缓存就把缓存时间摆出来(让人知道要不要点强制刷新)
const giftCacheLabel = giftsCachedAt ? formatTime(new Date(giftsCachedAt).toISOString()) : '';
const giftSourceText = ckPreview?.gifts?.length
@@ -1714,6 +1847,13 @@ export default function HuyaCollabPage() {
return options;
};
/** 礼包名对应的积分单价;缓存里没有 / 没价格时返回 null(后端核账时还会按名称补一次)。 */
const giftRequiredPoints = (name?: string | null): number | null => {
const value = String(name ?? '').trim();
if (!value) return null;
return giftOptions.find((gift) => gift.name === value)?.price ?? null;
};
/**
* 「常用礼包」快捷按钮(2026-10-18 用户要求):
* 礼包名**默认空白**(不再自动带上次选的那个,避免没注意时建错单),
@@ -1968,7 +2108,10 @@ export default function HuyaCollabPage() {
labelRender={() => renderGiftSelectedLabel(row.gift_name)}
styles={{ content: { whiteSpace: 'normal', textOverflow: 'clip', lineHeight: 1.3 } }}
notFoundContent={giftOptions.length === 0 ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' : '没有匹配的礼包'}
onChange={(next: string) => void patchOrder(row, { gift_name: next })}
onChange={(next: string) => void patchOrder(row, {
gift_name: next,
required_points: giftRequiredPoints(next),
})}
/>
<EditableText value={row.remark} disabled={!canEdit || row.is_archived} placeholder="+ 备注"
onCommit={(next) => void patchOrder(row, { remark: next })} />
@@ -2128,7 +2271,7 @@ export default function HuyaCollabPage() {
setStatusAction({ order: row, mode: 'set' });
}
if (key === 'rollback') {
setRollbackTarget('pending');
setRollbackTarget('to_recharge');
setStatusReason('');
setStatusAction({ order: row, mode: 'rollback' });
}
@@ -2157,52 +2300,42 @@ export default function HuyaCollabPage() {
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{/* 卡片用 flex 平分:状态多了一个「抢购失败」,固定 span=4 会换行参差不齐 */}
<Row gutter={12}>
<Col flex="1"><Card size="small"><Statistic title="全部" value={summary?.total ?? 0} /></Card></Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.incomplete.label} value={summary?.by_status.incomplete ?? 0}
valueStyle={{ color: '#fa8c16' }} />
{/* 卡片用 flex 平分并允许换行:状态多,固定 span 会参差不齐 */}
<Row gutter={[8, 8]}>
<Col flex="1 1 104px">
<Card size="small" hoverable style={cardStyle(allCardActive)} onClick={() => applyStatusCard(undefined)}>
<Statistic title="全部" value={summary?.total ?? 0} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title="待处理" value={summary?.by_status.pending ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.bound.label} value={summary?.by_status.bound ?? 0}
valueStyle={{ color: '#1677ff' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.recharged.label} value={summary?.by_status.recharged ?? 0}
valueStyle={{ color: '#2f54eb' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.scripted.label} value={summary?.by_status.scripted ?? 0}
valueStyle={{ color: '#722ed1' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.done.label} value={finished} valueStyle={{ color: '#52c41a' }} />
<Progress percent={summary?.total ? Math.round((finished / summary.total) * 100) : 0} size="small" />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.failed.label} value={summary?.by_status.failed ?? 0}
valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
{([
['incomplete', '#fa8c16'],
['to_recharge', '#faad14'],
['recharged', '#2f54eb'],
['scripted', '#722ed1'],
['done', '#52c41a'],
['failed', '#ff4d4f'],
['points_error', '#fa541c'],
] as const).map(([status, color]) => (
<Col key={status} flex="1 1 104px">
<Card
size="small"
hoverable
style={cardStyle(statusCardActive(status))}
onClick={() => applyStatusCard(status)}
>
<Statistic
title={STATUS_META[status].label}
value={status === 'done' ? finished : (summary?.by_status[status] ?? 0)}
valueStyle={{ color }}
/>
{status === 'done' && (
<Progress percent={summary?.total ? Math.round((finished / summary.total) * 100) : 0} size="small" />
)}
</Card>
</Col>
))}
<Col flex="1 1 104px">
<Card size="small" hoverable style={cardStyle(ckInvalidCardActive)} onClick={applyCkInvalidCard}>
<Statistic title="CK 失效" value={summary?.ck_invalid ?? 0} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
@@ -2245,6 +2378,7 @@ export default function HuyaCollabPage() {
)}
</Row>
<div ref={listAnchorRef} style={{ scrollMarginTop: 8 }} />
<Card size="small">
{/*
固定两行:第一行永远是筛选,第二行永远是动作按钮组 —— 不要用「筛选 + 动作同一个 wrap」
@@ -2353,6 +2487,14 @@ export default function HuyaCollabPage() {
</Button>
</Dropdown>
)}
{canEdit && !isArchiveView && (
<Tooltip title="按绑定账号整组校验 CK 和积分,整组落到「已充值 / 积分异常」">
<Button icon={<AuditOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => openRecharge(selectedIds)}>
充值核账{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</Tooltip>
)}
{canEdit && !isArchiveView && (
// 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照,
// 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。
@@ -2401,7 +2543,7 @@ export default function HuyaCollabPage() {
// 上次填了一半:订单号 / 店铺 / CK / 截图 / 备注都原样恢复,并重新验证暂存的 CK。
// **礼包名一律留空**(2026-10-18 用户要求):礼包默认空白,不再带上次选的那个;
// 反正上面有「常用礼包」备选,点一下就有,不值得为它记住上一次的选择。
createForm.setFieldsValue({ ...draft, gift_name: '' });
createForm.setFieldsValue({ ...draft, gift_name: '', required_points: null });
setGiftHint('');
setDraftRestoredAt(draft.saved_at || Date.now());
setCreateOpen(true);
@@ -2506,15 +2648,27 @@ export default function HuyaCollabPage() {
description={(() => {
const missing = [
!detail.has_cookie ? '外部 CK' : '',
!detail.bind_game_account ? '绑定账号(先验证 CK)' : '',
!detail.bind_game_role ? '绑定角色(先验证 CK)' : '',
!detail.gift_name ? '礼包名' : '',
detail.home_images.length === 0 ? '主页截图' : '',
].filter(Boolean);
return missing.length > 0
? `还缺:${missing.join('、')}。补齐后点下面的「标记为待处理」按钮即可进入正常流程。`
: '资料已齐,点下面的「标记为待处理」按钮进入正常流程。';
? `还缺:${missing.join('、')}。资料齐全且 CK 有效后会自动进入「待充值」。`
: '资料已齐,验证 CK 后会自动进入「待充值」。';
})()}
/>
)}
{detail.status === 'points_error' && (
<Alert
type="error"
showIcon
message={detail.points_check_message || '积分异常:账号积分与礼包所需积分不匹配'}
description={detail.points_checked_at
? `最近核账 ${formatTime(detail.points_checked_at)};如已补足/调整积分,重新勾选后点「充值核账」。`
: '请重新勾选后点「充值核账」。'}
/>
)}
<Descriptions size="small" column={2} bordered
items={[
{ key: 'uid', label: '虎牙号', children: detail.uid || '-' },
@@ -2532,6 +2686,11 @@ export default function HuyaCollabPage() {
{ key: 'game_role', label: '绑定角色(探针)', children: detail.bind_game_role || '-' },
{ key: 'game_channel', label: '平台 / 区服', children: detail.bind_game_channel || '-' },
{ key: 'snapshot_at', label: '绑定快照时间', children: formatTime(detail.bind_snapshot_at) },
{ key: 'gift', label: '礼包 / 所需积分', children: `${detail.gift_name || '-'} / ${detail.required_points ?? '未记录'}` },
{ key: 'points_check', label: '最近核账(账号积分 / 礼包合计 / 结余 / 阈值)',
children: detail.points_checked_at
? `${detail.points_balance ?? '-'} / ${detail.points_required_total ?? '-'} / ${detail.points_surplus ?? '-'} / ${detail.points_threshold ?? '-'}(${formatTime(detail.points_checked_at)})`
: '-' },
{ key: 'status', label: '业务状态',
children: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
...(detail.is_archived
@@ -2595,7 +2754,7 @@ export default function HuyaCollabPage() {
size="small"
initialValues={{
order_no: detail.order_no, shop: detail.shop, owner_id: detail.owner_id,
gift_name: detail.gift_name, remark: detail.remark,
gift_name: detail.gift_name, required_points: detail.required_points, remark: detail.remark,
}}
onFinish={(values) => void patchOrder(detail, values)}
>
@@ -2627,9 +2786,19 @@ export default function HuyaCollabPage() {
notFoundContent={giftOptions.length === 0
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
: '没有匹配的礼包'}
onChange={(value?: string) => {
detailForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="required_points" label="礼包所需积分"
tooltip="选中礼包时自动带出积分中心价格;也可按实际活动手工修正,核账时按这个值合计">
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="未记录时核账会按礼包名自动补" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} maxLength={512} /></Form.Item>
</Col>
@@ -2702,7 +2871,7 @@ export default function HuyaCollabPage() {
改状态…
</Button>
<Button icon={<UndoOutlined />} disabled={busy}
onClick={() => { setRollbackTarget('pending'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
onClick={() => { setRollbackTarget('to_recharge'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
回退状态
</Button>
{detail.status !== 'void' && (
@@ -2939,7 +3108,10 @@ export default function HuyaCollabPage() {
<Row gutter={16}>
<Col span={14}>
{renderFrequentGiftChips(createGiftValue, (name) => {
createForm.setFieldsValue({ gift_name: name });
createForm.setFieldsValue({
gift_name: name,
required_points: giftRequiredPoints(name),
});
setGiftHint('');
})}
<Form.Item
@@ -2959,12 +3131,21 @@ export default function HuyaCollabPage() {
options={giftSelectOptions(createGiftValue)}
disabled={giftOptions.length === 0 && !createGiftValue}
notFoundContent={giftOptions.length === 0 ? '先在左侧贴一份有效 CK 拉取礼包列表' : '没有匹配的礼包'}
onChange={() => {
// 选了就清掉「上次那个礼包不在本次列表里」的提示
onChange={(value?: string) => {
// 选了就清掉「上次那个礼包不在本次列表里」的提示,并带出积分价格
setGiftHint('');
createForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
<Form.Item
name="required_points"
label="礼包所需积分(可稍后补)"
tooltip="选择礼包时自动带出积分中心价格;核账时按每单这个值合计。留空也能核账,后端会按礼包名补一次"
>
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="选中礼包自动带出" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
@@ -3015,7 +3196,7 @@ export default function HuyaCollabPage() {
保存资料
</Button>,
<Button key="pending" type="primary" onClick={() => void submitSupplement(true)} loading={busy}>
保存并进入待处理
保存并检查资料
</Button>,
]}
>
@@ -3040,10 +3221,14 @@ export default function HuyaCollabPage() {
const images = supplementHomeImages ?? supplementTarget.home_images;
const missing = [
!hasCk ? '外部 CK' : '',
!supplementTarget.bind_game_account ? '绑定账号(保存后验证 CK)' : '',
!supplementTarget.bind_game_role ? '绑定角色(保存后验证 CK)' : '',
!supplementGiftValue ? '礼包名' : '',
images.length === 0 ? '主页截图' : '',
].filter(Boolean);
return missing.length > 0 ? `当前还缺:${missing.join('、')}` : '资料已齐,可以直接「保存并进入待处理」。';
return missing.length > 0
? `当前还缺:${missing.join('、')}`
: '资料已齐,保存并验证 CK 后会自动进入「待充值」。';
})()}
/>
<Form
@@ -3065,7 +3250,10 @@ export default function HuyaCollabPage() {
<Col span={12}>
{renderFrequentGiftChips(
supplementGiftValue ?? supplementTarget.gift_name,
(name) => supplementForm.setFieldsValue({ gift_name: name }),
(name) => supplementForm.setFieldsValue({
gift_name: name,
required_points: giftRequiredPoints(name),
}),
)}
<Form.Item name="gift_name" label="礼包名"
tooltip="只接受礼包缓存里的点选结果,可输入关键字搜索;不接受手工输入">
@@ -3079,8 +3267,16 @@ export default function HuyaCollabPage() {
notFoundContent={giftOptions.length === 0
? '礼包缓存为空:先在「新建」里贴 CK 拉一次'
: '没有匹配的礼包'}
onChange={(value?: string) => {
supplementForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
<Form.Item name="required_points" label="礼包所需积分"
tooltip="选中礼包时自动带出积分中心价格;核账时按每单这个值合计">
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="选中礼包自动带出" />
</Form.Item>
</Col>
</Row>
<Form.Item
@@ -3190,6 +3386,41 @@ export default function HuyaCollabPage() {
</Space>
</Modal>
<Modal
title={`充值核账(已选 ${rechargeIds.length} 条)`}
open={rechargeOpen}
onCancel={() => setRechargeOpen(false)}
onOk={() => void submitRecharge()}
confirmLoading={busy}
okText="核账并落状态"
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="按绑定账号整组核账"
description="同一 bind_game_account 下的待充值/已充值/积分异常订单会一起核账;CK 失效或积分查不到时整组保持原状态。"
/>
<Space size={8} align="center">
<Text>允许结余阈值</Text>
<InputNumber
min={0}
max={1_000_000}
precision={0}
value={rechargeThreshold}
onChange={(value) => setRechargeThreshold(Number(value) || 0)}
addonAfter="积分"
style={{ width: 160 }}
/>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
结余 = 账号积分合计 - 礼包积分合计。结余小于 0 或大于阈值 → 整组「积分异常」;
其余 → 整组「已充值」。阈值默认 100。
</Text>
</Space>
</Modal>
<Modal
title={statusAction?.mode === 'void'
? `作废订单 ${statusAction.order.order_no}`
@@ -3225,8 +3456,8 @@ export default function HuyaCollabPage() {
} />
{statusAction?.mode === 'set' && (
<Text type="secondary">
人工切换允许往前跳步(比如 待处理 → 已上脚本),不用一步一步点;
切到「已确认绑定」要求先有绑定角色 + 主页截图,切到「已作废」必须填原因。
人工切换允许往前跳步(比如 待补资料 → 已上脚本),不用一步一步点;
选「已充值」会转为整组核账;切到「已作废」必须填原因。
</Text>
)}
{statusAction?.mode === 'rollback' && (
@@ -3287,10 +3518,10 @@ export default function HuyaCollabPage() {
onChange={(event) => setBatchStatusReason(event.target.value)}
placeholder={batchStatusTarget === 'void' ? '作废原因(必填)' : '改状态说明(可选)'} />
<Checkbox checked={batchStatusRollback} onChange={(event) => setBatchStatusRollback(event.target.checked)}>
回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待处理)
回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待充值)
</Checkbox>
<Text type="secondary">
逐行独立提交:某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只跳过那一行,
逐行独立提交:某一行前置校验没过(例如资料不全进不了「待充值」)只跳过那一行,
结果会在弹窗里逐条列出来。一次最多 50 行。
</Text>
</Space>