上传素材管控-历史素材success

This commit is contained in:
2026-07-08 16:25:03 +08:00
parent 657505e63b
commit d085a6d2ec
47 changed files with 5424 additions and 447 deletions
@@ -0,0 +1,133 @@
"""add upload resource physical delete status
Revision ID: 1475d11b1d74
Revises: cd7688999204
Create Date: 2026-07-08 15:14:13.995718
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1475d11b1d74'
down_revision: Union[str, None] = 'cd7688999204'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('upload_resources',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('module', sa.String(length=64), nullable=False),
sa.Column('resource_type', sa.String(length=32), nullable=False),
sa.Column('resource_url', sa.Text(), nullable=False),
sa.Column('storage_path', sa.Text(), nullable=False),
sa.Column('file_name', sa.String(length=255), nullable=True),
sa.Column('file_ext', sa.String(length=32), nullable=True),
sa.Column('mime_type', sa.String(length=128), nullable=True),
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False),
sa.Column('duration_seconds', sa.Float(), nullable=True),
sa.Column('duration_source', sa.String(length=32), nullable=True),
sa.Column('width', sa.BigInteger(), nullable=True),
sa.Column('height', sa.BigInteger(), nullable=True),
sa.Column('source_model', sa.String(length=64), nullable=True),
sa.Column('source_id', sa.String(length=32), nullable=True),
sa.Column('source_module', sa.String(length=64), nullable=True),
sa.Column('bind_status', sa.String(length=32), server_default='pending', nullable=False),
sa.Column('delete_policy', sa.String(length=32), server_default='user_deletable', nullable=False),
sa.Column('created_by', sa.String(length=32), server_default='api', nullable=False),
sa.Column('metadata_json', sa.Text(), nullable=True),
sa.Column('capacity_released_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('physical_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('file_delete_status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('file_delete_error', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('storage_path', name='uq_upload_resources_storage_path')
)
op.create_index(op.f('ix_upload_resources_bind_status'), 'upload_resources', ['bind_status'], unique=False)
op.create_index(op.f('ix_upload_resources_capacity_released_at'), 'upload_resources', ['capacity_released_at'], unique=False)
op.create_index('ix_upload_resources_created', 'upload_resources', ['created_at'], unique=False)
op.create_index(op.f('ix_upload_resources_created_by'), 'upload_resources', ['created_by'], unique=False)
op.create_index(op.f('ix_upload_resources_delete_policy'), 'upload_resources', ['delete_policy'], unique=False)
op.create_index(op.f('ix_upload_resources_deleted_at'), 'upload_resources', ['deleted_at'], unique=False)
op.create_index('ix_upload_resources_file_delete_status', 'upload_resources', ['file_delete_status'], unique=False)
op.create_index(op.f('ix_upload_resources_module'), 'upload_resources', ['module'], unique=False)
op.create_index('ix_upload_resources_physical_deleted', 'upload_resources', ['physical_deleted_at'], unique=False)
op.create_index(op.f('ix_upload_resources_resource_type'), 'upload_resources', ['resource_type'], unique=False)
op.create_index('ix_upload_resources_source', 'upload_resources', ['source_model', 'source_id', 'deleted_at'], unique=False)
op.create_index(op.f('ix_upload_resources_source_id'), 'upload_resources', ['source_id'], unique=False)
op.create_index(op.f('ix_upload_resources_source_model'), 'upload_resources', ['source_model'], unique=False)
op.create_index('ix_upload_resources_url', 'upload_resources', ['resource_url'], unique=False)
op.create_index('ix_upload_resources_user_bind', 'upload_resources', ['user_id', 'bind_status', 'deleted_at'], unique=False)
op.create_index(op.f('ix_upload_resources_user_id'), 'upload_resources', ['user_id'], unique=False)
op.create_index('ix_upload_resources_user_module_type', 'upload_resources', ['user_id', 'module', 'resource_type', 'deleted_at'], unique=False)
op.create_index(
'ix_upload_resources_history_lookup',
'upload_resources',
['user_id', 'resource_type', sa.text('created_at DESC')],
unique=False,
postgresql_where=sa.text(
"deleted_at IS NULL "
"AND bind_status = 'pending' "
"AND delete_policy = 'user_deletable' "
"AND source_model IS NULL "
"AND source_id IS NULL"
),
)
op.add_column('user_resource_month_stats', sa.Column('upload_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('audio_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('shot_segment_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('upload_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('audio_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('shot_segment_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('upload_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('audio_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('shot_segment_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('upload_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('audio_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('shot_segment_count', sa.Integer(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user_resource_total_stats', 'shot_segment_count')
op.drop_column('user_resource_total_stats', 'audio_count')
op.drop_column('user_resource_total_stats', 'upload_count')
op.drop_column('user_resource_total_stats', 'shot_segment_size_bytes')
op.drop_column('user_resource_total_stats', 'audio_size_bytes')
op.drop_column('user_resource_total_stats', 'upload_size_bytes')
op.drop_column('user_resource_month_stats', 'shot_segment_count')
op.drop_column('user_resource_month_stats', 'audio_count')
op.drop_column('user_resource_month_stats', 'upload_count')
op.drop_column('user_resource_month_stats', 'shot_segment_size_bytes')
op.drop_column('user_resource_month_stats', 'audio_size_bytes')
op.drop_column('user_resource_month_stats', 'upload_size_bytes')
op.drop_index('ix_upload_resources_history_lookup', table_name='upload_resources')
op.drop_index('ix_upload_resources_user_module_type', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_user_id'), table_name='upload_resources')
op.drop_index('ix_upload_resources_user_bind', table_name='upload_resources')
op.drop_index('ix_upload_resources_url', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_source_model'), table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_source_id'), table_name='upload_resources')
op.drop_index('ix_upload_resources_source', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_resource_type'), table_name='upload_resources')
op.drop_index('ix_upload_resources_physical_deleted', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_module'), table_name='upload_resources')
op.drop_index('ix_upload_resources_file_delete_status', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_deleted_at'), table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_delete_policy'), table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_created_by'), table_name='upload_resources')
op.drop_index('ix_upload_resources_created', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_capacity_released_at'), table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_bind_status'), table_name='upload_resources')
op.drop_table('upload_resources')
# ### end Alembic commands ###
+2
View File
@@ -34,6 +34,7 @@ from app.api.admin import router as admin_module_router
from app.api.v1.material_admin import router as material_admin_router
from app.api.v1.private_portrait import router as private_portrait_router
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
from app.api.v1.upload_resource import router as upload_resource_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -70,3 +71,4 @@ api_router.include_router(admin_module_router)
api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router)
api_router.include_router(private_portrait_virtual_router)
api_router.include_router(upload_resource_router)
+155 -92
View File
@@ -36,6 +36,9 @@ from app.services.resource_accounting_service import (
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
from app.services.generation_billing_service import (
CHARGE_TEXT_PROMPT,
OWNER_GENERATION_RECORD,
@@ -780,128 +783,188 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)
return {"message": "ok"}
@router.post("/upload-image")
@router.post(
"/upload-image",
summary="上传 AI 创作普通参考图片",
description=(
"上传普通 AI 创作参考图片,写入 UploadResource 资源账本并纳入用户上传容量统计。"
"返回 resource_id 和 url。该文件在未绑定业务记录前可通过 /generation-records/delete-file 单独删除,"
"也会出现在 /upload-resources/history 历史素材中供 AI 创作复用。"
),
responses={400: {"description": "文件类型、大小或容量校验失败"}, 401: {"description": "未登录或 Token 无效"}},
)
async def upload_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
gen_type: str = Query("video", description="生成类型:video-视频,image-图片"),
db: AsyncSession = Depends(get_db),
):
"""Upload an image for generation reference."""
import os
import uuid
from app.config import settings
from datetime import datetime
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="仅支持图片文件")
ext = os.path.splitext(file.filename or ".png")[1] or ".png"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = f"{gen_type}_img_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
date_dir = datetime.now().strftime("%Y/%m/%d")
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "images", date_dir)
os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, safe_name)
content = await file.read()
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="图片大小不能超过10MB")
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/images/{date_dir}/{safe_name}"
return {"url": url, "filename": file.filename or safe_name, "type": "image", "gen_type": gen_type}
"""上传普通参考图片,记录 UploadResource 并纳入用户容量统计。"""
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.COMMON.value,
resource_type=UploadResourceTypeEnum.IMAGE.value,
gen_type=gen_type,
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "image",
"gen_type": gen_type,
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
}
@router.post("/upload-video")
@router.post(
"/upload-video",
summary="上传 AI 创作普通参考视频",
description=(
"上传普通 AI 创作参考视频,写入 UploadResource 资源账本并纳入用户上传容量统计。"
"duration_seconds 为前端识别的视频秒数,用于历史复用和 AI 创作视频总时长校验。"
"未绑定业务记录前可单独删除,并会出现在 /upload-resources/history 历史素材中。"
),
responses={400: {"description": "文件类型、大小、容量或视频参数校验失败"}, 401: {"description": "未登录或 Token 无效"}},
)
async def upload_video(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"),
db: AsyncSession = Depends(get_db),
):
"""Upload a video for generation reference."""
import os
import uuid
from app.config import settings
from datetime import datetime
if not file.content_type or not file.content_type.startswith("video/"):
raise HTTPException(status_code=400, detail="仅支持视频文件")
ext = os.path.splitext(file.filename or ".mp4")[1] or ".mp4"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = f"video_ref_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
date_dir = datetime.now().strftime("%Y/%m/%d")
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "videos", date_dir)
os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, safe_name)
content = await file.read()
if len(content) > 100 * 1024 * 1024:
raise HTTPException(status_code=400, detail="视频大小不能超过100MB")
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/videos/{date_dir}/{safe_name}"
return {"url": url, "filename": file.filename or safe_name, "type": "video"}
"""上传普通参考视频,记录 UploadResource 并纳入用户容量统计。"""
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.COMMON.value,
resource_type=UploadResourceTypeEnum.VIDEO.value,
duration_seconds=duration_seconds,
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "video",
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
"duration_seconds": result.duration_seconds,
}
@router.post("/upload-audio")
@router.post(
"/upload-audio",
summary="上传 AI 创作普通参考音频",
description=(
"上传普通 AI 创作参考音频,写入 UploadResource 资源账本并纳入用户上传容量统计。"
"当前仅支持 mp3、wav;单文件大小受 AUDIO_MAX_FILE_SIZE_MB 限制。"
"duration_seconds 为前端识别的音频秒数,用于 AI 创作音频总时长校验。"
"未绑定业务记录前可单独删除,并会出现在 /upload-resources/history 历史素材中。"
),
responses={400: {"description": "音频格式、MIME、大小或容量校验失败"}, 401: {"description": "未登录或 Token 无效"}},
)
async def upload_audio(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
duration_seconds: float | None = Query(None, description="前端识别的音频时长秒数,可选"),
db: AsyncSession = Depends(get_db),
):
"""Upload an audio file for AI creation reference."""
"""上传普通参考音频,记录 UploadResource 并纳入用户容量统计。"""
import os
import uuid
from app.config import settings
from datetime import datetime
ext = os.path.splitext(file.filename or "")[1].lower().lstrip(".")
if ext not in AUDIO_ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="仅支持 mp3、wav 音频文件")
expected_mime = AUDIO_ALLOWED_MIME_TYPES.get(ext)
if not file.content_type or file.content_type != expected_mime:
if expected_mime and file.content_type and file.content_type != expected_mime:
raise HTTPException(status_code=400, detail=f"音频 MIME 类型错误,{ext} 必须为 {expected_mime}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = f"audio_ref_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}.{ext}"
date_dir = datetime.now().strftime("%Y/%m/%d")
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "audios", date_dir)
os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, safe_name)
content = await file.read()
max_bytes = AUDIO_MAX_FILE_SIZE_MB * 1024 * 1024
if len(content) > max_bytes:
raise HTTPException(status_code=400, detail=f"音频大小不能超过{AUDIO_MAX_FILE_SIZE_MB}MB")
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/audios/{date_dir}/{safe_name}"
return {"url": url, "filename": file.filename or safe_name, "type": "audio"}
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.COMMON.value,
resource_type=UploadResourceTypeEnum.AUDIO.value,
duration_seconds=duration_seconds,
max_bytes=AUDIO_MAX_FILE_SIZE_MB * 1024 * 1024,
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "audio",
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
"duration_seconds": result.duration_seconds,
}
@router.post("/delete-file")
@router.post(
"/delete-file",
summary="删除未绑定上传文件",
description=(
"删除当前用户自己的未绑定上传文件,并释放 UploadResource 上传容量。"
"仅允许删除 bind_status=pending、delete_policy=user_deletable、未绑定 source_model/source_id 的资源。"
"删除顺序为主事务先 soft delete 并 commitcommit 成功后再清理真实文件。"
"该接口保留给单文件删除;批量删除请使用 DELETE /upload-resources/history/batch。"
),
responses={400: {"description": "文件路径无效、文件已被模块任务使用或不可单独删除"}, 401: {"description": "未登录或 Token 无效"}, 403: {"description": "无权删除此文件"}},
)
async def delete_upload(
url: str = Query(..., description="文件URL,如 /uploads/images/2024/01/01/video_img_xxx.png"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete an uploaded file by URL."""
import os
from app.config import settings
"""删除未绑定业务记录的上传文件,并释放 UploadResource 容量。"""
user_id = str(current_user.id)
pending_ids: list[str] = []
legacy_paths: list[str] = []
try:
result = await delete_unbound_upload_resource(db, user=current_user, url=url)
pending_ids = list(result.pop("_pending_physical_delete_resource_ids", []) or [])
legacy_paths = list(result.pop("_legacy_pending_delete_paths", []) or [])
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.DELETE_UPLOAD_ROLLBACK_FAILED.value,
message="删除上传文件主事务回滚失败",
user_id=user_id,
detail={"url": url},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.DELETE_UPLOAD_FAILED.value,
message=f"删除上传文件失败: {exc}",
user_id=user_id,
detail={"url": url},
exc=exc,
)
raise
if not url.startswith("/uploads/"):
raise HTTPException(status_code=400, detail="无效的文件路径")
if current_user.id not in url:
raise HTTPException(status_code=403, detail="无权删除此文件")
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", ""))
if os.path.exists(file_path):
os.remove(file_path)
return {"message": "ok"}
if pending_ids or legacy_paths:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids, legacy_paths=legacy_paths)
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.DELETE_UPLOAD_ROLLBACK_FAILED.value,
message="删除上传文件 cleanup 事务回滚失败",
user_id=user_id,
detail={"url": url, "pending_ids": pending_ids, "legacy_paths": legacy_paths},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.DELETE_UPLOAD_CLEANUP_FAILED.value,
message=f"删除上传文件后清理真实文件失败: {exc}",
user_id=user_id,
resource_ids=pending_ids,
detail={"url": url, "legacy_paths": legacy_paths},
exc=exc,
)
return result
+11 -2
View File
@@ -121,6 +121,9 @@ async def list_engines(
"该接口用于 Chat 风格的图片/视频生成,不再绑定 project_id。"
"创建成功后会写入 chat_generation_tasks 表,并投递 Celery 异步任务。"
"支持 image 图片生成和 video 视频生成。"
"media_references 支持 image/video/audiosource 可为 upload_resource=历史上传素材、"
"private_portrait_asset=私域真人/虚拟素材、空=本次普通上传素材。"
"视频/音频参考素材必须携带 duration,并按 AI 创作原逻辑校验单段 2~15 秒、总时长不超过 15 秒。"
"建议前端传入 idempotency_key,用于防止按钮连点、网络重试导致重复创建任务和重复扣费。"
),
responses={
@@ -141,7 +144,12 @@ async def list_engines(
async def create_task(
req: GenerationAITaskCreate = Body(
...,
description="AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数",
description=(
"AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。"
"枚举:gen_type=image/videomedia_references[].type=image/video/audio"
"media_references[].source=upload_resource/private_portrait_asset/空;"
"media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。"
),
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
@@ -186,7 +194,8 @@ async def create_task(
"分页获取当前登录用户的AI生成任务列表。"
"可按生成类型 gen_type 和任务状态 status 过滤。"
"该接口返回的是普通任务列表,不按日期分组。"
"如果前端需要按生成日期分组展示历史记录,请使用 /generation-ai/history 接口。"
"如果前端需要按生成日期分组展示生成历史记录,请使用 /generation-ai/history 接口。"
"上传素材历史不是生成历史,请使用 /upload-resources/history。"
),
responses={
200: {
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,6 +48,9 @@ from app.services.module_async_recovery_service import (
register_module_step_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
@@ -203,6 +206,66 @@ async def get_spec():
return HotOpeningSpecOut()
@router.post(
"/upload-image",
summary="上传爆款开头复刻图片素材",
description="上传后先记录为 pending UploadResource;创建项目成功后绑定 ModuleGenerationProject。",
)
async def upload_hot_opening_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
resource_type=UploadResourceTypeEnum.IMAGE.value,
gen_type="video",
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "image",
"module": result.module,
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
}
@router.post(
"/upload-video",
summary="上传爆款开头复刻视频素材",
description="上传后先记录为 pending UploadResource;创建项目成功后绑定 ModuleGenerationProject。",
)
async def upload_hot_opening_video(
file: UploadFile = File(...),
duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
resource_type=UploadResourceTypeEnum.VIDEO.value,
duration_seconds=duration_seconds,
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "video",
"module": result.module,
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
"duration_seconds": result.duration_seconds,
}
@router.post(
"/tasks",
response_model=HotOpeningTaskDetailOut,
@@ -222,6 +285,16 @@ async def create_task(
try:
project = await create_hot_opening_project(db, current_user, req)
project_id_value = str(project.id)
await bind_upload_resources(
db,
user_id=current_user.id,
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_id=project_id_value,
resource_ids=[req.material_video_resource_id, req.material_image_resource_id],
urls=[req.material_video_url, req.material_image_url],
allow_common_migrate=True,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -703,13 +776,72 @@ async def delete_task(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user_id = _safe_user_id(current_user)
pending_ids: list[str] = []
try:
result = await delete_hot_opening_project(db, current_user=current_user, project_id=project_id)
pending_ids = list(result.pending_delete_resource_ids or [])
await db.commit()
return result
except HTTPException:
await db.rollback()
except HTTPException as exc:
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_ROLLBACK_FAILED.value,
message="删除爆款开头复刻项目 HTTPException 回滚失败",
user_id=user_id,
module=MODULE,
detail={"project_id": project_id},
original_exc=exc,
)
raise
except Exception as exc:
await db.rollback()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_ROLLBACK_FAILED.value,
message="删除爆款开头复刻项目主事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"project_id": project_id},
original_exc=exc,
)
_log_api_error(
event_type=HotOpeningLogEventEnum.API_REQUEST_FAILED.value,
current_user=current_user,
project_id=project_id,
message=f"删除爆款开头复刻项目失败: {exc}",
detail={"project_id": project_id},
exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_FAILED.value,
message=f"删除爆款开头复刻项目失败: {exc}",
user_id=user_id,
module=MODULE,
detail={"project_id": project_id},
exc=exc,
)
raise HTTPException(status_code=500, detail=f"删除爆款开头复刻项目失败: {exc}")
if pending_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_ROLLBACK_FAILED.value,
message="删除爆款开头复刻项目 cleanup 事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"project_id": project_id, "pending_ids": pending_ids},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.HOT_OPENING_DELETE_PROJECT_CLEANUP_FAILED.value,
message=f"删除爆款开头复刻项目后清理真实文件失败: {exc}",
user_id=user_id,
resource_ids=pending_ids,
module=MODULE,
detail={"project_id": project_id},
exc=exc,
)
return result
+143 -29
View File
@@ -9,7 +9,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.enums.private_portrait import (
PrivatePortraitAssetType,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
@@ -23,8 +22,9 @@ from app.schemas.private_portrait import (
PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut,
PrivatePortraitAssetOut,
PrivatePortraitDeleteOut,
PrivatePortraitConfigOut,
PrivatePortraitDeleteOut,
PrivatePortraitEnumMetaOut,
PrivatePortraitProjectCreate,
PrivatePortraitProjectCreateWithValidateOut,
PrivatePortraitProjectListOut,
@@ -33,7 +33,6 @@ from app.schemas.private_portrait import (
PrivatePortraitSelectableAssetListOut,
PrivatePortraitValidateSessionCreate,
PrivatePortraitValidateSessionOut,
PrivatePortraitEnumMetaOut,
build_private_portrait_enum_meta,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
@@ -65,20 +64,47 @@ from app.services.private_portrait.real_person.service import (
router = APIRouter(tags=["私域真人素材库"])
_REAL_PERSON_API_DESCRIPTION = (
"私域真人素材库 API。真人和虚拟素材共用用户素材额度;user.private_portrait_asset_limit=0 表示关闭,"
">0 表示启用并限制总素材数量。真人项目创建后需要通过火山 CreateVisualValidateSession 进行人脸认证,"
"每个真人项目只允许认证一次。认证回调 resultCode=10000 表示成功,成功后才允许上传可用于 AI 创作的真人素材。"
)
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, exc=exc, detail={"task_name": task_name})
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
exc=exc,
detail={"task_name": task_name},
)
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, detail={"task_name": task_name})
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
detail={"task_name": task_name},
)
@router.get(
"/private-portrait/config",
response_model=PrivatePortraitConfigOut,
summary="获取当前用户私域人素材额度配置",
description="返回私域人像素材总量限制。额度由真人认证素材库与虚拟人像素材库共用,图片和视频共用,Audio 暂未开放。",
summary="获取当前用户私域人素材额度配置",
description=(
_REAL_PERSON_API_DESCRIPTION
+ "返回 enabled、asset_limit、used_asset_count、remaining_asset_count 等字段。额度按用户维度限制,真人/虚拟、图片/视频共用;Audio 暂不开放。"
),
)
async def get_my_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return await get_user_private_portrait_config(db, user_id=current_user.id)
@@ -87,8 +113,12 @@ async def get_my_private_portrait_config(current_user: User = Depends(get_curren
@router.get(
"/private-portrait/meta/enums",
response_model=PrivatePortraitEnumMetaOut,
summary="获取私域人素材库枚举说明",
description="给前端展示状态、类型、素材库类型使用。Audio 仅作为火山支持项展示,当前业务不开放上传。",
summary="获取私域人素材库枚举说明",
description=(
"返回前端展示所需枚举:library_type=real_person/aigc_virtual"
"asset_type=Image/Video/Audioproject status、asset status、remote_delete_status 等。"
"当前业务上传只开放 Image 和 Video,Audio 仅作为兼容枚举展示。"
),
)
async def get_private_portrait_enum_meta():
return build_private_portrait_enum_meta()
@@ -98,7 +128,11 @@ async def get_private_portrait_enum_meta():
"/private-portrait/projects",
response_model=PrivatePortraitProjectCreateWithValidateOut,
summary="创建真人认证素材项目并生成认证会话",
description="创建本地真人素材项目,随后调用火山 CreateVisualValidateSession 返回 H5Link。用户完成认证后,回调会创建本地 Asset Group 映射。",
description=(
_REAL_PERSON_API_DESCRIPTION
+ "创建本地真人项目后立即调用火山 CreateVisualValidateSession,返回 H5Link/BytedToken 给 PC 前端展示二维码。"
"手机扫码完成人脸认证后,PC 端通过 validate_session 查询状态;认证成功后才允许创建真人素材。"
),
)
async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
@@ -112,13 +146,13 @@ async def create_private_portrait_project(payload: PrivatePortraitProjectCreate,
"/private-portrait/projects",
response_model=PrivatePortraitProjectListOut,
summary="查询当前用户真人认证素材项目列表",
description="只返回 library_type=real_person 的项目。默认查询 active 项目,可 status 覆盖。",
description="只返回 library_type=real_person 的项目。默认查询 active 项目,可通过 status 覆盖。",
)
async def list_private_portrait_projects(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
keyword: str | None = Query(None, description="项目名称模糊搜索"),
status: str | None = Query(None, description="项目状态,不传默认 active"),
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
keyword: str | None = Query(None, description="项目名称模糊搜索"),
status: str | None = Query(None, description="项目状态筛选,不传默认 active"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -130,12 +164,22 @@ async def list_private_portrait_projects(
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取真人认证素材项目详情")
@router.get(
"/private-portrait/projects/{project_id}",
response_model=PrivatePortraitProjectOut,
summary="获取真人认证素材项目详情",
description="获取当前用户真人项目详情,project_id 必须属于当前用户且 library_type=real_person。",
)
async def get_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value))
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新真人认证素材项目")
@router.put(
"/private-portrait/projects/{project_id}",
response_model=PrivatePortraitProjectOut,
summary="更新真人认证素材项目",
description="更新真人项目本地展示信息;不会重新发起真人认证。每个真人项目只允许认证一次。",
)
async def update_private_portrait_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await update_real_person_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
out = project_to_out(project)
@@ -143,7 +187,12 @@ async def update_private_portrait_project(project_id: str, payload: PrivatePortr
return out
@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材项目")
@router.delete(
"/private-portrait/projects/{project_id}",
response_model=PrivatePortraitDeleteOut,
summary="删除真人认证素材项目",
description="软删真人项目和本地素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 AssetGroup/Assetremote_delete_status=pending 表示远端删除处理中。",
)
async def delete_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
project_id_snapshot = project.id
@@ -158,7 +207,12 @@ async def delete_private_portrait_project(project_id: str, current_user: User =
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut, summary="重新创建真人认证会话")
@router.post(
"/private-portrait/projects/{project_id}/validate-sessions",
response_model=PrivatePortraitValidateSessionOut,
summary="重新创建真人认证会话",
description="为真人项目创建新的认证会话。业务层会限制项目只能认证一次;已认证成功的项目不允许重复认证。",
)
async def create_private_portrait_validate_session(project_id: str, payload: PrivatePortraitValidateSessionCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url)
out = validate_session_to_out(session)
@@ -166,12 +220,21 @@ async def create_private_portrait_validate_session(project_id: str, payload: Pri
return out
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut, summary="查询真人认证会话状态")
@router.get(
"/private-portrait/validate-sessions/{session_id}",
response_model=PrivatePortraitValidateSessionOut,
summary="查询真人认证会话状态",
description="PC 端轮询该接口查看手机扫码认证结果。status/result_code/remote_group_id 可用于判断是否认证成功并提示用户回到 PC 查看项目。",
)
async def get_private_portrait_validate_session(session_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return validate_session_to_out(await get_validate_session(db, user_id=current_user.id, session_id=session_id))
@router.get("/private-portrait/validate-callback", summary="火山真人认证回调入口")
@router.get(
"/private-portrait/validate-callback",
summary="火山真人认证回调入口",
description="火山真人认证 H5 回调入口。resultCode=10000 表示认证成功;成功后会创建或更新本地 AssetGroup 映射,并可 redirect 回前端提示页。",
)
async def private_portrait_validate_callback(session_id: str, request: Request, redirect_url: str | None = None, db: AsyncSession = Depends(get_db)):
params = dict(request.query_params)
params.pop("session_id", None)
@@ -189,7 +252,16 @@ async def private_portrait_validate_callback(session_id: str, request: Request,
return response
@router.post("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传真人认证素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
@router.post(
"/private-portrait/projects/{project_id}/assets",
response_model=PrivatePortraitAssetOut,
summary="上传真人认证素材",
description=(
"在已认证成功的真人项目下创建素材。当前仅开放 asset_type=Image/VideoAudio 暂不开放。"
"Video 必须携带 video_duration,建议前端限制 2~15 秒。CreateAsset 是异步接口,返回后会投递轮询任务,"
"只有 status=Active 的素材才会出现在 selectable-assets 并可用于 AI 创作。"
),
)
async def create_private_portrait_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await create_real_person_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
asset_id_snapshot = asset.id
@@ -206,13 +278,32 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai
return out
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询真人认证素材列表")
async def list_private_portrait_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
@router.get(
"/private-portrait/projects/{project_id}/assets",
response_model=PrivatePortraitAssetListOut,
summary="查询真人认证素材列表",
description="查询指定真人项目下的素材。asset_type 可传 Image 或 Videostatus 可筛选素材状态。",
)
async def list_private_portrait_assets(
project_id: str,
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
status: str | None = Query(None, description="素材状态筛选,不传查全部"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image=图片,Video=视频;Audio 暂不开放"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/assets/{asset_id}", summary="获取真人认证素材详情")
@router.get(
"/private-portrait/assets/{asset_id}",
response_model=PrivatePortraitAssetOut,
summary="获取真人认证素材详情",
description="获取当前用户真人素材详情,asset_id 必须属于当前用户且 library_type=real_person。",
)
async def get_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.REAL_PERSON.value).limit(1))).scalar_one_or_none()
if not asset:
@@ -221,7 +312,12 @@ async def get_private_portrait_asset(asset_id: str, current_user: User = Depends
return asset_to_out(asset, project_name=project.name if project else None)
@router.post("/private-portrait/assets/{asset_id}/sync", summary="同步真人认证素材状态")
@router.post(
"/private-portrait/assets/{asset_id}/sync",
response_model=PrivatePortraitAssetOut,
summary="同步真人认证素材状态",
description="主动向火山查询并同步真人素材状态。一般由轮询任务自动执行;前端排查或手动刷新时可调用。",
)
async def sync_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
if asset.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
@@ -231,7 +327,12 @@ async def sync_private_portrait_asset(asset_id: str, current_user: User = Depend
return out
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材")
@router.delete(
"/private-portrait/assets/{asset_id}",
response_model=PrivatePortraitDeleteOut,
summary="删除真人认证素材",
description="软删本地真人素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 Assetremote_delete_status=pending 表示远端删除处理中。",
)
async def delete_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
asset_id_snapshot = asset.id
@@ -247,7 +348,20 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的真人认证素材")
async def list_private_portrait_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
@router.get(
"/private-portrait/selectable-assets",
response_model=PrivatePortraitSelectableAssetListOut,
summary="查询可用于 AI 创作的真人认证素材",
description="只返回当前用户真人素材库中 status=Active 且未删除的 Image/Video 素材。该接口给 AI 创作参考内容选择器使用。",
)
async def list_private_portrait_selectable_assets(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
project_id: str | None = Query(None, description="按真人项目 ID 筛选"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image 或 Video,不传查全部"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
@@ -50,26 +50,65 @@ from app.services.private_portrait.virtual.service import create_virtual_asset,
router = APIRouter(tags=["私域虚拟人像素材库"])
_VIRTUAL_API_DESCRIPTION = (
"私域虚拟素材库 API。真人和虚拟素材共用用户素材额度;user.private_portrait_asset_limit=0 表示关闭,"
">0 表示启用并限制总素材数量。创建虚拟项目会同步调用火山 CreateAssetGroupGroupType=AIGC"
"remote_project_name 默认使用 default。当前上传只开放 Image/VideoAudio 暂不开放。"
)
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, exc=exc, detail={"task_name": task_name})
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
exc=exc,
detail={"task_name": task_name},
)
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, detail={"task_name": task_name})
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
detail={"task_name": task_name},
)
@router.get("/private-portrait/virtual/config", response_model=PrivatePortraitConfigOut, summary="获取虚拟人像素材库额度配置", description="额度与真人认证素材库共用;图片/视频共用;Audio 暂不开放。")
@router.get(
"/private-portrait/virtual/config",
response_model=PrivatePortraitConfigOut,
summary="获取虚拟素材库额度配置",
description=_VIRTUAL_API_DESCRIPTION + "返回虚拟素材库可用额度,实际与真人素材库共用。",
)
async def get_my_virtual_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return await get_user_private_portrait_config(db, user_id=current_user.id)
@router.get("/private-portrait/virtual-meta/enums", response_model=PrivatePortraitEnumMetaOut, summary="获取虚拟人像素材库枚举说明")
@router.get(
"/private-portrait/virtual-meta/enums",
response_model=PrivatePortraitEnumMetaOut,
summary="获取虚拟素材库枚举说明",
description="返回前端展示所需枚举:library_type、asset_type、project status、asset status、remote_delete_status 等。Audio 暂不开放上传。",
)
async def get_virtual_private_portrait_enum_meta():
return build_private_portrait_enum_meta()
@router.post("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectOut, summary="创建虚拟人像项目组", description="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroupGroupType=AIGC。ProjectName 必须与后续生成 API Key 所属项目一致,默认使用 default。")
@router.post(
"/private-portrait/virtual-projects",
response_model=PrivatePortraitProjectOut,
summary="创建虚拟素材项目组",
description=_VIRTUAL_API_DESCRIPTION + "创建成功后返回本地项目详情,后续上传虚拟素材必须归属到该 project_id。",
)
async def create_private_portrait_virtual_project(payload: PrivatePortraitVirtualProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await create_virtual_project(db, user_id=current_user.id, payload=payload)
out = project_to_out(project)
@@ -77,8 +116,20 @@ async def create_private_portrait_virtual_project(payload: PrivatePortraitVirtua
return out
@router.get("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectListOut, summary="查询当前用户虚拟人像项目列表")
async def list_private_portrait_virtual_projects(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), keyword: str | None = Query(None), status: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
@router.get(
"/private-portrait/virtual-projects",
response_model=PrivatePortraitProjectListOut,
summary="查询当前用户虚拟素材项目列表",
description="只返回 library_type=aigc_virtual 的项目。默认查询 active 项目,可通过 status 覆盖。",
)
async def list_private_portrait_virtual_projects(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
keyword: str | None = Query(None, description="项目名称模糊搜索"),
status: str | None = Query(None, description="项目状态筛选,不传默认 active"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
await refresh_project_counters(db, [item.id for item in items])
@@ -87,12 +138,22 @@ async def list_private_portrait_virtual_projects(page: int = Query(1, ge=1), pag
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取虚拟人像项目详情")
@router.get(
"/private-portrait/virtual-projects/{project_id}",
response_model=PrivatePortraitProjectOut,
summary="获取虚拟素材项目详情",
description="获取当前用户虚拟素材项目详情,project_id 必须属于当前用户且 library_type=aigc_virtual。",
)
async def get_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value))
@router.put("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新虚拟人像项目")
@router.put(
"/private-portrait/virtual-projects/{project_id}",
response_model=PrivatePortraitProjectOut,
summary="更新虚拟素材项目",
description="更新虚拟素材项目本地展示信息。不会重新创建远端 AssetGroup。",
)
async def update_private_portrait_virtual_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await update_virtual_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
out = project_to_out(project)
@@ -100,7 +161,12 @@ async def update_private_portrait_virtual_project(project_id: str, payload: Priv
return out
@router.delete("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像项目")
@router.delete(
"/private-portrait/virtual-projects/{project_id}",
response_model=PrivatePortraitDeleteOut,
summary="删除虚拟素材项目",
description="软删虚拟素材项目和本地素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 AssetGroup/Assetremote_delete_status=pending 表示远端删除处理中。",
)
async def delete_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
project_id_snapshot = project.id
@@ -115,7 +181,16 @@ async def delete_private_portrait_virtual_project(project_id: str, current_user:
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.post("/private-portrait/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传虚拟人像素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
@router.post(
"/private-portrait/virtual-projects/{project_id}/assets",
response_model=PrivatePortraitAssetOut,
summary="上传虚拟素材",
description=(
"在虚拟素材项目下创建素材。当前仅开放 asset_type=Image/VideoAudio 暂不开放。"
"Video 必须携带 video_duration,建议前端限制 2~15 秒。CreateAsset 是异步接口,返回后会投递轮询任务,"
"只有 status=Active 的素材才会出现在 virtual-selectable-assets 并可用于 AI 创作。"
),
)
async def create_private_portrait_virtual_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await create_virtual_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
asset_id_snapshot = asset.id
@@ -132,13 +207,32 @@ async def create_private_portrait_virtual_asset(project_id: str, payload: Privat
return out
@router.get("/private-portrait/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询虚拟人像素材列表")
async def list_private_portrait_virtual_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
@router.get(
"/private-portrait/virtual-projects/{project_id}/assets",
response_model=PrivatePortraitAssetListOut,
summary="查询虚拟素材列表",
description="查询指定虚拟项目下的素材。asset_type 可传 Image 或 Videostatus 可筛选素材状态。",
)
async def list_private_portrait_virtual_assets(
project_id: str,
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
status: str | None = Query(None, description="素材状态筛选,不传查全部"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image=图片,Video=视频;Audio 暂不开放"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitAssetOut, summary="获取虚拟人像素材详情")
@router.get(
"/private-portrait/virtual-assets/{asset_id}",
response_model=PrivatePortraitAssetOut,
summary="获取虚拟素材详情",
description="获取当前用户虚拟素材详情,asset_id 必须属于当前用户且 library_type=aigc_virtual。",
)
async def get_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value).limit(1))).scalar_one_or_none()
if not asset:
@@ -147,7 +241,12 @@ async def get_private_portrait_virtual_asset(asset_id: str, current_user: User =
return asset_to_out(asset, project_name=project.name if project else None)
@router.post("/private-portrait/virtual-assets/{asset_id}/sync", response_model=PrivatePortraitAssetOut, summary="同步虚拟人像素材状态")
@router.post(
"/private-portrait/virtual-assets/{asset_id}/sync",
response_model=PrivatePortraitAssetOut,
summary="同步虚拟素材状态",
description="主动向火山查询并同步虚拟素材状态。一般由轮询任务自动执行;前端排查或手动刷新时可调用。",
)
async def sync_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
if asset.library_type != PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
@@ -157,7 +256,12 @@ async def sync_private_portrait_virtual_asset(asset_id: str, current_user: User
return out
@router.delete("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像素材")
@router.delete(
"/private-portrait/virtual-assets/{asset_id}",
response_model=PrivatePortraitDeleteOut,
summary="删除虚拟素材",
description="软删本地虚拟素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 Assetremote_delete_status=pending 表示远端删除处理中。",
)
async def delete_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
asset_id_snapshot = asset.id
@@ -173,7 +277,20 @@ async def delete_private_portrait_virtual_asset(asset_id: str, current_user: Use
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.get("/private-portrait/virtual-selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的虚拟人像素材")
async def list_private_portrait_virtual_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
@router.get(
"/private-portrait/virtual-selectable-assets",
response_model=PrivatePortraitSelectableAssetListOut,
summary="查询可用于 AI 创作的虚拟素材",
description="只返回当前用户虚拟素材库中 status=Active 且未删除的 Image/Video 素材。该接口给 AI 创作参考内容选择器使用。",
)
async def list_private_portrait_virtual_selectable_assets(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
project_id: str | None = Query(None, description="按虚拟项目 ID 筛选"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image 或 Video,不传查全部"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
+197 -21
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,6 +35,7 @@ from app.schemas.shot_replicate import (
ShotReplicateTaskDetailOut,
ShotReplicateVideoPromptSchemaUpdateRequest,
ShotSegmentDeleteOut,
ShotTaskSetDeleteOut,
ShotSegmentDetailOut,
ShotSegmentListOut,
ShotSegmentReplicationCreateRequest,
@@ -49,7 +50,6 @@ from app.schemas.shot_replicate import (
from app.services.shot_replicate_flow_service import (
_get_project_for_user,
create_shot_replicate_project_from_segment,
delete_shot_replicate_project,
generate_image_from_prompt,
generate_video_from_prompt,
mark_shot_replicate_step_dispatch_failed,
@@ -65,6 +65,7 @@ from app.services.shot_replicate_taskset_service import (
create_segments_by_ai,
create_task_set,
delete_segment,
delete_task_set,
get_segment_for_user,
list_segments,
list_task_sets,
@@ -83,6 +84,9 @@ from app.services.module_async_recovery_service import (
register_shot_task_set_analysis_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
MODULE = ModuleCodeEnum.SHOT_REPLICATE.value
@@ -245,6 +249,66 @@ async def get_spec():
return ShotReplicateSpecOut()
@router.post(
"/upload-image",
summary="上传拆镜复刻图片素材",
description="上传后先记录为 pending UploadResource;创建拆镜复刻项目后绑定业务记录。",
)
async def upload_shot_replicate_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
resource_type=UploadResourceTypeEnum.IMAGE.value,
gen_type="video",
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "image",
"module": result.module,
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
}
@router.post(
"/upload-video",
summary="上传拆镜复刻视频素材",
description="上传后先记录为 pending UploadResource;创建拆镜总任务集后绑定 ShotReplicateTaskSet。",
)
async def upload_shot_replicate_video(
file: UploadFile = File(...),
duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
resource_type=UploadResourceTypeEnum.VIDEO.value,
duration_seconds=duration_seconds,
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "video",
"module": result.module,
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
"duration_seconds": result.duration_seconds,
}
@router.post(
"/task-sets",
response_model=ShotTaskSetDetailOut,
@@ -264,6 +328,16 @@ async def create_shot_task_set(
try:
task_set = await create_task_set(db, current_user=current_user, req=req)
task_set_id = task_set.id
await bind_upload_resources(
db,
user_id=current_user.id,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
source_id=task_set_id,
resource_ids=[req.video_resource_id],
urls=[req.video_url],
allow_common_migrate=True,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -626,18 +700,69 @@ async def delete_shot_segment(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user_id = _safe_user_id(current_user)
pending_ids: list[str] = []
try:
out = await delete_segment(db, current_user=current_user, segment_id=segment_id)
pending_ids = list(out.pending_delete_resource_ids or [])
await db.commit()
return out
except HTTPException:
await db.rollback()
except HTTPException as exc:
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜片段 HTTPException 回滚失败",
user_id=user_id,
module=MODULE,
detail={"segment_id": segment_id},
original_exc=exc,
)
raise
except Exception as exc:
await db.rollback()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜片段主事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"segment_id": segment_id},
original_exc=exc,
)
_log_api_exception_from_locals(exc, locals(), f"删除拆镜片段失败: {exc}")
log_upload_resource_exception(
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_FAILED.value,
message=f"删除拆镜片段失败: {exc}",
user_id=user_id,
module=MODULE,
detail={"segment_id": segment_id},
exc=exc,
)
raise HTTPException(status_code=500, detail=f"删除拆镜片段失败: {exc}")
if pending_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜片段 cleanup 事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"segment_id": segment_id, "pending_ids": pending_ids},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_CLEANUP_FAILED.value,
message=f"删除拆镜片段后清理真实文件失败: {exc}",
user_id=user_id,
resource_ids=pending_ids,
module=MODULE,
detail={"segment_id": segment_id},
exc=exc,
)
return out
@router.post(
"/segments/{segment_id}/replication-projects",
@@ -950,24 +1075,75 @@ async def generate_video(
@router.delete(
"/projects/{project_id}",
response_model=ShotReplicateDeleteOut,
summary="软删除拆镜复刻项目",
description="软删除拆镜复刻 ModuleGenerationProject,并联动软删除当前有效步骤和关联的 ChatGenerationTask",
"/task-sets/{task_set_id}",
response_model=ShotTaskSetDeleteOut,
summary="软删除拆镜任务集",
description="软删除整个 ShotReplicateTaskSet,并联动软删除全部片段和片段内部复刻项目;UploadResource 真实文件在事务提交后清理",
)
async def delete_project(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
async def delete_shot_task_set(
task_set_id: str = Path(..., description="拆镜任务集ID,即 shot_replicate_task_sets.id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user_id = _safe_user_id(current_user)
pending_ids: list[str] = []
try:
out = await delete_shot_replicate_project(db, current_user=current_user, project_id=project_id)
out = await delete_task_set(db, current_user=current_user, task_set_id=task_set_id)
pending_ids = list(out.pending_delete_resource_ids or [])
await db.commit()
return out
except HTTPException:
await db.rollback()
except HTTPException as exc:
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜任务集 HTTPException 回滚失败",
user_id=user_id,
module=MODULE,
detail={"task_set_id": task_set_id},
original_exc=exc,
)
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"删除拆镜复刻项目失败: {exc}")
raise HTTPException(status_code=500, detail=f"删除拆镜复刻项目失败: {exc}")
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜任务集主事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"task_set_id": task_set_id},
original_exc=exc,
)
_log_api_exception_from_locals(exc, locals(), f"删除拆镜任务集失败: {exc}")
log_upload_resource_exception(
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_FAILED.value,
message=f"删除拆镜任务集失败: {exc}",
user_id=user_id,
module=MODULE,
detail={"task_set_id": task_set_id},
exc=exc,
)
raise HTTPException(status_code=500, detail=f"删除拆镜任务集失败: {exc}")
if pending_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜任务集 cleanup 事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"task_set_id": task_set_id, "pending_ids": pending_ids},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_CLEANUP_FAILED.value,
message=f"删除拆镜任务集后清理真实文件失败: {exc}",
user_id=user_id,
resource_ids=pending_ids,
module=MODULE,
detail={"task_set_id": task_set_id},
exc=exc,
)
return out
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.enums.upload_resource import UploadResourceEventEnum
from app.models.user import User
from app.schemas.upload_resource import (
UploadResourceHistoryBatchDeleteOut,
UploadResourceHistoryBatchDeleteRequest,
UploadResourceHistoryDayItemsOut,
UploadResourceHistoryGroupedOut,
)
from app.services.upload_resource.delete_service import (
cleanup_upload_resource_history_files,
mark_upload_resource_history_deleted,
)
from app.services.upload_resource.history_service import (
list_upload_resource_history_day_items,
list_upload_resource_history_grouped_days,
)
from app.services.upload_resource.log_service import (
log_upload_resource_exception,
log_upload_resource_event,
safe_rollback_with_log,
)
router = APIRouter(
prefix="/upload-resources",
tags=["upload-resources"],
)
_HISTORY_DESCRIPTION = (
"查询当前用户可展示、可复用、可删除的上传素材历史。"
"只返回 UploadResource 中未删除、未绑定业务记录、bind_status=pending、"
"delete_policy=user_deletable、resource_type=image/video/audio 的记录。"
"已绑定爆款开头复刻、拆镜复刻、拆镜切片或其它业务记录的素材不会返回,也不能通过该模块删除。"
)
@router.get(
"/history",
response_model=UploadResourceHistoryGroupedOut,
summary="获取上传历史素材日期分组",
description=(
_HISTORY_DESCRIPTION
+ "返回结构与 /generation-ai/history 接近,generated_date 表示上传日期。"
"每个日期默认回填前 10 条 items,避免前端首屏再次逐日请求。"
),
responses={
200: {"description": "查询成功,返回上传素材日期分组"},
400: {"description": "resource_type 不合法或分页参数不合法"},
401: {"description": "未登录或 Token 无效"},
},
)
async def list_history_grouped_days(
resource_type: str | None = Query(
None,
description="素材类型筛选:image=图片,video=视频,audio=音频;为空表示全部",
examples=["image"],
),
page: int = Query(1, ge=1, description="日期分组页码,从 1 开始"),
page_size: int = Query(10, ge=1, le=10, description="日期分组每页数量,最大 10"),
keyword: str | None = Query(None, description="文件名或资源 URL 模糊搜索,可为空"),
scene: str | None = Query(
None,
description="前端使用场景标识:record=素材云展示,picker=AI创作选择器;仅用于日志排查,不影响过滤规则",
examples=["picker"],
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await list_upload_resource_history_grouped_days(
db,
user_id=current_user.id,
resource_type=resource_type,
page=page,
page_size=page_size,
keyword=keyword,
)
except Exception as exc: # noqa: BLE001
log_upload_resource_exception(
event_type=UploadResourceEventEnum.HISTORY_LIST_FAILED.value,
message=f"查询上传历史素材日期分组失败: {exc}",
user_id=current_user.id,
detail={"resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword, "scene": scene},
exc=exc,
)
raise
@router.get(
"/history/{generated_date}",
response_model=UploadResourceHistoryDayItemsOut,
summary="获取指定日期下上传历史素材",
description=(
_HISTORY_DESCRIPTION
+ "generated_date 格式为 YYYY-MM-DD,字段名沿用生成历史接口,实际语义为上传日期。"
),
responses={
200: {"description": "查询成功,返回指定上传日期下的素材列表"},
400: {"description": "日期格式错误、resource_type 不合法或分页参数不合法"},
401: {"description": "未登录或 Token 无效"},
},
)
async def list_history_day_items(
generated_date: str = Path(..., description="上传日期,格式 YYYY-MM-DD", examples=["2026-07-08"]),
resource_type: str | None = Query(
None,
description="素材类型筛选:image=图片,video=视频,audio=音频;为空表示全部",
examples=["video"],
),
page: int = Query(1, ge=1, description="当前日期下分页页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="当前日期下每页数量,最大 100"),
keyword: str | None = Query(None, description="文件名或资源 URL 模糊搜索,可为空"),
scene: str | None = Query(
None,
description="前端使用场景标识:record=素材云展示,picker=AI创作选择器;仅用于日志排查,不影响过滤规则",
examples=["record"],
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await list_upload_resource_history_day_items(
db,
user_id=current_user.id,
generated_date=generated_date,
resource_type=resource_type,
page=page,
page_size=page_size,
keyword=keyword,
)
except Exception as exc: # noqa: BLE001
log_upload_resource_exception(
event_type=UploadResourceEventEnum.HISTORY_DAY_LIST_FAILED.value,
message=f"查询指定日期上传历史素材失败: {exc}",
user_id=current_user.id,
detail={"generated_date": generated_date, "resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword, "scene": scene},
exc=exc,
)
raise
@router.delete(
"/history/batch",
response_model=UploadResourceHistoryBatchDeleteOut,
summary="批量删除上传历史素材",
description=(
"批量删除当前用户的上传历史素材。一次最多 30 条。"
"只允许删除未绑定业务记录、bind_status=pending、delete_policy=user_deletable 的 UploadResource。"
"删除顺序为:主事务内软删 DB 记录并释放上传容量 -> commit 成功 -> 再删除真实静态文件 -> 写回真实文件删除状态。"
"如果真实文件删除失败,主删除不回滚,file_delete_status 会保留 delete_failed/pending_delete 供后续补偿排查。"
),
responses={
200: {"description": "删除成功,返回软删数量、释放容量和真实文件清理结果"},
400: {"description": "resource_ids 为空、重复或超过 30 条"},
404: {"description": "部分素材不存在、已删除、已绑定业务记录或无权操作"},
401: {"description": "未登录或 Token 无效"},
},
)
async def batch_delete_upload_history(
req: UploadResourceHistoryBatchDeleteRequest = Body(..., description="批量删除上传历史素材请求体"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result: UploadResourceHistoryBatchDeleteOut | None = None
user_id = str(current_user.id)
try:
result = await mark_upload_resource_history_deleted(db, current_user=current_user, resource_ids=req.resource_ids)
await db.commit()
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_BATCH_COMMIT_SUCCESS.value,
user_id=user_id,
detail={
"requested_ids": result.requested_ids,
"deleted_ids": result.deleted_ids,
"released_size_bytes": result.released_size_bytes,
},
)
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.DELETE_BATCH_ROLLBACK_FAILED.value,
message="批量删除上传历史素材主事务回滚失败",
user_id=user_id,
detail={"resource_ids": req.resource_ids},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.DELETE_BATCH_FAILED.value,
message=f"批量删除上传历史素材失败: {exc}",
user_id=user_id,
resource_ids=req.resource_ids,
detail={"resource_ids": req.resource_ids},
exc=exc,
)
raise
if not result:
raise HTTPException(status_code=500, detail="批量删除上传历史素材失败")
try:
result = await cleanup_upload_resource_history_files(db, result=result, current_user=current_user)
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.DELETE_BATCH_ROLLBACK_FAILED.value,
message="批量删除上传历史素材 cleanup 事务回滚失败",
user_id=user_id,
detail={"resource_ids": req.resource_ids, "deleted_ids": result.deleted_ids if result else []},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.DELETE_BATCH_CLEANUP_FAILED.value,
message=f"批量删除上传历史素材后清理真实文件失败: {exc}",
user_id=user_id,
resource_ids=result.deleted_ids if result else req.resource_ids,
detail={"resource_ids": req.resource_ids, "deleted_ids": result.deleted_ids if result else []},
exc=exc,
)
return result
+1
View File
@@ -0,0 +1 @@
"""项目 CLI 脚本包。"""
@@ -0,0 +1,88 @@
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from app.models.base import async_session
from app.enums.upload_resource import UploadResourceEventEnum
from app.services.upload_resource.backfill_service import BackfillOptions, run_upload_resource_backfill
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
VALID_MODULES = {"common", "hot_opening_replicate", "shot_replicate"}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="补录 storage/uploads 下的上传资源账本,并可重绑模块归属。")
parser.add_argument("--root", default="storage/uploads", help="扫描根目录,默认 storage/uploads")
parser.add_argument("--batch-size", type=int, default=500, help="每批处理数量,默认 500")
parser.add_argument("--dry-run", action="store_true", help="试跑,不写数据库")
parser.add_argument("--commit", action="store_true", help="真实执行写入")
parser.add_argument("--include-legacy", action="store_true", help="补录 gen_{user_id}_* 等旧版散落文件")
parser.add_argument("--rebind-modules", action="store_true", help="根据 hot/shot 业务表中保存的 URL 重新绑定模块归属")
parser.add_argument("--rebuild-stats", action="store_true", help="补录完成后按资源账本重算容量统计")
parser.add_argument("--only-user-id", default=None, help="只处理指定用户ID")
parser.add_argument("--only-module", choices=sorted(VALID_MODULES), default=None, help="只处理指定模块")
parser.add_argument("--cleanup-pending-files", action="store_true", help="清理已软删但真实文件待删除/删除失败的 UploadResource 文件")
parser.add_argument("--cleanup-limit", type=int, default=500, help="每次最多清理待删除真实文件数量,默认 500")
return parser
async def amain(argv: list[str]) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.dry_run and args.commit:
parser.error("--dry-run 和 --commit 不能同时使用")
if not args.dry_run and not args.commit:
args.dry_run = True
if args.batch_size <= 0:
parser.error("--batch-size 必须大于 0")
options = BackfillOptions(
root=args.root,
batch_size=args.batch_size,
dry_run=not args.commit,
include_legacy=args.include_legacy,
rebind_modules=args.rebind_modules,
rebuild_stats=args.rebuild_stats,
only_user_id=args.only_user_id,
only_module=args.only_module,
cleanup_pending_files=args.cleanup_pending_files,
cleanup_limit=args.cleanup_limit,
)
async with async_session() as db:
try:
result = await run_upload_resource_backfill(db, options)
if options.dry_run:
await db.rollback()
else:
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_BACKFILL_CLI_ROLLBACK_FAILED.value,
message="UploadResource 补录/清理 CLI 回滚失败",
detail={"argv": argv, "options": vars(args)},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_BACKFILL_CLI_FAILED.value,
message=f"UploadResource 补录/清理 CLI 执行失败: {exc}",
detail={"argv": argv, "options": vars(args)},
exc=exc,
)
raise
print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2, default=str))
return 0
def main() -> int:
return asyncio.run(amain(sys.argv[1:]))
if __name__ == "__main__":
raise SystemExit(main())
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
from enum import StrEnum
class UploadResourceModuleEnum(StrEnum):
"""上传资源所属业务模块。"""
COMMON = "common"
HOT_OPENING_REPLICATE = "hot_opening_replicate"
SHOT_REPLICATE = "shot_replicate"
class UploadResourceTypeEnum(StrEnum):
"""上传资源类型。"""
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
SHOT_SEGMENT = "shot_segment"
class UploadResourceBindStatusEnum(StrEnum):
"""上传资源绑定状态。"""
PENDING = "pending"
BOUND = "bound"
class UploadResourceDeletePolicyEnum(StrEnum):
"""上传资源删除策略。"""
USER_DELETABLE = "user_deletable"
MODULE_ONLY = "module_only"
SYSTEM_ONLY = "system_only"
class UploadResourceCreatedByEnum(StrEnum):
"""上传资源创建来源。"""
API = "api"
BACKFILL = "backfill"
SPLIT_TASK = "split_task"
SYSTEM_CLEANUP = "system_cleanup"
class UploadResourceDurationSourceEnum(StrEnum):
"""音视频秒数来源。"""
FFPROBE = "ffprobe"
CLIENT = "client"
BUSINESS = "business"
UNKNOWN = "unknown"
class UploadResourceFileDeleteStatusEnum(StrEnum):
"""上传资源真实文件删除状态。"""
ACTIVE = "active"
PENDING_DELETE = "pending_delete"
DELETED = "deleted"
DELETE_FAILED = "delete_failed"
DELETE_MARKED_PENDING = "delete_marked_pending"
DELETE_PHYSICAL_SUCCESS = "delete_physical_success"
DELETE_PHYSICAL_MISSING = "delete_physical_missing"
DELETE_PHYSICAL_FAILED = "delete_physical_failed"
CLEANUP_PENDING_START = "cleanup_pending_start"
CLEANUP_PENDING_FINISHED = "cleanup_pending_finished"
MISSING = "missing"
class UploadResourceSourceModelEnum(StrEnum):
"""上传资源业务来源模型名。"""
MODULE_GENERATION_PROJECT = "ModuleGenerationProject"
SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet"
SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment"
class UploadResourceEventEnum(StrEnum):
"""上传资源日志事件。"""
UPLOAD_START = "upload_start"
UPLOAD_CAPACITY_CHECKED = "upload_capacity_checked"
UPLOAD_CAPACITY_REJECTED = "upload_capacity_rejected"
UPLOAD_FILE_SAVED = "upload_file_saved"
UPLOAD_DB_RECORDED = "upload_db_recorded"
UPLOAD_FAILED = "upload_failed"
DELETE_START = "delete_start"
DELETE_REJECTED_BOUND = "delete_rejected_bound"
DELETE_FILE_REMOVED = "delete_file_removed"
DELETE_CAPACITY_RELEASED = "delete_capacity_released"
DELETE_FAILED = "delete_failed"
DELETE_MARKED_PENDING = "delete_marked_pending"
DELETE_PHYSICAL_SUCCESS = "delete_physical_success"
DELETE_PHYSICAL_MISSING = "delete_physical_missing"
DELETE_PHYSICAL_FAILED = "delete_physical_failed"
CLEANUP_PENDING_START = "cleanup_pending_start"
CLEANUP_PENDING_FINISHED = "cleanup_pending_finished"
BIND_START = "bind_start"
BIND_SUCCESS = "bind_success"
BIND_CONFLICT = "bind_conflict"
BIND_SKIPPED = "bind_skipped"
BACKFILL_START = "backfill_start"
BACKFILL_FILE_MATCHED = "backfill_file_matched"
BACKFILL_FILE_INSERTED = "backfill_file_inserted"
BACKFILL_FILE_EXISTS = "backfill_file_exists"
BACKFILL_FILE_SKIPPED = "backfill_file_skipped"
BACKFILL_REBIND_DONE = "backfill_rebind_done"
BACKFILL_STATS_REBUILT = "backfill_stats_rebuilt"
BACKFILL_FINISHED = "backfill_finished"
HISTORY_LIST_START = "history_list_start"
HISTORY_LIST_SUCCESS = "history_list_success"
HISTORY_LIST_FAILED = "history_list_failed"
HISTORY_DAY_LIST_START = "history_day_list_start"
HISTORY_DAY_LIST_SUCCESS = "history_day_list_success"
HISTORY_DAY_LIST_FAILED = "history_day_list_failed"
DELETE_BATCH_START = "delete_batch_start"
DELETE_BATCH_VALIDATE_FAILED = "delete_batch_validate_failed"
DELETE_BATCH_MARKED_PENDING = "delete_batch_marked_pending"
DELETE_BATCH_COMMIT_SUCCESS = "delete_batch_commit_success"
DELETE_BATCH_CLEANUP_SUCCESS = "delete_batch_cleanup_success"
DELETE_BATCH_CLEANUP_FAILED = "delete_batch_cleanup_failed"
DELETE_BATCH_FAILED = "delete_batch_failed"
DELETE_BATCH_ROLLBACK_FAILED = "delete_batch_rollback_failed"
DELETE_UPLOAD_FAILED = "delete_upload_failed"
DELETE_UPLOAD_ROLLBACK_FAILED = "delete_upload_rollback_failed"
DELETE_UPLOAD_CLEANUP_FAILED = "delete_upload_cleanup_failed"
HOT_OPENING_DELETE_PROJECT_FAILED = "hot_opening_delete_project_failed"
HOT_OPENING_DELETE_PROJECT_ROLLBACK_FAILED = "hot_opening_delete_project_rollback_failed"
HOT_OPENING_DELETE_PROJECT_CLEANUP_FAILED = "hot_opening_delete_project_cleanup_failed"
SHOT_SEGMENT_DELETE_FAILED = "shot_segment_delete_failed"
SHOT_SEGMENT_DELETE_ROLLBACK_FAILED = "shot_segment_delete_rollback_failed"
SHOT_SEGMENT_DELETE_CLEANUP_FAILED = "shot_segment_delete_cleanup_failed"
SHOT_TASK_SET_DELETE_FAILED = "shot_task_set_delete_failed"
SHOT_TASK_SET_DELETE_ROLLBACK_FAILED = "shot_task_set_delete_rollback_failed"
SHOT_TASK_SET_DELETE_CLEANUP_FAILED = "shot_task_set_delete_cleanup_failed"
UPLOAD_RESOURCE_PHYSICAL_DELETE_FAILED = "upload_resource_physical_delete_failed"
UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED = "upload_resource_cleanup_batch_failed"
UPLOAD_RESOURCE_BACKFILL_CLI_FAILED = "upload_resource_backfill_cli_failed"
UPLOAD_RESOURCE_BACKFILL_CLI_ROLLBACK_FAILED = "upload_resource_backfill_cli_rollback_failed"
UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
UploadResourceModuleEnum.COMMON.value: "普通上传",
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
}
UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
UploadResourceTypeEnum.IMAGE.value: "图片",
UploadResourceTypeEnum.VIDEO.value: "视频",
UploadResourceTypeEnum.AUDIO.value: "音频",
UploadResourceTypeEnum.SHOT_SEGMENT.value: "拆镜切片",
}
+2 -1
View File
@@ -22,6 +22,7 @@ from app.models.chat_generation_task import ChatGenerationTask
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
from app.models.chat_provider_call_log import ChatProviderCallLog
from app.models.generated_resource import GeneratedResource
from app.models.upload_resource import UploadResource
from app.models.user_resource_month_stat import UserResourceMonthStat
from app.models.user_resource_total_stat import UserResourceTotalStat
from app.models.user_resource_capacity_config import UserResourceCapacityConfig
@@ -44,7 +45,7 @@ __all__ = [
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
"GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat",
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig",
"ModuleGenerationProject", "ModuleGenerationStep",
"ShotReplicateTaskSet", "ShotReplicateSegment",
@@ -0,0 +1,77 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Float, ForeignKey, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class UploadResource(Base, TimestampMixin, SoftDeleteMixin):
"""统一上传资源账本。
记录用户直接上传、模块上传和拆镜切片文件,不建立 ORM relationship
通过 source_model + source_id 进行低耦合绑定。
"""
__tablename__ = "upload_resources"
__table_args__ = (
UniqueConstraint("storage_path", name="uq_upload_resources_storage_path"),
Index("ix_upload_resources_user_module_type", "user_id", "module", "resource_type", "deleted_at"),
Index("ix_upload_resources_user_bind", "user_id", "bind_status", "deleted_at"),
Index("ix_upload_resources_history_lookup", "user_id", "resource_type", "bind_status", "delete_policy", "deleted_at", "created_at"),
Index("ix_upload_resources_source", "source_model", "source_id", "deleted_at"),
Index("ix_upload_resources_url", "resource_url"),
Index("ix_upload_resources_created", "created_at"),
Index("ix_upload_resources_file_delete_status", "file_delete_status"),
Index("ix_upload_resources_physical_deleted", "physical_deleted_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
)
module: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
resource_type: Mapped[str] = mapped_column(String(32), index=True, nullable=False)
resource_url: Mapped[str] = mapped_column(Text, nullable=False)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
file_ext: Mapped[str | None] = mapped_column(String(32), nullable=True)
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
duration_source: Mapped[str | None] = mapped_column(String(32), nullable=True)
width: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
height: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
source_model: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
source_id: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
source_module: Mapped[str | None] = mapped_column(String(64), nullable=True)
bind_status: Mapped[str] = mapped_column(String(32), default="pending", server_default="pending", index=True, nullable=False)
delete_policy: Mapped[str] = mapped_column(String(32), default="user_deletable", server_default="user_deletable", index=True, nullable=False)
created_by: Mapped[str] = mapped_column(String(32), default="api", server_default="api", index=True, nullable=False)
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)
capacity_released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
physical_deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
file_delete_status: Mapped[str] = mapped_column(
String(32),
default="active",
server_default="active",
nullable=False,
)
file_delete_error: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
@@ -23,13 +23,19 @@ class UserResourceMonthStat(Base, TimestampMixin):
active_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
deleted_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
total_generated_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
upload_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
image_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
video_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
audio_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
shot_segment_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
active_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
deleted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
image_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
video_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
upload_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
audio_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
shot_segment_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
last_recalculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -22,13 +22,19 @@ class UserResourceTotalStat(Base, TimestampMixin):
active_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
deleted_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
total_generated_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
upload_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
image_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
video_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
audio_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
shot_segment_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
active_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
deleted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
image_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
video_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
upload_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
audio_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
shot_segment_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
last_recalculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+30 -13
View File
@@ -43,8 +43,11 @@ class GenerationAIReference(BaseModel):
)
source: str | None = Field(
None,
description="参考素材来源。private_portrait_asset=真人素材库;为空表示普通上传文件",
examples=["private_portrait_asset"],
description=(
"参考素材来源枚举:upload_resource=历史上传素材;"
"private_portrait_asset=私域真人/虚拟素材;为空表示本次普通上传素材。"
),
examples=["upload_resource"],
)
private_asset_id: str | None = Field(
None,
@@ -73,9 +76,17 @@ class GenerationAIReference(BaseModel):
)
role: str | None = Field(
None,
description="参考素材角色。image 可选 first_frame/last_frame/reference_imagevideo 可选 reference_videoaudio 可选 reference_audio",
description=(
"参考素材角色枚举:first_frame=首帧图;last_frame=尾帧图;"
"reference_image=普通参考图;reference_video=普通参考视频;reference_audio=普通参考音频。"
),
examples=["first_frame"],
)
upload_resource_id: str | None = Field(
None,
description="历史上传素材 ID。source=upload_resource 时由 /upload-resources/history 返回,便于前端排查和去重;生成链路主要使用 url。",
examples=["0019e0a448a23114888"],
)
class GenerationAITaskCreate(BaseModel):
@@ -147,7 +158,11 @@ class GenerationAITaskCreate(BaseModel):
)
media_references: list[GenerationAIReference] | None = Field(
None,
description="参考素材列表。可以传图片/视频/音频参考素材;为空表示不使用参考素材",
description=(
"参考素材列表。支持 image/video/audio。"
"source=upload_resource 时表示来自历史上传素材;source=private_portrait_asset 时表示来自私域真人/虚拟素材。"
"视频和音频素材必须携带 duration,前端和后端均按 AI 创作原规则校验数量、单段 2~15 秒、总时长不超过 15 秒。"
),
)
idempotency_key: str | None = Field(
None,
@@ -163,12 +178,12 @@ class GenerationAITaskCreate(BaseModel):
# image params
image_size: str | None = Field(
None,
description="图片分辨率档位,例如1K、2K。仅图片生成或视频首帧参数需要使用;为空则使用引擎默认值",
description="图片分辨率档位枚举示例1K、2K、4K。实际可选值以 /generation-ai/engines 返回的 supported_sizes 为准。",
examples=["2K"],
)
image_proportion: str | None = Field(
None,
description="图片比例,例如1:1、16:9、9:16。仅图片生成或视频首帧参数需要使用;为空则使用默认值",
description="图片比例枚举示例1:1、16:9、9:16、4:3、3:4、21:9。实际可选值以 /generation-ai/engines 返回为准。",
examples=["1:1"],
)
image_px: str | None = Field(
@@ -180,17 +195,17 @@ class GenerationAITaskCreate(BaseModel):
# video params
duration: int | None = Field(
None,
description="视频时长,单位秒。仅视频生成使用;为空则使用默认时长",
description="视频生成时长,单位秒。枚举范围通常为 4~15 秒,实际可选值以视频引擎 supported_durations 为准。",
examples=[4],
)
aspect_ratio: str | None = Field(
None,
description="视频比例,例如16:9、9:16、1:1。仅视频生成使用;为空则使用默认比例",
description="视频比例枚举示例16:9、4:3、1:1、3:4、9:16、21:9。实际可选值以视频引擎 supported_ratios 为准。",
examples=["16:9"],
)
resolution: str | None = Field(
None,
description="视频分辨率,例如480p、720p、1080p。仅视频生成使用;为空则使用默认分辨率",
description="视频分辨率枚举示例480p、720p、1080p。实际可选值以视频引擎 supported_resolutions 为准。",
examples=["480p"],
)
@@ -380,8 +395,9 @@ class GenerationAITaskOut(BaseModel):
history_source: str | None = Field(
None,
description=(
"素材云历史来源:chat_task=AI创作,generation_record=项目生成,"
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻"
"素材云生成历史来源:chat_task=AI创作,generation_record=项目生成,"
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻"
"注意:上传素材历史不在本接口返回,请使用 /upload-resources/history。"
),
)
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
@@ -705,8 +721,9 @@ class GenerationAIRecordHistoryItemOut(BaseModel):
history_source: str | None = Field(
None,
description=(
"素材云历史来源:chat_task=AI创作,generation_record=项目生成,"
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻"
"素材云生成历史来源:chat_task=AI创作,generation_record=项目生成,"
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻"
"注意:上传素材历史不在本接口返回,请使用 /upload-resources/history。"
),
)
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
@@ -43,6 +43,9 @@ HOT_OPENING_STEP_IO_EXAMPLES: dict[str, dict[str, Any]] = {
"payload": {
"material_video_url": "https://example.com/source.mp4",
"material_image_url": "https://example.com/product.png",
"material_video_resource_id": "0019...",
"material_image_resource_id": "0019...",
"material_video_duration_seconds": 8.2,
"source_project_name": "参考素材项目名称",
"target_project_name": "新项目名称",
"core_content_point": "50字以内核心内容点",
@@ -183,6 +186,9 @@ class HotOpeningTaskCreate(BaseModel):
"example": {
"material_video_url": "https://example.com/source.mp4",
"material_image_url": "https://example.com/product.png",
"material_video_resource_id": "0019...",
"material_image_resource_id": "0019...",
"material_video_duration_seconds": 8.2,
"source_project_name": "参考素材项目名称",
"target_project_name": "新项目名称",
"core_content_point": "突出产品能帮助用户认识附近新朋友",
@@ -193,6 +199,10 @@ class HotOpeningTaskCreate(BaseModel):
material_video_url: str = Field(..., min_length=1, description="素材视频链接,参考素材,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验")
material_image_url: str = Field(..., min_length=1, description="素材图片链接,新产品图片,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验")
material_video_resource_id: str | None = Field(None, max_length=32, description="可选,模块上传接口返回的视频 UploadResource.id;创建项目后用于绑定资源")
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,模块上传接口返回的图片 UploadResource.id;创建项目后用于绑定资源")
material_video_duration_seconds: float | None = Field(None, gt=0, description="可选,前端识别的视频秒数,用于上传资源元数据兜底")
material_image_duration_seconds: float | None = Field(None, gt=0, description="兼容字段,图片一般为空")
source_project_name: str = Field(..., min_length=1, max_length=20, description="视频素材内容项目名称")
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称")
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成的项目核心内容点,最多50字")
@@ -224,11 +234,14 @@ class HotOpeningMaterialUpdateRequest(BaseModel):
material_video_url: str | None = Field(None, min_length=1, description="素材视频链接,未传则沿用旧值")
material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值")
material_video_resource_id: str | None = Field(None, max_length=32, description="可选,模块上传接口返回的视频 UploadResource.id")
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,模块上传接口返回的图片 UploadResource.id")
material_video_duration_seconds: float | None = Field(None, gt=0, description="可选,前端识别的视频秒数")
source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值")
target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值")
core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值")
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", mode="before")
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", "material_video_resource_id", "material_image_resource_id", mode="before")
@classmethod
def _strip_optional(cls, value: str | None) -> str | None:
if value is None:
@@ -240,7 +253,7 @@ class HotOpeningMaterialUpdateRequest(BaseModel):
@model_validator(mode="after")
def _require_at_least_one(self) -> "HotOpeningMaterialUpdateRequest":
if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")):
if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", "material_video_resource_id", "material_image_resource_id")):
raise ValueError("至少需要传入一个需要修改的字段")
return self
@@ -486,6 +499,9 @@ class HotOpeningDeleteOut(BaseModel):
message: str = Field(..., description="删除结果提示")
project_id: str = Field(..., description="被软删除的总任务项目ID")
deleted: bool = Field(..., description="是否已软删除")
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
class HotOpeningSpecOut(BaseModel):
+271 -162
View File
@@ -6,10 +6,13 @@ from pydantic import BaseModel, Field, field_validator, model_validator
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
PrivatePortraitValidateSessionStatus,
)
from app.schemas.common import NaiveDatetimeOptional
@@ -17,141 +20,246 @@ PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
_LIBRARY_TYPE_DESCRIPTIONS: dict[str, tuple[str, str]] = {
PrivatePortraitLibraryType.REAL_PERSON.value: (
"真人认证素材库",
"真人素材库。创建项目时会生成火山真人认证 H5 链接,用户扫码并认证成功后,项目状态变为 active,才允许上传真人素材。",
),
PrivatePortraitLibraryType.AIGC_VIRTUAL.value: (
"私域虚拟人像素材库",
"虚拟素材库。创建项目时直接同步火山 CreateAssetGroupGroupType=AIGC,不需要真人扫码认证。",
),
}
_PROJECT_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
PrivatePortraitProjectStatus.VALIDATING.value: ("认证中", "真人项目已创建,等待用户扫码完成真人认证。"),
PrivatePortraitProjectStatus.ACTIVE.value: ("可用", "项目可用,允许上传素材,项目内 Active 素材可用于 AI 创作。"),
PrivatePortraitProjectStatus.VALIDATE_FAILED.value: ("认证失败", "真人认证失败或回调结果不通过。"),
PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value: ("远端分组创建中", "正在调用火山创建 Asset Group。"),
PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value: ("远端分组创建失败", "火山 Asset Group 创建失败,需要重试或人工排查。"),
PrivatePortraitProjectStatus.DELETED.value: ("已删除", "本地已软删,不再对普通前端展示。"),
}
_VALIDATE_SESSION_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
PrivatePortraitValidateSessionStatus.CREATED.value: ("已创建", "认证会话已创建,前端可展示 h5_link 二维码。"),
PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value: ("回调成功", "火山回调 resultCode=10000,认证通过,等待或已绑定远端分组。"),
PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value: ("回调失败", "火山回调 resultCode 非成功值,认证未通过。"),
PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value: ("分组可用", "认证成功并已获得可用 remote_group_id,项目可上传素材。"),
PrivatePortraitValidateSessionStatus.EXPIRED.value: ("已过期", "认证会话超过有效期,需要重新创建认证会话。"),
PrivatePortraitValidateSessionStatus.FAILED.value: ("失败", "认证会话创建、查询或回调处理失败。"),
}
_ASSET_TYPE_DESCRIPTIONS: dict[str, tuple[str, str]] = {
PrivatePortraitAssetType.IMAGE.value: ("图片", "当前开放。用于真人/虚拟图片参考素材。"),
PrivatePortraitAssetType.VIDEO.value: (
"视频",
f"当前开放。必须传 video_duration,支持 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}~{PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒。",
),
PrivatePortraitAssetType.AUDIO.value: ("音频", "火山支持但当前业务暂不开放,接口会拒绝 Audio。"),
}
_ASSET_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
PrivatePortraitAssetStatus.CREATING.value: ("创建中", "本地记录已创建,正在调用火山 CreateAsset。"),
PrivatePortraitAssetStatus.PROCESSING.value: ("处理中", "火山侧正在处理素材,暂不可用于 AI 创作。"),
PrivatePortraitAssetStatus.ACTIVE.value: ("可用", "素材已处理完成,可在 AI 创作中作为参考素材选择。"),
PrivatePortraitAssetStatus.FAILED.value: ("失败", "火山侧处理失败或本地同步失败。"),
PrivatePortraitAssetStatus.LOCAL_DELETED.value: ("本地已删除", "本地已软删,等待远端删除或无需远端删除。"),
PrivatePortraitAssetStatus.REMOTE_DELETED.value: ("远端已删除", "火山侧素材删除成功。"),
PrivatePortraitAssetStatus.DELETE_FAILED.value: ("删除失败", "火山侧删除失败,后续可通过补偿任务重试。"),
}
_REMOTE_DELETE_STATUS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
PrivatePortraitRemoteDeleteStatus.NONE.value: ("无需删除", "未触发远端删除,通常表示资源仍正常。"),
PrivatePortraitRemoteDeleteStatus.PENDING.value: ("等待删除", "本地已软删并提交成功,远端删除任务等待执行或执行中。"),
PrivatePortraitRemoteDeleteStatus.SUCCESS.value: ("删除成功", "火山侧远端资源已删除。"),
PrivatePortraitRemoteDeleteStatus.FAILED.value: ("删除失败", "火山侧远端删除失败,已记录错误,后续可补偿重试。"),
PrivatePortraitRemoteDeleteStatus.SKIPPED.value: ("跳过删除", "资源没有远端 ID 或已不需要调用远端删除。"),
}
def _enum_items(enum_values: list[str], mapping: dict[str, tuple[str, str]]) -> list["PrivatePortraitEnumItem"]:
return [
PrivatePortraitEnumItem(
value=value,
label=mapping.get(value, (value, ""))[0],
description=mapping.get(value, (value, None))[1],
)
for value in enum_values
]
class PrivatePortraitEnumItem(BaseModel):
value: str
label: str
description: str | None = None
value: str = Field(..., description="枚举值,接口入参和出参均使用该值。")
label: str = Field(..., description="枚举中文名称,用于前端展示。")
description: str | None = Field(None, description="枚举说明,用于 OpenAPI 文档、前端提示或排查。")
class PrivatePortraitEnumMetaOut(BaseModel):
library_types: list[PrivatePortraitEnumItem]
asset_types: list[PrivatePortraitEnumItem]
project_statuses: list[PrivatePortraitEnumItem]
asset_statuses: list[PrivatePortraitEnumItem]
library_types: list[PrivatePortraitEnumItem] = Field(..., description="素材库类型枚举:real_person=真人认证素材库,aigc_virtual=私域虚拟人像素材库。")
asset_types: list[PrivatePortraitEnumItem] = Field(..., description="素材类型枚举:Image=图片,Video=视频,Audio=音频;当前业务仅开放 Image/Video。")
project_statuses: list[PrivatePortraitEnumItem] = Field(..., description="项目状态枚举,用于项目列表、项目详情、筛选和状态展示。")
asset_statuses: list[PrivatePortraitEnumItem] = Field(..., description="素材状态枚举。只有 Active 素材可被 selectable-assets 返回并用于 AI 创作。")
validate_session_statuses: list[PrivatePortraitEnumItem] = Field(..., description="真人认证会话状态枚举,用于 PC 端轮询扫码认证结果。")
remote_delete_statuses: list[PrivatePortraitEnumItem] = Field(..., description="远端删除状态枚举。本地删除先 commit,远端删除异步执行。")
class PrivatePortraitConfigOut(BaseModel):
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
asset_limit: int = Field(..., description="私域人像素材总量限制真人/虚拟共用,图片/视频共用0 表示关闭。")
used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video")
remaining_asset_count: int = Field(..., description="剩余可上传素材数量。")
supported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value], description="当前业务开放的素材类型。")
unsupported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value], description="火山支持但当前业务暂不开放的素材类型。")
# 兼容旧前端,后续确认无引用后可移除。
image_limit: int | None = Field(None, description="兼容旧字段:请改用 asset_limit。")
used_image_count: int | None = Field(None, description="兼容旧字段:请改用 used_asset_count。")
remaining_image_count: int | None = Field(None, description="兼容旧字段:请改用 remaining_asset_count。")
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用asset_limit = 0 表示关闭")
asset_limit: int = Field(..., description="私域素材总量限制真人/虚拟共用,图片/视频共用0 表示关闭私域素材模块")
used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计真人+虚拟、Image+Video,状态包含 creating/Processing/Active。")
remaining_asset_count: int = Field(..., description="剩余可上传素材数量。计算方式:max(asset_limit - used_asset_count, 0)。")
supported_asset_types: list[str] = Field(
default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value],
description="当前业务开放的素材类型枚举值。固定为 Image / Video。",
)
unsupported_asset_types: list[str] = Field(
default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value],
description="火山支持但当前业务暂不开放的素材类型。当前为 Audio。",
)
image_limit: int | None = Field(None, description="兼容旧前端字段:旧图片额度字段。新前端请改用 asset_limit。")
used_image_count: int | None = Field(None, description="兼容旧前端字段:旧图片已用数量。新前端请改用 used_asset_count。")
remaining_image_count: int | None = Field(None, description="兼容旧前端字段:旧图片剩余额度。新前端请改用 remaining_asset_count。")
class PrivatePortraitAdminConfigUpdate(BaseModel):
private_portrait_asset_limit: int = Field(..., ge=0, le=9999, description="私域人像素材总量限制。0 表示关闭;>0 表示启用并限制真人/虚拟、图片/视频素材总量。")
private_portrait_asset_limit: int = Field(
...,
ge=0,
le=9999,
description="用户私域素材总量限制。0 表示关闭;>0 表示启用并限制真人/虚拟、图片/视频素材总量。",
)
class PrivatePortraitProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128, description="项目组名称")
description: str | None = Field(None, max_length=2000, description="项目组描述")
callback_redirect_url: str | None = Field(None, description="仅真人认证使用:认证完成后的手机端提示页地址。")
name: str = Field(..., min_length=1, max_length=128, description="真人认证素材项目组名称。前端展示用,不直接作为火山 ProjectName")
description: str | None = Field(None, max_length=2000, description="真人认证素材项目组描述,前端展示和管理备注用")
callback_redirect_url: str | None = Field(
None,
description="真人认证完成后的手机端跳转地址。为空时使用后端默认回调页;PC 端仍应通过 validate_session 轮询最终状态。",
)
class PrivatePortraitVirtualProjectCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128, description="虚拟人像素材项目组名称。创建后会同步创建火山 Asset Group。")
name: str = Field(
...,
min_length=1,
max_length=128,
description="虚拟人像素材项目组名称。创建后会同步火山 CreateAssetGroupGroupType=AIGC。",
)
description: str | None = Field(None, max_length=2000, description="虚拟人像素材项目组描述,会同步到火山 Asset Group。")
class PrivatePortraitProjectUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。")
description: str | None = Field(None, max_length=2000, description="项目组描述。")
status: str | None = Field(None, description="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。仅修改本地展示名称和必要远端分组名称。")
description: str | None = Field(None, max_length=2000, description="项目组描述。用于前端展示和管理备注。")
status: str | None = Field(
None,
description="项目状态。可选值:validating、active、validate_failed、creating_remote_group、create_group_failed、deleted。普通前端不建议手动变更。",
)
class PrivatePortraitProjectOut(BaseModel):
id: str
user_id: str | None = None
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
name: str
name_slug: str | None = None
remote_project_name: str | None = None
description: str | None = None
status: str
asset_group_count: int = 0
asset_count: int = 0
image_asset_count: int = 0
video_asset_count: int = 0
active_asset_count: int = 0
active_image_asset_count: int = 0
active_video_asset_count: int = 0
last_used_at: NaiveDatetimeOptional = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
id: str = Field(..., description="本地私域项目 ID。后续查询、更新、删除、上传素材均使用该 ID。")
user_id: str | None = Field(None, description="项目所属用户 ID。普通用户接口一般返回当前用户 ID;管理接口用于定位用户。")
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value, description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。")
name: str = Field(..., description="项目组名称,前端展示用。")
name_slug: str | None = Field(None, description="项目名称归一化值,兼容旧逻辑或排查使用。")
remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}")
description: str | None = Field(None, description="项目组描述。")
status: str = Field(..., description="项目状态。active 表示可上传素材;真人项目 validating 表示等待扫码认证。")
asset_group_count: int = Field(0, description="项目下本地素材组数量。真人一般认证成功后为 1,虚拟创建项目后为 1。")
asset_count: int = Field(0, description="项目下素材总数,包含图片和视频。")
image_asset_count: int = Field(0, description="项目下图片素材数量。")
video_asset_count: int = Field(0, description="项目下视频素材数量。")
active_asset_count: int = Field(0, description="项目下 Active 可用素材总数。")
active_image_asset_count: int = Field(0, description="项目下 Active 可用图片素材数量。")
active_video_asset_count: int = Field(0, description="项目下 Active 可用视频素材数量。")
last_used_at: NaiveDatetimeOptional = Field(None, description="最近被 AI 创作引用的时间。为空表示尚未使用。")
created_at: NaiveDatetimeOptional = Field(None, description="本地项目创建时间。")
updated_at: NaiveDatetimeOptional = Field(None, description="本地项目最后更新时间。")
model_config = {"from_attributes": True}
class PrivatePortraitProjectListOut(BaseModel):
items: list[PrivatePortraitProjectOut]
total: int
page: int
page_size: int
items: list[PrivatePortraitProjectOut] = Field(..., description="项目列表。真人接口只返回 real_person,虚拟接口只返回 aigc_virtual。")
total: int = Field(..., description="符合筛选条件的项目总数。")
page: int = Field(..., description="当前页码,从 1 开始。")
page_size: int = Field(..., description="每页数量。")
class PrivatePortraitValidateSessionCreate(BaseModel):
callback_redirect_url: str | None = Field(None, description="认证完成后前端要跳转的页面。为空时使用后端默认回调页。")
callback_redirect_url: str | None = Field(
None,
description="真人认证完成后手机端要跳转的页面。为空时使用后端默认回调页;PC 端应继续轮询 validate-session。",
)
class PrivatePortraitValidateSessionOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
byted_token: str | None = None
h5_link: str | None = None
callback_url: str | None = None
result_code: str | None = None
algorithm_base_resp_code: str | None = None
verify_type: str | None = None
status: str
remote_group_id: str | None = None
remote_project_name: str | None = None
expired_at: NaiveDatetimeOptional = None
error_message: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
id: str = Field(..., description="本地认证会话 ID。PC 端轮询认证状态时使用。")
user_id: str | None = Field(None, description="认证会话所属用户 ID。")
project_id: str = Field(..., description="认证会话绑定的本地真人项目 ID。")
byted_token: str | None = Field(None, description="火山认证 token,排查用。前端一般不直接展示。")
h5_link: str | None = Field(None, description="火山真人认证 H5 链接。前端应转二维码供手机扫码。")
callback_url: str | None = Field(None, description="后端传给火山的回调地址。认证完成后火山会请求该地址。")
result_code: str | None = Field(None, description="火山回调 resultCode。10000 表示成功,其他值表示失败或未通过。")
algorithm_base_resp_code: str | None = Field(None, description="火山算法返回码,排查认证失败原因用。")
verify_type: str | None = Field(None, description="火山认证类型。当前真人认证使用 real_time。")
status: str = Field(..., description="认证会话状态:created、callback_success、callback_failed、group_active、expired、failed。")
remote_group_id: str | None = Field(None, description="认证成功后火山返回的 Asset Group ID。后续上传真人素材会绑定该分组。")
remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}")
expired_at: NaiveDatetimeOptional = Field(None, description="认证会话过期时间。过期后需要重新创建认证会话。")
error_message: str | None = Field(None, description="认证失败、回调失败或查询失败的错误信息。")
created_at: NaiveDatetimeOptional = Field(None, description="认证会话创建时间。")
updated_at: NaiveDatetimeOptional = Field(None, description="认证会话最后更新时间。")
model_config = {"from_attributes": True}
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
project: PrivatePortraitProjectOut
validate_session: PrivatePortraitValidateSessionOut
project: PrivatePortraitProjectOut = Field(..., description="创建成功的真人项目数据。初始状态通常为 validating。")
validate_session: PrivatePortraitValidateSessionOut = Field(..., description="随项目创建一起生成的真人认证会话。前端用 h5_link 展示二维码。")
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒。")
class PrivatePortraitAssetGroupOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
library_type: str
remote_group_id: str
remote_group_name: str | None = None
remote_project_name: str
group_type: str
status: str
remote_delete_status: str
remote_deleted_at: NaiveDatetimeOptional = None
remote_delete_error: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
id: str = Field(..., description="本地素材组 ID。")
user_id: str | None = Field(None, description="素材组所属用户 ID。")
project_id: str = Field(..., description="素材组所属本地项目 ID。")
library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。")
remote_group_id: str = Field(..., description="火山 Asset Group ID。")
remote_group_name: str | None = Field(None, description="火山 Asset Group 名称。")
remote_project_name: str = Field(..., description=f"火山侧 ProjectName。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}")
group_type: str = Field(..., description="火山分组类型。真人为 LivenessFace,虚拟为 AIGC。")
status: str = Field(..., description="素材组本地状态:creating、active、local_deleted、remote_deleted、delete_failed、failed。")
remote_delete_status: str = Field(..., description="远端删除状态:none、pending、success、failed、skipped。")
remote_deleted_at: NaiveDatetimeOptional = Field(None, description="远端删除成功时间。")
remote_delete_error: str | None = Field(None, description="远端删除失败错误信息。")
created_at: NaiveDatetimeOptional = Field(None, description="素材组创建时间。")
updated_at: NaiveDatetimeOptional = Field(None, description="素材组最后更新时间。")
model_config = {"from_attributes": True}
class PrivatePortraitAssetCreate(BaseModel):
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / VideoAudio 暂不开放。")
name: str | None = Field(None, max_length=256, description="素材名称,仅用于检索和管理。")
video_duration: float | None = Field(None, ge=0, description="视频素材时长,单位秒。图片可为空")
video_cover_url: str | None = Field(None, description="视频封面预览地址。图片可为空。")
file_size: int | None = Field(None, ge=0, description="文件大小,字节。")
mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型。")
url: str = Field(
...,
min_length=1,
description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset",
)
asset_type: str = Field(
default=PrivatePortraitAssetType.IMAGE.value,
description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / VideoAudio 会被拒绝。",
)
name: str | None = Field(None, max_length=256, description="素材名称。用于前端展示、检索和管理备注;不影响远端素材处理。")
video_duration: float | None = Field(
None,
ge=0,
description=f"视频素材时长,单位秒。asset_type=Video 时必填,范围 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}~{PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒;图片可为空。",
)
video_cover_url: str | None = Field(None, description="视频封面预览地址。asset_type=Video 时建议传入;图片可为空。")
file_size: int | None = Field(None, ge=0, description="文件大小,单位字节。用于前端展示和排查。")
mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型,例如 image/png、video/mp4。用于前端展示和排查。")
@field_validator("asset_type")
@classmethod
@@ -177,100 +285,101 @@ class PrivatePortraitAssetCreate(BaseModel):
class PrivatePortraitAssetOut(BaseModel):
id: str
user_id: str | None = None
project_id: str
project_name: str | None = None
group_id: str
library_type: str
remote_group_id: str
remote_asset_id: str | None = None
remote_project_name: str | None = None
asset_type: str
name: str | None = None
source_url: str
preview_url: str | None = None
display_url: str | None = None
provider_url: str | None = None
remote_url: str | None = None
remote_url_expired_at: NaiveDatetimeOptional = None
video_duration: float | None = None
video_cover_url: str | None = None
file_size: int | None = None
mime_type: str | None = None
status: str
moderation: Any = None
last_poll_at: NaiveDatetimeOptional = None
next_poll_at: NaiveDatetimeOptional = None
poll_count: int = 0
remote_delete_status: str
remote_deleted_at: NaiveDatetimeOptional = None
remote_delete_error: str | None = None
error_message: str | None = None
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
id: str = Field(..., description="本地素材 ID。删除、同步、详情查询均使用该 ID。")
user_id: str | None = Field(None, description="素材所属用户 ID。")
project_id: str = Field(..., description="素材所属项目 ID。")
project_name: str | None = Field(None, description="素材所属项目名称。列表接口会尽量回填,详情接口会单独查询回填。")
group_id: str = Field(..., description="本地素材组 ID。")
library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。")
remote_group_id: str = Field(..., description="火山 Asset Group ID。")
remote_asset_id: str | None = Field(None, description="火山 Asset ID。创建成功后回填。")
remote_project_name: str | None = Field(None, description=f"火山侧 ProjectName 快照。当前固定为 {PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME}")
asset_type: str = Field(..., description="素材类型:Image=图片,Video=视频。Audio 当前不会返回给普通业务。")
name: str | None = Field(None, description="素材名称。")
source_url: str = Field(..., description="本地原始素材 URL。用于回显和排查,生成时通常使用 display_url/provider_url。")
preview_url: str | None = Field(None, description="前端预览 URL。图片/视频都优先使用该字段展示缩略或预览。")
display_url: str | None = Field(None, description="前端可访问展示 URL。用于素材列表、素材云或 AI 创作选择器展示。")
provider_url: str | None = Field(None, description="提供给生成服务使用的 URL。可能是本地签名 URL 或远端可访问 URL。")
remote_url: str | None = Field(None, description="火山侧返回的素材 URL。可能有过期时间,仅排查或兜底使用。")
remote_url_expired_at: NaiveDatetimeOptional = Field(None, description="remote_url 过期时间。过期后应重新同步素材状态或使用 display_url/provider_url。")
video_duration: float | None = Field(None, description="视频素材时长,单位秒。图片为空。")
video_cover_url: str | None = Field(None, description="视频封面 URL。图片为空。")
file_size: int | None = Field(None, description="素材文件大小,单位字节。")
mime_type: str | None = Field(None, description="素材 MIME 类型。")
status: str = Field(..., description="素材状态:creating、Processing、Active、Failed、local_deleted、remote_deleted、delete_failed。只有 Active 可用于 AI 创作。")
moderation: Any = Field(None, description="火山审核或处理返回的原始补充信息。结构可能随火山返回变化,前端一般不依赖该字段。")
last_poll_at: NaiveDatetimeOptional = Field(None, description="最近一次同步/轮询火山素材状态的时间。")
next_poll_at: NaiveDatetimeOptional = Field(None, description="下一次计划同步/轮询火山素材状态的时间。")
poll_count: int = Field(0, description="素材状态轮询次数。用于排查处理超时。")
remote_delete_status: str = Field(..., description="远端删除状态:none、pending、success、failed、skipped。")
remote_deleted_at: NaiveDatetimeOptional = Field(None, description="远端素材删除成功时间。")
remote_delete_error: str | None = Field(None, description="远端素材删除失败错误信息。")
error_message: str | None = Field(None, description="创建、同步、处理或删除过程中的错误信息。")
created_at: NaiveDatetimeOptional = Field(None, description="素材创建时间。")
updated_at: NaiveDatetimeOptional = Field(None, description="素材最后更新时间。")
model_config = {"from_attributes": True}
class PrivatePortraitAssetListOut(BaseModel):
items: list[PrivatePortraitAssetOut]
total: int
page: int
page_size: int
items: list[PrivatePortraitAssetOut] = Field(..., description="素材列表。包含图片/视频,是否可用以 status=Active 为准。")
total: int = Field(..., description="符合筛选条件的素材总数。")
page: int = Field(..., description="当前页码,从 1 开始。")
page_size: int = Field(..., description="每页数量。")
class PrivatePortraitSelectableAssetOut(BaseModel):
id: str
project_id: str
project_name: str
library_type: str
name: str | None = None
asset_type: str
preview_url: str | None = None
display_url: str | None = None
provider_url: str | None = None
video_duration: float | None = None
video_cover_url: str | None = None
status: str = PrivatePortraitAssetStatus.ACTIVE.value
created_at: NaiveDatetimeOptional = None
id: str = Field(..., description="本地素材 ID。AI 创作选择后可作为 private_asset_id / private_portrait_asset 引用。")
project_id: str = Field(..., description="素材所属项目 ID。")
project_name: str = Field(..., description="素材所属项目名称。")
library_type: str = Field(..., description="素材库类型:real_person=真人认证素材库,aigc_virtual=私域虚拟素材库。")
name: str | None = Field(None, description="素材名称。")
asset_type: str = Field(..., description="素材类型:Image=图片,Video=视频。")
preview_url: str | None = Field(None, description="前端选择器预览 URL。")
display_url: str | None = Field(None, description="前端展示 URL。")
provider_url: str | None = Field(None, description="传给 AI 创作生成接口的可访问 URL。")
video_duration: float | None = Field(None, description="视频素材时长,单位秒。图片为空。AI 创作会用它做视频总时长校验。")
video_cover_url: str | None = Field(None, description="视频封面 URL。图片为空。")
status: str = Field(default=PrivatePortraitAssetStatus.ACTIVE.value, description="固定返回 Active。该接口只返回可用于 AI 创作的素材。")
created_at: NaiveDatetimeOptional = Field(None, description="素材创建时间。")
class PrivatePortraitSelectableAssetListOut(BaseModel):
items: list[PrivatePortraitSelectableAssetOut]
total: int
page: int
page_size: int
items: list[PrivatePortraitSelectableAssetOut] = Field(..., description="可用于 AI 创作选择器的素材列表。只包含 Active 的 Image/Video。")
total: int = Field(..., description="符合筛选条件的可选素材总数。")
page: int = Field(..., description="当前页码,从 1 开始。")
page_size: int = Field(..., description="每页数量。")
class PrivatePortraitDeleteOut(BaseModel):
success: bool = True
remote_delete_status: str
success: bool = Field(True, description="本地删除是否成功。接口返回时表示本地软删已提交成功。")
remote_delete_status: str = Field(..., description="远端删除状态。通常返回 pending,表示远端删除任务已投递或等待补偿。")
class PrivatePortraitAdminStatsOut(BaseModel):
total_projects: int = 0
total_assets: int = 0
image_assets: int = 0
video_assets: int = 0
active_assets: int = 0
processing_assets: int = 0
failed_assets: int = 0
real_person_assets: int = 0
virtual_assets: int = 0
total_projects: int = Field(0, description="项目总数,包含真人和虚拟项目。")
total_assets: int = Field(0, description="素材总数,包含真人/虚拟、图片/视频。")
image_assets: int = Field(0, description="图片素材数量。")
video_assets: int = Field(0, description="视频素材数量。")
active_assets: int = Field(0, description="Active 可用素材数量。")
processing_assets: int = Field(0, description="Processing 处理中素材数量。")
failed_assets: int = Field(0, description="Failed 失败素材数量。")
real_person_assets: int = Field(0, description="真人素材数量,library_type=real_person。")
virtual_assets: int = Field(0, description="虚拟素材数量,library_type=aigc_virtual。")
def build_private_portrait_enum_meta() -> PrivatePortraitEnumMetaOut:
return PrivatePortraitEnumMetaOut(
library_types=[
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.REAL_PERSON.value, label="真人认证素材库", description="需要用户扫码完成真人授权认证后才能上传素材。"),
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, label="私域虚拟人像素材库", description="通过火山 CreateAssetGroup/CreateAsset 入库的虚拟人像素材。"),
],
asset_types=[
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.IMAGE.value, label="图片", description="当前开放。"),
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.VIDEO.value, label="视频", description="当前开放,处理时间通常比图片更长。"),
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.AUDIO.value, label="音频", description="火山支持但当前业务暂不开放。"),
],
project_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitProjectStatus],
asset_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitAssetStatus],
library_types=_enum_items(
[PrivatePortraitLibraryType.REAL_PERSON.value, PrivatePortraitLibraryType.AIGC_VIRTUAL.value],
_LIBRARY_TYPE_DESCRIPTIONS,
),
asset_types=_enum_items(
[PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value, PrivatePortraitAssetType.AUDIO.value],
_ASSET_TYPE_DESCRIPTIONS,
),
project_statuses=_enum_items([item.value for item in PrivatePortraitProjectStatus], _PROJECT_STATUS_DESCRIPTIONS),
asset_statuses=_enum_items([item.value for item in PrivatePortraitAssetStatus], _ASSET_STATUS_DESCRIPTIONS),
validate_session_statuses=_enum_items([item.value for item in PrivatePortraitValidateSessionStatus], _VALIDATE_SESSION_STATUS_DESCRIPTIONS),
remote_delete_statuses=_enum_items([item.value for item in PrivatePortraitRemoteDeleteStatus], _REMOTE_DELETE_STATUS_DESCRIPTIONS),
)
+25 -5
View File
@@ -227,6 +227,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel):
)
material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值。用于新产品/目标素材图片,来自已有上传接口")
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的图片 UploadResource.id")
source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值")
target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值")
core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值")
@@ -243,7 +244,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel):
@model_validator(mode="after")
def _require_at_least_one(self) -> "ShotReplicateMaterialUpdateRequest":
if not any(getattr(self, field) is not None for field in ("material_image_url", "source_project_name", "target_project_name", "core_content_point")):
if not any(getattr(self, field) is not None for field in ("material_image_url", "source_project_name", "target_project_name", "core_content_point", "material_image_resource_id")):
raise ValueError("至少需要传入一个需要修改的字段")
return self
@@ -482,9 +483,11 @@ class ShotReplicateActionOut(BaseModel):
class ShotReplicateDeleteOut(BaseModel):
message: str = Field(..., description="删除结果提示")
project_id: str = Field(..., description="被软删除的总任务项目ID")
project_id: str = Field(..., description="被软删除的内部拆镜复刻 ModuleGenerationProject ID")
deleted: bool = Field(..., description="是否已软删除")
released_size_bytes: int = Field(0, description="本次软删释放的用户容量占用字节数;不删除物理文件")
released_size_bytes: int = Field(0, description="本次软删释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
class ShotSegmentDeleteOut(BaseModel):
@@ -493,7 +496,20 @@ class ShotSegmentDeleteOut(BaseModel):
task_set_id: str = Field(..., description="所属拆镜总任务集ID")
deleted: bool = Field(..., description="是否已软删除")
deleted_module_project_id: str | None = Field(None, description="联动软删除的拆镜复刻项目ID;没有关联项目时为空")
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;只释放数据账本,不删除物理文件")
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
class ShotTaskSetDeleteOut(BaseModel):
message: str = Field(..., description="删除结果提示")
task_set_id: str = Field(..., description="被软删除的拆镜任务集ID")
deleted: bool = Field(..., description="是否已软删除")
deleted_segment_count: int = Field(0, description="本次软删除的片段数量")
deleted_module_project_count: int = Field(0, description="本次联动软删除的内部复刻项目数量")
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
@@ -568,6 +584,7 @@ class ShotTaskSetCreate(BaseModel):
"example": {
"video_url": "/uploads/2026/06/11/demo.mp4",
"video_duration_seconds": 31.42,
"video_resource_id": "0019...",
"title": "游戏视频拆镜",
"idempotency_key": "frontend-shot-task-001",
}
@@ -576,6 +593,7 @@ class ShotTaskSetCreate(BaseModel):
video_url: str = Field(..., min_length=1, description="已有上传接口返回的视频链接。必须能反解到 storage/uploads 下文件")
video_duration_seconds: float = Field(..., gt=0, description="前端获取的视频时长秒数,允许浮点;后端会用 ffprobe 校验并以后端真实时长为准")
video_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的视频 UploadResource.id;创建任务集后用于绑定资源")
title: str | None = Field(None, max_length=160, description="拆镜总任务标题")
idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键")
@@ -742,6 +760,7 @@ class ShotSegmentReplicationCreateRequest(BaseModel):
"target_project_name": "新产品推广视频",
"core_content_point": "突出产品附近交友和快速脱单",
"material_image_url": "/uploads/2026/06/11/product.png",
"material_image_resource_id": "0019...",
"idempotency_key": "optional-key",
}
}
@@ -750,9 +769,10 @@ class ShotSegmentReplicationCreateRequest(BaseModel):
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称,最多20字;会写入 module_generation_projects.title 和第1步 material_input")
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字;用于后续图片 AI 提词和视频 AI 提词")
material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口;作为图片生成参考素材")
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的图片 UploadResource.id;创建复刻项目后用于绑定资源")
idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键;为空时后端可按业务生成或不使用")
@field_validator("target_project_name", "core_content_point", "material_image_url", "idempotency_key", mode="before")
@field_validator("target_project_name", "core_content_point", "material_image_url", "material_image_resource_id", "idempotency_key", mode="before")
@classmethod
def _strip_text(cls, value: str | None) -> str | None:
if value is None:
@@ -0,0 +1,185 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.enums.upload_resource import (
UploadResourceBindStatusEnum,
UploadResourceDeletePolicyEnum,
UploadResourceModuleEnum,
UploadResourceTypeEnum,
)
from app.schemas.common import NaiveDatetimeOptional
class UploadResourceMediaReferenceOut(BaseModel):
"""AI 创作可直接复用的上传素材参考结构。"""
name: str | None = Field(None, description="参考素材名称,前端展示用")
type: Literal["image", "video", "audio"] = Field(..., description="参考素材类型:image=图片,video=视频,audio=音频")
url: str = Field(..., description="参考素材 URL,创建 AI 创作任务时传入 media_references[].url")
label: str | None = Field("", description="前端展示标签,默认空,由 AI 创作页重新计算")
duration: float | None = Field(None, ge=0, description="素材时长秒数,type=video/audio 时必须有有效值")
source: Literal["upload_resource"] = Field("upload_resource", description="参考素材来源固定为 upload_resource")
upload_resource_id: str = Field(..., description="UploadResource 本地资源账本 ID")
class UploadResourceHistoryItemOut(BaseModel):
"""上传资源历史素材项。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": "0019e0a448a23114888",
"source_type": "upload_resource",
"history_source": "upload_resource",
"history_source_label": "历史上传素材",
"module": "common",
"module_label": "普通上传",
"resource_type": "video",
"resource_type_label": "视频",
"resource_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
"display_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
"preview_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
"video_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
"duration_seconds": 5.2,
"bind_status": "pending",
"delete_policy": "user_deletable",
"deletable": True,
"media_reference": {
"name": "video_ref_xxx.mp4",
"type": "video",
"url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
"duration": 5.2,
"source": "upload_resource",
"upload_resource_id": "0019e0a448a23114888",
},
"created_at": "2026-07-08T13:20:00",
}
}
)
id: str = Field(..., description="UploadResource.id")
source_type: Literal["upload_resource"] = Field("upload_resource", description="前端判定来源固定值")
history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource")
history_source_label: str = Field("历史上传素材", description="素材云历史来源中文名称")
module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻")
module_label: str = Field(..., description="上传资源所属模块中文名称")
resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频")
resource_type_label: str = Field(..., description="资源类型中文名称")
resource_url: str = Field(..., description="原始资源 URL,可传给 AI 创作 media_references[].url")
display_url: str = Field(..., description="前端展示 URL。当前普通上传资源通常与 resource_url 一致")
preview_url: str = Field(..., description="前端预览 URL。当前普通上传资源通常与 display_url 一致")
image_url: str | None = Field(None, description="图片资源 URLresource_type=image 时有值")
video_url: str | None = Field(None, description="视频资源 URLresource_type=video 时有值")
audio_url: str | None = Field(None, description="音频资源 URLresource_type=audio 时有值")
file_name: str | None = Field(None, description="文件名")
file_ext: str | None = Field(None, description="文件扩展名")
mime_type: str | None = Field(None, description="MIME 类型")
file_size_bytes: int = Field(0, description="文件大小,单位字节")
duration_seconds: float | None = Field(None, description="视频/音频时长秒数")
width: int | None = Field(None, description="图片或视频宽度,未识别时为空")
height: int | None = Field(None, description="图片或视频高度,未识别时为空")
bind_status: str = Field(..., description="绑定状态。历史素材接口只返回 pending=未绑定")
delete_policy: str = Field(..., description="删除策略。历史素材接口只返回 user_deletable=用户可删除")
deletable: bool = Field(True, description="是否允许当前用户删除。历史素材接口只返回 true")
media_reference: UploadResourceMediaReferenceOut = Field(..., description="AI 创作可直接复用的参考素材对象")
created_at: NaiveDatetimeOptional = Field(None, description="上传时间")
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
class UploadResourceHistoryDayGroupOut(BaseModel):
"""上传资源历史按天分组响应项。"""
generated_date: str = Field(..., description="上传日期,格式 YYYY-MM-DD。字段名沿用生成历史,方便前端复用分组逻辑")
total: int = Field(..., description="当前日期下可展示/可复用/可删除的上传素材总数")
page: int = Field(1, description="当前日期下 items 的页码,分组接口固定为 1")
items: list[UploadResourceHistoryItemOut] = Field(default_factory=list, description="当前日期倒序前若干条上传素材")
class UploadResourceHistoryGroupedOut(BaseModel):
"""上传资源历史日期分组列表响应体。"""
total_days: int = Field(..., description="符合条件的上传素材日期分组总数")
page: int = Field(..., description="日期分组页码,从 1 开始")
page_size: int = Field(..., description="日期分组每页数量,最大 10")
groups: list[UploadResourceHistoryDayGroupOut] = Field(default_factory=list, description="上传日期倒序分组列表")
class UploadResourceHistoryDayItemsOut(BaseModel):
"""指定日期下上传资源历史分页响应体。"""
generated_date: str = Field(..., description="当前查询上传日期,格式 YYYY-MM-DD")
total: int = Field(..., description="当前日期下符合条件的上传素材总数")
page: int = Field(..., description="当前日期下分页页码,从 1 开始")
page_size: int = Field(..., description="当前日期下每页返回数量,最大 100")
items: list[UploadResourceHistoryItemOut] = Field(default_factory=list, description="当前日期下上传素材列表,按上传时间倒序排列")
class UploadResourceHistoryBatchDeleteRequest(BaseModel):
"""批量删除上传历史素材请求体。"""
resource_ids: list[str] = Field(
...,
min_length=1,
max_length=30,
description="UploadResource.id 列表。一次最多 30 条,不允许重复",
examples=[["0019e0a448a23114888", "0019e0a448a23114889"]],
)
@field_validator("resource_ids")
@classmethod
def validate_resource_ids(cls, values: list[str]) -> list[str]:
cleaned = [str(value).strip() for value in values if str(value or "").strip()]
if not cleaned:
raise ValueError("resource_ids 不能为空")
if len(cleaned) > 30:
raise ValueError("单次最多删除 30 条上传素材")
if len(cleaned) != len(set(cleaned)):
raise ValueError("resource_ids 不允许重复")
return cleaned
class UploadResourceCleanupOut(BaseModel):
"""真实文件清理结果。"""
matched: int = Field(0, description="匹配到待清理资源数量")
deleted: int = Field(0, description="真实文件删除成功数量")
missing: int = Field(0, description="真实文件已不存在数量")
failed: int = Field(0, description="真实文件删除失败数量")
legacy_deleted: int = Field(0, description="兼容历史路径删除成功数量")
legacy_missing: int = Field(0, description="兼容历史路径不存在数量")
legacy_failed: int = Field(0, description="兼容历史路径删除失败数量")
class UploadResourceHistoryBatchDeleteOut(BaseModel):
"""批量删除上传历史素材响应体。"""
message: str = Field("删除成功", description="操作结果消息")
requested_count: int = Field(..., description="请求删除数量")
deleted_count: int = Field(..., description="成功软删数量")
requested_ids: list[str] = Field(default_factory=list, description="请求删除的 UploadResource.id 列表")
deleted_ids: list[str] = Field(default_factory=list, description="已软删的 UploadResource.id 列表")
released_size_bytes: int = Field(0, description="已释放的上传容量字节数")
cleanup: UploadResourceCleanupOut = Field(default_factory=UploadResourceCleanupOut, description="commit 成功后的真实文件清理结果")
UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES = {
UploadResourceTypeEnum.IMAGE.value,
UploadResourceTypeEnum.VIDEO.value,
UploadResourceTypeEnum.AUDIO.value,
}
UPLOAD_RESOURCE_HISTORY_ALLOWED_BIND_STATUS = UploadResourceBindStatusEnum.PENDING.value
UPLOAD_RESOURCE_HISTORY_ALLOWED_DELETE_POLICY = UploadResourceDeletePolicyEnum.USER_DELETABLE.value
UPLOAD_RESOURCE_HISTORY_ALLOWED_MODULES = {
UploadResourceModuleEnum.COMMON.value,
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
UploadResourceModuleEnum.SHOT_REPLICATE.value,
}
@@ -83,6 +83,8 @@ from app.services.module_generation_step_update_service import (
update_module_video_prompt_schema,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
from app.services.upload_resource import release_upload_resources_by_source
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot, get_runtime_schema_snapshot
from app.utils.id_gen import generate_id
@@ -501,6 +503,9 @@ async def create_hot_opening_project(db: AsyncSession, current_user: User, req:
input_data={
"material_video_url": req.material_video_url,
"material_image_url": req.material_image_url,
"material_video_resource_id": req.material_video_resource_id,
"material_image_resource_id": req.material_image_resource_id,
"material_video_duration_seconds": req.material_video_duration_seconds,
"source_project_name": req.source_project_name,
"target_project_name": req.target_project_name,
"core_content_point": req.core_content_point,
@@ -1506,11 +1511,37 @@ async def mark_hot_opening_step_dispatch_failed(
async def delete_hot_opening_project(db: AsyncSession, *, current_user: User, project_id: str) -> HotOpeningDeleteOut:
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
project_id_snapshot = project.id
deleted_at = _now()
project.deleted_at = deleted_at
await _soft_delete_steps_from_index(db, project=project, start_index=1, deleted_at=deleted_at)
await log_module_event(db, project=project, event_type=ModuleEventTypeEnum.PROJECT_DELETED.value, message="软删除爆款开头复刻项目")
return HotOpeningDeleteOut(message="项目已删除", project_id=project.id, deleted=True)
upload_release = await release_upload_resources_by_source(
db,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_ids=[project_id_snapshot],
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
)
pending_ids = list(upload_release.get("released_resource_ids") or [])
released_size = int(upload_release.get("released_size_bytes") or 0)
upload_resource_released = int(upload_release.get("released") or 0)
await log_module_event(
db,
project=project,
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
message="软删除爆款开头复刻项目",
detail={
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
"pending_delete_resource_count": len(pending_ids),
},
)
return HotOpeningDeleteOut(
message="项目已删除",
project_id=project_id_snapshot,
deleted=True,
released_size_bytes=released_size,
upload_resource_released=upload_resource_released,
pending_delete_resource_ids=pending_ids,
)
def _build_file_url_or_data_uri(file_url: str) -> str:
return _common_build_file_url_or_data_uri(file_url)
@@ -88,6 +88,8 @@ from app.services.module_generation_step_update_service import (
update_module_video_prompt_schema,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot, get_runtime_schema_snapshot
from app.utils.id_gen import generate_id
from app.models.shot_replicate_segment import ShotReplicateSegment
@@ -513,6 +515,7 @@ async def create_shot_replicate_project(db: AsyncSession, current_user: User, re
input_data={
"material_video_url": req.material_video_url,
"material_image_url": req.material_image_url,
"material_image_resource_id": getattr(req, "material_image_resource_id", None),
"source_project_name": req.source_project_name,
"target_project_name": req.target_project_name,
"core_content_point": req.core_content_point,
@@ -1493,7 +1496,13 @@ async def delete_shot_replicate_project(
project_id: str,
refund_unfinished: bool = False,
) -> ShotReplicateDeleteOut:
"""内部 helper:只软删除指定 ModuleGenerationProject 自身。
这里不是对外 API,不反查 ShotReplicateSegment / ShotReplicateTaskSet
不 commit、不 rollback、不删除真实 UploadResource 文件。
"""
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
project_id_snapshot = project.id
deleted_at = _now()
release_stats: dict[str, int] = {"released_size_bytes": 0}
@@ -1509,21 +1518,34 @@ async def delete_shot_replicate_project(
refund_unfinished=refund_unfinished,
release_stats=release_stats,
)
upload_release = await release_upload_resources_by_source(
db,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_ids=[project_id_snapshot],
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
)
pending_ids = list(upload_release.get("released_resource_ids") or [])
generated_released = int(release_stats.get("released_size_bytes", 0) or 0)
upload_released_size = int(upload_release.get("released_size_bytes") or 0)
await log_module_event(
db,
project=project,
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
message="软删除拆镜复刻项目",
message="软删除拆镜复刻内部项目",
detail={
"refund_unfinished": refund_unfinished,
"released_size_bytes": int(release_stats.get("released_size_bytes", 0)),
"generated_resource_released_size_bytes": generated_released,
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
"pending_delete_resource_count": len(pending_ids),
},
)
return ShotReplicateDeleteOut(
message="项目已删除",
project_id=project.id,
project_id=project_id_snapshot,
deleted=True,
released_size_bytes=int(release_stats.get("released_size_bytes", 0)),
released_size_bytes=generated_released + upload_released_size,
upload_resource_released=int(upload_release.get("released") or 0),
pending_delete_resource_ids=pending_ids,
)
@@ -1583,6 +1605,7 @@ async def create_shot_replicate_project_from_segment(
"material_video_url": segment.segment_video_url,
"material_video_locked": True,
"material_image_url": req.material_image_url,
"material_image_resource_id": getattr(req, "material_image_resource_id", None),
"source_project_name": segment.segment_category or segment.original_video_category or "拆镜片段",
"target_project_name": req.target_project_name,
"core_content_point": req.core_content_point,
@@ -1626,6 +1649,16 @@ async def create_shot_replicate_project_from_segment(
)
project.current_step_code = ShotReplicateStepCodeEnum.MATERIAL_INPUT.value
project.status = ModuleProjectStatusEnum.WAITING_USER.value
await bind_upload_resources(
db,
user_id=current_user.id,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_id=project.id,
resource_ids=[getattr(req, "material_image_resource_id", None)],
urls=[req.material_image_url, segment.segment_video_url],
allow_common_migrate=True,
)
segment.module_project_id = project.id
segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value
await log_module_event(
@@ -36,12 +36,15 @@ from app.schemas.shot_replicate import (
ShotSplitCustomOut,
ShotSplitCustomRequest,
ShotTaskSetCreate,
ShotTaskSetDeleteOut,
ShotTaskSetDetailOut,
ShotTaskSetListOut,
ShotTaskSetOut,
)
from app.services.module_generation_log_service import log_module_event_file
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
from app.services.upload_resource import release_upload_resources_by_source
from app.services.upload_video_asset_service import (
build_time_node,
validate_split_range,
@@ -667,6 +670,15 @@ async def delete_segment(
source_ids=[segment.id],
deleted_at=deleted_at,
)
upload_release = await release_upload_resources_by_source(
db,
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
source_ids=[segment.id],
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
)
pending_delete_resource_ids: list[str] = list(upload_release.get("released_resource_ids") or [])
released_size_bytes += int(upload_release.get("released_size_bytes") or 0)
upload_resource_released = int(upload_release.get("released") or 0)
deleted_module_project_id: str | None = None
if module_project_id:
@@ -680,6 +692,8 @@ async def delete_segment(
)
deleted_module_project_id = project_delete_out.project_id
released_size_bytes += int(project_delete_out.released_size_bytes or 0)
upload_resource_released += int(project_delete_out.upload_resource_released or 0)
pending_delete_resource_ids.extend(project_delete_out.pending_delete_resource_ids or [])
segment.deleted_at = deleted_at
segment.replicate_status = (
@@ -704,7 +718,9 @@ async def delete_segment(
"module_project_id": module_project_id,
"deleted_module_project_id": deleted_module_project_id,
"released_size_bytes": released_size_bytes,
"physical_file_deleted": False,
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
"pending_delete_resource_count": len(pending_delete_resource_ids),
"physical_file_delete": "after_commit",
"refund": False,
},
)
@@ -716,9 +732,127 @@ async def delete_segment(
deleted=True,
deleted_module_project_id=deleted_module_project_id,
released_size_bytes=int(released_size_bytes or 0),
upload_resource_released=upload_resource_released,
pending_delete_resource_ids=pending_delete_resource_ids,
)
async def delete_task_set(
db: AsyncSession,
*,
current_user: User,
task_set_id: str,
) -> ShotTaskSetDeleteOut:
"""软删除整个拆镜任务集。
对外删除入口以 ShotReplicateTaskSet 为边界;内部 ModuleGenerationProject
只作为片段复刻链路被联动软删。这里不 commit、不 rollback、不删除真实文件。
"""
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
task_set_id_snapshot = task_set.id
user_id_snapshot = task_set.user_id
if task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value:
raise HTTPException(status_code=400, detail="原视频分析正在处理中,暂不能删除任务集")
if task_set.split_status == ShotSplitStatusEnum.PROCESSING.value:
raise HTTPException(status_code=400, detail="拆镜切片正在处理中,暂不能删除任务集")
segments_result = await db.execute(
select(ShotReplicateSegment)
.where(
ShotReplicateSegment.task_set_id == task_set_id_snapshot,
ShotReplicateSegment.deleted_at.is_(None),
)
.with_for_update()
)
segments = list(segments_result.scalars().all())
for segment in segments:
if segment.split_status == ShotSplitStatusEnum.PROCESSING.value:
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在切割处理中,暂不能删除任务集")
if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value:
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在分析处理中,暂不能删除任务集")
if segment.replicate_status == ShotSegmentReplicateStatusEnum.PROCESSING.value:
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}关联复刻流程正在处理中,暂不能删除任务集")
segment_ids = [segment.id for segment in segments]
module_project_ids = [segment.module_project_id for segment in segments if segment.module_project_id]
deleted_at = _now()
released_size_bytes = 0
upload_resource_released = 0
pending_delete_resource_ids: list[str] = []
task_upload_release = await release_upload_resources_by_source(
db,
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
source_ids=[task_set_id_snapshot],
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
)
released_size_bytes += int(task_upload_release.get("released_size_bytes") or 0)
upload_resource_released += int(task_upload_release.get("released") or 0)
pending_delete_resource_ids.extend(task_upload_release.get("released_resource_ids") or [])
segment_upload_release = await release_upload_resources_by_source(
db,
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
source_ids=segment_ids,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
)
released_size_bytes += int(segment_upload_release.get("released_size_bytes") or 0)
upload_resource_released += int(segment_upload_release.get("released") or 0)
pending_delete_resource_ids.extend(segment_upload_release.get("released_resource_ids") or [])
from app.services.shot_replicate_flow_service import delete_shot_replicate_project
deleted_module_project_count = 0
for module_project_id in dict.fromkeys(module_project_ids):
project_delete_out = await delete_shot_replicate_project(
db,
current_user=current_user,
project_id=module_project_id,
refund_unfinished=False,
)
deleted_module_project_count += 1
released_size_bytes += int(project_delete_out.released_size_bytes or 0)
upload_resource_released += int(project_delete_out.upload_resource_released or 0)
pending_delete_resource_ids.extend(project_delete_out.pending_delete_resource_ids or [])
task_set.deleted_at = deleted_at
task_set.status = ShotTaskSetStatusEnum.DELETED.value
for segment in segments:
segment.deleted_at = deleted_at
segment.replicate_status = ShotSegmentReplicateStatusEnum.FAILED.value if segment.module_project_id else segment.replicate_status
await db.flush()
log_module_event_file(
module=MODULE,
event_type="SHOT_TASK_SET_DELETED",
project_id=task_set_id_snapshot,
user_id=user_id_snapshot,
message="软删除拆镜任务集并标记上传资源待物理删除",
detail={
"task_set_id": task_set_id_snapshot,
"segment_count": len(segment_ids),
"module_project_count": deleted_module_project_count,
"released_size_bytes": released_size_bytes,
"upload_resource_released": upload_resource_released,
"pending_delete_resource_count": len(pending_delete_resource_ids),
"task_upload_release": {k: v for k, v in task_upload_release.items() if k != "released_resource_ids"},
"segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"},
"physical_file_delete": "after_commit",
"refund": False,
},
)
return ShotTaskSetDeleteOut(
message="拆镜任务集已删除",
task_set_id=task_set_id_snapshot,
deleted=True,
deleted_segment_count=len(segment_ids),
deleted_module_project_count=deleted_module_project_count,
released_size_bytes=int(released_size_bytes or 0),
upload_resource_released=upload_resource_released,
pending_delete_resource_ids=pending_delete_resource_ids,
)
async def prepare_reanalyze_task_set(
db: AsyncSession,
@@ -0,0 +1,37 @@
from app.services.upload_resource.core_service import (
delete_unbound_upload_resource,
record_external_upload_resource,
upload_reference_file,
)
from app.services.upload_resource.bind_service import (
bind_upload_resources,
release_upload_resources_by_source,
record_shot_segment_upload_resource,
)
__all__ = [
"upload_reference_file",
"delete_unbound_upload_resource",
"record_external_upload_resource",
"bind_upload_resources",
"release_upload_resources_by_source",
"record_shot_segment_upload_resource",
"list_upload_resource_history_grouped_days",
"list_upload_resource_history_day_items",
"mark_upload_resource_history_deleted",
"cleanup_upload_resource_history_files",
]
from app.services.upload_resource.file_cleanup_service import (
cleanup_pending_upload_resource_files,
cleanup_upload_resource_files_after_commit,
)
from app.services.upload_resource.history_service import (
list_upload_resource_history_grouped_days,
list_upload_resource_history_day_items,
)
from app.services.upload_resource.delete_service import (
cleanup_upload_resource_history_files,
mark_upload_resource_history_deleted,
)
@@ -0,0 +1,262 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.upload_resource import UploadResourceTypeEnum
from app.models.generated_resource import GeneratedResource
from app.models.upload_resource import UploadResource
from app.models.user_resource_month_stat import UserResourceMonthStat
from app.models.user_resource_total_stat import UserResourceTotalStat
from app.utils.id_gen import generate_id
def resource_month_from_datetime(value: datetime | None = None) -> date:
value = value or datetime.now(timezone.utc)
return date(value.year, value.month, 1)
def _int(value: Any) -> int:
return int(value or 0)
def _add_non_negative(obj: Any, field: str, delta: int) -> None:
current = _int(getattr(obj, field, 0))
setattr(obj, field, max(0, current + int(delta or 0)))
def _add_raw(obj: Any, field: str, delta: int) -> None:
current = _int(getattr(obj, field, 0))
setattr(obj, field, current + int(delta or 0))
async def get_or_create_month_stat(db: AsyncSession, user_id: str, stat_month: date) -> UserResourceMonthStat:
result = await db.execute(
select(UserResourceMonthStat).where(
UserResourceMonthStat.user_id == user_id,
UserResourceMonthStat.stat_month == stat_month,
).limit(1)
)
stat = result.scalar_one_or_none()
if stat:
return stat
stat = UserResourceMonthStat(id=generate_id(), user_id=user_id, stat_month=stat_month)
db.add(stat)
await db.flush()
return stat
async def get_or_create_total_stat(db: AsyncSession, user_id: str, *, for_update: bool = False) -> UserResourceTotalStat:
stmt = select(UserResourceTotalStat).where(UserResourceTotalStat.user_id == user_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
stat = result.scalar_one_or_none()
if stat:
return stat
stat = UserResourceTotalStat(id=generate_id(), user_id=user_id)
db.add(stat)
await db.flush()
if for_update:
result = await db.execute(
select(UserResourceTotalStat)
.where(UserResourceTotalStat.user_id == user_id)
.with_for_update()
.limit(1)
)
locked = result.scalar_one_or_none()
if locked:
return locked
return stat
async def apply_upload_resource_stat_delta(
db: AsyncSession,
*,
user_id: str,
stat_month: date,
resource_type: str,
active_size_delta: int = 0,
active_count_delta: int = 0,
deleted_size_delta: int = 0,
deleted_count_delta: int = 0,
upload_size_delta: int = 0,
upload_count_delta: int = 0,
) -> None:
month_stat = await get_or_create_month_stat(db, user_id, stat_month)
total_stat = await get_or_create_total_stat(db, user_id)
now = datetime.now(timezone.utc)
for stat in (month_stat, total_stat):
_add_non_negative(stat, "active_size_bytes", active_size_delta)
_add_non_negative(stat, "active_count", active_count_delta)
_add_non_negative(stat, "deleted_size_bytes", deleted_size_delta)
_add_non_negative(stat, "deleted_count", deleted_count_delta)
_add_raw(stat, "upload_size_bytes", upload_size_delta)
_add_raw(stat, "upload_count", upload_count_delta)
if resource_type == UploadResourceTypeEnum.IMAGE.value:
_add_non_negative(stat, "image_size_bytes", active_size_delta)
_add_non_negative(stat, "image_count", active_count_delta)
elif resource_type == UploadResourceTypeEnum.VIDEO.value:
_add_non_negative(stat, "video_size_bytes", active_size_delta)
_add_non_negative(stat, "video_count", active_count_delta)
elif resource_type == UploadResourceTypeEnum.AUDIO.value:
_add_non_negative(stat, "audio_size_bytes", active_size_delta)
_add_non_negative(stat, "audio_count", active_count_delta)
elif resource_type == UploadResourceTypeEnum.SHOT_SEGMENT.value:
_add_non_negative(stat, "shot_segment_size_bytes", active_size_delta)
_add_non_negative(stat, "shot_segment_count", active_count_delta)
stat.last_recalculated_at = now
async def release_upload_resource_capacity(db: AsyncSession, resource: UploadResource, *, released_at: datetime | None = None) -> bool:
if resource.capacity_released_at is not None:
return False
released_at = released_at or datetime.now(timezone.utc)
stat_month = resource_month_from_datetime(resource.created_at or released_at)
size = _int(resource.file_size_bytes)
resource.capacity_released_at = released_at
await apply_upload_resource_stat_delta(
db,
user_id=resource.user_id,
stat_month=stat_month,
resource_type=resource.resource_type,
active_size_delta=-size,
active_count_delta=-1,
deleted_size_delta=size,
deleted_count_delta=1,
)
return True
async def rebuild_user_resource_stats(db: AsyncSession, *, user_ids: list[str] | None = None) -> dict[str, int]:
"""按数据库真实资源账本重算统计。
说明:这里只重置并重算 user_ids 范围内的统计。未传 user_ids 时重算所有在资源表中出现过的用户。
"""
if user_ids is None:
ids: set[str] = set()
for model in (GeneratedResource, UploadResource):
result = await db.execute(select(model.user_id).distinct())
ids.update(v for v in result.scalars().all() if v)
user_ids = sorted(ids)
else:
user_ids = sorted({v for v in user_ids if v})
if not user_ids:
return {"users": 0, "month_rows": 0, "total_rows": 0}
await db.execute(update(UserResourceTotalStat).where(UserResourceTotalStat.user_id.in_(user_ids)).values(
active_size_bytes=0,
deleted_size_bytes=0,
total_generated_size_bytes=0,
upload_size_bytes=0,
image_size_bytes=0,
video_size_bytes=0,
audio_size_bytes=0,
shot_segment_size_bytes=0,
active_count=0,
deleted_count=0,
upload_count=0,
image_count=0,
video_count=0,
audio_count=0,
shot_segment_count=0,
last_recalculated_at=datetime.now(timezone.utc),
))
await db.execute(update(UserResourceMonthStat).where(UserResourceMonthStat.user_id.in_(user_ids)).values(
active_size_bytes=0,
deleted_size_bytes=0,
total_generated_size_bytes=0,
upload_size_bytes=0,
image_size_bytes=0,
video_size_bytes=0,
audio_size_bytes=0,
shot_segment_size_bytes=0,
active_count=0,
deleted_count=0,
upload_count=0,
image_count=0,
video_count=0,
audio_count=0,
shot_segment_count=0,
last_recalculated_at=datetime.now(timezone.utc),
))
month_rows = 0
total_rows = 0
gen_rows = await db.execute(
select(
GeneratedResource.user_id,
GeneratedResource.resource_month,
GeneratedResource.resource_type,
GeneratedResource.deleted_at,
func.count(GeneratedResource.id),
func.coalesce(func.sum(GeneratedResource.file_size_bytes), 0),
).where(GeneratedResource.user_id.in_(user_ids)).group_by(
GeneratedResource.user_id,
GeneratedResource.resource_month,
GeneratedResource.resource_type,
GeneratedResource.deleted_at,
)
)
for user_id, month, rtype, deleted_at, count, size in gen_rows.all():
active = deleted_at is None
await apply_upload_resource_stat_delta(
db,
user_id=user_id,
stat_month=month,
resource_type=rtype,
active_size_delta=int(size or 0) if active else 0,
active_count_delta=int(count or 0) if active else 0,
deleted_size_delta=0 if active else int(size or 0),
deleted_count_delta=0 if active else int(count or 0),
)
# 生成资源字段单独累加
month_stat = await get_or_create_month_stat(db, user_id, month)
total_stat = await get_or_create_total_stat(db, user_id)
if active:
_add_raw(month_stat, "total_generated_size_bytes", int(size or 0))
_add_raw(total_stat, "total_generated_size_bytes", int(size or 0))
upload_rows = await db.execute(
select(
UploadResource.user_id,
func.date_trunc("month", UploadResource.created_at).label("month"),
UploadResource.resource_type,
UploadResource.deleted_at,
func.count(UploadResource.id),
func.coalesce(func.sum(UploadResource.file_size_bytes), 0),
).where(UploadResource.user_id.in_(user_ids)).group_by(
UploadResource.user_id,
"month",
UploadResource.resource_type,
UploadResource.deleted_at,
)
)
for user_id, month_dt, rtype, deleted_at, count, size in upload_rows.all():
month = resource_month_from_datetime(month_dt or datetime.now(timezone.utc))
active = deleted_at is None
await apply_upload_resource_stat_delta(
db,
user_id=user_id,
stat_month=month,
resource_type=rtype,
active_size_delta=int(size or 0) if active else 0,
active_count_delta=int(count or 0) if active else 0,
deleted_size_delta=0 if active else int(size or 0),
deleted_count_delta=0 if active else int(count or 0),
upload_size_delta=int(size or 0) if active else 0,
upload_count_delta=int(count or 0) if active else 0,
)
await db.flush()
total_rows = len(user_ids)
month_count = await db.execute(select(func.count(UserResourceMonthStat.id)).where(UserResourceMonthStat.user_id.in_(user_ids)))
month_rows = int(month_count.scalar_one() or 0)
return {"users": len(user_ids), "month_rows": month_rows, "total_rows": total_rows}
@@ -0,0 +1,368 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Iterable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.upload_resource import (
UploadResourceBindStatusEnum,
UploadResourceCreatedByEnum,
UploadResourceDeletePolicyEnum,
UploadResourceEventEnum,
UploadResourceModuleEnum,
UploadResourceSourceModelEnum,
)
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.models.upload_resource import UploadResource
from app.models.user import User
from app.services.upload_resource.accounting_service import rebuild_user_resource_stats
from app.services.upload_resource.bind_service import bind_upload_resources
from app.services.upload_resource.core_service import record_external_upload_resource
from app.services.upload_resource.log_service import log_upload_resource_event
from app.services.upload_resource.file_cleanup_service import cleanup_pending_upload_resource_files
from app.services.upload_resource.path_resolver import ParsedUploadPath, iter_files, parse_upload_path, upload_url_to_storage_path
@dataclass(slots=True)
class BackfillOptions:
root: str = "storage/uploads"
batch_size: int = 500
dry_run: bool = True
include_legacy: bool = False
rebind_modules: bool = False
rebuild_stats: bool = False
only_user_id: str | None = None
only_module: str | None = None
cleanup_pending_files: bool = False
cleanup_limit: int = 500
@dataclass(slots=True)
class BackfillResult:
scanned: int = 0
matched: int = 0
inserted: int = 0
updated: int = 0
existed: int = 0
skipped: int = 0
rebind_bound: int = 0
rebuild_users: int = 0
cleanup_files: int = 0
cleanup_failed: int = 0
skip_reasons: dict[str, int] = field(default_factory=dict)
def skip(self, reason: str) -> None:
self.skipped += 1
self.skip_reasons[reason] = self.skip_reasons.get(reason, 0) + 1
def to_dict(self) -> dict[str, Any]:
return {
"scanned": self.scanned,
"matched": self.matched,
"inserted": self.inserted,
"updated": self.updated,
"existed": self.existed,
"skipped": self.skipped,
"rebind_bound": self.rebind_bound,
"rebuild_users": self.rebuild_users,
"cleanup_files": self.cleanup_files,
"cleanup_failed": self.cleanup_failed,
"skip_reasons": self.skip_reasons,
}
def _module_allowed(parsed: ParsedUploadPath, only_module: str | None) -> bool:
if not only_module:
return True
if only_module == UploadResourceModuleEnum.COMMON.value:
return parsed.module == UploadResourceModuleEnum.COMMON.value
if only_module == UploadResourceModuleEnum.SHOT_REPLICATE.value:
return parsed.module == UploadResourceModuleEnum.SHOT_REPLICATE.value
return parsed.module == only_module
def _collect_urls(value: Any) -> list[str]:
urls: list[str] = []
if value is None:
return urls
if isinstance(value, str):
if value.startswith("/uploads/"):
urls.append(value)
return urls
if isinstance(value, dict):
for v in value.values():
urls.extend(_collect_urls(v))
return urls
if isinstance(value, list):
for item in value:
urls.extend(_collect_urls(item))
return urls
async def _user_exists_map(db: AsyncSession, user_ids: Iterable[str]) -> set[str]:
ids = [v for v in dict.fromkeys(user_ids) if v]
if not ids:
return set()
result = await db.execute(select(User.id).where(User.id.in_(ids)))
return set(result.scalars().all())
async def _segment_map(db: AsyncSession, segment_ids: Iterable[str]) -> dict[str, ShotReplicateSegment]:
ids = [v for v in dict.fromkeys(segment_ids) if v]
if not ids:
return {}
result = await db.execute(select(ShotReplicateSegment).where(ShotReplicateSegment.id.in_(ids)))
return {item.id: item for item in result.scalars().all()}
async def _process_batch(db: AsyncSession, parsed_items: list[ParsedUploadPath], result: BackfillResult, options: BackfillOptions) -> None:
if not parsed_items:
return
segment_ids = [p.source_id for p in parsed_items if p.resource_type == "shot_segment" and p.source_id]
segments = await _segment_map(db, segment_ids)
for parsed in parsed_items:
if parsed.resource_type == "shot_segment":
segment = segments.get(parsed.source_id or "")
if not segment:
result.skip("shot_segment_not_found")
continue
parsed.user_id = segment.user_id
parsed.created_at = segment.created_at or parsed.created_at
parsed.source_model = UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value
parsed.source_id = segment.id
users = await _user_exists_map(db, [p.user_id for p in parsed_items if p.user_id])
existing_result = await db.execute(select(UploadResource.storage_path).where(UploadResource.storage_path.in_([p.storage_path for p in parsed_items])))
existing_paths = set(existing_result.scalars().all())
for parsed in parsed_items:
if not parsed.user_id:
result.skip("missing_user_id")
continue
if options.only_user_id and parsed.user_id != options.only_user_id:
result.skip("user_filtered")
continue
if parsed.user_id not in users:
result.skip("user_not_found")
continue
if parsed.storage_path in existing_paths:
result.existed += 1
if not options.dry_run:
await record_external_upload_resource(
db,
user_id=parsed.user_id,
module=parsed.module,
resource_type=parsed.resource_type,
resource_url=parsed.resource_url,
storage_path=parsed.storage_path,
file_size_bytes=parsed.file_size_bytes,
file_name=parsed.file_name,
source_model=parsed.source_model,
source_id=parsed.source_id,
bind_status=UploadResourceBindStatusEnum.BOUND.value if parsed.source_id else UploadResourceBindStatusEnum.PENDING.value,
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value if parsed.source_id else UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
created_by=UploadResourceCreatedByEnum.BACKFILL.value,
created_at=parsed.created_at,
metadata={"backfill": True},
)
result.updated += 1
continue
result.matched += 1
if options.dry_run:
continue
await record_external_upload_resource(
db,
user_id=parsed.user_id,
module=parsed.module,
resource_type=parsed.resource_type,
resource_url=parsed.resource_url,
storage_path=parsed.storage_path,
file_size_bytes=parsed.file_size_bytes,
file_name=parsed.file_name,
source_model=parsed.source_model,
source_id=parsed.source_id,
bind_status=UploadResourceBindStatusEnum.BOUND.value if parsed.source_id else UploadResourceBindStatusEnum.PENDING.value,
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value if parsed.source_id else UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
created_by=UploadResourceCreatedByEnum.BACKFILL.value,
created_at=parsed.created_at,
metadata={"backfill": True},
)
result.inserted += 1
def _batched(items: Iterable[Path], batch_size: int):
batch: list[Path] = []
for item in items:
batch.append(item)
if len(batch) >= batch_size:
yield batch
batch = []
if batch:
yield batch
async def _rebind_hot_opening(db: AsyncSession, *, only_user_id: str | None = None) -> int:
stmt = select(ModuleGenerationProject.id, ModuleGenerationProject.user_id).where(
ModuleGenerationProject.module == UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
ModuleGenerationProject.deleted_at.is_(None),
)
if only_user_id:
stmt = stmt.where(ModuleGenerationProject.user_id == only_user_id)
projects = (await db.execute(stmt)).all()
if not projects:
return 0
project_user = {pid: uid for pid, uid in projects}
steps_result = await db.execute(
select(ModuleGenerationStep.project_id, ModuleGenerationStep.input_json, ModuleGenerationStep.output_json).where(
ModuleGenerationStep.project_id.in_(list(project_user.keys())),
ModuleGenerationStep.deleted_at.is_(None),
)
)
grouped: dict[tuple[str, str], list[str]] = {}
for project_id, input_json, output_json in steps_result.all():
urls = _collect_urls(input_json) + _collect_urls(output_json)
key = (project_user[project_id], project_id)
grouped.setdefault(key, []).extend(urls)
bound = 0
for (user_id, project_id), urls in grouped.items():
stats = await bind_upload_resources(
db,
user_id=user_id,
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_id=project_id,
urls=urls,
allow_common_migrate=True,
)
bound += stats.get("bound", 0)
return bound
async def _rebind_shot_replicate(db: AsyncSession, *, only_user_id: str | None = None) -> int:
bound = 0
task_stmt = select(ShotReplicateTaskSet.id, ShotReplicateTaskSet.user_id, ShotReplicateTaskSet.video_url).where(ShotReplicateTaskSet.deleted_at.is_(None))
if only_user_id:
task_stmt = task_stmt.where(ShotReplicateTaskSet.user_id == only_user_id)
for task_set_id, user_id, video_url in (await db.execute(task_stmt)).all():
stats = await bind_upload_resources(
db,
user_id=user_id,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
source_id=task_set_id,
urls=[video_url],
allow_common_migrate=True,
)
bound += stats.get("bound", 0)
project_stmt = select(ModuleGenerationProject.id, ModuleGenerationProject.user_id).where(
ModuleGenerationProject.module == UploadResourceModuleEnum.SHOT_REPLICATE.value,
ModuleGenerationProject.deleted_at.is_(None),
)
if only_user_id:
project_stmt = project_stmt.where(ModuleGenerationProject.user_id == only_user_id)
projects = (await db.execute(project_stmt)).all()
if projects:
project_user = {pid: uid for pid, uid in projects}
steps_result = await db.execute(
select(ModuleGenerationStep.project_id, ModuleGenerationStep.input_json, ModuleGenerationStep.output_json).where(
ModuleGenerationStep.project_id.in_(list(project_user.keys())),
ModuleGenerationStep.deleted_at.is_(None),
)
)
grouped: dict[tuple[str, str], list[str]] = {}
for project_id, input_json, output_json in steps_result.all():
key = (project_user[project_id], project_id)
grouped.setdefault(key, []).extend(_collect_urls(input_json) + _collect_urls(output_json))
for (user_id, project_id), urls in grouped.items():
stats = await bind_upload_resources(
db,
user_id=user_id,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_id=project_id,
urls=urls,
allow_common_migrate=True,
)
bound += stats.get("bound", 0)
return bound
async def rebind_module_upload_resources(db: AsyncSession, *, only_module: str | None = None, only_user_id: str | None = None) -> int:
total = 0
if only_module in (None, UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value):
total += await _rebind_hot_opening(db, only_user_id=only_user_id)
if only_module in (None, UploadResourceModuleEnum.SHOT_REPLICATE.value):
total += await _rebind_shot_replicate(db, only_user_id=only_user_id)
return total
async def run_upload_resource_backfill(db: AsyncSession, options: BackfillOptions) -> BackfillResult:
result = BackfillResult()
log_upload_resource_event(
event_type=UploadResourceEventEnum.BACKFILL_START.value,
detail={"options": asdict(options)},
)
if options.cleanup_pending_files:
if not options.dry_run:
cleanup_stats = await cleanup_pending_upload_resource_files(db, limit=options.cleanup_limit)
result.cleanup_files = int(cleanup_stats.get("deleted", 0) or 0) + int(cleanup_stats.get("missing", 0) or 0)
result.cleanup_failed = int(cleanup_stats.get("failed", 0) or 0)
log_upload_resource_event(
event_type=UploadResourceEventEnum.BACKFILL_FINISHED.value,
detail=result.to_dict(),
)
return result
for paths in _batched(iter_files(options.root), options.batch_size):
parsed_items: list[ParsedUploadPath] = []
for path in paths:
result.scanned += 1
parsed = parse_upload_path(path, include_legacy=options.include_legacy)
if not parsed:
result.skip("unparsed")
continue
if parsed.skip_reason:
result.skip(parsed.skip_reason)
continue
if not _module_allowed(parsed, options.only_module):
result.skip("module_filtered")
continue
parsed_items.append(parsed)
await _process_batch(db, parsed_items, result, options)
if not options.dry_run:
await db.commit()
if options.rebind_modules:
if options.dry_run:
# rebind dry-run 不真实改库,只统计为 0,避免复杂模拟误导。
result.rebind_bound = 0
else:
result.rebind_bound = await rebind_module_upload_resources(
db,
only_module=options.only_module if options.only_module in (UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value, UploadResourceModuleEnum.SHOT_REPLICATE.value) else None,
only_user_id=options.only_user_id,
)
await db.commit()
if options.rebuild_stats and not options.dry_run:
stats = await rebuild_user_resource_stats(db, user_ids=[options.only_user_id] if options.only_user_id else None)
result.rebuild_users = stats.get("users", 0)
await db.commit()
log_upload_resource_event(
event_type=UploadResourceEventEnum.BACKFILL_FINISHED.value,
detail=result.to_dict(),
)
return result
@@ -0,0 +1,197 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Iterable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.upload_resource import (
UploadResourceBindStatusEnum,
UploadResourceCreatedByEnum,
UploadResourceDeletePolicyEnum,
UploadResourceEventEnum,
UploadResourceFileDeleteStatusEnum,
UploadResourceModuleEnum,
UploadResourceSourceModelEnum,
UploadResourceTypeEnum,
)
from app.models.upload_resource import UploadResource
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.services.upload_resource.accounting_service import release_upload_resource_capacity
from app.services.upload_resource.core_service import record_external_upload_resource
from app.services.upload_resource.log_service import log_upload_resource_event
from app.services.upload_resource.path_resolver import normalize_storage_path, upload_url_to_storage_path
def _clean_ids(values: Iterable[str | None] | None) -> list[str]:
if not values:
return []
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
def _clean_urls(values: Iterable[str | None] | None) -> list[str]:
if not values:
return []
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
async def bind_upload_resources(
db: AsyncSession,
*,
user_id: str,
module: str,
source_model: str,
source_id: str,
resource_ids: Iterable[str | None] | None = None,
urls: Iterable[str | None] | None = None,
allow_common_migrate: bool = True,
) -> dict[str, int]:
ids = _clean_ids(resource_ids)
url_values = _clean_urls(urls)
storage_paths = [p for p in (upload_url_to_storage_path(url) for url in url_values) if p]
if not ids and not storage_paths:
return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0}
conditions = []
if ids:
conditions.append(UploadResource.id.in_(ids))
if storage_paths:
conditions.append(UploadResource.storage_path.in_(storage_paths))
stmt = select(UploadResource).where(UploadResource.user_id == user_id, UploadResource.deleted_at.is_(None))
if len(conditions) == 1:
stmt = stmt.where(conditions[0])
else:
from sqlalchemy import or_
stmt = stmt.where(or_(*conditions))
stmt = stmt.with_for_update()
result = await db.execute(stmt)
resources = result.scalars().all()
stats = {"matched": len(resources), "bound": 0, "skipped": 0, "conflict": 0}
for resource in resources:
if resource.source_id == source_id and resource.source_model == source_model:
stats["skipped"] += 1
continue
if resource.source_id and resource.source_id != source_id:
stats["conflict"] += 1
log_upload_resource_event(
event_type=UploadResourceEventEnum.BIND_CONFLICT.value,
module=module,
user_id=user_id,
resource_id=resource.id,
source_model=source_model,
source_id=source_id,
event_status="warning",
detail={
"current_source_model": resource.source_model,
"current_source_id": resource.source_id,
"storage_path": resource.storage_path,
},
)
continue
if resource.module != module:
if not (allow_common_migrate and resource.module == UploadResourceModuleEnum.COMMON.value):
stats["conflict"] += 1
continue
resource.module = module
resource.source_module = module
resource.source_model = source_model
resource.source_id = source_id
resource.source_module = module
resource.bind_status = UploadResourceBindStatusEnum.BOUND.value
resource.delete_policy = UploadResourceDeletePolicyEnum.MODULE_ONLY.value
stats["bound"] += 1
if stats["bound"]:
await db.flush()
log_upload_resource_event(
event_type=UploadResourceEventEnum.BIND_SUCCESS.value,
module=module,
user_id=user_id,
source_model=source_model,
source_id=source_id,
detail=stats,
)
return stats
async def release_upload_resources_by_source(
db: AsyncSession,
*,
source_model: str,
source_ids: Iterable[str],
module: str | None = None,
) -> dict[str, Any]:
ids = _clean_ids(source_ids)
if not ids:
return {"matched": 0, "released": 0, "released_size_bytes": 0, "released_resource_ids": []}
stmt = select(UploadResource).where(
UploadResource.source_model == source_model,
UploadResource.source_id.in_(ids),
UploadResource.deleted_at.is_(None),
).with_for_update()
if module:
stmt = stmt.where(UploadResource.module == module)
result = await db.execute(stmt)
resources = result.scalars().all()
now = datetime.now(timezone.utc)
stats: dict[str, Any] = {
"matched": len(resources),
"released": 0,
"released_size_bytes": 0,
"released_resource_ids": [],
}
for resource in resources:
resource_id = resource.id
size = int(resource.file_size_bytes or 0)
resource.deleted_at = now
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
resource.file_delete_error = None
if await release_upload_resource_capacity(db, resource, released_at=now):
stats["released"] += 1
stats["released_size_bytes"] += size
stats["released_resource_ids"].append(resource_id)
if resources:
await db.flush()
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_MARKED_PENDING.value,
module=module,
source_model=source_model,
source_id=",".join(ids[:20]),
detail={k: v for k, v in stats.items() if k != "released_resource_ids"} | {"released_resource_count": len(stats["released_resource_ids"])},
)
return stats
async def record_shot_segment_upload_resource(
db: AsyncSession,
*,
segment: ShotReplicateSegment,
storage_path: str,
resource_url: str,
file_size_bytes: int,
) -> UploadResource:
return await record_external_upload_resource(
db,
user_id=segment.user_id,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
resource_type=UploadResourceTypeEnum.SHOT_SEGMENT.value,
resource_url=resource_url,
storage_path=normalize_storage_path(storage_path),
file_size_bytes=file_size_bytes,
file_name=Path(storage_path).name,
mime_type="video/mp4",
duration_seconds=float(segment.duration_seconds or 0) if segment.duration_seconds else None,
duration_source="business",
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
source_id=segment.id,
bind_status=UploadResourceBindStatusEnum.BOUND.value,
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value,
created_by=UploadResourceCreatedByEnum.SPLIT_TASK.value,
metadata={"task_set_id": segment.task_set_id, "segment_index": segment.segment_index},
created_at=segment.created_at,
)
@@ -0,0 +1,51 @@
from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.resource_capacity import RESOURCE_CAPACITY_EXCEEDED_MESSAGE, ResourceCapacityErrorCodeEnum
from app.models.user import User
from app.services.resource_capacity_service import get_user_resource_capacity_usage
from app.services.upload_resource.accounting_service import get_or_create_total_stat
def is_admin_user(user: User | object | None) -> bool:
if user is None:
return False
try:
if getattr(user, "user_type", None) == "admin":
return True
if bool(getattr(user, "is_admin", False)):
return True
except Exception:
return False
return False
async def assert_upload_capacity_available(
db: AsyncSession,
*,
user: User,
file_size_bytes: int,
) -> None:
"""上传容量拦截。
admin 用户不拦截,但统计仍会入账。普通用户锁定 total_stat 行后判断:
active_size_bytes + 本次上传大小 <= 容量上限。
"""
if is_admin_user(user):
return
total_stat = await get_or_create_total_stat(db, user.id, for_update=True)
usage = await get_user_resource_capacity_usage(db, user.id)
if not usage.enabled or usage.total_bytes is None:
return
used = int(total_stat.active_size_bytes or 0)
size = max(int(file_size_bytes or 0), 0)
if used + size > int(usage.total_bytes or 0):
raise HTTPException(
status_code=400,
detail=f"{RESOURCE_CAPACITY_EXCEEDED_MESSAGE},本次上传 {size} 字节,当前已用 {used} 字节,总容量 {usage.total_bytes} 字节",
headers={"X-Error-Code": ResourceCapacityErrorCodeEnum.RESOURCE_CAPACITY_EXCEEDED.value},
)
@@ -0,0 +1,394 @@
from __future__ import annotations
import json
import os
import shutil
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.upload_resource import (
UploadResourceBindStatusEnum,
UploadResourceCreatedByEnum,
UploadResourceDeletePolicyEnum,
UploadResourceDurationSourceEnum,
UploadResourceEventEnum,
UploadResourceFileDeleteStatusEnum,
UploadResourceModuleEnum,
UploadResourceTypeEnum,
)
from app.models.upload_resource import UploadResource
from app.models.user import User
from app.services.upload_resource.accounting_service import apply_upload_resource_stat_delta, release_upload_resource_capacity, resource_month_from_datetime
from app.services.upload_resource.capacity_service import assert_upload_capacity_available
from app.services.upload_resource.log_service import log_upload_resource_event
from app.services.upload_resource.path_resolver import build_upload_destination, normalize_storage_path, storage_path_to_upload_url, upload_url_to_storage_path
from app.utils.id_gen import generate_id
CHUNK_SIZE = 1024 * 1024
IMAGE_MAX_BYTES = 10 * 1024 * 1024
VIDEO_MAX_BYTES = 100 * 1024 * 1024
AUDIO_DEFAULT_MAX_BYTES = 15 * 1024 * 1024
@dataclass(slots=True)
class UploadResourceResult:
resource_id: str
url: str
filename: str
resource_type: str
module: str
file_size_bytes: int
duration_seconds: float | None = None
def _json(data: Any) -> str | None:
if data is None:
return None
if isinstance(data, str):
return data
return json.dumps(data, ensure_ascii=False, default=str)
def _duration(value: float | int | str | None) -> float | None:
if value is None or value == "":
return None
try:
number = float(value)
return number if number > 0 else None
except Exception:
return None
def _validate_content_type(file: UploadFile, *, resource_type: str) -> None:
content_type = file.content_type or ""
if resource_type == UploadResourceTypeEnum.IMAGE.value and not content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="仅支持图片文件")
if resource_type == UploadResourceTypeEnum.VIDEO.value and not content_type.startswith("video/"):
raise HTTPException(status_code=400, detail="仅支持视频文件")
if resource_type == UploadResourceTypeEnum.AUDIO.value and not content_type.startswith("audio/"):
# 兼容部分浏览器/系统上传 mp3 时返回 application/octet-stream,最终仍由扩展名校验兜底。
ext = Path(file.filename or "").suffix.lower().lstrip(".")
if ext not in {"mp3", "wav", "m4a", "aac", "flac"}:
raise HTTPException(status_code=400, detail="仅支持音频文件")
def _max_bytes(resource_type: str, max_bytes: int | None = None) -> int:
if max_bytes:
return int(max_bytes)
if resource_type == UploadResourceTypeEnum.IMAGE.value:
return IMAGE_MAX_BYTES
if resource_type == UploadResourceTypeEnum.VIDEO.value:
return VIDEO_MAX_BYTES
return AUDIO_DEFAULT_MAX_BYTES
async def _save_to_temp(file: UploadFile, *, max_bytes: int) -> tuple[str, int]:
fd, temp_path = tempfile.mkstemp(prefix="upload_resource_", suffix=".tmp")
total = 0
try:
with os.fdopen(fd, "wb") as out:
while True:
chunk = await file.read(CHUNK_SIZE)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_bytes // 1024 // 1024}MB")
out.write(chunk)
return temp_path, total
except Exception:
try:
os.close(fd)
except Exception:
pass
try:
os.remove(temp_path)
except OSError:
pass
raise
async def record_external_upload_resource(
db: AsyncSession,
*,
user_id: str,
module: str,
resource_type: str,
resource_url: str,
storage_path: str,
file_size_bytes: int | None = None,
file_name: str | None = None,
mime_type: str | None = None,
duration_seconds: float | None = None,
duration_source: str | None = None,
source_model: str | None = None,
source_id: str | None = None,
bind_status: str = UploadResourceBindStatusEnum.PENDING.value,
delete_policy: str = UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
created_by: str = UploadResourceCreatedByEnum.API.value,
metadata: Any = None,
created_at: datetime | None = None,
) -> UploadResource:
storage_path = normalize_storage_path(storage_path)
size = int(file_size_bytes if file_size_bytes is not None else os.path.getsize(storage_path) if os.path.exists(storage_path) else 0)
result = await db.execute(select(UploadResource).where(UploadResource.storage_path == storage_path).limit(1))
existing = result.scalar_one_or_none()
now = datetime.now(timezone.utc)
if existing:
old_size = int(existing.file_size_bytes or 0)
existing.user_id = user_id
existing.module = module
existing.resource_type = resource_type
existing.resource_url = resource_url
existing.file_name = file_name or existing.file_name or Path(storage_path).name
existing.file_ext = Path(storage_path).suffix.lower().lstrip(".")
existing.mime_type = mime_type or existing.mime_type
existing.file_size_bytes = size
existing.duration_seconds = duration_seconds if duration_seconds is not None else existing.duration_seconds
existing.duration_source = duration_source or existing.duration_source
existing.source_model = source_model or existing.source_model
existing.source_id = source_id or existing.source_id
existing.source_module = module
existing.bind_status = bind_status or existing.bind_status
existing.delete_policy = delete_policy or existing.delete_policy
existing.created_by = existing.created_by or created_by
existing.metadata_json = _json(metadata) if metadata is not None else existing.metadata_json
if created_at is not None:
existing.created_at = created_at
delta = size - old_size if existing.deleted_at is None and existing.capacity_released_at is None else 0
if delta:
await apply_upload_resource_stat_delta(
db,
user_id=user_id,
stat_month=resource_month_from_datetime(existing.created_at or now),
resource_type=resource_type,
active_size_delta=delta,
upload_size_delta=delta,
)
await db.flush()
return existing
resource = UploadResource(
id=generate_id(),
user_id=user_id,
module=module,
resource_type=resource_type,
resource_url=resource_url,
storage_path=storage_path,
file_name=file_name or Path(storage_path).name,
file_ext=Path(storage_path).suffix.lower().lstrip("."),
mime_type=mime_type,
file_size_bytes=size,
duration_seconds=duration_seconds,
duration_source=duration_source,
source_model=source_model,
source_id=source_id,
source_module=module,
bind_status=bind_status,
delete_policy=delete_policy,
created_by=created_by,
metadata_json=_json(metadata),
)
if created_at is not None:
resource.created_at = created_at
db.add(resource)
await db.flush()
await apply_upload_resource_stat_delta(
db,
user_id=user_id,
stat_month=resource_month_from_datetime(created_at or now),
resource_type=resource_type,
active_size_delta=size,
active_count_delta=1,
upload_size_delta=size,
upload_count_delta=1,
)
await db.flush()
return resource
async def upload_reference_file(
db: AsyncSession,
*,
file: UploadFile,
current_user: User,
module: str = UploadResourceModuleEnum.COMMON.value,
resource_type: str,
gen_type: str = "video",
duration_seconds: float | None = None,
max_bytes: int | None = None,
) -> UploadResourceResult:
_validate_content_type(file, resource_type=resource_type)
max_size = _max_bytes(resource_type, max_bytes)
log_upload_resource_event(
event_type=UploadResourceEventEnum.UPLOAD_START.value,
module=module,
user_id=current_user.id,
detail={"filename": file.filename, "content_type": file.content_type, "resource_type": resource_type},
)
temp_path: str | None = None
final_path: Path | None = None
try:
temp_path, size = await _save_to_temp(file, max_bytes=max_size)
await assert_upload_capacity_available(db, user=current_user, file_size_bytes=size)
log_upload_resource_event(
event_type=UploadResourceEventEnum.UPLOAD_CAPACITY_CHECKED.value,
module=module,
user_id=current_user.id,
detail={"file_size_bytes": size},
)
final_path, url, safe_name = build_upload_destination(
module=module,
resource_type=resource_type,
user_id=current_user.id,
original_filename=file.filename,
gen_type=gen_type,
)
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(temp_path, final_path)
temp_path = None
resource = await record_external_upload_resource(
db,
user_id=current_user.id,
module=module,
resource_type=resource_type,
resource_url=url,
storage_path=str(final_path),
file_size_bytes=size,
file_name=safe_name,
mime_type=file.content_type,
duration_seconds=_duration(duration_seconds),
duration_source=UploadResourceDurationSourceEnum.CLIENT.value if _duration(duration_seconds) is not None else None,
bind_status=UploadResourceBindStatusEnum.PENDING.value,
delete_policy=UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
created_by=UploadResourceCreatedByEnum.API.value,
metadata={"original_filename": file.filename, "client_duration_seconds": duration_seconds},
)
log_upload_resource_event(
event_type=UploadResourceEventEnum.UPLOAD_DB_RECORDED.value,
module=module,
user_id=current_user.id,
resource_id=resource.id,
detail={"url": url, "storage_path": str(final_path), "file_size_bytes": size},
)
return UploadResourceResult(
resource_id=resource.id,
url=url,
filename=file.filename or safe_name,
resource_type=resource_type,
module=module,
file_size_bytes=size,
duration_seconds=resource.duration_seconds,
)
except Exception as exc:
if temp_path:
try:
os.remove(temp_path)
except OSError:
pass
if final_path and final_path.exists():
try:
final_path.unlink()
except OSError:
pass
log_upload_resource_event(
event_type=UploadResourceEventEnum.UPLOAD_FAILED.value,
module=module,
user_id=current_user.id,
detail={"filename": file.filename, "resource_type": resource_type},
exc=exc,
)
raise
async def delete_unbound_upload_resource(
db: AsyncSession,
*,
user: User,
url: str,
) -> dict[str, Any]:
storage_path = upload_url_to_storage_path(url)
if not storage_path:
raise HTTPException(status_code=400, detail="无效的文件路径")
result = await db.execute(
select(UploadResource)
.where(
UploadResource.storage_path == storage_path,
UploadResource.user_id == user.id,
UploadResource.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
resource = result.scalar_one_or_none()
if not resource:
# 兼容历史未补录文件:仍然要求 URL 中包含用户 ID。
# 真实文件删除必须等 API 主事务 commit 成功后由 file_cleanup_service 执行。
if user.id not in url:
raise HTTPException(status_code=403, detail="无权删除此文件")
return {
"message": "ok",
"resource_id": None,
"deleted": True,
"capacity_released": False,
"released_size_bytes": 0,
"_pending_physical_delete_resource_ids": [],
"_legacy_pending_delete_paths": [storage_path],
}
if resource.bind_status == UploadResourceBindStatusEnum.BOUND.value or resource.source_id or resource.delete_policy != UploadResourceDeletePolicyEnum.USER_DELETABLE.value:
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_REJECTED_BOUND.value,
module=resource.module,
user_id=user.id,
resource_id=resource.id,
source_model=resource.source_model,
source_id=resource.source_id,
event_status="rejected",
message="已绑定模块业务记录的上传资源不允许单独删除",
)
raise HTTPException(status_code=400, detail="该文件已被模块任务使用,不能单独删除,请删除对应模块记录后自动释放空间。")
now = datetime.now(timezone.utc)
resource_id = resource.id
storage_path_snapshot = resource.storage_path
file_size_snapshot = int(resource.file_size_bytes or 0)
module_snapshot = resource.module
resource.deleted_at = now
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
resource.file_delete_error = None
released = await release_upload_resource_capacity(db, resource, released_at=now)
await db.flush()
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_MARKED_PENDING.value,
module=module_snapshot,
user_id=user.id,
resource_id=resource_id,
detail={
"url": url,
"storage_path": storage_path_snapshot,
"file_size_bytes": file_size_snapshot,
"capacity_released": released,
"file_delete_status": UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
},
)
return {
"message": "ok",
"resource_id": resource_id,
"deleted": True,
"capacity_released": released,
"released_size_bytes": file_size_snapshot if released else 0,
"_pending_physical_delete_resource_ids": [resource_id],
"_legacy_pending_delete_paths": [],
}
@@ -0,0 +1,136 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Iterable
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceFileDeleteStatusEnum
from app.models.upload_resource import UploadResource
from app.models.user import User
from app.schemas.upload_resource import (
UploadResourceCleanupOut,
UploadResourceHistoryBatchDeleteOut,
)
from app.services.upload_resource.accounting_service import release_upload_resource_capacity
from app.services.upload_resource.file_cleanup_service import cleanup_upload_resource_files_after_commit
from app.services.upload_resource.history_service import assert_upload_resource_history_ids_visible
from app.services.upload_resource.log_service import log_upload_resource_event
MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT = 30
def normalize_upload_resource_ids(values: Iterable[str | None]) -> list[str]:
ids = [str(value).strip() for value in values if str(value or "").strip()]
if not ids:
raise HTTPException(status_code=400, detail="resource_ids 不能为空")
if len(ids) > MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT:
raise HTTPException(status_code=400, detail=f"单次最多删除 {MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT} 条上传素材")
if len(ids) != len(set(ids)):
raise HTTPException(status_code=400, detail="resource_ids 不允许重复")
return ids
def _cleanup_out(stats: dict[str, int] | None) -> UploadResourceCleanupOut:
stats = stats or {}
return UploadResourceCleanupOut(
matched=int(stats.get("matched", 0) or 0),
deleted=int(stats.get("deleted", 0) or 0),
missing=int(stats.get("missing", 0) or 0),
failed=int(stats.get("failed", 0) or 0),
legacy_deleted=int(stats.get("legacy_deleted", 0) or 0),
legacy_missing=int(stats.get("legacy_missing", 0) or 0),
legacy_failed=int(stats.get("legacy_failed", 0) or 0),
)
async def mark_upload_resource_history_deleted(
db: AsyncSession,
*,
current_user: User,
resource_ids: Iterable[str | None],
) -> UploadResourceHistoryBatchDeleteOut:
"""主事务内软删上传历史素材并释放容量,不删除真实文件。"""
ids = normalize_upload_resource_ids(resource_ids)
user_id = str(current_user.id)
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_BATCH_START.value,
user_id=user_id,
detail={"requested_ids": ids, "requested_count": len(ids)},
)
resources = await assert_upload_resource_history_ids_visible(db, user_id=user_id, resource_ids=ids)
now = datetime.now(timezone.utc)
# 先抽 primitive 快照,commit/rollback 后日志不再碰 ORM,规避懒加载失效风险。
snapshots = [
{
"id": resource.id,
"user_id": resource.user_id,
"module": resource.module,
"resource_type": resource.resource_type,
"resource_url": resource.resource_url,
"storage_path": resource.storage_path,
"file_size_bytes": int(resource.file_size_bytes or 0),
}
for resource in resources
]
released_size = 0
released_ids: list[str] = []
for resource in resources:
resource.deleted_at = now
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
resource.file_delete_error = None
released = await release_upload_resource_capacity(db, resource, released_at=now)
if released:
released_size += int(resource.file_size_bytes or 0)
released_ids.append(resource.id)
await db.flush()
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_BATCH_MARKED_PENDING.value,
user_id=user_id,
detail={
"requested_ids": ids,
"deleted_ids": ids,
"released_ids": released_ids,
"released_size_bytes": released_size,
"snapshots": snapshots,
"file_delete_status": UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
},
)
return UploadResourceHistoryBatchDeleteOut(
message="删除成功",
requested_count=len(ids),
deleted_count=len(ids),
requested_ids=ids,
deleted_ids=ids,
released_size_bytes=released_size,
cleanup=UploadResourceCleanupOut(),
)
async def cleanup_upload_resource_history_files(
db: AsyncSession,
*,
result: UploadResourceHistoryBatchDeleteOut,
current_user: User,
) -> UploadResourceHistoryBatchDeleteOut:
"""主事务 commit 成功后清理真实文件,失败不回滚主删除。"""
stats = await cleanup_upload_resource_files_after_commit(db, resource_ids=result.deleted_ids)
result.cleanup = _cleanup_out(stats)
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_BATCH_CLEANUP_SUCCESS.value,
user_id=str(current_user.id),
detail={
"requested_ids": result.requested_ids,
"deleted_ids": result.deleted_ids,
"cleanup": result.cleanup.model_dump(),
},
)
return result
@@ -0,0 +1,204 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceFileDeleteStatusEnum
from app.models.upload_resource import UploadResource
from app.services.upload_resource.log_service import log_upload_resource_event, log_upload_resource_exception
from app.services.upload_resource.path_resolver import normalize_storage_path
def _clean_ids(values: Iterable[str | None] | None) -> list[str]:
if not values:
return []
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
def _clean_paths(values: Iterable[str | None] | None) -> list[str]:
if not values:
return []
cleaned: list[str] = []
for value in values:
if not value:
continue
try:
cleaned.append(normalize_storage_path(value))
except Exception:
cleaned.append(str(value))
return list(dict.fromkeys(cleaned))
def _short_error(exc: BaseException) -> str:
text = str(exc) or exc.__class__.__name__
return text[:2000]
async def cleanup_upload_resource_files_after_commit(
db: AsyncSession,
*,
resource_ids: Iterable[str | None] | None = None,
legacy_paths: Iterable[str | None] | None = None,
) -> dict[str, int]:
"""API 主事务 commit 成功后清理 UploadResource 真实文件。
这里不负责业务删除事务,不做 rollback;调用方如需持久化清理状态,
应在本函数返回后由 API 层再次 commit。
"""
ids = _clean_ids(resource_ids)
paths = _clean_paths(legacy_paths)
stats = {
"matched": 0,
"deleted": 0,
"missing": 0,
"failed": 0,
"legacy_deleted": 0,
"legacy_missing": 0,
"legacy_failed": 0,
}
if ids:
try:
result = await db.execute(
select(UploadResource)
.where(
UploadResource.id.in_(ids),
UploadResource.deleted_at.is_not(None),
UploadResource.physical_deleted_at.is_(None),
UploadResource.file_delete_status.in_([
UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value,
]),
)
.with_for_update()
)
resources = list(result.scalars().all())
except Exception as exc: # noqa: BLE001
log_upload_resource_exception(
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
message="UploadResource 真实文件清理批量查询失败",
resource_ids=ids,
detail={"stage": "query_resources", "resource_ids_count": len(ids)},
exc=exc,
)
raise
stats["matched"] = len(resources)
for resource in resources:
now = datetime.now(timezone.utc)
try:
path = Path(resource.storage_path)
if path.exists():
path.unlink()
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.DELETED.value
stats["deleted"] += 1
event = UploadResourceEventEnum.DELETE_PHYSICAL_SUCCESS.value
else:
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.MISSING.value
stats["missing"] += 1
event = UploadResourceEventEnum.DELETE_PHYSICAL_MISSING.value
resource.physical_deleted_at = now
resource.file_delete_error = None
log_upload_resource_event(
event_type=event,
module=resource.module,
user_id=resource.user_id,
resource_id=resource.id,
source_model=resource.source_model,
source_id=resource.source_id,
detail={"storage_path": resource.storage_path, "file_delete_status": resource.file_delete_status},
)
except Exception as exc: # noqa: BLE001
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value
resource.file_delete_error = _short_error(exc)
stats["failed"] += 1
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_PHYSICAL_FAILED.value,
module=resource.module,
user_id=resource.user_id,
resource_id=resource.id,
source_model=resource.source_model,
source_id=resource.source_id,
detail={"storage_path": resource.storage_path},
exc=exc,
)
if resources:
try:
await db.flush()
except Exception as exc: # noqa: BLE001
log_upload_resource_exception(
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
message="UploadResource 真实文件清理状态 flush 失败",
resource_ids=[resource.id for resource in resources],
detail={"stage": "flush_cleanup_status"},
exc=exc,
)
raise
for raw_path in paths:
try:
path = Path(raw_path)
if path.exists():
path.unlink()
stats["legacy_deleted"] += 1
else:
stats["legacy_missing"] += 1
except Exception as exc: # noqa: BLE001
stats["legacy_failed"] += 1
log_upload_resource_event(
event_type=UploadResourceEventEnum.DELETE_PHYSICAL_FAILED.value,
detail={"legacy_path": raw_path},
exc=exc,
)
return stats
async def cleanup_pending_upload_resource_files(db: AsyncSession, *, limit: int = 500) -> dict[str, int]:
limit = max(1, int(limit or 500))
log_upload_resource_event(
event_type=UploadResourceEventEnum.CLEANUP_PENDING_START.value,
detail={"limit": limit},
)
try:
result = await db.execute(
select(UploadResource.id)
.where(
UploadResource.deleted_at.is_not(None),
UploadResource.physical_deleted_at.is_(None),
UploadResource.file_delete_status.in_([
UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value,
]),
)
.order_by(UploadResource.updated_at.asc())
.limit(limit)
)
ids = list(result.scalars().all())
except Exception as exc: # noqa: BLE001
log_upload_resource_exception(
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
message="UploadResource pending 清理查询失败",
detail={"stage": "query_pending_cleanup", "limit": limit},
exc=exc,
)
raise
try:
stats = await cleanup_upload_resource_files_after_commit(db, resource_ids=ids)
except Exception as exc: # noqa: BLE001
log_upload_resource_exception(
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
message="UploadResource pending 真实文件补偿清理失败",
resource_ids=ids,
detail={"stage": "cleanup_pending", "limit": limit},
exc=exc,
)
raise
log_upload_resource_event(
event_type=UploadResourceEventEnum.CLEANUP_PENDING_FINISHED.value,
detail={"ids": len(ids), **stats},
)
return stats
@@ -0,0 +1,305 @@
from __future__ import annotations
from datetime import date
from typing import Iterable
from fastapi import HTTPException
from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.upload_resource import (
UPLOAD_RESOURCE_MODULE_LABELS,
UPLOAD_RESOURCE_TYPE_LABELS,
UploadResourceBindStatusEnum,
UploadResourceDeletePolicyEnum,
UploadResourceEventEnum,
UploadResourceTypeEnum,
)
from app.models.upload_resource import UploadResource
from app.schemas.upload_resource import (
UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES,
UploadResourceHistoryDayGroupOut,
UploadResourceHistoryDayItemsOut,
UploadResourceHistoryGroupedOut,
UploadResourceHistoryItemOut,
UploadResourceMediaReferenceOut,
)
from app.services.upload_resource.log_service import log_upload_resource_event
HISTORY_SOURCE = "upload_resource"
HISTORY_SOURCE_LABEL = "历史上传素材"
DEFAULT_GROUP_ITEMS_LIMIT = 10
MAX_GROUP_PAGE_SIZE = 10
MAX_DAY_PAGE_SIZE = 100
def _normalize_resource_type(resource_type: str | None) -> str | None:
if resource_type is None or str(resource_type).strip() == "":
return None
value = str(resource_type).strip()
if value not in UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES:
raise HTTPException(status_code=400, detail="resource_type 仅支持 image、video、audio")
return value
def _page(value: int, *, default: int = 1) -> int:
return max(int(value or default), 1)
def _page_size(value: int, *, default: int, max_value: int) -> int:
return min(max(int(value or default), 1), max_value)
def _date_text(value: object) -> str:
if isinstance(value, date):
return value.isoformat()
return str(value)[:10]
def _display_url(resource: UploadResource) -> str:
return str(resource.resource_url or "")
def _resource_type(resource: UploadResource) -> str:
return str(resource.resource_type or "")
def _item_to_out(resource: UploadResource) -> UploadResourceHistoryItemOut:
resource_type = _resource_type(resource)
display_url = _display_url(resource)
file_name = resource.file_name or display_url.rsplit("/", 1)[-1] or resource.id
duration = float(resource.duration_seconds) if resource.duration_seconds is not None else None
media_reference = UploadResourceMediaReferenceOut(
name=file_name,
type=resource_type, # type: ignore[arg-type]
url=display_url,
label="",
duration=duration if resource_type in {UploadResourceTypeEnum.VIDEO.value, UploadResourceTypeEnum.AUDIO.value} else None,
source=HISTORY_SOURCE,
upload_resource_id=resource.id,
)
return UploadResourceHistoryItemOut(
id=resource.id,
source_type=HISTORY_SOURCE,
history_source=HISTORY_SOURCE,
history_source_label=HISTORY_SOURCE_LABEL,
module=resource.module,
module_label=UPLOAD_RESOURCE_MODULE_LABELS.get(resource.module, resource.module),
resource_type=resource_type, # type: ignore[arg-type]
resource_type_label=UPLOAD_RESOURCE_TYPE_LABELS.get(resource_type, resource_type),
resource_url=display_url,
display_url=display_url,
preview_url=display_url,
image_url=display_url if resource_type == UploadResourceTypeEnum.IMAGE.value else None,
video_url=display_url if resource_type == UploadResourceTypeEnum.VIDEO.value else None,
audio_url=display_url if resource_type == UploadResourceTypeEnum.AUDIO.value else None,
file_name=resource.file_name,
file_ext=resource.file_ext,
mime_type=resource.mime_type,
file_size_bytes=int(resource.file_size_bytes or 0),
duration_seconds=duration,
width=int(resource.width) if resource.width is not None else None,
height=int(resource.height) if resource.height is not None else None,
bind_status=resource.bind_status,
delete_policy=resource.delete_policy,
deletable=True,
media_reference=media_reference,
created_at=resource.created_at,
updated_at=resource.updated_at,
)
def _base_filters(*, user_id: str, resource_type: str | None = None, keyword: str | None = None) -> list:
filters = [
UploadResource.user_id == user_id,
UploadResource.deleted_at.is_(None),
UploadResource.source_model.is_(None),
UploadResource.source_id.is_(None),
UploadResource.bind_status == UploadResourceBindStatusEnum.PENDING.value,
UploadResource.delete_policy == UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
UploadResource.resource_type.in_(list(UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES)),
]
if resource_type:
filters.append(UploadResource.resource_type == resource_type)
if keyword and keyword.strip():
pattern = f"%{keyword.strip()}%"
filters.append(
or_(
UploadResource.file_name.ilike(pattern),
UploadResource.resource_url.ilike(pattern),
)
)
return filters
async def list_upload_resource_history_grouped_days(
db: AsyncSession,
*,
user_id: str,
resource_type: str | None = None,
page: int = 1,
page_size: int = 10,
keyword: str | None = None,
) -> UploadResourceHistoryGroupedOut:
"""按上传日期分组查询可展示/可复用/可删除的 UploadResource。"""
resource_type = _normalize_resource_type(resource_type)
page = _page(page)
page_size = _page_size(page_size, default=10, max_value=MAX_GROUP_PAGE_SIZE)
filters = _base_filters(user_id=user_id, resource_type=resource_type, keyword=keyword)
date_expr = func.date(UploadResource.created_at).label("generated_date")
log_upload_resource_event(
event_type=UploadResourceEventEnum.HISTORY_LIST_START.value,
user_id=user_id,
detail={"resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword},
)
total_days_stmt = select(func.count()).select_from(
select(date_expr).where(and_(*filters)).group_by(date_expr).subquery()
)
total_days = int((await db.execute(total_days_stmt)).scalar_one() or 0)
group_stmt = (
select(date_expr, func.count(UploadResource.id).label("total"))
.where(and_(*filters))
.group_by(date_expr)
.order_by(desc(date_expr))
.offset((page - 1) * page_size)
.limit(page_size)
)
group_rows = (await db.execute(group_stmt)).mappings().all()
date_values = [row["generated_date"] for row in group_rows]
date_texts = [_date_text(value) for value in date_values]
items_by_date: dict[str, list[UploadResourceHistoryItemOut]] = {date_text: [] for date_text in date_texts}
if date_texts:
rn = func.row_number().over(
partition_by=func.date(UploadResource.created_at),
order_by=(UploadResource.created_at.desc(), UploadResource.id.desc()),
).label("rn")
id_subq = (
select(
UploadResource.id.label("id"),
func.date(UploadResource.created_at).label("generated_date"),
rn,
)
.where(and_(*filters), func.date(UploadResource.created_at).in_(date_values))
.subquery()
)
item_stmt = (
select(UploadResource)
.join(id_subq, UploadResource.id == id_subq.c.id)
.where(id_subq.c.rn <= DEFAULT_GROUP_ITEMS_LIMIT)
.order_by(id_subq.c.generated_date.desc(), UploadResource.created_at.desc(), UploadResource.id.desc())
)
resources = list((await db.execute(item_stmt)).scalars().all())
for resource in resources:
items_by_date.setdefault(_date_text(resource.created_at.date()), []).append(_item_to_out(resource))
groups = [
UploadResourceHistoryDayGroupOut(
generated_date=_date_text(row["generated_date"]),
total=int(row["total"] or 0),
page=1,
items=items_by_date.get(_date_text(row["generated_date"]), []),
)
for row in group_rows
]
log_upload_resource_event(
event_type=UploadResourceEventEnum.HISTORY_LIST_SUCCESS.value,
user_id=user_id,
detail={"resource_type": resource_type, "page": page, "page_size": page_size, "groups": len(groups)},
)
return UploadResourceHistoryGroupedOut(total_days=total_days, page=page, page_size=page_size, groups=groups)
async def list_upload_resource_history_day_items(
db: AsyncSession,
*,
user_id: str,
generated_date: str,
resource_type: str | None = None,
page: int = 1,
page_size: int = 20,
keyword: str | None = None,
) -> UploadResourceHistoryDayItemsOut:
"""查询指定上传日期下的 UploadResource 历史素材。"""
try:
target_date = date.fromisoformat(str(generated_date))
except ValueError:
raise HTTPException(status_code=400, detail="generated_date 必须是 YYYY-MM-DD 格式")
resource_type = _normalize_resource_type(resource_type)
page = _page(page)
page_size = _page_size(page_size, default=20, max_value=MAX_DAY_PAGE_SIZE)
filters = _base_filters(user_id=user_id, resource_type=resource_type, keyword=keyword)
filters.append(func.date(UploadResource.created_at) == target_date)
log_upload_resource_event(
event_type=UploadResourceEventEnum.HISTORY_DAY_LIST_START.value,
user_id=user_id,
detail={"generated_date": generated_date, "resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword},
)
total_stmt = select(func.count(UploadResource.id)).where(and_(*filters))
total = int((await db.execute(total_stmt)).scalar_one() or 0)
item_stmt = (
select(UploadResource)
.where(and_(*filters))
.order_by(UploadResource.created_at.desc(), UploadResource.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
resources = list((await db.execute(item_stmt)).scalars().all())
items = [_item_to_out(resource) for resource in resources]
log_upload_resource_event(
event_type=UploadResourceEventEnum.HISTORY_DAY_LIST_SUCCESS.value,
user_id=user_id,
detail={"generated_date": generated_date, "resource_type": resource_type, "page": page, "page_size": page_size, "total": total},
)
return UploadResourceHistoryDayItemsOut(
generated_date=target_date.isoformat(),
total=total,
page=page,
page_size=page_size,
items=items,
)
async def assert_upload_resource_history_ids_visible(
db: AsyncSession,
*,
user_id: str,
resource_ids: Iterable[str],
) -> list[UploadResource]:
ids = [str(value).strip() for value in resource_ids if str(value or "").strip()]
if not ids:
raise HTTPException(status_code=400, detail="resource_ids 不能为空")
stmt = (
select(UploadResource)
.where(
UploadResource.id.in_(ids),
*_base_filters(user_id=user_id),
)
.with_for_update()
)
resources = list((await db.execute(stmt)).scalars().all())
found = {resource.id for resource in resources}
missing = [resource_id for resource_id in ids if resource_id not in found]
if missing:
raise HTTPException(
status_code=404,
detail={
"message": "上传素材不存在、已删除、已绑定模块业务记录或无权操作",
"missing_ids": missing,
"missing_count": len(missing),
},
)
return resources
@@ -0,0 +1,136 @@
from __future__ import annotations
import logging
import traceback
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.operation_log_service import build_exception_detail, log_operation_event
DOMAIN = "upload_resource"
logger = logging.getLogger("videogen.upload_resource")
def _exception_payload(exc: BaseException | None, detail: dict[str, Any] | None = None) -> dict[str, Any]:
payload = dict(detail or {})
if exc is not None:
payload.setdefault("error_type", exc.__class__.__name__)
payload.setdefault("error_message", str(exc))
payload.setdefault("traceback", "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
return payload
def log_upload_resource_event(
*,
event_type: str,
module: str | None = None,
user_id: str | None = None,
resource_id: str | None = None,
source_model: str | None = None,
source_id: str | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
exc: BaseException | None = None,
event_status: str = "success",
) -> None:
"""Write an upload-resource operation log.
日志失败不能影响主业务流程;失败时降级到标准 logger。
detail 只接收普通 dict,禁止传 ORM 对象,避免 commit/rollback 后懒加载异常。
"""
payload = dict(detail or {})
if resource_id:
payload["resource_id"] = resource_id
if source_model:
payload["source_model"] = source_model
if source_id:
payload["source_id"] = source_id
if exc is not None:
payload = build_exception_detail(exc, payload)
error = error or str(exc)
event_status = "failed"
try:
log_operation_event(
domain=DOMAIN,
event_type=event_type,
module=module or DOMAIN,
event_status=event_status,
source="service",
user_id=user_id,
message=message,
detail=payload,
error=error,
)
except Exception: # noqa: BLE001 - 日志降级,不能影响主流程
logger.exception(
"upload_resource operation log failed: event_type=%s user_id=%s resource_id=%s detail=%s error=%s",
event_type,
user_id,
resource_id,
payload,
error,
)
def log_upload_resource_exception(
*,
event_type: str,
message: str | None = None,
user_id: str | None = None,
resource_id: str | None = None,
resource_ids: list[str] | None = None,
module: str | None = None,
source_model: str | None = None,
source_id: str | None = None,
detail: dict[str, Any] | None = None,
exc: BaseException | None = None,
) -> None:
"""统一记录 UploadResource 相关异常。"""
payload = _exception_payload(exc, detail)
if resource_ids is not None:
payload["resource_ids"] = list(resource_ids)
log_upload_resource_event(
event_type=event_type,
module=module,
user_id=user_id,
resource_id=resource_id,
source_model=source_model,
source_id=source_id,
message=message,
detail=payload,
error=str(exc) if exc else None,
event_status="failed",
)
async def safe_rollback_with_log(
db: AsyncSession,
*,
event_type: str,
message: str | None = None,
user_id: str | None = None,
module: str | None = None,
detail: dict[str, Any] | None = None,
original_exc: BaseException | None = None,
) -> None:
"""Rollback with rollback-failure logging.
只记录 rollback 自身异常;不吞掉主异常,调用方继续 raise 原始异常。
"""
try:
await db.rollback()
except Exception as rollback_exc: # noqa: BLE001
payload = dict(detail or {})
if original_exc is not None:
payload["original_error_type"] = original_exc.__class__.__name__
payload["original_error_message"] = str(original_exc)
log_upload_resource_exception(
event_type=event_type,
message=message or "数据库回滚失败",
user_id=user_id,
module=module,
detail=payload,
exc=rollback_exc,
)
@@ -0,0 +1,218 @@
from __future__ import annotations
import os
import re
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
from urllib.parse import urlsplit
from app.config import settings
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTypeEnum
COMMON_IMAGE_RE = re.compile(r"^images/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_img_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
COMMON_VIDEO_RE = re.compile(r"^videos/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/audio_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac"}
@dataclass(slots=True)
class ParsedUploadPath:
storage_path: str
resource_url: str
file_name: str
file_ext: str
file_size_bytes: int
module: str
resource_type: str
user_id: str | None
created_at: datetime | None
source_model: str | None = None
source_id: str | None = None
skip_reason: str | None = None
def upload_root() -> Path:
return Path(settings.UPLOAD_LOCAL_PATH).resolve()
def to_abs_path(path: str | os.PathLike[str]) -> Path:
p = Path(path)
return p if p.is_absolute() else Path.cwd() / p
def normalize_storage_path(path: str | os.PathLike[str]) -> str:
return str(to_abs_path(path).resolve())
def upload_url_to_storage_path(url: str | None) -> str | None:
if not url:
return None
value = str(url).strip()
if not value:
return None
value = urlsplit(value).path or value
if not value.startswith("/uploads/"):
return None
rel = value[len("/uploads/"):].lstrip("/")
if not rel or ".." in Path(rel).parts:
return None
return str((upload_root() / rel).resolve())
def storage_path_to_upload_url(path: str | os.PathLike[str]) -> str:
abs_path = to_abs_path(path).resolve()
rel = abs_path.relative_to(upload_root()).as_posix()
return f"/uploads/{rel}"
def _parse_created_at(parts: dict[str, str], fallback: datetime | None = None) -> datetime | None:
ymd = parts.get("ymd")
hms = parts.get("hms")
try:
if ymd and hms:
return datetime.strptime(f"{ymd}_{hms}", "%Y%m%d_%H%M%S")
return datetime(int(parts["year"]), int(parts["month"]), int(parts["day"]))
except Exception:
return fallback
def _file_size(path: Path) -> int:
try:
return int(path.stat().st_size)
except OSError:
return 0
def _base(path: Path, module: str, resource_type: str, user_id: str | None, created_at: datetime | None) -> ParsedUploadPath:
return ParsedUploadPath(
storage_path=normalize_storage_path(path),
resource_url=storage_path_to_upload_url(path),
file_name=path.name,
file_ext=path.suffix.lower().lstrip("."),
file_size_bytes=_file_size(path),
module=module,
resource_type=resource_type,
user_id=user_id,
created_at=created_at,
)
def parse_upload_path(path: str | os.PathLike[str], *, include_legacy: bool = False) -> ParsedUploadPath | None:
abs_path = to_abs_path(path).resolve()
if not abs_path.is_file():
return None
try:
rel = abs_path.relative_to(upload_root()).as_posix()
except ValueError:
return None
ignored_prefixes = ("home_materials/",)
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
return ParsedUploadPath(
storage_path=normalize_storage_path(abs_path),
resource_url="",
file_name=abs_path.name,
file_ext=abs_path.suffix.lower().lstrip("."),
file_size_bytes=_file_size(abs_path),
module=UploadResourceModuleEnum.COMMON.value,
resource_type="unknown",
user_id=None,
created_at=None,
skip_reason="ignored_path",
)
for pattern, rtype in (
(COMMON_IMAGE_RE, UploadResourceTypeEnum.IMAGE.value),
(COMMON_VIDEO_RE, UploadResourceTypeEnum.VIDEO.value),
(COMMON_AUDIO_RE, UploadResourceTypeEnum.AUDIO.value),
):
m = pattern.match(rel)
if m:
d = m.groupdict()
return _base(abs_path, UploadResourceModuleEnum.COMMON.value, rtype, d.get("user_id"), _parse_created_at(d))
m = MODULE_RE.match(rel)
if m:
d = m.groupdict()
kind = d.get("kind")
rtype = UploadResourceTypeEnum.IMAGE.value if kind == "images" else UploadResourceTypeEnum.VIDEO.value
return _base(abs_path, d["module"], rtype, d.get("user_id"), _parse_created_at(d))
m = SHOT_SEGMENT_RE.match(rel)
if m:
d = m.groupdict()
parsed = _base(abs_path, UploadResourceModuleEnum.SHOT_REPLICATE.value, UploadResourceTypeEnum.SHOT_SEGMENT.value, None, _parse_created_at(d))
parsed.source_model = "ShotReplicateSegment"
parsed.source_id = d["segment_id"]
return parsed
if include_legacy:
m = LEGACY_GEN_RE.match(rel)
if m:
d = m.groupdict()
ext = abs_path.suffix.lower()
if ext in IMAGE_EXTS:
rtype = UploadResourceTypeEnum.IMAGE.value
elif ext in VIDEO_EXTS:
rtype = UploadResourceTypeEnum.VIDEO.value
else:
return None
try:
created = datetime.fromtimestamp(abs_path.stat().st_mtime)
except OSError:
created = None
return _base(abs_path, UploadResourceModuleEnum.COMMON.value, rtype, d.get("user_id"), created)
return ParsedUploadPath(
storage_path=normalize_storage_path(abs_path),
resource_url="",
file_name=abs_path.name,
file_ext=abs_path.suffix.lower().lstrip("."),
file_size_bytes=_file_size(abs_path),
module=UploadResourceModuleEnum.COMMON.value,
resource_type="unknown",
user_id=None,
created_at=None,
skip_reason="unmatched_path",
)
def iter_files(root: str | os.PathLike[str]) -> Iterable[Path]:
base = to_abs_path(root).resolve()
if not base.exists():
return []
return (p for p in base.rglob("*") if p.is_file())
def build_upload_destination(*, module: str, resource_type: str, user_id: str, original_filename: str | None, gen_type: str = "video") -> tuple[Path, str, str]:
now = datetime.now()
date_dir = now.strftime("%Y/%m/%d")
ext = Path(original_filename or "").suffix.lower()
if not ext:
ext = ".mp4" if resource_type == UploadResourceTypeEnum.VIDEO.value else ".mp3" if resource_type == UploadResourceTypeEnum.AUDIO.value else ".png"
timestamp = now.strftime("%Y%m%d_%H%M%S")
suffix = uuid.uuid4().hex[:8]
if resource_type == UploadResourceTypeEnum.AUDIO.value:
filename = f"audio_ref_{user_id}_{timestamp}_{suffix}{ext}"
rel_dir = Path("audios") / date_dir
elif resource_type == UploadResourceTypeEnum.VIDEO.value:
filename = f"video_ref_{user_id}_{timestamp}_{suffix}{ext}"
rel_dir = Path("videos") / date_dir if module == UploadResourceModuleEnum.COMMON.value else Path(module) / "videos" / date_dir
else:
filename = f"{gen_type}_img_{user_id}_{timestamp}_{suffix}{ext}"
rel_dir = Path("images") / date_dir if module == UploadResourceModuleEnum.COMMON.value else Path(module) / "images" / date_dir
dir_path = upload_root() / rel_dir
file_path = dir_path / filename
url = f"/uploads/{(rel_dir / filename).as_posix()}"
return file_path, url, filename
@@ -41,6 +41,7 @@ from app.services.shot_video_analysis_service import analyze_video_for_shot_spli
from app.services.generation_billing_service import charge_shot_video_analysis_usage
from app.services.shot_video_split_service import split_video_segment_async
from app.services.upload_video_asset_service import validate_split_range
from app.services.upload_resource import record_shot_segment_upload_resource
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
@@ -467,6 +468,13 @@ async def _run_split_one_segment(segment_id: str) -> None:
return
segment.segment_video_url = split_result.url
segment.segment_video_path = split_result.path
await record_shot_segment_upload_resource(
db,
segment=segment,
storage_path=split_result.path,
resource_url=split_result.url,
file_size_bytes=split_result.file_size_bytes,
)
segment.split_status = ShotSplitStatusEnum.COMPLETED.value
segment.split_completed_at = _now()
segment.split_lease_until = None
+169 -12
View File
@@ -116,20 +116,30 @@ export async function optimizePrompt(
});
}
export async function uploadAudio(file: File): Promise<{ url: string; filename: string }> {
export interface UploadResourceResult {
url: string;
filename: string;
type?: string;
module?: string;
resource_id?: string;
file_size_bytes?: number;
duration_seconds?: number | null;
}
export async function uploadAudio(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio`, {
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio${query}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('图片上传失败');
const data = await res.json();
return { url: data.url, filename: data.filename };
if (!res.ok) throw new Error('音频上传失败');
return await res.json();
}
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
export async function uploadImage(file: File): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
@@ -139,21 +149,74 @@ export async function uploadImage(file: File): Promise<{ url: string; filename:
body: form,
});
if (!res.ok) throw new Error('图片上传失败');
const data = await res.json();
return { url: data.url, filename: data.filename };
return await res.json();
}
export async function uploadVideo(file: File): Promise<{ url: string; filename: string }> {
export async function uploadVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-video`, {
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-video${query}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('视频上传失败');
const data = await res.json();
return { url: data.url, filename: data.filename };
return await res.json();
}
export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/hot-opening-replications/upload-image`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('图片上传失败');
return await res.json();
}
export async function uploadHotOpeningVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/hot-opening-replications/upload-video${query}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('视频上传失败');
return await res.json();
}
export async function uploadShotReplicateImage(file: File): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/shot-replications/upload-image`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('图片上传失败');
return await res.json();
}
export async function uploadShotReplicateVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/shot-replications/upload-video${query}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('视频上传失败');
return await res.json();
}
export async function deleteUpload(url: string): Promise<void> {
await api.post(`/generation-records/delete-file?url=${encodeURIComponent(url)}`);
@@ -741,6 +804,100 @@ export async function deleteResourcesMaterial(params:any): Promise<any> {
return api.delete(`/generation-ai/history/batch`, params);
}
// ── UploadResource History ───────────────────────────────
export interface UploadResourceMediaReferenceOut {
name?: string | null;
type: 'image' | 'video' | 'audio';
url: string;
label?: string | null;
duration?: number | null;
source: 'upload_resource';
uploadResourceId: string;
}
export interface UploadResourceHistoryItemOut {
id: string;
sourceType: 'upload_resource';
historySource: 'upload_resource';
historySourceLabel: string;
module: string;
moduleLabel: string;
resourceType: 'image' | 'video' | 'audio';
resourceTypeLabel: string;
resourceUrl: string;
displayUrl: string;
previewUrl: string;
imageUrl?: string | null;
videoUrl?: string | null;
audioUrl?: string | null;
fileName?: string | null;
fileExt?: string | null;
mimeType?: string | null;
fileSizeBytes: number;
durationSeconds?: number | null;
width?: number | null;
height?: number | null;
bindStatus: string;
deletePolicy: string;
deletable: boolean;
mediaReference: UploadResourceMediaReferenceOut;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface UploadResourceHistoryDayGroupOut {
generatedDate: string;
total: number;
page: number;
items: UploadResourceHistoryItemOut[];
}
export interface UploadResourceHistoryGroupedOut {
totalDays: number;
page: number;
pageSize: number;
groups: UploadResourceHistoryDayGroupOut[];
}
export interface UploadResourceHistoryDayItemsOut {
generatedDate: string;
total: number;
page: number;
pageSize: number;
items: UploadResourceHistoryItemOut[];
}
export interface UploadResourceHistoryQueryParams {
resourceType?: 'image' | 'video' | 'audio' | '';
page?: number;
pageSize?: number;
keyword?: string;
scene?: 'record' | 'picker' | string;
}
function buildUploadResourceHistoryQuery(params: UploadResourceHistoryQueryParams = {}): string {
const query = new URLSearchParams();
if (params.resourceType) query.set('resource_type', params.resourceType);
if (params.page) query.set('page', String(params.page));
if (params.pageSize) query.set('page_size', String(params.pageSize));
if (params.keyword) query.set('keyword', params.keyword);
if (params.scene) query.set('scene', params.scene);
const qs = query.toString();
return qs ? `?${qs}` : '';
}
export async function getUploadResourceHistory(params: UploadResourceHistoryQueryParams = {}): Promise<UploadResourceHistoryGroupedOut> {
return api.get<UploadResourceHistoryGroupedOut>(`/upload-resources/history${buildUploadResourceHistoryQuery(params)}`);
}
export async function getUploadResourceHistoryItems(generatedDate: string, params: UploadResourceHistoryQueryParams = {}): Promise<UploadResourceHistoryDayItemsOut> {
return api.get<UploadResourceHistoryDayItemsOut>(`/upload-resources/history/${generatedDate}${buildUploadResourceHistoryQuery(params)}`);
}
export async function deleteUploadResourceHistoryBatch(resourceIds: string[]): Promise<any> {
return api.delete('/upload-resources/history/batch', { resource_ids: resourceIds });
}
export async function getPrivatePortraitConfig(): Promise<PrivatePortraitConfig> {
return api.get<PrivatePortraitConfig>('/private-portrait/config');
+12 -40
View File
@@ -1,14 +1,15 @@
import React, { useRef, useState } from 'react';
import { Modal, Tooltip } from 'antd';
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
interface UploadSelectorProps {
children: React.ReactNode;
accept?: string;
onLocalSelect?: (files: File[]) => void;
onHistorySelect?: (items: any[]) => void;
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
@@ -63,12 +64,6 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
setPortraitPickerOpen(true);
};
const confirmHistorySelection = () => {
onHistorySelect?.([]);
setHistoryModalVisible(false);
setModalVisible(false);
};
const options = [
{
key: 'history',
@@ -192,39 +187,16 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
</div>
</Modal>
<Modal
title="选择资产素材"
<UploadResourceHistoryPicker
open={historyModalVisible}
onCancel={() => setHistoryModalVisible(false)}
footer={null}
width="80%"
centered
destroyOnHidden
>
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
<button
onClick={confirmHistorySelection}
style={{
padding: '8px 24px',
borderRadius: 8,
border: 'none',
background: '#8b5cf6',
cursor: 'pointer',
fontSize: 14,
color: '#fff',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
</button>
</div>
</Modal>
onClose={() => setHistoryModalVisible(false)}
onSelect={(items) => {
onHistorySelect?.(items);
setHistoryModalVisible(false);
setModalVisible(false);
}}
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
/>
<PrivatePortraitAssetPicker
open={portraitPickerOpen}
@@ -0,0 +1,270 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AudioOutlined, DeleteOutlined, DownloadOutlined, EyeOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Button, Checkbox, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
import { deleteUpload, deleteUploadResourceHistoryBatch, getUploadResourceHistory, getUploadResourceHistoryItems } from '../../api';
import type { UploadResourceHistoryDayGroup, UploadResourceHistoryItem } from '../../types';
const { Text } = Typography;
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (/^(https?:|data:|blob:)/i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const typeIcon = (type: string) => {
if (type === 'video') return <VideoCameraOutlined />;
if (type === 'audio') return <AudioOutlined />;
return <PictureOutlined />;
};
const typeLabel = (type: string) => {
if (type === 'video') return '视频';
if (type === 'audio') return '音频';
return '图片';
};
const bytesText = (bytes?: number | null) => {
const value = Number(bytes || 0);
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${value} B`;
};
const UploadResourceHistoryPanel: React.FC = () => {
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [groups, setGroups] = useState<UploadResourceHistoryDayGroup[]>([]);
const [totalDays, setTotalDays] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [previewItem, setPreviewItem] = useState<UploadResourceHistoryItem | null>(null);
const allItems = useMemo(() => groups.flatMap((group) => group.items || []), [groups]);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getUploadResourceHistory({
resourceType: resourceType || undefined,
keyword: keyword || undefined,
page,
pageSize,
scene: 'record',
});
setGroups(res.groups || []);
setTotalDays(res.totalDays || 0);
setSelectedIds(new Set());
} catch (error: any) {
message.error(error?.message || '加载历史上传素材失败');
setGroups([]);
setTotalDays(0);
} finally {
setLoading(false);
}
}, [keyword, page, pageSize, resourceType]);
useEffect(() => {
load();
}, [load]);
const toggle = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
const ids = allItems.map((item) => item.id);
setSelectedIds((prev) => (prev.size === ids.length ? new Set() : new Set(ids)));
};
const handleLoadMoreDay = async (group: UploadResourceHistoryDayGroup) => {
const nextPage = (group.page || 1) + 1;
try {
const res = await getUploadResourceHistoryItems(group.generatedDate, {
resourceType: resourceType || undefined,
keyword: keyword || undefined,
page: nextPage,
pageSize: 10,
scene: 'record',
});
setGroups((prev) => prev.map((item) => item.generatedDate === group.generatedDate ? { ...item, page: nextPage, items: [...item.items, ...(res.items || [])] } : item));
} catch (error: any) {
message.error(error?.message || '加载更多失败');
}
};
const handleDeleteOne = (item: UploadResourceHistoryItem) => {
Modal.confirm({
title: '确认删除历史上传素材',
content: `确定删除 ${item.fileName || item.id} 吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteUpload(item.resourceUrl);
message.success('删除成功');
load();
} catch (error: any) {
message.error(error?.message || '删除失败');
}
},
});
};
const handleBatchDelete = () => {
if (selectedIds.size === 0) {
message.warning('请先选择要删除的上传素材');
return;
}
if (selectedIds.size > 30) {
message.warning('一次最多删除 30 条上传素材');
return;
}
const ids = Array.from(selectedIds);
Modal.confirm({
title: '确认批量删除历史上传素材',
content: `确定删除选中的 ${ids.length} 条上传素材吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
const res = await deleteUploadResourceHistoryBatch(ids);
message.success(`删除成功,释放 ${bytesText(res?.releasedSizeBytes || 0)}`);
load();
} catch (error: any) {
message.error(error?.message || '批量删除失败');
}
},
});
};
const handleDownload = (item: UploadResourceHistoryItem) => {
const link = document.createElement('a');
link.href = buildPreviewUrl(item.resourceUrl);
link.download = item.fileName || item.id;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const renderMedia = (item: UploadResourceHistoryItem, size: 'card' | 'preview' = 'card') => {
const url = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
const style = size === 'card'
? { width: '100%', height: 130, objectFit: 'cover' as const }
: { width: '100%', maxHeight: 620, objectFit: 'contain' as const };
if (item.resourceType === 'image') return <img src={url} alt={item.fileName || item.id} style={style} />;
if (item.resourceType === 'video') return <video src={url} controls={size === 'preview'} muted={size === 'card'} style={style} />;
return <audio src={url} controls style={{ width: '100%' }} />;
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap>
<Select
value={resourceType}
style={{ width: 120 }}
options={[
{ label: '全部', value: '' },
{ label: '图片', value: 'image' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
]}
onChange={(value) => {
setResourceType(value);
setPage(1);
}}
/>
<Input
allowClear
value={keyword}
prefix={<SearchOutlined />}
placeholder="搜索文件名或URL"
style={{ width: 240 }}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => {
setPage(1);
load();
}}
/>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Space>
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}> ({selectedIds.size})</Button>
</Space>
</div>
<Spin spinning={loading}>
{groups.length === 0 ? (
<Empty description="暂无可展示的历史上传素材" style={{ padding: '60px 0' }} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
{groups.map((group) => (
<div key={group.generatedDate}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
<Text type="secondary"> {group.total} </Text>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: 16 }}>
{group.items.map((item) => {
const checked = selectedIds.has(item.id);
return (
<div key={item.id} style={{ border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
<div style={{ position: 'relative', background: '#f1f5f9' }}>
{renderMedia(item)}
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
</div>
<div style={{ padding: 12 }}>
<Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text>
<Space size={4} wrap style={{ marginTop: 8 }}>
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
</Space>
<Space style={{ marginTop: 12 }}>
<Button size="small" icon={<EyeOutlined />} onClick={() => setPreviewItem(item)}></Button>
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(item)}></Button>
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteOne(item)}></Button>
</Space>
</div>
</div>
);
})}
</div>
{group.items.length < group.total && (
<div style={{ textAlign: 'center', marginTop: 14 }}>
<Button onClick={() => handleLoadMoreDay(group)}></Button>
</div>
)}
</div>
))}
</div>
)}
</Spin>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
</div>
<Modal open={!!previewItem} title={previewItem?.fileName || '预览'} footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
{previewItem ? renderMedia(previewItem, 'preview') : null}
</Modal>
</div>
);
};
export default UploadResourceHistoryPanel;
@@ -0,0 +1,251 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AudioOutlined, CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Button, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
import { getUploadResourceHistoryItems } from '../../api';
import type { UploadResourceHistoryItem } from '../../types';
const { Text } = Typography;
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
interface UploadResourceHistoryPickerProps {
open: boolean;
onClose: () => void;
onSelect: (items: UploadResourceHistoryItem[]) => void;
allowedTypes?: Array<'image' | 'video' | 'audio'>;
selectedIds?: string[];
title?: string;
}
const today = () => new Date().toISOString().slice(0, 10);
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (/^(https?:|data:|blob:)/i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const typeIcon = (type: string) => {
if (type === 'video') return <VideoCameraOutlined />;
if (type === 'audio') return <AudioOutlined />;
return <PictureOutlined />;
};
const typeLabel = (type: string) => {
if (type === 'video') return '视频';
if (type === 'audio') return '音频';
return '图片';
};
const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> = ({
open,
onClose,
onSelect,
allowedTypes,
selectedIds = [],
title = '选择历史上传素材',
}) => {
const [date, setDate] = useState(today());
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<UploadResourceHistoryItem[]>([]);
const [total, setTotal] = useState(0);
const [checkedMap, setCheckedMap] = useState<Map<string, UploadResourceHistoryItem>>(new Map());
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const effectiveAllowedTypes = useMemo(() => new Set(allowedTypes || ['image', 'video', 'audio']), [allowedTypes]);
const load = useCallback(async () => {
if (!open) return;
setLoading(true);
try {
const res = await getUploadResourceHistoryItems(date, {
resourceType: resourceType || undefined,
keyword: keyword || undefined,
page,
pageSize,
scene: 'picker',
});
const filtered = (res.items || []).filter((item) => effectiveAllowedTypes.has(item.resourceType));
setItems(filtered);
setTotal(res.total || 0);
} catch (error: any) {
message.error(error?.message || '加载历史上传素材失败');
setItems([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [date, effectiveAllowedTypes, keyword, open, page, pageSize, resourceType]);
useEffect(() => {
if (open) load();
}, [open, load]);
useEffect(() => {
if (open) setCheckedMap(new Map());
}, [open]);
const toggle = (item: UploadResourceHistoryItem) => {
if (selectedIdSet.has(item.id)) {
message.warning('该素材已经在参考内容中');
return;
}
setCheckedMap((prev) => {
const next = new Map(prev);
if (next.has(item.id)) next.delete(item.id);
else next.set(item.id, item);
return next;
});
};
const confirm = () => {
const selected = Array.from(checkedMap.values());
if (selected.length === 0) {
message.warning('请先选择历史素材');
return;
}
onSelect(selected);
setCheckedMap(new Map());
onClose();
};
const typeOptions = useMemo(() => {
const all = [
{ label: '全部', value: '' },
{ label: '图片', value: 'image' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
];
return all.filter((opt) => !opt.value || effectiveAllowedTypes.has(opt.value as any));
}, [effectiveAllowedTypes]);
return (
<Modal
title={title}
open={open}
onCancel={onClose}
width={920}
centered
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
({checkedMap.size})
</Button>,
]}
>
<Space wrap style={{ width: '100%', marginBottom: 16 }}>
<Input
type="date"
value={date}
onChange={(e) => {
setDate(e.target.value || today());
setPage(1);
}}
style={{ width: 160 }}
/>
<Select
value={resourceType}
options={typeOptions}
style={{ width: 120 }}
onChange={(value) => {
setResourceType(value);
setPage(1);
}}
/>
<Input
allowClear
value={keyword}
prefix={<SearchOutlined />}
placeholder="搜索文件名"
style={{ width: 220 }}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => {
setPage(1);
load();
}}
/>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Spin spinning={loading}>
{items.length === 0 ? (
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14, minHeight: 260 }}>
{items.map((item) => {
const checked = checkedMap.has(item.id);
const disabled = selectedIdSet.has(item.id);
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
return (
<div
key={item.id}
onClick={() => !disabled && toggle(item)}
style={{
position: 'relative',
borderRadius: 14,
border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0',
background: disabled ? '#f8fafc' : '#fff',
opacity: disabled ? 0.55 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
overflow: 'hidden',
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
}}
>
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.resourceType === 'image' ? (
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : item.resourceType === 'video' ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
<AudioOutlined />
</div>
)}
</div>
<div style={{ padding: 10 }}>
<Space size={6} style={{ marginBottom: 6 }}>
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
</Space>
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
{item.fileName || item.id}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
</div>
{checked && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
</div>
);
})}
</div>
)}
</Spin>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</div>
</Modal>
);
};
export default UploadResourceHistoryPicker;
@@ -0,0 +1,2 @@
export { default as UploadResourceHistoryPicker } from './UploadResourceHistoryPicker';
export { default as UploadResourceHistoryPanel } from './UploadResourceHistoryPanel';
+47 -21
View File
@@ -33,7 +33,7 @@ import {
import { useAppStore } from '../store/useAppStore';
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
import {
PlusOutlined,
@@ -78,8 +78,10 @@ interface MediaReference {
source?: string;
private_asset_id?: string;
remote_asset_id?: string;
upload_resource_id?: string;
}
interface Message {
id: string;
original_prompt: string;
@@ -1503,6 +1505,48 @@ const AIChatPage: React.FC = () => {
return true;
};
const normalizeUploadResourceHistoryItem = (item: UploadResourceHistoryItem): MediaReference | null => {
const media = item.mediaReference;
const refType = (media?.type || item.resourceType) as 'image' | 'video' | 'audio';
const url = media?.url || item.resourceUrl || item.displayUrl || item.previewUrl || '';
if (!url) {
message.error(`${item.fileName || item.id} 缺少素材地址,不能用于 AI 创作`);
return null;
}
if (refType === 'audio' && mediaType !== 'video') {
message.error('仅视频模式支持添加音频素材');
return null;
}
const duration = Number(media?.duration ?? item.durationSeconds);
return {
name: media?.name || item.fileName || item.id,
type: refType,
url,
source: 'upload_resource',
upload_resource_id: item.id,
label: '',
...((refType === 'video' || refType === 'audio') && Number.isFinite(duration) && duration > 0 ? { duration } : {}),
};
};
const handleUploadResourceHistorySelected = (items: UploadResourceHistoryItem[]) => {
const normalized = items
.map(normalizeUploadResourceHistoryItem)
.filter(Boolean) as MediaReference[];
if (!normalized.length) return;
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) {
return;
}
const newList = [...latestMedia, ...normalized];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
message.success(`已添加 ${normalized.length} 个历史上传素材参考`);
};
const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => {
if (asset.assetType === 'Audio') {
message.error('音频私域素材暂不支持用于 AI 创作');
@@ -2612,16 +2656,7 @@ const AIChatPage: React.FC = () => {
<UploadSelector
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
onLocalSelect={handleBatchUpload}
onHistorySelect={(items) => {
const newMedia = items.map((item: any) => ({
name: item.name,
type: item.type as 'image' | 'video' | 'audio',
url: '',
label: '',
}));
setCurrentMedia([...currentMedia, ...newMedia]);
message.success(`成功添加${items.length}个历史记录`);
}}
onHistorySelect={handleUploadResourceHistorySelected}
onPortraitLibrarySelect={(libraryType) => {
openPrivatePortraitPicker(libraryType);
}}
@@ -2792,16 +2827,7 @@ const AIChatPage: React.FC = () => {
<UploadSelector
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
onLocalSelect={handleBatchUpload}
onHistorySelect={(items) => {
const newMedia = items.map((item: any) => ({
name: item.name,
type: item.type as 'image' | 'video' | 'audio',
url: '',
label: '',
}));
setCurrentMedia([...currentMedia, ...newMedia]);
message.success(`成功添加${items.length}个历史记录`);
}}
onHistorySelect={handleUploadResourceHistorySelected}
onPortraitLibrarySelect={(libraryType) => {
openPrivatePortraitPicker(libraryType);
}}
+27 -4
View File
@@ -20,6 +20,7 @@ import {
} from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom';
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
import { UploadResourceHistoryPanel } from '../components/uploadResource';
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
const { Search } = Input;
@@ -52,8 +53,8 @@ const GeneratedRecord: React.FC = () => {
});
const [searchParams] = useSearchParams();
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : 'project';
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait'>(initialFilterType);
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : searchParams.get('filterType') === 'upload_resource' ? 'upload_resource' : 'project';
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait' | 'upload_resource'>(initialFilterType);
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
const [recordlist, setRecordList] = useState<any[]>([]);
const [Pagebreak, setPagebreak] = useState<any>({
@@ -759,7 +760,7 @@ const GeneratedRecord: React.FC = () => {
});
};
const loadRecordList = () => {
if (filterType === 'private_portrait') {
if (filterType === 'private_portrait' || filterType === 'upload_resource') {
setLoading(false);
setRecordList([]);
return;
@@ -1033,9 +1034,29 @@ const GeneratedRecord: React.FC = () => {
>
</Button>
<Button
type={filterType === 'upload_resource' ? 'primary' : 'default'}
onClick={() => {
setFilterType('upload_resource');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'upload_resource'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'upload_resource' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'upload_resource' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<UploadOutlined />}
>
</Button>
</Space>
</div>
{filterType !== 'private_portrait' && (
{filterType !== 'private_portrait' && filterType !== 'upload_resource' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{/* 多选模式按钮 */}
{isSelectionMode ? (
@@ -1120,6 +1141,8 @@ const GeneratedRecord: React.FC = () => {
</div>
{filterType === 'private_portrait' ? (
<PrivatePortraitLibraryPanel />
) : filterType === 'upload_resource' ? (
<UploadResourceHistoryPanel />
) : (
<>
{/* Second row filter: 视频 / 图片 */}
+28 -7
View File
@@ -18,11 +18,20 @@ import {
LoadingOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { uploadVideo, uploadImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
const { Header, Content } = Layout;
const { TextArea } = Input;
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const buildAssetUrl = (url?: string): string => {
if (!url) return '';
if (/^https?:\/\//i.test(url) || url.startsWith('blob:')) return url;
return `${API_BASE}${url}`;
};
const GenerateConver: React.FC = () => {
const navigate = useNavigate();
@@ -50,8 +59,11 @@ const GenerateConver: React.FC = () => {
// 文件状态
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoUrl, setVideoUrl] = useState<string>('');
const [videoResourceId, setVideoResourceId] = useState<string>('');
const [videoDurationSeconds, setVideoDurationSeconds] = useState<number | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageUrl, setImageUrl] = useState<string>('');
const [imageResourceId, setImageResourceId] = useState<string>('');
const [videoUploading, setVideoUploading] = useState(false);
const [imageUploading, setImageUploading] = useState(false);
@@ -222,9 +234,11 @@ const GenerateConver: React.FC = () => {
// 调用上传接口
setVideoUploading(true);
try {
const res = await uploadVideo(file);
const res = await uploadHotOpeningVideo(file, video.duration);
setVideoFile(file);
setVideoUrl(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${res.url}`);
setVideoUrl(res.url);
setVideoResourceId(res.resource_id || '');
setVideoDurationSeconds(video.duration);
message.success('视频上传成功');
resolve(false);
} catch (error) {
@@ -280,9 +294,10 @@ const GenerateConver: React.FC = () => {
// 调用上传接口
setImageUploading(true);
try {
const res = await uploadImage(file);
const res = await uploadHotOpeningImage(file);
setImageFile(file);
setImageUrl(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${res.url}`);
setImageUrl(res.url);
setImageResourceId(res.resource_id || '');
message.success('图片上传成功');
resolve(false);
} catch (error) {
@@ -363,6 +378,9 @@ const GenerateConver: React.FC = () => {
let params = {
material_video_url: videoUrl,
material_image_url: imageUrl,
material_video_resource_id: videoResourceId || undefined,
material_image_resource_id: imageResourceId || undefined,
material_video_duration_seconds: videoDurationSeconds || undefined,
source_project_name: originalProductName,
target_project_name: ownProductName,
core_content_point: productSellingPoints,
@@ -380,7 +398,10 @@ const GenerateConver: React.FC = () => {
// 清空上传的媒体和文本
setVideoUrl('');
setVideoResourceId('');
setVideoDurationSeconds(null);
setImageUrl('');
setImageResourceId('');
setOriginalProductName('');
setOwnProductName('');
setProductSellingPoints('');
@@ -818,7 +839,7 @@ const GenerateConver: React.FC = () => {
{videoUrl ? (
<div style={{ position: 'relative' }}>
<video
src={videoUrl}
src={buildAssetUrl(videoUrl)}
controls
style={{ width: '100%', borderRadius: 12, maxHeight: 200, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)' }}
/>
@@ -934,7 +955,7 @@ const GenerateConver: React.FC = () => {
{imageUrl ? (
<div style={{ position: 'relative' }}>
<img
src={imageUrl}
src={buildAssetUrl(imageUrl)}
alt="产品图片"
style={{ width: '100%', borderRadius: 12, maxHeight: 200, objectFit: 'contain', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)' }}
/>
+8 -3
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadShotReplicateImage } from '../api';
import VideoTrimPicker from '../components/VideoTrimPicker';
const { TextArea } = Input;
@@ -30,6 +30,7 @@ function RemoveInfo() {
const [productName, setProductName] = useState('');
const [productSellingPoint, setProductSellingPoint] = useState('');
const [productImage, setProductImage] = useState('');
const [productImageResourceId, setProductImageResourceId] = useState('');
const [taskDetail, setTaskDetail] = useState<any>(null);
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -190,18 +191,21 @@ function RemoveInfo() {
setCurrentSegmentName('');
setProductSellingPoint('');
setProductImage('');
setProductImageResourceId('');
};
const handleProductImageChange: any = (info: any) => {
if (info.fileList.length === 0) {
setProductImage('');
setProductImageResourceId('');
}
};
const beforeUploadProductImage = async (file: File) => {
try {
const uploadResult = await uploadImage(file);
const uploadResult = await uploadShotReplicateImage(file);
setProductImage(uploadResult.url);
setProductImageResourceId(uploadResult.resource_id || '');
message.success('图片上传成功');
} catch {
message.error('图片上传失败,请重试');
@@ -231,7 +235,8 @@ function RemoveInfo() {
const params = {
target_project_name: productName.trim(),
core_content_point: productSellingPoint.trim(),
material_image_url: buildAssetUrl(productImage),
material_image_url: productImage,
material_image_resource_id: productImageResourceId || undefined,
idempotency_key: `replication_${Date.now()}`,
};
+6 -2
View File
@@ -2,13 +2,14 @@ import { useState, useRef, useCallback } from 'react';
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { uploadVideo, createShotReplication, getShotReplicationList } from '../api';
import { uploadShotReplicateVideo, createShotReplication, getShotReplicationList } from '../api';
import bg1 from '../assets/bg1.png';
export default function VideoFrameExtractor() {
const navigate = useNavigate();
const [videoUrl, setVideoUrl] = useState<string>('');
const [videoResourceId, setVideoResourceId] = useState<string>('');
const [error, setError] = useState<string>('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [productName, setProductName] = useState<string>('');
@@ -28,6 +29,7 @@ export default function VideoFrameExtractor() {
URL.revokeObjectURL(videoUrl);
}
setVideoUrl('');
setVideoResourceId('');
setError('');
setVideoDuration(0);
setProductName('');
@@ -66,8 +68,9 @@ export default function VideoFrameExtractor() {
setLoading(true);
try {
const uploadResult = await uploadVideo(file);
const uploadResult = await uploadShotReplicateVideo(file, duration);
setVideoUrl(uploadResult.url);
setVideoResourceId(uploadResult.resource_id || '');
setError('');
message.success('视频上传成功');
} catch (err) {
@@ -97,6 +100,7 @@ export default function VideoFrameExtractor() {
const params = {
video_url: videoUrl,
video_duration_seconds: videoDuration,
video_resource_id: videoResourceId || undefined,
title: productName.trim(),
idempotency_key: `shot_${Date.now()}`,
};
+68
View File
@@ -139,8 +139,10 @@ export interface MediaReference {
providerUrl?: string;
displayUrl?: string;
previewUrl?: string;
upload_resource_id?: string;
}
export interface GenerationRecord {
id: string;
items:[],
@@ -386,3 +388,69 @@ export interface PrivatePortraitSelectableAssetListOut {
pageSize: number;
}
// ── UploadResource History Types ──────────────────────────
export type UploadResourceType = 'image' | 'video' | 'audio';
export interface UploadResourceMediaReference {
name?: string | null;
type: UploadResourceType;
url: string;
label?: string | null;
duration?: number | null;
source: 'upload_resource';
uploadResourceId: string;
}
export interface UploadResourceHistoryItem {
id: string;
sourceType: 'upload_resource';
historySource: 'upload_resource';
historySourceLabel: string;
module: string;
moduleLabel: string;
resourceType: UploadResourceType;
resourceTypeLabel: string;
resourceUrl: string;
displayUrl: string;
previewUrl: string;
imageUrl?: string | null;
videoUrl?: string | null;
audioUrl?: string | null;
fileName?: string | null;
fileExt?: string | null;
mimeType?: string | null;
fileSizeBytes: number;
durationSeconds?: number | null;
width?: number | null;
height?: number | null;
bindStatus: string;
deletePolicy: string;
deletable: boolean;
mediaReference: UploadResourceMediaReference;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface UploadResourceHistoryDayGroup {
generatedDate: string;
total: number;
page: number;
items: UploadResourceHistoryItem[];
}
export interface UploadResourceHistoryGrouped {
totalDays: number;
page: number;
pageSize: number;
groups: UploadResourceHistoryDayGroup[];
}
export interface UploadResourceHistoryDayItems {
generatedDate: string;
total: number;
page: number;
pageSize: number;
items: UploadResourceHistoryItem[];
}