290 lines
12 KiB
Python
290 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,
|
|
)
|
|
)
|
|
|
|
|
|
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
|