1
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user