后端生成闸+多项修复;前端全站更新;QA 审计与报告
后端: - 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口 一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失 - 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复 - 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令 - 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令) 前端:全站页面更新(pipeline/library/products/projects/team/account 等), 新增共享 pager 分页组件 QA:刷新 function-audit 全量输出,新增 full-qa 报告 文档:BP 产品介绍资料、design/CLAUDE.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"""释放僵尸预留额度。
|
||||
|
||||
生成流程中断(进程被杀/浏览器关闭/轮询停止)会留下 status=ACTIVE 的 CreditReservation,
|
||||
对应 AITask 永远停在 reserved/submitted/polling——这些预留会一直占用 reserved_balance,
|
||||
吃掉团队可用额度。本命令把「超过 --hours 小时仍未终态」的预留按正规 release 流程释放
|
||||
(走 release_credit,有 RELEASE 流水可审计),并把对应任务标记为 FAILED。
|
||||
|
||||
用法:
|
||||
python manage.py sweep_stale_reservations # 默认释放超过 12 小时的
|
||||
python manage.py sweep_stale_reservations --hours 1 # 收紧窗口
|
||||
python manage.py sweep_stale_reservations --dry-run # 只看不动
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask
|
||||
from apps.billing.models import CreditReservation
|
||||
from apps.billing.services.ledger import release_credit
|
||||
|
||||
# 这些任务状态意味着流程没走到终态(成功会 charge、失败会 release),超时即视为僵尸
|
||||
STUCK_TASK_STATUSES = (
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "释放超时未终态的 ACTIVE 预留额度(僵尸预留),并把对应任务标记为失败"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--hours", type=int, default=12, help="超过多少小时视为僵尸(默认 12)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只列出将要释放的,不实际执行")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
cutoff = timezone.now() - timedelta(hours=options["hours"])
|
||||
stale = (
|
||||
CreditReservation.objects.filter(status=CreditReservation.Status.ACTIVE, created_at__lt=cutoff)
|
||||
.select_related("task")
|
||||
.order_by("created_at")
|
||||
)
|
||||
released = 0
|
||||
for reservation in stale:
|
||||
task = reservation.task
|
||||
if task is not None and task.status not in STUCK_TASK_STATUSES:
|
||||
# 任务已终态却仍 ACTIVE:同样是泄漏,照常释放
|
||||
pass
|
||||
label = f"resv {str(reservation.id)[:8]} ¥{reservation.amount} task={str(task.id)[:8] if task else '-'}({task.status if task else '-'}) created={reservation.created_at:%m-%d %H:%M}"
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(f"[dry-run] would release {label}")
|
||||
continue
|
||||
release_credit(reservation=reservation, reason=f"僵尸预留清扫(>{options['hours']}h 未终态)")
|
||||
if task is not None and task.status in STUCK_TASK_STATUSES:
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = "stale task swept: 流程中断超时,预留已释放"
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
released += 1
|
||||
self.stdout.write(f"released {label}")
|
||||
self.stdout.write(self.style.SUCCESS(f"done: released {released} stale reservation(s)"))
|
||||
Reference in New Issue
Block a user