feat: add resource library and real image workflow

This commit is contained in:
afei A
2026-03-29 00:24:29 +08:00
parent eeaff269eb
commit 04da401ab4
38 changed files with 3033 additions and 117 deletions

View File

@@ -0,0 +1,366 @@
"""Library resource application service."""
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.schemas.library import (
ArchiveLibraryResourceResponse,
CreateLibraryResourceRequest,
LibraryResourceFileRead,
LibraryResourceListResponse,
LibraryResourceRead,
PresignUploadResponse,
UpdateLibraryResourceRequest,
)
from app.config.settings import get_settings
from app.domain.enums import LibraryFileRole, LibraryResourceStatus, LibraryResourceType
from app.infra.db.models.library_resource import LibraryResourceORM
from app.infra.db.models.library_resource_file import LibraryResourceFileORM
from app.infra.storage.s3 import RESOURCE_PREFIXES, S3PresignService
class LibraryService:
"""Application service for resource-library uploads and queries."""
def __init__(self, presign_service: S3PresignService | None = None) -> None:
self.presign_service = presign_service or S3PresignService()
def create_upload_presign(
self,
resource_type: LibraryResourceType,
file_name: str,
content_type: str,
) -> PresignUploadResponse:
"""Create upload metadata for a direct S3 PUT."""
settings = get_settings()
if not all(
[
settings.s3_bucket,
settings.s3_region,
settings.s3_access_key,
settings.s3_secret_key,
settings.s3_endpoint,
]
):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="S3 upload settings are incomplete",
)
storage_key, upload_url = self.presign_service.create_upload(
resource_type=resource_type,
file_name=file_name,
content_type=content_type,
)
return PresignUploadResponse(
method="PUT",
upload_url=upload_url,
headers={"content-type": content_type},
storage_key=storage_key,
public_url=self.presign_service.get_public_url(storage_key),
)
async def create_resource(
self,
session: AsyncSession,
payload: CreateLibraryResourceRequest,
) -> LibraryResourceRead:
"""Persist a resource and its uploaded file metadata."""
self._validate_payload(payload)
resource = LibraryResourceORM(
resource_type=payload.resource_type,
name=payload.name.strip(),
description=payload.description.strip() if payload.description else None,
tags=[tag.strip() for tag in payload.tags if tag.strip()],
status=LibraryResourceStatus.ACTIVE,
gender=payload.gender.strip() if payload.gender else None,
age_group=payload.age_group.strip() if payload.age_group else None,
pose_id=payload.pose_id,
environment=payload.environment.strip() if payload.environment else None,
category=payload.category.strip() if payload.category else None,
)
session.add(resource)
await session.flush()
file_models: list[LibraryResourceFileORM] = []
for item in payload.files:
file_model = LibraryResourceFileORM(
resource_id=resource.id,
file_role=item.file_role,
storage_key=item.storage_key,
public_url=item.public_url,
bucket=get_settings().s3_bucket,
mime_type=item.mime_type,
size_bytes=item.size_bytes,
sort_order=item.sort_order,
width=item.width,
height=item.height,
)
session.add(file_model)
file_models.append(file_model)
await session.flush()
resource.original_file_id = self._find_role(file_models, LibraryFileRole.ORIGINAL).id
resource.cover_file_id = self._find_role(file_models, LibraryFileRole.THUMBNAIL).id
await session.commit()
return self._to_read(resource, files=file_models)
async def list_resources(
self,
session: AsyncSession,
*,
resource_type: LibraryResourceType | None = None,
query: str | None = None,
gender: str | None = None,
age_group: str | None = None,
environment: str | None = None,
category: str | None = None,
page: int = 1,
limit: int = 20,
) -> LibraryResourceListResponse:
"""List persisted resources with simple filter support."""
filters = [LibraryResourceORM.status == LibraryResourceStatus.ACTIVE]
if resource_type is not None:
filters.append(LibraryResourceORM.resource_type == resource_type)
if query:
like = f"%{query.strip()}%"
filters.append(
or_(
LibraryResourceORM.name.ilike(like),
LibraryResourceORM.description.ilike(like),
)
)
if gender:
filters.append(LibraryResourceORM.gender == gender)
if age_group:
filters.append(LibraryResourceORM.age_group == age_group)
if environment:
filters.append(LibraryResourceORM.environment == environment)
if category:
filters.append(LibraryResourceORM.category == category)
total = (
await session.execute(select(func.count(LibraryResourceORM.id)).where(*filters))
).scalar_one()
result = await session.execute(
select(LibraryResourceORM)
.options(selectinload(LibraryResourceORM.files))
.where(*filters)
.order_by(LibraryResourceORM.created_at.desc(), LibraryResourceORM.id.desc())
.offset((page - 1) * limit)
.limit(limit)
)
items = result.scalars().all()
return LibraryResourceListResponse(total=total, items=[self._to_read(item) for item in items])
async def update_resource(
self,
session: AsyncSession,
resource_id: int,
payload: UpdateLibraryResourceRequest,
) -> LibraryResourceRead:
"""Update editable metadata on a library resource."""
resource = await self._get_resource_or_404(session, resource_id)
if payload.name is not None:
name = payload.name.strip()
if not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Resource name is required",
)
resource.name = name
if payload.description is not None:
resource.description = payload.description.strip() or None
if payload.tags is not None:
resource.tags = [tag.strip() for tag in payload.tags if tag.strip()]
if resource.resource_type == LibraryResourceType.MODEL:
if payload.gender is not None:
resource.gender = payload.gender.strip() or None
if payload.age_group is not None:
resource.age_group = payload.age_group.strip() or None
if payload.pose_id is not None:
resource.pose_id = payload.pose_id
elif resource.resource_type == LibraryResourceType.SCENE:
if payload.environment is not None:
resource.environment = payload.environment.strip() or None
elif resource.resource_type == LibraryResourceType.GARMENT:
if payload.category is not None:
resource.category = payload.category.strip() or None
if payload.cover_file_id is not None:
cover_file = next(
(file for file in resource.files if file.id == payload.cover_file_id),
None,
)
if cover_file is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cover file does not belong to the resource",
)
resource.cover_file_id = cover_file.id
self._validate_existing_resource(resource)
await session.commit()
await session.refresh(resource, attribute_names=["files"])
return self._to_read(resource)
async def archive_resource(
self,
session: AsyncSession,
resource_id: int,
) -> ArchiveLibraryResourceResponse:
"""Soft delete a library resource by archiving it."""
resource = await self._get_resource_or_404(session, resource_id)
resource.status = LibraryResourceStatus.ARCHIVED
await session.commit()
return ArchiveLibraryResourceResponse(id=resource.id)
def _validate_payload(self, payload: CreateLibraryResourceRequest) -> None:
if not payload.name.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Resource name is required")
if not payload.files:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="At least one file is required")
originals = [file for file in payload.files if file.file_role == LibraryFileRole.ORIGINAL]
thumbnails = [file for file in payload.files if file.file_role == LibraryFileRole.THUMBNAIL]
if len(originals) != 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Exactly one original file is required",
)
if len(thumbnails) != 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Exactly one thumbnail file is required",
)
expected_prefix = f"library/{RESOURCE_PREFIXES[payload.resource_type]}/"
for file in payload.files:
if not file.storage_key.startswith(expected_prefix):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Uploaded file key does not match resource type",
)
if payload.resource_type == LibraryResourceType.MODEL:
if not payload.gender or not payload.age_group:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Model resources require gender and age_group",
)
elif payload.resource_type == LibraryResourceType.SCENE:
if not payload.environment:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Scene resources require environment",
)
elif payload.resource_type == LibraryResourceType.GARMENT and not payload.category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Garment resources require category",
)
def _find_role(
self,
files: list[LibraryResourceFileORM],
role: LibraryFileRole,
) -> LibraryResourceFileORM:
return next(file for file in files if file.file_role == role)
def _to_read(
self,
resource: LibraryResourceORM,
*,
files: list[LibraryResourceFileORM] | None = None,
) -> LibraryResourceRead:
resource_files = files if files is not None else list(resource.files)
files_sorted = sorted(resource_files, key=lambda item: (item.sort_order, item.id))
cover = next((item for item in files_sorted if item.id == resource.cover_file_id), None)
original = next((item for item in files_sorted if item.id == resource.original_file_id), None)
return LibraryResourceRead(
id=resource.id,
resource_type=resource.resource_type,
name=resource.name,
description=resource.description,
tags=resource.tags,
status=resource.status,
gender=resource.gender,
age_group=resource.age_group,
pose_id=resource.pose_id,
environment=resource.environment,
category=resource.category,
cover_url=cover.public_url if cover else None,
original_url=original.public_url if original else None,
files=[
LibraryResourceFileRead(
id=file.id,
file_role=file.file_role,
storage_key=file.storage_key,
public_url=file.public_url,
bucket=file.bucket,
mime_type=file.mime_type,
size_bytes=file.size_bytes,
sort_order=file.sort_order,
width=file.width,
height=file.height,
created_at=file.created_at,
)
for file in files_sorted
],
created_at=resource.created_at,
updated_at=resource.updated_at,
)
async def _get_resource_or_404(
self,
session: AsyncSession,
resource_id: int,
) -> LibraryResourceORM:
result = await session.execute(
select(LibraryResourceORM)
.options(selectinload(LibraryResourceORM.files))
.where(LibraryResourceORM.id == resource_id)
)
resource = result.scalar_one_or_none()
if resource is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
return resource
def _validate_existing_resource(self, resource: LibraryResourceORM) -> None:
if not resource.name.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Resource name is required")
if resource.resource_type == LibraryResourceType.MODEL:
if not resource.gender or not resource.age_group:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Model resources require gender and age_group",
)
elif resource.resource_type == LibraryResourceType.SCENE:
if not resource.environment:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Scene resources require environment",
)
elif resource.resource_type == LibraryResourceType.GARMENT and not resource.category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Garment resources require category",
)