1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理 3、增加apikey单独的模型定价 4、增加apikey调用情况 5、完善所有数据的注释增加
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v3.videos import router as videos_router
|
||||
from app.api.v3.images import router as images_router
|
||||
from app.api.v3.models import router as models_router
|
||||
from app.api.v3.virtual_portrait import router as virtual_portrait_router
|
||||
|
||||
api_router_v3 = APIRouter()
|
||||
api_router_v3.include_router(models_router)
|
||||
api_router_v3.include_router(videos_router)
|
||||
api_router_v3.include_router(images_router)
|
||||
api_router_v3.include_router(virtual_portrait_router)
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ApiError(BaseModel):
|
||||
"""API 错误详情。"""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class ApiErrorResponse(BaseModel):
|
||||
"""API 错误响应(旧格式,保留兼容)。"""
|
||||
|
||||
error: ApiError
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.schemas.api_v3.image import (
|
||||
ApiImageGenerateRequest,
|
||||
ApiImageGenerateResponse,
|
||||
)
|
||||
from app.services.api_v3 import auth_service, generation_service
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
router = APIRouter(prefix="/images", tags=["api-v3-images"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
summary="生成图片",
|
||||
description="同步生成图片,等待完成后直接返回结果",
|
||||
)
|
||||
async def generate_image(
|
||||
req: ApiImageGenerateRequest,
|
||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> JSONResponse:
|
||||
"""同步生成图片。"""
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
result = await generation_service.generate_image_sync(
|
||||
db=db,
|
||||
key=key_context.api_key,
|
||||
callable_models=key_context.callable_models,
|
||||
req=req,
|
||||
start_time=start_time,
|
||||
)
|
||||
data = result.model_dump()
|
||||
# 处理 datetime 序列化
|
||||
if data.get("created"):
|
||||
data["created"] = data["created"] if isinstance(data["created"], int) else int(data["created"])
|
||||
return JSONResponse(
|
||||
content={"code": 0, "data": data, "message": "ok"},
|
||||
status_code=200,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("API image generation failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"图片生成失败: {str(exc)[:200]}",
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.models.image_engine import ImageEngine
|
||||
from app.models.video_engine import VideoEngine
|
||||
from app.schemas.api_v3.model import ApiModelInfo, ApiModelsResponse
|
||||
from app.services.api_v3 import auth_service
|
||||
from app.services.api_v3.pricing_service import get_priced_models
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
router = APIRouter(prefix="/models", tags=["api-v3-models"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="获取可用模型列表",
|
||||
description="获取当前 API Key 可调用的所有视频和图片模型(仅返回已配置价格的模型)",
|
||||
)
|
||||
async def list_models(
|
||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> JSONResponse:
|
||||
"""获取当前 API Key 可用的模型列表。"""
|
||||
models: list[ApiModelInfo] = []
|
||||
|
||||
# 获取所有已配置价格的引擎 ID 集合
|
||||
priced_engine_ids = await get_priced_models(db)
|
||||
|
||||
# 获取 API Key 的白名单引擎 ID 集合
|
||||
allowed_engine_ids = {m.get("engine_id", "") for m in key_context.callable_models} if key_context.callable_models else set()
|
||||
|
||||
# 确定要返回的引擎 ID 列表
|
||||
target_engine_ids = priced_engine_ids if not allowed_engine_ids else (allowed_engine_ids & priced_engine_ids)
|
||||
|
||||
# 构建引擎信息映射
|
||||
engine_info_map = {m.get("engine_id", ""): m for m in key_context.callable_models}
|
||||
|
||||
for engine_id in target_engine_ids:
|
||||
engine_type = engine_info_map.get(engine_id, {}).get("engine_type", "")
|
||||
model_name = engine_info_map.get(engine_id, {}).get("model_name", "")
|
||||
|
||||
# 如果没有从白名单获取到类型,尝试从数据库加载
|
||||
if not engine_type:
|
||||
video_result = await db.execute(
|
||||
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
if video_result.scalar_one_or_none():
|
||||
engine_type = "video"
|
||||
else:
|
||||
image_result = await db.execute(
|
||||
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
if image_result.scalar_one_or_none():
|
||||
engine_type = "image"
|
||||
|
||||
# 加载引擎详情
|
||||
supported_ratios = None
|
||||
supported_resolutions = None
|
||||
supported_durations = None
|
||||
supported_sizes = None
|
||||
|
||||
try:
|
||||
if engine_type == "video":
|
||||
result = await db.execute(
|
||||
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
engine = result.scalar_one_or_none()
|
||||
if engine:
|
||||
if not model_name:
|
||||
model_name = engine.model_name
|
||||
supported_ratios = _parse_json_list(engine.supported_ratios)
|
||||
supported_resolutions = _parse_json_list(engine.supported_resolutions)
|
||||
supported_durations = _parse_json_list(engine.supported_durations)
|
||||
elif engine_type == "image":
|
||||
result = await db.execute(
|
||||
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
engine = result.scalar_one_or_none()
|
||||
if engine:
|
||||
if not model_name:
|
||||
model_name = engine.model_name
|
||||
supported_sizes = _parse_json_list(engine.supported_sizes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
info = ApiModelInfo(
|
||||
model=model_name,
|
||||
engine_type=engine_type,
|
||||
engine_id=engine_id,
|
||||
supported_ratios=supported_ratios,
|
||||
supported_resolutions=supported_resolutions,
|
||||
supported_durations=supported_durations,
|
||||
supported_sizes=supported_sizes,
|
||||
)
|
||||
|
||||
models.append(info)
|
||||
|
||||
return JSONResponse(
|
||||
content={"code": 0, "data": {"models": [m.model_dump() for m in models]}, "message": "ok"},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_list(value: str | None) -> list[str | int] | None:
|
||||
"""解析 JSON 列表字段。"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,162 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.models.api.api_generation_task import ApiGenerationTask
|
||||
from app.schemas.api_v3.video import (
|
||||
ApiVideoCreateRequest,
|
||||
ApiVideoCreateResponse,
|
||||
ApiVideoStatusResponse,
|
||||
)
|
||||
from app.services.api_v3 import auth_service, generation_service, task_service
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["api-v3-videos"])
|
||||
|
||||
|
||||
async def _validate_request(
|
||||
db: AsyncSession,
|
||||
key_context: auth_service.ApiKeyContext,
|
||||
req: ApiVideoCreateRequest,
|
||||
) -> ApiGenerationTask | None:
|
||||
"""请求层校验:参数、权限、幂等性。
|
||||
|
||||
Returns:
|
||||
None = 校验通过,继续创建
|
||||
ApiGenerationTask = 幂等请求,返回已有任务
|
||||
"""
|
||||
# 模型权限校验
|
||||
allowed_model_names = {m.get("model_name", "") for m in key_context.callable_models}
|
||||
if allowed_model_names and req.model not in allowed_model_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"无权使用模型 {req.model}",
|
||||
)
|
||||
|
||||
# 幂等性检查
|
||||
if req.idempotency_key:
|
||||
result = await db.execute(
|
||||
select(ApiGenerationTask).where(
|
||||
ApiGenerationTask.api_key_id == key_context.api_key.id,
|
||||
ApiGenerationTask.external_idempotency_key == req.idempotency_key,
|
||||
ApiGenerationTask.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
existing_task = result.scalar_one_or_none()
|
||||
if existing_task:
|
||||
logger.info(
|
||||
"Idempotent request: returning existing task %s for key=%s",
|
||||
existing_task.id, req.idempotency_key,
|
||||
)
|
||||
return existing_task
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _map_status(internal_status: str) -> str:
|
||||
"""将内部状态映射为 API 状态。"""
|
||||
status_map = {
|
||||
"pending": "queued",
|
||||
"queued": "queued",
|
||||
"generating": "running",
|
||||
"processing": "running",
|
||||
"completed": "succeeded",
|
||||
"failed": "failed",
|
||||
"timeout": "expired",
|
||||
}
|
||||
return status_map.get(internal_status, internal_status)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ApiVideoCreateResponse,
|
||||
summary="创建视频生成任务",
|
||||
)
|
||||
async def create_video(
|
||||
req: ApiVideoCreateRequest,
|
||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiVideoCreateResponse:
|
||||
"""创建视频生成任务(异步)。"""
|
||||
try:
|
||||
# 路由层校验:权限、幂等性
|
||||
existing_task = await _validate_request(db, key_context, req)
|
||||
if existing_task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"幂等键已存在: 任务 {req.idempotency_key} 已创建",
|
||||
)
|
||||
|
||||
# 调用服务层创建任务
|
||||
result = await generation_service.submit_video_generation(
|
||||
db=db,
|
||||
key=key_context.api_key,
|
||||
callable_models=key_context.callable_models,
|
||||
req=req,
|
||||
)
|
||||
return ApiVideoCreateResponse(id=f"zc-{result.id}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("API video creation failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建视频任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}",
|
||||
response_model=ApiVideoStatusResponse,
|
||||
summary="查询视频任务状态",
|
||||
)
|
||||
async def get_video_status(
|
||||
task_id: str,
|
||||
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiVideoStatusResponse:
|
||||
"""查询视频任务状态。"""
|
||||
# 去掉 zc- 前缀
|
||||
if task_id.startswith("zc-"):
|
||||
task_id = task_id[3:]
|
||||
task = await task_service.get_task(db, task_id, key_context.api_key.id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务 {task_id} 不存在或不属于当前 API Key",
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
# 构建 content(成功时返回完整视频URL,包含 BASE_URL)
|
||||
content = None
|
||||
if task.status == "completed" and task.video_url:
|
||||
from app.schemas.api_v3.video import ApiVideoContent
|
||||
from app.config import settings
|
||||
# 拼接完整 URL
|
||||
video_url = task.video_url
|
||||
if video_url and not video_url.startswith(("http://", "https://")):
|
||||
base = settings.BASE_URL.rstrip("/")
|
||||
if video_url.startswith("/"):
|
||||
video_url = f"{base}{video_url}"
|
||||
else:
|
||||
video_url = f"{base}/{video_url}"
|
||||
content = ApiVideoContent(video_url=video_url)
|
||||
|
||||
return ApiVideoStatusResponse(
|
||||
id=f"zc-{task.id}",
|
||||
model=task.model_name,
|
||||
status=_map_status(task.status),
|
||||
created_at=int(task.created_at.timestamp()) if task.created_at else now,
|
||||
updated_at=int(task.updated_at.timestamp()) if task.updated_at else now,
|
||||
content=content,
|
||||
duration=task.duration,
|
||||
ratio=task.aspect_ratio,
|
||||
resolution=task.resolution,
|
||||
error=task.error_message if task.status in ("failed", "timeout") else None,
|
||||
)
|
||||
@@ -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