修复拆机复刻json类型落库失败BUG,修补celery重试遗漏机制

This commit is contained in:
2026-06-18 10:22:42 +08:00
parent e8ac763f9c
commit 3453a6445c
3 changed files with 135 additions and 46 deletions
@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json import json
from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
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
@@ -191,6 +193,19 @@ def _merge_dict(old: dict[str, Any] | None, new: dict[str, Any] | None) -> dict[
return merged 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)
async def log_module_event( async def log_module_event(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -729,8 +744,21 @@ async def update_shot_replicate_step(
if req.output_json: if req.output_json:
output_data = _merge_dict(output_data, req.output_json) output_data = _merge_dict(output_data, req.output_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) _force_set_json(
step.output_json = _step_output(step_code=step.step_code, status=ModuleStepStatusEnum.COMPLETED.value, payload=output_data) 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,
),
)
_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.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None step.error_message = None
step.completed_at = _now() step.completed_at = _now()
@@ -740,6 +768,7 @@ async def update_shot_replicate_step(
await _soft_delete_steps_from_index(db, project=project, start_index=step.step_index + 1) 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 log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_UPDATED.value, message="用户修改子任务内容")
await db.flush()
return project, step return project, step
@@ -834,11 +863,15 @@ async def update_shot_replicate_image_prompt(
output_data["manual_edited"] = True output_data["manual_edited"] = True
output_data["manual_edited_at"] = _now().isoformat() output_data["manual_edited_at"] = _now().isoformat()
step.output_json = _step_output( _force_set_json(
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
payload=output_data, _step_output(
usage=usage, step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
),
) )
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None step.error_message = None
@@ -862,6 +895,7 @@ async def update_shot_replicate_image_prompt(
message="用户直接修改图片 AI 优化提词,已软删除后续步骤", message="用户直接修改图片 AI 优化提词,已软删除后续步骤",
detail={"start_deleted_step_index": STEP_INDEX_MAP[ShotReplicateStepCodeEnum.IMAGE_GENERATE.value]}, detail={"start_deleted_step_index": STEP_INDEX_MAP[ShotReplicateStepCodeEnum.IMAGE_GENERATE.value]},
) )
await db.flush()
return project, step return project, step
@@ -906,11 +940,15 @@ async def update_shot_replicate_video_prompt_schema(
output_data["manual_edited"] = True output_data["manual_edited"] = True
output_data["manual_edited_at"] = _now().isoformat() output_data["manual_edited_at"] = _now().isoformat()
step.output_json = _step_output( _force_set_json(
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
payload=output_data, _step_output(
usage=usage, step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
),
) )
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None step.error_message = None
@@ -950,6 +988,7 @@ async def update_shot_replicate_video_prompt_schema(
], ],
}, },
) )
await db.flush()
return project, step return project, step
@@ -1087,16 +1126,20 @@ async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
}) })
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now() step.completed_at = _now()
step.output_json = _step_output( _force_set_json(
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
payload={ _step_output(
"optimized_prompt": optimized, step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
"prompt": optimized, status=ModuleStepStatusEnum.COMPLETED.value,
"original_prompt": prompt_text, payload={
"references": references, "optimized_prompt": optimized,
}, "prompt": optimized,
usage=usage, "original_prompt": prompt_text,
"references": references,
},
usage=usage,
),
) )
project.status = ModuleProjectStatusEnum.WAITING_USER.value project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
@@ -1386,16 +1429,20 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
}) })
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now() step.completed_at = _now()
step.output_json = _step_output( _force_set_json(
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
payload={ _step_output(
"prompt_schema": prompt_schema, step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
"final_prompt": final_prompt, status=ModuleStepStatusEnum.COMPLETED.value,
"params_used_for_prompt": video_config, payload={
"target_platform": target_platform, "prompt_schema": prompt_schema,
}, "final_prompt": final_prompt,
usage=usage, "params_used_for_prompt": video_config,
"target_platform": target_platform,
},
usage=usage,
),
) )
project.status = ModuleProjectStatusEnum.WAITING_USER.value project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
@@ -1549,10 +1596,14 @@ async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGene
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_GENERATE.value: if step.step_code == ShotReplicateStepCodeEnum.IMAGE_GENERATE.value:
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now() step.completed_at = _now()
step.output_json = _step_output( _force_set_json(
step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
result={"result_image_url": task.image_url, "chat_task_id": task.id}, _step_output(
step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
result={"result_image_url": task.image_url, "chat_task_id": task.id},
),
) )
project.final_image_url = task.image_url project.final_image_url = task.image_url
project.status = ModuleProjectStatusEnum.WAITING_USER.value project.status = ModuleProjectStatusEnum.WAITING_USER.value
@@ -1561,10 +1612,14 @@ async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGene
elif step.step_code == ShotReplicateStepCodeEnum.VIDEO_GENERATE.value: elif step.step_code == ShotReplicateStepCodeEnum.VIDEO_GENERATE.value:
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now() step.completed_at = _now()
step.output_json = _step_output( _force_set_json(
step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
result={"result_video_url": task.video_url, "result_video_cover_url": task.video_cover_url, "chat_task_id": task.id}, _step_output(
step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
result={"result_video_url": task.video_url, "result_video_cover_url": task.video_cover_url, "chat_task_id": task.id},
),
) )
project.final_video_url = task.video_url project.final_video_url = task.video_url
project.final_video_cover_url = task.video_cover_url project.final_video_cover_url = task.video_cover_url
@@ -77,13 +77,29 @@ async def _run_video_prompt(project_id: str, step_id: str | None = None):
if celery_app: if celery_app:
@celery_app.task(name="hot_opening.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30) @celery_app.task(name="hot_opening.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None): def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None):
"""手动触发后的图片 AI 提词任务。""" """手动触发后的图片 AI 提词任务。
return run_async(_run_image_prompt(project_id, step_id))
max_retries 只有在显式 self.retry() 时才会生效。
这里仅兜底 Celery 包装层 / DB commit / Redis registry 等未被 service 捕获的异常;
service 内部已经落库为业务失败的情况不会抛出异常,也不会重复 retry。
"""
try:
return run_async(_run_image_prompt(project_id, step_id))
except Exception as exc:
raise self.retry(exc=exc) from exc
@celery_app.task(name="hot_opening.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30) @celery_app.task(name="hot_opening.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None): def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None):
"""手动触发后的视频 AI 提词任务。""" """手动触发后的视频 AI 提词任务。
return run_async(_run_video_prompt(project_id, step_id))
max_retries 只有在显式 self.retry() 时才会生效。
这里仅兜底 Celery 包装层 / DB commit / Redis registry 等未被 service 捕获的异常;
service 内部已经落库为业务失败的情况不会抛出异常,也不会重复 retry。
"""
try:
return run_async(_run_video_prompt(project_id, step_id))
except Exception as exc:
raise self.retry(exc=exc) from exc
else: else:
class _DisabledTask: class _DisabledTask:
def delay(self, *args: Any, **kwargs: Any): def delay(self, *args: Any, **kwargs: Any):
@@ -77,12 +77,30 @@ if celery_app:
@celery_app.task(name="shot_replicate.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30) @celery_app.task(name="shot_replicate.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None): def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None):
return run_async(_run_image_prompt(project_id, step_id)) """手动触发后的图片 AI 提词任务。
max_retries 只有在显式 self.retry() 时才会生效。
这里仅兜底 Celery 包装层 / DB commit / Redis registry 等未被 service 捕获的异常;
service 内部已经落库为业务失败的情况不会抛出异常,也不会重复 retry。
"""
try:
return run_async(_run_image_prompt(project_id, step_id))
except Exception as exc:
raise self.retry(exc=exc) from exc
@celery_app.task(name="shot_replicate.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30) @celery_app.task(name="shot_replicate.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30)
def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None): def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None):
return run_async(_run_video_prompt(project_id, step_id)) """手动触发后的视频 AI 提词任务。
max_retries 只有在显式 self.retry() 时才会生效。
这里仅兜底 Celery 包装层 / DB commit / Redis registry 等未被 service 捕获的异常;
service 内部已经落库为业务失败的情况不会抛出异常,也不会重复 retry。
"""
try:
return run_async(_run_video_prompt(project_id, step_id))
except Exception as exc:
raise self.retry(exc=exc) from exc
else: else: