From d466bd60c67015ca21879be95adc961a2ea9de94 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Tue, 7 Jul 2026 10:38:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(adminpanel):=20=E4=BB=BB=E5=8A=A1=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E3=80=8C=E5=9B=9E=E6=94=B6=E9=80=80=E6=AC=BE=E3=80=8D?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/admin/tasks//reap/:仅限卡在「已预留」超 10 分钟的僵尸任务(SUBMITTED/ POLLING 故意不许杀,视频合法耗时 5-10 分钟+,强杀会与轮询结算竞态),标失败 + 退还冻结积分, 与自动回收 _reap_stale_standalone_image_tasks 同款账务路径,幂等。 自动回收只在同团队下次提交时顺手触发,团队弃用某功能后冻结积分会永久躺着——这是给超管的 手动出口。序列化器加 reapable 字段(与端点闸同口径),任务监控行级「回收退款」按钮。 4 条测试(成功退款+审计/窗口内拒绝/非 RESERVED 拒绝/非超管 403)。 Co-Authored-By: Claude Sonnet 5 --- core/backend/apps/adminpanel/serializers.py | 15 ++++- core/backend/apps/adminpanel/tests.py | 61 +++++++++++++++++++ core/backend/apps/adminpanel/urls.py | 2 + core/backend/apps/adminpanel/views.py | 46 ++++++++++++++ core/frontend/src/api.ts | 3 + .../frontend/src/routes/admin/admin-tasks.tsx | 19 ++++++ core/frontend/src/types.ts | 2 + 7 files changed, 147 insertions(+), 1 deletion(-) diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 6fd9c54..8af33a6 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -118,18 +118,31 @@ class AdminTaskSerializer(serializers.ModelSerializer): cost_anomaly = serializers.SerializerMethodField() # 单任务毛利(¥):actual_cost(积分)÷汇率 − base_cost。base_cost=0(成本未知)时 None,报表侧过滤 margin_yuan = serializers.SerializerMethodField() + # 可手动回收:卡在 RESERVED 超过 10 分钟(与 admin_task_reap 的服务端闸完全同口径,前端据此显示按钮) + reapable = serializers.SerializerMethodField() class Meta: model = AITask fields = [ "id", "task_type", "status", "team", "team_name", "model_name", - "estimated_cost", "actual_cost", "base_cost", "margin_yuan", "cost_anomaly", "error_code", "created_at", + "estimated_cost", "actual_cost", "base_cost", "margin_yuan", "cost_anomaly", "error_code", "reapable", "created_at", ] read_only_fields = fields def get_cost_anomaly(self, obj) -> bool: return is_cost_anomaly(obj.estimated_cost, obj.actual_cost) + def get_reapable(self, obj) -> bool: + from datetime import timedelta + + from django.utils import timezone + + return bool( + obj.status == AITask.Status.RESERVED + and obj.updated_at is not None + and obj.updated_at < timezone.now() - timedelta(minutes=10) + ) + def get_margin_yuan(self, obj) -> str | None: base = obj.base_cost or Decimal("0") actual = obj.actual_cost or Decimal("0") diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 7e1beb0..a7282c5 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -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 + 拦截生效)+ 权限。""" diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index 1dc4656..0c7b6e5 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -18,6 +18,7 @@ from .views import ( admin_quota_policies, admin_quota_policy_detail, admin_task_detail, + admin_task_reap, admin_task_retry, admin_tasks, admin_prompt_template_detail, @@ -55,6 +56,7 @@ urlpatterns = [ path("tasks/", admin_tasks, name="admin-tasks"), path("tasks//", admin_task_detail, name="admin-task-detail"), path("tasks//retry/", admin_task_retry, name="admin-task-retry"), + path("tasks//reap/", admin_task_reap, name="admin-task-reap"), path("ledgers/", admin_ledgers, name="admin-ledgers"), path("ledgers/adjust/", admin_ledger_adjust, name="admin-ledger-adjust"), path("billing-config/", admin_billing_config, name="admin-billing-config"), diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 2cebb8f..d969669 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -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) + + # ─────────────────────────── 计费审计 + 额度策略 ─────────────────────────── diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 13d12bc..5a54f56 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -878,6 +878,9 @@ export const adminApi = { retryTask(id: string) { return request<{ retried: boolean; task_id: string }>(`/api/admin/tasks/${id}/retry/`, { method: "POST" }); }, + reapTask(id: string) { + return request(`/api/admin/tasks/${id}/reap/`, { method: "POST" }); + }, ledgers(params?: { ledger_type?: string; team?: string; page?: number; page_size?: number }) { const qs = new URLSearchParams(); if (params?.ledger_type) qs.set("ledger_type", params.ledger_type); diff --git a/core/frontend/src/routes/admin/admin-tasks.tsx b/core/frontend/src/routes/admin/admin-tasks.tsx index 0ccd482..cd321fc 100644 --- a/core/frontend/src/routes/admin/admin-tasks.tsx +++ b/core/frontend/src/routes/admin/admin-tasks.tsx @@ -84,6 +84,22 @@ export function AdminTasksPage({ notify }: { notify: Notify }) { } } + // 手动回收僵尸任务:卡在「已预留」超 10 分钟(worker 从未认领)→ 标失败 + 退还冻结积分。 + // 自动回收只在同团队下次提交时触发,团队弃用该功能时积分会永久冻结,这是超管兜底出口。 + async function reap(t: AdminTask) { + if (busy) return; + setBusy(true); + try { + await adminApi.reapTask(t.id); + notify("success", "已回收:任务标记失败,预留积分已退还"); + await load(); + } catch (e) { + notify("error", e instanceof Error ? e.message : "回收失败"); + } finally { + setBusy(false); + } + } + return ( <>
@@ -131,6 +147,9 @@ export function AdminTasksPage({ notify }: { notify: Notify }) { {t.status === "failed" && ( )} + {t.reapable && ( + + )} ))} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index fd01077..26f4a52 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -129,6 +129,8 @@ export type AdminTask = { margin_yuan?: string | null; cost_anomaly: boolean; error_code: string; + // 卡在 RESERVED 超 10 分钟 = 僵尸,可手动「回收退款」(与后端 admin_task_reap 闸同口径) + reapable?: boolean; created_at: string; }; export type AdminTaskDetail = AdminTask & {