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/admin/menu_config.py b/video-gen-api/app/api/admin/menu_config.py index d1f5ffc1..653b14c7 100644 --- a/video-gen-api/app/api/admin/menu_config.py +++ b/video-gen-api/app/api/admin/menu_config.py @@ -50,6 +50,7 @@ async def create_menu_config( ensure_ascii=False, ), ) + await db.commit() return menu @@ -89,6 +90,7 @@ async def update_menu_config( ensure_ascii=False, ), ) + await db.commit() return menu @@ -121,4 +123,5 @@ async def delete_menu_config( ensure_ascii=False, ), ) + await db.commit() return {"message": "ok"} diff --git a/video-gen-api/app/api/admin/recharge_package.py b/video-gen-api/app/api/admin/recharge_package.py index 4875c96c..bf23147e 100644 --- a/video-gen-api/app/api/admin/recharge_package.py +++ b/video-gen-api/app/api/admin/recharge_package.py @@ -72,6 +72,7 @@ async def create_package( ensure_ascii=False, ), ) + await db.commit() return _to_out(pkg) @@ -109,6 +110,7 @@ async def update_package( ensure_ascii=False, ), ) + await db.commit() return _to_out(pkg) @@ -142,4 +144,5 @@ async def delete_package( ensure_ascii=False, ), ) + await db.commit() return {"ok": True} diff --git a/video-gen-api/app/api/admin/video_prompt_schema_config.py b/video-gen-api/app/api/admin/video_prompt_schema_config.py index 69ee31cb..07907f94 100644 --- a/video-gen-api/app/api/admin/video_prompt_schema_config.py +++ b/video-gen-api/app/api/admin/video_prompt_schema_config.py @@ -58,6 +58,7 @@ async def save_config( ensure_ascii=False, ), ) + await db.commit() return result @@ -81,6 +82,7 @@ async def reset_default( ensure_ascii=False, ), ) + await db.commit() return result @@ -114,6 +116,7 @@ async def import_config( ensure_ascii=False, ), ) + await db.commit() return result 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/admin.py b/video-gen-api/app/api/v1/admin.py index c80d854a..1a96bc87 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -202,6 +202,7 @@ async def create_user( ), ip=None, ) + await db.commit() return user @@ -233,6 +234,7 @@ async def update_user_menus( ensure_ascii=False, ), ) + await db.commit() return {"message": "ok"} @@ -289,6 +291,7 @@ async def adjust_credits( ensure_ascii=False, ), ) + await db.commit() return {"message": "ok"} @@ -319,6 +322,7 @@ async def update_user_status( ensure_ascii=False, ), ) + await db.commit() return {"message": "ok"} @@ -1594,7 +1598,7 @@ async def update_system_config( if not config: raise HTTPException(status_code=404, detail="配置不存在") config.value = str(req.value) - await db.commit() + await db.flush() await log_operation( db, admin.id, @@ -1611,6 +1615,7 @@ async def update_system_config( ensure_ascii=False, ), ) + await db.commit() return config @@ -2185,7 +2190,7 @@ async def upload_pdf( key=config_key, value=url, )) - await db.commit() + await db.flush() await log_operation( db, admin.id, @@ -2202,6 +2207,7 @@ async def upload_pdf( ensure_ascii=False, ), ) + await db.commit() return {"url": url} @@ -2247,7 +2253,7 @@ async def upload_logo( value=url, description="网站Logo图片", )) - await db.commit() + await db.flush() await log_operation( db, admin.id, @@ -2263,6 +2269,7 @@ async def upload_logo( ensure_ascii=False, ), ) + await db.commit() return {"url": url} 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/dist/assets/index-BIirSavc.js b/video-gen-app/dist/assets/index-BIirSavc.js deleted file mode 100644 index 01fd47a0..00000000 --- a/video-gen-app/dist/assets/index-BIirSavc.js +++ /dev/null @@ -1,506 +0,0 @@ -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),l=(e=>typeof require<`u`?require:typeof Proxy<`u`?new Proxy(e,{get:(e,t)=>(typeof require<`u`?require:e)[t]}):e)(function(e){if(typeof require<`u`)return require.apply(this,arguments);throw Error('Calling `require` for "'+e+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var u=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&j(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&j(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var k=new MessageChannel,A=k.port2;k.port1.onmessage=D,O=function(){A.postMessage(null)}}else O=function(){_(D,0)};function j(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,j(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),d=o(((e,t)=>{t.exports=u()})),f=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function D(e,t){return E(e.type,t,e.props)}function O(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function k(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var A=/\/+/g;function j(e,t){return typeof e==`object`&&e&&e.key!=null?k(``+e.key):t.toString(36)}function M(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function N(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,N(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+j(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(A,`$&/`)+`/`),N(o,r,i,``,function(e){return e})):o!=null&&(O(o)&&(o=D(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(A,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=f()})),m=o((e=>{var t=p();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=m()})),g=o((e=>{var t=d(),n=p(),r=h();function i(e){var t=`https://react.dev/errors/`+e;if(1B||(e.current=z[B],z[B]=null,B--)}function U(e,t){B++,z[B]=e.current,e.current=t}var W=V(null),G=V(null),ee=V(null),te=V(null);function ne(e,t){switch(U(ee,t),U(G,e),U(W,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Gd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Gd(t),e=Kd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}H(W),U(W,e)}function K(){H(W),H(G),H(ee)}function re(e){e.memoizedState!==null&&U(te,e);var t=W.current,n=Kd(t,e.type);t!==n&&(U(G,e),U(W,n))}function ie(e){G.current===e&&(H(W),H(G)),te.current===e&&(H(te),np._currentValue=R)}var ae,q;function oe(e){if(ae===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);ae=t&&t[1]||``,q=-1)`:-1i||c[r]!==l[i]){var u=` -`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{se=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?oe(n):``}function le(e,t){switch(e.tag){case 26:case 27:case 5:return oe(e.type);case 16:return oe(`Lazy`);case 13:return e.child!==t&&t!==null?oe(`Suspense Fallback`):oe(`Suspense`);case 19:return oe(`SuspenseList`);case 0:case 15:return ce(e.type,!1);case 11:return ce(e.type.render,!1);case 1:return ce(e.type,!0);case 31:return oe(`Activity`);default:return``}}function ue(e){try{var t=``,n=null;do t+=le(e,n),n=e,e=e.return;while(e);return t}catch(e){return` -Error generating stack: `+e.message+` -`+e.stack}}var de=Object.prototype.hasOwnProperty,fe=t.unstable_scheduleCallback,pe=t.unstable_cancelCallback,me=t.unstable_shouldYield,he=t.unstable_requestPaint,ge=t.unstable_now,_e=t.unstable_getCurrentPriorityLevel,ve=t.unstable_ImmediatePriority,ye=t.unstable_UserBlockingPriority,be=t.unstable_NormalPriority,xe=t.unstable_LowPriority,Se=t.unstable_IdlePriority,Ce=t.log,we=t.unstable_setDisableYieldValue,Te=null,Ee=null;function De(e){if(typeof Ce==`function`&&we(e),Ee&&typeof Ee.setStrictMode==`function`)try{Ee.setStrictMode(Te,e)}catch{}}var Oe=Math.clz32?Math.clz32:je,ke=Math.log,Ae=Math.LN2;function je(e){return e>>>=0,e===0?32:31-(ke(e)/Ae|0)|0}var Me=256,Ne=262144,Pe=4194304;function Fe(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ie(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Fe(n))):i=Fe(o):i=Fe(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Fe(n))):i=Fe(o)):i=Fe(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Le(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Re(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function ze(){var e=Pe;return Pe<<=1,!(Pe&62914560)&&(Pe=4194304),e}function Be(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Ve(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function He(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),en=!1;if($t)try{var tn={};Object.defineProperty(tn,`passive`,{get:function(){en=!0}}),window.addEventListener(`test`,tn,tn),window.removeEventListener(`test`,tn,tn)}catch{en=!1}var nn=null,rn=null,an=null;function on(){if(an)return an;var e,t=rn,n=t.length,r,i=`value`in nn?nn.value:nn.textContent,a=i.length;for(e=0;e=Rn),Vn=` `,Hn=!1;function Un(e,t){switch(e){case`keyup`:return In.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Wn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Gn=!1;function Kn(e,t){switch(e){case`compositionend`:return Wn(t);case`keypress`:return t.which===32?(Hn=!0,Vn):null;case`textInput`:return e=t.data,e===Vn&&Hn?null:e;default:return null}}function qn(e,t){if(Gn)return e===`compositionend`||!Ln&&Un(e,t)?(e=on(),an=rn=nn=null,Gn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=hr(n)}}function _r(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?_r(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function vr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Dt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Dt(e.document)}return t}function yr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var br=$t&&`documentMode`in document&&11>=document.documentMode,xr=null,Sr=null,Cr=null,wr=!1;function Tr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;wr||xr==null||xr!==Dt(r)||(r=xr,`selectionStart`in r&&yr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Cr&&mr(Cr,r)||(Cr=r,r=kd(Sr,`onSelect`),0>=o,i-=o,_i=1<<32-Oe(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),Ei&&yi(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),Ei&&yi(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return Ei&&yi(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),Ei&&yi(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===O&&ya(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ea(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=ii(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=ri(o.type,o.key,o.props,null,e.mode,c),Ea(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=si(o,e.mode,c),c.return=e,e=c}return s(e);case O:return o=ya(o),b(e,r,o,c)}if(F(o))return h(e,r,o,c);if(M(o)){if(l=M(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ta(o),c);if(o.$$typeof===C)return b(e,r,Ji(e,o),c);Da(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=ai(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{wa=0;var i=b(e,t,n,r);return Ca=null,i}catch(t){if(t===pa||t===ha)throw t;var a=$r(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var ka=Oa(!0),Aa=Oa(!1),ja=!1;function Ma(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Na(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Pa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Fa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Fl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=Xr(e),Yr(e,null,n),t}return Kr(e,r,t,n),Xr(e)}function Ia(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,We(e,n)}}function La(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ra=!1;function za(){if(Ra){var e=aa;if(e!==null)throw e}}function Ba(e,t,n,r){Ra=!1;var i=e.updateQueue;ja=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(Rl&f)===f:(r&f)===f){f!==0&&f===ia&&(Ra=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=m({},d,f);break a;case 2:ja=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Kl|=o,e.lanes=o,e.memoizedState=d}}function Va(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Ha(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=I.T,s={};I.T=s,Os(e,!1,t,n);try{var c=i(),l=I.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ds(e,t,J(c,r),mu(e)):Ds(e,t,r,mu(e))}catch(n){Ds(e,t,{then:function(){},status:`rejected`,reason:n},mu())}finally{L.p=a,o!==null&&s.types!==null&&(o.types=s.types),I.T=o}}function _s(){}function vs(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=ys(e).queue;gs(e,a,t,R,n===null?_s:function(){return bs(e),n(r)})}function ys(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:R,baseState:R,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ko,lastRenderedState:R},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ko,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function bs(e){var t=ys(e);t.next===null&&(t=e.alternate.memoizedState),Ds(e,t.next.queue,{},mu())}function xs(){return qi(np)}function Ss(){return wo().memoizedState}function Cs(){return wo().memoizedState}function ws(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=mu();e=Pa(n);var r=Fa(t,e,n);r!==null&&(gu(r,t,n),Ia(r,t,n)),t={cache:ea()},e.payload=t;return}t=t.return}}function Ts(e,t,n){var r=mu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},ks(e)?As(t,n):(n=qr(e,t,n,r),n!==null&&(gu(n,e,r),js(n,t,r)))}function Es(e,t,n){Ds(e,t,n,mu())}function Ds(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(ks(e))As(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,pr(s,o))return Kr(e,t,i,0),Il===null&&Gr(),!1}catch{}if(n=qr(e,t,i,r),n!==null)return gu(n,e,r),js(n,t,r),!0}return!1}function Os(e,t,n,r){if(r={lane:2,revertLane:pd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},ks(e)){if(t)throw Error(i(479))}else t=qr(e,n,r,2),t!==null&&gu(t,e,2)}function ks(e){var t=e.alternate;return e===ro||t!==null&&t===ro}function As(e,t){so=oo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function js(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,We(e,n)}}var Ms={readContext:qi,use:Do,useCallback:mo,useContext:mo,useEffect:mo,useImperativeHandle:mo,useLayoutEffect:mo,useInsertionEffect:mo,useMemo:mo,useReducer:mo,useRef:mo,useState:mo,useDebugValue:mo,useDeferredValue:mo,useTransition:mo,useSyncExternalStore:mo,useId:mo,useHostTransitionStatus:mo,useFormState:mo,useActionState:mo,useOptimistic:mo,useMemoCache:mo,useCacheRefresh:mo};Ms.useEffectEvent=mo;var Ns={readContext:qi,use:Do,useCallback:function(e,t){return Co().memoizedState=[e,t===void 0?null:t],e},useContext:qi,useEffect:rs,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),ts(4194308,4,ls.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ts(4194308,4,e,t)},useInsertionEffect:function(e,t){ts(4,2,e,t)},useMemo:function(e,t){var n=Co();t=t===void 0?null:t;var r=e();if(co){De(!0);try{e()}finally{De(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Co();if(n!==void 0){var i=n(t);if(co){De(!0);try{n(t)}finally{De(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Ts.bind(null,ro,e),[r.memoizedState,e]},useRef:function(e){var t=Co();return e={current:e},t.memoizedState=e},useState:function(e){e=zo(e);var t=e.queue,n=Es.bind(null,ro,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ds,useDeferredValue:function(e,t){return ms(Co(),e,t)},useTransition:function(){var e=zo(!1);return e=gs.bind(null,ro,e.queue,!0,!1),Co().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=ro,a=Co();if(Ei){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),Il===null)throw Error(i(349));Rl&127||Po(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,rs(Io.bind(null,r,o,e),[e]),r.flags|=2048,$o(9,{destroy:void 0},Fo.bind(null,r,o,n,t),null),n},useId:function(){var e=Co(),t=Il.identifierPrefix;if(Ei){var n=vi,r=_i;n=(r&~(1<<32-Oe(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=lo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[Ze]=t,o[Qe]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Rd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Dc(t)}}return Mc(t),Oc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Dc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ee.current,Ni(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=wi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[Ze]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Fd(e.nodeValue,n)),e||Ai(t,!0)}else e=Wd(e).createTextNode(r),e[Ze]=t,t.stateNode=e}return Mc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ni(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[Ze]=t}else Pi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Mc(t),e=!1}else n=Fi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?($a(t),t):($a(t),null);if(t.flags&128)throw Error(i(558))}return Mc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ni(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[Ze]=t}else Pi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Mc(t),a=!1}else a=Fi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?($a(t),t):($a(t),null)}return $a(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Ac(t,t.updateQueue),Mc(t),null);case 4:return K(),e===null&&Td(t.stateNode.containerInfo),Mc(t),null;case 10:return Vi(t.type),Mc(t),null;case 19:if(H(eo),r=t.memoizedState,r===null)return Mc(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)jc(r,!1);else{if(Gl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=to(e),o!==null){for(t.flags|=128,jc(r,!1),e=o.updateQueue,t.updateQueue=e,Ac(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)ni(n,e),n=n.sibling;return U(eo,eo.current&1|2),Ei&&yi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&ge()>nu&&(t.flags|=128,a=!0,jc(r,!1),t.lanes=4194304)}else{if(!a)if(e=to(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Ac(t,e),jc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!Ei)return Mc(t),null}else 2*ge()-r.renderingStartTime>nu&&n!==536870912&&(t.flags|=128,a=!0,jc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Mc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=ge(),e.sibling=null,n=eo.current,U(eo,a?n&1|2:n&1),Ei&&yi(t,r.treeForkCount),e);case 22:case 23:return $a(t),Ka(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Mc(t),t.subtreeFlags&6&&(t.flags|=8192)):Mc(t),n=t.updateQueue,n!==null&&Ac(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&H(la),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Vi($i),Mc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Pc(e,t){switch(Si(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Vi($i),K(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return ie(t),null;case 31:if(t.memoizedState!==null){if($a(t),t.alternate===null)throw Error(i(340));Pi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if($a(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Pi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return H(eo),null;case 4:return K(),null;case 10:return Vi(t.type),null;case 22:case 23:return $a(t),Ka(),e!==null&&H(la),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Vi($i),null;case 25:return null;default:return null}}function Fc(e,t){switch(Si(t),t.tag){case 3:Vi($i),K();break;case 26:case 27:case 5:ie(t);break;case 4:K();break;case 31:t.memoizedState!==null&&$a(t);break;case 13:$a(t);break;case 19:H(eo);break;case 10:Vi(t.type);break;case 22:case 23:$a(t),Ka(),e!==null&&H(la);break;case 24:Vi($i)}}function Ic(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Ku(t,t.return,e)}}function Lc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Ku(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Ku(t,t.return,e)}}function Rc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ha(t,n)}catch(t){Ku(e,e.return,t)}}}function zc(e,t,n){n.props=Bs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Ku(e,t,n)}}function Bc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Ku(e,t,n)}}function Vc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Ku(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Ku(e,t,n)}else n.current=null}function Hc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Ku(e,e.return,t)}}function Uc(e,t,n){try{var r=e.stateNode;zd(r,e.type,n,t),r[Qe]=t}catch(t){Ku(e,e.return,t)}}function Wc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&tf(e.type)||e.tag===4}function Gc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Wc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&tf(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Kc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Wt));else if(r!==4&&(r===27&&tf(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Kc(e,t,n),e=e.sibling;e!==null;)Kc(e,t,n),e=e.sibling}function qc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&tf(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(qc(e,t,n),e=e.sibling;e!==null;)qc(e,t,n),e=e.sibling}function Jc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Rd(t,r,n),t[Ze]=e,t[Qe]=n}catch(t){Ku(e,e.return,t)}}var Yc=!1,Xc=!1,Zc=!1,Qc=typeof WeakSet==`function`?WeakSet:Set,$c=null;function el(e,t){if(e=e.containerInfo,Hd=dp,e=vr(e),yr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Ud={focusedElem:e,selectionRange:n},dp=!1,$c=t;$c!==null;)if(t=$c,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,$c=e;else for(;$c!==null;){switch(t=$c,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Rd(o,r,n),o[Ze]=e,ut(o),r=o;break a;case`link`:var s=Gf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=gr(s,h),v=gr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,I.T=null,n=uu,uu=null;var o=ou,s=cu;if(au=0,su=ou=null,cu=0,Fl&6)throw Error(i(331));var c=Fl;if(Fl|=4,Al(o.current),Sl(o,o.current,s,n),Fl=c,od(0,!1),Ee&&typeof Ee.onPostCommitFiberRoot==`function`)try{Ee.onPostCommitFiberRoot(Te,o)}catch{}return!0}finally{L.p=a,I.T=r,Hu(e,t)}}function Gu(e,t,n){t=li(n,t),t=Ks(e.stateNode,t,2),e=Fa(e,t,2),e!==null&&(Ve(e,2),ad(e))}function Ku(e,t,n){if(e.tag===3)Gu(e,e,n);else for(;t!==null;){if(t.tag===3){Gu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(iu===null||!iu.has(r))){e=li(n,e),n=qs(2),r=Fa(t,n,2),r!==null&&(Js(n,r,t,e),Ve(r,2),ad(r));break}}t=t.return}}function qu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Pl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Ul=!0,i.add(n),e=Ju.bind(null,e,t,n),t.then(e,e))}function Ju(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Il===e&&(Rl&n)===n&&(Gl===4||Gl===3&&(Rl&62914560)===Rl&&300>ge()-eu?!(Fl&2)&&Cu(e,0):Jl|=n,Xl===Rl&&(Xl=0)),ad(e)}function Yu(e,t){t===0&&(t=ze()),e=Jr(e,t),e!==null&&(Ve(e,t),ad(e))}function Xu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Yu(e,n)}function Zu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),Yu(e,n)}function Qu(e,t){return fe(e,t)}var $u=null,ed=null,td=!1,nd=!1,rd=!1,id=0;function ad(e){e!==ed&&e.next===null&&(ed===null?$u=ed=e:ed=ed.next=e),nd=!0,td||(td=!0,fd())}function od(e,t){if(!rd&&nd){rd=!0;do for(var n=!1,r=$u;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Oe(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,dd(r,a))}else a=Rl,a=Ie(r,r===Il?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Le(r,a)||(n=!0,dd(r,a));r=r.next}while(n);rd=!1}}function sd(){cd()}function cd(){nd=td=!1;var e=0;id!==0&&Yd()&&(e=id);for(var t=ge(),n=null,r=$u;r!==null;){var i=r.next,a=ld(r,t);a===0?(r.next=null,n===null?$u=i:n.next=i,i===null&&(ed=n)):(n=r,(e!==0||a&3)&&(nd=!0)),r=i}au!==0&&au!==5||od(e,!1),id!==0&&(id=0)}function ld(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Bd(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function Tf(e,t,n){var r=wf;if(r&&typeof t==`string`&&t){var i=kt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),yf.has(i)||(yf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Rd(t,`link`,e),ut(t),r.head.appendChild(t)))}}function Ef(e){xf.D(e),Tf(`dns-prefetch`,e,null)}function Df(e,t){xf.C(e,t),Tf(`preconnect`,e,t)}function Of(e,t,n){xf.L(e,t,n);var r=wf;if(r&&e&&t){var i=`link[rel="preload"][as="`+kt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+kt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+kt(n.imageSizes)+`"]`)):i+=`[href="`+kt(e)+`"]`;var a=i;switch(t){case`style`:a=Pf(e);break;case`script`:a=Rf(e)}vf.has(a)||(e=m({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),vf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Ff(a))||t===`script`&&r.querySelector(zf(a))||(t=r.createElement(`link`),Rd(t,`link`,e),ut(t),r.head.appendChild(t)))}}function kf(e,t){xf.m(e,t);var n=wf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+kt(r)+`"][href="`+kt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Rf(e)}if(!vf.has(a)&&(e=m({rel:`modulepreload`,href:e},t),vf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(zf(a)))return}r=n.createElement(`link`),Rd(r,`link`,e),ut(r),n.head.appendChild(r)}}}function Af(e,t,n){xf.S(e,t,n);var r=wf;if(r&&e){var i=lt(r).hoistableStyles,a=Pf(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Ff(a)))s.loading=5;else{e=m({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=vf.get(a))&&Hf(e,n);var c=o=r.createElement(`link`);ut(c),Rd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Vf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function jf(e,t){xf.X(e,t);var n=wf;if(n&&e){var r=lt(n).hoistableScripts,i=Rf(e),a=r.get(i);a||(a=n.querySelector(zf(i)),a||(e=m({src:e,async:!0},t),(t=vf.get(i))&&Uf(e,t),a=n.createElement(`script`),ut(a),Rd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Mf(e,t){xf.M(e,t);var n=wf;if(n&&e){var r=lt(n).hoistableScripts,i=Rf(e),a=r.get(i);a||(a=n.querySelector(zf(i)),a||(e=m({src:e,async:!0,type:`module`},t),(t=vf.get(i))&&Uf(e,t),a=n.createElement(`script`),ut(a),Rd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Nf(e,t,n,r){var a=(a=ee.current)?bf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Pf(n.href),n=lt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Pf(n.href);var o=lt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(Ff(e)))&&!o._p&&(s.instance=o,s.state.loading=5),vf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},vf.set(e,n),o||Lf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Rf(n),n=lt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Pf(e){return`href="`+kt(e)+`"`}function Ff(e){return`link[rel="stylesheet"][`+e+`]`}function If(e){return m({},e,{"data-precedence":e.precedence,precedence:null})}function Lf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Rd(t,`link`,n),ut(t),e.head.appendChild(t))}function Rf(e){return`[src="`+kt(e)+`"]`}function zf(e){return`script[async]`+e}function Bf(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+kt(n.href)+`"]`);if(r)return t.instance=r,ut(r),r;var a=m({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),ut(r),Rd(r,`style`,a),Vf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Pf(n.href);var o=e.querySelector(Ff(a));if(o)return t.state.loading|=4,t.instance=o,ut(o),o;r=If(n),(a=vf.get(a))&&Hf(r,a),o=(e.ownerDocument||e).createElement(`link`),ut(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Rd(o,`link`,r),t.state.loading|=4,Vf(o,n.precedence,e),t.instance=o;case`script`:return o=Rf(n.src),(a=e.querySelector(zf(o)))?(t.instance=a,ut(a),a):(r=n,(a=vf.get(o))&&(r=m({},n),Uf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),ut(a),Rd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Vf(r,n.precedence,e));return t.instance}function Vf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function qf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Jf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Yf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Pf(r.href),a=t.querySelector(Ff(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Qf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,ut(a);return}a=t.ownerDocument||t,r=If(r),(i=vf.get(i))&&Hf(r,i),a=a.createElement(`link`),ut(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Rd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Qf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Xf=0;function Zf(e,t){return e.stylesheets&&e.count===0&&ep(e,e.stylesheets),0Xf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Qf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)ep(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var $f=null;function ep(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,$f=new Map,t.forEach(tp,e),$f=null,Qf.call(e))}function tp(e,t){if(!(t.state.loading&4)){var n=$f.get(e);if(n)var r=n.get(null);else{n=new Map,$f.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=g()}))(),v=`modulepreload`,y=function(e){return`/`+e},b={},x=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=y(t,n),t in b)return;b[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:v,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},S=c(p(),1),C=`popstate`;function w(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function T(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return A(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:j(t)}return N(t,n,null,e)}function E(e,t){if(e===!1||e==null)throw Error(t)}function D(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function O(){return Math.random().toString(36).substring(2,10)}function k(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function A(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?M(t):t,state:n,key:t&&t.key||r||O(),mask:i}}function j({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function M(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function N(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=w(e)?e:A(h.location,e,t);n&&n(r,e),l=u()+1;let d=k(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=w(e)?e:A(h.location,e,t);n&&n(r,e),l=u();let i=k(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return P(e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(C,d),c=e,()=>{i.removeEventListener(C,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function P(e,t=!1){let n=`http://localhost`;typeof window<`u`&&(n=window.location.origin===`null`?window.location.href:window.location.origin),E(n,`No window.location.(origin|href) available to create URL`);let r=typeof e==`string`?e:j(e);return r=r.replace(/ $/,`%20`),!t&&r.startsWith(`//`)&&(r=n+r),new URL(r,n)}function F(e,t,n=`/`){return I(e,t,n,!1)}function I(e,t,n,r,i){let a=se((typeof t==`string`?M(t):t).pathname||`/`,n);if(a==null)return null;let o=i??R(e),s=null,c=oe(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;E(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=ge([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(E(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),z(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:K(l,e.index),routesMeta:u})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of B(e.path))a(e,t,!0,n)}),t}function B(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=B(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function V(e){e.sort((e,t)=>e.score===t.score?re(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var H=/^:[\w-]+$/,U=3,W=2,G=1,ee=10,te=-2,ne=e=>e===`*`;function K(e,t){let n=e.split(`/`),r=n.length;return n.some(ne)&&(r+=te),t&&(r+=W),n.filter(e=>!ne(e)).reduce((e,t)=>e+(H.test(t)?U:t===``?G:ee),r)}function re(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function ie(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function q(e,t=!1,n=!0){D(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function oe(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return D(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function se(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}var ce=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function le(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?M(e):e,a;return n?(n=he(n),a=n.startsWith(`/`)?ue(n.substring(1),`/`):ue(n,t)):a=t,{pathname:a,search:ye(r),hash:be(i)}}function ue(e,t){let n=_e(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function de(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function fe(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function pe(e){let t=fe(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function me(e,t,n,r=!1){let i;typeof e==`string`?i=M(e):(i={...e},E(!i.pathname||!i.pathname.includes(`?`),de(`?`,`pathname`,`search`,i)),E(!i.pathname||!i.pathname.includes(`#`),de(`#`,`pathname`,`hash`,i)),E(!i.search||!i.search.includes(`#`),de(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=le(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var he=e=>e.replace(/\/\/+/g,`/`),ge=e=>he(e.join(`/`)),_e=e=>e.replace(/\/+$/,``),ve=e=>_e(e).replace(/^\/*/,`/`),ye=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,be=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,xe=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function Se(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Ce(e){return ge(e.map(e=>e.route.path).filter(Boolean))||`/`}var we=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function Te(e,t){let n=e;if(typeof n!=`string`||!ce.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(we)try{let e=new URL(window.location.href),r=n.startsWith(`//`)?new URL(e.protocol+n):new URL(n),a=se(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{D(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var Ee=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Ee);var De=[`GET`,...Ee];new Set(De);var Oe=S.createContext(null);Oe.displayName=`DataRouter`;var ke=S.createContext(null);ke.displayName=`DataRouterState`;var Ae=S.createContext(!1);function je(){return S.useContext(Ae)}var Me=S.createContext({isTransitioning:!1});Me.displayName=`ViewTransition`;var Ne=S.createContext(new Map);Ne.displayName=`Fetchers`;var Pe=S.createContext(null);Pe.displayName=`Await`;var Fe=S.createContext(null);Fe.displayName=`Navigation`;var Ie=S.createContext(null);Ie.displayName=`Location`;var Le=S.createContext({outlet:null,matches:[],isDataRoute:!1});Le.displayName=`Route`;var Re=S.createContext(null);Re.displayName=`RouteError`;var ze=`REACT_ROUTER_ERROR`,Be=`REDIRECT`,Ve=`ROUTE_ERROR_RESPONSE`;function He(e){if(e.startsWith(`${ze}:${Be}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function Ue(e){if(e.startsWith(`${ze}:${Ve}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new xe(t.status,t.statusText,t.data)}catch{}}function We(e,{relative:t}={}){E(Ge(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=S.useContext(Fe),{hash:i,pathname:a,search:o}=et(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:ge([n,a])),r.createHref({pathname:s,search:o,hash:i})}function Ge(){return S.useContext(Ie)!=null}function Ke(){return E(Ge(),`useLocation() may be used only in the context of a component.`),S.useContext(Ie).location}var qe=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function Je(e){S.useContext(Fe).static||S.useLayoutEffect(e)}function Ye(){let{isDataRoute:e}=S.useContext(Le);return e?yt():Xe()}function Xe(){E(Ge(),`useNavigate() may be used only in the context of a component.`);let e=S.useContext(Oe),{basename:t,navigator:n}=S.useContext(Fe),{matches:r}=S.useContext(Le),{pathname:i}=Ke(),a=JSON.stringify(pe(r)),o=S.useRef(!1);return Je(()=>{o.current=!0}),S.useCallback((r,s={})=>{if(D(o.current,qe),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=me(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:ge([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var Ze=S.createContext(null);function Qe(e){let t=S.useContext(Le).outlet;return S.useMemo(()=>t&&S.createElement(Ze.Provider,{value:e},t),[t,e])}function $e(){let{matches:e}=S.useContext(Le);return e[e.length-1]?.params??{}}function et(e,{relative:t}={}){let{matches:n}=S.useContext(Le),{pathname:r}=Ke(),i=JSON.stringify(pe(n));return S.useMemo(()=>me(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function tt(e,t){return nt(e,t)}function nt(e,t,n){E(Ge(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=S.useContext(Fe),{matches:i}=S.useContext(Le),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;xt(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. - -Please change the parent to .`)}let u=Ke(),d;if(t){let e=typeof t==`string`?M(t):t;E(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):F(e,{pathname:p});D(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),D(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=lt(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:ge([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:ge([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?S.createElement(Ie.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function rt(){let e=vt(),t=Se(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=S.createElement(S.Fragment,null,S.createElement(`p`,null,`💿 Hey developer 👋`),S.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,S.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,S.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),S.createElement(S.Fragment,null,S.createElement(`h2`,null,`Unexpected Application Error!`),S.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?S.createElement(`pre`,{style:i},n):null,o)}var it=S.createElement(rt,null),at=class extends S.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=Ue(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:S.createElement(Le.Provider,{value:this.props.routeContext},S.createElement(Re.Provider,{value:e,children:this.props.component}));return this.context?S.createElement(st,{error:e},t):t}};at.contextType=Ae;var ot=new WeakMap;function st({children:e,error:t}){let{basename:n}=S.useContext(Fe);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=He(t.digest);if(e){let r=ot.get(t);if(r)throw r;let i=Te(e.location,n);if(we&&!ot.get(t))if(i.isExternal||e.reloadDocument)window.location.href=i.absoluteURL||i.to;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw ot.set(t,n),n}return S.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${i.absoluteURL||i.to}`})}}return e}function ct({routeContext:e,match:t,children:n}){let r=S.useContext(Oe);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),S.createElement(Le.Provider,{value:e},n)}function lt(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);E(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Ce(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||it,o&&(s<0&&c===0?(xt(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?S.createElement(n.route.Component,null):n.route.element?n.route.element:e,S.createElement(ct,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?S.createElement(at,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function ut(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function dt(e){let t=S.useContext(Oe);return E(t,ut(e)),t}function ft(e){let t=S.useContext(ke);return E(t,ut(e)),t}function pt(e){let t=S.useContext(Le);return E(t,ut(e)),t}function mt(e){let t=pt(e),n=t.matches[t.matches.length-1];return E(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function ht(){return mt(`useRouteId`)}function gt(){return ft(`useNavigation`).navigation}function _t(){let{matches:e,loaderData:t}=ft(`useMatches`);return S.useMemo(()=>e.map(e=>L(e,t)),[e,t])}function vt(){let e=S.useContext(Re),t=ft(`useRouteError`),n=mt(`useRouteError`);return e===void 0?t.errors?.[n]:e}function yt(){let{router:e}=dt(`useNavigate`),t=mt(`useNavigate`),n=S.useRef(!1);return Je(()=>{n.current=!0}),S.useCallback(async(r,i={})=>{D(n.current,qe),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var bt={};function xt(e,t,n){!t&&!bt[e]&&(bt[e]=!0,D(!1,n))}S.memo(St);function St({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return nt(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Ct({to:e,replace:t,state:n,relative:r}){E(Ge(),` may be used only in the context of a component.`);let{static:i}=S.useContext(Fe);D(!i,` must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.`);let{matches:a}=S.useContext(Le),{pathname:o}=Ke(),s=Ye(),c=me(e,pe(a),o,r===`path`),l=JSON.stringify(c);return S.useEffect(()=>{s(JSON.parse(l),{replace:t,state:n,relative:r})},[s,l,r,t,n]),null}function wt(e){return Qe(e.context)}function Tt(e){E(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function Et({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){E(!Ge(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=S.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=M(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=S.useMemo(()=>{let e=se(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return D(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:S.createElement(Fe.Provider,{value:c},S.createElement(Ie.Provider,{children:t,value:h}))}function Dt({children:e,location:t}){return tt(Ot(e),t)}S.Component;function Ot(e,t=[]){let n=[];return S.Children.forEach(e,(e,r)=>{if(!S.isValidElement(e))return;let i=[...t,r];if(e.type===S.Fragment){n.push.apply(n,Ot(e.props.children,i));return}E(e.type===Tt,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),E(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=Ot(e.props.children,i)),n.push(a)}),n}var kt=`get`,At=`application/x-www-form-urlencoded`;function jt(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Mt(e){return jt(e)&&e.tagName.toLowerCase()===`button`}function Nt(e){return jt(e)&&e.tagName.toLowerCase()===`form`}function Pt(e){return jt(e)&&e.tagName.toLowerCase()===`input`}function Ft(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function It(e,t){return e.button===0&&(!t||t===`_self`)&&!Ft(e)}function Lt(e=``){return new URLSearchParams(typeof e==`string`||Array.isArray(e)||e instanceof URLSearchParams?e:Object.keys(e).reduce((t,n)=>{let r=e[n];return t.concat(Array.isArray(r)?r.map(e=>[n,e]):[[n,r]])},[]))}function Rt(e,t){let n=Lt(e);return t&&t.forEach((e,r)=>{n.has(r)||t.getAll(r).forEach(e=>{n.append(r,e)})}),n}var zt=null;function Bt(){if(zt===null)try{new FormData(document.createElement(`form`),0),zt=!1}catch{zt=!0}return zt}var Vt=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function Ht(e){return e!=null&&!Vt.has(e)?(D(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${At}"`),null):e}function Ut(e,t){let n,r,i,a,o;if(Nt(e)){let o=e.getAttribute(`action`);r=o?se(o,t):null,n=e.getAttribute(`method`)||kt,i=Ht(e.getAttribute(`enctype`))||At,a=new FormData(e)}else if(Mt(e)||Pt(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a - - + 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