"""修复 reserved_balance 冻结漂移(审计 I2)存量坏账。 根因:CreditReservation.task 是 OneToOneField(on_delete=CASCADE) —— 删任务/删项目 级联删掉 ACTIVE 预留行,但 account.reserved_balance 冻结的份额永不归还。代码侧已由 apps/billing/signals.py 的 pre_delete 守护堵住;本脚本回填历史漂移: 对每个账户,在行锁事务内令 reserved_balance = Σ(ACTIVE 预留),差额落一条 RELEASE 流水(task/user 为空,reason 注明修复),与 release_credit 的记账口径一致。 跑法(默认 dry-run 只报告;--apply 才写库): cd backend && DJANGO_SETTINGS_MODULE=airshelf.settings.development \ .venv/bin/python ../qa/repair_reserved_drift.py [--apply] """ import os import sys from decimal import Decimal sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development") import django # noqa: E402 django.setup() from django.db import transaction # noqa: E402 from django.db.models import Sum # noqa: E402 from apps.billing.models import CreditAccount, CreditLedger, CreditReservation # noqa: E402 apply_mode = "--apply" in sys.argv fixed = 0 for account_id in CreditAccount.objects.values_list("id", flat=True): with transaction.atomic(): account = CreditAccount.objects.select_for_update().get(id=account_id) active_sum = ( CreditReservation.objects.filter(team=account.team, status=CreditReservation.Status.ACTIVE) .aggregate(s=Sum("amount"))["s"] or Decimal("0") ) drift = account.reserved_balance - active_sum if drift == 0: continue print(f"[{account.team}] reserved={account.reserved_balance} ACTIVEΣ={active_sum} 漂移={drift}" f" -> {'修复' if apply_mode else 'dry-run 不写'}") if drift < 0: # reserved 比 ACTIVE 还小属另一种坏账(理论不该出现),不自动动,人工核 print(" !! 漂移为负,跳过(需人工核查)") continue if not apply_mode: continue account.reserved_balance = active_sum account.save(update_fields=["reserved_balance", "updated_at"]) CreditLedger.objects.create( team=account.team, ledger_type=CreditLedger.Type.RELEASE, amount=drift, balance_after=account.balance, reason="修复:任务删除级联吞预留的历史漂移回填(I2)", metadata={"kind": "repair_reserved_drift"}, ) fixed += 1 print(f"\n{'已修复' if apply_mode else '待修复(dry-run)'}账户数:{fixed if apply_mode else '见上'}")