From b4801336c5c94480962ffaee2d87e359cdd5ef5e Mon Sep 17 00:00:00 2001 From: yml Date: Sat, 14 Nov 2026 10:22:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(huya):=20=E6=94=B9=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E6=A0=A1=E9=AA=8C=20CK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_huya_collab_orders.py | 94 +++++++++++- web/backend/routers/huya/collab.py | 7 +- web/backend/services/huya_collab_service.py | 152 +++++++++++++++++++- web/frontend/src/pages/HuyaCollabPage.tsx | 6 +- 4 files changed, 245 insertions(+), 14 deletions(-) diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 2cf27ae..72424d8 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -39,6 +39,7 @@ from web.backend.schemas import ( HuyaExternalOrderOut, HuyaExternalOrderRechargeOut, HuyaExternalOrderStatusBatchRequest, + HuyaExternalOrderStatusRequest, HuyaExternalOrderUpdate, ) from web.backend.services import huya_collab_service as collab @@ -371,6 +372,61 @@ class TestCollabStatusMachine(TestCollabBase): ) assert order.status == collab.STATUS_TO_RECHARGE + def test_status_change_auto_verifies_ck_for_to_recharge(self): + """工作台改状态时不再要求用户先手工点「验证 CK」。""" + order = self._create( + order_no="AUTO-STATUS-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")) + assert order.ck_status == "unverified" + assert order.bind_game_role == "" + + collab.set_status( + self.db, + self.user, + order, + collab.STATUS_TO_RECHARGE, + auto_verify_ck=True, + client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")), + ) + + assert order.status == collab.STATUS_TO_RECHARGE + assert order.ck_status == collab.CK_VALID + assert order.bind_game_account == "微信用户" + assert order.bind_game_role == "吃鸡199706" + + def test_status_route_auto_verifies_unverified_ck(self, monkeypatch): + order = self._create( + order_no="AUTO-STATUS-ROUTE", + gift_name="不朽伊人大礼包", + required_points=8000, + home_images=["collab/2026/10/16/home01.png"], + ) + collab.set_ck(self.db, self.user, order, fake_ck("1199647005678")) + monkeypatch.setattr( + collab, + "_build_probe_client", + lambda: FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")), + ) + + out = collab_api.update_external_order_status( + order.id, + HuyaExternalOrderStatusRequest( + status=collab.STATUS_TO_RECHARGE, + expected_version=order.version, + ), + db=self.db, + current=self.user, + ) + + self.db.refresh(order) + assert out["status"] == collab.STATUS_TO_RECHARGE + assert order.ck_status == collab.CK_VALID + assert order.bind_game_role == "吃鸡199706" + def test_forward_flow_sets_timestamps(self): order = self._bind_ready(self._create()) assert order.status == collab.STATUS_TO_RECHARGE @@ -2459,6 +2515,40 @@ class TestCollabRecharge(TestCollabBase): row.action for row in self._audits(first) ] + def test_recharge_auto_refreshes_changed_binding_and_requires_retry(self): + """核账前自动验 CK;换绑时保存新快照并中止,不把旧单充到新角色。""" + order = self._ready("RC-AUTO-1", "1015", gift_name="礼包A", required_points=200) + assert order.bind_game_role == self.ROLE + + changed = self._check( + [order], + FakeRechargeClient( + FakeBindResp(nick=self.BIND, role="角色B"), + score=self._score(200), + ), + ) + + assert changed["updated"] == 0 + assert changed["failed"] == 1 + assert changed["groups"] == [] + assert "CK 已自动刷新" in changed["failures"][0]["message"] + assert "角色B" in changed["failures"][0]["message"] + assert order.status == collab.STATUS_TO_RECHARGE + assert order.bind_game_role == "角色B" + assert order.points_checked_at is None + + retried = self._check( + [order], + FakeRechargeClient( + FakeBindResp(nick=self.BIND, role="角色B"), + score=self._score(200), + ), + ) + + assert retried["updated"] == 1 + assert retried["failed"] == 0 + assert order.status == collab.STATUS_RECHARGED + def test_threshold_boundary_is_allowed(self): order = self._ready("RC-3", "1002", gift_name="礼包A", required_points=500) client = FakeRechargeClient( @@ -2724,8 +2814,8 @@ class TestCollabRecharge(TestCollabBase): result = self._check([order], client) assert result["failed"] == 1 - assert result["groups"][0]["status"] == "" - assert "CK 验证失败" in result["groups"][0]["message"] + assert result["groups"] == [] + assert "CK 验证失败" in result["failures"][0]["message"] assert order.status == collab.STATUS_TO_RECHARGE assert order.points_checked_at is None diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index fd4dee3..7142389 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -468,7 +468,7 @@ def recharge_external_orders( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:collab:edit")), ): - """充值核账:按绑定账号整组校验 CK + 积分,整组落「已充值 / 积分异常」。""" + """充值核账:自动刷新 CK/绑定快照后,按绑定账号整组校验积分并落状态。""" try: orders = collab.list_orders_by_ids(db, req.ids) result = collab.check_recharge_groups( @@ -494,6 +494,7 @@ def batch_update_external_order_status( """勾选多行一次改业务状态(单个也走这里)。 人工切换允许**往前跳步**(待补资料 → 已上脚本),回退要带 ``rollback=true``; + 进入「待充值」时若 CK 尚未验证,服务层自动跑一次只读探针; 「已充值 / 积分异常」不走这里,必须用 ``POST /external-orders/recharge`` 整组核账。 """ try: @@ -506,6 +507,7 @@ def batch_update_external_order_status( reason=req.reason, rollback=req.rollback, force=req.force, + auto_verify_ck=req.status == collab.STATUS_TO_RECHARGE, ) except collab.CollabError as exc: raise _http_error(exc) from exc @@ -760,7 +762,7 @@ def update_external_order_status( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:collab:edit")), ): - """推进/回退业务状态。回退必须显式带 ``rollback=true``。""" + """推进/回退业务状态;进入待充值会自动补验未验证的 CK。""" try: order = collab.get_order(db, order_id) collab.assert_version(order, req.expected_version) @@ -772,6 +774,7 @@ def update_external_order_status( reason=req.reason, rollback=req.rollback, force=req.force, + auto_verify_ck=req.status == collab.STATUS_TO_RECHARGE, ) except collab.CollabError as exc: raise _http_error(exc) from exc diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index fbddc5a..caa0d0f 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -637,6 +637,8 @@ def set_status( rollback: bool = False, force: bool = False, allow_recharge: bool = False, + auto_verify_ck: bool = False, + client: Any | None = None, commit: bool = True, ) -> dict: """推进/回退业务状态(本模块唯一的 ``status`` 写入口)。 @@ -644,6 +646,9 @@ def set_status( ``recharged`` / ``points_error`` 只能由充值核账流程写入(``allow_recharge=True``); 普通人工状态接口不允许绕过核账。 + ``auto_verify_ck=True`` 时,进入「待充值」若 CK 尚未有效,会先自动跑一次只读探针, + 用户不需要再单独点「验证 CK」。 + ``force=True`` 仅用于超级管理员把 ``void`` 恢复到流程内:必须填写原因, 只跳过“作废不可逆”限制,不绕过资料完整性与核账校验。 """ @@ -694,6 +699,15 @@ def set_status( code="status_from_void", ) + if target == STATUS_TO_RECHARGE and auto_verify_ck and order.ck_status != CK_VALID: + # 工作台改状态时自动跑一次只读探针:用户不必先手工点「验证 CK」。 + probe = verify_ck(db, current, order, client=client, commit=False) + if not probe.get("ok"): + raise CollabError( + f"CK 验证失败:{probe.get('message') or '未知原因'}", + code="ck_invalid", + ) + if from_status == STATUS_INCOMPLETE and target not in ( STATUS_VOID, STATUS_FAILED, @@ -756,11 +770,14 @@ def set_status_batch( reason: str = "", rollback: bool = False, force: bool = False, + auto_verify_ck: bool = False, + client: Any | None = None, ) -> dict: """批量改状态:勾选多行一次切到同一个状态(部分失败不影响其它行)。 逐行用 SAVEPOINT 隔离(同 :func:`import_orders` 的做法):某一行因为前置校验没过 (例如资料不全进不了「待充值」)只记在这一条的 ``message`` 里,整体不整体失败。 + ``auto_verify_ck=True`` 会透传给单行状态机,在进入「待充值」前自动补做 CK 探针。 上限与批量验证一致(``PROBE_BATCH_MAX``)—— 一次别点太多,人会看不过来。 """ rows = list(orders) @@ -790,6 +807,8 @@ def set_status_batch( reason=reason, rollback=rollback, force=force, + auto_verify_ck=auto_verify_ck, + client=client, commit=False, ) except CollabError as exc: @@ -864,6 +883,103 @@ def _recharge_group_label(group_key: RechargeGroupKey) -> str: return f"{group_key[0]} / {group_key[1]}" +def _preflight_recharge_binding( + db: Session, + current: User | None, + orders: Sequence[HuyaExternalOrder], + *, + client: Any | None, +) -> tuple[set[int], set[int], list[dict[str, Any]]]: + """核账前自动刷新绑定快照,并拦下真正发生换绑的订单。 + + 返回 ``(verified_uids, failed_ids, failures)``。快照更新保留在本次事务里; + 即使随后因换绑中止核账,也会在调用方统一提交,用户下次核账不必再手工验证。 + """ + by_uid: dict[str, list[HuyaExternalOrder]] = {} + for order in orders: + uid = str(order.uid or "").strip() + if uid: + by_uid.setdefault(uid, []).append(order) + + verified_uids: set[int] = set() + failed_ids: set[int] = set() + failures: list[dict[str, Any]] = [] + for uid_text, rows in by_uid.items(): + representative = rows[0] + if not str(representative.cookie or "").strip(): + continue + before_by_id = { + int(order.id): ( + str(order.bind_game_account or ""), + str(order.bind_game_role or ""), + ) + for order in rows + } + try: + probe = verify_ck( + db, + current, + representative, + client=client, + commit=False, + ) + except CollabError as exc: + for order in rows: + failures.append( + { + "id": int(order.id), + "order_no": order.order_no, + "message": exc.detail, + } + ) + failed_ids.add(int(order.id)) + continue + + if not probe.get("ok"): + message = f"CK 验证失败:{probe.get('message') or '未知原因'}" + for order in rows: + failures.append( + { + "id": int(order.id), + "order_no": order.order_no, + "message": message, + } + ) + failed_ids.add(int(order.id)) + continue + + binding = probe.get("binding") or {} + after_identity = ( + str(binding.get("bind_game_account") or ""), + str(binding.get("bind_game_role") or ""), + ) + changed = [ + order for order in rows if before_by_id[int(order.id)] != after_identity + ] + if changed: + before_label = " / ".join( + str(part or "-") for part in before_by_id[int(changed[0].id)] + ) + after_label = " / ".join(str(part or "-") for part in after_identity) + for order in rows: + failures.append( + { + "id": int(order.id), + "order_no": order.order_no, + "message": ( + "CK 已自动刷新,绑定由 " + f"「{before_label}」变为「{after_label}」;" + "本次未核账,请确认角色后重试" + ), + } + ) + failed_ids.add(int(order.id)) + continue + + verified_uids.add(int(uid_text)) + return verified_uids, failed_ids, failures + + def _recharge_group_orders( db: Session, group_key: RechargeGroupKey ) -> list[HuyaExternalOrder]: @@ -1058,6 +1174,7 @@ def _check_recharge_group( confirm_points_error: bool = True, confirmation: Mapping[str, Any] | None = None, score_source: str = "recharge_check", + verified_uids: set[int] | None = None, ) -> dict: group_label = _recharge_group_label(group_key) group_orders = _recharge_group_orders(db, group_key) @@ -1096,6 +1213,8 @@ def _check_recharge_group( ) for uid, order in representatives.items(): + if verified_uids is not None and int(uid) in verified_uids: + continue result = verify_ck(db, current, order, client=client, commit=False) if not result.get("ok"): raise CollabError( @@ -1318,6 +1437,7 @@ def check_recharge_groups( 通用昵称,只按账号会把不同买家并成一组(生产 8 单被合成 48000 分); - 只统计同组 ``to_recharge / recharged / points_error`` 的订单; - 组内不同 uid 的 CK 各查一次积分,合计为“账号积分”; + - 核账前统一自动刷新绑定快照;如角色发生换绑,只保存新快照并中止,待人工确认; - ``结余 = 账号积分合计 - 礼包积分合计``; - ``结余 < 0`` 或 ``结余 > 阈值`` → 整组 ``points_error``; - 其余 → 整组 ``recharged``; @@ -1335,7 +1455,7 @@ def check_recharge_groups( group_keys: list[RechargeGroupKey] = [] failures: list[dict] = [] - failed_order_count = 0 + eligible_rows: list[HuyaExternalOrder] = [] for order in rows: key = _recharge_group_key(order) if key is None: @@ -1347,7 +1467,23 @@ def check_recharge_groups( "message": "缺少绑定账号或绑定角色,已保持/退回「待补资料」", } ) - failed_order_count += 1 + continue + eligible_rows.append(order) + + verified_uids, preflight_failed_ids, preflight_failures = ( + _preflight_recharge_binding( + db, + current, + eligible_rows, + client=client, + ) + ) + failures.extend(preflight_failures) + for order in eligible_rows: + if int(order.id) in preflight_failed_ids: + continue + key = _recharge_group_key(order) + if key is None: continue if key not in group_keys: group_keys.append(key) @@ -1364,6 +1500,7 @@ def check_recharge_groups( threshold=threshold_value, client=client, confirm_points_error=False, + verified_uids=verified_uids, ) if group.get("_needs_confirmation"): pending_confirmation[key] = group @@ -1371,7 +1508,6 @@ def check_recharge_groups( group_results[key] = group except CollabError as exc: group_rows = _recharge_group_orders(db, key) - failed_order_count += max(1, len(group_rows)) group_results[key] = _failed_recharge_group_result( key, group_rows, @@ -1381,7 +1517,6 @@ def check_recharge_groups( 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)) group_results[key] = _failed_recharge_group_result( key, group_rows, @@ -1415,11 +1550,11 @@ def check_recharge_groups( "first_reason": first.get("reason"), }, score_source="recharge_retry", + verified_uids=verified_uids, ) group_results[key] = group except CollabError as exc: group_rows = _recharge_group_orders(db, key) - failed_order_count += max(1, len(group_rows)) group_results[key] = _failed_recharge_group_result( key, group_rows, @@ -1431,7 +1566,6 @@ def check_recharge_groups( f"[huya-collab] 充值核账二次确认失败:{type(exc).__name__}: {exc}" ) group_rows = _recharge_group_orders(db, key) - failed_order_count += max(1, len(group_rows)) group_results[key] = _failed_recharge_group_result( key, group_rows, @@ -1444,6 +1578,10 @@ def check_recharge_groups( updated = sum( len(group.get("order_ids") or []) for group in groups if group.get("status") ) + failed_ids = {int(item["id"]) for item in failures if item.get("id") is not None} + for group in groups: + if not group.get("status"): + failed_ids.update(int(item) for item in group.get("order_ids") or []) if commit: db.commit() for order in rows: @@ -1451,7 +1589,7 @@ def check_recharge_groups( return { "total": len(rows), "updated": updated, - "failed": failed_order_count, + "failed": len(failed_ids), "threshold": threshold_value, "confirmed_groups": len(pending_confirmation), "confirmation_delay_seconds": delay, diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 6a1eb8d..544b4ca 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -1308,7 +1308,7 @@ export default function HuyaCollabPage() { ? `${group.group_key}:${group.message}` : `${group.group_key}:${group.message}`, })); - if (lines.length === 0) { + if (lines.length === 0 && result.failures.length === 0) { message.warning('没有可核账的订单'); } else { modal.info({ @@ -2625,7 +2625,7 @@ export default function HuyaCollabPage() { )} {canEdit && !isArchiveView && ( - +