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

This commit is contained in:
2026-07-08 16:25:03 +08:00
parent 657505e63b
commit d085a6d2ec
47 changed files with 5424 additions and 447 deletions
@@ -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