Files
video-gen/video-gen-api/app/services/private_portrait/project_service.py
T

291 lines
12 KiB
Python

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