From 56e5d0ea1ce72987b3a4463c84060eae677e1c8f Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 12 Nov 2026 16:08:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(huya):=20=E4=BD=9C=E5=BA=9F=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=90=8E=E5=A4=B1=E6=95=88=E6=97=A7=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/设计-虎牙外部CK协作台账.md | 3 + tests/test_huya_collab_orders.py | 70 +++++++++ tests/test_migrations.py | 145 +++++++++++++++++- ..._0078_huya_void_points_snapshot_cleanup.py | 115 ++++++++++++++ web/backend/services/huya_collab_service.py | 106 +++++++++++++ web/frontend/src/pages/HuyaCollabPage.tsx | 1 + 6 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 web/backend/migrations/versions/20261112_0078_huya_void_points_snapshot_cleanup.py diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 955816a..4dae3c7 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -268,6 +268,9 @@ incomplete ─资料齐全且 CK 有效─▶ to_recharge ─充值核账─▶ 探针读到的账号经常只是「微信用户 / QQ用户」这种通用昵称,**只按账号会把不同买家并成一组** (2026-10-19 生产上 8 单被合成 48000 分);组内只统计 `to_recharge / recharged / points_error`,`incomplete / scripted / done / failed / void` 不参与; +- 订单进入/离开 `void` 会改变组内有效订单:立即清空同组旧积分快照;资料仍完整且 CK + 有效的 `recharged / points_error` 行退回 `to_recharge`,审计写 + `huya:collab:recharge_invalidate`。作废后的旧快照不能继续显示为当前统计; - 同一 uid 只验一次 CK、只查一次积分;组内多个 uid 的积分**合计**; - `必充积分 = Σ required_points`;`结余 = 账号积分合计 - 必充积分`; - `结余 < 0` → 整组 `points_error`(`reason=shortage`); diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index 814c95b..b418f05 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -2554,6 +2554,76 @@ class TestCollabRecharge(TestCollabBase): assert "还差 100 分" in first.points_check_message assert first.points_check_reason == second.points_check_reason == "shortage" + def test_void_member_invalidates_stale_group_points_snapshot(self): + first = self._ready("RC-VOID-1", "1101", gift_name="礼包A", required_points=988) + second = self._ready( + "RC-VOID-2", "1101", gift_name="礼包B", required_points=988 + ) + self._check( + [first, second], + FakeRechargeClient( + FakeBindResp(nick=self.BIND, role=self.ROLE), + FakeBindResp(nick=self.BIND, role=self.ROLE), + score=self._score(988), + ), + ) + assert first.status == second.status == collab.STATUS_POINTS_ERROR + assert first.points_required_total == second.points_required_total == 1976 + + collab.set_status( + self.db, + self.user, + second, + collab.STATUS_VOID, + reason="客户退款", + ) + + assert second.status == collab.STATUS_VOID + assert first.status == collab.STATUS_TO_RECHARGE + for order in (first, second): + assert order.points_checked_at is None + assert order.points_balance is None + assert order.points_required_total is None + assert order.points_surplus is None + assert order.points_threshold is None + assert order.points_check_detail is None + assert order.points_check_reason == "stale_group" + assert "原积分统计已失效" in order.points_check_message + assert "huya:collab:recharge_invalidate" in [ + row.action for row in self._audits(first) + ] + + result = self._check( + [first], + FakeRechargeClient( + FakeBindResp(nick=self.BIND, role=self.ROLE), + score=self._score(988), + ), + ) + + assert result["groups"][0]["required_total"] == 988 + assert result["groups"][0]["surplus"] == 0 + assert first.status == collab.STATUS_RECHARGED + + def test_scripted_transition_keeps_completed_points_snapshot(self): + order = self._ready( + "RC-SCRIPT-1", "1102", gift_name="礼包A", required_points=988 + ) + self._check( + [order], + FakeRechargeClient( + FakeBindResp(nick=self.BIND, role=self.ROLE), + score=self._score(988), + ), + ) + checked_at = order.points_checked_at + + collab.set_status(self.db, self.user, order, collab.STATUS_SCRIPTED) + + assert order.status == collab.STATUS_SCRIPTED + assert order.points_checked_at == checked_at + assert order.points_required_total == 988 + def test_excess_marks_whole_group_points_error(self): order = self._ready("RC-6", "1004", gift_name="礼包A", required_points=500) client = FakeRechargeClient( diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 4e53b01..d9cb1b3 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -9,7 +9,9 @@ 并确认迁移新增的复合查询索引确实落库。 """ +import json import secrets +from datetime import UTC, datetime from pathlib import Path from urllib.parse import quote_plus @@ -22,13 +24,13 @@ from sqlalchemy.orm import Session import web.backend.database as database_module from tests.support.db import get_mysql_credentials, get_test_engine from web.backend.database import Base -from web.backend.models import User, WeiboAccount +from web.backend.models import AuditLog, HuyaExternalOrder, User, WeiboAccount TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261112_0077" +HEAD_REVISION = "20261112_0078" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( @@ -267,3 +269,142 @@ class TestMigrationSmoke: mig_engine.dispose() with admin_engine.begin() as conn: conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;")) + + def test_0078_cleans_only_pre_void_points_snapshots(self): + creds = get_mysql_credentials() + mig_db = f"douyu_login_mig_{secrets.token_hex(4)}" + mig_url = ( + f"mysql+pymysql://{quote_plus(creds['user'])}:" + f"{quote_plus(creds['password'])}@{creds['host']}:{creds['port']}/" + f"{mig_db}?charset=utf8mb4" + ) + + admin_engine = get_test_engine() + with admin_engine.begin() as conn: + conn.execute( + text( + f"CREATE DATABASE `{mig_db}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;" + ) + ) + + original_url = database_module.DATABASE_URL + mig_engine = create_engine(mig_url) + try: + database_module.DATABASE_URL = mig_url + cfg = _migration_config() + cfg.set_main_option("sqlalchemy.url", mig_url) + command.upgrade(cfg, "20261112_0077") + + with Session(mig_engine) as session: + user = User( + username="migration-collab", + password_hash="hash", + role="super_admin", + is_active=True, + ) + session.add(user) + session.commit() + + def make_order( + order_no: str, + *, + status: str, + checked_at: datetime, + required_total: int, + surplus: int, + ) -> HuyaExternalOrder: + return HuyaExternalOrder( + order_no=order_no, + shop="向日葵", + uid="1199665363355", + cookie="C" * 32, + raw_cookie="raw", + ck_status="valid", + bind_game_account="泡泡", + bind_game_role="厶婕", + gift_name="套装-芝士鼠鼠", + required_points=988, + home_images=["collab/2026/11/12/home.png"], + status=status, + points_checked_at=checked_at, + points_balance=988, + points_required_total=required_total, + points_surplus=surplus, + points_threshold=100, + points_check_reason="shortage" if surplus < 0 else "ok", + points_check_message="旧核账", + points_check_detail={"orders": []}, + created_by=user.id, + updated_by=user.id, + ) + + stale = make_order( + "MIG-0078-STALE", + status="points_error", + checked_at=datetime(2026, 11, 12, 1, 46, tzinfo=UTC), + required_total=1976, + surplus=-988, + ) + voided = make_order( + "MIG-0078-VOID", + status="void", + checked_at=datetime(2026, 11, 12, 1, 46, tzinfo=UTC), + required_total=1976, + surplus=-988, + ) + current = make_order( + "MIG-0078-CURRENT", + status="recharged", + checked_at=datetime(2026, 11, 12, 7, 51, 50, tzinfo=UTC), + required_total=988, + surplus=0, + ) + session.add_all([stale, voided, current]) + session.commit() + session.add( + AuditLog( + user_id=user.id, + username=user.username, + action="huya:collab:status", + target=f"huya_external_order:{voided.id}", + detail=json.dumps({"to": "void"}), + success=True, + created_at=datetime(2026, 11, 12, 1, 49, 53, tzinfo=UTC), + ) + ) + session.commit() + + command.upgrade(cfg, "20261112_0078") + + with mig_engine.connect() as conn: + rows = { + row.order_no: row + for row in conn.execute( + text( + "SELECT order_no, status, points_checked_at, " + "points_required_total, points_surplus, " + "points_check_reason, points_check_detail " + "FROM huya_external_orders" + ) + ) + } + assert rows["MIG-0078-VOID"].status == "void" + assert rows["MIG-0078-VOID"].points_checked_at is None + assert rows["MIG-0078-VOID"].points_required_total is None + assert rows["MIG-0078-VOID"].points_check_reason == "stale_group" + + assert rows["MIG-0078-STALE"].status == "to_recharge" + assert rows["MIG-0078-STALE"].points_checked_at is None + assert rows["MIG-0078-STALE"].points_required_total is None + assert rows["MIG-0078-STALE"].points_check_reason == "stale_group" + + assert rows["MIG-0078-CURRENT"].status == "recharged" + assert rows["MIG-0078-CURRENT"].points_required_total == 988 + assert rows["MIG-0078-CURRENT"].points_surplus == 0 + assert rows["MIG-0078-CURRENT"].points_check_reason == "ok" + finally: + database_module.DATABASE_URL = original_url + mig_engine.dispose() + with admin_engine.begin() as conn: + conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;")) diff --git a/web/backend/migrations/versions/20261112_0078_huya_void_points_snapshot_cleanup.py b/web/backend/migrations/versions/20261112_0078_huya_void_points_snapshot_cleanup.py new file mode 100644 index 0000000..1793b05 --- /dev/null +++ b/web/backend/migrations/versions/20261112_0078_huya_void_points_snapshot_cleanup.py @@ -0,0 +1,115 @@ +"""清理虎牙外部单协作中作废订单遗留的积分核账快照。 + +作废只代表订单不再参与后续核账,但历史版本没有清掉作废前写入的组积分合计, +导致作废行以及尚未重算的同组订单继续显示旧的需求/结余。这里做一次幂等修复: + +1. 所有 ``void`` 行清空积分快照; +2. 同组仍在核账状态、且快照早于最近一次作废动作的订单,也清空快照; +3. 资料完整且 CK 仍有效的 ``recharged / points_error`` 行退回 ``to_recharge``, + 提示重新核账。 + +实时状态切换已在服务层同步失效快照;本迁移只修复升级前已经产生的历史数据。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261112_0078" +down_revision: str | None = "20261112_0077" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "huya_external_orders" +STALE_MESSAGE = "订单组成员已作废或恢复,原积分统计已失效,请重新核账" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table(TABLE) or not inspector.has_table("audit_logs"): + return + + # 作废行不再参与任何后续核账,旧快照只保留在审计历史里。 + bind.execute( + sa.text( + f""" + UPDATE {TABLE} + SET points_checked_at = NULL, + points_balance = NULL, + points_required_total = NULL, + points_surplus = NULL, + points_threshold = NULL, + points_check_reason = 'stale_group', + points_check_message = :message, + points_check_detail = NULL + WHERE status = 'void' + AND ( + points_checked_at IS NOT NULL + OR points_balance IS NOT NULL + OR points_required_total IS NOT NULL + OR points_surplus IS NOT NULL + OR points_threshold IS NOT NULL + OR points_check_detail IS NOT NULL + ) + """ + ), + {"message": STALE_MESSAGE}, + ) + + # 只处理“快照早于最近一次作废”的同组行;作废后又重新核账过的结果必须保留。 + bind.execute( + sa.text( + f""" + UPDATE {TABLE} AS orders + JOIN ( + SELECT void_order.bind_game_account, + void_order.bind_game_role, + MAX(audit.created_at) AS voided_at + FROM {TABLE} AS void_order + JOIN audit_logs AS audit + ON audit.target = CONCAT('huya_external_order:', void_order.id) + AND audit.action = 'huya:collab:status' + AND JSON_UNQUOTE(JSON_EXTRACT(audit.detail, '$.to')) = 'void' + WHERE void_order.status = 'void' + AND void_order.bind_game_account <> '' + AND void_order.bind_game_role <> '' + GROUP BY void_order.bind_game_account, void_order.bind_game_role + ) AS void_groups + ON orders.bind_game_account = void_groups.bind_game_account + AND orders.bind_game_role = void_groups.bind_game_role + SET orders.status = CASE + WHEN orders.status IN ('recharged', 'points_error') + AND orders.ck_status = 'valid' + AND orders.cookie IS NOT NULL + AND orders.cookie <> '' + AND orders.gift_name <> '' + AND COALESCE(JSON_LENGTH(orders.home_images), 0) > 0 + THEN 'to_recharge' + ELSE orders.status + END, + orders.recharged_at = NULL, + orders.scripted_at = NULL, + orders.done_at = NULL, + orders.points_checked_at = NULL, + orders.points_balance = NULL, + orders.points_required_total = NULL, + orders.points_surplus = NULL, + orders.points_threshold = NULL, + orders.points_check_reason = 'stale_group', + orders.points_check_message = :message, + orders.points_check_detail = NULL + WHERE orders.status IN ('to_recharge', 'recharged', 'points_error') + AND orders.points_checked_at IS NOT NULL + AND orders.points_checked_at <= void_groups.voided_at + """ + ), + {"message": STALE_MESSAGE}, + ) + + +def downgrade() -> None: + """数据修复不可逆;旧快照仍保留在审计日志中,无法可靠恢复当前展示字段。""" diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 0379b00..3d08981 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -730,6 +730,14 @@ def set_status( "allow_recharge": bool(allow_recharge), }, ) + if target == STATUS_VOID or from_status == STATUS_VOID: + _invalidate_recharge_group_after_void( + db, + current, + order, + changed_from=from_status, + changed_to=target, + ) if commit: db.commit() db.refresh(order) @@ -827,6 +835,17 @@ RECHARGE_GROUP_STATUSES: tuple[str, ...] = ( STATUS_RECHARGED, STATUS_POINTS_ERROR, ) +_POINTS_SNAPSHOT_FIELDS: tuple[str, ...] = ( + "points_checked_at", + "points_balance", + "points_required_total", + "points_surplus", + "points_threshold", + "points_check_reason", + "points_check_message", + "points_check_detail", +) +_STALE_RECHARGE_MESSAGE = "订单组成员已作废或恢复,原积分统计已失效,请重新核账" # 探针读到的 bind_game_account 往往是「微信用户 / QQ用户」这种通用昵称, # 只按它分组会把不同买家并成一组(2026-10-19 生产 8 单被合成 48000 分)。 @@ -857,6 +876,93 @@ def _recharge_group_orders( ) +def _clear_points_snapshot(order: HuyaExternalOrder) -> None: + order.points_checked_at = None + order.points_balance = None + order.points_required_total = None + order.points_surplus = None + order.points_threshold = None + order.points_check_reason = "" + order.points_check_message = "" + order.points_check_detail = None + + +def _invalidate_recharge_group_after_void( + db: Session, + current: User | None, + changed_order: HuyaExternalOrder, + *, + changed_from: str, + changed_to: str, +) -> None: + """订单作废/恢复后,让同组旧核账结果失效,避免继续展示作废前的合计。""" + + group_key = buyer_key_of(changed_order) + if group_key is None: + return + rows = ( + db.query(HuyaExternalOrder) + .filter( + HuyaExternalOrder.bind_game_account == group_key[0], + HuyaExternalOrder.bind_game_role == group_key[1], + or_( + HuyaExternalOrder.id == changed_order.id, + HuyaExternalOrder.status.in_(RECHARGE_GROUP_STATUSES), + ), + ) + .order_by(HuyaExternalOrder.id.asc()) + .all() + ) + now = _now() + for row in rows: + previous_status = row.status + previous_snapshot = { + field: getattr(row, field) + for field in ( + "points_checked_at", + "points_balance", + "points_required_total", + "points_surplus", + ) + } + had_snapshot = any( + getattr(row, field) not in (None, "") for field in _POINTS_SNAPSHOT_FIELDS + ) + next_status = previous_status + if ( + row.id != changed_order.id + and previous_status in (STATUS_RECHARGED, STATUS_POINTS_ERROR) + and had_snapshot + and _is_ready_for_recharge(row) + ): + next_status = STATUS_TO_RECHARGE + row.status = next_status + if row.bound_at is None: + row.bound_at = now + _sync_stage_timestamps(row, next_status, now) + if not had_snapshot and next_status == previous_status: + continue + + _clear_points_snapshot(row) + row.points_check_reason = "stale_group" + row.points_check_message = _STALE_RECHARGE_MESSAGE + _touch(row, current) + record_audit( + db, + current, + action="huya:collab:recharge_invalidate", + target=_audit_target(row), + detail={ + "source_order_no": changed_order.order_no, + "source_change": f"{changed_from}->{changed_to}", + "group_key": _recharge_group_label(group_key), + "from_status": previous_status, + "to_status": next_status, + "previous": previous_snapshot, + }, + ) + + def _resolve_group_required_points( db: Session, current: User | None, diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 8c1d200..c6efe53 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -154,6 +154,7 @@ const ACTION_LABELS: Record = { 'huya:collab:ck': 'CK 变更', 'huya:collab:ck_status': 'CK 验证', 'huya:collab:probe_snapshot': '绑定快照刷新', + 'huya:collab:recharge_invalidate': '核账快照失效', 'huya:collab:image': '图片变更', 'huya:collab:delete': '删除', };