后端生成闸+多项修复;前端全站更新;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:
zyc
2026-06-15 10:06:15 +08:00
co-authored by Claude Fable 5
parent 890cb9ab67
commit 216a711291
92 changed files with 5114 additions and 2027 deletions
@@ -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)"))
@@ -1,16 +1,46 @@
from decimal import Decimal
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None:
"""成员月度限额管控。团队页可设 monthly_credit_limit(0=不限),此前只存不管——纯装饰。
口径与成员列表的 month_charged 一致:自然月内该成员的 CHARGE 流水合计;
再加上其当前 ACTIVE 预留(在途任务),防止并发把限额冲穿。
调用方(reserve_credit)已持有 account 行锁,团队内 reserve 串行化,此检查无竞态。"""
if user is None:
return
member = team.members.filter(user=user).first()
if member is None:
return
limit = member.monthly_credit_limit or Decimal("0")
if limit <= 0:
return # 0 = 不限额
now = timezone.now()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
charged = (
CreditLedger.objects.filter(team=team, user=user, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start)
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
)
reserved = (
CreditReservation.objects.filter(team=team, user=user, status=CreditReservation.Status.ACTIVE)
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
)
if charged + reserved + amount > limit:
raise ValueError(f"成员本月额度不足:限额 ¥{limit},本月已用 ¥{charged}(另有在途 ¥{reserved})")
@transaction.atomic
def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
available = account.balance - account.reserved_balance
if available < amount:
raise ValueError("insufficient credit")
_enforce_member_monthly_limit(team=team, user=user, amount=amount)
account.reserved_balance += amount
account.save(update_fields=["reserved_balance", "updated_at"])
@@ -37,6 +67,9 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
@transaction.atomic
def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
# 锁内重读:与 charge 同款竞态——并发双 release(或 charge+release 交错)用陈旧快照都看到 ACTIVE,
# reserved_balance 会被减两次直接记坏账。已终态(已扣/已释放)→ 幂等返回。
reservation = CreditReservation.objects.select_for_update().get(id=reservation.id)
if reservation.status != CreditReservation.Status.ACTIVE:
return
@@ -59,6 +92,11 @@ def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
@transaction.atomic
def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Decimal) -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
# 锁内重读 reservation:调用方传入的可能是并发竞态下的陈旧快照(双方都看到 ACTIVE),
# 旧实现会对同一笔预留扣两次费(实测同秒两条 charge 流水)。已扣费 → 幂等直接返回。
reservation = CreditReservation.objects.select_for_update().get(id=reservation.id)
if reservation.status == CreditReservation.Status.CHARGED:
return
if reservation.status != CreditReservation.Status.ACTIVE:
raise ValueError("reservation is not active")
if actual_amount > reservation.amount:
+29
View File
@@ -1,6 +1,7 @@
from decimal import Decimal
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
@@ -58,3 +59,31 @@ class CreditLedgerTests(TestCase):
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1)
class RechargePermissionTests(TestCase):
"""充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客被 403 拦截。"""
def setUp(self):
self.owner = User.objects.create_user(username="r-owner", password="ownerpass123")
self.member = User.objects.create_user(username="r-member", password="memberpass123")
self.team = Team.objects.create(name="Recharge Team", owner=self.owner)
TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER, status=TeamMember.Status.ACTIVE)
TeamMember.objects.create(team=self.team, user=self.member, role=TeamMember.Role.MEMBER, status=TeamMember.Status.ACTIVE)
CreditAccount.objects.create(team=self.team, balance=Decimal("0.0000"))
def test_owner_can_recharge(self):
client = APIClient()
client.force_authenticate(self.owner)
response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json")
self.assertEqual(response.status_code, 201)
account = CreditAccount.objects.get(team=self.team)
self.assertEqual(account.balance, Decimal("100.0000"))
def test_member_cannot_recharge(self):
client = APIClient()
client.force_authenticate(self.member)
response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json")
self.assertEqual(response.status_code, 403)
account = CreditAccount.objects.get(team=self.team)
self.assertEqual(account.balance, Decimal("0.0000")) # 余额未变,越权被拦
+4 -1
View File
@@ -11,7 +11,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.ai.models import AITask
from apps.common.api import get_current_team
from apps.common.api import can_manage_team, get_current_team
from .models import CreditAccount, CreditLedger
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
@@ -83,6 +83,9 @@ def ledgers(request):
@permission_classes([IsAuthenticated])
def recharge(request):
team = get_current_team(request.user)
# 充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客一律拒绝
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
try:
amount = Decimal(str(request.data.get("amount", "0")))
bonus = Decimal(str(request.data.get("bonus", "0")))