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>
This commit is contained in:
zyc
2026-07-02 13:07:09 +08:00
co-authored by Claude Fable 5
parent aab4d7ea4c
commit dcc5d7fe50
43 changed files with 4860 additions and 3143 deletions
+3
View File
@@ -5,3 +5,6 @@ class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.billing"
def ready(self):
from . import signals # noqa: F401 — 挂 AITask pre_delete 预留释放守护
+25
View File
@@ -0,0 +1,25 @@
"""跨 app 账目守护信号。
CreditReservation.task 是 OneToOneField(on_delete=CASCADE):AITask 被硬删(用户删项目
→ AITask.project CASCADE 级联删任务、或直接删任务)时,预留行会被一并删掉。若该预留
还处于 ACTIVE,account.reserved_balance 里冻结的份额就永远退不回来 —— 这是资金审计
I2「冻结不平」的根因(ZWQ-Test / 演示团队实测漂移 ¥10 / ¥30,且随测试删项目持续增长)。
修法:在 AITask 删除前(pre_delete,级联删除同样触发)把仍 ACTIVE 的预留正常走
release_credit 释放 —— 退还冻结、落 RELEASE 流水,然后再让级联删掉预留行。
release_credit 自带行锁 + 幂等(非 ACTIVE 直接返回),并发安全。
"""
from django.db.models.signals import pre_delete
from django.dispatch import receiver
@receiver(pre_delete, sender="ai.AITask", dispatch_uid="billing.release_reservation_before_task_delete")
def release_reservation_before_task_delete(sender, instance, **kwargs):
from apps.billing.models import CreditReservation
from apps.billing.services.ledger import release_credit
reservation = CreditReservation.objects.filter(
task=instance, status=CreditReservation.Status.ACTIVE
).first()
if reservation is not None:
release_credit(reservation=reservation, reason="任务删除,释放未结预留")
+42
View File
@@ -59,6 +59,48 @@ class CreditLedgerTests(TestCase):
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1)
def test_task_delete_releases_active_reservation(self):
"""回归:CreditReservation.task 是 CASCADE,直接删任务会把 ACTIVE 预留行吞掉,
reserved_balance 冻结永不归还(审计 I2 冻结不平根因)。pre_delete 信号必须先释放。"""
reserve_credit(team=self.team, user=self.user, task=self.task, amount=Decimal("10.0000"))
self.task.delete()
self.account.refresh_from_db()
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(self.account.balance, Decimal("100.0000"))
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1)
self.assertFalse(CreditReservation.objects.exists()) # 预留行随任务级联删除
def test_project_delete_cascade_releases_reservation(self):
"""回归:删项目 → AITask.project CASCADE 级联删任务,同样必须先释放 ACTIVE 预留。"""
from apps.products.models import Product
from apps.projects.models import Project
product = Product.objects.create(team=self.team, created_by=self.user, title="P 商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="P")
task = AITask.objects.create(
team=self.team, created_by=self.user, project=project,
task_type=AITask.Type.SCRIPT_GENERATION, model_config=self.model,
idempotency_key="billing-test-task-proj", estimated_cost=Decimal("5.0000"),
)
reserve_credit(team=self.team, user=self.user, task=task, amount=Decimal("5.0000"))
project.delete()
self.account.refresh_from_db()
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertTrue(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE, reason="任务删除,释放未结预留").exists())
def test_task_delete_after_charge_does_not_double_release(self):
"""已扣费(CHARGED)的预留再删任务,信号不应重复释放(release_credit 幂等 + 信号只挑 ACTIVE)。"""
reservation = reserve_credit(team=self.team, user=self.user, task=self.task, amount=Decimal("10.0000"))
charge_reserved_credit(reservation=reservation, actual_amount=Decimal("10.0000"))
self.task.delete()
self.account.refresh_from_db()
self.assertEqual(self.account.balance, Decimal("90.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 0) # 无多余释放
class MemberLimitTests(TestCase):
"""成员三档额度(每日/每月/累计总额,0=不限)在 reserve_credit 时逐档管控(PMC#17/#18)。"""