上传素材管控-历史素材success
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user