1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api

2、增加后台apikkey管理
3、增加apikey单独的模型定价
4、增加apikey调用情况
5、完善所有数据的注释增加
This commit is contained in:
2026-08-06 13:13:28 +08:00
parent a55d4d649c
commit 0c511f3451
102 changed files with 13986 additions and 41 deletions
@@ -0,0 +1,485 @@
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Form, HTTPException, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.enums.upload_resource import UploadResourceTypeEnum # noqa: F401 (内部引用保留)
from app.enums.private_portrait import (
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.schemas.virtual_portrait_v3 import (
VpV3AssetCreate,
VpV3AssetDeleteOut,
VpV3AssetListOut,
VpV3EnumMeta,
VpV3IdOut,
VpV3ProjectCreate,
VpV3ProjectDeleteOut,
VpV3ProjectListOut,
VpV3ProjectOut,
VpV3ProjectUpdate,
VpV3QuotaConfigOut,
VpV3SelectableAssetListOut,
)
from app.services import virtual_portrait_v3 as vp_v3
from app.services.api_v3 import auth_service
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/virtual-portrait", tags=["api-v3-virtual-portrait"])
API_PREFIX_INFO = """
> **虚拟素材库(V3 中转 API**
>
> - 数据与前台用户私域素材库完全隔离(独立 `vp_v3_*` 表),归属按 API Key 管理
> - 所有接口需要在 Header 中携带 `Authorization: Bearer <API Key>`(或通过 `X-API-Key`,详见鉴权说明)
> - 配额:每个 API Key 需要管理员在后台配置虚拟素材额度(项目数/素材数/存储 MB),默认 0=不可使用
> - 生命周期:上传文件 → 创建素材(异步审核,会自动轮询)→ 状态 Active 后可用于 AI 创作
> - 远端删除遵循「先本地软删 → commit 后投递 Celery 异步任务删火山」模式,API 返回 `remote_delete_status=pending` 表示处理中
""" # noqa: E501
# ---------------------------------------------------------------------------
# 基础 & 配置
# ---------------------------------------------------------------------------
@router.get(
"/config",
response_model=VpV3QuotaConfigOut,
summary="获取虚拟素材库配额配置",
description=(
"返回当前 API Key 的虚拟素材配额上限(项目/素材/存储)和已使用量。"
"任一上限大于 0 表示启用虚拟素材库功能。"
+ API_PREFIX_INFO
),
)
async def get_virtual_portrait_config(
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
quota = await vp_v3.quota_service.get_quota(db, api_key_id=key_context.api_key_id, refresh=True)
enabled = any([
(quota.project_limit or 0) > 0,
(quota.asset_limit or 0) > 0,
(quota.storage_mb_limit or 0) > 0,
])
return VpV3QuotaConfigOut(
project_limit=int(quota.project_limit or 0),
asset_limit=int(quota.asset_limit or 0),
storage_mb_limit=int(quota.storage_mb_limit or 0),
project_used=int(quota.project_used or 0),
asset_used=int(quota.asset_used or 0),
storage_mb_used=float(quota.storage_mb_used or 0),
enabled=bool(enabled),
)
@router.get(
"/enums",
response_model=VpV3EnumMeta,
summary="获取虚拟素材库枚举元数据",
description="返回素材类型、素材状态、项目状态、远端删除状态等枚举说明。",
)
async def get_virtual_portrait_enums(
_: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
):
return VpV3EnumMeta(
asset_type={
PrivatePortraitAssetType.IMAGE.value: "图片素材",
PrivatePortraitAssetType.VIDEO.value: "视频素材",
},
asset_status={
PrivatePortraitAssetStatus.CREATING.value: "创建中/审核中",
PrivatePortraitAssetStatus.ACTIVE.value: "已就绪/可用",
PrivatePortraitAssetStatus.FAILED.value: "失败",
PrivatePortraitAssetStatus.DELETING.value: "删除中",
},
project_status={
PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value: "远端组创建中",
PrivatePortraitProjectStatus.ACTIVE.value: "就绪",
PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value: "远端组创建失败",
PrivatePortraitProjectStatus.DELETING.value: "删除中",
},
remote_delete_status={
PrivatePortraitRemoteDeleteStatus.NONE.value: "未删除",
PrivatePortraitRemoteDeleteStatus.PENDING.value: "待异步删除",
PrivatePortraitRemoteDeleteStatus.PROCESSING.value: "远端删除中",
PrivatePortraitRemoteDeleteStatus.DELETED.value: "远端已删除",
PrivatePortraitRemoteDeleteStatus.FAILED.value: "远端删除失败",
},
)
# ---------------------------------------------------------------------------
# 项目 CRUD
# ---------------------------------------------------------------------------
@router.post(
"/projects",
response_model=VpV3IdOut,
summary="创建虚拟素材项目",
description=(
"在当前 API Key 下创建一个虚拟素材项目(同步调用火山创建远端 AssetGroup)。"
"项目名称 1-100 字符;描述最多 500 字符。"
"创建项目会占用 1 个项目配额,超出上限将返回 403。"
),
)
async def create_virtual_portrait_project(
payload: VpV3ProjectCreate,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
try:
project = await vp_v3.project_service.create_project(
db, api_key_id=key_context.api_key_id, payload=payload
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"创建项目失败:{exc}") from exc
return VpV3IdOut(Id=project.remote_group_id)
@router.get(
"/projects",
response_model=VpV3ProjectListOut,
summary="查询虚拟素材项目列表",
description="按 API Key 分页查询虚拟素材项目。支持项目名称模糊搜索、状态筛选。默认按创建时间倒序。",
)
async def list_virtual_portrait_projects(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量 1-100"),
keyword: str | None = Query(None, description="项目名称模糊搜索"),
status: str | None = Query(None, description="项目状态筛选(不传查全部)"),
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
items, total = await vp_v3.project_service.list_projects(
db,
api_key_id=key_context.api_key_id,
page=page,
page_size=page_size,
keyword=keyword,
status=status,
)
return VpV3ProjectListOut(
items=[vp_v3.project_service.project_to_out(it) for it in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/projects/{project_id}",
response_model=VpV3ProjectOut,
summary="获取虚拟素材项目详情",
)
async def get_virtual_portrait_project(
project_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
project = await vp_v3.project_service.get_project(
db, api_key_id=key_context.api_key_id, project_id=project_id
)
return vp_v3.project_service.project_to_out(project)
@router.put(
"/projects/{project_id}",
response_model=VpV3ProjectOut,
summary="更新虚拟素材项目",
description="更新虚拟素材项目本地展示信息(名称/描述),不会重新创建火山远端 Group。",
)
async def update_virtual_portrait_project(
project_id: str,
payload: VpV3ProjectUpdate,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
try:
project = await vp_v3.project_service.update_project(
db, api_key_id=key_context.api_key_id, project_id=project_id, payload=payload
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"更新项目失败:{exc}") from exc
return vp_v3.project_service.project_to_out(project)
@router.delete(
"/projects/{project_id}",
response_model=VpV3ProjectDeleteOut,
summary="删除虚拟素材项目",
description=(
"软删虚拟素材项目及其下所有素材。本地 commit 后会投递 Celery 异步任务去删除火山远端 AssetGroup/Asset。"
"返回的 remote_delete_status=pending 表示远端删除处理中(可通过项目详情接口轮询)。"
),
)
async def delete_virtual_portrait_project(
project_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
project = await vp_v3.project_service.soft_delete_project(
db, api_key_id=key_context.api_key_id, project_id=project_id
)
project_id_snapshot = project.id
try:
await db.commit()
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"删除项目失败:{exc}") from exc
# commit 后投递 V3 专属的异步删除任务
try:
from app.tasks.vp_v3_asset_tasks import delete_v3_project_remote_task # type: ignore
delete_v3_project_remote_task.delay(project_id_snapshot)
logger.info("vp_v3 project %s 已投递远端删除任务", project_id_snapshot)
except Exception as exc: # noqa: BLE001
logger.warning("vp_v3 项目删除任务投递失败:project_id=%s err=%s", project_id_snapshot, exc)
return VpV3ProjectDeleteOut(
success=True,
remote_delete_status=project.remote_delete_status or PrivatePortraitRemoteDeleteStatus.PENDING.value,
)
# ---------------------------------------------------------------------------
# 素材 CRUD
# ---------------------------------------------------------------------------
@router.post(
"/projects/{project_id}/assets",
response_model=VpV3IdOut,
summary="创建虚拟素材(提交审核)",
description=(
"在指定项目下创建虚拟素材,提交到火山进行异步审核。\n"
"- source_url:必填,必须是 POST /uploads/image 或 /uploads/video 返回的 url(或 /uploads/* 路径)\n"
"- asset_typeImage/VideoVideo 必须提供 video_duration(秒),最多 60 秒\n"
"- 创建成功后 status=Creating;建议调用方自行轮询 /assets/{id}/sync 或详情接口直到 status=Active\n"
"- 同时会占用 1 份素材配额和文件大小对应的存储配额"
),
)
async def create_virtual_portrait_asset(
project_id: str,
payload: VpV3AssetCreate,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
try:
project = await vp_v3.project_service.get_project(
db, api_key_id=key_context.api_key_id, project_id=project_id
)
asset = await vp_v3.asset_service.create_asset(
db, api_key_id=key_context.api_key_id, project=project, payload=payload
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"创建素材失败:{exc}") from exc
asset_id_snapshot = asset.remote_asset_id
# commit 成功后投递 V3 专属轮询任务
try:
from app.tasks.vp_v3_asset_tasks import poll_v3_asset_status # type: ignore
async_result = poll_v3_asset_status.delay(asset_id_snapshot)
logger.info(
"vp_v3 素材轮询任务投递成功:asset_id=%s celery_task_id=%s",
asset_id_snapshot,
getattr(async_result, "id", None),
)
except Exception as exc: # noqa: BLE001
logger.warning("vp_v3 素材轮询任务投递失败:asset_id=%s err=%s", asset_id_snapshot, exc)
return VpV3IdOut(Id=asset.remote_asset_id)
@router.get(
"/projects/{project_id}/assets",
response_model=VpV3AssetListOut,
summary="查询指定项目下的虚拟素材列表",
description="按项目分页查询素材。可按 status/asset_type 筛选,按素材名称 keyword 模糊搜索。",
)
async def list_virtual_portrait_project_assets(
project_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = Query(None, description="素材状态筛选(Creating/Active/Failed/Deleting"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image/Video"),
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
# 先校验项目归属
await vp_v3.project_service.get_project(db, api_key_id=key_context.api_key_id, project_id=project_id)
items, total = await vp_v3.asset_service.list_assets(
db,
api_key_id=key_context.api_key_id,
project_id=project_id,
status=status,
keyword=keyword,
asset_type=asset_type,
page=page,
page_size=page_size,
)
return VpV3AssetListOut(
items=[vp_v3.asset_service.asset_to_out(it) for it in items],
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/assets/{asset_id}",
summary="获取虚拟素材审核详情",
description=(
"返回素材的 moderation_json(火山审核 JSON)。\n"
"- 若素材状态为 Creating(审核中)且 next_poll_at 已到期,内部会自动调火山 GetAsset 同步最新状态。\n"
"- 返回内容为解析后的 JSON 对象。"
),
)
async def get_virtual_portrait_asset(
asset_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
# 北京时间(UTC+8)统一基准
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
asset = await vp_v3.asset_service.get_asset(db, api_key_id=key_context.api_key_id, asset_id=asset_id)
# 统一为 naive 北京时间比较
def _naive(dt: datetime | None) -> datetime | None:
if dt is None:
return None
return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
need_sync = (
asset.status == PrivatePortraitAssetStatus.CREATING.value
and asset.remote_asset_id
and (_naive(asset.next_poll_at) is None or _naive(asset.next_poll_at) <= _bj_now())
)
if need_sync:
try:
asset = await vp_v3.asset_service.sync_asset_status(
db, api_key_id=key_context.api_key_id, asset_id=asset_id,
)
await db.commit()
await db.refresh(asset)
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"同步素材状态失败:{exc}") from exc
# 只返回 moderation_json 解析后的内容
moderation = None
if asset.moderation_json:
try:
moderation = json.loads(asset.moderation_json)
except (json.JSONDecodeError, TypeError):
moderation = asset.moderation_json
return JSONResponse(content=moderation)
@router.delete(
"/assets/{asset_id}",
response_model=VpV3AssetDeleteOut,
summary="删除虚拟素材",
description=(
"软删虚拟素材。本地 commit 后会投递 Celery 异步任务去删除火山远端 Asset。"
"返回 remote_delete_status=pending 表示处理中(可通过素材详情接口轮询)。"
),
)
async def delete_virtual_portrait_asset(
asset_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
asset = await vp_v3.asset_service.soft_delete_asset(
db, api_key_id=key_context.api_key_id, asset_id=asset_id
)
asset_id_snapshot = asset.remote_asset_id
try:
await db.commit()
except Exception as exc: # noqa: BLE001
await db.rollback()
raise HTTPException(status_code=500, detail=f"删除素材失败:{exc}") from exc
# commit 后投递 V3 专属的异步删除任务
try:
from app.tasks.vp_v3_asset_tasks import delete_v3_asset_remote_task # type: ignore
delete_v3_asset_remote_task.delay(asset_id_snapshot)
except Exception as exc: # noqa: BLE001
logger.warning("vp_v3 素材远端删除任务投递失败:asset_id=%s err=%s", asset_id_snapshot, exc)
return VpV3AssetDeleteOut(
success=True,
remote_delete_status=asset.remote_delete_status or PrivatePortraitRemoteDeleteStatus.PENDING.value,
)
# ---------------------------------------------------------------------------
# AI 创作选择器用
# ---------------------------------------------------------------------------
@router.get(
"/selectable-assets",
response_model=VpV3SelectableAssetListOut,
summary="查询可用于 AI 创作的虚拟素材",
description=(
"只返回当前 API Key 虚拟素材库中 status=Active 的图片/视频素材。"
"该接口提供给 AI 创作参考素材选择器使用。"
),
)
async def list_virtual_portrait_selectable_assets(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
project_id: str | None = Query(None, description="按项目筛选(可选)"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image/Video"),
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
):
items, total = await vp_v3.asset_service.list_selectable_assets(
db,
api_key_id=key_context.api_key_id,
project_id=project_id,
keyword=keyword,
asset_type=asset_type,
page=page,
page_size=page_size,
)
return VpV3SelectableAssetListOut(
items=[vp_v3.asset_service.asset_to_selectable(it) for it in items],
total=total,
page=page,
page_size=page_size,
)