feat: 重做协作台账状态机并支持充值核账
This commit is contained in:
+96
-14
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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/*`;开工前需确认当前分支与库迁移版本对齐 |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user