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
+46
View File
@@ -478,6 +478,52 @@ def admin_task_retry(request, task_id):
return Response({"retried": True, "task_id": str(task.id)})
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_task_reap(request, task_id):
"""手动回收僵尸任务:标失败 + 退还预留积分(与自动回收 _reap_stale_standalone_image_tasks 同款账务路径)。
自动回收只在「同团队下次提交」时顺手触发——团队从此不再用该功能,冻结积分就永远躺着;
这里给超管一个兜底出口。范围收紧到 RESERVED(worker 从未认领):SUBMITTED/POLLING 的视频
任务可能正在 ARK 生成(合法耗时 5-10 分钟+),后台强杀会与轮询结算竞态,不冒险。"""
from datetime import timedelta
from django.db import transaction
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from apps.billing.services.ledger import release_credit
with transaction.atomic():
# 锁行后再判状态:worker 可能恰好并发认领,凭陈旧快照回收会把在途任务误杀
task = AITask.objects.select_for_update().filter(id=task_id).first()
if task is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if task.status != AITask.Status.RESERVED:
return Response({"detail": "仅能回收卡在「已预留」状态的任务"}, status=status.HTTP_400_BAD_REQUEST)
if task.updated_at >= timezone.now() - timedelta(minutes=10):
return Response({"detail": "任务仍在 10 分钟活跃窗口内,可能只是在排队,稍后再试"}, status=status.HTTP_400_BAD_REQUEST)
task.status = AITask.Status.FAILED
task.error_message = "管理员手动回收(预留积分已退还)"
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
try:
reservation = task.credit_reservation
except ObjectDoesNotExist:
reservation = None
if reservation is not None:
release_credit(reservation=reservation, reason="管理员手动回收僵尸任务")
log_admin_action(
request,
"task.reap",
target_type="ai_task",
target_id=task.id,
target_name=task.task_type,
)
return Response(AdminTaskSerializer(task).data)
# ─────────────────────────── 计费审计 + 额度策略 ───────────────────────────