拆镜复刻、爆款开头复刻抽象公共逻辑,拆镜复刻关联项目步骤状态BUG修复

This commit is contained in:
2026-06-18 13:03:11 +08:00
parent 158c680a18
commit 0be8091181
6 changed files with 1093 additions and 962 deletions
@@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping
@dataclass(frozen=True, slots=True)
class ModuleGenerationFlowConfig:
"""模块生成流程公共配置。
这个配置只描述两个复刻模块的差异点,不承载具体业务逻辑。
具体日志、退款、生成、回调等仍保留在各自模块 service 中。
"""
module: str
step_index_map: Mapping[str, int]
material_step_code: str
image_prompt_step_code: str
image_generate_step_code: str
video_prompt_step_code: str
video_generate_step_code: str
project_not_found_message: str
step_not_found_message: str
cancel_chat_task_error_message: str
material_video_url_editable: bool = True
step_io_schema_version: str = "module_generation_step_io_v1"
@@ -49,6 +49,39 @@ from app.services.hot_opening_video_prompt_service import build_final_video_prom
from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event
from app.services.llm import optimize_prompt
from app.services.resource_accounting_service import soft_delete_chat_task_resources
from app.services.module_generation_flow_base_service import (
chat_tasks_by_id as _base_chat_tasks_by_id,
create_module_step as _base_create_step,
get_current_step_by_code as _base_get_current_step_by_code,
get_current_steps as _base_get_current_steps,
get_project_for_user as _base_get_project_for_user,
get_step_for_user as _base_get_step_for_user,
next_version as _base_next_version,
soft_delete_steps_from_index as _base_soft_delete_steps_from_index,
)
from app.enums.module_generation_flow import ModuleGenerationFlowConfig
from app.services.module_generation_step_common_service import (
build_file_url_or_data_uri as _common_build_file_url_or_data_uri,
build_step_input as _common_build_step_input,
build_step_output as _common_build_step_output,
force_set_json as _common_force_set_json,
is_wrapped_step_io as _common_is_wrapped_step_io,
json_dumps as _common_json,
merge_dict as _common_merge_dict,
parse_json as _common_parse_json,
snapshot_from_chat as _common_snapshot_from_chat,
step_payload as _common_step_payload,
step_result as _common_step_result,
step_usage as _common_step_usage,
unwrap_step_output as _common_unwrap_step_output,
utc_now as _common_now,
)
from app.services.module_generation_step_update_service import (
update_module_image_prompt,
update_module_material_input,
update_module_step,
update_module_video_prompt_schema,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.utils.id_gen import generate_id
@@ -65,28 +98,27 @@ STEP_INDEX_MAP = {
STEP_IO_SCHEMA_VERSION = "hot_opening_step_io_v1"
def _now() -> datetime:
return datetime.now(timezone.utc)
FLOW_CONFIG = ModuleGenerationFlowConfig(
module=MODULE,
step_index_map=STEP_INDEX_MAP,
material_step_code=HotOpeningStepCodeEnum.MATERIAL_INPUT.value,
image_prompt_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
image_generate_step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value,
video_prompt_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
video_generate_step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
project_not_found_message="爆款开头复刻项目不存在",
step_not_found_message="子任务不存在",
cancel_chat_task_error_message="爆款开头复刻步骤被重新生成或删除,旧生成任务已取消",
material_video_url_editable=True,
step_io_schema_version=STEP_IO_SCHEMA_VERSION,
)
def _json(data: Any) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def _parse_json(value: Any, fallback: Any = None) -> Any:
if value is None or value == "":
return fallback
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return fallback
return fallback
_now = _common_now
_json = _common_json
_parse_json = _common_parse_json
_merge_dict = _common_merge_dict
_force_set_json = _common_force_set_json
def _step_input(
@@ -97,16 +129,14 @@ def _step_input(
parent_step_id: str | None = None,
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"schema_version": STEP_IO_SCHEMA_VERSION,
"step_code": step_code,
"source": {
"source_step_id": source_step_id,
"parent_step_id": parent_step_id,
},
"payload": payload or {},
"context": context or {},
}
return _common_build_step_input(
step_code=step_code,
payload=payload,
source_step_id=source_step_id,
parent_step_id=parent_step_id,
context=context,
schema_version=STEP_IO_SCHEMA_VERSION,
)
def _step_output(
@@ -118,85 +148,35 @@ def _step_output(
usage: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"schema_version": STEP_IO_SCHEMA_VERSION,
"step_code": step_code,
"status": status,
"payload": payload or {},
"result": result or {},
"usage": usage or {},
"error": error or {},
}
return _common_build_step_output(
step_code=step_code,
status=status,
payload=payload,
result=result,
usage=usage,
error=error,
schema_version=STEP_IO_SCHEMA_VERSION,
)
def _is_wrapped_step_io(value: Any) -> bool:
return isinstance(value, dict) and value.get("schema_version") == STEP_IO_SCHEMA_VERSION
return _common_is_wrapped_step_io(value, schema_version=STEP_IO_SCHEMA_VERSION)
def _step_payload(value: Any) -> dict[str, Any]:
data = _parse_json(value, {}) or {}
if _is_wrapped_step_io(data):
payload = data.get("payload")
return payload if isinstance(payload, dict) else {}
return data if isinstance(data, dict) else {}
return _common_step_payload(value, schema_version=STEP_IO_SCHEMA_VERSION)
def _step_result(value: Any) -> dict[str, Any]:
data = _parse_json(value, {}) or {}
if _is_wrapped_step_io(data):
result = data.get("result")
if isinstance(result, dict) and result:
return result
payload = data.get("payload")
return payload if isinstance(payload, dict) else {}
return data if isinstance(data, dict) else {}
return _common_step_result(value, schema_version=STEP_IO_SCHEMA_VERSION)
def _step_usage(value: Any) -> dict[str, Any]:
data = _parse_json(value, {}) or {}
if _is_wrapped_step_io(data):
usage = data.get("usage")
return usage if isinstance(usage, dict) else {}
usage = data.get("token_usage") if isinstance(data, dict) else {}
return usage if isinstance(usage, dict) else {}
return _common_step_usage(value, schema_version=STEP_IO_SCHEMA_VERSION)
def _unwrap_step_output(value: Any) -> dict[str, Any]:
data = _parse_json(value, {}) or {}
if not _is_wrapped_step_io(data):
return data if isinstance(data, dict) else {}
merged: dict[str, Any] = {}
payload = data.get("payload")
result = data.get("result")
usage = data.get("usage")
if isinstance(payload, dict):
merged.update(payload)
if isinstance(result, dict):
merged.update(result)
if isinstance(usage, dict) and usage:
merged["token_usage"] = usage
return merged
def _merge_dict(old: dict[str, Any] | None, new: dict[str, Any] | None) -> dict[str, Any]:
merged = dict(old or {})
for key, value in (new or {}).items():
if value is not None:
merged[key] = value
return merged
def _force_set_json(model_obj: Any, field_name: str, value: Any) -> None:
"""强制持久化 JSON / JSONB 字段。
SQLAlchemy 对 dict/list 的嵌套原地修改不会稳定触发 dirty 判定。
所有编辑类接口在写入 input_json / output_json 时统一走这里:
1. deepcopy 断开旧引用;
2. 整体重新赋值;
3. flag_modified 显式标记字段已变更。
"""
setattr(model_obj, field_name, deepcopy(value))
flag_modified(model_obj, field_name)
return _common_unwrap_step_output(value, schema_version=STEP_IO_SCHEMA_VERSION)
async def log_module_event(
@@ -251,22 +231,14 @@ async def _get_project_for_user(
for_update: bool = False,
populate_existing: bool = False,
) -> ModuleGenerationProject:
query = select(ModuleGenerationProject).where(
ModuleGenerationProject.id == project_id,
ModuleGenerationProject.module == MODULE,
ModuleGenerationProject.deleted_at.is_(None),
return await _base_get_project_for_user(
db,
project_id=project_id,
user=user,
config=FLOW_CONFIG,
for_update=for_update,
populate_existing=populate_existing,
)
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="爆款开头复刻项目不存在")
return project
async def _get_step_for_user(
@@ -277,64 +249,26 @@ async def _get_step_for_user(
user: User,
for_update: bool = False,
) -> ModuleGenerationStep:
await _get_project_for_user(db, project_id=project_id, user=user, for_update=for_update)
query = select(ModuleGenerationStep).where(
ModuleGenerationStep.id == step_id,
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == MODULE,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.is_current == True,
return await _base_get_step_for_user(
db,
project_id=project_id,
step_id=step_id,
user=user,
config=FLOW_CONFIG,
for_update=for_update,
)
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="子任务不存在")
return step
async def _get_current_steps(db: AsyncSession, project_id: str) -> list[ModuleGenerationStep]:
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == 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())
return await _base_get_current_steps(db, project_id=project_id, config=FLOW_CONFIG)
async def _get_current_step_by_code(db: AsyncSession, project_id: str, step_code: str) -> ModuleGenerationStep | None:
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == 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()
return await _base_get_current_step_by_code(db, project_id=project_id, step_code=step_code, config=FLOW_CONFIG)
async def _next_version(db: AsyncSession, project_id: str, step_code: str) -> int:
result = await db.execute(
select(func.max(ModuleGenerationStep.version)).where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == MODULE,
ModuleGenerationStep.step_code == step_code,
)
)
return int(result.scalar_one_or_none() or 0) + 1
return await _base_next_version(db, project_id=project_id, step_code=step_code, config=FLOW_CONFIG)
async def _create_step(
@@ -349,39 +283,19 @@ async def _create_step(
input_data: dict[str, Any] | None = None,
output_data: dict[str, Any] | None = None,
) -> ModuleGenerationStep:
version = await _next_version(db, project.id, step_code)
step = ModuleGenerationStep(
id=generate_id(),
project_id=project.id,
user_id=project.user_id,
module=project.module,
step_index=STEP_INDEX_MAP[step_code],
return await _base_create_step(
db,
project=project,
step_code=step_code,
config=FLOW_CONFIG,
log_module_event=log_module_event,
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=_step_input(
step_code=step_code,
payload=input_data,
source_step_id=source_step_id,
parent_step_id=parent_step_id,
) if input_data is not None else None,
output_json=_step_output(
step_code=step_code,
status=status,
result=output_data,
) if output_data is not None else None,
started_at=_now() if status == ModuleStepStatusEnum.PROCESSING.value else None,
completed_at=_now() if status == ModuleStepStatusEnum.COMPLETED.value else None,
input_data=input_data,
output_data=output_data,
)
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
async def _soft_delete_steps_from_index(
@@ -391,49 +305,14 @@ async def _soft_delete_steps_from_index(
start_index: int,
deleted_at: datetime | None = None,
) -> None:
deleted_at = deleted_at or _now()
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == MODULE,
ModuleGenerationStep.is_current == True,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.step_index >= start_index,
)
.with_for_update()
await _base_soft_delete_steps_from_index(
db,
project=project,
start_index=start_index,
config=FLOW_CONFIG,
log_module_event=log_module_event,
deleted_at=deleted_at,
)
steps = list(result.scalars().all())
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="爆款开头复刻步骤被重新生成或删除,旧生成任务已取消",
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]},
)
def _step_to_out(step: ModuleGenerationStep) -> HotOpeningStepOut:
@@ -459,17 +338,11 @@ def _step_to_out(step: ModuleGenerationStep) -> HotOpeningStepOut:
def _snapshot_from_chat(chat_task: ChatGenerationTask | None) -> dict[str, Any]:
if not chat_task:
return {}
return _parse_json(chat_task.engine_snapshot_json, {}) or {}
return _common_snapshot_from_chat(chat_task)
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()}
return await _base_chat_tasks_by_id(db, steps)
async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProject) -> HotOpeningTaskDetailOut:
@@ -763,73 +636,15 @@ async def update_hot_opening_step(
step_id: str,
req: HotOpeningStepUpdate,
) -> tuple[ModuleGenerationProject, ModuleGenerationStep]:
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True)
if step.status == ModuleStepStatusEnum.PROCESSING.value:
raise HTTPException(status_code=400, detail="当前子任务正在处理中,暂不能修改")
input_data = _step_payload(step.input_json)
output_data = _unwrap_step_output(step.output_json)
if step.step_code == HotOpeningStepCodeEnum.MATERIAL_INPUT.value:
input_data = _merge_dict(
input_data,
{
"material_video_url": req.material_video_url,
"material_image_url": req.material_image_url,
"source_project_name": req.source_project_name,
"target_project_name": req.target_project_name,
"core_content_point": req.core_content_point,
},
)
if req.target_project_name:
project.title = req.target_project_name
elif step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value:
if req.prompt is not None:
output_data["optimized_prompt"] = req.prompt
output_data["prompt"] = req.prompt
elif step.step_code == HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value:
if req.prompt_schema is not None:
output_data["prompt_schema"] = req.prompt_schema
if req.prompt is not None:
output_data["final_prompt"] = req.prompt
else:
if req.input_json:
input_data = _merge_dict(input_data, req.input_json)
if req.output_json:
output_data = _merge_dict(output_data, req.output_json)
if req.input_json:
input_data = _merge_dict(input_data, req.input_json)
if req.output_json:
output_data = _merge_dict(output_data, req.output_json)
_force_set_json(
step,
"input_json",
_step_input(
step_code=step.step_code,
payload=input_data,
source_step_id=step.source_step_id,
parent_step_id=step.parent_step_id,
),
return await update_module_step(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
config=FLOW_CONFIG,
log_module_event=log_module_event,
)
_force_set_json(
step,
"output_json",
_step_output(step_code=step.step_code, status=ModuleStepStatusEnum.COMPLETED.value, payload=output_data),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = step.step_code
project.error_message = None
await _soft_delete_steps_from_index(db, project=project, start_index=step.step_index + 1)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_UPDATED.value, message="用户修改子任务内容")
await db.flush()
return project, step
async def update_hot_opening_material_input(
@@ -844,54 +659,14 @@ async def update_hot_opening_material_input(
采用方案 B:软删除旧第1步及之后的当前有效步骤,然后新建第1步 version+1。
未传字段沿用旧第1步素材输入,避免前端只改一个字段时丢失其它素材信息。
"""
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
old_material_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.MATERIAL_INPUT.value)
old_material = _step_payload(old_material_step.input_json if old_material_step else None)
material = {
"material_video_url": req.material_video_url if req.material_video_url is not None else old_material.get("material_video_url"),
"material_image_url": req.material_image_url if req.material_image_url is not None else old_material.get("material_image_url"),
"source_project_name": req.source_project_name if req.source_project_name is not None else old_material.get("source_project_name"),
"target_project_name": req.target_project_name if req.target_project_name is not None else old_material.get("target_project_name"),
"core_content_point": req.core_content_point if req.core_content_point is not None else old_material.get("core_content_point"),
}
missing_fields = [key for key, value in material.items() if value is None or str(value).strip() == ""]
if missing_fields:
raise HTTPException(status_code=400, detail=f"素材输入缺少必要字段: {', '.join(missing_fields)}")
await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.MATERIAL_INPUT.value])
project.title = str(material["target_project_name"])
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = HotOpeningStepCodeEnum.MATERIAL_INPUT.value
project.final_image_url = None
project.final_video_url = None
project.final_video_cover_url = None
project.error_message = None
project.completed_at = None
new_step = await _create_step(
return await update_module_material_input(
db,
project=project,
step_code=HotOpeningStepCodeEnum.MATERIAL_INPUT.value,
status=ModuleStepStatusEnum.COMPLETED.value,
input_data=material,
output_data={"message": "素材输入已修改,旧步骤已软删除。下一步请重新生成图片AI提词。"},
current_user=current_user,
project_id=project_id,
req=req,
config=FLOW_CONFIG,
log_module_event=log_module_event,
)
await log_module_event(
db,
project=project,
step=new_step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户修改素材输入并重建第1步新版本",
detail={
"old_material_step_id": old_material_step.id if old_material_step else None,
"new_material_step_id": new_step.id,
"version": new_step.version,
},
)
return project.id, new_step.id
async def update_hot_opening_image_prompt(
@@ -906,55 +681,15 @@ async def update_hot_opening_image_prompt(
修改后软删除第3、4、5步当前有效任务,让用户从图片生成开始重新执行。
"""
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True)
if step.step_code != HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value:
raise HTTPException(status_code=400, detail="只能修改第2步图片 AI 提词子任务")
if step.status != ModuleStepStatusEnum.COMPLETED.value:
raise HTTPException(status_code=400, detail="图片 AI 提词未完成,不能直接修改")
output_data = _step_payload(step.output_json)
usage = _step_usage(step.output_json)
new_prompt = req.prompt.strip()
output_data["optimized_prompt"] = new_prompt
output_data["prompt"] = new_prompt
output_data["manual_edited"] = True
output_data["manual_edited_at"] = _now().isoformat()
_force_set_json(
step,
"output_json",
_step_output(
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = _now()
await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.IMAGE_GENERATE.value])
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
project.final_image_url = None
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await log_module_event(
return await update_module_image_prompt(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户直接修改图片 AI 优化提词,已软删除后续步骤",
detail={"start_deleted_step_index": STEP_INDEX_MAP[HotOpeningStepCodeEnum.IMAGE_GENERATE.value]},
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
config=FLOW_CONFIG,
log_module_event=log_module_event,
)
await db.flush()
return project, step
async def update_hot_opening_video_prompt_schema(
@@ -970,84 +705,17 @@ async def update_hot_opening_video_prompt_schema(
服务端已有 schema 为基准:视频规格、数组长度、时间段、合规控制、质量控制、协议字段均锁定。
最终提示词允许修改,但保存前会清洗视频时长、比例、分辨率、帧率等参数。
"""
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True)
if step.step_code != HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value:
raise HTTPException(status_code=400, detail="只能修改第4步视频 AI 提词 JSON schema 子任务")
if step.status != ModuleStepStatusEnum.COMPLETED.value:
raise HTTPException(status_code=400, detail="视频 AI 提词未完成,不能直接修改")
output_data = _step_payload(step.output_json)
usage = _step_usage(step.output_json)
input_data = _step_payload(step.input_json)
server_schema = output_data.get("prompt_schema") if isinstance(output_data.get("prompt_schema"), dict) else {}
video_config = output_data.get("params_used_for_prompt") or input_data.get("video_config") or {}
if not isinstance(video_config, dict) or not video_config.get("duration") or not video_config.get("aspect_ratio") or not video_config.get("resolution"):
raise HTTPException(status_code=400, detail="缺少第4步视频参数快照,不能安全修改视频 schema")
patched_schema = patch_video_prompt_schema_from_client(
server_schema=server_schema,
client_schema=req.prompt_schema,
video_config=video_config,
)
final_prompt = build_final_video_prompt(patched_schema)
output_data["prompt_schema"] = patched_schema
output_data["final_prompt"] = final_prompt
output_data["params_used_for_prompt"] = video_config
output_data["manual_edited"] = True
output_data["manual_edited_at"] = _now().isoformat()
_force_set_json(
step,
"output_json",
_step_output(
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = _now()
await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.VIDEO_GENERATE.value])
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await log_module_event(
return await update_module_video_prompt_schema(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户修改视频 AI 提词 schema,已软删除视频生成步骤",
detail={
"start_deleted_step_index": STEP_INDEX_MAP[HotOpeningStepCodeEnum.VIDEO_GENERATE.value],
"locked_fields": [
"schema_version",
"schema_usage",
"画面属性.视频时长",
"画面属性.视频比例",
"画面属性.清晰度",
"画面属性.帧率",
"画面属性.推荐分辨率",
"动作流程[*].时间段",
"镜头流程[*].时间段",
"动态时间规划",
"输出规格限制",
"质量控制",
"合规控制",
],
},
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
config=FLOW_CONFIG,
log_module_event=log_module_event,
patch_video_prompt_schema_from_client=patch_video_prompt_schema_from_client,
build_final_video_prompt=build_final_video_prompt,
)
await db.flush()
return project, step
async def submit_image_prompt_optimize(
@@ -1754,11 +1422,4 @@ async def delete_hot_opening_project(db: AsyncSession, *, current_user: User, pr
return HotOpeningDeleteOut(message="项目已删除", project_id=project.id, deleted=True)
def _build_file_url_or_data_uri(file_url: str) -> str:
"""
Convert local upload path to base64 data URI.
Keep remote http/https/data URLs as-is.
"""
if file_url.startswith(("http://", "https://", "data:")):
return file_url
file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400)
return f"{settings.BASE_URL}{file_url_sign}"
return _common_build_file_url_or_data_uri(file_url)
@@ -0,0 +1,258 @@
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
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())
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]},
)
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()}
@@ -0,0 +1,168 @@
from __future__ import annotations
import base64
import json
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm.attributes import flag_modified
from app.config import settings
from app.models.chat_generation_task import ChatGenerationTask
from app.services.resource_signed_url_service import build_resource_signed_url
STEP_IO_SCHEMA_VERSION = "module_generation_step_io_v1"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def json_dumps(data: Any) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def parse_json(value: Any, fallback: Any = None) -> Any:
if value is None or value == "":
return fallback
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return fallback
return fallback
def build_step_input(
*,
step_code: str,
payload: dict[str, Any] | None = None,
source_step_id: str | None = None,
parent_step_id: str | None = None,
context: dict[str, Any] | None = None,
schema_version: str = STEP_IO_SCHEMA_VERSION,
) -> dict[str, Any]:
return {
"schema_version": schema_version,
"step_code": step_code,
"source": {
"source_step_id": source_step_id,
"parent_step_id": parent_step_id,
},
"payload": payload or {},
"context": context or {},
}
def build_step_output(
*,
step_code: str,
status: str,
payload: dict[str, Any] | None = None,
result: dict[str, Any] | None = None,
usage: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
schema_version: str = STEP_IO_SCHEMA_VERSION,
) -> dict[str, Any]:
return {
"schema_version": schema_version,
"step_code": step_code,
"status": status,
"payload": payload or {},
"result": result or {},
"usage": usage or {},
"error": error or {},
}
def is_wrapped_step_io(value: Any, *, schema_version: str = STEP_IO_SCHEMA_VERSION) -> bool:
return isinstance(value, dict) and value.get("schema_version") == schema_version
def step_payload(value: Any, *, schema_version: str = STEP_IO_SCHEMA_VERSION) -> dict[str, Any]:
data = parse_json(value, {}) or {}
if is_wrapped_step_io(data, schema_version=schema_version):
payload = data.get("payload")
return payload if isinstance(payload, dict) else {}
return data if isinstance(data, dict) else {}
def step_result(value: Any, *, schema_version: str = STEP_IO_SCHEMA_VERSION) -> dict[str, Any]:
data = parse_json(value, {}) or {}
if is_wrapped_step_io(data, schema_version=schema_version):
result = data.get("result")
if isinstance(result, dict) and result:
return result
payload = data.get("payload")
return payload if isinstance(payload, dict) else {}
return data if isinstance(data, dict) else {}
def step_usage(value: Any, *, schema_version: str = STEP_IO_SCHEMA_VERSION) -> dict[str, Any]:
data = parse_json(value, {}) or {}
if is_wrapped_step_io(data, schema_version=schema_version):
usage = data.get("usage")
return usage if isinstance(usage, dict) else {}
usage = data.get("token_usage") if isinstance(data, dict) else {}
return usage if isinstance(usage, dict) else {}
def unwrap_step_output(value: Any, *, schema_version: str = STEP_IO_SCHEMA_VERSION) -> dict[str, Any]:
data = parse_json(value, {}) or {}
if not is_wrapped_step_io(data, schema_version=schema_version):
return data if isinstance(data, dict) else {}
merged: dict[str, Any] = {}
payload = data.get("payload")
result = data.get("result")
usage = data.get("usage")
if isinstance(payload, dict):
merged.update(payload)
if isinstance(result, dict):
merged.update(result)
if isinstance(usage, dict) and usage:
merged["token_usage"] = usage
return merged
def merge_dict(old: dict[str, Any] | None, new: dict[str, Any] | None) -> dict[str, Any]:
merged = dict(old or {})
for key, value in (new or {}).items():
if value is not None:
merged[key] = value
return merged
def force_set_json(model_obj: Any, field_name: str, value: Any) -> None:
"""强制持久化 JSON / JSONB 字段。
SQLAlchemy 对 dict/list 的嵌套原地修改不会稳定触发 dirty 判定。
所有编辑类接口在写入 input_json / output_json 时统一走这里:
1. deepcopy 断开旧引用;
2. 整体重新赋值;
3. flag_modified 显式标记字段已变更。
"""
setattr(model_obj, field_name, deepcopy(value))
flag_modified(model_obj, field_name)
def snapshot_from_chat(chat_task: ChatGenerationTask | None) -> dict[str, Any]:
if not chat_task:
return {}
return {"chat_task_id": chat_task.id, "status": chat_task.status, "pipeline_stage": chat_task.pipeline_stage}
def build_file_url_or_data_uri(file_url: str) -> str:
if file_url.startswith("http://") or file_url.startswith("https://") or file_url.startswith("data:"):
return file_url
file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400)
return f"{settings.BASE_URL}{file_url_sign}"
# try:
# with open(file_url, "rb") as f:
# return "data:image/png;base64," + base64.b64encode(f.read()).decode("utf-8")
# except Exception:
# return f"{settings.STORAGE_BASE_URL.rstrip('/')}/{file_url.lstrip('/')}"
@@ -0,0 +1,363 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModuleStepStatusEnum
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.module_generation_flow_base_service import (
create_module_step,
get_current_step_by_code,
get_project_for_user,
get_step_for_user,
soft_delete_steps_from_index,
)
from app.enums.module_generation_flow import ModuleGenerationFlowConfig
from app.services.module_generation_step_common_service import (
build_step_input,
build_step_output,
force_set_json,
merge_dict,
step_payload,
step_usage,
unwrap_step_output,
utc_now,
)
LogModuleEventCallable = Callable[..., Awaitable[None]]
PatchVideoPromptSchemaCallable = Callable[..., dict[str, Any]]
BuildFinalVideoPromptCallable = Callable[[dict[str, Any]], str]
async def update_module_step(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str,
req: Any,
config: ModuleGenerationFlowConfig,
log_module_event: LogModuleEventCallable,
) -> tuple[ModuleGenerationProject, ModuleGenerationStep]:
project = await get_project_for_user(db, project_id=project_id, user=current_user, config=config, for_update=True)
step = await get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, config=config, for_update=True)
if step.status == ModuleStepStatusEnum.PROCESSING.value:
raise HTTPException(status_code=400, detail="当前子任务正在处理中,暂不能修改")
input_data = step_payload(step.input_json, schema_version=config.step_io_schema_version)
output_data = unwrap_step_output(step.output_json, schema_version=config.step_io_schema_version)
if step.step_code == config.material_step_code:
input_data = merge_dict(
input_data,
{
"material_video_url": getattr(req, "material_video_url", None),
"material_image_url": getattr(req, "material_image_url", None),
"source_project_name": getattr(req, "source_project_name", None),
"target_project_name": getattr(req, "target_project_name", None),
"core_content_point": getattr(req, "core_content_point", None),
},
)
target_project_name = getattr(req, "target_project_name", None)
if target_project_name:
project.title = target_project_name
elif step.step_code == config.image_prompt_step_code:
prompt = getattr(req, "prompt", None)
if prompt is not None:
output_data["optimized_prompt"] = prompt
output_data["prompt"] = prompt
elif step.step_code == config.video_prompt_step_code:
prompt_schema = getattr(req, "prompt_schema", None)
prompt = getattr(req, "prompt", None)
if prompt_schema is not None:
output_data["prompt_schema"] = prompt_schema
if prompt is not None:
output_data["final_prompt"] = prompt
else:
if getattr(req, "input_json", None):
input_data = merge_dict(input_data, req.input_json)
if getattr(req, "output_json", None):
output_data = merge_dict(output_data, req.output_json)
if getattr(req, "input_json", None):
input_data = merge_dict(input_data, req.input_json)
if getattr(req, "output_json", None):
output_data = merge_dict(output_data, req.output_json)
force_set_json(
step,
"input_json",
build_step_input(
step_code=step.step_code,
payload=input_data,
source_step_id=step.source_step_id,
parent_step_id=step.parent_step_id,
schema_version=config.step_io_schema_version,
),
)
force_set_json(
step,
"output_json",
build_step_output(step_code=step.step_code, status=ModuleStepStatusEnum.COMPLETED.value, payload=output_data, schema_version=config.step_io_schema_version),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = step.step_code
project.error_message = None
await soft_delete_steps_from_index(
db,
project=project,
start_index=step.step_index + 1,
config=config,
log_module_event=log_module_event,
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_UPDATED.value, message="用户修改子任务内容")
await db.flush()
return project, step
async def update_module_material_input(
db: AsyncSession,
*,
current_user: User,
project_id: str,
req: Any,
config: ModuleGenerationFlowConfig,
log_module_event: LogModuleEventCallable,
) -> tuple[str, str]:
project = await get_project_for_user(db, project_id=project_id, user=current_user, config=config, for_update=True)
old_material_step = await get_current_step_by_code(db, project_id=project.id, step_code=config.material_step_code, config=config)
old_material = step_payload(old_material_step.input_json if old_material_step else None, schema_version=config.step_io_schema_version)
material_video_url = (
getattr(req, "material_video_url", None)
if config.material_video_url_editable and getattr(req, "material_video_url", None) is not None
else old_material.get("material_video_url")
)
material = {
"material_video_url": material_video_url,
"material_image_url": getattr(req, "material_image_url", None) if getattr(req, "material_image_url", None) is not None else old_material.get("material_image_url"),
"source_project_name": getattr(req, "source_project_name", None) if getattr(req, "source_project_name", None) is not None else old_material.get("source_project_name"),
"target_project_name": getattr(req, "target_project_name", None) if getattr(req, "target_project_name", None) is not None else old_material.get("target_project_name"),
"core_content_point": getattr(req, "core_content_point", None) if getattr(req, "core_content_point", None) is not None else old_material.get("core_content_point"),
}
missing_fields = [key for key, value in material.items() if value is None or str(value).strip() == ""]
if missing_fields:
raise HTTPException(status_code=400, detail=f"素材输入缺少必要字段: {', '.join(missing_fields)}")
await soft_delete_steps_from_index(
db,
project=project,
start_index=config.step_index_map[config.material_step_code],
config=config,
log_module_event=log_module_event,
)
project.title = str(material["target_project_name"])
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = config.material_step_code
project.final_image_url = None
project.final_video_url = None
project.final_video_cover_url = None
project.error_message = None
project.completed_at = None
new_step = await create_module_step(
db,
project=project,
step_code=config.material_step_code,
config=config,
log_module_event=log_module_event,
status=ModuleStepStatusEnum.COMPLETED.value,
input_data=material,
output_data={"message": "素材输入已修改,旧步骤已软删除。下一步请重新生成图片AI提词。"},
)
await log_module_event(
db,
project=project,
step=new_step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户修改素材输入并重建第1步新版本",
detail={
"old_material_step_id": old_material_step.id if old_material_step else None,
"new_material_step_id": new_step.id,
"version": new_step.version,
},
)
return project.id, new_step.id
async def update_module_image_prompt(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str,
req: Any,
config: ModuleGenerationFlowConfig,
log_module_event: LogModuleEventCallable,
) -> tuple[ModuleGenerationProject, ModuleGenerationStep]:
project = await get_project_for_user(db, project_id=project_id, user=current_user, config=config, for_update=True)
step = await get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, config=config, for_update=True)
if step.step_code != config.image_prompt_step_code:
raise HTTPException(status_code=400, detail="只能修改第2步图片 AI 提词子任务")
if step.status != ModuleStepStatusEnum.COMPLETED.value:
raise HTTPException(status_code=400, detail="图片 AI 提词未完成,不能直接修改")
output_data = step_payload(step.output_json, schema_version=config.step_io_schema_version)
usage = step_usage(step.output_json, schema_version=config.step_io_schema_version)
new_prompt = req.prompt.strip()
output_data["optimized_prompt"] = new_prompt
output_data["prompt"] = new_prompt
output_data["manual_edited"] = True
output_data["manual_edited_at"] = utc_now().isoformat()
force_set_json(
step,
"output_json",
build_step_output(
step_code=config.image_prompt_step_code,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
schema_version=config.step_io_schema_version,
),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = utc_now()
await soft_delete_steps_from_index(
db,
project=project,
start_index=config.step_index_map[config.image_generate_step_code],
config=config,
log_module_event=log_module_event,
)
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = config.image_prompt_step_code
project.final_image_url = None
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await log_module_event(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户直接修改图片 AI 优化提词,已软删除后续步骤",
detail={"start_deleted_step_index": config.step_index_map[config.image_generate_step_code]},
)
await db.flush()
return project, step
async def update_module_video_prompt_schema(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str,
req: Any,
config: ModuleGenerationFlowConfig,
log_module_event: LogModuleEventCallable,
patch_video_prompt_schema_from_client: PatchVideoPromptSchemaCallable,
build_final_video_prompt: BuildFinalVideoPromptCallable,
) -> tuple[ModuleGenerationProject, ModuleGenerationStep]:
project = await get_project_for_user(db, project_id=project_id, user=current_user, config=config, for_update=True)
step = await get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, config=config, for_update=True)
if step.step_code != config.video_prompt_step_code:
raise HTTPException(status_code=400, detail="只能修改第4步视频 AI 提词 JSON schema 子任务")
if step.status != ModuleStepStatusEnum.COMPLETED.value:
raise HTTPException(status_code=400, detail="视频 AI 提词未完成,不能直接修改")
output_data = step_payload(step.output_json, schema_version=config.step_io_schema_version)
usage = step_usage(step.output_json, schema_version=config.step_io_schema_version)
input_data = step_payload(step.input_json, schema_version=config.step_io_schema_version)
server_schema = output_data.get("prompt_schema") if isinstance(output_data.get("prompt_schema"), dict) else {}
video_config = output_data.get("params_used_for_prompt") or input_data.get("video_config") or {}
if not isinstance(video_config, dict) or not video_config.get("duration") or not video_config.get("aspect_ratio") or not video_config.get("resolution"):
raise HTTPException(status_code=400, detail="缺少第4步视频参数快照,不能安全修改视频 schema")
patched_schema = patch_video_prompt_schema_from_client(
server_schema=server_schema,
client_schema=req.prompt_schema,
video_config=video_config,
)
final_prompt = build_final_video_prompt(patched_schema)
output_data["prompt_schema"] = patched_schema
output_data["final_prompt"] = final_prompt
output_data["params_used_for_prompt"] = video_config
output_data["manual_edited"] = True
output_data["manual_edited_at"] = utc_now().isoformat()
force_set_json(
step,
"output_json",
build_step_output(
step_code=config.video_prompt_step_code,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
schema_version=config.step_io_schema_version,
),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = utc_now()
await soft_delete_steps_from_index(
db,
project=project,
start_index=config.step_index_map[config.video_generate_step_code],
config=config,
log_module_event=log_module_event,
)
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = config.video_prompt_step_code
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await log_module_event(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户修改视频 AI 提词 schema,已软删除视频生成步骤",
detail={
"start_deleted_step_index": config.step_index_map[config.video_generate_step_code],
"locked_fields": [
"schema_version",
"schema_usage",
"画面属性.视频时长",
"画面属性.视频比例",
"画面属性.清晰度",
"画面属性.帧率",
"画面属性.推荐分辨率",
"动作流程[*].时间段",
"镜头流程[*].时间段",
"动态时间规划",
"输出规格限制",
"质量控制",
"合规控制",
],
},
)
await db.flush()
return project, step
@@ -53,6 +53,39 @@ from app.services.hot_opening_video_prompt_service import (
from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event
from app.services.llm import optimize_prompt
from app.services.resource_accounting_service import soft_delete_chat_task_resources
from app.services.module_generation_flow_base_service import (
chat_tasks_by_id as _base_chat_tasks_by_id,
create_module_step as _base_create_step,
get_current_step_by_code as _base_get_current_step_by_code,
get_current_steps as _base_get_current_steps,
get_project_for_user as _base_get_project_for_user,
get_step_for_user as _base_get_step_for_user,
next_version as _base_next_version,
soft_delete_steps_from_index as _base_soft_delete_steps_from_index,
)
from app.enums.module_generation_flow import ModuleGenerationFlowConfig
from app.services.module_generation_step_common_service import (
build_file_url_or_data_uri as _common_build_file_url_or_data_uri,
build_step_input as _common_build_step_input,
build_step_output as _common_build_step_output,
force_set_json as _common_force_set_json,
is_wrapped_step_io as _common_is_wrapped_step_io,
json_dumps as _common_json,
merge_dict as _common_merge_dict,
parse_json as _common_parse_json,
snapshot_from_chat as _common_snapshot_from_chat,
step_payload as _common_step_payload,
step_result as _common_step_result,
step_usage as _common_step_usage,
unwrap_step_output as _common_unwrap_step_output,
utc_now as _common_now,
)
from app.services.module_generation_step_update_service import (
update_module_image_prompt,
update_module_material_input,
update_module_step,
update_module_video_prompt_schema,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.utils.id_gen import generate_id
from app.models.shot_replicate_segment import ShotReplicateSegment
@@ -72,28 +105,27 @@ STEP_INDEX_MAP = {
STEP_IO_SCHEMA_VERSION = "shot_replicate_step_io_v1"
def _now() -> datetime:
return datetime.now(timezone.utc)
FLOW_CONFIG = ModuleGenerationFlowConfig(
module=MODULE,
step_index_map=STEP_INDEX_MAP,
material_step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value,
image_prompt_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
image_generate_step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value,
video_prompt_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
video_generate_step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
project_not_found_message="拆镜复刻项目不存在",
step_not_found_message="子任务不存在",
cancel_chat_task_error_message="拆镜复刻步骤被重新生成或删除,旧生成任务已取消",
material_video_url_editable=False,
step_io_schema_version=STEP_IO_SCHEMA_VERSION,
)
def _json(data: Any) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
def _parse_json(value: Any, fallback: Any = None) -> Any:
if value is None or value == "":
return fallback
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return fallback
return fallback
_now = _common_now
_json = _common_json
_parse_json = _common_parse_json
_merge_dict = _common_merge_dict
_force_set_json = _common_force_set_json
def _step_input(
@@ -104,16 +136,14 @@ def _step_input(
parent_step_id: str | None = None,
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"schema_version": STEP_IO_SCHEMA_VERSION,
"step_code": step_code,
"source": {
"source_step_id": source_step_id,
"parent_step_id": parent_step_id,
},
"payload": payload or {},
"context": context or {},
}
return _common_build_step_input(
step_code=step_code,
payload=payload,
source_step_id=source_step_id,
parent_step_id=parent_step_id,
context=context,
schema_version=STEP_IO_SCHEMA_VERSION,
)
def _step_output(
@@ -125,85 +155,35 @@ def _step_output(
usage: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"schema_version": STEP_IO_SCHEMA_VERSION,
"step_code": step_code,
"status": status,
"payload": payload or {},
"result": result or {},
"usage": usage or {},
"error": error or {},
}
return _common_build_step_output(
step_code=step_code,
status=status,
payload=payload,
result=result,
usage=usage,
error=error,
schema_version=STEP_IO_SCHEMA_VERSION,
)
def _is_wrapped_step_io(value: Any) -> bool:
return isinstance(value, dict) and value.get("schema_version") == STEP_IO_SCHEMA_VERSION
return _common_is_wrapped_step_io(value, schema_version=STEP_IO_SCHEMA_VERSION)
def _step_payload(value: Any) -> dict[str, Any]:
data = _parse_json(value, {}) or {}
if _is_wrapped_step_io(data):
payload = data.get("payload")
return payload if isinstance(payload, dict) else {}
return data if isinstance(data, dict) else {}
return _common_step_payload(value, schema_version=STEP_IO_SCHEMA_VERSION)
def _step_result(value: Any) -> dict[str, Any]:
data = _parse_json(value, {}) or {}
if _is_wrapped_step_io(data):
result = data.get("result")
if isinstance(result, dict) and result:
return result
payload = data.get("payload")
return payload if isinstance(payload, dict) else {}
return data if isinstance(data, dict) else {}
return _common_step_result(value, schema_version=STEP_IO_SCHEMA_VERSION)
def _step_usage(value: Any) -> dict[str, Any]:
data = _parse_json(value, {}) or {}
if _is_wrapped_step_io(data):
usage = data.get("usage")
return usage if isinstance(usage, dict) else {}
usage = data.get("token_usage") if isinstance(data, dict) else {}
return usage if isinstance(usage, dict) else {}
return _common_step_usage(value, schema_version=STEP_IO_SCHEMA_VERSION)
def _unwrap_step_output(value: Any) -> dict[str, Any]:
data = _parse_json(value, {}) or {}
if not _is_wrapped_step_io(data):
return data if isinstance(data, dict) else {}
merged: dict[str, Any] = {}
payload = data.get("payload")
result = data.get("result")
usage = data.get("usage")
if isinstance(payload, dict):
merged.update(payload)
if isinstance(result, dict):
merged.update(result)
if isinstance(usage, dict) and usage:
merged["token_usage"] = usage
return merged
def _merge_dict(old: dict[str, Any] | None, new: dict[str, Any] | None) -> dict[str, Any]:
merged = dict(old or {})
for key, value in (new or {}).items():
if value is not None:
merged[key] = value
return merged
def _force_set_json(model_obj: Any, field_name: str, value: Any) -> None:
"""强制持久化 JSON / JSONB 字段。
SQLAlchemy 对 dict/list 的嵌套原地修改不会稳定触发 dirty 判定。
所有编辑类接口在写入 input_json / output_json 时统一走这里:
1. deepcopy 断开旧引用;
2. 整体重新赋值;
3. flag_modified 显式标记字段已变更。
"""
setattr(model_obj, field_name, deepcopy(value))
flag_modified(model_obj, field_name)
return _common_unwrap_step_output(value, schema_version=STEP_IO_SCHEMA_VERSION)
async def log_module_event(
@@ -258,22 +238,14 @@ async def _get_project_for_user(
for_update: bool = False,
populate_existing: bool = False,
) -> ModuleGenerationProject:
query = select(ModuleGenerationProject).where(
ModuleGenerationProject.id == project_id,
ModuleGenerationProject.module == MODULE,
ModuleGenerationProject.deleted_at.is_(None),
return await _base_get_project_for_user(
db,
project_id=project_id,
user=user,
config=FLOW_CONFIG,
for_update=for_update,
populate_existing=populate_existing,
)
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="拆镜复刻项目不存在")
return project
async def _get_step_for_user(
@@ -284,64 +256,26 @@ async def _get_step_for_user(
user: User,
for_update: bool = False,
) -> ModuleGenerationStep:
await _get_project_for_user(db, project_id=project_id, user=user, for_update=for_update)
query = select(ModuleGenerationStep).where(
ModuleGenerationStep.id == step_id,
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == MODULE,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.is_current == True,
return await _base_get_step_for_user(
db,
project_id=project_id,
step_id=step_id,
user=user,
config=FLOW_CONFIG,
for_update=for_update,
)
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="子任务不存在")
return step
async def _get_current_steps(db: AsyncSession, project_id: str) -> list[ModuleGenerationStep]:
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == 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())
return await _base_get_current_steps(db, project_id=project_id, config=FLOW_CONFIG)
async def _get_current_step_by_code(db: AsyncSession, project_id: str, step_code: str) -> ModuleGenerationStep | None:
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == 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()
return await _base_get_current_step_by_code(db, project_id=project_id, step_code=step_code, config=FLOW_CONFIG)
async def _next_version(db: AsyncSession, project_id: str, step_code: str) -> int:
result = await db.execute(
select(func.max(ModuleGenerationStep.version)).where(
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.module == MODULE,
ModuleGenerationStep.step_code == step_code,
)
)
return int(result.scalar_one_or_none() or 0) + 1
return await _base_next_version(db, project_id=project_id, step_code=step_code, config=FLOW_CONFIG)
async def _create_step(
@@ -356,39 +290,19 @@ async def _create_step(
input_data: dict[str, Any] | None = None,
output_data: dict[str, Any] | None = None,
) -> ModuleGenerationStep:
version = await _next_version(db, project.id, step_code)
step = ModuleGenerationStep(
id=generate_id(),
project_id=project.id,
user_id=project.user_id,
module=project.module,
step_index=STEP_INDEX_MAP[step_code],
return await _base_create_step(
db,
project=project,
step_code=step_code,
config=FLOW_CONFIG,
log_module_event=log_module_event,
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=_step_input(
step_code=step_code,
payload=input_data,
source_step_id=source_step_id,
parent_step_id=parent_step_id,
) if input_data is not None else None,
output_json=_step_output(
step_code=step_code,
status=status,
result=output_data,
) if output_data is not None else None,
started_at=_now() if status == ModuleStepStatusEnum.PROCESSING.value else None,
completed_at=_now() if status == ModuleStepStatusEnum.COMPLETED.value else None,
input_data=input_data,
output_data=output_data,
)
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
async def _soft_delete_steps_from_index(
@@ -398,49 +312,14 @@ async def _soft_delete_steps_from_index(
start_index: int,
deleted_at: datetime | None = None,
) -> None:
deleted_at = deleted_at or _now()
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == MODULE,
ModuleGenerationStep.is_current == True,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.step_index >= start_index,
)
.with_for_update()
await _base_soft_delete_steps_from_index(
db,
project=project,
start_index=start_index,
config=FLOW_CONFIG,
log_module_event=log_module_event,
deleted_at=deleted_at,
)
steps = list(result.scalars().all())
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="拆镜复刻步骤被重新生成或删除,旧生成任务已取消",
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]},
)
def _step_to_out(step: ModuleGenerationStep) -> ShotReplicateStepOut:
@@ -466,17 +345,11 @@ def _step_to_out(step: ModuleGenerationStep) -> ShotReplicateStepOut:
def _snapshot_from_chat(chat_task: ChatGenerationTask | None) -> dict[str, Any]:
if not chat_task:
return {}
return _parse_json(chat_task.engine_snapshot_json, {}) or {}
return _common_snapshot_from_chat(chat_task)
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()}
return await _base_chat_tasks_by_id(db, steps)
async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProject) -> ShotReplicateTaskDetailOut:
@@ -703,73 +576,15 @@ async def update_shot_replicate_step(
step_id: str,
req: ShotReplicateStepUpdate,
) -> tuple[ModuleGenerationProject, ModuleGenerationStep]:
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True)
if step.status == ModuleStepStatusEnum.PROCESSING.value:
raise HTTPException(status_code=400, detail="当前子任务正在处理中,暂不能修改")
input_data = _step_payload(step.input_json)
output_data = _unwrap_step_output(step.output_json)
if step.step_code == ShotReplicateStepCodeEnum.MATERIAL_INPUT.value:
input_data = _merge_dict(
input_data,
{
"material_video_url": req.material_video_url,
"material_image_url": req.material_image_url,
"source_project_name": req.source_project_name,
"target_project_name": req.target_project_name,
"core_content_point": req.core_content_point,
},
)
if req.target_project_name:
project.title = req.target_project_name
elif step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value:
if req.prompt is not None:
output_data["optimized_prompt"] = req.prompt
output_data["prompt"] = req.prompt
elif step.step_code == ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value:
if req.prompt_schema is not None:
output_data["prompt_schema"] = req.prompt_schema
if req.prompt is not None:
output_data["final_prompt"] = req.prompt
else:
if req.input_json:
input_data = _merge_dict(input_data, req.input_json)
if req.output_json:
output_data = _merge_dict(output_data, req.output_json)
if req.input_json:
input_data = _merge_dict(input_data, req.input_json)
if req.output_json:
output_data = _merge_dict(output_data, req.output_json)
_force_set_json(
step,
"input_json",
_step_input(
step_code=step.step_code,
payload=input_data,
source_step_id=step.source_step_id,
parent_step_id=step.parent_step_id,
),
return await update_module_step(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
config=FLOW_CONFIG,
log_module_event=log_module_event,
)
_force_set_json(
step,
"output_json",
_step_output(step_code=step.step_code, status=ModuleStepStatusEnum.COMPLETED.value, payload=output_data),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = step.step_code
project.error_message = None
await _soft_delete_steps_from_index(db, project=project, start_index=step.step_index + 1)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_UPDATED.value, message="用户修改子任务内容")
await db.flush()
return project, step
async def update_shot_replicate_material_input(
@@ -784,56 +599,14 @@ async def update_shot_replicate_material_input(
采用方案 B:软删除旧第1步及之后的当前有效步骤,然后新建第1步 version+1。
未传字段沿用旧第1步素材输入,避免前端只改一个字段时丢失其它素材信息。
"""
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
old_material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value)
old_material = _step_payload(old_material_step.input_json if old_material_step else None)
# 拆镜复刻的素材视频来自 shot_replicate_segments.segment_video_url
# 与片段强绑定,不允许前端在项目素材修改接口中覆盖。
material = {
"material_video_url": old_material.get("material_video_url"),
"material_image_url": req.material_image_url if req.material_image_url is not None else old_material.get("material_image_url"),
"source_project_name": req.source_project_name if req.source_project_name is not None else old_material.get("source_project_name"),
"target_project_name": req.target_project_name if req.target_project_name is not None else old_material.get("target_project_name"),
"core_content_point": req.core_content_point if req.core_content_point is not None else old_material.get("core_content_point"),
}
missing_fields = [key for key, value in material.items() if value is None or str(value).strip() == ""]
if missing_fields:
raise HTTPException(status_code=400, detail=f"素材输入缺少必要字段: {', '.join(missing_fields)}")
await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.MATERIAL_INPUT.value])
project.title = str(material["target_project_name"])
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = ShotReplicateStepCodeEnum.MATERIAL_INPUT.value
project.final_image_url = None
project.final_video_url = None
project.final_video_cover_url = None
project.error_message = None
project.completed_at = None
new_step = await _create_step(
return await update_module_material_input(
db,
project=project,
step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value,
status=ModuleStepStatusEnum.COMPLETED.value,
input_data=material,
output_data={"message": "素材输入已修改,旧步骤已软删除。下一步请重新生成图片AI提词。"},
current_user=current_user,
project_id=project_id,
req=req,
config=FLOW_CONFIG,
log_module_event=log_module_event,
)
await log_module_event(
db,
project=project,
step=new_step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户修改素材输入并重建第1步新版本",
detail={
"old_material_step_id": old_material_step.id if old_material_step else None,
"new_material_step_id": new_step.id,
"version": new_step.version,
},
)
return project.id, new_step.id
async def update_shot_replicate_image_prompt(
@@ -848,55 +621,15 @@ async def update_shot_replicate_image_prompt(
修改后软删除第3、4、5步当前有效任务,让用户从图片生成开始重新执行。
"""
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True)
if step.step_code != ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value:
raise HTTPException(status_code=400, detail="只能修改第2步图片 AI 提词子任务")
if step.status != ModuleStepStatusEnum.COMPLETED.value:
raise HTTPException(status_code=400, detail="图片 AI 提词未完成,不能直接修改")
output_data = _step_payload(step.output_json)
usage = _step_usage(step.output_json)
new_prompt = req.prompt.strip()
output_data["optimized_prompt"] = new_prompt
output_data["prompt"] = new_prompt
output_data["manual_edited"] = True
output_data["manual_edited_at"] = _now().isoformat()
_force_set_json(
step,
"output_json",
_step_output(
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = _now()
await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.IMAGE_GENERATE.value])
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
project.final_image_url = None
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await log_module_event(
return await update_module_image_prompt(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户直接修改图片 AI 优化提词,已软删除后续步骤",
detail={"start_deleted_step_index": STEP_INDEX_MAP[ShotReplicateStepCodeEnum.IMAGE_GENERATE.value]},
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
config=FLOW_CONFIG,
log_module_event=log_module_event,
)
await db.flush()
return project, step
async def update_shot_replicate_video_prompt_schema(
@@ -912,84 +645,17 @@ async def update_shot_replicate_video_prompt_schema(
服务端已有 schema 为基准:视频规格、数组长度、时间段、合规控制、质量控制、协议字段均锁定。
最终提示词允许修改,但保存前会清洗视频时长、比例、分辨率、帧率等参数。
"""
project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True)
step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True)
if step.step_code != ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value:
raise HTTPException(status_code=400, detail="只能修改第4步视频 AI 提词 JSON schema 子任务")
if step.status != ModuleStepStatusEnum.COMPLETED.value:
raise HTTPException(status_code=400, detail="视频 AI 提词未完成,不能直接修改")
output_data = _step_payload(step.output_json)
usage = _step_usage(step.output_json)
input_data = _step_payload(step.input_json)
server_schema = output_data.get("prompt_schema") if isinstance(output_data.get("prompt_schema"), dict) else {}
video_config = output_data.get("params_used_for_prompt") or input_data.get("video_config") or {}
if not isinstance(video_config, dict) or not video_config.get("duration") or not video_config.get("aspect_ratio") or not video_config.get("resolution"):
raise HTTPException(status_code=400, detail="缺少第4步视频参数快照,不能安全修改视频 schema")
patched_schema = patch_video_prompt_schema_from_client(
server_schema=server_schema,
client_schema=req.prompt_schema,
video_config=video_config,
)
final_prompt = build_final_video_prompt(patched_schema)
output_data["prompt_schema"] = patched_schema
output_data["final_prompt"] = final_prompt
output_data["params_used_for_prompt"] = video_config
output_data["manual_edited"] = True
output_data["manual_edited_at"] = _now().isoformat()
_force_set_json(
step,
"output_json",
_step_output(
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = _now()
await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.VIDEO_GENERATE.value])
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await log_module_event(
return await update_module_video_prompt_schema(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.STEP_UPDATED.value,
message="用户修改视频 AI 提词 schema,已软删除视频生成步骤",
detail={
"start_deleted_step_index": STEP_INDEX_MAP[ShotReplicateStepCodeEnum.VIDEO_GENERATE.value],
"locked_fields": [
"schema_version",
"schema_usage",
"画面属性.视频时长",
"画面属性.视频比例",
"画面属性.清晰度",
"画面属性.帧率",
"画面属性.推荐分辨率",
"动作流程[*].时间段",
"镜头流程[*].时间段",
"动态时间规划",
"输出规格限制",
"质量控制",
"合规控制",
],
},
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
config=FLOW_CONFIG,
log_module_event=log_module_event,
patch_video_prompt_schema_from_client=patch_video_prompt_schema_from_client,
build_final_video_prompt=build_final_video_prompt,
)
await db.flush()
return project, step
async def submit_image_prompt_optimize(
@@ -1764,7 +1430,7 @@ async def create_shot_replicate_project_from_segment(
module=MODULE,
title=req.target_project_name,
status=ModuleProjectStatusEnum.WAITING_USER.value,
current_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
current_step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value,
idempotency_key=req.idempotency_key,
)
db.add(project)
@@ -1815,7 +1481,7 @@ async def create_shot_replicate_project_from_segment(
**context["analysis"],
},
)
project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
project.current_step_code = ShotReplicateStepCodeEnum.MATERIAL_INPUT.value
project.status = ModuleProjectStatusEnum.WAITING_USER.value
segment.module_project_id = project.id
segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value
@@ -1832,15 +1498,4 @@ async def create_shot_replicate_project_from_segment(
def _build_file_url_or_data_uri(file_url: str) -> str:
"""构建模型可访问的素材 URL。
- /uploads 与 /uploads/shot_segments 属于上传/素材域,不走 generate token 签名。
- /generate 下的 AI 生成资源继续走 token 签名。
- 远程 URL / data URI 原样返回。
"""
if file_url.startswith(("http://", "https://", "data:")):
return file_url
if file_url.startswith("/generate/"):
file_url = build_resource_signed_url(resource_url=file_url, expire_seconds=86400)
base_url = str(settings.BASE_URL or "").rstrip("/")
return f"{base_url}{file_url}" if base_url else file_url
return _common_build_file_url_or_data_uri(file_url)