Files
video-gen/video-gen-api/app/services/module_generation_flow_base_service.py
T
2026-07-20 13:48:17 +08:00

449 lines
16 KiB
Python

from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import ModuleEventTypeEnum, ModuleStepStatusEnum
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.user import User
from app.enums.module_generation_flow import ModuleGenerationFlowConfig
from app.services.module_generation_step_common_service import build_step_input, build_step_output, utc_now
from app.services.generation.pipeline.db_lock_service import (
apply_short_lock_timeout,
execute_with_lock_timeout,
raise_if_database_lock_busy,
)
from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
soft_delete_resources_by_source,
)
from app.utils.id_gen import generate_id
LogModuleEventCallable = Callable[..., Awaitable[None]]
async def get_project_for_user(
db: AsyncSession,
*,
project_id: str,
user: User,
config: ModuleGenerationFlowConfig,
for_update: bool = False,
populate_existing: bool = False,
) -> ModuleGenerationProject:
query = select(ModuleGenerationProject).where(
ModuleGenerationProject.id == project_id,
ModuleGenerationProject.module == config.module,
ModuleGenerationProject.deleted_at.is_(None),
)
if not user.is_admin:
query = query.where(ModuleGenerationProject.user_id == user.id)
if populate_existing:
query = query.execution_options(populate_existing=True)
if for_update:
await apply_short_lock_timeout(db)
query = query.with_for_update()
try:
result = await db.execute(query.limit(1))
except Exception as exc:
raise_if_database_lock_busy(exc)
raise
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail=config.project_not_found_message)
return project
async def get_step_for_user(
db: AsyncSession,
*,
project_id: str,
step_id: str,
user: User,
config: ModuleGenerationFlowConfig,
for_update: bool = False,
) -> ModuleGenerationStep:
await get_project_for_user(db, project_id=project_id, user=user, config=config, for_update=for_update)
query = select(ModuleGenerationStep).where(
ModuleGenerationStep.id == step_id,
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.is_current == True,
)
if not user.is_admin:
query = query.where(ModuleGenerationStep.user_id == user.id)
if for_update:
await apply_short_lock_timeout(db)
query = query.with_for_update()
try:
result = await db.execute(query.limit(1))
except Exception as exc:
raise_if_database_lock_busy(exc)
raise
step = result.scalar_one_or_none()
if not step:
raise HTTPException(status_code=404, detail=config.step_not_found_message)
return step
async def get_current_steps(db: AsyncSession, *, project_id: str, config: ModuleGenerationFlowConfig) -> list[ModuleGenerationStep]:
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.is_current == True,
)
.order_by(ModuleGenerationStep.step_index.asc(), ModuleGenerationStep.created_at.asc())
)
return list(result.scalars().all())
async def get_current_step_by_code(
db: AsyncSession,
*,
project_id: str,
step_code: str,
config: ModuleGenerationFlowConfig,
) -> ModuleGenerationStep | None:
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.step_code == step_code,
ModuleGenerationStep.is_current == True,
ModuleGenerationStep.deleted_at.is_(None),
)
.order_by(ModuleGenerationStep.version.desc(), ModuleGenerationStep.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def next_version(
db: AsyncSession,
*,
project_id: str,
step_code: str,
config: ModuleGenerationFlowConfig,
) -> int:
result = await db.execute(
select(func.max(ModuleGenerationStep.version)).where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.step_code == step_code,
)
)
return int(result.scalar_one_or_none() or 0) + 1
async def create_module_step(
db: AsyncSession,
*,
project: ModuleGenerationProject,
step_code: str,
config: ModuleGenerationFlowConfig,
log_module_event: LogModuleEventCallable,
status: str = ModuleStepStatusEnum.PENDING.value,
parent_step_id: str | None = None,
source_step_id: str | None = None,
chat_task_id: str | None = None,
input_data: dict[str, Any] | None = None,
output_data: dict[str, Any] | None = None,
) -> ModuleGenerationStep:
version = await next_version(db, project_id=project.id, step_code=step_code, config=config)
step = ModuleGenerationStep(
id=generate_id(),
project_id=project.id,
user_id=project.user_id,
module=project.module,
step_index=config.step_index_map[step_code],
step_code=step_code,
status=status,
version=version,
is_current=True,
parent_step_id=parent_step_id,
source_step_id=source_step_id,
chat_task_id=chat_task_id,
input_json=(
build_step_input(
step_code=step_code,
payload=input_data,
source_step_id=source_step_id,
parent_step_id=parent_step_id,
schema_version=config.step_io_schema_version,
)
if input_data is not None
else None
),
output_json=(
build_step_output(
step_code=step_code,
status=status,
result=output_data,
schema_version=config.step_io_schema_version,
)
if output_data is not None
else None
),
started_at=utc_now() if status == ModuleStepStatusEnum.PROCESSING.value else None,
completed_at=utc_now() if status == ModuleStepStatusEnum.COMPLETED.value else None,
)
db.add(step)
project.current_step_code = step_code
await db.flush()
await log_module_event(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.STEP_CREATED.value,
detail={"step_code": step_code, "version": version},
)
return step
def _clear_project_final_resources_by_deleted_steps(
project: ModuleGenerationProject,
*,
deleted_step_codes: set[str],
config: ModuleGenerationFlowConfig,
) -> list[str]:
"""根据被软删的步骤,清空项目表中对应的最终资源字段。
第 3 步图片生成被软删时,下游第 5 步视频也基于旧图片失效,所以图片、视频、封面都要清空。
第 5 步视频生成被软删时,只清空视频和封面,保留第 3 步图片结果。
"""
cleared_fields: list[str] = []
def clear_field(field_name: str) -> None:
if getattr(project, field_name, None):
cleared_fields.append(field_name)
setattr(project, field_name, None)
if config.image_generate_step_code in deleted_step_codes:
clear_field("final_image_url")
clear_field("final_video_url")
clear_field("final_video_cover_url")
elif config.video_generate_step_code in deleted_step_codes:
clear_field("final_video_url")
clear_field("final_video_cover_url")
return cleared_fields
ACTIVE_CHAT_TASK_BLOCK_STATUSES = {
ChatGenerationTaskStatus.PENDING.value,
ChatGenerationTaskStatus.GENERATING.value,
}
ACTIVE_CHAT_TASK_BLOCK_STAGES = {
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
ChatGenerationPipelineStage.WAITING_REMOTE.value,
ChatGenerationPipelineStage.POLLING.value,
ChatGenerationPipelineStage.RESULT_READY.value,
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
ChatGenerationPipelineStage.DOWNLOADING.value,
ChatGenerationPipelineStage.RETRY_WAITING.value,
ChatGenerationPipelineStage.UPSCALE_QUEUED.value,
ChatGenerationPipelineStage.UPSCALE_PROCESSING.value,
ChatGenerationPipelineStage.UPSCALE_POLLING.value,
ChatGenerationPipelineStage.UPSCALE_DOWNLOADING.value,
ChatGenerationPipelineStage.UPSCALE_FINALIZING.value,
ChatGenerationPipelineStage.UPSCALE_RETRY_WAITING.value,
}
def is_active_chat_generation_task(task: ChatGenerationTask) -> bool:
"""判断 ChatGenerationTask 是否仍处于不可主动删除/废弃的处理中状态。"""
return bool(
task.status in ACTIVE_CHAT_TASK_BLOCK_STATUSES
or (task.pipeline_stage in ACTIVE_CHAT_TASK_BLOCK_STAGES)
)
def _unique_ids(values: list[str | None] | tuple[str | None, ...]) -> list[str]:
return list(dict.fromkeys(str(value) for value in values if value))
async def load_chat_tasks_for_steps(
db: AsyncSession,
steps: list[ModuleGenerationStep],
*,
for_update: bool = False,
) -> dict[str, ChatGenerationTask]:
ids = _unique_ids([step.chat_task_id for step in steps])
if not ids:
return {}
stmt = select(ChatGenerationTask).where(
ChatGenerationTask.id.in_(ids),
ChatGenerationTask.deleted_at.is_(None),
)
if for_update:
stmt = stmt.with_for_update()
result = await execute_with_lock_timeout(db, stmt)
else:
result = await db.execute(stmt)
return {task.id: task for task in result.scalars().all()}
async def assert_no_active_chat_tasks_for_steps(
db: AsyncSession,
steps: list[ModuleGenerationStep],
*,
detail_message: str = "当前存在生成中任务,请等待生成完成或失败后再操作",
for_update: bool = True,
) -> dict[str, ChatGenerationTask]:
"""批量校验步骤关联任务是否有进行中任务。
用户主动修改上游步骤、删除项目、删除历史时,统一采用“生成中拦截、不退款”。
返回已批量加载的未删除 ChatGenerationTask,调用方可继续复用,避免重复查询。
"""
task_map = await load_chat_tasks_for_steps(db, steps, for_update=for_update)
active_task_ids = [task.id for task in task_map.values() if is_active_chat_generation_task(task)]
if active_task_ids:
raise HTTPException(
status_code=409,
detail={
"message": detail_message,
"active_count": len(active_task_ids),
},
)
return task_map
async def assert_project_has_no_active_chat_tasks(
db: AsyncSession,
*,
project: ModuleGenerationProject,
config: ModuleGenerationFlowConfig,
detail_message: str = "当前存在生成中任务,请等待生成完成或失败后再操作",
) -> dict[str, ChatGenerationTask]:
result = await execute_with_lock_timeout(
db,
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.chat_task_id.is_not(None),
)
.with_for_update()
)
steps = list(result.scalars().all())
return await assert_no_active_chat_tasks_for_steps(
db,
steps,
detail_message=detail_message,
for_update=True,
)
async def soft_delete_steps_from_index(
db: AsyncSession,
*,
project: ModuleGenerationProject,
start_index: int,
config: ModuleGenerationFlowConfig,
log_module_event: LogModuleEventCallable,
deleted_at: datetime | None = None,
refund_unfinished: bool = False,
block_active_tasks: bool = True,
release_stats: dict[str, int] | None = None,
) -> list[ModuleGenerationStep]:
"""软删指定步骤及其后续当前版本步骤。
说明:
- 兼容旧调用方保留 refund_unfinished 参数,但用户主动修改/删除链路不再退款。
- 存在 pending/generating/下载中/轮询中等任务时直接 409 拦截。
- 已完成任务只做软删任务与 generated_resources,释放容量统计;失败任务只软删任务。
"""
deleted_at = deleted_at or utc_now()
result = await execute_with_lock_timeout(
db,
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.is_current == True,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.step_index >= start_index,
)
.with_for_update()
)
steps = list(result.scalars().all())
if not steps:
return []
task_map = await load_chat_tasks_for_steps(db, steps, for_update=True)
if block_active_tasks:
active_task_ids = [task.id for task in task_map.values() if is_active_chat_generation_task(task)]
if active_task_ids:
raise HTTPException(
status_code=409,
detail={
"message": "当前存在生成中任务,请等待生成完成或失败后再操作",
"active_count": len(active_task_ids),
},
)
deleted_step_codes = {step.step_code for step in steps}
cleared_project_fields = _clear_project_final_resources_by_deleted_steps(
project,
deleted_step_codes=deleted_step_codes,
config=config,
)
completed_task_ids = [
task.id
for task in task_map.values()
if task.status == ChatGenerationTaskStatus.COMPLETED.value
]
if completed_task_ids:
released_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=completed_task_ids,
deleted_at=deleted_at,
)
if release_stats is not None:
release_stats["released_size_bytes"] = int(release_stats.get("released_size_bytes", 0)) + int(released_size or 0)
for step in steps:
step.is_current = False
step.deleted_at = deleted_at
if step.chat_task_id and step.chat_task_id in task_map:
task_map[step.chat_task_id].deleted_at = deleted_at
if steps:
await log_module_event(
db,
project=project,
event_type=ModuleEventTypeEnum.SOFT_DELETE_STEPS.value,
message=f"软删除第 {start_index} 步及之后的旧子任务",
detail={
"step_ids": [step.id for step in steps],
"step_codes": [step.step_code for step in steps],
"cleared_project_fields": cleared_project_fields,
"block_active_tasks": block_active_tasks,
"refund_unfinished": False,
},
)
return steps
async def chat_tasks_by_id(db: AsyncSession, steps: list[ModuleGenerationStep]) -> dict[str, ChatGenerationTask]:
return await load_chat_tasks_for_steps(db, steps, for_update=False)