1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理 3、增加apikey单独的模型定价 4、增加apikey调用情况 5、完善所有数据的注释增加
This commit is contained in:
@@ -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_type:Image/Video;Video 必须提供 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+8)naive 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,
|
||||
)
|
||||
Reference in New Issue
Block a user