299 lines
11 KiB
Python
299 lines
11 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.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.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
|
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.resource_accounting_service import soft_delete_chat_task_resources
|
|
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:
|
|
query = query.with_for_update()
|
|
result = await db.execute(query.limit(1))
|
|
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:
|
|
query = query.with_for_update()
|
|
result = await db.execute(query.limit(1))
|
|
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
|
|
|
|
|
|
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,
|
|
) -> list[ModuleGenerationStep]:
|
|
deleted_at = deleted_at or utc_now()
|
|
result = await db.execute(
|
|
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())
|
|
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,
|
|
)
|
|
|
|
for step in steps:
|
|
step.is_current = False
|
|
step.deleted_at = deleted_at
|
|
if step.chat_task_id:
|
|
chat_result = await db.execute(
|
|
select(ChatGenerationTask)
|
|
.where(ChatGenerationTask.id == step.chat_task_id, ChatGenerationTask.deleted_at.is_(None))
|
|
.with_for_update()
|
|
.limit(1)
|
|
)
|
|
chat_task = chat_result.scalar_one_or_none()
|
|
if chat_task:
|
|
if chat_task.status == "completed":
|
|
await soft_delete_chat_task_resources(db, chat_task.id, deleted_at=deleted_at)
|
|
elif chat_task.status != "failed":
|
|
await mark_chat_generation_task_failed_and_refund_once(
|
|
db,
|
|
task=chat_task,
|
|
error_message=config.cancel_chat_task_error_message,
|
|
pipeline_stage="failed",
|
|
)
|
|
chat_task.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,
|
|
},
|
|
)
|
|
return steps
|
|
|
|
|
|
async def chat_tasks_by_id(db: AsyncSession, steps: list[ModuleGenerationStep]) -> dict[str, ChatGenerationTask]:
|
|
ids = [step.chat_task_id for step in steps if step.chat_task_id]
|
|
if not ids:
|
|
return {}
|
|
result = await db.execute(select(ChatGenerationTask).where(ChatGenerationTask.id.in_(ids)))
|
|
return {task.id: task for task in result.scalars().all()}
|