素材云批量删除API

This commit is contained in:
2026-07-02 11:37:32 +08:00
parent aa076e4e9a
commit 0fba6fdf09
6 changed files with 815 additions and 76 deletions
+50
View File
@@ -9,6 +9,8 @@ from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User from app.models.user import User
from app.schemas.generation_ai import ( from app.schemas.generation_ai import (
GenerationAIEngineOptionsOut, GenerationAIEngineOptionsOut,
GenerationAIHistoryBatchDeleteOut,
GenerationAIHistoryBatchDeleteRequest,
GenerationAIHistoryDayItemsOut, GenerationAIHistoryDayItemsOut,
GenerationAIHistoryGroupedOut, GenerationAIHistoryGroupedOut,
GenerationAIRetryOut, GenerationAIRetryOut,
@@ -31,6 +33,7 @@ from app.services.generation_billing_service import (
charge_generation_media_by_params, charge_generation_media_by_params,
get_next_credit_attempt_no, get_next_credit_attempt_no,
) )
from app.services.generation_history_delete_service import batch_delete_generation_history_items
from app.services.generation_log_service import log_task_event from app.services.generation_log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
@@ -332,6 +335,53 @@ async def list_history_grouped_days(
) )
@router.delete(
"/history/batch",
response_model=GenerationAIHistoryBatchDeleteOut,
summary="批量删除素材云历史记录",
description=(
"按 history_source 批量软删除素材云历史记录,单次最多30条。"
"generation_record 和 chat_task 入参 ids 为对应记录ID,且只有生成完成后才能删除;"
"hot_opening_replicate 入参 ids 为 module_project_id"
"shot_replicate 入参 ids 为 shot_segment_id。"
"爆款开头复刻和拆镜复刻会联动软删 ModuleGenerationProject、ModuleGenerationStep、关联 ChatGenerationTask 和 generated_resources"
"拆镜复刻还会联动软删 ShotReplicateSegment。"
"如果存在生成中、轮询中、下载中等任务,接口直接拦截,不做失败标记、不退款。"
),
responses={
200: {
"description": "批量软删除成功",
},
400: {
"description": "参数错误,例如 history_source 不支持、ids 为空、超过30条或重复",
},
401: {
"description": "未登录或 Token 无效",
},
404: {
"description": "部分 ID 不存在、不属于当前用户或已删除",
},
409: {
"description": "存在未完成或生成中的记录,当前不能删除",
},
},
)
async def batch_delete_history_items(
req: GenerationAIHistoryBatchDeleteRequest = Body(
...,
description="素材云历史批量删除参数。不同 history_source 对应不同 ID 语义,详见字段说明",
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await batch_delete_generation_history_items(
db=db,
current_user=current_user,
history_source=req.history_source,
ids=req.ids,
)
@router.get( @router.get(
"/history/{generated_date}", "/history/{generated_date}",
response_model=GenerationAIHistoryDayItemsOut, response_model=GenerationAIHistoryDayItemsOut,
@@ -118,3 +118,6 @@ def is_generation_history_module_source(source: GenerationHistorySourceEnum) ->
"""判断当前来源是否需要回填模块项目信息。""" """判断当前来源是否需要回填模块项目信息。"""
return source in GENERATION_HISTORY_MODULE_SOURCES return source in GENERATION_HISTORY_MODULE_SOURCES
MAX_BATCH_DELETE_COUNT = 30
+91 -1
View File
@@ -1,8 +1,9 @@
from typing import Annotated, Literal from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.common import NaiveDatetimeOptional from app.schemas.common import NaiveDatetimeOptional
from app.enums.generation_history import normalize_generation_history_source
class GenerationAIReference(BaseModel): class GenerationAIReference(BaseModel):
@@ -492,6 +493,95 @@ class GenerationAITaskDeleteOut(BaseModel):
freed_size_bytes: int = Field(0, description="本次软删联动释放的有效资源空间字节数") freed_size_bytes: int = Field(0, description="本次软删联动释放的有效资源空间字节数")
class GenerationAIHistoryBatchDeleteRequest(BaseModel):
"""素材云历史批量删除请求体。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"history_source": "shot_replicate",
"ids": ["shot_segment_id_1", "shot_segment_id_2"],
}
}
)
history_source: str = Field(
...,
description=(
"素材云历史来源。"
"generation_record=项目生成,chat_task=AI创作,"
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻。"
"注意:项目生成/AI创作传记录ID;爆款开头复刻传 module_project_id;拆镜复刻传 shot_segment_id。"
),
examples=["shot_replicate"],
)
ids: list[str] = Field(
...,
min_length=1,
max_length=30,
description="需要删除的ID数组,最多30个;不允许重复或空字符串",
examples=[["shot_segment_id_1", "shot_segment_id_2"]],
)
@field_validator("history_source")
@classmethod
def validate_history_source(cls, value: str) -> str:
try:
return normalize_generation_history_source(value).value
except ValueError as exc:
raise ValueError("history_source 不支持") from exc
@field_validator("ids")
@classmethod
def validate_ids(cls, values: list[str]) -> list[str]:
normalized = [str(item).strip() for item in values if str(item or "").strip()]
if not normalized:
raise ValueError("ids 不能为空")
if len(normalized) > 30:
raise ValueError("单次最多删除30条记录")
if len(normalized) != len(set(normalized)):
raise ValueError("ids 不允许重复")
return normalized
class GenerationAIHistoryBatchDeleteOut(BaseModel):
"""素材云历史批量删除响应体。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"message": "删除成功",
"history_source": "shot_replicate",
"history_source_label": "拆镜复刻",
"requested_count": 2,
"deleted_count": 2,
"requested_ids": ["shot_segment_id_1", "shot_segment_id_2"],
"deleted_ids": ["shot_segment_id_1", "shot_segment_id_2"],
"generation_record_ids": [],
"chat_task_ids": ["chat_task_id_1", "chat_task_id_2"],
"module_project_ids": ["module_project_id_1", "module_project_id_2"],
"shot_segment_ids": ["shot_segment_id_1", "shot_segment_id_2"],
"deleted": True,
"freed_size_bytes": 123456,
}
}
)
message: str = Field(..., description="操作结果提示信息")
history_source: str = Field(..., description="素材云历史来源")
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
requested_count: int = Field(..., description="请求删除数量")
deleted_count: int = Field(..., description="实际删除数量")
requested_ids: list[str] = Field(default_factory=list, description="请求删除的原始ID列表")
deleted_ids: list[str] = Field(default_factory=list, description="已删除的原始ID列表")
generation_record_ids: list[str] = Field(default_factory=list, description="联动软删除的 GenerationRecord ID")
chat_task_ids: list[str] = Field(default_factory=list, description="联动软删除的 ChatGenerationTask ID")
module_project_ids: list[str] = Field(default_factory=list, description="联动软删除的 ModuleGenerationProject ID")
shot_segment_ids: list[str] = Field(default_factory=list, description="联动软删除的 ShotReplicateSegment ID")
deleted: bool = Field(..., description="是否已完成软删除")
freed_size_bytes: int = Field(0, description="本次软删联动释放的有效资源空间字节数")
class GenerationAIRetryOut(BaseModel): class GenerationAIRetryOut(BaseModel):
"""AI生成任务重试响应体。""" """AI生成任务重试响应体。"""
@@ -0,0 +1,514 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Iterable, Sequence
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import ModuleEventTypeEnum
from app.enums.generation_history import (
GenerationHistorySourceEnum,
get_generation_history_source_label,
normalize_generation_history_source,
MAX_BATCH_DELETE_COUNT,
)
from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode
from app.enums.shot_replicate import ShotSegmentAnalysisStatusEnum, ShotSegmentReplicateStatusEnum, ShotSplitStatusEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.user import User
from app.schemas.generation_ai import GenerationAIHistoryBatchDeleteOut
from app.services.module_generation_flow_base_service import is_active_chat_generation_task
from app.services.module_generation_log_service import log_module_event_file
# from app.services.operation_log import log_operation
from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
SOURCE_MODEL_GENERATION_RECORD,
SOURCE_MODEL_SHOT_SEGMENT,
soft_delete_generation_record_resources,
soft_delete_resources_by_source,
)
COMPLETED_STATUS = ChatGenerationTaskStatus.COMPLETED.value
_ACTIVE_SPLIT_STATUSES = {
ShotSplitStatusEnum.PENDING.value,
ShotSplitStatusEnum.PROCESSING.value,
ShotSplitStatusEnum.RETRY_WAITING.value,
}
_ACTIVE_SEGMENT_ANALYSIS_STATUSES = {
ShotSegmentAnalysisStatusEnum.PENDING.value,
ShotSegmentAnalysisStatusEnum.PROCESSING.value,
}
_ACTIVE_SEGMENT_REPLICATE_STATUSES = {
ShotSegmentReplicateStatusEnum.PROCESSING.value,
}
def _normalize_ids(ids: Sequence[str] | Iterable[str]) -> list[str]:
normalized = [str(item).strip() for item in ids if str(item or "").strip()]
if not normalized:
raise HTTPException(status_code=400, detail="ids 不能为空")
if len(normalized) > MAX_BATCH_DELETE_COUNT:
raise HTTPException(status_code=400, detail=f"单次最多删除 {MAX_BATCH_DELETE_COUNT} 条记录")
if len(normalized) != len(set(normalized)):
raise HTTPException(status_code=400, detail="ids 不允许重复")
return normalized
def _missing_ids(request_ids: list[str], actual_ids: Iterable[str]) -> list[str]:
actual_set = {str(item) for item in actual_ids if item}
return [item for item in request_ids if item not in actual_set]
def _raise_missing_if_any(*, ids: list[str], found_ids: Iterable[str], message: str) -> None:
missing = _missing_ids(ids, found_ids)
if missing:
raise HTTPException(
status_code=404,
detail={
"message": message,
"missing_ids": missing,
"missing_count": len(missing),
},
)
def _raise_invalid_if_any(*, invalid_ids: list[str], message: str, status_code: int = 409) -> None:
if invalid_ids:
raise HTTPException(
status_code=status_code,
detail={
"message": message,
"invalid_ids": invalid_ids,
"invalid_count": len(invalid_ids),
},
)
def _task_id_list(steps: list[ModuleGenerationStep]) -> list[str]:
return list(dict.fromkeys(step.chat_task_id for step in steps if step.chat_task_id))
async def _load_chat_tasks_for_steps(
db: AsyncSession,
steps: list[ModuleGenerationStep],
) -> dict[str, ChatGenerationTask]:
task_ids = _task_id_list(steps)
if not task_ids:
return {}
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(task_ids),
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
)
return {task.id: task for task in result.scalars().all()}
def _assert_no_active_chat_tasks(tasks: Iterable[ChatGenerationTask]) -> None:
active_task_ids = [task.id for task in tasks if is_active_chat_generation_task(task)]
if active_task_ids:
raise HTTPException(
status_code=409,
detail={
"message": "当前存在生成中任务,请等待生成完成或失败后再操作",
"active_count": len(active_task_ids),
},
)
# async def _log_history_batch_delete(
# db: AsyncSession,
# *,
# current_user: User,
# result: GenerationAIHistoryBatchDeleteOut,
# ) -> None:
# detail = {
# "history_source": result.history_source,
# "history_source_label": result.history_source_label,
# "requested_count": result.requested_count,
# "deleted_count": result.deleted_count,
# "requested_ids": result.requested_ids,
# "deleted_ids": result.deleted_ids,
# "generation_record_ids": result.generation_record_ids,
# "chat_task_ids": result.chat_task_ids,
# "module_project_ids": result.module_project_ids,
# "shot_segment_ids": result.shot_segment_ids,
# "freed_size_bytes": result.freed_size_bytes,
# }
# await log_operation(
# db,
# current_user.id,
# current_user.username,
# f"批量删除素材云历史-{result.history_source_label or result.history_source}",
# "DELETE",
# "/generation-ai/history/batch",
# detail=json.dumps(detail, ensure_ascii=False, default=str),
# )
def _build_out(
*,
source: GenerationHistorySourceEnum,
requested_ids: list[str],
deleted_ids: list[str],
generation_record_ids: list[str] | None = None,
chat_task_ids: list[str] | None = None,
module_project_ids: list[str] | None = None,
shot_segment_ids: list[str] | None = None,
freed_size_bytes: int = 0,
) -> GenerationAIHistoryBatchDeleteOut:
return GenerationAIHistoryBatchDeleteOut(
message="删除成功",
history_source=source.value,
history_source_label=get_generation_history_source_label(source),
requested_count=len(requested_ids),
deleted_count=len(deleted_ids),
requested_ids=requested_ids,
deleted_ids=deleted_ids,
generation_record_ids=generation_record_ids or [],
chat_task_ids=chat_task_ids or [],
module_project_ids=module_project_ids or [],
shot_segment_ids=shot_segment_ids or [],
deleted=True,
freed_size_bytes=int(freed_size_bytes or 0),
)
async def _delete_generation_records(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
ids: list[str],
deleted_at: datetime,
) -> GenerationAIHistoryBatchDeleteOut:
result = await db.execute(
select(GenerationRecord)
.where(
GenerationRecord.id.in_(ids),
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
)
.with_for_update()
)
records = list(result.scalars().all())
_raise_missing_if_any(ids=ids, found_ids=[record.id for record in records], message="项目生成记录不存在或已删除")
invalid_ids = [
record.id
for record in records
if record.status != COMPLETED_STATUS or record.generated_at is None
]
_raise_invalid_if_any(invalid_ids=invalid_ids, message="项目生成记录只有生成完成后才能删除")
freed_size = await soft_delete_generation_record_resources(db, [record.id for record in records], deleted_at=deleted_at)
for record in records:
record.deleted_at = deleted_at
return _build_out(
source=source,
requested_ids=ids,
deleted_ids=ids,
generation_record_ids=ids,
freed_size_bytes=freed_size,
)
async def _delete_chat_tasks(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
ids: list[str],
deleted_at: datetime,
) -> GenerationAIHistoryBatchDeleteOut:
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(ids),
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_ASYNC.value,
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
)
tasks = list(result.scalars().all())
_raise_missing_if_any(ids=ids, found_ids=[task.id for task in tasks], message="AI 创作记录不存在或已删除")
invalid_ids = [
task.id
for task in tasks
if task.status != COMPLETED_STATUS or task.generated_at is None
]
_raise_invalid_if_any(invalid_ids=invalid_ids, message="AI 创作记录只有生成完成后才能删除")
freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=[task.id for task in tasks],
deleted_at=deleted_at,
)
for task in tasks:
task.deleted_at = deleted_at
return _build_out(
source=source,
requested_ids=ids,
deleted_ids=ids,
chat_task_ids=ids,
freed_size_bytes=freed_size,
)
async def _load_module_projects_by_ids(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
project_ids: list[str],
) -> list[ModuleGenerationProject]:
result = await db.execute(
select(ModuleGenerationProject)
.where(
ModuleGenerationProject.id.in_(project_ids),
ModuleGenerationProject.user_id == current_user.id,
ModuleGenerationProject.module == source.value,
ModuleGenerationProject.deleted_at.is_(None),
)
.with_for_update()
)
projects = list(result.scalars().all())
_raise_missing_if_any(ids=project_ids, found_ids=[project.id for project in projects], message="模块生成项目不存在或已删除")
return projects
async def _soft_delete_module_projects(
db: AsyncSession,
*,
source: GenerationHistorySourceEnum,
projects: list[ModuleGenerationProject],
deleted_at: datetime,
) -> tuple[list[str], list[str], int]:
project_ids = list(dict.fromkeys(project.id for project in projects))
if not project_ids:
return [], [], 0
step_result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id.in_(project_ids),
ModuleGenerationStep.module == source.value,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.is_current == True,
)
.with_for_update()
)
steps = list(step_result.scalars().all())
task_map = await _load_chat_tasks_for_steps(db, steps)
_assert_no_active_chat_tasks(task_map.values())
chat_task_ids = list(task_map.keys())
freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=chat_task_ids,
deleted_at=deleted_at,
)
steps_by_project: dict[str, list[ModuleGenerationStep]] = {}
for step in steps:
steps_by_project.setdefault(step.project_id, []).append(step)
step.deleted_at = deleted_at
step.is_current = False
for task in task_map.values():
task.deleted_at = deleted_at
for project in projects:
project.deleted_at = deleted_at
project.final_image_url = None
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project_steps = steps_by_project.get(project.id, [])
log_module_event_file(
module=source.value,
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
project_id=project.id,
user_id=project.user_id,
message="素材云历史批量删除模块项目",
detail={
"source": "generation_history_batch_delete",
"step_ids": [step.id for step in project_steps],
"step_codes": [step.step_code for step in project_steps],
"chat_task_ids": [step.chat_task_id for step in project_steps if step.chat_task_id],
"refund_unfinished": False,
},
)
return project_ids, chat_task_ids, int(freed_size or 0)
async def _delete_hot_opening_projects(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
ids: list[str],
deleted_at: datetime,
) -> GenerationAIHistoryBatchDeleteOut:
projects = await _load_module_projects_by_ids(db, current_user=current_user, source=source, project_ids=ids)
module_project_ids, chat_task_ids, freed_size = await _soft_delete_module_projects(
db,
source=source,
projects=projects,
deleted_at=deleted_at,
)
return _build_out(
source=source,
requested_ids=ids,
deleted_ids=ids,
module_project_ids=module_project_ids,
chat_task_ids=chat_task_ids,
freed_size_bytes=freed_size,
)
def _assert_segments_not_active(segments: list[ShotReplicateSegment]) -> None:
invalid_ids = [
segment.id
for segment in segments
if segment.split_status in _ACTIVE_SPLIT_STATUSES
or segment.analysis_status in _ACTIVE_SEGMENT_ANALYSIS_STATUSES
or segment.replicate_status in _ACTIVE_SEGMENT_REPLICATE_STATUSES
]
_raise_invalid_if_any(invalid_ids=invalid_ids, message="拆镜片段仍在分割、分析或复刻处理中,暂不能删除")
async def _delete_shot_segments(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
ids: list[str],
deleted_at: datetime,
) -> GenerationAIHistoryBatchDeleteOut:
result = await db.execute(
select(ShotReplicateSegment)
.where(
ShotReplicateSegment.id.in_(ids),
ShotReplicateSegment.user_id == current_user.id,
ShotReplicateSegment.deleted_at.is_(None),
)
.with_for_update()
)
segments = list(result.scalars().all())
_raise_missing_if_any(ids=ids, found_ids=[segment.id for segment in segments], message="拆镜复刻片段不存在或已删除")
_assert_segments_not_active(segments)
missing_project_segment_ids = [segment.id for segment in segments if not segment.module_project_id]
_raise_invalid_if_any(invalid_ids=missing_project_segment_ids, message="拆镜复刻片段尚未关联复刻项目,不能按素材云历史删除")
module_project_ids = list(dict.fromkeys(segment.module_project_id for segment in segments if segment.module_project_id))
projects = await _load_module_projects_by_ids(
db,
current_user=current_user,
source=source,
project_ids=module_project_ids,
)
deleted_project_ids, chat_task_ids, project_freed_size = await _soft_delete_module_projects(
db,
source=source,
projects=projects,
deleted_at=deleted_at,
)
segment_freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_SHOT_SEGMENT,
source_ids=[segment.id for segment in segments],
deleted_at=deleted_at,
)
for segment in segments:
segment.deleted_at = deleted_at
return _build_out(
source=source,
requested_ids=ids,
deleted_ids=ids,
module_project_ids=deleted_project_ids,
chat_task_ids=chat_task_ids,
shot_segment_ids=ids,
freed_size_bytes=int(project_freed_size or 0) + int(segment_freed_size or 0),
)
async def batch_delete_generation_history_items(
db: AsyncSession,
*,
current_user: User,
history_source: str,
ids: Sequence[str] | Iterable[str],
) -> GenerationAIHistoryBatchDeleteOut:
"""
按素材云 history_source 批量软删除历史记录。
事务由 get_db 统一提交/回滚;本服务只 flush,不主动 commit。
所有分支均为“先批量查询校验,再统一软删”,任何校验失败都会整体回滚。
"""
try:
source = normalize_generation_history_source(history_source)
except ValueError as exc:
raise HTTPException(status_code=400, detail="history_source 不支持") from exc
normalized_ids = _normalize_ids(ids)
deleted_at = datetime.now(timezone.utc)
if source == GenerationHistorySourceEnum.GENERATION_RECORD:
result = await _delete_generation_records(
db,
current_user=current_user,
source=source,
ids=normalized_ids,
deleted_at=deleted_at,
)
elif source == GenerationHistorySourceEnum.CHAT_TASK:
result = await _delete_chat_tasks(
db,
current_user=current_user,
source=source,
ids=normalized_ids,
deleted_at=deleted_at,
)
elif source == GenerationHistorySourceEnum.HOT_OPENING_REPLICATE:
result = await _delete_hot_opening_projects(
db,
current_user=current_user,
source=source,
ids=normalized_ids,
deleted_at=deleted_at,
)
elif source == GenerationHistorySourceEnum.SHOT_REPLICATE:
result = await _delete_shot_segments(
db,
current_user=current_user,
source=source,
ids=normalized_ids,
deleted_at=deleted_at,
)
else:
raise HTTPException(status_code=400, detail="history_source 不支持")
# await _log_history_batch_delete(db, current_user=current_user, result=result)
await db.flush()
return result
@@ -9,14 +9,17 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import ModuleEventTypeEnum, ModuleStepStatusEnum 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.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep from app.models.module_generation_step import ModuleGenerationStep
from app.models.user import User 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.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.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.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
soft_delete_resources_by_source,
)
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
LogModuleEventCallable = Callable[..., Awaitable[None]] LogModuleEventCallable = Callable[..., Awaitable[None]]
@@ -224,6 +227,106 @@ def _clear_project_final_resources_by_deleted_steps(
return cleared_fields 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,
}
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 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 db.execute(
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( async def soft_delete_steps_from_index(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -232,9 +335,17 @@ async def soft_delete_steps_from_index(
config: ModuleGenerationFlowConfig, config: ModuleGenerationFlowConfig,
log_module_event: LogModuleEventCallable, log_module_event: LogModuleEventCallable,
deleted_at: datetime | None = None, deleted_at: datetime | None = None,
refund_unfinished: bool = True, refund_unfinished: bool = False,
block_active_tasks: bool = True,
release_stats: dict[str, int] | None = None, release_stats: dict[str, int] | None = None,
) -> list[ModuleGenerationStep]: ) -> list[ModuleGenerationStep]:
"""软删指定步骤及其后续当前版本步骤。
说明:
- 兼容旧调用方保留 refund_unfinished 参数,但用户主动修改/删除链路不再退款。
- 存在 pending/generating/下载中/轮询中等任务时直接 409 拦截。
- 已完成任务只做软删任务与 generated_resources,释放容量统计;失败任务只软删任务。
"""
deleted_at = deleted_at or utc_now() deleted_at = deleted_at or utc_now()
result = await db.execute( result = await db.execute(
select(ModuleGenerationStep) select(ModuleGenerationStep)
@@ -248,6 +359,21 @@ async def soft_delete_steps_from_index(
.with_for_update() .with_for_update()
) )
steps = list(result.scalars().all()) 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} deleted_step_codes = {step.step_code for step in steps}
cleared_project_fields = _clear_project_final_resources_by_deleted_steps( cleared_project_fields = _clear_project_final_resources_by_deleted_steps(
project, project,
@@ -255,30 +381,27 @@ async def soft_delete_steps_from_index(
config=config, 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: for step in steps:
step.is_current = False step.is_current = False
step.deleted_at = deleted_at step.deleted_at = deleted_at
if step.chat_task_id: if step.chat_task_id and step.chat_task_id in task_map:
chat_result = await db.execute( task_map[step.chat_task_id].deleted_at = deleted_at
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":
released_size = await soft_delete_chat_task_resources(db, chat_task.id, 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)
elif refund_unfinished and 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: if steps:
await log_module_event( await log_module_event(
db, db,
@@ -289,14 +412,12 @@ async def soft_delete_steps_from_index(
"step_ids": [step.id for step in steps], "step_ids": [step.id for step in steps],
"step_codes": [step.step_code for step in steps], "step_codes": [step.step_code for step in steps],
"cleared_project_fields": cleared_project_fields, "cleared_project_fields": cleared_project_fields,
"block_active_tasks": block_active_tasks,
"refund_unfinished": False,
}, },
) )
return steps return steps
async def chat_tasks_by_id(db: AsyncSession, steps: list[ModuleGenerationStep]) -> dict[str, ChatGenerationTask]: 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] return await load_chat_tasks_for_steps(db, steps, for_update=False)
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()}
@@ -12,7 +12,6 @@ from sqlalchemy.orm.attributes import flag_modified
from app.config import settings from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus
from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject from app.models.module_generation_project import ModuleGenerationProject
@@ -55,6 +54,7 @@ from app.services.module_generation_log_service import log_module_error, log_mod
from app.services.llm import optimize_prompt from app.services.llm import optimize_prompt
from app.services.resource_accounting_service import soft_delete_chat_task_resources from app.services.resource_accounting_service import soft_delete_chat_task_resources
from app.services.module_generation_flow_base_service import ( from app.services.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
chat_tasks_by_id as _base_chat_tasks_by_id, chat_tasks_by_id as _base_chat_tasks_by_id,
create_module_step as _base_create_step, create_module_step as _base_create_step,
get_current_step_by_code as _base_get_current_step_by_code, get_current_step_by_code as _base_get_current_step_by_code,
@@ -336,7 +336,7 @@ async def _soft_delete_steps_from_index(
project: ModuleGenerationProject, project: ModuleGenerationProject,
start_index: int, start_index: int,
deleted_at: datetime | None = None, deleted_at: datetime | None = None,
refund_unfinished: bool = True, refund_unfinished: bool = False,
release_stats: dict[str, int] | None = None, release_stats: dict[str, int] | None = None,
) -> None: ) -> None:
await _base_soft_delete_steps_from_index( await _base_soft_delete_steps_from_index(
@@ -1412,57 +1412,18 @@ async def handle_chat_generation_task_failed(db: AsyncSession, task: ChatGenerat
_ACTIVE_DELETE_BLOCK_STATUSES = {
ChatGenerationTaskStatus.PENDING.value,
ChatGenerationTaskStatus.GENERATING.value,
}
_ACTIVE_DELETE_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,
}
async def _assert_project_has_no_active_chat_tasks_for_delete( async def _assert_project_has_no_active_chat_tasks_for_delete(
db: AsyncSession, db: AsyncSession,
*, *,
project: ModuleGenerationProject, project: ModuleGenerationProject,
) -> None: ) -> None:
"""用户主动删除项目/切片时不退款;如仍有异步生成任务进行中,直接拦截。""" """用户主动删除项目/切片时不退款;如仍有异步生成任务进行中,直接拦截。"""
step_result = await db.execute( await _base_assert_project_has_no_active_chat_tasks(
select(ModuleGenerationStep.chat_task_id) db,
.where( project=project,
ModuleGenerationStep.project_id == project.id, config=FLOW_CONFIG,
ModuleGenerationStep.module == MODULE, detail_message="当前拆镜复刻项目仍有生成中任务,暂不能删除",
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.chat_task_id.is_not(None),
)
) )
chat_task_ids = [task_id for task_id in step_result.scalars().all() if task_id]
if not chat_task_ids:
return
task_result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(chat_task_ids),
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
)
active_tasks = []
for task in task_result.scalars().all():
if task.status in _ACTIVE_DELETE_BLOCK_STATUSES or (task.pipeline_stage in _ACTIVE_DELETE_BLOCK_STAGES):
active_tasks.append(task.id)
if active_tasks:
raise HTTPException(status_code=400, detail="当前拆镜复刻项目仍有生成中任务,暂不能删除")
async def mark_shot_replicate_step_dispatch_failed( async def mark_shot_replicate_step_dispatch_failed(