Files
video-gen/video-gen-api/app/services/private_portrait/project_service.py
T
2026-07-06 15:13:41 +08:00

226 lines
11 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.config import settings
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_PROJECT_ENV_VALUES,
PrivatePortraitAssetGroupStatus,
PrivatePortraitAssetStatus,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
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()
# 先保留常见英文数字连字符;中文等字符统一转 _,避免火山 ProjectName 字符限制不明确导致失败。
value = re.sub(r"[^a-z0-9_-]+", "_", value)
value = re.sub(r"_+", "_", value).strip("_-")
return (value[:max_length] or "project")
def get_private_portrait_env() -> str:
env = str(getattr(settings, "APP_ENV", "local") or "local").strip().lower()
if env not in PRIVATE_PORTRAIT_PROJECT_ENV_VALUES:
env = "local"
return env
def build_remote_project_name(*, user_id: str, project_name: str) -> tuple[str, str]:
slug = _safe_slug(project_name)
user_part = _safe_slug(user_id, max_length=40)
env = get_private_portrait_env()
return f"{env}-{user_part}-{slug}"[:256], slug
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,
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,
active_asset_count=project.active_asset_count 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) -> PrivatePortraitProject:
result = await db.execute(
select(PrivatePortraitProject).where(
PrivatePortraitProject.id == project_id,
PrivatePortraitProject.user_id == user_id,
PrivatePortraitProject.deleted_at.is_(None),
).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) -> PrivatePortraitProject:
remote_project_name, slug = build_remote_project_name(user_id=user_id, project_name=payload.name)
project = PrivatePortraitProject(
id=generate_id(),
user_id=user_id,
name=payload.name.strip(),
name_slug=slug,
remote_project_name=remote_project_name,
description=payload.description,
status=PrivatePortraitProjectStatus.ACTIVE.value,
)
db.add(project)
await db.flush()
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_CREATE.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
message="创建真人素材项目",
detail={"name": project.name, "remote_project_name": project.remote_project_name},
)
return project
async def _project_has_remote_resources(db: AsyncSession, *, project_id: str) -> bool:
session_count = (await db.execute(select(func.count(PrivatePortraitValidateSession.id)).where(PrivatePortraitValidateSession.project_id == project_id))).scalar_one() or 0
group_count = (await db.execute(select(func.count(PrivatePortraitAssetGroup.id)).where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.remote_group_id.is_not(None)))).scalar_one() or 0
asset_count = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.remote_asset_id.is_not(None)))).scalar_one() or 0
return bool(session_count or group_count or asset_count)
async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject:
project = await get_user_project(db, user_id=user_id, project_id=project_id)
before = {
"name": project.name,
"name_slug": project.name_slug,
"remote_project_name": project.remote_project_name,
"description": project.description,
"status": project.status,
}
remote_name_locked = False
if payload.name is not None:
new_name = payload.name.strip()
if new_name and new_name != project.name:
has_remote = await _project_has_remote_resources(db, project_id=project.id)
project.name = new_name
if not has_remote:
remote_project_name, slug = build_remote_project_name(user_id=user_id, project_name=new_name)
project.name_slug = slug
project.remote_project_name = remote_project_name
else:
remote_name_locked = True
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_REMOTE_NAME_LOCKED.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project.id,
message="项目已有远程资源,仅修改展示名,remote_project_name 保持不变",
detail={"remote_project_name": project.remote_project_name},
)
if payload.description is not None:
project.description = payload.description
if payload.status is not None:
if payload.status not in {PrivatePortraitProjectStatus.ACTIVE.value}:
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,
}
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, "remote_name_locked": remote_name_locked},
)
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) -> 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 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.status == PrivatePortraitAssetStatus.ACTIVE.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]] = {}
for pid, total, active_total in asset_rows.all():
asset_count_map[pid] = (int(total or 0), int(active_total or 0))
for pid in project_ids:
total, active_total = asset_count_map.get(pid, (0, 0))
await db.execute(update(PrivatePortraitProject).where(PrivatePortraitProject.id == pid).values(asset_group_count=group_count_map.get(pid, 0), asset_count=total, active_asset_count=active_total))
async def soft_delete_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
project = await get_user_project(db, user_id=user_id, project_id=project_id)
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={"remote_project_name": project.remote_project_name})
return project