from __future__ import annotations import re from datetime import datetime, timezone from fastapi import HTTPException from sqlalchemy import case, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.enums.private_portrait import ( PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, PrivatePortraitAssetGroupStatus, PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitEventSource, PrivatePortraitEventStatus, PrivatePortraitEventType, PrivatePortraitLibraryType, PrivatePortraitProjectStatus, PrivatePortraitRemoteDeleteStatus, ) from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate from app.services.operation_log_service import log_operation_event from app.utils.id_gen import generate_id DOMAIN = "private_portrait" def _safe_slug(value: str, *, max_length: int = 80) -> str: value = (value or "").strip().lower() value = re.sub(r"[^a-z0-9_-]+", "_", value) value = re.sub(r"_+", "_", value).strip("_-") return (value[:max_length] or "project") def _status_for_created_project(library_type: str) -> str: if library_type == PrivatePortraitLibraryType.REAL_PERSON.value: return PrivatePortraitProjectStatus.VALIDATING.value if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value: return PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value raise HTTPException(status_code=400, detail="library_type 不支持") def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut: return PrivatePortraitProjectOut( id=project.id, user_id=project.user_id if include_user else None, library_type=project.library_type, name=project.name, name_slug=project.name_slug, remote_project_name=project.remote_project_name, description=project.description, status=project.status, asset_group_count=project.asset_group_count or 0, asset_count=project.asset_count or 0, image_asset_count=getattr(project, "image_asset_count", 0) or 0, video_asset_count=getattr(project, "video_asset_count", 0) or 0, active_asset_count=project.active_asset_count or 0, active_image_asset_count=getattr(project, "active_image_asset_count", 0) or 0, active_video_asset_count=getattr(project, "active_video_asset_count", 0) or 0, last_used_at=project.last_used_at, created_at=project.created_at, updated_at=project.updated_at, ) async def get_user_project( db: AsyncSession, *, user_id: str, project_id: str, library_type: str | None = None, ) -> PrivatePortraitProject: filters = [ PrivatePortraitProject.id == project_id, PrivatePortraitProject.user_id == user_id, PrivatePortraitProject.deleted_at.is_(None), ] if library_type: filters.append(PrivatePortraitProject.library_type == library_type) result = await db.execute(select(PrivatePortraitProject).where(*filters).limit(1)) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="私域人像素材项目不存在") return project async def create_project( db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate, library_type: str = PrivatePortraitLibraryType.REAL_PERSON.value, status: str | None = None, remote_project_name: str = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME, ) -> PrivatePortraitProject: slug = _safe_slug(payload.name) project = PrivatePortraitProject( id=generate_id(), user_id=user_id, library_type=library_type, name=payload.name.strip(), name_slug=slug, remote_project_name=remote_project_name, description=payload.description, status=status or _status_for_created_project(library_type), ) db.add(project) await db.flush() log_operation_event( domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_CREATE.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, message="创建私域人像素材项目", detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status}, ) return project async def update_project( db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate, library_type: str | None = None, ) -> PrivatePortraitProject: project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) before = { "name": project.name, "name_slug": project.name_slug, "remote_project_name": project.remote_project_name, "description": project.description, "status": project.status, "library_type": project.library_type, } if payload.name is not None: new_name = payload.name.strip() if new_name and new_name != project.name: project.name = new_name project.name_slug = _safe_slug(new_name) if payload.description is not None: project.description = payload.description if payload.status is not None: allowed_statuses = {item.value for item in PrivatePortraitProjectStatus if item != PrivatePortraitProjectStatus.DELETED} if payload.status not in allowed_statuses: raise HTTPException(status_code=400, detail="项目状态不支持") project.status = payload.status await db.flush() after = { "name": project.name, "name_slug": project.name_slug, "remote_project_name": project.remote_project_name, "description": project.description, "status": project.status, "library_type": project.library_type, } log_operation_event( domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_UPDATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, message="更新私域人像素材项目", detail={"before": before, "after": after}, ) return project async def list_projects( db: AsyncSession, *, user_id: str | None, page: int = 1, page_size: int = 20, keyword: str | None = None, status: str | None = None, library_type: str | None = None, ) -> tuple[list[PrivatePortraitProject], int]: page = max(1, page) page_size = min(max(1, page_size), 100) filters = [PrivatePortraitProject.deleted_at.is_(None)] if user_id: filters.append(PrivatePortraitProject.user_id == user_id) if library_type: filters.append(PrivatePortraitProject.library_type == library_type) if keyword: filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%")) if status: filters.append(PrivatePortraitProject.status == status) total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one() result = await db.execute( select(PrivatePortraitProject) .where(*filters) .order_by(PrivatePortraitProject.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) return list(result.scalars().all()), int(total or 0) async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) -> None: project_ids = list({pid for pid in project_ids if pid}) if not project_ids: return group_rows = await db.execute( select(PrivatePortraitAssetGroup.project_id, func.count(PrivatePortraitAssetGroup.id)) .where(PrivatePortraitAssetGroup.project_id.in_(project_ids), PrivatePortraitAssetGroup.deleted_at.is_(None)) .group_by(PrivatePortraitAssetGroup.project_id) ) asset_rows = await db.execute( select( PrivatePortraitAsset.project_id, func.count(PrivatePortraitAsset.id), func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)), func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)), func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)), func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value), 1), else_=0)), func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value), 1), else_=0)), ) .where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None)) .group_by(PrivatePortraitAsset.project_id) ) group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()} asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {} for pid, total, image_total, video_total, active_total, active_image_total, active_video_total in asset_rows.all(): asset_count_map[pid] = ( int(total or 0), int(image_total or 0), int(video_total or 0), int(active_total or 0), int(active_image_total or 0), int(active_video_total or 0), ) for pid in project_ids: total, image_total, video_total, active_total, active_image_total, active_video_total = asset_count_map.get(pid, (0, 0, 0, 0, 0, 0)) await db.execute( update(PrivatePortraitProject) .where(PrivatePortraitProject.id == pid) .values( asset_group_count=group_count_map.get(pid, 0), asset_count=total, image_asset_count=image_total, video_asset_count=video_total, active_asset_count=active_total, active_image_asset_count=active_image_total, active_video_asset_count=active_video_total, ) .execution_options(synchronize_session=False) ) async def soft_delete_project( db: AsyncSession, *, user_id: str, project_id: str, library_type: str | None = None, ) -> PrivatePortraitProject: project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type) now = datetime.now(timezone.utc) project.deleted_at = now project.status = PrivatePortraitProjectStatus.DELETED.value await db.execute( update(PrivatePortraitAsset) .where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)) .values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) ) await db.execute( update(PrivatePortraitAssetGroup) .where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None)) .values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value) ) await db.flush() log_operation_event( domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, message="本地软删私域人像素材项目", detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name}, ) return project