diff --git a/video-gen-api/alembic/versions/1475d11b1d74_add_upload_resource_physical_delete_.py b/video-gen-api/alembic/versions/1475d11b1d74_add_upload_resource_physical_delete_.py new file mode 100644 index 00000000..4560be8b --- /dev/null +++ b/video-gen-api/alembic/versions/1475d11b1d74_add_upload_resource_physical_delete_.py @@ -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 ### diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py index f64d0a74..bd89d179 100644 --- a/video-gen-api/app/api/v1/__init__.py +++ b/video-gen-api/app/api/v1/__init__.py @@ -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) diff --git a/video-gen-api/app/api/v1/generation.py b/video-gen-api/app/api/v1/generation.py index b3b21729..92fa81a4 100644 --- a/video-gen-api/app/api/v1/generation.py +++ b/video-gen-api/app/api/v1/generation.py @@ -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 并 commit,commit 成功后再清理真实文件。" + "该接口保留给单文件删除;批量删除请使用 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 diff --git a/video-gen-api/app/api/v1/generation_ai.py b/video-gen-api/app/api/v1/generation_ai.py index 23ee8b68..0e96f362 100644 --- a/video-gen-api/app/api/v1/generation_ai.py +++ b/video-gen-api/app/api/v1/generation_ai.py @@ -121,6 +121,9 @@ async def list_engines( "该接口用于 Chat 风格的图片/视频生成,不再绑定 project_id。" "创建成功后会写入 chat_generation_tasks 表,并投递 Celery 异步任务。" "支持 image 图片生成和 video 视频生成。" + "media_references 支持 image/video/audio;source 可为 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/video;media_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: { diff --git a/video-gen-api/app/api/v1/hot_opening_replicate.py b/video-gen-api/app/api/v1/hot_opening_replicate.py index e0258556..31bbfefb 100644 --- a/video-gen-api/app/api/v1/hot_opening_replicate.py +++ b/video-gen-api/app/api/v1/hot_opening_replicate.py @@ -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 diff --git a/video-gen-api/app/api/v1/private_portrait.py b/video-gen-api/app/api/v1/private_portrait.py index 994fecb8..36594d54 100644 --- a/video-gen-api/app/api/v1/private_portrait.py +++ b/video-gen-api/app/api/v1/private_portrait.py @@ -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/Audio;project 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/Asset;remote_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/Video,Audio 暂不开放。" + "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 或 Video;status 可筛选素材状态。", +) +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 删除远端 Asset;remote_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) diff --git a/video-gen-api/app/api/v1/private_portrait_virtual.py b/video-gen-api/app/api/v1/private_portrait_virtual.py index a50c725c..0173158f 100644 --- a/video-gen-api/app/api/v1/private_portrait_virtual.py +++ b/video-gen-api/app/api/v1/private_portrait_virtual.py @@ -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 表示启用并限制总素材数量。创建虚拟项目会同步调用火山 CreateAssetGroup,GroupType=AIGC," + "remote_project_name 默认使用 default。当前上传只开放 Image/Video,Audio 暂不开放。" +) + 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="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroup,GroupType=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/Asset;remote_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/Video,Audio 暂不开放。" + "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 或 Video;status 可筛选素材状态。", +) +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 删除远端 Asset;remote_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) diff --git a/video-gen-api/app/api/v1/shot_replicate.py b/video-gen-api/app/api/v1/shot_replicate.py index c809fda2..a53478f9 100644 --- a/video-gen-api/app/api/v1/shot_replicate.py +++ b/video-gen-api/app/api/v1/shot_replicate.py @@ -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 diff --git a/video-gen-api/app/api/v1/upload_resource.py b/video-gen-api/app/api/v1/upload_resource.py new file mode 100644 index 00000000..6680b34e --- /dev/null +++ b/video-gen-api/app/api/v1/upload_resource.py @@ -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 diff --git a/video-gen-api/app/cmd/__init__.py b/video-gen-api/app/cmd/__init__.py new file mode 100644 index 00000000..9aec4710 --- /dev/null +++ b/video-gen-api/app/cmd/__init__.py @@ -0,0 +1 @@ +"""项目 CLI 脚本包。""" diff --git a/video-gen-api/app/cmd/backfill_upload_resources.py b/video-gen-api/app/cmd/backfill_upload_resources.py new file mode 100644 index 00000000..7ea1e13b --- /dev/null +++ b/video-gen-api/app/cmd/backfill_upload_resources.py @@ -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()) diff --git a/video-gen-api/app/enums/upload_resource.py b/video-gen-api/app/enums/upload_resource.py new file mode 100644 index 00000000..db2cb24a --- /dev/null +++ b/video-gen-api/app/enums/upload_resource.py @@ -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: "拆镜切片", +} diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py index 7a20b70b..2d5744ba 100644 --- a/video-gen-api/app/models/__init__.py +++ b/video-gen-api/app/models/__init__.py @@ -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", diff --git a/video-gen-api/app/models/upload_resource.py b/video-gen-api/app/models/upload_resource.py new file mode 100644 index 00000000..3c34d1e3 --- /dev/null +++ b/video-gen-api/app/models/upload_resource.py @@ -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, + ) \ No newline at end of file diff --git a/video-gen-api/app/models/user_resource_month_stat.py b/video-gen-api/app/models/user_resource_month_stat.py index 00deb9c3..8d8ea7ee 100644 --- a/video-gen-api/app/models/user_resource_month_stat.py +++ b/video-gen-api/app/models/user_resource_month_stat.py @@ -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) diff --git a/video-gen-api/app/models/user_resource_total_stat.py b/video-gen-api/app/models/user_resource_total_stat.py index 8df2a188..5df29e17 100644 --- a/video-gen-api/app/models/user_resource_total_stat.py +++ b/video-gen-api/app/models/user_resource_total_stat.py @@ -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) diff --git a/video-gen-api/app/schemas/generation_ai.py b/video-gen-api/app/schemas/generation_ai.py index e87e177c..febb22bc 100644 --- a/video-gen-api/app/schemas/generation_ai.py +++ b/video-gen-api/app/schemas/generation_ai.py @@ -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_image;video 可选 reference_video;audio 可选 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="素材云历史来源中文名称") diff --git a/video-gen-api/app/schemas/hot_opening_replicate.py b/video-gen-api/app/schemas/hot_opening_replicate.py index c5f60ea9..09d0df72 100644 --- a/video-gen-api/app/schemas/hot_opening_replicate.py +++ b/video-gen-api/app/schemas/hot_opening_replicate.py @@ -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): diff --git a/video-gen-api/app/schemas/private_portrait.py b/video-gen-api/app/schemas/private_portrait.py index 5b4d0722..d05e6c16 100644 --- a/video-gen-api/app/schemas/private_portrait.py +++ b/video-gen-api/app/schemas/private_portrait.py @@ -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: ( + "私域虚拟人像素材库", + "虚拟素材库。创建项目时直接同步火山 CreateAssetGroup,GroupType=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="虚拟人像素材项目组名称。创建后会同步火山 CreateAssetGroup,GroupType=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 / Video,Audio 暂不开放。") - 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 / Video,Audio 会被拒绝。", + ) + 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), ) diff --git a/video-gen-api/app/schemas/shot_replicate.py b/video-gen-api/app/schemas/shot_replicate.py index 3ac1ab9c..26ac430e 100644 --- a/video-gen-api/app/schemas/shot_replicate.py +++ b/video-gen-api/app/schemas/shot_replicate.py @@ -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: diff --git a/video-gen-api/app/schemas/upload_resource.py b/video-gen-api/app/schemas/upload_resource.py new file mode 100644 index 00000000..e06fe1c5 --- /dev/null +++ b/video-gen-api/app/schemas/upload_resource.py @@ -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="图片资源 URL;resource_type=image 时有值") + video_url: str | None = Field(None, description="视频资源 URL;resource_type=video 时有值") + audio_url: str | None = Field(None, description="音频资源 URL;resource_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, +} diff --git a/video-gen-api/app/services/hot_opening_replicate_service.py b/video-gen-api/app/services/hot_opening_replicate_service.py index a79e4d24..980c76f8 100644 --- a/video-gen-api/app/services/hot_opening_replicate_service.py +++ b/video-gen-api/app/services/hot_opening_replicate_service.py @@ -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) diff --git a/video-gen-api/app/services/shot_replicate_flow_service.py b/video-gen-api/app/services/shot_replicate_flow_service.py index 91397bd3..473eb21d 100644 --- a/video-gen-api/app/services/shot_replicate_flow_service.py +++ b/video-gen-api/app/services/shot_replicate_flow_service.py @@ -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( diff --git a/video-gen-api/app/services/shot_replicate_taskset_service.py b/video-gen-api/app/services/shot_replicate_taskset_service.py index 9026698b..1ef4b4d2 100644 --- a/video-gen-api/app/services/shot_replicate_taskset_service.py +++ b/video-gen-api/app/services/shot_replicate_taskset_service.py @@ -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, diff --git a/video-gen-api/app/services/upload_resource/__init__.py b/video-gen-api/app/services/upload_resource/__init__.py new file mode 100644 index 00000000..636fe68f --- /dev/null +++ b/video-gen-api/app/services/upload_resource/__init__.py @@ -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, +) diff --git a/video-gen-api/app/services/upload_resource/accounting_service.py b/video-gen-api/app/services/upload_resource/accounting_service.py new file mode 100644 index 00000000..61f00e50 --- /dev/null +++ b/video-gen-api/app/services/upload_resource/accounting_service.py @@ -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} diff --git a/video-gen-api/app/services/upload_resource/backfill_service.py b/video-gen-api/app/services/upload_resource/backfill_service.py new file mode 100644 index 00000000..0648125a --- /dev/null +++ b/video-gen-api/app/services/upload_resource/backfill_service.py @@ -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 diff --git a/video-gen-api/app/services/upload_resource/bind_service.py b/video-gen-api/app/services/upload_resource/bind_service.py new file mode 100644 index 00000000..884e3a51 --- /dev/null +++ b/video-gen-api/app/services/upload_resource/bind_service.py @@ -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, + ) diff --git a/video-gen-api/app/services/upload_resource/capacity_service.py b/video-gen-api/app/services/upload_resource/capacity_service.py new file mode 100644 index 00000000..322846bb --- /dev/null +++ b/video-gen-api/app/services/upload_resource/capacity_service.py @@ -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}, + ) diff --git a/video-gen-api/app/services/upload_resource/core_service.py b/video-gen-api/app/services/upload_resource/core_service.py new file mode 100644 index 00000000..709bb41d --- /dev/null +++ b/video-gen-api/app/services/upload_resource/core_service.py @@ -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": [], + } diff --git a/video-gen-api/app/services/upload_resource/delete_service.py b/video-gen-api/app/services/upload_resource/delete_service.py new file mode 100644 index 00000000..745311f9 --- /dev/null +++ b/video-gen-api/app/services/upload_resource/delete_service.py @@ -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 diff --git a/video-gen-api/app/services/upload_resource/file_cleanup_service.py b/video-gen-api/app/services/upload_resource/file_cleanup_service.py new file mode 100644 index 00000000..2b3c28fe --- /dev/null +++ b/video-gen-api/app/services/upload_resource/file_cleanup_service.py @@ -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 diff --git a/video-gen-api/app/services/upload_resource/history_service.py b/video-gen-api/app/services/upload_resource/history_service.py new file mode 100644 index 00000000..a148f03f --- /dev/null +++ b/video-gen-api/app/services/upload_resource/history_service.py @@ -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 diff --git a/video-gen-api/app/services/upload_resource/log_service.py b/video-gen-api/app/services/upload_resource/log_service.py new file mode 100644 index 00000000..3b8ca0a0 --- /dev/null +++ b/video-gen-api/app/services/upload_resource/log_service.py @@ -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, + ) diff --git a/video-gen-api/app/services/upload_resource/path_resolver.py b/video-gen-api/app/services/upload_resource/path_resolver.py new file mode 100644 index 00000000..fd950fb3 --- /dev/null +++ b/video-gen-api/app/services/upload_resource/path_resolver.py @@ -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\d{4})/(?P\d{2})/(?P\d{2})/video_img_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") +COMMON_VIDEO_RE = re.compile(r"^videos/(?P\d{4})/(?P\d{2})/(?P\d{2})/video_ref_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") +COMMON_AUDIO_RE = re.compile(r"^audios/(?P\d{4})/(?P\d{2})/(?P\d{2})/audio_ref_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") +MODULE_RE = re.compile(r"^(?Phot_opening_replicate|shot_replicate)/(?Pimages|videos)/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?Pvideo_img|video_ref)_(?P[^_]+)_(?P\d{8})_(?P\d{6})_(?P[0-9a-fA-F]{8})\.(?P[^/]+)$") +SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[^/]+)\.mp4$") +LEGACY_GEN_RE = re.compile(r"^(?Pimages|videos)/gen_(?P[^_]+)_(?P[0-9a-zA-Z]+)\.(?P[^/]+)$") + +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 diff --git a/video-gen-api/app/tasks/shot_replicate_tasks.py b/video-gen-api/app/tasks/shot_replicate_tasks.py index 3233b36f..ed106824 100644 --- a/video-gen-api/app/tasks/shot_replicate_tasks.py +++ b/video-gen-api/app/tasks/shot_replicate_tasks.py @@ -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 diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 67984d6e..630732cd 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -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 { 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 { 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 { 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 { + 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 { + 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 { + 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 { + 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 { await api.post(`/generation-records/delete-file?url=${encodeURIComponent(url)}`); @@ -741,6 +804,100 @@ export async function deleteResourcesMaterial(params:any): Promise { 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 { + return api.get(`/upload-resources/history${buildUploadResourceHistoryQuery(params)}`); +} + +export async function getUploadResourceHistoryItems(generatedDate: string, params: UploadResourceHistoryQueryParams = {}): Promise { + return api.get(`/upload-resources/history/${generatedDate}${buildUploadResourceHistoryQuery(params)}`); +} + +export async function deleteUploadResourceHistoryBatch(resourceIds: string[]): Promise { + return api.delete('/upload-resources/history/batch', { resource_ids: resourceIds }); +} + export async function getPrivatePortraitConfig(): Promise { return api.get('/private-portrait/config'); diff --git a/video-gen-app/src/components/UploadSelector.tsx b/video-gen-app/src/components/UploadSelector.tsx index cffc5fd1..920fdbc1 100644 --- a/video-gen-app/src/components/UploadSelector.tsx +++ b/video-gen-app/src/components/UploadSelector.tsx @@ -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 = ({ setPortraitPickerOpen(true); }; - const confirmHistorySelection = () => { - onHistorySelect?.([]); - setHistoryModalVisible(false); - setModalVisible(false); - }; - const options = [ { key: 'history', @@ -192,39 +187,16 @@ const UploadSelector: React.FC = ({ - setHistoryModalVisible(false)} - footer={null} - width="80%" - centered - destroyOnHidden - > -
-
- 暂无资产图片 -
-
-
- -
-
+ onClose={() => setHistoryModalVisible(false)} + onSelect={(items) => { + onHistorySelect?.(items); + setHistoryModalVisible(false); + setModalVisible(false); + }} + allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']} + /> { + 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 ; + if (type === 'audio') return ; + return ; +}; + +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(''); + const [keyword, setKeyword] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [loading, setLoading] = useState(false); + const [groups, setGroups] = useState([]); + const [totalDays, setTotalDays] = useState(0); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [previewItem, setPreviewItem] = useState(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 {item.fileName; + if (item.resourceType === 'video') return