Files
yingqing/core/qa/repair_reserved_drift.py
T
zycandClaude Fable 5 dcc5d7fe50 fix(billing): 删任务/删项目级联吞 ACTIVE 预留致 reserved_balance 永久冻结(审计 I2)
- 根因: CreditReservation.task 是 OneToOneField(CASCADE), AITask.project 也是 CASCADE
  —— 删项目→级联删任务→ACTIVE 预留行被吞, 账户冻结份额永不归还(实测 4 账户漂移共 ¥42,
  且随删项目持续增长)
- 修法: billing/signals.py 挂 AITask pre_delete, 删除前先 release_credit 释放(级联同样
  触发, release 自带行锁+幂等); apps.py ready() 接线
- 回归测试 x3: 直删任务/删项目级联/已扣费不重复释放
- qa/repair_reserved_drift.py: 存量漂移回填脚本(dry-run 默认, --apply 落 RELEASE 流水),
  已对线上 4 账户执行并复验清零
- qa/function-audit/output: 本轮 18 页模拟点击审计报告(0 真死按钮 0 JS 报错)

验证: 后端测试 213/213 通过, django check 无警告
补齐 aab4d7e: 该提交带走了 signals.py 之外的测试轮改动, 信号挂载点与回归测试在本提交收口

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 13:07:09 +08:00

60 lines
2.7 KiB
Python

"""修复 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 '见上'}")