Files
video-gen/video-gen-api/app/services/virtual_portrait_v3/quota_service.py
T
root f5d7cfadb4 1、图片生成同步接口超时风险
2、图片返回的 URL 是本地路径
3、视频生成中媒体文件重复下载
4、幂等性检查无数据库唯一约束
5、幂等键冲突返回 409 改为返回已有任务信息
6、虚拟素材库配额校验 TOCTOU
7、项目级联删除与独立素材删除任务并发冲突
2026-08-06 18:57:14 +08:00

184 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
PrivatePortraitAssetType,
PrivatePortraitAssetStatus,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
)
from app.models.virtual_portrait_v3 import (
VpV3ApiKeyQuota,
VpV3Asset,
VpV3Project,
)
from app.utils.id_gen import generate_id
MB_BYTES = 1024 * 1024
_SAFE_SLUG = re.compile(r"[^a-zA-Z0-9_-]")
def _slug(name: str) -> str:
if not name:
return "unnamed"
return _SAFE_SLUG.sub("_", name.strip())[:80] or "unnamed"
def _bytes_to_mb(b: int | float | None) -> float:
if not b:
return 0.0
return round(b / MB_BYTES, 3)
# ---------------------------------------------------------------------------
# 配额读写(确保 VpV3ApiKeyQuota 记录存在)
# ---------------------------------------------------------------------------
async def _upsert_quota(db: AsyncSession, api_key_id: str) -> VpV3ApiKeyQuota:
"""获取配额记录;不存在则创建(默认全 0=不可用)。"""
from sqlalchemy.dialects.postgresql import insert
stmt = (
insert(VpV3ApiKeyQuota)
.values(
id=generate_id(),
api_key_id=api_key_id,
project_limit=0,
asset_limit=0,
storage_mb_limit=0,
project_used=0,
asset_used=0,
storage_mb_used=0,
)
.on_conflict_do_nothing(index_elements=["api_key_id"])
)
await db.execute(stmt)
row = (await db.execute(
select(VpV3ApiKeyQuota).where(VpV3ApiKeyQuota.api_key_id == api_key_id).limit(1)
)).scalar_one()
return row
async def _refresh_quota_used(db: AsyncSession, quota: VpV3ApiKeyQuota) -> None:
"""按真实数据重算已使用量(最终一致性)。"""
project_result = await db.execute(
select(func.count(VpV3Project.id)).where(
VpV3Project.api_key_id == quota.api_key_id,
VpV3Project.deleted_at.is_(None),
)
)
asset_result = await db.execute(
select(
func.count(VpV3Asset.id),
func.coalesce(func.sum(VpV3Asset.file_size_bytes), 0),
).where(
VpV3Asset.api_key_id == quota.api_key_id,
VpV3Asset.deleted_at.is_(None),
)
)
project_used = project_result.scalar() or 0
asset_row = asset_result.one()
asset_used = asset_row[0] or 0
storage_bytes = asset_row[1] or 0
quota.project_used = int(project_used)
quota.asset_used = int(asset_used)
quota.storage_mb_used = int(_bytes_to_mb(storage_bytes))
async def get_quota(db: AsyncSession, *, api_key_id: str, refresh: bool = True) -> VpV3ApiKeyQuota:
"""获取当前 API Key 的配额(含已使用量)。不存在则创建默认 0。"""
quota = await _upsert_quota(db, api_key_id)
if refresh:
await _refresh_quota_used(db, quota)
return quota
async def ensure_quota_enabled(db: AsyncSession, *, api_key_id: str) -> VpV3ApiKeyQuota:
"""校验是否已启用虚拟素材库功能,未启用直接 403。返回已刷新的配额。
判定口径(与后台设置保持一致):只要「项目数上限」或「素材数上限」任一 > 0 即视为启用;
存储上限已从配置中移除(不再作为启用条件,也不做硬性限制,仅保留数据库字段做统计展示)。
"""
quota = await get_quota(db, api_key_id=api_key_id, refresh=True)
if (quota.project_limit or 0) <= 0 and (quota.asset_limit or 0) <= 0:
raise HTTPException(status_code=403, detail="当前 API Key 未开启虚拟素材库功能,请联系管理员配置配额")
return quota
def _check(limit: int | None, used: int | float | None, delta: int | float, field: str) -> None:
"""通用配额上限校验。
约定:limit <= 0 视为该维度「未配置 / 不做限制」,此时直接跳过不报错;
只有 limit > 0 时才按「已用 + 本次 <= 上限」判断,避免影响已移除的维度(如存储上限)。
"""
if (limit or 0) <= 0:
return # 不限制,直接通过
if (used or 0) + delta > limit:
raise HTTPException(
status_code=403,
detail=f"虚拟素材库配额不足:{field} 上限 {limit},已使用 {used},本次需要 {delta},超出上限",
)
async def check_project_quota(db: AsyncSession, *, api_key_id: str, delta: int = 1) -> VpV3ApiKeyQuota:
"""创建项目前校验配额(带行锁,防止并发超配)。"""
quota = await ensure_quota_enabled(db, api_key_id=api_key_id)
# 用行锁重新读取,保证并发安全
quota = (
await db.execute(
select(VpV3ApiKeyQuota)
.where(VpV3ApiKeyQuota.api_key_id == api_key_id)
.with_for_update()
.limit(1)
)
).scalar_one()
await _refresh_quota_used(db, quota)
_check(quota.project_limit, quota.project_used, delta, "项目数")
return quota
async def check_asset_quota(
db: AsyncSession,
*,
api_key_id: str,
asset_count_delta: int = 1,
file_size_bytes: int | None = None,
) -> VpV3ApiKeyQuota:
"""上传素材前校验配额(带行锁,防止并发超配)。
注:「存储空间上限」已从业务约束中移除(不再做硬性配额限制),仅保留素材数量上限
与项目数量上限的校验;storage_mb_used 字段仍会在 get_quota 中刷新用于统计展示。
"""
del file_size_bytes # 不再用于配额校验(仅保留形参兼容现有调用点)
quota = await ensure_quota_enabled(db, api_key_id=api_key_id)
# 用行锁重新读取,保证并发安全
quota = (
await db.execute(
select(VpV3ApiKeyQuota)
.where(VpV3ApiKeyQuota.api_key_id == api_key_id)
.with_for_update()
.limit(1)
)
).scalar_one()
await _refresh_quota_used(db, quota)
_check(quota.asset_limit, quota.asset_used, asset_count_delta, "素材总数")
return quota
def remote_project_name() -> str:
"""火山 ProjectNameV3 中转统一共用这个 Project)。"""
return PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
def remote_group_name(*, api_key_id: str, project_slug: str, id: str) -> str:
"""火山 GroupNamevp-api-{api_key_id_short}-{id}-{slug} 最多 128 字符。"""
short_key = (api_key_id or "")
return f"vp-api-{short_key}-{id}-{project_slug}"[:128]