Files
video-gen/video-gen-api/app/services/upload_resource/history_service.py
T
2026-07-09 09:10:49 +08:00

306 lines
11 KiB
Python

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