From c299f63537bfe9ca3e4cd4ed2670a72527cd26c6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 19 Oct 2026 17:22:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=85=E5=80=BC=E6=A0=B8=E8=B4=A6?= =?UTF-8?q?=E6=8C=89=E7=BB=91=E5=AE=9A=E8=B4=A6=E5=8F=B7=E5=92=8C=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=95=B4=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/设计-虎牙外部CK协作台账.md | 20 +++--- tests/test_huya_collab_orders.py | 46 +++++++++++++- web/backend/services/huya_collab_service.py | 67 ++++++++++++++------- web/frontend/src/pages/HuyaCollabPage.tsx | 8 +-- 4 files changed, 103 insertions(+), 38 deletions(-) diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index d4084e4..09e0ca7 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -242,8 +242,8 @@ incomplete ─资料齐全且 CK 有效─▶ to_recharge ─充值核账─▶ 审计 `reason=data_missing`。**CK 失效本身不触发退回**:业务上可能只是凭据过期, 充值核账会强制重验并阻止误判。 4. **换 CK 不回退业务状态**:`set_ck()` 只把 `ck_status` 打回 `unverified`;等用户点 - 「充值核账」时强制重验。若探针读到的 `bind_game_account` 变了,整组核账报错, - 不会把订单误落到旧账号组。 + 「充值核账」时强制重验。若探针读到的 `bind_game_account` / `bind_game_role` 变了, + 整组核账报错,不会把订单误落到旧买家组。 5. **人工切换允许往前跳步**:目标阶段序号 ≥ 当前即放行(`is_forward_status()`)。 跳步会把沿途的 `*_at` 时间戳**补齐**(`_sync_stage_timestamps()`)。 6. **回退必须显式声明**:目标阶段序号 < 当前时必须 `rollback=true`(界面勾「回退」), @@ -262,7 +262,9 @@ incomplete ─资料齐全且 CK 有效─▶ to_recharge ─充值核账─▶ **充值核账(整组,`check_recharge_groups()`)**: -- 分组键 = **精确的 `bind_game_account`**;组内只统计 `to_recharge / recharged / +- 分组键 = **`bind_game_account` + `bind_game_role`**(和「同一买家」软识别一致)。 + 探针读到的账号经常只是「微信用户 / QQ用户」这种通用昵称,**只按账号会把不同买家并成一组** + (2026-10-19 生产上 8 单被合成 48000 分);组内只统计 `to_recharge / recharged / points_error`,`incomplete / scripted / done / failed / void` 不参与; - 同一 uid 只验一次 CK、只查一次积分;组内多个 uid 的积分**合计**; - `必充积分 = Σ required_points`;`结余 = 账号积分合计 - 必充积分`; @@ -572,7 +574,7 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid | 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 条;**不接受** `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/recharge` | `huya:collab:edit` | **充值核账**:`{ids, threshold}`,按「绑定账号 + 绑定角色」整组验证 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) | @@ -1032,20 +1034,22 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid | 状态 | 删除 `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 积分合计 | +| 核账 | `check_recharge_groups()` + `POST /external-orders/recharge`:按「`bind_game_account` + `bind_game_role`」分组;只统计 `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)先落审计,再追加自动状态,时间线因果顺序不再倒置 | +| 生产修正 | 首版只按 `bind_game_account` 分组,生产发现「微信用户 / QQ用户」是通用昵称,8 个不同角色被合成一组(required_total=48000)。现改为「账号 + 角色」;`points_check_detail` 同时保留 `bind_game_account` 与 `bind_game_role` | **三条边界**: -1. **不要用 uid 当分组键**:同一买家可能用多份 CK。分组只认探针读到的 `bind_game_account`; - `bind_game_account` 为空的行保持/退回 `incomplete`,绝不回落到 uid。 +1. **不要只拿 `bind_game_account` 当分组键**:它经常是「微信用户 / QQ用户」这种通用昵称; + 同一买家必须靠「账号 + 角色」识别。账号或角色为空的行保持/退回 `incomplete`, + 绝不回落到 uid。 2. **不要让人工直写 `recharged` / `points_error`**:这两个状态必须由核账产生,否则积分快照与状态会各说各话。 -3. **换 CK 不等于资料缺失**:`set_ck()` 只重置 `ck_status`;核账会强制重验,若绑定账号变了整组报错,不会误落旧组。 +3. **换 CK 不等于资料缺失**:`set_ck()` 只重置 `ck_status`;核账会强制重验,若绑定账号/角色变了整组报错,不会误落旧组。 --- diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index e610c3b..9a62167 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -2042,6 +2042,7 @@ class TestCollabRecharge(TestCollabBase): gift_name: str, required_points: int | None, role: str = ROLE, + account: str = BIND, ) -> HuyaExternalOrder: order = self._create( order_no=order_no, @@ -2055,11 +2056,11 @@ class TestCollabRecharge(TestCollabBase): self.user, order, client=FakeProbeClient( - FakeBindResp(nick=self.BIND, role=role, plat="苹果(iOS)", area="微信") + FakeBindResp(nick=account, role=role, plat="苹果(iOS)", area="微信") ), ) assert order.status == collab.STATUS_TO_RECHARGE - assert order.bind_game_account == self.BIND + assert order.bind_game_account == account return order @staticmethod @@ -2100,7 +2101,9 @@ class TestCollabRecharge(TestCollabBase): 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 + assert ( + order.points_check_detail["group_key"] == f"{self.BIND} / {self.ROLE}" + ) # 同一 uid 只验一次 CK、只查一次积分 assert len(client.calls) == 1 assert len(client.score_calls) == 1 @@ -2167,6 +2170,43 @@ class TestCollabRecharge(TestCollabBase): assert first.status == second.status == collab.STATUS_RECHARGED assert [call["uid"] for call in client.score_calls] == [1005, 1006] + def test_same_generic_nickname_with_different_roles_are_separate_groups(self): + """生产回归:bind_game_account 可能都是「微信用户」,必须再按角色分组。 + + 只按账号分组会把不同买家合成一组,required_total 被放大成 48000。 + """ + first = self._ready( + "RC-NICK-1", + "1013", + gift_name="礼包A", + required_points=200, + role="角色A", + account="微信用户", + ) + second = self._ready( + "RC-NICK-2", + "1014", + gift_name="礼包B", + required_points=200, + role="角色B", + account="微信用户", + ) + client = FakeRechargeClient( + FakeBindResp(nick="微信用户", role="角色A"), + FakeBindResp(nick="微信用户", role="角色B"), + scores=[self._score(250), self._score(300)], + ) + + result = self._check([first, second], client) + + assert len(result["groups"]) == 2 + assert {group["balance"] for group in result["groups"]} == {250, 300} + assert {group["required_total"] for group in result["groups"]} == {200} + assert first.status == second.status == collab.STATUS_RECHARGED + assert first.points_required_total == second.points_required_total == 200 + assert first.points_check_detail["group_key"] == "微信用户 / 角色A" + assert second.points_check_detail["group_key"] == "微信用户 / 角色B" + def test_ck_invalid_keeps_group_status_unchanged(self): order = self._ready("RC-9", "1007", gift_name="礼包A", required_points=200) client = FakeRechargeClient( diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 0fb3c2b..b369f7d 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -777,7 +777,7 @@ def set_status_batch( # -------------------------------------------------------------------------- -# 充值核账(按 bind_game_account 整组) +# 充值核账(按「绑定账号 + 绑定角色」整组) # -------------------------------------------------------------------------- RECHARGE_GROUP_STATUSES: tuple[str, ...] = ( @@ -786,12 +786,28 @@ RECHARGE_GROUP_STATUSES: tuple[str, ...] = ( STATUS_POINTS_ERROR, ) +# 探针读到的 bind_game_account 往往是「微信用户 / QQ用户」这种通用昵称, +# 只按它分组会把不同买家并成一组(2026-10-19 生产 8 单被合成 48000 分)。 +# 与「同一买家」软识别保持一致:账号 + 角色才是可用的买家指纹。 +RechargeGroupKey = tuple[str, str] -def _recharge_group_orders(db: Session, group_key: str) -> list[HuyaExternalOrder]: + +def _recharge_group_key(order: HuyaExternalOrder) -> RechargeGroupKey | None: + return buyer_key_of(order) + + +def _recharge_group_label(group_key: RechargeGroupKey) -> str: + return f"{group_key[0]} / {group_key[1]}" + + +def _recharge_group_orders( + db: Session, group_key: RechargeGroupKey +) -> list[HuyaExternalOrder]: return ( db.query(HuyaExternalOrder) .filter( - HuyaExternalOrder.bind_game_account == group_key, + HuyaExternalOrder.bind_game_account == group_key[0], + HuyaExternalOrder.bind_game_role == group_key[1], HuyaExternalOrder.status.in_(RECHARGE_GROUP_STATUSES), ) .order_by(HuyaExternalOrder.id.asc()) @@ -866,23 +882,21 @@ def _resolve_group_required_points( def _check_recharge_group( db: Session, current: User | None, - group_key: str, + group_key: RechargeGroupKey, *, threshold: int, client: Any | None = None, ) -> dict: + group_label = _recharge_group_label(group_key) group_orders = _recharge_group_orders(db, group_key) if not group_orders: raise CollabError( - f"绑定账号「{group_key}」下没有可核账的订单", + f"绑定账号「{group_label}」下没有可核账的订单", code="recharge_group_empty", ) - if any( - str(order.bind_game_account or "").strip() != group_key - for order in group_orders - ): + if any(_recharge_group_key(order) != group_key for order in group_orders): raise CollabError( - "核账过程中绑定账号发生变化,请刷新后重试", + "核账过程中绑定账号/角色发生变化,请刷新后重试", code="bind_account_changed", ) @@ -916,9 +930,9 @@ def _check_recharge_group( f"{order.order_no} 的 CK 验证失败:{result.get('message') or '未知原因'}", code="ck_invalid", ) - if str(order.bind_game_account or "").strip() != group_key: + if _recharge_group_key(order) != group_key: raise CollabError( - "核账过程中探针读到的绑定账号发生变化,请刷新后重试", + "核账过程中探针读到的绑定账号/角色发生变化,请刷新后重试", code="bind_account_changed", ) @@ -979,7 +993,9 @@ def _check_recharge_group( now = _now() detail = { - "group_key": group_key, + "group_key": group_label, + "bind_game_account": group_key[0], + "bind_game_role": group_key[1], "threshold": threshold, "balance": balance, "required_total": required_total, @@ -1033,7 +1049,9 @@ def _check_recharge_group( target=_audit_target(order), detail={ "order_no": order.order_no, - "group_key": group_key, + "group_key": group_label, + "bind_game_account": group_key[0], + "bind_game_role": group_key[1], "status": result_status, "balance": balance, "required_total": required_total, @@ -1045,7 +1063,7 @@ def _check_recharge_group( ) return { - "group_key": group_key, + "group_key": group_label, "status": result_status, "status_label": ORDER_STATUS_LABELS.get(result_status, result_status), "balance": balance, @@ -1076,10 +1094,12 @@ def check_recharge_groups( client: Any | None = None, commit: bool = True, ) -> dict: - """按 ``bind_game_account`` 整组核账,并整组落到「已充值 / 积分异常」。 + """按「``bind_game_account`` + ``bind_game_role``」整组核账,并整组落到「已充值 / 积分异常」。 - 规则(2026-10-19 用户拍板): + 规则(2026-10-19 用户拍板,随后按生产数据修正分组键): + - 分组键必须是**账号 + 角色**:探针读到的账号经常是「微信用户 / QQ用户」这种 + 通用昵称,只按账号会把不同买家并成一组(生产 8 单被合成 48000 分); - 只统计同组 ``to_recharge / recharged / points_error`` 的订单; - 组内不同 uid 的 CK 各查一次积分,合计为“账号积分”; - ``结余 = 账号积分合计 - 礼包积分合计``; @@ -1097,18 +1117,18 @@ def check_recharge_groups( ) threshold_value = normalize_points_threshold(threshold) - group_keys: list[str] = [] + group_keys: list[RechargeGroupKey] = [] failures: list[dict] = [] failed_order_count = 0 for order in rows: - key = str(order.bind_game_account or "").strip() - if not key: + key = _recharge_group_key(order) + if key is None: _maybe_demote_missing_data(db, current, order) failures.append( { "id": int(order.id), "order_no": order.order_no, - "message": "缺少绑定账号,已保持/退回「待补资料」", + "message": "缺少绑定账号或绑定角色,已保持/退回「待补资料」", } ) failed_order_count += 1 @@ -1118,6 +1138,7 @@ def check_recharge_groups( groups: list[dict] = [] for key in group_keys: + group_label = _recharge_group_label(key) try: with db.begin_nested(): group = _check_recharge_group( @@ -1133,7 +1154,7 @@ def check_recharge_groups( failed_order_count += max(1, len(group_rows)) groups.append( { - "group_key": key, + "group_key": group_label, "status": "", "status_label": "", "balance": None, @@ -1152,7 +1173,7 @@ def check_recharge_groups( failed_order_count += max(1, len(group_rows)) groups.append( { - "group_key": key, + "group_key": group_label, "status": "", "status_label": "", "balance": None, diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index c082e2d..93e3392 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -585,7 +585,7 @@ export default function HuyaCollabPage() { const [batchStatusTarget, setBatchStatusTarget] = useState('to_recharge'); const [batchStatusReason, setBatchStatusReason] = useState(''); const [batchStatusRollback, setBatchStatusRollback] = useState(false); - // 充值核账弹窗:按 bind_game_account 整组核账,阈值默认 100 + // 充值核账弹窗:按「绑定账号 + 绑定角色」整组核账,阈值默认 100 const [rechargeOpen, setRechargeOpen] = useState(false); const [rechargeIds, setRechargeIds] = useState([]); const [rechargeThreshold, setRechargeThreshold] = useState(readRechargeThreshold); @@ -2488,7 +2488,7 @@ export default function HuyaCollabPage() { )} {canEdit && !isArchiveView && ( - +