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