fix(huya): 作废订单后失效旧积分快照

This commit is contained in:
yml
2026-11-12 16:08:13 +08:00
parent 2ae90ef78f
commit 56e5d0ea1c
6 changed files with 438 additions and 2 deletions
@@ -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`);
+70
View File
@@ -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(
+143 -2
View File
@@ -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}`;"))
@@ -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:
"""数据修复不可逆;旧快照仍保留在审计日志中,无法可靠恢复当前展示字段。"""
+106
View File
@@ -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,
@@ -154,6 +154,7 @@ const ACTION_LABELS: Record<string, string> = {
'huya:collab:ck': 'CK 变更',
'huya:collab:ck_status': 'CK 验证',
'huya:collab:probe_snapshot': '绑定快照刷新',
'huya:collab:recharge_invalidate': '核账快照失效',
'huya:collab:image': '图片变更',
'huya:collab:delete': '删除',
};