1
This commit is contained in:
@@ -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 ###
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""项目 CLI 脚本包。"""
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.enums.upload_resource import UploadResourceEventEnum
|
||||
from app.services.upload_resource.backfill_service import BackfillOptions, run_upload_resource_backfill
|
||||
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
||||
|
||||
|
||||
VALID_MODULES = {"common", "hot_opening_replicate", "shot_replicate"}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="补录 storage/uploads 下的上传资源账本,并可重绑模块归属。")
|
||||
parser.add_argument("--root", default="storage/uploads", help="扫描根目录,默认 storage/uploads")
|
||||
parser.add_argument("--batch-size", type=int, default=500, help="每批处理数量,默认 500")
|
||||
parser.add_argument("--dry-run", action="store_true", help="试跑,不写数据库")
|
||||
parser.add_argument("--commit", action="store_true", help="真实执行写入")
|
||||
parser.add_argument("--include-legacy", action="store_true", help="补录 gen_{user_id}_* 等旧版散落文件")
|
||||
parser.add_argument("--rebind-modules", action="store_true", help="根据 hot/shot 业务表中保存的 URL 重新绑定模块归属")
|
||||
parser.add_argument("--rebuild-stats", action="store_true", help="补录完成后按资源账本重算容量统计")
|
||||
parser.add_argument("--only-user-id", default=None, help="只处理指定用户ID")
|
||||
parser.add_argument("--only-module", choices=sorted(VALID_MODULES), default=None, help="只处理指定模块")
|
||||
parser.add_argument("--cleanup-pending-files", action="store_true", help="清理已软删但真实文件待删除/删除失败的 UploadResource 文件")
|
||||
parser.add_argument("--cleanup-limit", type=int, default=500, help="每次最多清理待删除真实文件数量,默认 500")
|
||||
return parser
|
||||
|
||||
|
||||
async def amain(argv: list[str]) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.dry_run and args.commit:
|
||||
parser.error("--dry-run 和 --commit 不能同时使用")
|
||||
if not args.dry_run and not args.commit:
|
||||
args.dry_run = True
|
||||
if args.batch_size <= 0:
|
||||
parser.error("--batch-size 必须大于 0")
|
||||
|
||||
options = BackfillOptions(
|
||||
root=args.root,
|
||||
batch_size=args.batch_size,
|
||||
dry_run=not args.commit,
|
||||
include_legacy=args.include_legacy,
|
||||
rebind_modules=args.rebind_modules,
|
||||
rebuild_stats=args.rebuild_stats,
|
||||
only_user_id=args.only_user_id,
|
||||
only_module=args.only_module,
|
||||
cleanup_pending_files=args.cleanup_pending_files,
|
||||
cleanup_limit=args.cleanup_limit,
|
||||
)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
result = await run_upload_resource_backfill(db, options)
|
||||
if options.dry_run:
|
||||
await db.rollback()
|
||||
else:
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await safe_rollback_with_log(
|
||||
db,
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_BACKFILL_CLI_ROLLBACK_FAILED.value,
|
||||
message="UploadResource 补录/清理 CLI 回滚失败",
|
||||
detail={"argv": argv, "options": vars(args)},
|
||||
original_exc=exc,
|
||||
)
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_BACKFILL_CLI_FAILED.value,
|
||||
message=f"UploadResource 补录/清理 CLI 执行失败: {exc}",
|
||||
detail={"argv": argv, "options": vars(args)},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return asyncio.run(amain(sys.argv[1:]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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: "拆镜切片",
|
||||
}
|
||||
@@ -22,6 +22,7 @@ from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
|
||||
from app.models.chat_provider_call_log import ChatProviderCallLog
|
||||
from app.models.generated_resource import GeneratedResource
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user_resource_month_stat import UserResourceMonthStat
|
||||
from app.models.user_resource_total_stat import UserResourceTotalStat
|
||||
from app.models.user_resource_capacity_config import UserResourceCapacityConfig
|
||||
@@ -44,7 +45,7 @@ __all__ = [
|
||||
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
||||
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
|
||||
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
|
||||
"GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat",
|
||||
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
|
||||
"UserResourceCapacityConfig",
|
||||
"ModuleGenerationProject", "ModuleGenerationStep",
|
||||
"ShotReplicateTaskSet", "ShotReplicateSegment",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Float, ForeignKey, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class UploadResource(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""统一上传资源账本。
|
||||
|
||||
记录用户直接上传、模块上传和拆镜切片文件,不建立 ORM relationship,
|
||||
通过 source_model + source_id 进行低耦合绑定。
|
||||
"""
|
||||
|
||||
__tablename__ = "upload_resources"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("storage_path", name="uq_upload_resources_storage_path"),
|
||||
Index("ix_upload_resources_user_module_type", "user_id", "module", "resource_type", "deleted_at"),
|
||||
Index("ix_upload_resources_user_bind", "user_id", "bind_status", "deleted_at"),
|
||||
Index("ix_upload_resources_history_lookup", "user_id", "resource_type", "bind_status", "delete_policy", "deleted_at", "created_at"),
|
||||
Index("ix_upload_resources_source", "source_model", "source_id", "deleted_at"),
|
||||
Index("ix_upload_resources_url", "resource_url"),
|
||||
Index("ix_upload_resources_created", "created_at"),
|
||||
Index("ix_upload_resources_file_delete_status", "file_delete_status"),
|
||||
Index("ix_upload_resources_physical_deleted", "physical_deleted_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
|
||||
module: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
resource_type: Mapped[str] = mapped_column(String(32), index=True, nullable=False)
|
||||
|
||||
resource_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
file_ext: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
duration_source: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
width: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
height: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
source_model: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
source_id: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
|
||||
source_module: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
bind_status: Mapped[str] = mapped_column(String(32), default="pending", server_default="pending", index=True, nullable=False)
|
||||
delete_policy: Mapped[str] = mapped_column(String(32), default="user_deletable", server_default="user_deletable", index=True, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(String(32), default="api", server_default="api", index=True, nullable=False)
|
||||
|
||||
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
capacity_released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
physical_deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
file_delete_status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default="active",
|
||||
server_default="active",
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
file_delete_error: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
)
|
||||
@@ -23,13 +23,19 @@ class UserResourceMonthStat(Base, TimestampMixin):
|
||||
active_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
deleted_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
total_generated_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
upload_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
image_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
video_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
audio_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
shot_segment_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
active_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
deleted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
image_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
video_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
upload_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
audio_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
shot_segment_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
|
||||
last_recalculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -22,13 +22,19 @@ class UserResourceTotalStat(Base, TimestampMixin):
|
||||
active_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
deleted_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
total_generated_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
upload_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
image_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
video_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||
audio_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
shot_segment_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False)
|
||||
|
||||
active_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
deleted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
image_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
video_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
upload_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
audio_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
shot_segment_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
||||
|
||||
last_recalculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -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="素材云历史来源中文名称")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -227,6 +227,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel):
|
||||
)
|
||||
|
||||
material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值。用于新产品/目标素材图片,来自已有上传接口")
|
||||
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的图片 UploadResource.id")
|
||||
source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值")
|
||||
target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值")
|
||||
core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值")
|
||||
@@ -243,7 +244,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_at_least_one(self) -> "ShotReplicateMaterialUpdateRequest":
|
||||
if not any(getattr(self, field) is not None for field in ("material_image_url", "source_project_name", "target_project_name", "core_content_point")):
|
||||
if not any(getattr(self, field) is not None for field in ("material_image_url", "source_project_name", "target_project_name", "core_content_point", "material_image_resource_id")):
|
||||
raise ValueError("至少需要传入一个需要修改的字段")
|
||||
return self
|
||||
|
||||
@@ -482,9 +483,11 @@ class ShotReplicateActionOut(BaseModel):
|
||||
|
||||
class ShotReplicateDeleteOut(BaseModel):
|
||||
message: str = Field(..., description="删除结果提示")
|
||||
project_id: str = Field(..., description="被软删除的总任务项目ID")
|
||||
project_id: str = Field(..., description="被软删除的内部拆镜复刻 ModuleGenerationProject ID")
|
||||
deleted: bool = Field(..., description="是否已软删除")
|
||||
released_size_bytes: int = Field(0, description="本次软删释放的用户容量占用字节数;不删除物理文件")
|
||||
released_size_bytes: int = Field(0, description="本次软删释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
|
||||
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
|
||||
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
|
||||
|
||||
|
||||
class ShotSegmentDeleteOut(BaseModel):
|
||||
@@ -493,7 +496,20 @@ class ShotSegmentDeleteOut(BaseModel):
|
||||
task_set_id: str = Field(..., description="所属拆镜总任务集ID")
|
||||
deleted: bool = Field(..., description="是否已软删除")
|
||||
deleted_module_project_id: str | None = Field(None, description="联动软删除的拆镜复刻项目ID;没有关联项目时为空")
|
||||
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;只释放数据账本,不删除物理文件")
|
||||
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
|
||||
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
|
||||
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
|
||||
|
||||
|
||||
class ShotTaskSetDeleteOut(BaseModel):
|
||||
message: str = Field(..., description="删除结果提示")
|
||||
task_set_id: str = Field(..., description="被软删除的拆镜任务集ID")
|
||||
deleted: bool = Field(..., description="是否已软删除")
|
||||
deleted_segment_count: int = Field(0, description="本次软删除的片段数量")
|
||||
deleted_module_project_count: int = Field(0, description="本次联动软删除的内部复刻项目数量")
|
||||
released_size_bytes: int = Field(0, description="本次释放的用户容量占用字节数;UploadResource 真实文件在事务提交后清理")
|
||||
upload_resource_released: int = Field(0, description="本次标记删除的上传资源数量")
|
||||
pending_delete_resource_ids: list[str] = Field(default_factory=list, exclude=True, description="内部字段:commit 后待删除真实文件的 UploadResource ID")
|
||||
|
||||
|
||||
|
||||
@@ -568,6 +584,7 @@ class ShotTaskSetCreate(BaseModel):
|
||||
"example": {
|
||||
"video_url": "/uploads/2026/06/11/demo.mp4",
|
||||
"video_duration_seconds": 31.42,
|
||||
"video_resource_id": "0019...",
|
||||
"title": "游戏视频拆镜",
|
||||
"idempotency_key": "frontend-shot-task-001",
|
||||
}
|
||||
@@ -576,6 +593,7 @@ class ShotTaskSetCreate(BaseModel):
|
||||
|
||||
video_url: str = Field(..., min_length=1, description="已有上传接口返回的视频链接。必须能反解到 storage/uploads 下文件")
|
||||
video_duration_seconds: float = Field(..., gt=0, description="前端获取的视频时长秒数,允许浮点;后端会用 ffprobe 校验并以后端真实时长为准")
|
||||
video_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的视频 UploadResource.id;创建任务集后用于绑定资源")
|
||||
title: str | None = Field(None, max_length=160, description="拆镜总任务标题")
|
||||
idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键")
|
||||
|
||||
@@ -742,6 +760,7 @@ class ShotSegmentReplicationCreateRequest(BaseModel):
|
||||
"target_project_name": "新产品推广视频",
|
||||
"core_content_point": "突出产品附近交友和快速脱单",
|
||||
"material_image_url": "/uploads/2026/06/11/product.png",
|
||||
"material_image_resource_id": "0019...",
|
||||
"idempotency_key": "optional-key",
|
||||
}
|
||||
}
|
||||
@@ -750,9 +769,10 @@ class ShotSegmentReplicationCreateRequest(BaseModel):
|
||||
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称,最多20字;会写入 module_generation_projects.title 和第1步 material_input")
|
||||
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字;用于后续图片 AI 提词和视频 AI 提词")
|
||||
material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口;作为图片生成参考素材")
|
||||
material_image_resource_id: str | None = Field(None, max_length=32, description="可选,拆镜模块上传接口返回的图片 UploadResource.id;创建复刻项目后用于绑定资源")
|
||||
idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键;为空时后端可按业务生成或不使用")
|
||||
|
||||
@field_validator("target_project_name", "core_content_point", "material_image_url", "idempotency_key", mode="before")
|
||||
@field_validator("target_project_name", "core_content_point", "material_image_url", "material_image_resource_id", "idempotency_key", mode="before")
|
||||
@classmethod
|
||||
def _strip_text(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
|
||||
class UploadResourceMediaReferenceOut(BaseModel):
|
||||
"""AI 创作可直接复用的上传素材参考结构。"""
|
||||
|
||||
name: str | None = Field(None, description="参考素材名称,前端展示用")
|
||||
type: Literal["image", "video", "audio"] = Field(..., description="参考素材类型:image=图片,video=视频,audio=音频")
|
||||
url: str = Field(..., description="参考素材 URL,创建 AI 创作任务时传入 media_references[].url")
|
||||
label: str | None = Field("", description="前端展示标签,默认空,由 AI 创作页重新计算")
|
||||
duration: float | None = Field(None, ge=0, description="素材时长秒数,type=video/audio 时必须有有效值")
|
||||
source: Literal["upload_resource"] = Field("upload_resource", description="参考素材来源固定为 upload_resource")
|
||||
upload_resource_id: str = Field(..., description="UploadResource 本地资源账本 ID")
|
||||
|
||||
|
||||
class UploadResourceHistoryItemOut(BaseModel):
|
||||
"""上传资源历史素材项。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": "0019e0a448a23114888",
|
||||
"source_type": "upload_resource",
|
||||
"history_source": "upload_resource",
|
||||
"history_source_label": "历史上传素材",
|
||||
"module": "common",
|
||||
"module_label": "普通上传",
|
||||
"resource_type": "video",
|
||||
"resource_type_label": "视频",
|
||||
"resource_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"display_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"preview_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"video_url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"duration_seconds": 5.2,
|
||||
"bind_status": "pending",
|
||||
"delete_policy": "user_deletable",
|
||||
"deletable": True,
|
||||
"media_reference": {
|
||||
"name": "video_ref_xxx.mp4",
|
||||
"type": "video",
|
||||
"url": "/uploads/videos/2026/07/08/video_ref_xxx.mp4",
|
||||
"duration": 5.2,
|
||||
"source": "upload_resource",
|
||||
"upload_resource_id": "0019e0a448a23114888",
|
||||
},
|
||||
"created_at": "2026-07-08T13:20:00",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: str = Field(..., description="UploadResource.id")
|
||||
source_type: Literal["upload_resource"] = Field("upload_resource", description="前端判定来源固定值")
|
||||
history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource")
|
||||
history_source_label: str = Field("历史上传素材", description="素材云历史来源中文名称")
|
||||
|
||||
module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻")
|
||||
module_label: str = Field(..., description="上传资源所属模块中文名称")
|
||||
resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频")
|
||||
resource_type_label: str = Field(..., description="资源类型中文名称")
|
||||
|
||||
resource_url: str = Field(..., description="原始资源 URL,可传给 AI 创作 media_references[].url")
|
||||
display_url: str = Field(..., description="前端展示 URL。当前普通上传资源通常与 resource_url 一致")
|
||||
preview_url: str = Field(..., description="前端预览 URL。当前普通上传资源通常与 display_url 一致")
|
||||
image_url: str | None = Field(None, description="图片资源 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,
|
||||
}
|
||||
@@ -83,6 +83,8 @@ from app.services.module_generation_step_update_service import (
|
||||
update_module_video_prompt_schema,
|
||||
)
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||
from app.services.upload_resource import release_upload_resources_by_source
|
||||
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot, get_runtime_schema_snapshot
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
@@ -501,6 +503,9 @@ async def create_hot_opening_project(db: AsyncSession, current_user: User, req:
|
||||
input_data={
|
||||
"material_video_url": req.material_video_url,
|
||||
"material_image_url": req.material_image_url,
|
||||
"material_video_resource_id": req.material_video_resource_id,
|
||||
"material_image_resource_id": req.material_image_resource_id,
|
||||
"material_video_duration_seconds": req.material_video_duration_seconds,
|
||||
"source_project_name": req.source_project_name,
|
||||
"target_project_name": req.target_project_name,
|
||||
"core_content_point": req.core_content_point,
|
||||
@@ -1506,11 +1511,37 @@ async def mark_hot_opening_step_dispatch_failed(
|
||||
|
||||
async def delete_hot_opening_project(db: AsyncSession, *, current_user: User, project_id: str) -> HotOpeningDeleteOut:
|
||||
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
|
||||
project_id_snapshot = project.id
|
||||
deleted_at = _now()
|
||||
project.deleted_at = deleted_at
|
||||
await _soft_delete_steps_from_index(db, project=project, start_index=1, deleted_at=deleted_at)
|
||||
await log_module_event(db, project=project, event_type=ModuleEventTypeEnum.PROJECT_DELETED.value, message="软删除爆款开头复刻项目")
|
||||
return HotOpeningDeleteOut(message="项目已删除", project_id=project.id, deleted=True)
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_ids=[project_id_snapshot],
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
)
|
||||
pending_ids = list(upload_release.get("released_resource_ids") or [])
|
||||
released_size = int(upload_release.get("released_size_bytes") or 0)
|
||||
upload_resource_released = int(upload_release.get("released") or 0)
|
||||
await log_module_event(
|
||||
db,
|
||||
project=project,
|
||||
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
|
||||
message="软删除爆款开头复刻项目",
|
||||
detail={
|
||||
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
|
||||
"pending_delete_resource_count": len(pending_ids),
|
||||
},
|
||||
)
|
||||
return HotOpeningDeleteOut(
|
||||
message="项目已删除",
|
||||
project_id=project_id_snapshot,
|
||||
deleted=True,
|
||||
released_size_bytes=released_size,
|
||||
upload_resource_released=upload_resource_released,
|
||||
pending_delete_resource_ids=pending_ids,
|
||||
)
|
||||
|
||||
def _build_file_url_or_data_uri(file_url: str) -> str:
|
||||
return _common_build_file_url_or_data_uri(file_url)
|
||||
|
||||
@@ -88,6 +88,8 @@ from app.services.module_generation_step_update_service import (
|
||||
update_module_video_prompt_schema,
|
||||
)
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||
from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source
|
||||
from app.services.video_prompt_schema_config_service import fallback_runtime_schema_snapshot, get_runtime_schema_snapshot
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
@@ -513,6 +515,7 @@ async def create_shot_replicate_project(db: AsyncSession, current_user: User, re
|
||||
input_data={
|
||||
"material_video_url": req.material_video_url,
|
||||
"material_image_url": req.material_image_url,
|
||||
"material_image_resource_id": getattr(req, "material_image_resource_id", None),
|
||||
"source_project_name": req.source_project_name,
|
||||
"target_project_name": req.target_project_name,
|
||||
"core_content_point": req.core_content_point,
|
||||
@@ -1493,7 +1496,13 @@ async def delete_shot_replicate_project(
|
||||
project_id: str,
|
||||
refund_unfinished: bool = False,
|
||||
) -> ShotReplicateDeleteOut:
|
||||
"""内部 helper:只软删除指定 ModuleGenerationProject 自身。
|
||||
|
||||
这里不是对外 API,不反查 ShotReplicateSegment / ShotReplicateTaskSet,
|
||||
不 commit、不 rollback、不删除真实 UploadResource 文件。
|
||||
"""
|
||||
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
|
||||
project_id_snapshot = project.id
|
||||
deleted_at = _now()
|
||||
release_stats: dict[str, int] = {"released_size_bytes": 0}
|
||||
|
||||
@@ -1509,21 +1518,34 @@ async def delete_shot_replicate_project(
|
||||
refund_unfinished=refund_unfinished,
|
||||
release_stats=release_stats,
|
||||
)
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_ids=[project_id_snapshot],
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
)
|
||||
pending_ids = list(upload_release.get("released_resource_ids") or [])
|
||||
generated_released = int(release_stats.get("released_size_bytes", 0) or 0)
|
||||
upload_released_size = int(upload_release.get("released_size_bytes") or 0)
|
||||
await log_module_event(
|
||||
db,
|
||||
project=project,
|
||||
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
|
||||
message="软删除拆镜复刻项目",
|
||||
message="软删除拆镜复刻内部项目",
|
||||
detail={
|
||||
"refund_unfinished": refund_unfinished,
|
||||
"released_size_bytes": int(release_stats.get("released_size_bytes", 0)),
|
||||
"generated_resource_released_size_bytes": generated_released,
|
||||
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
|
||||
"pending_delete_resource_count": len(pending_ids),
|
||||
},
|
||||
)
|
||||
return ShotReplicateDeleteOut(
|
||||
message="项目已删除",
|
||||
project_id=project.id,
|
||||
project_id=project_id_snapshot,
|
||||
deleted=True,
|
||||
released_size_bytes=int(release_stats.get("released_size_bytes", 0)),
|
||||
released_size_bytes=generated_released + upload_released_size,
|
||||
upload_resource_released=int(upload_release.get("released") or 0),
|
||||
pending_delete_resource_ids=pending_ids,
|
||||
)
|
||||
|
||||
|
||||
@@ -1583,6 +1605,7 @@ async def create_shot_replicate_project_from_segment(
|
||||
"material_video_url": segment.segment_video_url,
|
||||
"material_video_locked": True,
|
||||
"material_image_url": req.material_image_url,
|
||||
"material_image_resource_id": getattr(req, "material_image_resource_id", None),
|
||||
"source_project_name": segment.segment_category or segment.original_video_category or "拆镜片段",
|
||||
"target_project_name": req.target_project_name,
|
||||
"core_content_point": req.core_content_point,
|
||||
@@ -1626,6 +1649,16 @@ async def create_shot_replicate_project_from_segment(
|
||||
)
|
||||
project.current_step_code = ShotReplicateStepCodeEnum.MATERIAL_INPUT.value
|
||||
project.status = ModuleProjectStatusEnum.WAITING_USER.value
|
||||
await bind_upload_resources(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project.id,
|
||||
resource_ids=[getattr(req, "material_image_resource_id", None)],
|
||||
urls=[req.material_image_url, segment.segment_video_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
segment.module_project_id = project.id
|
||||
segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value
|
||||
await log_module_event(
|
||||
|
||||
@@ -36,12 +36,15 @@ from app.schemas.shot_replicate import (
|
||||
ShotSplitCustomOut,
|
||||
ShotSplitCustomRequest,
|
||||
ShotTaskSetCreate,
|
||||
ShotTaskSetDeleteOut,
|
||||
ShotTaskSetDetailOut,
|
||||
ShotTaskSetListOut,
|
||||
ShotTaskSetOut,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_event_file
|
||||
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||
from app.services.upload_resource import release_upload_resources_by_source
|
||||
from app.services.upload_video_asset_service import (
|
||||
build_time_node,
|
||||
validate_split_range,
|
||||
@@ -667,6 +670,15 @@ async def delete_segment(
|
||||
source_ids=[segment.id],
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
|
||||
source_ids=[segment.id],
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
)
|
||||
pending_delete_resource_ids: list[str] = list(upload_release.get("released_resource_ids") or [])
|
||||
released_size_bytes += int(upload_release.get("released_size_bytes") or 0)
|
||||
upload_resource_released = int(upload_release.get("released") or 0)
|
||||
|
||||
deleted_module_project_id: str | None = None
|
||||
if module_project_id:
|
||||
@@ -680,6 +692,8 @@ async def delete_segment(
|
||||
)
|
||||
deleted_module_project_id = project_delete_out.project_id
|
||||
released_size_bytes += int(project_delete_out.released_size_bytes or 0)
|
||||
upload_resource_released += int(project_delete_out.upload_resource_released or 0)
|
||||
pending_delete_resource_ids.extend(project_delete_out.pending_delete_resource_ids or [])
|
||||
|
||||
segment.deleted_at = deleted_at
|
||||
segment.replicate_status = (
|
||||
@@ -704,7 +718,9 @@ async def delete_segment(
|
||||
"module_project_id": module_project_id,
|
||||
"deleted_module_project_id": deleted_module_project_id,
|
||||
"released_size_bytes": released_size_bytes,
|
||||
"physical_file_deleted": False,
|
||||
"upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"},
|
||||
"pending_delete_resource_count": len(pending_delete_resource_ids),
|
||||
"physical_file_delete": "after_commit",
|
||||
"refund": False,
|
||||
},
|
||||
)
|
||||
@@ -716,9 +732,127 @@ async def delete_segment(
|
||||
deleted=True,
|
||||
deleted_module_project_id=deleted_module_project_id,
|
||||
released_size_bytes=int(released_size_bytes or 0),
|
||||
upload_resource_released=upload_resource_released,
|
||||
pending_delete_resource_ids=pending_delete_resource_ids,
|
||||
)
|
||||
|
||||
|
||||
async def delete_task_set(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
task_set_id: str,
|
||||
) -> ShotTaskSetDeleteOut:
|
||||
"""软删除整个拆镜任务集。
|
||||
|
||||
对外删除入口以 ShotReplicateTaskSet 为边界;内部 ModuleGenerationProject
|
||||
只作为片段复刻链路被联动软删。这里不 commit、不 rollback、不删除真实文件。
|
||||
"""
|
||||
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
|
||||
task_set_id_snapshot = task_set.id
|
||||
user_id_snapshot = task_set.user_id
|
||||
|
||||
if task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail="原视频分析正在处理中,暂不能删除任务集")
|
||||
if task_set.split_status == ShotSplitStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail="拆镜切片正在处理中,暂不能删除任务集")
|
||||
|
||||
segments_result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
.where(
|
||||
ShotReplicateSegment.task_set_id == task_set_id_snapshot,
|
||||
ShotReplicateSegment.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
segments = list(segments_result.scalars().all())
|
||||
for segment in segments:
|
||||
if segment.split_status == ShotSplitStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在切割处理中,暂不能删除任务集")
|
||||
if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在分析处理中,暂不能删除任务集")
|
||||
if segment.replicate_status == ShotSegmentReplicateStatusEnum.PROCESSING.value:
|
||||
raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}关联复刻流程正在处理中,暂不能删除任务集")
|
||||
|
||||
segment_ids = [segment.id for segment in segments]
|
||||
module_project_ids = [segment.module_project_id for segment in segments if segment.module_project_id]
|
||||
deleted_at = _now()
|
||||
released_size_bytes = 0
|
||||
upload_resource_released = 0
|
||||
pending_delete_resource_ids: list[str] = []
|
||||
|
||||
task_upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
|
||||
source_ids=[task_set_id_snapshot],
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
)
|
||||
released_size_bytes += int(task_upload_release.get("released_size_bytes") or 0)
|
||||
upload_resource_released += int(task_upload_release.get("released") or 0)
|
||||
pending_delete_resource_ids.extend(task_upload_release.get("released_resource_ids") or [])
|
||||
|
||||
segment_upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
|
||||
source_ids=segment_ids,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
)
|
||||
released_size_bytes += int(segment_upload_release.get("released_size_bytes") or 0)
|
||||
upload_resource_released += int(segment_upload_release.get("released") or 0)
|
||||
pending_delete_resource_ids.extend(segment_upload_release.get("released_resource_ids") or [])
|
||||
|
||||
from app.services.shot_replicate_flow_service import delete_shot_replicate_project
|
||||
|
||||
deleted_module_project_count = 0
|
||||
for module_project_id in dict.fromkeys(module_project_ids):
|
||||
project_delete_out = await delete_shot_replicate_project(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=module_project_id,
|
||||
refund_unfinished=False,
|
||||
)
|
||||
deleted_module_project_count += 1
|
||||
released_size_bytes += int(project_delete_out.released_size_bytes or 0)
|
||||
upload_resource_released += int(project_delete_out.upload_resource_released or 0)
|
||||
pending_delete_resource_ids.extend(project_delete_out.pending_delete_resource_ids or [])
|
||||
|
||||
task_set.deleted_at = deleted_at
|
||||
task_set.status = ShotTaskSetStatusEnum.DELETED.value
|
||||
for segment in segments:
|
||||
segment.deleted_at = deleted_at
|
||||
segment.replicate_status = ShotSegmentReplicateStatusEnum.FAILED.value if segment.module_project_id else segment.replicate_status
|
||||
|
||||
await db.flush()
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type="SHOT_TASK_SET_DELETED",
|
||||
project_id=task_set_id_snapshot,
|
||||
user_id=user_id_snapshot,
|
||||
message="软删除拆镜任务集并标记上传资源待物理删除",
|
||||
detail={
|
||||
"task_set_id": task_set_id_snapshot,
|
||||
"segment_count": len(segment_ids),
|
||||
"module_project_count": deleted_module_project_count,
|
||||
"released_size_bytes": released_size_bytes,
|
||||
"upload_resource_released": upload_resource_released,
|
||||
"pending_delete_resource_count": len(pending_delete_resource_ids),
|
||||
"task_upload_release": {k: v for k, v in task_upload_release.items() if k != "released_resource_ids"},
|
||||
"segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"},
|
||||
"physical_file_delete": "after_commit",
|
||||
"refund": False,
|
||||
},
|
||||
)
|
||||
return ShotTaskSetDeleteOut(
|
||||
message="拆镜任务集已删除",
|
||||
task_set_id=task_set_id_snapshot,
|
||||
deleted=True,
|
||||
deleted_segment_count=len(segment_ids),
|
||||
deleted_module_project_count=deleted_module_project_count,
|
||||
released_size_bytes=int(released_size_bytes or 0),
|
||||
upload_resource_released=upload_resource_released,
|
||||
pending_delete_resource_ids=pending_delete_resource_ids,
|
||||
)
|
||||
|
||||
|
||||
async def prepare_reanalyze_task_set(
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from app.services.upload_resource.core_service import (
|
||||
delete_unbound_upload_resource,
|
||||
record_external_upload_resource,
|
||||
upload_reference_file,
|
||||
)
|
||||
from app.services.upload_resource.bind_service import (
|
||||
bind_upload_resources,
|
||||
release_upload_resources_by_source,
|
||||
record_shot_segment_upload_resource,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"upload_reference_file",
|
||||
"delete_unbound_upload_resource",
|
||||
"record_external_upload_resource",
|
||||
"bind_upload_resources",
|
||||
"release_upload_resources_by_source",
|
||||
"record_shot_segment_upload_resource",
|
||||
"list_upload_resource_history_grouped_days",
|
||||
"list_upload_resource_history_day_items",
|
||||
"mark_upload_resource_history_deleted",
|
||||
"cleanup_upload_resource_history_files",
|
||||
]
|
||||
|
||||
from app.services.upload_resource.file_cleanup_service import (
|
||||
cleanup_pending_upload_resource_files,
|
||||
cleanup_upload_resource_files_after_commit,
|
||||
)
|
||||
|
||||
from app.services.upload_resource.history_service import (
|
||||
list_upload_resource_history_grouped_days,
|
||||
list_upload_resource_history_day_items,
|
||||
)
|
||||
from app.services.upload_resource.delete_service import (
|
||||
cleanup_upload_resource_history_files,
|
||||
mark_upload_resource_history_deleted,
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import UploadResourceTypeEnum
|
||||
from app.models.generated_resource import GeneratedResource
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user_resource_month_stat import UserResourceMonthStat
|
||||
from app.models.user_resource_total_stat import UserResourceTotalStat
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
def resource_month_from_datetime(value: datetime | None = None) -> date:
|
||||
value = value or datetime.now(timezone.utc)
|
||||
return date(value.year, value.month, 1)
|
||||
|
||||
|
||||
def _int(value: Any) -> int:
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def _add_non_negative(obj: Any, field: str, delta: int) -> None:
|
||||
current = _int(getattr(obj, field, 0))
|
||||
setattr(obj, field, max(0, current + int(delta or 0)))
|
||||
|
||||
|
||||
def _add_raw(obj: Any, field: str, delta: int) -> None:
|
||||
current = _int(getattr(obj, field, 0))
|
||||
setattr(obj, field, current + int(delta or 0))
|
||||
|
||||
|
||||
async def get_or_create_month_stat(db: AsyncSession, user_id: str, stat_month: date) -> UserResourceMonthStat:
|
||||
result = await db.execute(
|
||||
select(UserResourceMonthStat).where(
|
||||
UserResourceMonthStat.user_id == user_id,
|
||||
UserResourceMonthStat.stat_month == stat_month,
|
||||
).limit(1)
|
||||
)
|
||||
stat = result.scalar_one_or_none()
|
||||
if stat:
|
||||
return stat
|
||||
stat = UserResourceMonthStat(id=generate_id(), user_id=user_id, stat_month=stat_month)
|
||||
db.add(stat)
|
||||
await db.flush()
|
||||
return stat
|
||||
|
||||
|
||||
async def get_or_create_total_stat(db: AsyncSession, user_id: str, *, for_update: bool = False) -> UserResourceTotalStat:
|
||||
stmt = select(UserResourceTotalStat).where(UserResourceTotalStat.user_id == user_id).limit(1)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
stat = result.scalar_one_or_none()
|
||||
if stat:
|
||||
return stat
|
||||
stat = UserResourceTotalStat(id=generate_id(), user_id=user_id)
|
||||
db.add(stat)
|
||||
await db.flush()
|
||||
if for_update:
|
||||
result = await db.execute(
|
||||
select(UserResourceTotalStat)
|
||||
.where(UserResourceTotalStat.user_id == user_id)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
locked = result.scalar_one_or_none()
|
||||
if locked:
|
||||
return locked
|
||||
return stat
|
||||
|
||||
|
||||
async def apply_upload_resource_stat_delta(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
stat_month: date,
|
||||
resource_type: str,
|
||||
active_size_delta: int = 0,
|
||||
active_count_delta: int = 0,
|
||||
deleted_size_delta: int = 0,
|
||||
deleted_count_delta: int = 0,
|
||||
upload_size_delta: int = 0,
|
||||
upload_count_delta: int = 0,
|
||||
) -> None:
|
||||
month_stat = await get_or_create_month_stat(db, user_id, stat_month)
|
||||
total_stat = await get_or_create_total_stat(db, user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for stat in (month_stat, total_stat):
|
||||
_add_non_negative(stat, "active_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "active_count", active_count_delta)
|
||||
_add_non_negative(stat, "deleted_size_bytes", deleted_size_delta)
|
||||
_add_non_negative(stat, "deleted_count", deleted_count_delta)
|
||||
_add_raw(stat, "upload_size_bytes", upload_size_delta)
|
||||
_add_raw(stat, "upload_count", upload_count_delta)
|
||||
|
||||
if resource_type == UploadResourceTypeEnum.IMAGE.value:
|
||||
_add_non_negative(stat, "image_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "image_count", active_count_delta)
|
||||
elif resource_type == UploadResourceTypeEnum.VIDEO.value:
|
||||
_add_non_negative(stat, "video_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "video_count", active_count_delta)
|
||||
elif resource_type == UploadResourceTypeEnum.AUDIO.value:
|
||||
_add_non_negative(stat, "audio_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "audio_count", active_count_delta)
|
||||
elif resource_type == UploadResourceTypeEnum.SHOT_SEGMENT.value:
|
||||
_add_non_negative(stat, "shot_segment_size_bytes", active_size_delta)
|
||||
_add_non_negative(stat, "shot_segment_count", active_count_delta)
|
||||
|
||||
stat.last_recalculated_at = now
|
||||
|
||||
|
||||
async def release_upload_resource_capacity(db: AsyncSession, resource: UploadResource, *, released_at: datetime | None = None) -> bool:
|
||||
if resource.capacity_released_at is not None:
|
||||
return False
|
||||
released_at = released_at or datetime.now(timezone.utc)
|
||||
stat_month = resource_month_from_datetime(resource.created_at or released_at)
|
||||
size = _int(resource.file_size_bytes)
|
||||
resource.capacity_released_at = released_at
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=resource.user_id,
|
||||
stat_month=stat_month,
|
||||
resource_type=resource.resource_type,
|
||||
active_size_delta=-size,
|
||||
active_count_delta=-1,
|
||||
deleted_size_delta=size,
|
||||
deleted_count_delta=1,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def rebuild_user_resource_stats(db: AsyncSession, *, user_ids: list[str] | None = None) -> dict[str, int]:
|
||||
"""按数据库真实资源账本重算统计。
|
||||
|
||||
说明:这里只重置并重算 user_ids 范围内的统计。未传 user_ids 时重算所有在资源表中出现过的用户。
|
||||
"""
|
||||
if user_ids is None:
|
||||
ids: set[str] = set()
|
||||
for model in (GeneratedResource, UploadResource):
|
||||
result = await db.execute(select(model.user_id).distinct())
|
||||
ids.update(v for v in result.scalars().all() if v)
|
||||
user_ids = sorted(ids)
|
||||
else:
|
||||
user_ids = sorted({v for v in user_ids if v})
|
||||
if not user_ids:
|
||||
return {"users": 0, "month_rows": 0, "total_rows": 0}
|
||||
|
||||
await db.execute(update(UserResourceTotalStat).where(UserResourceTotalStat.user_id.in_(user_ids)).values(
|
||||
active_size_bytes=0,
|
||||
deleted_size_bytes=0,
|
||||
total_generated_size_bytes=0,
|
||||
upload_size_bytes=0,
|
||||
image_size_bytes=0,
|
||||
video_size_bytes=0,
|
||||
audio_size_bytes=0,
|
||||
shot_segment_size_bytes=0,
|
||||
active_count=0,
|
||||
deleted_count=0,
|
||||
upload_count=0,
|
||||
image_count=0,
|
||||
video_count=0,
|
||||
audio_count=0,
|
||||
shot_segment_count=0,
|
||||
last_recalculated_at=datetime.now(timezone.utc),
|
||||
))
|
||||
await db.execute(update(UserResourceMonthStat).where(UserResourceMonthStat.user_id.in_(user_ids)).values(
|
||||
active_size_bytes=0,
|
||||
deleted_size_bytes=0,
|
||||
total_generated_size_bytes=0,
|
||||
upload_size_bytes=0,
|
||||
image_size_bytes=0,
|
||||
video_size_bytes=0,
|
||||
audio_size_bytes=0,
|
||||
shot_segment_size_bytes=0,
|
||||
active_count=0,
|
||||
deleted_count=0,
|
||||
upload_count=0,
|
||||
image_count=0,
|
||||
video_count=0,
|
||||
audio_count=0,
|
||||
shot_segment_count=0,
|
||||
last_recalculated_at=datetime.now(timezone.utc),
|
||||
))
|
||||
|
||||
month_rows = 0
|
||||
total_rows = 0
|
||||
|
||||
gen_rows = await db.execute(
|
||||
select(
|
||||
GeneratedResource.user_id,
|
||||
GeneratedResource.resource_month,
|
||||
GeneratedResource.resource_type,
|
||||
GeneratedResource.deleted_at,
|
||||
func.count(GeneratedResource.id),
|
||||
func.coalesce(func.sum(GeneratedResource.file_size_bytes), 0),
|
||||
).where(GeneratedResource.user_id.in_(user_ids)).group_by(
|
||||
GeneratedResource.user_id,
|
||||
GeneratedResource.resource_month,
|
||||
GeneratedResource.resource_type,
|
||||
GeneratedResource.deleted_at,
|
||||
)
|
||||
)
|
||||
for user_id, month, rtype, deleted_at, count, size in gen_rows.all():
|
||||
active = deleted_at is None
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=user_id,
|
||||
stat_month=month,
|
||||
resource_type=rtype,
|
||||
active_size_delta=int(size or 0) if active else 0,
|
||||
active_count_delta=int(count or 0) if active else 0,
|
||||
deleted_size_delta=0 if active else int(size or 0),
|
||||
deleted_count_delta=0 if active else int(count or 0),
|
||||
)
|
||||
# 生成资源字段单独累加
|
||||
month_stat = await get_or_create_month_stat(db, user_id, month)
|
||||
total_stat = await get_or_create_total_stat(db, user_id)
|
||||
if active:
|
||||
_add_raw(month_stat, "total_generated_size_bytes", int(size or 0))
|
||||
_add_raw(total_stat, "total_generated_size_bytes", int(size or 0))
|
||||
|
||||
upload_rows = await db.execute(
|
||||
select(
|
||||
UploadResource.user_id,
|
||||
func.date_trunc("month", UploadResource.created_at).label("month"),
|
||||
UploadResource.resource_type,
|
||||
UploadResource.deleted_at,
|
||||
func.count(UploadResource.id),
|
||||
func.coalesce(func.sum(UploadResource.file_size_bytes), 0),
|
||||
).where(UploadResource.user_id.in_(user_ids)).group_by(
|
||||
UploadResource.user_id,
|
||||
"month",
|
||||
UploadResource.resource_type,
|
||||
UploadResource.deleted_at,
|
||||
)
|
||||
)
|
||||
for user_id, month_dt, rtype, deleted_at, count, size in upload_rows.all():
|
||||
month = resource_month_from_datetime(month_dt or datetime.now(timezone.utc))
|
||||
active = deleted_at is None
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=user_id,
|
||||
stat_month=month,
|
||||
resource_type=rtype,
|
||||
active_size_delta=int(size or 0) if active else 0,
|
||||
active_count_delta=int(count or 0) if active else 0,
|
||||
deleted_size_delta=0 if active else int(size or 0),
|
||||
deleted_count_delta=0 if active else int(count or 0),
|
||||
upload_size_delta=int(size or 0) if active else 0,
|
||||
upload_count_delta=int(count or 0) if active else 0,
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
total_rows = len(user_ids)
|
||||
month_count = await db.execute(select(func.count(UserResourceMonthStat.id)).where(UserResourceMonthStat.user_id.in_(user_ids)))
|
||||
month_rows = int(month_count.scalar_one() or 0)
|
||||
return {"users": len(user_ids), "month_rows": month_rows, "total_rows": total_rows}
|
||||
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceEventEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceSourceModelEnum,
|
||||
)
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user import User
|
||||
from app.services.upload_resource.accounting_service import rebuild_user_resource_stats
|
||||
from app.services.upload_resource.bind_service import bind_upload_resources
|
||||
from app.services.upload_resource.core_service import record_external_upload_resource
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
from app.services.upload_resource.file_cleanup_service import cleanup_pending_upload_resource_files
|
||||
from app.services.upload_resource.path_resolver import ParsedUploadPath, iter_files, parse_upload_path, upload_url_to_storage_path
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackfillOptions:
|
||||
root: str = "storage/uploads"
|
||||
batch_size: int = 500
|
||||
dry_run: bool = True
|
||||
include_legacy: bool = False
|
||||
rebind_modules: bool = False
|
||||
rebuild_stats: bool = False
|
||||
only_user_id: str | None = None
|
||||
only_module: str | None = None
|
||||
cleanup_pending_files: bool = False
|
||||
cleanup_limit: int = 500
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackfillResult:
|
||||
scanned: int = 0
|
||||
matched: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
existed: int = 0
|
||||
skipped: int = 0
|
||||
rebind_bound: int = 0
|
||||
rebuild_users: int = 0
|
||||
cleanup_files: int = 0
|
||||
cleanup_failed: int = 0
|
||||
skip_reasons: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def skip(self, reason: str) -> None:
|
||||
self.skipped += 1
|
||||
self.skip_reasons[reason] = self.skip_reasons.get(reason, 0) + 1
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"scanned": self.scanned,
|
||||
"matched": self.matched,
|
||||
"inserted": self.inserted,
|
||||
"updated": self.updated,
|
||||
"existed": self.existed,
|
||||
"skipped": self.skipped,
|
||||
"rebind_bound": self.rebind_bound,
|
||||
"rebuild_users": self.rebuild_users,
|
||||
"cleanup_files": self.cleanup_files,
|
||||
"cleanup_failed": self.cleanup_failed,
|
||||
"skip_reasons": self.skip_reasons,
|
||||
}
|
||||
|
||||
|
||||
def _module_allowed(parsed: ParsedUploadPath, only_module: str | None) -> bool:
|
||||
if not only_module:
|
||||
return True
|
||||
if only_module == UploadResourceModuleEnum.COMMON.value:
|
||||
return parsed.module == UploadResourceModuleEnum.COMMON.value
|
||||
if only_module == UploadResourceModuleEnum.SHOT_REPLICATE.value:
|
||||
return parsed.module == UploadResourceModuleEnum.SHOT_REPLICATE.value
|
||||
return parsed.module == only_module
|
||||
|
||||
|
||||
def _collect_urls(value: Any) -> list[str]:
|
||||
urls: list[str] = []
|
||||
if value is None:
|
||||
return urls
|
||||
if isinstance(value, str):
|
||||
if value.startswith("/uploads/"):
|
||||
urls.append(value)
|
||||
return urls
|
||||
if isinstance(value, dict):
|
||||
for v in value.values():
|
||||
urls.extend(_collect_urls(v))
|
||||
return urls
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
urls.extend(_collect_urls(item))
|
||||
return urls
|
||||
|
||||
|
||||
async def _user_exists_map(db: AsyncSession, user_ids: Iterable[str]) -> set[str]:
|
||||
ids = [v for v in dict.fromkeys(user_ids) if v]
|
||||
if not ids:
|
||||
return set()
|
||||
result = await db.execute(select(User.id).where(User.id.in_(ids)))
|
||||
return set(result.scalars().all())
|
||||
|
||||
|
||||
async def _segment_map(db: AsyncSession, segment_ids: Iterable[str]) -> dict[str, ShotReplicateSegment]:
|
||||
ids = [v for v in dict.fromkeys(segment_ids) if v]
|
||||
if not ids:
|
||||
return {}
|
||||
result = await db.execute(select(ShotReplicateSegment).where(ShotReplicateSegment.id.in_(ids)))
|
||||
return {item.id: item for item in result.scalars().all()}
|
||||
|
||||
|
||||
async def _process_batch(db: AsyncSession, parsed_items: list[ParsedUploadPath], result: BackfillResult, options: BackfillOptions) -> None:
|
||||
if not parsed_items:
|
||||
return
|
||||
|
||||
segment_ids = [p.source_id for p in parsed_items if p.resource_type == "shot_segment" and p.source_id]
|
||||
segments = await _segment_map(db, segment_ids)
|
||||
|
||||
for parsed in parsed_items:
|
||||
if parsed.resource_type == "shot_segment":
|
||||
segment = segments.get(parsed.source_id or "")
|
||||
if not segment:
|
||||
result.skip("shot_segment_not_found")
|
||||
continue
|
||||
parsed.user_id = segment.user_id
|
||||
parsed.created_at = segment.created_at or parsed.created_at
|
||||
parsed.source_model = UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value
|
||||
parsed.source_id = segment.id
|
||||
|
||||
users = await _user_exists_map(db, [p.user_id for p in parsed_items if p.user_id])
|
||||
existing_result = await db.execute(select(UploadResource.storage_path).where(UploadResource.storage_path.in_([p.storage_path for p in parsed_items])))
|
||||
existing_paths = set(existing_result.scalars().all())
|
||||
|
||||
for parsed in parsed_items:
|
||||
if not parsed.user_id:
|
||||
result.skip("missing_user_id")
|
||||
continue
|
||||
if options.only_user_id and parsed.user_id != options.only_user_id:
|
||||
result.skip("user_filtered")
|
||||
continue
|
||||
if parsed.user_id not in users:
|
||||
result.skip("user_not_found")
|
||||
continue
|
||||
if parsed.storage_path in existing_paths:
|
||||
result.existed += 1
|
||||
if not options.dry_run:
|
||||
await record_external_upload_resource(
|
||||
db,
|
||||
user_id=parsed.user_id,
|
||||
module=parsed.module,
|
||||
resource_type=parsed.resource_type,
|
||||
resource_url=parsed.resource_url,
|
||||
storage_path=parsed.storage_path,
|
||||
file_size_bytes=parsed.file_size_bytes,
|
||||
file_name=parsed.file_name,
|
||||
source_model=parsed.source_model,
|
||||
source_id=parsed.source_id,
|
||||
bind_status=UploadResourceBindStatusEnum.BOUND.value if parsed.source_id else UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value if parsed.source_id else UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.BACKFILL.value,
|
||||
created_at=parsed.created_at,
|
||||
metadata={"backfill": True},
|
||||
)
|
||||
result.updated += 1
|
||||
continue
|
||||
result.matched += 1
|
||||
if options.dry_run:
|
||||
continue
|
||||
await record_external_upload_resource(
|
||||
db,
|
||||
user_id=parsed.user_id,
|
||||
module=parsed.module,
|
||||
resource_type=parsed.resource_type,
|
||||
resource_url=parsed.resource_url,
|
||||
storage_path=parsed.storage_path,
|
||||
file_size_bytes=parsed.file_size_bytes,
|
||||
file_name=parsed.file_name,
|
||||
source_model=parsed.source_model,
|
||||
source_id=parsed.source_id,
|
||||
bind_status=UploadResourceBindStatusEnum.BOUND.value if parsed.source_id else UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value if parsed.source_id else UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.BACKFILL.value,
|
||||
created_at=parsed.created_at,
|
||||
metadata={"backfill": True},
|
||||
)
|
||||
result.inserted += 1
|
||||
|
||||
|
||||
def _batched(items: Iterable[Path], batch_size: int):
|
||||
batch: list[Path] = []
|
||||
for item in items:
|
||||
batch.append(item)
|
||||
if len(batch) >= batch_size:
|
||||
yield batch
|
||||
batch = []
|
||||
if batch:
|
||||
yield batch
|
||||
|
||||
|
||||
async def _rebind_hot_opening(db: AsyncSession, *, only_user_id: str | None = None) -> int:
|
||||
stmt = select(ModuleGenerationProject.id, ModuleGenerationProject.user_id).where(
|
||||
ModuleGenerationProject.module == UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
)
|
||||
if only_user_id:
|
||||
stmt = stmt.where(ModuleGenerationProject.user_id == only_user_id)
|
||||
projects = (await db.execute(stmt)).all()
|
||||
if not projects:
|
||||
return 0
|
||||
project_user = {pid: uid for pid, uid in projects}
|
||||
steps_result = await db.execute(
|
||||
select(ModuleGenerationStep.project_id, ModuleGenerationStep.input_json, ModuleGenerationStep.output_json).where(
|
||||
ModuleGenerationStep.project_id.in_(list(project_user.keys())),
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
grouped: dict[tuple[str, str], list[str]] = {}
|
||||
for project_id, input_json, output_json in steps_result.all():
|
||||
urls = _collect_urls(input_json) + _collect_urls(output_json)
|
||||
key = (project_user[project_id], project_id)
|
||||
grouped.setdefault(key, []).extend(urls)
|
||||
|
||||
bound = 0
|
||||
for (user_id, project_id), urls in grouped.items():
|
||||
stats = await bind_upload_resources(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project_id,
|
||||
urls=urls,
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
bound += stats.get("bound", 0)
|
||||
return bound
|
||||
|
||||
|
||||
async def _rebind_shot_replicate(db: AsyncSession, *, only_user_id: str | None = None) -> int:
|
||||
bound = 0
|
||||
task_stmt = select(ShotReplicateTaskSet.id, ShotReplicateTaskSet.user_id, ShotReplicateTaskSet.video_url).where(ShotReplicateTaskSet.deleted_at.is_(None))
|
||||
if only_user_id:
|
||||
task_stmt = task_stmt.where(ShotReplicateTaskSet.user_id == only_user_id)
|
||||
for task_set_id, user_id, video_url in (await db.execute(task_stmt)).all():
|
||||
stats = await bind_upload_resources(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
|
||||
source_id=task_set_id,
|
||||
urls=[video_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
bound += stats.get("bound", 0)
|
||||
|
||||
project_stmt = select(ModuleGenerationProject.id, ModuleGenerationProject.user_id).where(
|
||||
ModuleGenerationProject.module == UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
)
|
||||
if only_user_id:
|
||||
project_stmt = project_stmt.where(ModuleGenerationProject.user_id == only_user_id)
|
||||
projects = (await db.execute(project_stmt)).all()
|
||||
if projects:
|
||||
project_user = {pid: uid for pid, uid in projects}
|
||||
steps_result = await db.execute(
|
||||
select(ModuleGenerationStep.project_id, ModuleGenerationStep.input_json, ModuleGenerationStep.output_json).where(
|
||||
ModuleGenerationStep.project_id.in_(list(project_user.keys())),
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
grouped: dict[tuple[str, str], list[str]] = {}
|
||||
for project_id, input_json, output_json in steps_result.all():
|
||||
key = (project_user[project_id], project_id)
|
||||
grouped.setdefault(key, []).extend(_collect_urls(input_json) + _collect_urls(output_json))
|
||||
for (user_id, project_id), urls in grouped.items():
|
||||
stats = await bind_upload_resources(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project_id,
|
||||
urls=urls,
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
bound += stats.get("bound", 0)
|
||||
return bound
|
||||
|
||||
|
||||
async def rebind_module_upload_resources(db: AsyncSession, *, only_module: str | None = None, only_user_id: str | None = None) -> int:
|
||||
total = 0
|
||||
if only_module in (None, UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value):
|
||||
total += await _rebind_hot_opening(db, only_user_id=only_user_id)
|
||||
if only_module in (None, UploadResourceModuleEnum.SHOT_REPLICATE.value):
|
||||
total += await _rebind_shot_replicate(db, only_user_id=only_user_id)
|
||||
return total
|
||||
|
||||
|
||||
async def run_upload_resource_backfill(db: AsyncSession, options: BackfillOptions) -> BackfillResult:
|
||||
result = BackfillResult()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BACKFILL_START.value,
|
||||
detail={"options": asdict(options)},
|
||||
)
|
||||
|
||||
if options.cleanup_pending_files:
|
||||
if not options.dry_run:
|
||||
cleanup_stats = await cleanup_pending_upload_resource_files(db, limit=options.cleanup_limit)
|
||||
result.cleanup_files = int(cleanup_stats.get("deleted", 0) or 0) + int(cleanup_stats.get("missing", 0) or 0)
|
||||
result.cleanup_failed = int(cleanup_stats.get("failed", 0) or 0)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BACKFILL_FINISHED.value,
|
||||
detail=result.to_dict(),
|
||||
)
|
||||
return result
|
||||
|
||||
for paths in _batched(iter_files(options.root), options.batch_size):
|
||||
parsed_items: list[ParsedUploadPath] = []
|
||||
for path in paths:
|
||||
result.scanned += 1
|
||||
parsed = parse_upload_path(path, include_legacy=options.include_legacy)
|
||||
if not parsed:
|
||||
result.skip("unparsed")
|
||||
continue
|
||||
if parsed.skip_reason:
|
||||
result.skip(parsed.skip_reason)
|
||||
continue
|
||||
if not _module_allowed(parsed, options.only_module):
|
||||
result.skip("module_filtered")
|
||||
continue
|
||||
parsed_items.append(parsed)
|
||||
await _process_batch(db, parsed_items, result, options)
|
||||
if not options.dry_run:
|
||||
await db.commit()
|
||||
|
||||
if options.rebind_modules:
|
||||
if options.dry_run:
|
||||
# rebind dry-run 不真实改库,只统计为 0,避免复杂模拟误导。
|
||||
result.rebind_bound = 0
|
||||
else:
|
||||
result.rebind_bound = await rebind_module_upload_resources(
|
||||
db,
|
||||
only_module=options.only_module if options.only_module in (UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value, UploadResourceModuleEnum.SHOT_REPLICATE.value) else None,
|
||||
only_user_id=options.only_user_id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if options.rebuild_stats and not options.dry_run:
|
||||
stats = await rebuild_user_resource_stats(db, user_ids=[options.only_user_id] if options.only_user_id else None)
|
||||
result.rebuild_users = stats.get("users", 0)
|
||||
await db.commit()
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BACKFILL_FINISHED.value,
|
||||
detail=result.to_dict(),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceEventEnum,
|
||||
UploadResourceFileDeleteStatusEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceSourceModelEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.services.upload_resource.accounting_service import release_upload_resource_capacity
|
||||
from app.services.upload_resource.core_service import record_external_upload_resource
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
from app.services.upload_resource.path_resolver import normalize_storage_path, upload_url_to_storage_path
|
||||
|
||||
|
||||
def _clean_ids(values: Iterable[str | None] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
|
||||
|
||||
|
||||
def _clean_urls(values: Iterable[str | None] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
|
||||
|
||||
|
||||
async def bind_upload_resources(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
module: str,
|
||||
source_model: str,
|
||||
source_id: str,
|
||||
resource_ids: Iterable[str | None] | None = None,
|
||||
urls: Iterable[str | None] | None = None,
|
||||
allow_common_migrate: bool = True,
|
||||
) -> dict[str, int]:
|
||||
ids = _clean_ids(resource_ids)
|
||||
url_values = _clean_urls(urls)
|
||||
storage_paths = [p for p in (upload_url_to_storage_path(url) for url in url_values) if p]
|
||||
|
||||
if not ids and not storage_paths:
|
||||
return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0}
|
||||
|
||||
conditions = []
|
||||
if ids:
|
||||
conditions.append(UploadResource.id.in_(ids))
|
||||
if storage_paths:
|
||||
conditions.append(UploadResource.storage_path.in_(storage_paths))
|
||||
|
||||
stmt = select(UploadResource).where(UploadResource.user_id == user_id, UploadResource.deleted_at.is_(None))
|
||||
if len(conditions) == 1:
|
||||
stmt = stmt.where(conditions[0])
|
||||
else:
|
||||
from sqlalchemy import or_
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
stmt = stmt.with_for_update()
|
||||
|
||||
result = await db.execute(stmt)
|
||||
resources = result.scalars().all()
|
||||
stats = {"matched": len(resources), "bound": 0, "skipped": 0, "conflict": 0}
|
||||
|
||||
for resource in resources:
|
||||
if resource.source_id == source_id and resource.source_model == source_model:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
if resource.source_id and resource.source_id != source_id:
|
||||
stats["conflict"] += 1
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BIND_CONFLICT.value,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
resource_id=resource.id,
|
||||
source_model=source_model,
|
||||
source_id=source_id,
|
||||
event_status="warning",
|
||||
detail={
|
||||
"current_source_model": resource.source_model,
|
||||
"current_source_id": resource.source_id,
|
||||
"storage_path": resource.storage_path,
|
||||
},
|
||||
)
|
||||
continue
|
||||
if resource.module != module:
|
||||
if not (allow_common_migrate and resource.module == UploadResourceModuleEnum.COMMON.value):
|
||||
stats["conflict"] += 1
|
||||
continue
|
||||
resource.module = module
|
||||
resource.source_module = module
|
||||
resource.source_model = source_model
|
||||
resource.source_id = source_id
|
||||
resource.source_module = module
|
||||
resource.bind_status = UploadResourceBindStatusEnum.BOUND.value
|
||||
resource.delete_policy = UploadResourceDeletePolicyEnum.MODULE_ONLY.value
|
||||
stats["bound"] += 1
|
||||
|
||||
if stats["bound"]:
|
||||
await db.flush()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.BIND_SUCCESS.value,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
source_model=source_model,
|
||||
source_id=source_id,
|
||||
detail=stats,
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def release_upload_resources_by_source(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_model: str,
|
||||
source_ids: Iterable[str],
|
||||
module: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ids = _clean_ids(source_ids)
|
||||
if not ids:
|
||||
return {"matched": 0, "released": 0, "released_size_bytes": 0, "released_resource_ids": []}
|
||||
stmt = select(UploadResource).where(
|
||||
UploadResource.source_model == source_model,
|
||||
UploadResource.source_id.in_(ids),
|
||||
UploadResource.deleted_at.is_(None),
|
||||
).with_for_update()
|
||||
if module:
|
||||
stmt = stmt.where(UploadResource.module == module)
|
||||
result = await db.execute(stmt)
|
||||
resources = result.scalars().all()
|
||||
now = datetime.now(timezone.utc)
|
||||
stats: dict[str, Any] = {
|
||||
"matched": len(resources),
|
||||
"released": 0,
|
||||
"released_size_bytes": 0,
|
||||
"released_resource_ids": [],
|
||||
}
|
||||
for resource in resources:
|
||||
resource_id = resource.id
|
||||
size = int(resource.file_size_bytes or 0)
|
||||
resource.deleted_at = now
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
|
||||
resource.file_delete_error = None
|
||||
if await release_upload_resource_capacity(db, resource, released_at=now):
|
||||
stats["released"] += 1
|
||||
stats["released_size_bytes"] += size
|
||||
stats["released_resource_ids"].append(resource_id)
|
||||
if resources:
|
||||
await db.flush()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_MARKED_PENDING.value,
|
||||
module=module,
|
||||
source_model=source_model,
|
||||
source_id=",".join(ids[:20]),
|
||||
detail={k: v for k, v in stats.items() if k != "released_resource_ids"} | {"released_resource_count": len(stats["released_resource_ids"])},
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def record_shot_segment_upload_resource(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
segment: ShotReplicateSegment,
|
||||
storage_path: str,
|
||||
resource_url: str,
|
||||
file_size_bytes: int,
|
||||
) -> UploadResource:
|
||||
return await record_external_upload_resource(
|
||||
db,
|
||||
user_id=segment.user_id,
|
||||
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
resource_type=UploadResourceTypeEnum.SHOT_SEGMENT.value,
|
||||
resource_url=resource_url,
|
||||
storage_path=normalize_storage_path(storage_path),
|
||||
file_size_bytes=file_size_bytes,
|
||||
file_name=Path(storage_path).name,
|
||||
mime_type="video/mp4",
|
||||
duration_seconds=float(segment.duration_seconds or 0) if segment.duration_seconds else None,
|
||||
duration_source="business",
|
||||
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value,
|
||||
source_id=segment.id,
|
||||
bind_status=UploadResourceBindStatusEnum.BOUND.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.MODULE_ONLY.value,
|
||||
created_by=UploadResourceCreatedByEnum.SPLIT_TASK.value,
|
||||
metadata={"task_set_id": segment.task_set_id, "segment_index": segment.segment_index},
|
||||
created_at=segment.created_at,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.resource_capacity import RESOURCE_CAPACITY_EXCEEDED_MESSAGE, ResourceCapacityErrorCodeEnum
|
||||
from app.models.user import User
|
||||
from app.services.resource_capacity_service import get_user_resource_capacity_usage
|
||||
from app.services.upload_resource.accounting_service import get_or_create_total_stat
|
||||
|
||||
|
||||
def is_admin_user(user: User | object | None) -> bool:
|
||||
if user is None:
|
||||
return False
|
||||
try:
|
||||
if getattr(user, "user_type", None) == "admin":
|
||||
return True
|
||||
if bool(getattr(user, "is_admin", False)):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
async def assert_upload_capacity_available(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
file_size_bytes: int,
|
||||
) -> None:
|
||||
"""上传容量拦截。
|
||||
|
||||
admin 用户不拦截,但统计仍会入账。普通用户锁定 total_stat 行后判断:
|
||||
active_size_bytes + 本次上传大小 <= 容量上限。
|
||||
"""
|
||||
if is_admin_user(user):
|
||||
return
|
||||
|
||||
total_stat = await get_or_create_total_stat(db, user.id, for_update=True)
|
||||
usage = await get_user_resource_capacity_usage(db, user.id)
|
||||
if not usage.enabled or usage.total_bytes is None:
|
||||
return
|
||||
|
||||
used = int(total_stat.active_size_bytes or 0)
|
||||
size = max(int(file_size_bytes or 0), 0)
|
||||
if used + size > int(usage.total_bytes or 0):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{RESOURCE_CAPACITY_EXCEEDED_MESSAGE},本次上传 {size} 字节,当前已用 {used} 字节,总容量 {usage.total_bytes} 字节",
|
||||
headers={"X-Error-Code": ResourceCapacityErrorCodeEnum.RESOURCE_CAPACITY_EXCEEDED.value},
|
||||
)
|
||||
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceDurationSourceEnum,
|
||||
UploadResourceEventEnum,
|
||||
UploadResourceFileDeleteStatusEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user import User
|
||||
from app.services.upload_resource.accounting_service import apply_upload_resource_stat_delta, release_upload_resource_capacity, resource_month_from_datetime
|
||||
from app.services.upload_resource.capacity_service import assert_upload_capacity_available
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
from app.services.upload_resource.path_resolver import build_upload_destination, normalize_storage_path, storage_path_to_upload_url, upload_url_to_storage_path
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
||||
VIDEO_MAX_BYTES = 100 * 1024 * 1024
|
||||
AUDIO_DEFAULT_MAX_BYTES = 15 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UploadResourceResult:
|
||||
resource_id: str
|
||||
url: str
|
||||
filename: str
|
||||
resource_type: str
|
||||
module: str
|
||||
file_size_bytes: int
|
||||
duration_seconds: float | None = None
|
||||
|
||||
|
||||
def _json(data: Any) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _duration(value: float | int | str | None) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
return number if number > 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_content_type(file: UploadFile, *, resource_type: str) -> None:
|
||||
content_type = file.content_type or ""
|
||||
if resource_type == UploadResourceTypeEnum.IMAGE.value and not content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="仅支持图片文件")
|
||||
if resource_type == UploadResourceTypeEnum.VIDEO.value and not content_type.startswith("video/"):
|
||||
raise HTTPException(status_code=400, detail="仅支持视频文件")
|
||||
if resource_type == UploadResourceTypeEnum.AUDIO.value and not content_type.startswith("audio/"):
|
||||
# 兼容部分浏览器/系统上传 mp3 时返回 application/octet-stream,最终仍由扩展名校验兜底。
|
||||
ext = Path(file.filename or "").suffix.lower().lstrip(".")
|
||||
if ext not in {"mp3", "wav", "m4a", "aac", "flac"}:
|
||||
raise HTTPException(status_code=400, detail="仅支持音频文件")
|
||||
|
||||
|
||||
def _max_bytes(resource_type: str, max_bytes: int | None = None) -> int:
|
||||
if max_bytes:
|
||||
return int(max_bytes)
|
||||
if resource_type == UploadResourceTypeEnum.IMAGE.value:
|
||||
return IMAGE_MAX_BYTES
|
||||
if resource_type == UploadResourceTypeEnum.VIDEO.value:
|
||||
return VIDEO_MAX_BYTES
|
||||
return AUDIO_DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
async def _save_to_temp(file: UploadFile, *, max_bytes: int) -> tuple[str, int]:
|
||||
fd, temp_path = tempfile.mkstemp(prefix="upload_resource_", suffix=".tmp")
|
||||
total = 0
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_bytes // 1024 // 1024}MB")
|
||||
out.write(chunk)
|
||||
return temp_path, total
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
async def record_external_upload_resource(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
module: str,
|
||||
resource_type: str,
|
||||
resource_url: str,
|
||||
storage_path: str,
|
||||
file_size_bytes: int | None = None,
|
||||
file_name: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
duration_seconds: float | None = None,
|
||||
duration_source: str | None = None,
|
||||
source_model: str | None = None,
|
||||
source_id: str | None = None,
|
||||
bind_status: str = UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy: str = UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by: str = UploadResourceCreatedByEnum.API.value,
|
||||
metadata: Any = None,
|
||||
created_at: datetime | None = None,
|
||||
) -> UploadResource:
|
||||
storage_path = normalize_storage_path(storage_path)
|
||||
size = int(file_size_bytes if file_size_bytes is not None else os.path.getsize(storage_path) if os.path.exists(storage_path) else 0)
|
||||
result = await db.execute(select(UploadResource).where(UploadResource.storage_path == storage_path).limit(1))
|
||||
existing = result.scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
if existing:
|
||||
old_size = int(existing.file_size_bytes or 0)
|
||||
existing.user_id = user_id
|
||||
existing.module = module
|
||||
existing.resource_type = resource_type
|
||||
existing.resource_url = resource_url
|
||||
existing.file_name = file_name or existing.file_name or Path(storage_path).name
|
||||
existing.file_ext = Path(storage_path).suffix.lower().lstrip(".")
|
||||
existing.mime_type = mime_type or existing.mime_type
|
||||
existing.file_size_bytes = size
|
||||
existing.duration_seconds = duration_seconds if duration_seconds is not None else existing.duration_seconds
|
||||
existing.duration_source = duration_source or existing.duration_source
|
||||
existing.source_model = source_model or existing.source_model
|
||||
existing.source_id = source_id or existing.source_id
|
||||
existing.source_module = module
|
||||
existing.bind_status = bind_status or existing.bind_status
|
||||
existing.delete_policy = delete_policy or existing.delete_policy
|
||||
existing.created_by = existing.created_by or created_by
|
||||
existing.metadata_json = _json(metadata) if metadata is not None else existing.metadata_json
|
||||
if created_at is not None:
|
||||
existing.created_at = created_at
|
||||
delta = size - old_size if existing.deleted_at is None and existing.capacity_released_at is None else 0
|
||||
if delta:
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=user_id,
|
||||
stat_month=resource_month_from_datetime(existing.created_at or now),
|
||||
resource_type=resource_type,
|
||||
active_size_delta=delta,
|
||||
upload_size_delta=delta,
|
||||
)
|
||||
await db.flush()
|
||||
return existing
|
||||
|
||||
resource = UploadResource(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
resource_url=resource_url,
|
||||
storage_path=storage_path,
|
||||
file_name=file_name or Path(storage_path).name,
|
||||
file_ext=Path(storage_path).suffix.lower().lstrip("."),
|
||||
mime_type=mime_type,
|
||||
file_size_bytes=size,
|
||||
duration_seconds=duration_seconds,
|
||||
duration_source=duration_source,
|
||||
source_model=source_model,
|
||||
source_id=source_id,
|
||||
source_module=module,
|
||||
bind_status=bind_status,
|
||||
delete_policy=delete_policy,
|
||||
created_by=created_by,
|
||||
metadata_json=_json(metadata),
|
||||
)
|
||||
if created_at is not None:
|
||||
resource.created_at = created_at
|
||||
db.add(resource)
|
||||
await db.flush()
|
||||
await apply_upload_resource_stat_delta(
|
||||
db,
|
||||
user_id=user_id,
|
||||
stat_month=resource_month_from_datetime(created_at or now),
|
||||
resource_type=resource_type,
|
||||
active_size_delta=size,
|
||||
active_count_delta=1,
|
||||
upload_size_delta=size,
|
||||
upload_count_delta=1,
|
||||
)
|
||||
await db.flush()
|
||||
return resource
|
||||
|
||||
|
||||
async def upload_reference_file(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file: UploadFile,
|
||||
current_user: User,
|
||||
module: str = UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type: str,
|
||||
gen_type: str = "video",
|
||||
duration_seconds: float | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> UploadResourceResult:
|
||||
_validate_content_type(file, resource_type=resource_type)
|
||||
max_size = _max_bytes(resource_type, max_bytes)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_START.value,
|
||||
module=module,
|
||||
user_id=current_user.id,
|
||||
detail={"filename": file.filename, "content_type": file.content_type, "resource_type": resource_type},
|
||||
)
|
||||
|
||||
temp_path: str | None = None
|
||||
final_path: Path | None = None
|
||||
try:
|
||||
temp_path, size = await _save_to_temp(file, max_bytes=max_size)
|
||||
await assert_upload_capacity_available(db, user=current_user, file_size_bytes=size)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_CAPACITY_CHECKED.value,
|
||||
module=module,
|
||||
user_id=current_user.id,
|
||||
detail={"file_size_bytes": size},
|
||||
)
|
||||
|
||||
final_path, url, safe_name = build_upload_destination(
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
user_id=current_user.id,
|
||||
original_filename=file.filename,
|
||||
gen_type=gen_type,
|
||||
)
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(temp_path, final_path)
|
||||
temp_path = None
|
||||
|
||||
resource = await record_external_upload_resource(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
resource_url=url,
|
||||
storage_path=str(final_path),
|
||||
file_size_bytes=size,
|
||||
file_name=safe_name,
|
||||
mime_type=file.content_type,
|
||||
duration_seconds=_duration(duration_seconds),
|
||||
duration_source=UploadResourceDurationSourceEnum.CLIENT.value if _duration(duration_seconds) is not None else None,
|
||||
bind_status=UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.API.value,
|
||||
metadata={"original_filename": file.filename, "client_duration_seconds": duration_seconds},
|
||||
)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_DB_RECORDED.value,
|
||||
module=module,
|
||||
user_id=current_user.id,
|
||||
resource_id=resource.id,
|
||||
detail={"url": url, "storage_path": str(final_path), "file_size_bytes": size},
|
||||
)
|
||||
return UploadResourceResult(
|
||||
resource_id=resource.id,
|
||||
url=url,
|
||||
filename=file.filename or safe_name,
|
||||
resource_type=resource_type,
|
||||
module=module,
|
||||
file_size_bytes=size,
|
||||
duration_seconds=resource.duration_seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
if temp_path:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
if final_path and final_path.exists():
|
||||
try:
|
||||
final_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_FAILED.value,
|
||||
module=module,
|
||||
user_id=current_user.id,
|
||||
detail={"filename": file.filename, "resource_type": resource_type},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def delete_unbound_upload_resource(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
url: str,
|
||||
) -> dict[str, Any]:
|
||||
storage_path = upload_url_to_storage_path(url)
|
||||
if not storage_path:
|
||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||
|
||||
result = await db.execute(
|
||||
select(UploadResource)
|
||||
.where(
|
||||
UploadResource.storage_path == storage_path,
|
||||
UploadResource.user_id == user.id,
|
||||
UploadResource.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
resource = result.scalar_one_or_none()
|
||||
if not resource:
|
||||
# 兼容历史未补录文件:仍然要求 URL 中包含用户 ID。
|
||||
# 真实文件删除必须等 API 主事务 commit 成功后由 file_cleanup_service 执行。
|
||||
if user.id not in url:
|
||||
raise HTTPException(status_code=403, detail="无权删除此文件")
|
||||
return {
|
||||
"message": "ok",
|
||||
"resource_id": None,
|
||||
"deleted": True,
|
||||
"capacity_released": False,
|
||||
"released_size_bytes": 0,
|
||||
"_pending_physical_delete_resource_ids": [],
|
||||
"_legacy_pending_delete_paths": [storage_path],
|
||||
}
|
||||
|
||||
if resource.bind_status == UploadResourceBindStatusEnum.BOUND.value or resource.source_id or resource.delete_policy != UploadResourceDeletePolicyEnum.USER_DELETABLE.value:
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_REJECTED_BOUND.value,
|
||||
module=resource.module,
|
||||
user_id=user.id,
|
||||
resource_id=resource.id,
|
||||
source_model=resource.source_model,
|
||||
source_id=resource.source_id,
|
||||
event_status="rejected",
|
||||
message="已绑定模块业务记录的上传资源不允许单独删除",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="该文件已被模块任务使用,不能单独删除,请删除对应模块记录后自动释放空间。")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
resource_id = resource.id
|
||||
storage_path_snapshot = resource.storage_path
|
||||
file_size_snapshot = int(resource.file_size_bytes or 0)
|
||||
module_snapshot = resource.module
|
||||
resource.deleted_at = now
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
|
||||
resource.file_delete_error = None
|
||||
released = await release_upload_resource_capacity(db, resource, released_at=now)
|
||||
await db.flush()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_MARKED_PENDING.value,
|
||||
module=module_snapshot,
|
||||
user_id=user.id,
|
||||
resource_id=resource_id,
|
||||
detail={
|
||||
"url": url,
|
||||
"storage_path": storage_path_snapshot,
|
||||
"file_size_bytes": file_size_snapshot,
|
||||
"capacity_released": released,
|
||||
"file_delete_status": UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"message": "ok",
|
||||
"resource_id": resource_id,
|
||||
"deleted": True,
|
||||
"capacity_released": released,
|
||||
"released_size_bytes": file_size_snapshot if released else 0,
|
||||
"_pending_physical_delete_resource_ids": [resource_id],
|
||||
"_legacy_pending_delete_paths": [],
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceFileDeleteStatusEnum
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.models.user import User
|
||||
from app.schemas.upload_resource import (
|
||||
UploadResourceCleanupOut,
|
||||
UploadResourceHistoryBatchDeleteOut,
|
||||
)
|
||||
from app.services.upload_resource.accounting_service import release_upload_resource_capacity
|
||||
from app.services.upload_resource.file_cleanup_service import cleanup_upload_resource_files_after_commit
|
||||
from app.services.upload_resource.history_service import assert_upload_resource_history_ids_visible
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
|
||||
MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT = 30
|
||||
|
||||
|
||||
def normalize_upload_resource_ids(values: Iterable[str | None]) -> list[str]:
|
||||
ids = [str(value).strip() for value in values if str(value or "").strip()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="resource_ids 不能为空")
|
||||
if len(ids) > MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT:
|
||||
raise HTTPException(status_code=400, detail=f"单次最多删除 {MAX_UPLOAD_RESOURCE_BATCH_DELETE_COUNT} 条上传素材")
|
||||
if len(ids) != len(set(ids)):
|
||||
raise HTTPException(status_code=400, detail="resource_ids 不允许重复")
|
||||
return ids
|
||||
|
||||
|
||||
def _cleanup_out(stats: dict[str, int] | None) -> UploadResourceCleanupOut:
|
||||
stats = stats or {}
|
||||
return UploadResourceCleanupOut(
|
||||
matched=int(stats.get("matched", 0) or 0),
|
||||
deleted=int(stats.get("deleted", 0) or 0),
|
||||
missing=int(stats.get("missing", 0) or 0),
|
||||
failed=int(stats.get("failed", 0) or 0),
|
||||
legacy_deleted=int(stats.get("legacy_deleted", 0) or 0),
|
||||
legacy_missing=int(stats.get("legacy_missing", 0) or 0),
|
||||
legacy_failed=int(stats.get("legacy_failed", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
async def mark_upload_resource_history_deleted(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
resource_ids: Iterable[str | None],
|
||||
) -> UploadResourceHistoryBatchDeleteOut:
|
||||
"""主事务内软删上传历史素材并释放容量,不删除真实文件。"""
|
||||
|
||||
ids = normalize_upload_resource_ids(resource_ids)
|
||||
user_id = str(current_user.id)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_START.value,
|
||||
user_id=user_id,
|
||||
detail={"requested_ids": ids, "requested_count": len(ids)},
|
||||
)
|
||||
|
||||
resources = await assert_upload_resource_history_ids_visible(db, user_id=user_id, resource_ids=ids)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 先抽 primitive 快照,commit/rollback 后日志不再碰 ORM,规避懒加载失效风险。
|
||||
snapshots = [
|
||||
{
|
||||
"id": resource.id,
|
||||
"user_id": resource.user_id,
|
||||
"module": resource.module,
|
||||
"resource_type": resource.resource_type,
|
||||
"resource_url": resource.resource_url,
|
||||
"storage_path": resource.storage_path,
|
||||
"file_size_bytes": int(resource.file_size_bytes or 0),
|
||||
}
|
||||
for resource in resources
|
||||
]
|
||||
|
||||
released_size = 0
|
||||
released_ids: list[str] = []
|
||||
for resource in resources:
|
||||
resource.deleted_at = now
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value
|
||||
resource.file_delete_error = None
|
||||
released = await release_upload_resource_capacity(db, resource, released_at=now)
|
||||
if released:
|
||||
released_size += int(resource.file_size_bytes or 0)
|
||||
released_ids.append(resource.id)
|
||||
|
||||
await db.flush()
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_MARKED_PENDING.value,
|
||||
user_id=user_id,
|
||||
detail={
|
||||
"requested_ids": ids,
|
||||
"deleted_ids": ids,
|
||||
"released_ids": released_ids,
|
||||
"released_size_bytes": released_size,
|
||||
"snapshots": snapshots,
|
||||
"file_delete_status": UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
|
||||
},
|
||||
)
|
||||
|
||||
return UploadResourceHistoryBatchDeleteOut(
|
||||
message="删除成功",
|
||||
requested_count=len(ids),
|
||||
deleted_count=len(ids),
|
||||
requested_ids=ids,
|
||||
deleted_ids=ids,
|
||||
released_size_bytes=released_size,
|
||||
cleanup=UploadResourceCleanupOut(),
|
||||
)
|
||||
|
||||
|
||||
async def cleanup_upload_resource_history_files(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
result: UploadResourceHistoryBatchDeleteOut,
|
||||
current_user: User,
|
||||
) -> UploadResourceHistoryBatchDeleteOut:
|
||||
"""主事务 commit 成功后清理真实文件,失败不回滚主删除。"""
|
||||
|
||||
stats = await cleanup_upload_resource_files_after_commit(db, resource_ids=result.deleted_ids)
|
||||
result.cleanup = _cleanup_out(stats)
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_BATCH_CLEANUP_SUCCESS.value,
|
||||
user_id=str(current_user.id),
|
||||
detail={
|
||||
"requested_ids": result.requested_ids,
|
||||
"deleted_ids": result.deleted_ids,
|
||||
"cleanup": result.cleanup.model_dump(),
|
||||
},
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceFileDeleteStatusEnum
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event, log_upload_resource_exception
|
||||
from app.services.upload_resource.path_resolver import normalize_storage_path
|
||||
|
||||
|
||||
def _clean_ids(values: Iterable[str | None] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
return [str(v).strip() for v in dict.fromkeys(values) if v and str(v).strip()]
|
||||
|
||||
|
||||
def _clean_paths(values: Iterable[str | None] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
cleaned: list[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
cleaned.append(normalize_storage_path(value))
|
||||
except Exception:
|
||||
cleaned.append(str(value))
|
||||
return list(dict.fromkeys(cleaned))
|
||||
|
||||
|
||||
def _short_error(exc: BaseException) -> str:
|
||||
text = str(exc) or exc.__class__.__name__
|
||||
return text[:2000]
|
||||
|
||||
|
||||
async def cleanup_upload_resource_files_after_commit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
resource_ids: Iterable[str | None] | None = None,
|
||||
legacy_paths: Iterable[str | None] | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""API 主事务 commit 成功后清理 UploadResource 真实文件。
|
||||
|
||||
这里不负责业务删除事务,不做 rollback;调用方如需持久化清理状态,
|
||||
应在本函数返回后由 API 层再次 commit。
|
||||
"""
|
||||
ids = _clean_ids(resource_ids)
|
||||
paths = _clean_paths(legacy_paths)
|
||||
stats = {
|
||||
"matched": 0,
|
||||
"deleted": 0,
|
||||
"missing": 0,
|
||||
"failed": 0,
|
||||
"legacy_deleted": 0,
|
||||
"legacy_missing": 0,
|
||||
"legacy_failed": 0,
|
||||
}
|
||||
|
||||
if ids:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UploadResource)
|
||||
.where(
|
||||
UploadResource.id.in_(ids),
|
||||
UploadResource.deleted_at.is_not(None),
|
||||
UploadResource.physical_deleted_at.is_(None),
|
||||
UploadResource.file_delete_status.in_([
|
||||
UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
|
||||
UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value,
|
||||
]),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
resources = list(result.scalars().all())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
|
||||
message="UploadResource 真实文件清理批量查询失败",
|
||||
resource_ids=ids,
|
||||
detail={"stage": "query_resources", "resource_ids_count": len(ids)},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
stats["matched"] = len(resources)
|
||||
for resource in resources:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
path = Path(resource.storage_path)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.DELETED.value
|
||||
stats["deleted"] += 1
|
||||
event = UploadResourceEventEnum.DELETE_PHYSICAL_SUCCESS.value
|
||||
else:
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.MISSING.value
|
||||
stats["missing"] += 1
|
||||
event = UploadResourceEventEnum.DELETE_PHYSICAL_MISSING.value
|
||||
resource.physical_deleted_at = now
|
||||
resource.file_delete_error = None
|
||||
log_upload_resource_event(
|
||||
event_type=event,
|
||||
module=resource.module,
|
||||
user_id=resource.user_id,
|
||||
resource_id=resource.id,
|
||||
source_model=resource.source_model,
|
||||
source_id=resource.source_id,
|
||||
detail={"storage_path": resource.storage_path, "file_delete_status": resource.file_delete_status},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
resource.file_delete_status = UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value
|
||||
resource.file_delete_error = _short_error(exc)
|
||||
stats["failed"] += 1
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_PHYSICAL_FAILED.value,
|
||||
module=resource.module,
|
||||
user_id=resource.user_id,
|
||||
resource_id=resource.id,
|
||||
source_model=resource.source_model,
|
||||
source_id=resource.source_id,
|
||||
detail={"storage_path": resource.storage_path},
|
||||
exc=exc,
|
||||
)
|
||||
if resources:
|
||||
try:
|
||||
await db.flush()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
|
||||
message="UploadResource 真实文件清理状态 flush 失败",
|
||||
resource_ids=[resource.id for resource in resources],
|
||||
detail={"stage": "flush_cleanup_status"},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
for raw_path in paths:
|
||||
try:
|
||||
path = Path(raw_path)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
stats["legacy_deleted"] += 1
|
||||
else:
|
||||
stats["legacy_missing"] += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
stats["legacy_failed"] += 1
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.DELETE_PHYSICAL_FAILED.value,
|
||||
detail={"legacy_path": raw_path},
|
||||
exc=exc,
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def cleanup_pending_upload_resource_files(db: AsyncSession, *, limit: int = 500) -> dict[str, int]:
|
||||
limit = max(1, int(limit or 500))
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.CLEANUP_PENDING_START.value,
|
||||
detail={"limit": limit},
|
||||
)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UploadResource.id)
|
||||
.where(
|
||||
UploadResource.deleted_at.is_not(None),
|
||||
UploadResource.physical_deleted_at.is_(None),
|
||||
UploadResource.file_delete_status.in_([
|
||||
UploadResourceFileDeleteStatusEnum.PENDING_DELETE.value,
|
||||
UploadResourceFileDeleteStatusEnum.DELETE_FAILED.value,
|
||||
]),
|
||||
)
|
||||
.order_by(UploadResource.updated_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
ids = list(result.scalars().all())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
|
||||
message="UploadResource pending 清理查询失败",
|
||||
detail={"stage": "query_pending_cleanup", "limit": limit},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
try:
|
||||
stats = await cleanup_upload_resource_files_after_commit(db, resource_ids=ids)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_upload_resource_exception(
|
||||
event_type=UploadResourceEventEnum.UPLOAD_RESOURCE_CLEANUP_BATCH_FAILED.value,
|
||||
message="UploadResource pending 真实文件补偿清理失败",
|
||||
resource_ids=ids,
|
||||
detail={"stage": "cleanup_pending", "limit": limit},
|
||||
exc=exc,
|
||||
)
|
||||
raise
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.CLEANUP_PENDING_FINISHED.value,
|
||||
detail={"ids": len(ids), **stats},
|
||||
)
|
||||
return stats
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, desc, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.upload_resource import (
|
||||
UPLOAD_RESOURCE_MODULE_LABELS,
|
||||
UPLOAD_RESOURCE_TYPE_LABELS,
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceEventEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.schemas.upload_resource import (
|
||||
UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES,
|
||||
UploadResourceHistoryDayGroupOut,
|
||||
UploadResourceHistoryDayItemsOut,
|
||||
UploadResourceHistoryGroupedOut,
|
||||
UploadResourceHistoryItemOut,
|
||||
UploadResourceMediaReferenceOut,
|
||||
)
|
||||
from app.services.upload_resource.log_service import log_upload_resource_event
|
||||
|
||||
HISTORY_SOURCE = "upload_resource"
|
||||
HISTORY_SOURCE_LABEL = "历史上传素材"
|
||||
DEFAULT_GROUP_ITEMS_LIMIT = 10
|
||||
MAX_GROUP_PAGE_SIZE = 10
|
||||
MAX_DAY_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def _normalize_resource_type(resource_type: str | None) -> str | None:
|
||||
if resource_type is None or str(resource_type).strip() == "":
|
||||
return None
|
||||
value = str(resource_type).strip()
|
||||
if value not in UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="resource_type 仅支持 image、video、audio")
|
||||
return value
|
||||
|
||||
|
||||
def _page(value: int, *, default: int = 1) -> int:
|
||||
return max(int(value or default), 1)
|
||||
|
||||
|
||||
def _page_size(value: int, *, default: int, max_value: int) -> int:
|
||||
return min(max(int(value or default), 1), max_value)
|
||||
|
||||
|
||||
def _date_text(value: object) -> str:
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return str(value)[:10]
|
||||
|
||||
|
||||
def _display_url(resource: UploadResource) -> str:
|
||||
return str(resource.resource_url or "")
|
||||
|
||||
|
||||
def _resource_type(resource: UploadResource) -> str:
|
||||
return str(resource.resource_type or "")
|
||||
|
||||
|
||||
def _item_to_out(resource: UploadResource) -> UploadResourceHistoryItemOut:
|
||||
resource_type = _resource_type(resource)
|
||||
display_url = _display_url(resource)
|
||||
file_name = resource.file_name or display_url.rsplit("/", 1)[-1] or resource.id
|
||||
duration = float(resource.duration_seconds) if resource.duration_seconds is not None else None
|
||||
|
||||
media_reference = UploadResourceMediaReferenceOut(
|
||||
name=file_name,
|
||||
type=resource_type, # type: ignore[arg-type]
|
||||
url=display_url,
|
||||
label="",
|
||||
duration=duration if resource_type in {UploadResourceTypeEnum.VIDEO.value, UploadResourceTypeEnum.AUDIO.value} else None,
|
||||
source=HISTORY_SOURCE,
|
||||
upload_resource_id=resource.id,
|
||||
)
|
||||
|
||||
return UploadResourceHistoryItemOut(
|
||||
id=resource.id,
|
||||
source_type=HISTORY_SOURCE,
|
||||
history_source=HISTORY_SOURCE,
|
||||
history_source_label=HISTORY_SOURCE_LABEL,
|
||||
module=resource.module,
|
||||
module_label=UPLOAD_RESOURCE_MODULE_LABELS.get(resource.module, resource.module),
|
||||
resource_type=resource_type, # type: ignore[arg-type]
|
||||
resource_type_label=UPLOAD_RESOURCE_TYPE_LABELS.get(resource_type, resource_type),
|
||||
resource_url=display_url,
|
||||
display_url=display_url,
|
||||
preview_url=display_url,
|
||||
image_url=display_url if resource_type == UploadResourceTypeEnum.IMAGE.value else None,
|
||||
video_url=display_url if resource_type == UploadResourceTypeEnum.VIDEO.value else None,
|
||||
audio_url=display_url if resource_type == UploadResourceTypeEnum.AUDIO.value else None,
|
||||
file_name=resource.file_name,
|
||||
file_ext=resource.file_ext,
|
||||
mime_type=resource.mime_type,
|
||||
file_size_bytes=int(resource.file_size_bytes or 0),
|
||||
duration_seconds=duration,
|
||||
width=int(resource.width) if resource.width is not None else None,
|
||||
height=int(resource.height) if resource.height is not None else None,
|
||||
bind_status=resource.bind_status,
|
||||
delete_policy=resource.delete_policy,
|
||||
deletable=True,
|
||||
media_reference=media_reference,
|
||||
created_at=resource.created_at,
|
||||
updated_at=resource.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _base_filters(*, user_id: str, resource_type: str | None = None, keyword: str | None = None) -> list:
|
||||
filters = [
|
||||
UploadResource.user_id == user_id,
|
||||
UploadResource.deleted_at.is_(None),
|
||||
UploadResource.source_model.is_(None),
|
||||
UploadResource.source_id.is_(None),
|
||||
UploadResource.bind_status == UploadResourceBindStatusEnum.PENDING.value,
|
||||
UploadResource.delete_policy == UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
UploadResource.resource_type.in_(list(UPLOAD_RESOURCE_HISTORY_ALLOWED_RESOURCE_TYPES)),
|
||||
]
|
||||
if resource_type:
|
||||
filters.append(UploadResource.resource_type == resource_type)
|
||||
if keyword and keyword.strip():
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
UploadResource.file_name.ilike(pattern),
|
||||
UploadResource.resource_url.ilike(pattern),
|
||||
)
|
||||
)
|
||||
return filters
|
||||
|
||||
|
||||
async def list_upload_resource_history_grouped_days(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
resource_type: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: str | None = None,
|
||||
) -> UploadResourceHistoryGroupedOut:
|
||||
"""按上传日期分组查询可展示/可复用/可删除的 UploadResource。"""
|
||||
|
||||
resource_type = _normalize_resource_type(resource_type)
|
||||
page = _page(page)
|
||||
page_size = _page_size(page_size, default=10, max_value=MAX_GROUP_PAGE_SIZE)
|
||||
filters = _base_filters(user_id=user_id, resource_type=resource_type, keyword=keyword)
|
||||
date_expr = func.date(UploadResource.created_at).label("generated_date")
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.HISTORY_LIST_START.value,
|
||||
user_id=user_id,
|
||||
detail={"resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword},
|
||||
)
|
||||
|
||||
total_days_stmt = select(func.count()).select_from(
|
||||
select(date_expr).where(and_(*filters)).group_by(date_expr).subquery()
|
||||
)
|
||||
total_days = int((await db.execute(total_days_stmt)).scalar_one() or 0)
|
||||
|
||||
group_stmt = (
|
||||
select(date_expr, func.count(UploadResource.id).label("total"))
|
||||
.where(and_(*filters))
|
||||
.group_by(date_expr)
|
||||
.order_by(desc(date_expr))
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
group_rows = (await db.execute(group_stmt)).mappings().all()
|
||||
date_values = [row["generated_date"] for row in group_rows]
|
||||
date_texts = [_date_text(value) for value in date_values]
|
||||
|
||||
items_by_date: dict[str, list[UploadResourceHistoryItemOut]] = {date_text: [] for date_text in date_texts}
|
||||
if date_texts:
|
||||
rn = func.row_number().over(
|
||||
partition_by=func.date(UploadResource.created_at),
|
||||
order_by=(UploadResource.created_at.desc(), UploadResource.id.desc()),
|
||||
).label("rn")
|
||||
id_subq = (
|
||||
select(
|
||||
UploadResource.id.label("id"),
|
||||
func.date(UploadResource.created_at).label("generated_date"),
|
||||
rn,
|
||||
)
|
||||
.where(and_(*filters), func.date(UploadResource.created_at).in_(date_values))
|
||||
.subquery()
|
||||
)
|
||||
item_stmt = (
|
||||
select(UploadResource)
|
||||
.join(id_subq, UploadResource.id == id_subq.c.id)
|
||||
.where(id_subq.c.rn <= DEFAULT_GROUP_ITEMS_LIMIT)
|
||||
.order_by(id_subq.c.generated_date.desc(), UploadResource.created_at.desc(), UploadResource.id.desc())
|
||||
)
|
||||
resources = list((await db.execute(item_stmt)).scalars().all())
|
||||
for resource in resources:
|
||||
items_by_date.setdefault(_date_text(resource.created_at.date()), []).append(_item_to_out(resource))
|
||||
|
||||
groups = [
|
||||
UploadResourceHistoryDayGroupOut(
|
||||
generated_date=_date_text(row["generated_date"]),
|
||||
total=int(row["total"] or 0),
|
||||
page=1,
|
||||
items=items_by_date.get(_date_text(row["generated_date"]), []),
|
||||
)
|
||||
for row in group_rows
|
||||
]
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.HISTORY_LIST_SUCCESS.value,
|
||||
user_id=user_id,
|
||||
detail={"resource_type": resource_type, "page": page, "page_size": page_size, "groups": len(groups)},
|
||||
)
|
||||
return UploadResourceHistoryGroupedOut(total_days=total_days, page=page, page_size=page_size, groups=groups)
|
||||
|
||||
|
||||
async def list_upload_resource_history_day_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
generated_date: str,
|
||||
resource_type: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
keyword: str | None = None,
|
||||
) -> UploadResourceHistoryDayItemsOut:
|
||||
"""查询指定上传日期下的 UploadResource 历史素材。"""
|
||||
|
||||
try:
|
||||
target_date = date.fromisoformat(str(generated_date))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="generated_date 必须是 YYYY-MM-DD 格式")
|
||||
|
||||
resource_type = _normalize_resource_type(resource_type)
|
||||
page = _page(page)
|
||||
page_size = _page_size(page_size, default=20, max_value=MAX_DAY_PAGE_SIZE)
|
||||
filters = _base_filters(user_id=user_id, resource_type=resource_type, keyword=keyword)
|
||||
filters.append(func.date(UploadResource.created_at) == target_date)
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.HISTORY_DAY_LIST_START.value,
|
||||
user_id=user_id,
|
||||
detail={"generated_date": generated_date, "resource_type": resource_type, "page": page, "page_size": page_size, "keyword": keyword},
|
||||
)
|
||||
|
||||
total_stmt = select(func.count(UploadResource.id)).where(and_(*filters))
|
||||
total = int((await db.execute(total_stmt)).scalar_one() or 0)
|
||||
|
||||
item_stmt = (
|
||||
select(UploadResource)
|
||||
.where(and_(*filters))
|
||||
.order_by(UploadResource.created_at.desc(), UploadResource.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
resources = list((await db.execute(item_stmt)).scalars().all())
|
||||
items = [_item_to_out(resource) for resource in resources]
|
||||
|
||||
log_upload_resource_event(
|
||||
event_type=UploadResourceEventEnum.HISTORY_DAY_LIST_SUCCESS.value,
|
||||
user_id=user_id,
|
||||
detail={"generated_date": generated_date, "resource_type": resource_type, "page": page, "page_size": page_size, "total": total},
|
||||
)
|
||||
return UploadResourceHistoryDayItemsOut(
|
||||
generated_date=target_date.isoformat(),
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
async def assert_upload_resource_history_ids_visible(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
resource_ids: Iterable[str],
|
||||
) -> list[UploadResource]:
|
||||
ids = [str(value).strip() for value in resource_ids if str(value or "").strip()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="resource_ids 不能为空")
|
||||
stmt = (
|
||||
select(UploadResource)
|
||||
.where(
|
||||
UploadResource.id.in_(ids),
|
||||
*_base_filters(user_id=user_id),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
resources = list((await db.execute(stmt)).scalars().all())
|
||||
found = {resource.id for resource in resources}
|
||||
missing = [resource_id for resource_id in ids if resource_id not in found]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"message": "上传素材不存在、已删除、已绑定模块业务记录或无权操作",
|
||||
"missing_ids": missing,
|
||||
"missing_count": len(missing),
|
||||
},
|
||||
)
|
||||
return resources
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_event
|
||||
|
||||
DOMAIN = "upload_resource"
|
||||
logger = logging.getLogger("videogen.upload_resource")
|
||||
|
||||
|
||||
def _exception_payload(exc: BaseException | None, detail: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
payload = dict(detail or {})
|
||||
if exc is not None:
|
||||
payload.setdefault("error_type", exc.__class__.__name__)
|
||||
payload.setdefault("error_message", str(exc))
|
||||
payload.setdefault("traceback", "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
||||
return payload
|
||||
|
||||
|
||||
def log_upload_resource_event(
|
||||
*,
|
||||
event_type: str,
|
||||
module: str | None = None,
|
||||
user_id: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
source_model: str | None = None,
|
||||
source_id: str | None = None,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
exc: BaseException | None = None,
|
||||
event_status: str = "success",
|
||||
) -> None:
|
||||
"""Write an upload-resource operation log.
|
||||
|
||||
日志失败不能影响主业务流程;失败时降级到标准 logger。
|
||||
detail 只接收普通 dict,禁止传 ORM 对象,避免 commit/rollback 后懒加载异常。
|
||||
"""
|
||||
payload = dict(detail or {})
|
||||
if resource_id:
|
||||
payload["resource_id"] = resource_id
|
||||
if source_model:
|
||||
payload["source_model"] = source_model
|
||||
if source_id:
|
||||
payload["source_id"] = source_id
|
||||
if exc is not None:
|
||||
payload = build_exception_detail(exc, payload)
|
||||
error = error or str(exc)
|
||||
event_status = "failed"
|
||||
try:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=event_type,
|
||||
module=module or DOMAIN,
|
||||
event_status=event_status,
|
||||
source="service",
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
detail=payload,
|
||||
error=error,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 日志降级,不能影响主流程
|
||||
logger.exception(
|
||||
"upload_resource operation log failed: event_type=%s user_id=%s resource_id=%s detail=%s error=%s",
|
||||
event_type,
|
||||
user_id,
|
||||
resource_id,
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
def log_upload_resource_exception(
|
||||
*,
|
||||
event_type: str,
|
||||
message: str | None = None,
|
||||
user_id: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
resource_ids: list[str] | None = None,
|
||||
module: str | None = None,
|
||||
source_model: str | None = None,
|
||||
source_id: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
exc: BaseException | None = None,
|
||||
) -> None:
|
||||
"""统一记录 UploadResource 相关异常。"""
|
||||
payload = _exception_payload(exc, detail)
|
||||
if resource_ids is not None:
|
||||
payload["resource_ids"] = list(resource_ids)
|
||||
log_upload_resource_event(
|
||||
event_type=event_type,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
resource_id=resource_id,
|
||||
source_model=source_model,
|
||||
source_id=source_id,
|
||||
message=message,
|
||||
detail=payload,
|
||||
error=str(exc) if exc else None,
|
||||
event_status="failed",
|
||||
)
|
||||
|
||||
|
||||
async def safe_rollback_with_log(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
event_type: str,
|
||||
message: str | None = None,
|
||||
user_id: str | None = None,
|
||||
module: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
original_exc: BaseException | None = None,
|
||||
) -> None:
|
||||
"""Rollback with rollback-failure logging.
|
||||
|
||||
只记录 rollback 自身异常;不吞掉主异常,调用方继续 raise 原始异常。
|
||||
"""
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_exc: # noqa: BLE001
|
||||
payload = dict(detail or {})
|
||||
if original_exc is not None:
|
||||
payload["original_error_type"] = original_exc.__class__.__name__
|
||||
payload["original_error_message"] = str(original_exc)
|
||||
log_upload_resource_exception(
|
||||
event_type=event_type,
|
||||
message=message or "数据库回滚失败",
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
detail=payload,
|
||||
exc=rollback_exc,
|
||||
)
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTypeEnum
|
||||
|
||||
COMMON_IMAGE_RE = re.compile(r"^images/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_img_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
COMMON_VIDEO_RE = re.compile(r"^videos/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/audio_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
|
||||
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
|
||||
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
|
||||
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ParsedUploadPath:
|
||||
storage_path: str
|
||||
resource_url: str
|
||||
file_name: str
|
||||
file_ext: str
|
||||
file_size_bytes: int
|
||||
module: str
|
||||
resource_type: str
|
||||
user_id: str | None
|
||||
created_at: datetime | None
|
||||
source_model: str | None = None
|
||||
source_id: str | None = None
|
||||
skip_reason: str | None = None
|
||||
|
||||
|
||||
def upload_root() -> Path:
|
||||
return Path(settings.UPLOAD_LOCAL_PATH).resolve()
|
||||
|
||||
|
||||
def to_abs_path(path: str | os.PathLike[str]) -> Path:
|
||||
p = Path(path)
|
||||
return p if p.is_absolute() else Path.cwd() / p
|
||||
|
||||
|
||||
def normalize_storage_path(path: str | os.PathLike[str]) -> str:
|
||||
return str(to_abs_path(path).resolve())
|
||||
|
||||
|
||||
def upload_url_to_storage_path(url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
value = str(url).strip()
|
||||
if not value:
|
||||
return None
|
||||
value = urlsplit(value).path or value
|
||||
if not value.startswith("/uploads/"):
|
||||
return None
|
||||
rel = value[len("/uploads/"):].lstrip("/")
|
||||
if not rel or ".." in Path(rel).parts:
|
||||
return None
|
||||
return str((upload_root() / rel).resolve())
|
||||
|
||||
|
||||
def storage_path_to_upload_url(path: str | os.PathLike[str]) -> str:
|
||||
abs_path = to_abs_path(path).resolve()
|
||||
rel = abs_path.relative_to(upload_root()).as_posix()
|
||||
return f"/uploads/{rel}"
|
||||
|
||||
|
||||
def _parse_created_at(parts: dict[str, str], fallback: datetime | None = None) -> datetime | None:
|
||||
ymd = parts.get("ymd")
|
||||
hms = parts.get("hms")
|
||||
try:
|
||||
if ymd and hms:
|
||||
return datetime.strptime(f"{ymd}_{hms}", "%Y%m%d_%H%M%S")
|
||||
return datetime(int(parts["year"]), int(parts["month"]), int(parts["day"]))
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _file_size(path: Path) -> int:
|
||||
try:
|
||||
return int(path.stat().st_size)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def _base(path: Path, module: str, resource_type: str, user_id: str | None, created_at: datetime | None) -> ParsedUploadPath:
|
||||
return ParsedUploadPath(
|
||||
storage_path=normalize_storage_path(path),
|
||||
resource_url=storage_path_to_upload_url(path),
|
||||
file_name=path.name,
|
||||
file_ext=path.suffix.lower().lstrip("."),
|
||||
file_size_bytes=_file_size(path),
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
user_id=user_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
def parse_upload_path(path: str | os.PathLike[str], *, include_legacy: bool = False) -> ParsedUploadPath | None:
|
||||
abs_path = to_abs_path(path).resolve()
|
||||
if not abs_path.is_file():
|
||||
return None
|
||||
try:
|
||||
rel = abs_path.relative_to(upload_root()).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
ignored_prefixes = ("home_materials/",)
|
||||
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
|
||||
return ParsedUploadPath(
|
||||
storage_path=normalize_storage_path(abs_path),
|
||||
resource_url="",
|
||||
file_name=abs_path.name,
|
||||
file_ext=abs_path.suffix.lower().lstrip("."),
|
||||
file_size_bytes=_file_size(abs_path),
|
||||
module=UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type="unknown",
|
||||
user_id=None,
|
||||
created_at=None,
|
||||
skip_reason="ignored_path",
|
||||
)
|
||||
|
||||
for pattern, rtype in (
|
||||
(COMMON_IMAGE_RE, UploadResourceTypeEnum.IMAGE.value),
|
||||
(COMMON_VIDEO_RE, UploadResourceTypeEnum.VIDEO.value),
|
||||
(COMMON_AUDIO_RE, UploadResourceTypeEnum.AUDIO.value),
|
||||
):
|
||||
m = pattern.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
return _base(abs_path, UploadResourceModuleEnum.COMMON.value, rtype, d.get("user_id"), _parse_created_at(d))
|
||||
|
||||
m = MODULE_RE.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
kind = d.get("kind")
|
||||
rtype = UploadResourceTypeEnum.IMAGE.value if kind == "images" else UploadResourceTypeEnum.VIDEO.value
|
||||
return _base(abs_path, d["module"], rtype, d.get("user_id"), _parse_created_at(d))
|
||||
|
||||
m = SHOT_SEGMENT_RE.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
parsed = _base(abs_path, UploadResourceModuleEnum.SHOT_REPLICATE.value, UploadResourceTypeEnum.SHOT_SEGMENT.value, None, _parse_created_at(d))
|
||||
parsed.source_model = "ShotReplicateSegment"
|
||||
parsed.source_id = d["segment_id"]
|
||||
return parsed
|
||||
|
||||
if include_legacy:
|
||||
m = LEGACY_GEN_RE.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
ext = abs_path.suffix.lower()
|
||||
if ext in IMAGE_EXTS:
|
||||
rtype = UploadResourceTypeEnum.IMAGE.value
|
||||
elif ext in VIDEO_EXTS:
|
||||
rtype = UploadResourceTypeEnum.VIDEO.value
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
created = datetime.fromtimestamp(abs_path.stat().st_mtime)
|
||||
except OSError:
|
||||
created = None
|
||||
return _base(abs_path, UploadResourceModuleEnum.COMMON.value, rtype, d.get("user_id"), created)
|
||||
|
||||
return ParsedUploadPath(
|
||||
storage_path=normalize_storage_path(abs_path),
|
||||
resource_url="",
|
||||
file_name=abs_path.name,
|
||||
file_ext=abs_path.suffix.lower().lstrip("."),
|
||||
file_size_bytes=_file_size(abs_path),
|
||||
module=UploadResourceModuleEnum.COMMON.value,
|
||||
resource_type="unknown",
|
||||
user_id=None,
|
||||
created_at=None,
|
||||
skip_reason="unmatched_path",
|
||||
)
|
||||
|
||||
|
||||
def iter_files(root: str | os.PathLike[str]) -> Iterable[Path]:
|
||||
base = to_abs_path(root).resolve()
|
||||
if not base.exists():
|
||||
return []
|
||||
return (p for p in base.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
def build_upload_destination(*, module: str, resource_type: str, user_id: str, original_filename: str | None, gen_type: str = "video") -> tuple[Path, str, str]:
|
||||
now = datetime.now()
|
||||
date_dir = now.strftime("%Y/%m/%d")
|
||||
ext = Path(original_filename or "").suffix.lower()
|
||||
if not ext:
|
||||
ext = ".mp4" if resource_type == UploadResourceTypeEnum.VIDEO.value else ".mp3" if resource_type == UploadResourceTypeEnum.AUDIO.value else ".png"
|
||||
|
||||
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
if resource_type == UploadResourceTypeEnum.AUDIO.value:
|
||||
filename = f"audio_ref_{user_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = Path("audios") / date_dir
|
||||
elif resource_type == UploadResourceTypeEnum.VIDEO.value:
|
||||
filename = f"video_ref_{user_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = Path("videos") / date_dir if module == UploadResourceModuleEnum.COMMON.value else Path(module) / "videos" / date_dir
|
||||
else:
|
||||
filename = f"{gen_type}_img_{user_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = Path("images") / date_dir if module == UploadResourceModuleEnum.COMMON.value else Path(module) / "images" / date_dir
|
||||
|
||||
dir_path = upload_root() / rel_dir
|
||||
file_path = dir_path / filename
|
||||
url = f"/uploads/{(rel_dir / filename).as_posix()}"
|
||||
return file_path, url, filename
|
||||
@@ -41,6 +41,7 @@ from app.services.shot_video_analysis_service import analyze_video_for_shot_spli
|
||||
from app.services.generation_billing_service import charge_shot_video_analysis_usage
|
||||
from app.services.shot_video_split_service import split_video_segment_async
|
||||
from app.services.upload_video_asset_service import validate_split_range
|
||||
from app.services.upload_resource import record_shot_segment_upload_resource
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
@@ -467,6 +468,13 @@ async def _run_split_one_segment(segment_id: str) -> None:
|
||||
return
|
||||
segment.segment_video_url = split_result.url
|
||||
segment.segment_video_path = split_result.path
|
||||
await record_shot_segment_upload_resource(
|
||||
db,
|
||||
segment=segment,
|
||||
storage_path=split_result.path,
|
||||
resource_url=split_result.url,
|
||||
file_size_bytes=split_result.file_size_bytes,
|
||||
)
|
||||
segment.split_status = ShotSplitStatusEnum.COMPLETED.value
|
||||
segment.split_completed_at = _now()
|
||||
segment.split_lease_until = None
|
||||
|
||||
-506
File diff suppressed because one or more lines are too long
+506
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>民众智创</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName;
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BIirSavc.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>民众智创</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName;
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CYqrWKKz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+169
-12
@@ -116,20 +116,30 @@ export async function optimizePrompt(
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadAudio(file: File): Promise<{ url: string; filename: string }> {
|
||||
export interface UploadResourceResult {
|
||||
url: string;
|
||||
filename: string;
|
||||
type?: string;
|
||||
module?: string;
|
||||
resource_id?: string;
|
||||
file_size_bytes?: number;
|
||||
duration_seconds?: number | null;
|
||||
}
|
||||
|
||||
export async function uploadAudio(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio`, {
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
if (!res.ok) throw new Error('音频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||
export async function uploadImage(file: File): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
@@ -139,21 +149,74 @@ export async function uploadImage(file: File): Promise<{ url: string; filename:
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
return await res.json();
|
||||
}
|
||||
export async function uploadVideo(file: File): Promise<{ url: string; filename: string }> {
|
||||
export async function uploadVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-video`, {
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-video${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('视频上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/hot-opening-replications/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadHotOpeningVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/hot-opening-replications/upload-video${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('视频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadShotReplicateImage(file: File): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/shot-replications/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadShotReplicateVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/shot-replications/upload-video${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('视频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
export async function deleteUpload(url: string): Promise<void> {
|
||||
await api.post(`/generation-records/delete-file?url=${encodeURIComponent(url)}`);
|
||||
@@ -741,6 +804,100 @@ export async function deleteResourcesMaterial(params:any): Promise<any> {
|
||||
return api.delete(`/generation-ai/history/batch`, params);
|
||||
}
|
||||
|
||||
// ── UploadResource History ───────────────────────────────
|
||||
export interface UploadResourceMediaReferenceOut {
|
||||
name?: string | null;
|
||||
type: 'image' | 'video' | 'audio';
|
||||
url: string;
|
||||
label?: string | null;
|
||||
duration?: number | null;
|
||||
source: 'upload_resource';
|
||||
uploadResourceId: string;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryItemOut {
|
||||
id: string;
|
||||
sourceType: 'upload_resource';
|
||||
historySource: 'upload_resource';
|
||||
historySourceLabel: string;
|
||||
module: string;
|
||||
moduleLabel: string;
|
||||
resourceType: 'image' | 'video' | 'audio';
|
||||
resourceTypeLabel: string;
|
||||
resourceUrl: string;
|
||||
displayUrl: string;
|
||||
previewUrl: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
audioUrl?: string | null;
|
||||
fileName?: string | null;
|
||||
fileExt?: string | null;
|
||||
mimeType?: string | null;
|
||||
fileSizeBytes: number;
|
||||
durationSeconds?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
bindStatus: string;
|
||||
deletePolicy: string;
|
||||
deletable: boolean;
|
||||
mediaReference: UploadResourceMediaReferenceOut;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayGroupOut {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
items: UploadResourceHistoryItemOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryGroupedOut {
|
||||
totalDays: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
groups: UploadResourceHistoryDayGroupOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayItemsOut {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: UploadResourceHistoryItemOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryQueryParams {
|
||||
resourceType?: 'image' | 'video' | 'audio' | '';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
scene?: 'record' | 'picker' | string;
|
||||
}
|
||||
|
||||
function buildUploadResourceHistoryQuery(params: UploadResourceHistoryQueryParams = {}): string {
|
||||
const query = new URLSearchParams();
|
||||
if (params.resourceType) query.set('resource_type', params.resourceType);
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.pageSize) query.set('page_size', String(params.pageSize));
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.scene) query.set('scene', params.scene);
|
||||
const qs = query.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export async function getUploadResourceHistory(params: UploadResourceHistoryQueryParams = {}): Promise<UploadResourceHistoryGroupedOut> {
|
||||
return api.get<UploadResourceHistoryGroupedOut>(`/upload-resources/history${buildUploadResourceHistoryQuery(params)}`);
|
||||
}
|
||||
|
||||
export async function getUploadResourceHistoryItems(generatedDate: string, params: UploadResourceHistoryQueryParams = {}): Promise<UploadResourceHistoryDayItemsOut> {
|
||||
return api.get<UploadResourceHistoryDayItemsOut>(`/upload-resources/history/${generatedDate}${buildUploadResourceHistoryQuery(params)}`);
|
||||
}
|
||||
|
||||
export async function deleteUploadResourceHistoryBatch(resourceIds: string[]): Promise<any> {
|
||||
return api.delete('/upload-resources/history/batch', { resource_ids: resourceIds });
|
||||
}
|
||||
|
||||
|
||||
export async function getPrivatePortraitConfig(): Promise<PrivatePortraitConfig> {
|
||||
return api.get<PrivatePortraitConfig>('/private-portrait/config');
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Modal, Tooltip } from 'antd';
|
||||
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
||||
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
|
||||
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
|
||||
|
||||
interface UploadSelectorProps {
|
||||
children: React.ReactNode;
|
||||
accept?: string;
|
||||
onLocalSelect?: (files: File[]) => void;
|
||||
onHistorySelect?: (items: any[]) => void;
|
||||
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
|
||||
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
|
||||
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
|
||||
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
|
||||
@@ -63,12 +64,6 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
setPortraitPickerOpen(true);
|
||||
};
|
||||
|
||||
const confirmHistorySelection = () => {
|
||||
onHistorySelect?.([]);
|
||||
setHistoryModalVisible(false);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'history',
|
||||
@@ -192,39 +187,16 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="选择资产素材"
|
||||
<UploadResourceHistoryPicker
|
||||
open={historyModalVisible}
|
||||
onCancel={() => setHistoryModalVisible(false)}
|
||||
footer={null}
|
||||
width="80%"
|
||||
centered
|
||||
destroyOnHidden
|
||||
>
|
||||
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
|
||||
暂无资产图片
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
|
||||
<button
|
||||
onClick={confirmHistorySelection}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: '#8b5cf6',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
onClose={() => setHistoryModalVisible(false)}
|
||||
onSelect={(items) => {
|
||||
onHistorySelect?.(items);
|
||||
setHistoryModalVisible(false);
|
||||
setModalVisible(false);
|
||||
}}
|
||||
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
|
||||
/>
|
||||
|
||||
<PrivatePortraitAssetPicker
|
||||
open={portraitPickerOpen}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AudioOutlined, DeleteOutlined, DownloadOutlined, EyeOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import { Button, Checkbox, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { deleteUpload, deleteUploadResourceHistoryBatch, getUploadResourceHistory, getUploadResourceHistoryItems } from '../../api';
|
||||
import type { UploadResourceHistoryDayGroup, UploadResourceHistoryItem } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
|
||||
|
||||
const buildPreviewUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const typeIcon = (type: string) => {
|
||||
if (type === 'video') return <VideoCameraOutlined />;
|
||||
if (type === 'audio') return <AudioOutlined />;
|
||||
return <PictureOutlined />;
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
if (type === 'video') return '视频';
|
||||
if (type === 'audio') return '音频';
|
||||
return '图片';
|
||||
};
|
||||
|
||||
const bytesText = (bytes?: number | null) => {
|
||||
const value = Number(bytes || 0);
|
||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${value} B`;
|
||||
};
|
||||
|
||||
const UploadResourceHistoryPanel: React.FC = () => {
|
||||
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groups, setGroups] = useState<UploadResourceHistoryDayGroup[]>([]);
|
||||
const [totalDays, setTotalDays] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [previewItem, setPreviewItem] = useState<UploadResourceHistoryItem | null>(null);
|
||||
|
||||
const allItems = useMemo(() => groups.flatMap((group) => group.items || []), [groups]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUploadResourceHistory({
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
scene: 'record',
|
||||
});
|
||||
setGroups(res.groups || []);
|
||||
setTotalDays(res.totalDays || 0);
|
||||
setSelectedIds(new Set());
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载历史上传素材失败');
|
||||
setGroups([]);
|
||||
setTotalDays(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword, page, pageSize, resourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const ids = allItems.map((item) => item.id);
|
||||
setSelectedIds((prev) => (prev.size === ids.length ? new Set() : new Set(ids)));
|
||||
};
|
||||
|
||||
const handleLoadMoreDay = async (group: UploadResourceHistoryDayGroup) => {
|
||||
const nextPage = (group.page || 1) + 1;
|
||||
try {
|
||||
const res = await getUploadResourceHistoryItems(group.generatedDate, {
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page: nextPage,
|
||||
pageSize: 10,
|
||||
scene: 'record',
|
||||
});
|
||||
setGroups((prev) => prev.map((item) => item.generatedDate === group.generatedDate ? { ...item, page: nextPage, items: [...item.items, ...(res.items || [])] } : item));
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载更多失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOne = (item: UploadResourceHistoryItem) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除历史上传素材',
|
||||
content: `确定删除 ${item.fileName || item.id} 吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteUpload(item.resourceUrl);
|
||||
message.success('删除成功');
|
||||
load();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
message.warning('请先选择要删除的上传素材');
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 30) {
|
||||
message.warning('一次最多删除 30 条上传素材');
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
Modal.confirm({
|
||||
title: '确认批量删除历史上传素材',
|
||||
content: `确定删除选中的 ${ids.length} 条上传素材吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await deleteUploadResourceHistoryBatch(ids);
|
||||
message.success(`删除成功,释放 ${bytesText(res?.releasedSizeBytes || 0)}`);
|
||||
load();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '批量删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = (item: UploadResourceHistoryItem) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = buildPreviewUrl(item.resourceUrl);
|
||||
link.download = item.fileName || item.id;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const renderMedia = (item: UploadResourceHistoryItem, size: 'card' | 'preview' = 'card') => {
|
||||
const url = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||
const style = size === 'card'
|
||||
? { width: '100%', height: 130, objectFit: 'cover' as const }
|
||||
: { width: '100%', maxHeight: 620, objectFit: 'contain' as const };
|
||||
if (item.resourceType === 'image') return <img src={url} alt={item.fileName || item.id} style={style} />;
|
||||
if (item.resourceType === 'video') return <video src={url} controls={size === 'preview'} muted={size === 'card'} style={style} />;
|
||||
return <audio src={url} controls style={{ width: '100%' }} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={resourceType}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setResourceType(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件名或URL"
|
||||
style={{ width: 240 }}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}>批量删除 ({selectedIds.size})</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{groups.length === 0 ? (
|
||||
<Empty description="暂无可展示的历史上传素材" style={{ padding: '60px 0' }} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||
{groups.map((group) => (
|
||||
<div key={group.generatedDate}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
|
||||
<Text type="secondary">共 {group.total} 个素材</Text>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: 16 }}>
|
||||
{group.items.map((item) => {
|
||||
const checked = selectedIds.has(item.id);
|
||||
return (
|
||||
<div key={item.id} style={{ border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
|
||||
<div style={{ position: 'relative', background: '#f1f5f9' }}>
|
||||
{renderMedia(item)}
|
||||
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
|
||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||
</div>
|
||||
<div style={{ padding: 12 }}>
|
||||
<Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text>
|
||||
<Space size={4} wrap style={{ marginTop: 8 }}>
|
||||
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
|
||||
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
|
||||
</Space>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setPreviewItem(item)}>预览</Button>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(item)}>下载</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteOne(item)}>删除</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{group.items.length < group.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
||||
<Button onClick={() => handleLoadMoreDay(group)}>加载更多</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
|
||||
</div>
|
||||
|
||||
<Modal open={!!previewItem} title={previewItem?.fileName || '预览'} footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
|
||||
{previewItem ? renderMedia(previewItem, 'preview') : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadResourceHistoryPanel;
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AudioOutlined, CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import { Button, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { getUploadResourceHistoryItems } from '../../api';
|
||||
import type { UploadResourceHistoryItem } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
|
||||
|
||||
interface UploadResourceHistoryPickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (items: UploadResourceHistoryItem[]) => void;
|
||||
allowedTypes?: Array<'image' | 'video' | 'audio'>;
|
||||
selectedIds?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const buildPreviewUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const typeIcon = (type: string) => {
|
||||
if (type === 'video') return <VideoCameraOutlined />;
|
||||
if (type === 'audio') return <AudioOutlined />;
|
||||
return <PictureOutlined />;
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
if (type === 'video') return '视频';
|
||||
if (type === 'audio') return '音频';
|
||||
return '图片';
|
||||
};
|
||||
|
||||
const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
allowedTypes,
|
||||
selectedIds = [],
|
||||
title = '选择历史上传素材',
|
||||
}) => {
|
||||
const [date, setDate] = useState(today());
|
||||
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<UploadResourceHistoryItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [checkedMap, setCheckedMap] = useState<Map<string, UploadResourceHistoryItem>>(new Map());
|
||||
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const effectiveAllowedTypes = useMemo(() => new Set(allowedTypes || ['image', 'video', 'audio']), [allowedTypes]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUploadResourceHistoryItems(date, {
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
scene: 'picker',
|
||||
});
|
||||
const filtered = (res.items || []).filter((item) => effectiveAllowedTypes.has(item.resourceType));
|
||||
setItems(filtered);
|
||||
setTotal(res.total || 0);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载历史上传素材失败');
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date, effectiveAllowedTypes, keyword, open, page, pageSize, resourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setCheckedMap(new Map());
|
||||
}, [open]);
|
||||
|
||||
const toggle = (item: UploadResourceHistoryItem) => {
|
||||
if (selectedIdSet.has(item.id)) {
|
||||
message.warning('该素材已经在参考内容中');
|
||||
return;
|
||||
}
|
||||
setCheckedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
else next.set(item.id, item);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
const selected = Array.from(checkedMap.values());
|
||||
if (selected.length === 0) {
|
||||
message.warning('请先选择历史素材');
|
||||
return;
|
||||
}
|
||||
onSelect(selected);
|
||||
setCheckedMap(new Map());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const all = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
];
|
||||
return all.filter((opt) => !opt.value || effectiveAllowedTypes.has(opt.value as any));
|
||||
}, [effectiveAllowedTypes]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={920}
|
||||
centered
|
||||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
|
||||
应用选择 ({checkedMap.size})
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space wrap style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value || today());
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Select
|
||||
value={resourceType}
|
||||
options={typeOptions}
|
||||
style={{ width: 120 }}
|
||||
onChange={(value) => {
|
||||
setResourceType(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件名"
|
||||
style={{ width: 220 }}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{items.length === 0 ? (
|
||||
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14, minHeight: 260 }}>
|
||||
{items.map((item) => {
|
||||
const checked = checkedMap.has(item.id);
|
||||
const disabled = selectedIdSet.has(item.id);
|
||||
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => !disabled && toggle(item)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 14,
|
||||
border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0',
|
||||
background: disabled ? '#f8fafc' : '#fff',
|
||||
opacity: disabled ? 0.55 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
overflow: 'hidden',
|
||||
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{item.resourceType === 'image' ? (
|
||||
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : item.resourceType === 'video' ? (
|
||||
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 10 }}>
|
||||
<Space size={6} style={{ marginBottom: 6 }}>
|
||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
|
||||
</Space>
|
||||
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
|
||||
{item.fileName || item.id}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
|
||||
</div>
|
||||
{checked && (
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
)}
|
||||
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag>已选</Tag></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadResourceHistoryPicker;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as UploadResourceHistoryPicker } from './UploadResourceHistoryPicker';
|
||||
export { default as UploadResourceHistoryPanel } from './UploadResourceHistoryPanel';
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
||||
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -78,8 +78,10 @@ interface MediaReference {
|
||||
source?: string;
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
upload_resource_id?: string;
|
||||
}
|
||||
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
original_prompt: string;
|
||||
@@ -1503,6 +1505,48 @@ const AIChatPage: React.FC = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
const normalizeUploadResourceHistoryItem = (item: UploadResourceHistoryItem): MediaReference | null => {
|
||||
const media = item.mediaReference;
|
||||
const refType = (media?.type || item.resourceType) as 'image' | 'video' | 'audio';
|
||||
const url = media?.url || item.resourceUrl || item.displayUrl || item.previewUrl || '';
|
||||
if (!url) {
|
||||
message.error(`${item.fileName || item.id} 缺少素材地址,不能用于 AI 创作`);
|
||||
return null;
|
||||
}
|
||||
if (refType === 'audio' && mediaType !== 'video') {
|
||||
message.error('仅视频模式支持添加音频素材');
|
||||
return null;
|
||||
}
|
||||
const duration = Number(media?.duration ?? item.durationSeconds);
|
||||
return {
|
||||
name: media?.name || item.fileName || item.id,
|
||||
type: refType,
|
||||
url,
|
||||
source: 'upload_resource',
|
||||
upload_resource_id: item.id,
|
||||
label: '',
|
||||
...((refType === 'video' || refType === 'audio') && Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const handleUploadResourceHistorySelected = (items: UploadResourceHistoryItem[]) => {
|
||||
const normalized = items
|
||||
.map(normalizeUploadResourceHistoryItem)
|
||||
.filter(Boolean) as MediaReference[];
|
||||
if (!normalized.length) return;
|
||||
|
||||
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
|
||||
if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = [...latestMedia, ...normalized];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
message.success(`已添加 ${normalized.length} 个历史上传素材参考`);
|
||||
};
|
||||
|
||||
const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => {
|
||||
if (asset.assetType === 'Audio') {
|
||||
message.error('音频私域素材暂不支持用于 AI 创作');
|
||||
@@ -2612,16 +2656,7 @@ const AIChatPage: React.FC = () => {
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onHistorySelect={handleUploadResourceHistorySelected}
|
||||
onPortraitLibrarySelect={(libraryType) => {
|
||||
openPrivatePortraitPicker(libraryType);
|
||||
}}
|
||||
@@ -2792,16 +2827,7 @@ const AIChatPage: React.FC = () => {
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onHistorySelect={handleUploadResourceHistorySelected}
|
||||
onPortraitLibrarySelect={(libraryType) => {
|
||||
openPrivatePortraitPicker(libraryType);
|
||||
}}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
|
||||
import { UploadResourceHistoryPanel } from '../components/uploadResource';
|
||||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -52,8 +53,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : 'project';
|
||||
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait'>(initialFilterType);
|
||||
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : searchParams.get('filterType') === 'upload_resource' ? 'upload_resource' : 'project';
|
||||
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait' | 'upload_resource'>(initialFilterType);
|
||||
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
|
||||
const [recordlist, setRecordList] = useState<any[]>([]);
|
||||
const [Pagebreak, setPagebreak] = useState<any>({
|
||||
@@ -759,7 +760,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
};
|
||||
const loadRecordList = () => {
|
||||
if (filterType === 'private_portrait') {
|
||||
if (filterType === 'private_portrait' || filterType === 'upload_resource') {
|
||||
setLoading(false);
|
||||
setRecordList([]);
|
||||
return;
|
||||
@@ -1033,9 +1034,29 @@ const GeneratedRecord: React.FC = () => {
|
||||
>
|
||||
私域素材库
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'upload_resource' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('upload_resource');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'upload_resource'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'upload_resource' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'upload_resource' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
历史素材
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{filterType !== 'private_portrait' && (
|
||||
{filterType !== 'private_portrait' && filterType !== 'upload_resource' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{/* 多选模式按钮 */}
|
||||
{isSelectionMode ? (
|
||||
@@ -1120,6 +1141,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
</div>
|
||||
{filterType === 'private_portrait' ? (
|
||||
<PrivatePortraitLibraryPanel />
|
||||
) : filterType === 'upload_resource' ? (
|
||||
<UploadResourceHistoryPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* Second row filter: 视频 / 图片 */}
|
||||
|
||||
@@ -18,11 +18,20 @@ import {
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadVideo, uploadImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
|
||||
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
|
||||
const buildAssetUrl = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//i.test(url) || url.startsWith('blob:')) return url;
|
||||
return `${API_BASE}${url}`;
|
||||
};
|
||||
|
||||
|
||||
const GenerateConver: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -50,8 +59,11 @@ const GenerateConver: React.FC = () => {
|
||||
// 文件状态
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [videoResourceId, setVideoResourceId] = useState<string>('');
|
||||
const [videoDurationSeconds, setVideoDurationSeconds] = useState<number | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string>('');
|
||||
const [imageResourceId, setImageResourceId] = useState<string>('');
|
||||
const [videoUploading, setVideoUploading] = useState(false);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
|
||||
@@ -222,9 +234,11 @@ const GenerateConver: React.FC = () => {
|
||||
// 调用上传接口
|
||||
setVideoUploading(true);
|
||||
try {
|
||||
const res = await uploadVideo(file);
|
||||
const res = await uploadHotOpeningVideo(file, video.duration);
|
||||
setVideoFile(file);
|
||||
setVideoUrl(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${res.url}`);
|
||||
setVideoUrl(res.url);
|
||||
setVideoResourceId(res.resource_id || '');
|
||||
setVideoDurationSeconds(video.duration);
|
||||
message.success('视频上传成功');
|
||||
resolve(false);
|
||||
} catch (error) {
|
||||
@@ -280,9 +294,10 @@ const GenerateConver: React.FC = () => {
|
||||
// 调用上传接口
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
const res = await uploadHotOpeningImage(file);
|
||||
setImageFile(file);
|
||||
setImageUrl(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${res.url}`);
|
||||
setImageUrl(res.url);
|
||||
setImageResourceId(res.resource_id || '');
|
||||
message.success('图片上传成功');
|
||||
resolve(false);
|
||||
} catch (error) {
|
||||
@@ -363,6 +378,9 @@ const GenerateConver: React.FC = () => {
|
||||
let params = {
|
||||
material_video_url: videoUrl,
|
||||
material_image_url: imageUrl,
|
||||
material_video_resource_id: videoResourceId || undefined,
|
||||
material_image_resource_id: imageResourceId || undefined,
|
||||
material_video_duration_seconds: videoDurationSeconds || undefined,
|
||||
source_project_name: originalProductName,
|
||||
target_project_name: ownProductName,
|
||||
core_content_point: productSellingPoints,
|
||||
@@ -380,7 +398,10 @@ const GenerateConver: React.FC = () => {
|
||||
|
||||
// 清空上传的媒体和文本
|
||||
setVideoUrl('');
|
||||
setVideoResourceId('');
|
||||
setVideoDurationSeconds(null);
|
||||
setImageUrl('');
|
||||
setImageResourceId('');
|
||||
setOriginalProductName('');
|
||||
setOwnProductName('');
|
||||
setProductSellingPoints('');
|
||||
@@ -818,7 +839,7 @@ const GenerateConver: React.FC = () => {
|
||||
{videoUrl ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<video
|
||||
src={videoUrl}
|
||||
src={buildAssetUrl(videoUrl)}
|
||||
controls
|
||||
style={{ width: '100%', borderRadius: 12, maxHeight: 200, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)' }}
|
||||
/>
|
||||
@@ -934,7 +955,7 @@ const GenerateConver: React.FC = () => {
|
||||
{imageUrl ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
src={buildAssetUrl(imageUrl)}
|
||||
alt="产品图片"
|
||||
style={{ width: '100%', borderRadius: 12, maxHeight: 200, objectFit: 'contain', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)' }}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
||||
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
||||
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
|
||||
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadShotReplicateImage } from '../api';
|
||||
import VideoTrimPicker from '../components/VideoTrimPicker';
|
||||
|
||||
const { TextArea } = Input;
|
||||
@@ -30,6 +30,7 @@ function RemoveInfo() {
|
||||
const [productName, setProductName] = useState('');
|
||||
const [productSellingPoint, setProductSellingPoint] = useState('');
|
||||
const [productImage, setProductImage] = useState('');
|
||||
const [productImageResourceId, setProductImageResourceId] = useState('');
|
||||
const [taskDetail, setTaskDetail] = useState<any>(null);
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -190,18 +191,21 @@ function RemoveInfo() {
|
||||
setCurrentSegmentName('');
|
||||
setProductSellingPoint('');
|
||||
setProductImage('');
|
||||
setProductImageResourceId('');
|
||||
};
|
||||
|
||||
const handleProductImageChange: any = (info: any) => {
|
||||
if (info.fileList.length === 0) {
|
||||
setProductImage('');
|
||||
setProductImageResourceId('');
|
||||
}
|
||||
};
|
||||
|
||||
const beforeUploadProductImage = async (file: File) => {
|
||||
try {
|
||||
const uploadResult = await uploadImage(file);
|
||||
const uploadResult = await uploadShotReplicateImage(file);
|
||||
setProductImage(uploadResult.url);
|
||||
setProductImageResourceId(uploadResult.resource_id || '');
|
||||
message.success('图片上传成功');
|
||||
} catch {
|
||||
message.error('图片上传失败,请重试');
|
||||
@@ -231,7 +235,8 @@ function RemoveInfo() {
|
||||
const params = {
|
||||
target_project_name: productName.trim(),
|
||||
core_content_point: productSellingPoint.trim(),
|
||||
material_image_url: buildAssetUrl(productImage),
|
||||
material_image_url: productImage,
|
||||
material_image_resource_id: productImageResourceId || undefined,
|
||||
idempotency_key: `replication_${Date.now()}`,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useState, useRef, useCallback } from 'react';
|
||||
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
|
||||
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadVideo, createShotReplication, getShotReplicationList } from '../api';
|
||||
import { uploadShotReplicateVideo, createShotReplication, getShotReplicationList } from '../api';
|
||||
import bg1 from '../assets/bg1.png';
|
||||
|
||||
export default function VideoFrameExtractor() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [videoResourceId, setVideoResourceId] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [productName, setProductName] = useState<string>('');
|
||||
@@ -28,6 +29,7 @@ export default function VideoFrameExtractor() {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
}
|
||||
setVideoUrl('');
|
||||
setVideoResourceId('');
|
||||
setError('');
|
||||
setVideoDuration(0);
|
||||
setProductName('');
|
||||
@@ -66,8 +68,9 @@ export default function VideoFrameExtractor() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadVideo(file);
|
||||
const uploadResult = await uploadShotReplicateVideo(file, duration);
|
||||
setVideoUrl(uploadResult.url);
|
||||
setVideoResourceId(uploadResult.resource_id || '');
|
||||
setError('');
|
||||
message.success('视频上传成功');
|
||||
} catch (err) {
|
||||
@@ -97,6 +100,7 @@ export default function VideoFrameExtractor() {
|
||||
const params = {
|
||||
video_url: videoUrl,
|
||||
video_duration_seconds: videoDuration,
|
||||
video_resource_id: videoResourceId || undefined,
|
||||
title: productName.trim(),
|
||||
idempotency_key: `shot_${Date.now()}`,
|
||||
};
|
||||
|
||||
@@ -139,8 +139,10 @@ export interface MediaReference {
|
||||
providerUrl?: string;
|
||||
displayUrl?: string;
|
||||
previewUrl?: string;
|
||||
upload_resource_id?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
items:[],
|
||||
@@ -386,3 +388,69 @@ export interface PrivatePortraitSelectableAssetListOut {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── UploadResource History Types ──────────────────────────
|
||||
export type UploadResourceType = 'image' | 'video' | 'audio';
|
||||
|
||||
export interface UploadResourceMediaReference {
|
||||
name?: string | null;
|
||||
type: UploadResourceType;
|
||||
url: string;
|
||||
label?: string | null;
|
||||
duration?: number | null;
|
||||
source: 'upload_resource';
|
||||
uploadResourceId: string;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryItem {
|
||||
id: string;
|
||||
sourceType: 'upload_resource';
|
||||
historySource: 'upload_resource';
|
||||
historySourceLabel: string;
|
||||
module: string;
|
||||
moduleLabel: string;
|
||||
resourceType: UploadResourceType;
|
||||
resourceTypeLabel: string;
|
||||
resourceUrl: string;
|
||||
displayUrl: string;
|
||||
previewUrl: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
audioUrl?: string | null;
|
||||
fileName?: string | null;
|
||||
fileExt?: string | null;
|
||||
mimeType?: string | null;
|
||||
fileSizeBytes: number;
|
||||
durationSeconds?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
bindStatus: string;
|
||||
deletePolicy: string;
|
||||
deletable: boolean;
|
||||
mediaReference: UploadResourceMediaReference;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayGroup {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
items: UploadResourceHistoryItem[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryGrouped {
|
||||
totalDays: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
groups: UploadResourceHistoryDayGroup[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayItems {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: UploadResourceHistoryItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user