diff --git a/core/huya/frame_decoder.py b/core/huya/frame_decoder.py index 74f9afb..c249934 100644 --- a/core/huya/frame_decoder.py +++ b/core/huya/frame_decoder.py @@ -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"" 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"" 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): diff --git a/core/huya/http_client.py b/core/huya/http_client.py index dba8707..6e611e1 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -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, diff --git a/core/huya/shop_structs.py b/core/huya/shop_structs.py index cb0c2d6..e731b59 100644 --- a/core/huya/shop_structs.py +++ b/core/huya/shop_structs.py @@ -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 系接口的应用信息。 diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py index 293490d..1fd8836 100644 --- a/core/huya/wss_client.py +++ b/core/huya/wss_client.py @@ -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, diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index afec521..d4084e4 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -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/*`;开工前需确认当前分支与库迁移版本对齐 | diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 34f1726..e610c3b 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -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): diff --git a/tests/test_migrations.py b/tests/test_migrations.py index bd84dab..87d3fc3 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261019_0060" +HEAD_REVISION = "20261019_0061" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20261019_0061_huya_order_recharge_points.py b/web/backend/migrations/versions/20261019_0061_huya_order_recharge_points.py new file mode 100644 index 0000000..140c81c --- /dev/null +++ b/web/backend/migrations/versions/20261019_0061_huya_order_recharge_points.py @@ -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) diff --git a/web/backend/models.py b/web/backend/models.py index d20cd68..1e794e6 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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( diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 0017438..eccfdb8 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -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) diff --git a/web/backend/schemas.py b/web/backend/schemas.py index a2e6f6d..aebbe8b 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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 = "" # 归档是可见性状态,不影响上面的业务状态。 diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index b898f5e..0fb3c2b 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -52,56 +52,58 @@ from .huya_service import build_web_cookie_from_raw, parse_huya_cookie_line # -------------------------------------------------------------------------- # 「待补资料」是流程链的起点:只登记订单号 + 店铺,CK / 礼包 / 主页截图等客户资料 -# 到了再补(2026-10-17 用户需求)。资料齐全后才能进「待处理」。 +# 到了再补(2026-10-17 用户需求)。资料齐全且 CK 有效后自动进入「待充值」。 STATUS_INCOMPLETE = "incomplete" -STATUS_PENDING = "pending" -STATUS_BOUND = "bound" +STATUS_TO_RECHARGE = "to_recharge" STATUS_RECHARGED = "recharged" STATUS_SCRIPTED = "scripted" STATUS_DONE = "done" +STATUS_POINTS_ERROR = "points_error" STATUS_FAILED = "failed" STATUS_VOID = "void" ORDER_STATUSES: tuple[str, ...] = ( STATUS_INCOMPLETE, - STATUS_PENDING, - STATUS_BOUND, + STATUS_TO_RECHARGE, STATUS_RECHARGED, STATUS_SCRIPTED, STATUS_DONE, + STATUS_POINTS_ERROR, STATUS_FAILED, STATUS_VOID, ) ORDER_STATUS_LABELS = { STATUS_INCOMPLETE: "待补资料", - STATUS_PENDING: "待处理", - # 人工核对过「探针读到的绑定角色」与「主页截图」一致,才允许进这一步(见 _assert_can_enter_bound) - STATUS_BOUND: "已确认绑定", + STATUS_TO_RECHARGE: "待充值", STATUS_RECHARGED: "已充值", STATUS_SCRIPTED: "已上脚本", # 抢购跑到结果:查兑换记录能确认(自动改)也可以人工切(2026-10-16 用户把「已完成」改叫这个) STATUS_DONE: "抢购成功", + # 充值完成但账号积分总额与礼包积分不匹配(不足或多余超过阈值) + STATUS_POINTS_ERROR: "积分异常", # 旁路终态:抢购后查兑换记录,没查到这一单的礼包 → 判失败(人工确认后可作废或回退重试) STATUS_FAILED: "抢购失败", STATUS_VOID: "已作废", + # 历史标签:只用于旧审计/旧行展示,不在 ORDER_STATUSES 里,不能再作为目标状态。 + "pending": "待处理(历史)", + "bound": "已确认绑定(历史)", } -# 流程链(时间戳同步与「能不能向前走」都只看这条链);failed / void 是**旁路**终态。 +# 流程链(时间戳同步与「能不能向前走」都只看这条链);failed / void 是**旁路**终态, +# points_error 是可恢复的异常态,都不进主链。 STATUS_FLOW: tuple[str, ...] = ( STATUS_INCOMPLETE, - STATUS_PENDING, - STATUS_BOUND, + STATUS_TO_RECHARGE, STATUS_RECHARGED, STATUS_SCRIPTED, STATUS_DONE, ) -# 「向前一步」的下一站;done 是终点,failed / void 是任意态可达的旁路,都不在这条链上。 +# 「向前一步」的下一站;done 是终点,failed / void 是旁路,points_error 由核账自动进入。 NEXT_STATUS = { - STATUS_INCOMPLETE: STATUS_PENDING, - STATUS_PENDING: STATUS_BOUND, - STATUS_BOUND: STATUS_RECHARGED, + STATUS_INCOMPLETE: STATUS_TO_RECHARGE, + STATUS_TO_RECHARGE: STATUS_RECHARGED, STATUS_RECHARGED: STATUS_SCRIPTED, STATUS_SCRIPTED: STATUS_DONE, } @@ -109,15 +111,13 @@ NEXT_STATUS = { # 阶段序号:用来同步时间戳(回退时要清掉后面阶段的时间戳,否则状态与时间戳互相矛盾)。 STAGE_INDEX = { STATUS_INCOMPLETE: 0, - STATUS_PENDING: 1, - STATUS_BOUND: 2, - STATUS_RECHARGED: 3, - STATUS_SCRIPTED: 4, - STATUS_DONE: 5, + STATUS_TO_RECHARGE: 1, + STATUS_RECHARGED: 2, + STATUS_SCRIPTED: 3, + STATUS_DONE: 4, } STATUS_TIMESTAMP_FIELDS = { - STATUS_BOUND: "bound_at", STATUS_RECHARGED: "recharged_at", STATUS_SCRIPTED: "scripted_at", STATUS_DONE: "done_at", @@ -129,22 +129,42 @@ ARCHIVABLE_STATUSES: tuple[str, ...] = (STATUS_DONE,) # DB-only 的批量归档不需要像外部探针那样限 50 条。 ARCHIVE_BATCH_MAX = 5000 +# 充值核账阈值:账号积分 - 礼包积分合计 的允许结余上限(默认 100)。 +RECHARGE_POINTS_THRESHOLD_DEFAULT = 100 +RECHARGE_POINTS_THRESHOLD_MAX = 1_000_000 + + +def normalize_points_threshold(value: Any) -> int: + """把前端传来的核账阈值规整成 0..MAX 的整数;空值/脏值回落默认 100。""" + if value is None or value == "": + return RECHARGE_POINTS_THRESHOLD_DEFAULT + try: + number = int(value) + except (TypeError, ValueError) as exc: + raise CollabError( + "积分阈值必须是整数", code="points_threshold_invalid" + ) from exc + if number < 0: + raise CollabError("积分阈值不能小于 0", code="points_threshold_invalid") + return min(number, RECHARGE_POINTS_THRESHOLD_MAX) + def is_forward_status(from_status: str, target: str) -> bool: """这一步算不算「向前」(人工切换允许往前跳步,回退必须显式声明)。 - 2026-10-16 用户拍板:以前只允许一步一步推进,但人工实际会跳(比如没记充值就直接上脚本)。 - 现在规则: - - 流程链内:目标阶段序号 **≥ 当前** 就是向前(含原地重复设置,幂等); - - `void`(作废):任意流程内状态都能进; - - `failed`(抢购失败):流程内任意状态都能进(兑换记录查出来没有就是没有); - - 从 `void` / `failed` 回到流程内:**必须** rollback(这两步是"要人工再确认一次"的)。 + - `void` / `failed`:任意非终态可进; + - `points_error`:只能从 `to_recharge` / `recharged` 进入,补足后可直接回 `recharged`; + - 从 `void` / `failed` 回到流程内:**必须** rollback。 """ if target == STATUS_VOID or target == STATUS_FAILED: return from_status not in (STATUS_VOID, STATUS_FAILED) if from_status in (STATUS_VOID, STATUS_FAILED): return False + if from_status == STATUS_POINTS_ERROR: + return target in (STATUS_TO_RECHARGE, STATUS_RECHARGED) + if target == STATUS_POINTS_ERROR: + return from_status in (STATUS_TO_RECHARGE, STATUS_RECHARGED) return STAGE_INDEX[target] >= STAGE_INDEX[from_status] @@ -180,6 +200,7 @@ EDITABLE_FIELDS: tuple[str, ...] = ( "bind_douyu_account", "bind_douyu_uid", "gift_name", + "required_points", "remark", ) @@ -203,6 +224,7 @@ FIELD_LABELS = { "bind_douyu_account": "绑定斗鱼号", "bind_douyu_uid": "绑定斗鱼号 uid", "gift_name": "礼包名", + "required_points": "礼包所需积分", "remark": "备注", "void_reason": "作废原因", "archive_reason": "归档备注", @@ -451,45 +473,33 @@ def _apply_ck(order: HuyaExternalOrder, cookie_text: str) -> dict: # -------------------------------------------------------------------------- -def _assert_can_enter_bound(order: HuyaExternalOrder) -> None: - """进入「已确认绑定」的前置校验(服务层做,前端校验只是体验)。 - - 2026-10-16 用户口径:**人工核对过「探针读到的绑定角色」与「主页截图」一致**,才允许 - 切到这一步。所以这里要两样东西齐全: - - - **绑定角色**(`bind_game_role`,探针写)—— 没读到角色就没人知道该跟哪张图对; - - **主页截图**(`home_images`,人工上传)—— 没有图就没有"核对"这件事的依据。 - - 绑定图已停用(绑定关系改由探针读),不再要求;`bind_douyu_account` 也不再算数 - (那是人工自由文本,且已从界面下线)。 - """ - missing: list[str] = [] - if not str(order.cookie or "").strip(): - missing.append("CK") - if not str(order.bind_game_role or "").strip(): - missing.append("绑定角色(先贴 CK 让它自动探针读一次)") - if not images_of(order, "home_images"): - missing.append("主页截图(至少 1 张)") - if missing: - raise CollabError( - f"还不能标记为「{ORDER_STATUS_LABELS[STATUS_BOUND]}」,缺少:{'、'.join(missing)}", - code="bound_precondition", - ) - - -def _assert_can_leave_incomplete(order: HuyaExternalOrder) -> None: - """离开「待补资料」的前置校验:客户资料必须齐全。 - - 只在 ``incomplete`` 这个起点用:CK / 礼包名 / 主页截图至少各一份, - 否则「待处理」只是个空壳(后面谁都处理不了)。作废 / 判失败是旁路,不拦。 - """ +def _missing_recharge_data( + order: HuyaExternalOrder, *, require_ck_valid: bool +) -> list[str]: + """「待充值」所需资料;返回缺失项(空列表 = 齐全)。""" missing: list[str] = [] if not str(order.cookie or "").strip(): missing.append("外部 CK") + elif require_ck_valid and order.ck_status != CK_VALID: + missing.append("CK 验证有效(先贴 CK 让它自动探针读一次)") + if not str(order.bind_game_account or "").strip(): + missing.append("绑定账号(先贴 CK 让探针读一次)") + if not str(order.bind_game_role or "").strip(): + missing.append("绑定角色(先贴 CK 让探针读一次)") if not str(order.gift_name or "").strip(): missing.append("礼包名") if not images_of(order, "home_images"): missing.append("主页截图(至少 1 张)") + return missing + + +def _is_ready_for_recharge(order: HuyaExternalOrder) -> bool: + return not _missing_recharge_data(order, require_ck_valid=True) + + +def _assert_can_leave_incomplete(order: HuyaExternalOrder) -> None: + """离开「待补资料」的前置校验:客户资料必须齐全。""" + missing = _missing_recharge_data(order, require_ck_valid=False) if missing: raise CollabError( f"「{ORDER_STATUS_LABELS[STATUS_INCOMPLETE]}」还不能往前改,缺少:{'、'.join(missing)}", @@ -497,20 +507,97 @@ def _assert_can_leave_incomplete(order: HuyaExternalOrder) -> None: ) +def _assert_can_enter_to_recharge(order: HuyaExternalOrder) -> None: + missing = _missing_recharge_data(order, require_ck_valid=True) + if missing: + raise CollabError( + f"还不能进入「{ORDER_STATUS_LABELS[STATUS_TO_RECHARGE]}」,缺少:{'、'.join(missing)}", + code="to_recharge_precondition", + ) + + +def _maybe_advance_to_recharge( + db: Session, + current: User | None, + order: HuyaExternalOrder, + *, + commit: bool = False, +) -> bool: + """资料齐全且 CK 有效时,把 ``incomplete`` 自动推进到 ``to_recharge``。""" + if order.status != STATUS_INCOMPLETE or not _is_ready_for_recharge(order): + return False + now = _now() + order.status = STATUS_TO_RECHARGE + if order.bound_at is None: + order.bound_at = now + _sync_stage_timestamps(order, STATUS_TO_RECHARGE, now) + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:status_auto", + target=_audit_target(order), + detail={ + "from": STATUS_INCOMPLETE, + "to": STATUS_TO_RECHARGE, + "reason": "data_complete", + "order_no": order.order_no, + }, + ) + if commit: + db.commit() + db.refresh(order) + return True + + +def _maybe_demote_missing_data( + db: Session, + current: User | None, + order: HuyaExternalOrder, +) -> bool: + """待充值/积分异常的单子如果关键资料被清空,退回「待补资料」。 + + 注意:CK 失效不等于资料缺失,不触发降级;只有 CK/绑定账号/角色/礼包/截图 + 这些“没有就无法处理”的字段为空时才退回。 + """ + if order.status not in (STATUS_TO_RECHARGE, STATUS_POINTS_ERROR): + return False + if not _missing_recharge_data(order, require_ck_valid=False): + return False + from_status = order.status + order.status = STATUS_INCOMPLETE + order.recharged_at = None + order.scripted_at = None + order.done_at = None + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:status_auto", + target=_audit_target(order), + detail={ + "from": from_status, + "to": STATUS_INCOMPLETE, + "reason": "data_missing", + "order_no": order.order_no, + }, + ) + return True + + def _sync_stage_timestamps( order: HuyaExternalOrder, target: str, now: datetime ) -> None: - """让各阶段时间戳与目标状态保持一致。 - - - 目标状态**之前**(含自身)缺失的时间戳补上:状态说「已上脚本」, - 时间戳就不能是空的,否则前端那格显示空白、没人知道什么时候做的; - - 目标状态**之后**的时间戳清空:回退之后还留着「已上脚本时间」, - 就是状态与时间戳互相矛盾; - - `void` / `failed` 是旁路终态:不动流程链上的时间戳(从"已上脚本"判失败时, - "什么时候上的脚本"这条信息必须留着)。 - """ + """让各阶段时间戳与目标状态保持一致。""" if target in (STATUS_VOID, STATUS_FAILED): return + if target == STATUS_POINTS_ERROR: + # 充值动作已经发生,只是积分不匹配;保留/补上充值时间,清掉后续阶段。 + if order.recharged_at is None: + order.recharged_at = now + order.scripted_at = None + order.done_at = None + return target_index = STAGE_INDEX[target] for stage, field in STATUS_TIMESTAMP_FIELDS.items(): if STAGE_INDEX[stage] <= target_index: @@ -528,20 +615,21 @@ def set_status( *, reason: str = "", rollback: bool = False, + allow_recharge: bool = False, commit: bool = True, ) -> dict: """推进/回退业务状态(本模块唯一的 ``status`` 写入口)。 - 人工切换**允许往前跳步**(如 待处理 → 已上脚本),回退必须显式 ``rollback=True``; - 规则见 :func:`is_forward_status`。 - - Args: - target: 目标状态,必须在 ``ORDER_STATUSES`` 里。 - reason: 作废原因(``target=void`` 时必填)。 - rollback: 目标不是「向前」时必须显式置 True 才允许改。 + ``recharged`` / ``points_error`` 只能由充值核账流程写入(``allow_recharge=True``); + 普通人工状态接口不允许绕过核账。 """ if target not in ORDER_STATUSES: raise CollabError(f"未知状态:{target}", code="status_unknown") + if target in (STATUS_RECHARGED, STATUS_POINTS_ERROR) and not allow_recharge: + raise CollabError( + "「已充值 / 积分异常」必须通过充值核账流程写入", + code="recharge_check_required", + ) assert_order_mutable(order) if target == order.status: return {} @@ -571,20 +659,20 @@ def set_status( STATUS_VOID, STATUS_FAILED, ): - # 「待补资料」是起点:客户资料没齐之前不许往前,否则下一个环节无从下手。 _assert_can_leave_incomplete(order) - if target == STATUS_BOUND: - _assert_can_enter_bound(order) + if target == STATUS_TO_RECHARGE: + _assert_can_enter_to_recharge(order) now = _now() if target == STATUS_VOID: order.void_reason = clean_text(reason, field="void_reason", required=True) else: - # 从作废改回流程内状态时,作废原因要清掉,不然列表上会自相矛盾。 order.void_reason = "" order.status = target + if target == STATUS_TO_RECHARGE and order.bound_at is None: + order.bound_at = now _sync_stage_timestamps(order, target, now) _touch(order, current) @@ -599,6 +687,7 @@ def set_status( "rollback": bool(not forward), "reason": order.void_reason, "order_no": order.order_no, + "allow_recharge": bool(allow_recharge), }, ) if commit: @@ -619,12 +708,17 @@ def set_status_batch( """批量改状态:勾选多行一次切到同一个状态(部分失败不影响其它行)。 逐行用 SAVEPOINT 隔离(同 :func:`import_orders` 的做法):某一行因为前置校验没过 - (例如没主页截图不能进「已确认绑定」)只记在这一条的 ``message`` 里,整体不整体失败。 + (例如资料不全进不了「待充值」)只记在这一条的 ``message`` 里,整体不整体失败。 上限与批量验证一致(``PROBE_BATCH_MAX``)—— 一次别点太多,人会看不过来。 """ rows = list(orders) if not rows: raise CollabError("请先勾选要改状态的行", code="status_batch_empty") + if target in (STATUS_RECHARGED, STATUS_POINTS_ERROR): + raise CollabError( + "「已充值 / 积分异常」必须通过充值核账流程写入", + code="recharge_check_required", + ) if len(rows) > PROBE_BATCH_MAX: raise CollabError( f"一次最多改 {PROBE_BATCH_MAX} 行,请分批操作", @@ -682,6 +776,413 @@ def set_status_batch( } +# -------------------------------------------------------------------------- +# 充值核账(按 bind_game_account 整组) +# -------------------------------------------------------------------------- + +RECHARGE_GROUP_STATUSES: tuple[str, ...] = ( + STATUS_TO_RECHARGE, + STATUS_RECHARGED, + STATUS_POINTS_ERROR, +) + + +def _recharge_group_orders(db: Session, group_key: str) -> list[HuyaExternalOrder]: + return ( + db.query(HuyaExternalOrder) + .filter( + HuyaExternalOrder.bind_game_account == group_key, + HuyaExternalOrder.status.in_(RECHARGE_GROUP_STATUSES), + ) + .order_by(HuyaExternalOrder.id.asc()) + .all() + ) + + +def _resolve_group_required_points( + db: Session, + current: User | None, + group_orders: Sequence[HuyaExternalOrder], + *, + client: Any | None = None, +) -> tuple[list[str], str]: + """给组内缺少 ``required_points`` 的订单按礼包名补一次快照。 + + 返回 ``(仍未解析的单号, 说明)``。只有真的缺数据时才打积分中心接口。 + """ + missing = [order for order in group_orders if order.required_points is None] + if not missing: + return [], "" + + source = next( + ( + order + for order in group_orders + if str(order.uid or "").strip() and str(order.cookie or "").strip() + ), + None, + ) + if source is None: + return [ + order.order_no for order in missing + ], "组内没有可用的 CK,无法解析礼包积分" + + catalog, message = fetch_points_center_gifts( + int(source.uid), + str(source.cookie), + client=client, + ) + if not catalog: + return [order.order_no for order in missing], message + + by_name = { + str(gift.get("name") or "").strip(): gift + for gift in catalog + if gift.get("name") and gift.get("price") is not None + } + unresolved: list[str] = [] + for order in missing: + wanted = str(order.gift_name or "").strip() + gift = by_name.get(wanted) + if gift is None and len(wanted) >= 2: + candidates = [ + item + for item in catalog + if item.get("price") is not None + and ( + wanted in str(item.get("name") or "") + or str(item.get("name") or "") in wanted + ) + ] + if len(candidates) == 1: + gift = candidates[0] + if gift is None: + unresolved.append(order.order_no) + continue + order.required_points = int(gift["price"]) + return unresolved, message + + +def _check_recharge_group( + db: Session, + current: User | None, + group_key: str, + *, + threshold: int, + client: Any | None = None, +) -> dict: + group_orders = _recharge_group_orders(db, group_key) + if not group_orders: + raise CollabError( + f"绑定账号「{group_key}」下没有可核账的订单", + code="recharge_group_empty", + ) + if any( + str(order.bind_game_account or "").strip() != group_key + for order in group_orders + ): + raise CollabError( + "核账过程中绑定账号发生变化,请刷新后重试", + code="bind_account_changed", + ) + + # 同一 uid 只验一次 CK;组内多单共用一份 CK 时不重复打接口。 + representatives: dict[str, HuyaExternalOrder] = {} + missing_ck: list[str] = [] + for order in group_orders: + uid = str(order.uid or "").strip() + cookie = str(order.cookie or "").strip() + if not uid or not cookie: + missing_ck.append(order.order_no) + continue + representatives.setdefault(uid, order) + if missing_ck: + raise CollabError( + "组内订单缺少 CK,无法核账:" + + "、".join(missing_ck[:10]) + + (" ..." if len(missing_ck) > 10 else ""), + code="no_cookie", + ) + if not representatives: + raise CollabError( + "组内没有可用 CK,无法核账", + code="no_cookie", + ) + + for uid, order in representatives.items(): + result = verify_ck(db, current, order, client=client, commit=False) + if not result.get("ok"): + raise CollabError( + f"{order.order_no} 的 CK 验证失败:{result.get('message') or '未知原因'}", + code="ck_invalid", + ) + if str(order.bind_game_account or "").strip() != group_key: + raise CollabError( + "核账过程中探针读到的绑定账号发生变化,请刷新后重试", + code="bind_account_changed", + ) + + unresolved, catalog_message = _resolve_group_required_points( + db, + current, + group_orders, + client=client, + ) + if unresolved: + raise CollabError( + "缺少礼包所需积分,无法核账:" + + "、".join(unresolved[:10]) + + (f"({catalog_message})" if catalog_message else ""), + code="required_points_missing", + ) + + uid_scores: dict[str, int] = {} + for uid, order in representatives.items(): + result = query_order_activity( + db, + current, + order, + scope=ACTIVITY_SCOPE_SCORE, + client=client, + source="recharge_check", + commit=False, + ) + score = result.get("score") + if not result.get("ok") or score is None: + raise CollabError( + f"{order.order_no} 的积分查询失败:{result.get('message') or '没有拿到积分'}", + code="points_query_failed", + ) + uid_scores[uid] = int(score) + + balance = sum(uid_scores.values()) + required_total = sum(int(order.required_points or 0) for order in group_orders) + surplus = balance - required_total + if surplus < 0: + result_status = STATUS_POINTS_ERROR + reason = "shortage" + message = f"积分异常:账号积分 {balance},礼包合计 {required_total},还差 {-surplus} 分" + elif surplus > threshold: + result_status = STATUS_POINTS_ERROR + reason = "excess" + message = ( + f"积分异常:账号积分 {balance},礼包合计 {required_total}," + f"多出 {surplus} 分(阈值 {threshold})" + ) + else: + result_status = STATUS_RECHARGED + reason = "ok" + message = ( + f"核账通过:账号积分 {balance},礼包合计 {required_total}," + f"结余 {surplus} 分(阈值 {threshold})" + ) + + now = _now() + detail = { + "group_key": group_key, + "threshold": threshold, + "balance": balance, + "required_total": required_total, + "surplus": surplus, + "reason": reason, + "uids": [ + { + "uid": uid, + "score": score, + "order_nos": [ + order.order_no + for order in group_orders + if str(order.uid or "").strip() == uid + ], + } + for uid, score in uid_scores.items() + ], + "orders": [ + { + "id": int(order.id), + "order_no": order.order_no, + "gift_name": order.gift_name or "", + "required_points": order.required_points, + } + for order in group_orders + ], + } + + for order in group_orders: + set_status( + db, + current, + order, + result_status, + allow_recharge=True, + commit=False, + ) + order.points_checked_at = now + order.points_balance = balance + order.points_required_total = required_total + order.points_surplus = surplus + order.points_threshold = threshold + order.points_check_reason = reason + order.points_check_message = message[:512] + order.points_check_detail = detail + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:recharge_check", + target=_audit_target(order), + detail={ + "order_no": order.order_no, + "group_key": group_key, + "status": result_status, + "balance": balance, + "required_total": required_total, + "surplus": surplus, + "threshold": threshold, + "reason": reason, + "order_ids": [int(item.id) for item in group_orders], + }, + ) + + return { + "group_key": group_key, + "status": result_status, + "status_label": ORDER_STATUS_LABELS.get(result_status, result_status), + "balance": balance, + "required_total": required_total, + "surplus": surplus, + "threshold": threshold, + "reason": reason, + "message": message, + "order_ids": [int(order.id) for order in group_orders], + "orders": [ + { + "id": int(order.id), + "order_no": order.order_no, + "status": order.status, + "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), + } + for order in group_orders + ], + } + + +def check_recharge_groups( + db: Session, + current: User | None, + orders: Sequence[HuyaExternalOrder], + *, + threshold: Any = RECHARGE_POINTS_THRESHOLD_DEFAULT, + client: Any | None = None, + commit: bool = True, +) -> dict: + """按 ``bind_game_account`` 整组核账,并整组落到「已充值 / 积分异常」。 + + 规则(2026-10-19 用户拍板): + + - 只统计同组 ``to_recharge / recharged / points_error`` 的订单; + - 组内不同 uid 的 CK 各查一次积分,合计为“账号积分”; + - ``结余 = 账号积分合计 - 礼包积分合计``; + - ``结余 < 0`` 或 ``结余 > 阈值`` → 整组 ``points_error``; + - 其余 → 整组 ``recharged``; + - CK 失效 / 积分查询失败 / 缺少所需积分:整组保持原状态并报错,不误判成积分异常。 + """ + rows = list(orders) + if not rows: + raise CollabError("请先选择要核账的订单", code="recharge_empty") + if len(rows) > PROBE_BATCH_MAX: + raise CollabError( + f"一次最多核账 {PROBE_BATCH_MAX} 条,请分批操作", + code="recharge_too_large", + ) + threshold_value = normalize_points_threshold(threshold) + + group_keys: list[str] = [] + failures: list[dict] = [] + failed_order_count = 0 + for order in rows: + key = str(order.bind_game_account or "").strip() + if not key: + _maybe_demote_missing_data(db, current, order) + failures.append( + { + "id": int(order.id), + "order_no": order.order_no, + "message": "缺少绑定账号,已保持/退回「待补资料」", + } + ) + failed_order_count += 1 + continue + if key not in group_keys: + group_keys.append(key) + + groups: list[dict] = [] + for key in group_keys: + try: + with db.begin_nested(): + group = _check_recharge_group( + db, + current, + key, + threshold=threshold_value, + client=client, + ) + groups.append(group) + except CollabError as exc: + group_rows = _recharge_group_orders(db, key) + failed_order_count += max(1, len(group_rows)) + groups.append( + { + "group_key": key, + "status": "", + "status_label": "", + "balance": None, + "required_total": None, + "surplus": None, + "threshold": threshold_value, + "reason": "error", + "message": exc.detail, + "order_ids": [int(order.id) for order in group_rows], + "orders": [], + } + ) + except Exception as exc: # noqa: BLE001 单个账号组失败不影响其它组 + logger.debug(f"[huya-collab] 充值核账单组失败:{type(exc).__name__}: {exc}") + group_rows = _recharge_group_orders(db, key) + failed_order_count += max(1, len(group_rows)) + groups.append( + { + "group_key": key, + "status": "", + "status_label": "", + "balance": None, + "required_total": None, + "surplus": None, + "threshold": threshold_value, + "reason": "error", + "message": f"{type(exc).__name__}: {exc}", + "order_ids": [int(order.id) for order in group_rows], + "orders": [], + } + ) + + updated = sum( + len(group.get("order_ids") or []) for group in groups if group.get("status") + ) + if commit: + db.commit() + for order in rows: + db.refresh(order) + return { + "total": len(rows), + "updated": updated, + "failed": failed_order_count, + "threshold": threshold_value, + "groups": groups, + "failures": failures, + } + + # -------------------------------------------------------------------------- # CK 写入口 # -------------------------------------------------------------------------- @@ -699,6 +1200,10 @@ def set_ck( 同一个虎牙号**允许**挂在多张单上(2026-10-16 用户拍板放开 uid 唯一性): 复用到的那些单号只记一笔审计,不挡操作 —— 挡了就变成"同一个买家的第二单建不了"。 + + **不因换 CK 回退业务状态**:``ck_status`` 只代表凭据是否刚验证过。等用户点 + 「充值核账」时会强制重验 CK;如果探针读到的 ``bind_game_account`` 变了, + 整组核账会报错让人先处理,而不是把订单误判到旧账号组里。 """ assert_order_mutable(order) before = str(order.cookie or "") @@ -856,20 +1361,25 @@ def set_images( if before == after: return {"slot": slot, "images": after} setattr(order, slot, after) - _touch(order, current) + raw_detail = { + "slot": slot, + "slot_label": IMAGE_SLOT_LABELS[slot], + "added": [key for key in after if key not in before], + "removed": [key for key in before if key not in after], + "order_no": order.order_no, + } + # 先记录图片变更,再让自动状态迁移追加在后面(时间线顺序与因果一致)。 record_audit( db, current, action="huya:collab:image", target=_audit_target(order), - detail={ - "slot": slot, - "slot_label": IMAGE_SLOT_LABELS[slot], - "added": [key for key in after if key not in before], - "removed": [key for key in before if key not in after], - "order_no": order.order_no, - }, + detail=raw_detail, ) + advanced = _maybe_advance_to_recharge(db, current, order) + demoted = False if advanced else _maybe_demote_missing_data(db, current, order) + if not advanced and not demoted: + _touch(order, current) if commit: db.commit() db.refresh(order) @@ -1095,14 +1605,11 @@ def fetch_activity_gifts( sid: int | None = None, client: Any | None = None, ) -> tuple[list[dict], str]: - """用这份 CK 拉**活动可兑换礼包**列表(只读,不落库)。 + """LEGACY:用这份 CK 拉 ``webActUI.getActPrizeList`` 活动礼包。 - ``sid`` 不传就用 :data:`GIFTS_SID` 兜底;接口层会传工作台配置里的活动 ID - (:func:`resolve_gifts_sid`)。 - - Returns: - ``(礼包列表, 可读结论)`` —— 每项是 ``{product_id, name, price, ...}``; - 拉不到时返回 ``([], "…")``,不影响 CK 本身的有效性结论。 + 外部单协作台账已改用 :func:`fetch_points_center_gifts` + (``PlayMallUI.queryAllocation``);本函数保留给旧调用/测试,不再从 + ``preview_ck`` 调用。 """ sid_value = int(sid or GIFTS_SID) try: @@ -1150,6 +1657,59 @@ def fetch_activity_gifts( return gifts, f"已拉取 {len(gifts)} 个礼包" +def fetch_points_center_gifts( + uid: int, + cookie: str, + *, + client: Any | None = None, +) -> tuple[list[dict], str]: + """用这份 CK 拉**积分中心商品**(``PlayMallUI.queryAllocation``,只读)。 + + 2026-10-19 用户拍板:外部单协作台账改用这个接口,不再调用 + ``webActUI.getActPrizeList``。商品积分取 ``tag75.tag0.tag2``, + 与积分中心页面显示一致;旧接口保留给精英宝典工作台的兑换链路。 + """ + try: + gift_client = client or _build_probe_client() + resp = gift_client.play_mall_query_allocation( + uid=int(uid), + guid="", + cookie=cookie, + deep=True, + ) + except Exception as exc: # noqa: BLE001 拉礼包失败不该让整个预览 500 + logger.debug(f"[huya-collab] 拉取积分中心商品异常:{type(exc).__name__}: {exc}") + return [], f"拉取礼包失败:{type(exc).__name__}: {_clip(exc)}" + + if resp is None: + return [], "拉取礼包失败:积分中心接口没有返回数据" + + code = getattr(resp, "code", None) + if code is None: + code = getattr(resp, "status", 0) + code = int(code or 0) + if code != 200: + msg = _clip(getattr(resp, "message", "") or getattr(resp, "msg", "")) + return [], f"拉取礼包失败:code={code}{(':' + msg) if msg else ''}" + + payload = getattr(resp, "payload", None) + if payload is None and hasattr(resp, "to_dict"): + try: + payload = (resp.to_dict() or {}).get("payload") + except Exception: # noqa: BLE001 第三方响应结构异常时按空处理 + payload = None + + from core.huya.shop_structs import parse_points_center_gifts + + gifts = parse_points_center_gifts(payload)[:GIFTS_LIMIT] + if not gifts: + return [], ( + "积分中心没返回商品:接口通了但列表是空的," + "检查账号是否有积分中心访问权限,或稍后重试" + ) + return gifts, f"已拉取 {len(gifts)} 个礼包(积分中心)" + + # -------------------------------------------------------------------------- # 账号全生命周期(积分 / 兑换记录:抢购之后靠它判断到底兑换成功没有) # -------------------------------------------------------------------------- @@ -1702,13 +2262,13 @@ def preview_ck( sid: int | None = None, client: Any | None = None, ) -> dict: - """**只读预览**一份待提交的 CK:解析 + 探针(+ 顺带拉活动礼包),一行都不写库。 + """**只读预览**一份待提交的 CK:解析 + 探针(+ 顺带拉积分中心礼包),一行都不写库。 新建协作单时用它给出「CK 有效 + 绑定角色」的即时反馈;提交时 :func:`create_order` 还会再校验一次(同一份 CK 命中探针缓存,不会重复打接口)。 - ``sid`` = 活动 ID(接口层传工作台配置里的那个,见 :func:`resolve_gifts_sid`): - 它决定礼包列表能不能拉到,**不影响 CK 有效性结论**。 + 礼包源是 ``PlayMallUI.queryAllocation``(2026-10-19 用户拍板); + ``sid`` 仅为旧调用签名兼容保留,当前不再参与礼包拉取。 """ parsed = parse_ck(str(ck_text or "")) uid_text = str(parsed.get("uid") or "") @@ -1722,8 +2282,8 @@ def preview_ck( gifts: list[dict] = [] gifts_message = "" if with_gifts and result.ok and uid_text: - gifts, gifts_message = fetch_activity_gifts( - int(uid_text), cookie, sid=sid, client=client + gifts, gifts_message = fetch_points_center_gifts( + int(uid_text), cookie, client=client ) return { "ok": result.ok, @@ -1796,6 +2356,7 @@ def verify_ck( target = CK_VALID if result.ok else CK_INVALID set_ck_status(db, current, order, target, message=result.message, commit=False) + _maybe_advance_to_recharge(db, current, order) if commit: db.commit() db.refresh(order) @@ -2293,6 +2854,23 @@ def frequent_gift_names(db: Session, *, limit: int = 8) -> list[dict]: # -------------------------------------------------------------------------- +def _clean_required_points(value: Any) -> int | None: + """礼包所需积分:允许空;非负整数,拒绝布尔/小数/脏值。""" + if value is None or value == "": + return None + if isinstance(value, bool): + raise CollabError("礼包所需积分必须是整数", code="required_points_invalid") + try: + points = int(value) + except (TypeError, ValueError) as exc: + raise CollabError( + "礼包所需积分必须是整数", code="required_points_invalid" + ) from exc + if points < 0: + raise CollabError("礼包所需积分不能小于 0", code="required_points_invalid") + return points + + def create_order( db: Session, current: User, @@ -2303,6 +2881,7 @@ def create_order( ck_text: str = "", bind_douyu_account: str = "", gift_name: str = "", + required_points: Any = None, home_images: Any = None, remark: str = "", require_verified: bool = False, @@ -2341,10 +2920,11 @@ def create_order( owner_id=_clean_owner_id(owner_id) if owner_id is not None else current.id, bind_douyu_account=clean_text(bind_douyu_account, field="bind_douyu_account"), gift_name=clean_text(gift_name, field="gift_name"), + required_points=_clean_required_points(required_points), remark=clean_text(remark, field="remark"), bind_images=[], home_images=home_images_keys, - status=STATUS_INCOMPLETE if draft else STATUS_PENDING, + status=STATUS_INCOMPLETE, ck_status=CK_UNVERIFIED, created_by=current.id, updated_by=current.id, @@ -2392,11 +2972,8 @@ def create_order( db.add(order) db.flush() # 先拿到 id,审计里要带 target - if probe_result is not None and probe_result.ok: - # 结论仍然走 set_ck_status 这个唯一写入口(审计与 ck_checked_at 不会漂)。 - set_ck_status( - db, current, order, CK_VALID, message=probe_result.message, commit=False - ) + # 先记「创建」这一笔,再做探针结论/自动推进:时间线按“谁导致什么”排序, + # 不能出现 status_auto 排在 create 前面。 record_audit( db, current, @@ -2406,8 +2983,14 @@ def create_order( "order_no": order.order_no, "shop": order.shop, "uid": order.uid or "", - "ck_status": order.ck_status, + "ck_status": ( + CK_VALID + if probe_result is not None and probe_result.ok + else order.ck_status + ), "gift_name": order.gift_name, + "required_points": order.required_points, + "bind_game_account": order.bind_game_account or "", "bind_game_role": order.bind_game_role or "", "home_images": len(order.home_images or []), "draft": bool(draft), @@ -2415,6 +2998,12 @@ def create_order( "uid_reused_in": uid_reused, }, ) + if probe_result is not None and probe_result.ok: + # 结论仍然走 set_ck_status 这个唯一写入口(审计与 ck_checked_at 不会漂)。 + set_ck_status( + db, current, order, CK_VALID, message=probe_result.message, commit=False + ) + _maybe_advance_to_recharge(db, current, order) if commit: db.commit() db.refresh(order) @@ -2444,6 +3033,8 @@ def update_order( continue if field == "owner_id": new_value: Any = _clean_owner_id(payload[field]) + elif field == "required_points": + new_value = _clean_required_points(payload[field]) else: new_value = clean_text( payload[field], @@ -2465,7 +3056,8 @@ def update_order( # 改成字典里没有的名字也允许(自定义),顺手登记,筛选下拉不会漏。 ensure_shops_registered(db, current, [changes["shop"]["new"]]) - _touch(order, current) + # 先记「人工改了什么」,再跑自动状态迁移:否则时间线会出现 + # status_auto 排在 update 前面,像状态是被后一次操作改的。 record_audit( db, current, @@ -2473,6 +3065,10 @@ def update_order( target=_audit_target(order), detail={"order_no": order.order_no, "changes": changes}, ) + advanced = _maybe_advance_to_recharge(db, current, order) + demoted = False if advanced else _maybe_demote_missing_data(db, current, order) + if not advanced and not demoted: + _touch(order, current) if commit: db.commit() db.refresh(order) @@ -3001,6 +3597,7 @@ def serialize_order( "bind_image_thumb_urls": urls(bind_images, "thumb"), "home_image_thumb_urls": urls(home_images, "thumb"), "gift_name": order.gift_name or "", + "required_points": order.required_points, "remark": order.remark or "", "status": order.status, "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), @@ -3009,6 +3606,14 @@ def serialize_order( "recharged_at": order.recharged_at, "scripted_at": order.scripted_at, "done_at": order.done_at, + "points_checked_at": order.points_checked_at, + "points_balance": order.points_balance, + "points_required_total": order.points_required_total, + "points_surplus": order.points_surplus, + "points_threshold": order.points_threshold, + "points_check_reason": order.points_check_reason or "", + "points_check_message": order.points_check_message or "", + "points_check_detail": order.points_check_detail, "void_reason": order.void_reason or "", "is_archived": order.archived_at is not None, "archived_at": order.archived_at, diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index e2b46bf..d547d07 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -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( + '/huya/external-orders/recharge', + data, + ), /** 归档抢购成功的订单(可带备注;archive_filtered 时按当前筛选批量归档) */ archiveCollabOrders: (data: HuyaCollabArchiveRequest) => api.post( diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index f204c8c..3c127cb 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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 | 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; diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 38345ea..c082e2d 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -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 = { // 只登记了订单号 + 店铺,客户资料(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 = { /** 「改状态」能选的目标(顺序 = 业务流程顺序,回退要显式勾选) * 「待补资料」只作为新建起点,不放进「改状态」:它只能通过「回退状态」进入。 */ 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= 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([]); + // 点顶部统计卡筛选后滚到列表,避免用户还要自己往下找 + const listAnchorRef = useRef(null); const [detail, setDetail] = useState(null); const [timeline, setTimeline] = useState([]); @@ -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('pending'); + const [rollbackTarget, setRollbackTarget] = useState('to_recharge'); // 批量改状态(勾选多行一次切) const [batchStatusOpen, setBatchStatusOpen] = useState(false); - const [batchStatusTarget, setBatchStatusTarget] = useState('bound'); + const [batchStatusTarget, setBatchStatusTarget] = useState('to_recharge'); const [batchStatusReason, setBatchStatusReason] = useState(''); const [batchStatusRollback, setBatchStatusRollback] = useState(false); + // 充值核账弹窗:按 bind_game_account 整组核账,阈值默认 100 + const [rechargeOpen, setRechargeOpen] = useState(false); + const [rechargeIds, setRechargeIds] = useState([]); + const [rechargeThreshold, setRechargeThreshold] = useState(readRechargeThreshold); // 归档弹窗:ids=null 表示按当前筛选结果批量归档 const [archiveOpen, setArchiveOpen] = useState(false); const [archiveIds, setArchiveIds] = useState(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: ( + + {lines.map((line) => ( + + {line.text} + + ))} + {result.failures.map((item) => ( + + {item.order_no}:{item.message} + + ))} + + ), + }); + } + 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), + })} /> 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 ( - {/* 卡片用 flex 平分:状态多了一个「抢购失败」,固定 span=4 会换行参差不齐 */} - - - - - + {/* 卡片用 flex 平分并允许换行:状态多,固定 span 会参差不齐 */} + + + applyStatusCard(undefined)}> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {([ + ['incomplete', '#fa8c16'], + ['to_recharge', '#faad14'], + ['recharged', '#2f54eb'], + ['scripted', '#722ed1'], + ['done', '#52c41a'], + ['failed', '#ff4d4f'], + ['points_error', '#fa541c'], + ] as const).map(([status, color]) => ( + + applyStatusCard(status)} + > + + {status === 'done' && ( + + )} + + + ))} + + @@ -2245,6 +2378,7 @@ export default function HuyaCollabPage() { )} +
{/* 固定两行:第一行永远是筛选,第二行永远是动作按钮组 —— 不要用「筛选 + 动作同一个 wrap」 @@ -2353,6 +2487,14 @@ export default function HuyaCollabPage() { )} + {canEdit && !isArchiveView && ( + + + + )} {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' && ( + + )} {detail.status_label} }, ...(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)); + }} /> + + + + + @@ -2702,7 +2871,7 @@ export default function HuyaCollabPage() { 改状态… {detail.status !== 'void' && ( @@ -2939,7 +3108,10 @@ export default function HuyaCollabPage() { {renderFrequentGiftChips(createGiftValue, (name) => { - createForm.setFieldsValue({ gift_name: name }); + createForm.setFieldsValue({ + gift_name: name, + required_points: giftRequiredPoints(name), + }); setGiftHint(''); })} { - // 选了就清掉「上次那个礼包不在本次列表里」的提示 + onChange={(value?: string) => { + // 选了就清掉「上次那个礼包不在本次列表里」的提示,并带出积分价格 setGiftHint(''); + createForm.setFieldValue('required_points', giftRequiredPoints(value)); }} /> + + + , , ]} > @@ -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 后会自动进入「待充值」。'; })()} />
{renderFrequentGiftChips( supplementGiftValue ?? supplementTarget.gift_name, - (name) => supplementForm.setFieldsValue({ gift_name: name }), + (name) => supplementForm.setFieldsValue({ + gift_name: name, + required_points: giftRequiredPoints(name), + }), )} @@ -3079,8 +3267,16 @@ export default function HuyaCollabPage() { notFoundContent={giftOptions.length === 0 ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' : '没有匹配的礼包'} + onChange={(value?: string) => { + supplementForm.setFieldValue('required_points', giftRequiredPoints(value)); + }} /> + + + + setRechargeOpen(false)} + onOk={() => void submitRecharge()} + confirmLoading={busy} + okText="核账并落状态" + destroyOnHidden + > + + + + 允许结余阈值 + setRechargeThreshold(Number(value) || 0)} + addonAfter="积分" + style={{ width: 160 }} + /> + + + 结余 = 账号积分合计 - 礼包积分合计。结余小于 0 或大于阈值 → 整组「积分异常」; + 其余 → 整组「已充值」。阈值默认 100。 + + + + {statusAction?.mode === 'set' && ( - 人工切换允许往前跳步(比如 待处理 → 已上脚本),不用一步一步点; - 切到「已确认绑定」要求先有绑定角色 + 主页截图,切到「已作废」必须填原因。 + 人工切换允许往前跳步(比如 待补资料 → 已上脚本),不用一步一步点; + 选「已充值」会转为整组核账;切到「已作废」必须填原因。 )} {statusAction?.mode === 'rollback' && ( @@ -3287,10 +3518,10 @@ export default function HuyaCollabPage() { onChange={(event) => setBatchStatusReason(event.target.value)} placeholder={batchStatusTarget === 'void' ? '作废原因(必填)' : '改状态说明(可选)'} /> setBatchStatusRollback(event.target.checked)}> - 回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待处理) + 回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待充值) - 逐行独立提交:某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只跳过那一行, + 逐行独立提交:某一行前置校验没过(例如资料不全进不了「待充值」)只跳过那一行, 结果会在弹窗里逐条列出来。一次最多 50 行。