feat(adminpanel): 任务监控「回收退款」手动兜底

新增 POST /api/admin/tasks/<id>/reap/:仅限卡在「已预留」超 10 分钟的僵尸任务(SUBMITTED/
POLLING 故意不许杀,视频合法耗时 5-10 分钟+,强杀会与轮询结算竞态),标失败 + 退还冻结积分,
与自动回收 _reap_stale_standalone_image_tasks 同款账务路径,幂等。

自动回收只在同团队下次提交时顺手触发,团队弃用某功能后冻结积分会永久躺着——这是给超管的
手动出口。序列化器加 reapable 字段(与端点闸同口径),任务监控行级「回收退款」按钮。
4 条测试(成功退款+审计/窗口内拒绝/非 RESERVED 拒绝/非超管 403)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-07 10:38:45 +08:00
co-authored by Claude Sonnet 5
parent 299b2c9ecd
commit d466bd60c6
7 changed files with 147 additions and 1 deletions
+61
View File
@@ -401,6 +401,67 @@ class AdminTaskMonitorTests(TestCase):
def test_retry_requires_admin(self):
self.assertEqual(self.nc.post(f"/api/admin/tasks/{self.t_failed.id}/retry/").status_code, 403)
# ── 手动回收僵尸任务(reap):标失败 + 退预留;10 分钟窗口内/非 RESERVED/非超管全拒 ──
def _mk_stuck_reserved(self, key: str, minutes_ago: int = 30):
"""卡死任务工厂:RESERVED + 冻结 20 积分 + 回拨 updated_at(auto_now 只能 queryset.update 绕)。"""
from datetime import timedelta
from django.utils import timezone
from apps.billing.models import CreditAccount
from apps.billing.services.ledger import reserve_credit
CreditAccount.objects.get_or_create(team=self.team, defaults={"balance": Decimal("1000")})
task = self.AITask.objects.create(
team=self.team, model_config=self.mc, task_type=self.AITask.Type.PRODUCT_IMAGE,
status=self.AITask.Status.RESERVED, estimated_cost="20", idempotency_key=key,
)
reserve_credit(team=self.team, user=self.normal, task=task, amount=Decimal("20"))
self.AITask.objects.filter(id=task.id).update(updated_at=timezone.now() - timedelta(minutes=minutes_ago))
task.refresh_from_db()
return task
def test_reap_stuck_reserved_refunds(self):
from apps.billing.models import CreditAccount, CreditReservation
task = self._mk_stuck_reserved("k-reap-stuck")
acct = CreditAccount.objects.get(team=self.team)
self.assertEqual(acct.reserved_balance, Decimal("20"))
# 列表侧 reapable 标记(前端据此显示按钮),与端点闸同口径
row = next(t for t in self.ac.get("/api/admin/tasks/?status=reserved").data["results"] if t["id"] == str(task.id))
self.assertTrue(row["reapable"])
r = self.ac.post(f"/api/admin/tasks/{task.id}/reap/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["status"], "failed")
self.assertFalse(r.data["reapable"])
task.refresh_from_db()
self.assertEqual(task.status, self.AITask.Status.FAILED)
acct.refresh_from_db()
self.assertEqual(acct.reserved_balance, Decimal("0"))
self.assertEqual(task.credit_reservation.status, CreditReservation.Status.RELEASED)
self.assertTrue(AdminAuditLog.objects.filter(action="task.reap").exists())
# 幂等收口:已终态再回收 → 400,不会双退
self.assertEqual(self.ac.post(f"/api/admin/tasks/{task.id}/reap/").status_code, 400)
def test_reap_fresh_reserved_rejected(self):
task = self._mk_stuck_reserved("k-reap-fresh", minutes_ago=0)
r = self.ac.post(f"/api/admin/tasks/{task.id}/reap/")
self.assertEqual(r.status_code, 400)
task.refresh_from_db()
self.assertEqual(task.status, self.AITask.Status.RESERVED)
# 新鲜任务不显示回收按钮
row = next(t for t in self.ac.get("/api/admin/tasks/?status=reserved").data["results"] if t["id"] == str(task.id))
self.assertFalse(row["reapable"])
def test_reap_non_reserved_rejected(self):
self.assertEqual(self.ac.post(f"/api/admin/tasks/{self.t_ok.id}/reap/").status_code, 400)
def test_reap_requires_admin(self):
task = self._mk_stuck_reserved("k-reap-perm")
self.assertEqual(self.nc.post(f"/api/admin/tasks/{task.id}/reap/").status_code, 403)
class AdminBillingTests(TestCase):
"""Phase 7:计费审计(流水浏览/手动调额)+ 4 层额度策略(CRUD + 拦截生效)+ 权限。"""