- 根因: 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>
60 lines
2.7 KiB
Python
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 '见上'}")
|