爆款开头API开发完成

This commit is contained in:
2026-06-10 15:17:51 +08:00
parent 949bb794cc
commit 2923b1d6e1
16 changed files with 4034 additions and 0 deletions
@@ -0,0 +1,527 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.hot_opening_replicate import (
HotOpeningActionOut,
HotOpeningDeleteOut,
HotOpeningGenerateImagePromptRequest,
HotOpeningGenerateImageRequest,
HotOpeningGenerateVideoPromptRequest,
HotOpeningGenerateVideoRequest,
HotOpeningImagePromptUpdateRequest,
HotOpeningMaterialUpdateRequest,
HotOpeningSpecOut,
HotOpeningTaskCreate,
HotOpeningTaskDetailOut,
HotOpeningTaskListOut,
HotOpeningVideoPromptSchemaUpdateRequest,
)
from app.services.hot_opening_replicate_service import (
_get_project_for_user,
create_hot_opening_project,
delete_hot_opening_project,
generate_image_from_prompt,
generate_video_from_prompt,
list_hot_opening_projects,
mark_hot_opening_step_dispatch_failed,
project_to_detail_out,
submit_image_prompt_optimize,
submit_video_prompt_optimize,
update_hot_opening_image_prompt,
update_hot_opening_material_input,
update_hot_opening_video_prompt_schema,
)
from app.tasks.celery_app import celery_app
router = APIRouter(
prefix="/hot-opening-replications",
tags=["hot-opening-replications"],
)
async def _reload_project_detail(
db: AsyncSession,
current_user: User,
project_id: str,
) -> HotOpeningTaskDetailOut:
"""提交事务后统一重新查询详情,避免继续访问 commit 前 ORM 对象。"""
project = await _get_project_for_user(
db,
project_id=project_id,
user=current_user,
for_update=False,
populate_existing=True,
)
return await project_to_detail_out(db, project)
async def _mark_dispatch_failed_and_raise(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str | None,
message: str,
) -> None:
"""Celery 投递失败后,数据库事务已提交,单独标记步骤失败,避免一直 processing。"""
if step_id:
try:
await mark_hot_opening_step_dispatch_failed(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
error_message=message,
)
await db.commit()
except Exception:
await db.rollback()
raise HTTPException(status_code=503, detail=message)
@router.get(
"/spec",
response_model=HotOpeningSpecOut,
summary="查询爆款开头复刻模块状态枚举和步骤 JSON 结构说明",
description="返回总任务状态、子任务状态、5个固定步骤编码以及每个步骤 input_json/output_json 的统一结构示例,方便前端和排查人员对照。",
)
async def get_spec():
return HotOpeningSpecOut()
@router.post(
"/tasks",
response_model=HotOpeningTaskDetailOut,
summary="创建爆款开头复刻总任务项目",
description=(
"创建爆款开头复刻总任务项目。总任务表 id 就是项目ID,不再传 project_id。"
"接口只同步创建第1个素材输入子任务,保存素材视频链接、素材图片链接、视频素材内容项目名称、生成项目名称和50字核心内容点。"
"后端不开发上传接口,也不校验素材文件时长、大小、格式,直接使用前端已有上传接口返回的链接。"
"创建后不会自动生成第2步图片 AI 提词,需要前端手动调用 generate-image-prompt。"
),
)
async def create_task(
req: HotOpeningTaskCreate = Body(..., description="爆款开头复刻创建参数,只包含素材链接和项目描述,不包含图片/视频引擎参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project = await create_hot_opening_project(db, current_user, req)
project_id_value = str(project.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}")
return await _reload_project_detail(db, current_user, project_id_value)
@router.get(
"/tasks",
response_model=HotOpeningTaskListOut,
summary="查询爆款开头复刻总任务项目列表",
description="分页查询爆款开头复刻总任务项目列表。普通用户只能查看自己的项目,管理员可查看全部。",
)
async def list_tasks(
status: str | None = Query(None, description="总任务状态筛选,例如 waiting_user、processing、completed、failed;为空不过滤"),
page: int = Query(1, ge=1, description="分页页码,从1开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_hot_opening_projects(db, current_user=current_user, status=status, page=page, page_size=page_size)
@router.get(
"/tasks/{project_id}",
response_model=HotOpeningTaskDetailOut,
summary="获取爆款开头复刻总任务项目详情",
description=(
"获取爆款开头复刻总任务详情。详情会聚合返回第1步素材信息、第2步图片提词、第3步图片引擎和参数、"
"第4步视频提词 JSON schema、第5步视频引擎和参数、最终图片、最终视频和完整子任务列表。"
),
)
async def get_task(
project_id: str = Path(..., description="总任务项目ID,即 module_generation_projects.id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await _reload_project_detail(db, current_user, project_id)
@router.put(
"/tasks/{project_id}/material",
response_model=HotOpeningActionOut,
summary="修改第1步素材输入并重建第1步新版本",
description=(
"反复修改爆款开头复刻第1步素材输入。"
"接口会软删除旧第1步以及第2、3、4、5步当前有效子任务,联动软删除关联 ChatGenerationTask"
"然后新建第1步 material_input 的 version+1,项目回到 waiting_user 状态。"
),
)
async def update_material(
project_id: str = Path(..., description="总任务项目ID"),
req: HotOpeningMaterialUpdateRequest = Body(..., description="第1步素材输入修改参数,至少传一个字段"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project_id_value, step_id_value = await update_hot_opening_material_input(
db,
current_user=current_user,
project_id=project_id,
req=req,
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}")
return HotOpeningActionOut(
message="素材输入已修改,旧步骤已软删除,请重新生成图片 AI 提词",
project_id=project_id_value,
step_id=step_id_value,
detail=await _reload_project_detail(db, current_user, project_id_value),
)
@router.put(
"/tasks/{project_id}/steps/{step_id}/image-prompt",
response_model=HotOpeningActionOut,
summary="直接修改第2步图片 AI 优化提词",
description=(
"直接修改第2步图片 AI 优化提词,不调用 AI、不扣积分。"
"保存后会软删除第3、4、5步当前有效任务和关联 ChatGenerationTask"
"清空旧图片/视频结果,让用户从图片生成开始重新执行。"
),
)
async def update_image_prompt(
project_id: str = Path(..., description="总任务项目ID"),
step_id: str = Path(..., description="第2步图片 AI 提词子任务ID"),
req: HotOpeningImagePromptUpdateRequest = Body(..., description="图片 AI 优化提词修改参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await update_hot_opening_image_prompt(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
)
project_id_value = str(project.id)
step_id_value = str(step.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}")
return HotOpeningActionOut(
message="图片 AI 提词已修改,后续步骤已软删除,请重新生成图片",
project_id=project_id_value,
step_id=step_id_value,
detail=await _reload_project_detail(db, current_user, project_id_value),
)
@router.put(
"/tasks/{project_id}/steps/{step_id}/video-prompt-schema",
response_model=HotOpeningActionOut,
summary="修改第4步视频 AI 提词 JSON schema",
description=(
"修改第4步视频 AI 提词 JSON schema,不调用 AI、不扣积分。"
"前端提交的 schema 只作为 patch,服务端会锁定视频时长、比例、清晰度、帧率、推荐分辨率、"
"动作/镜头/动态时间规划数组长度和时间段、输出规格、质量控制、合规控制、schema_version、schema_usage。"
"最终提示词允许修改,但会清洗秒数、比例、分辨率、帧率等视频参数。保存后软删除第5步视频生成任务。"
),
)
async def update_video_prompt_schema(
project_id: str = Path(..., description="总任务项目ID"),
step_id: str = Path(..., description="第4步视频 AI 提词子任务ID"),
req: HotOpeningVideoPromptSchemaUpdateRequest = Body(..., description="视频 AI 提词 schema 修改参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await update_hot_opening_video_prompt_schema(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
)
project_id_value = str(project.id)
step_id_value = str(step.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"修改视频 AI 提词 schema 失败: {exc}")
return HotOpeningActionOut(
message="视频 AI 提词 schema 已修改,第5步视频生成任务已软删除,请重新生成视频",
project_id=project_id_value,
step_id=step_id_value,
detail=await _reload_project_detail(db, current_user, project_id_value),
)
@router.post(
"/tasks/{project_id}/steps/{step_id}/generate-image-prompt",
response_model=HotOpeningActionOut,
summary="基于素材输入手动生成图片 AI 提词",
description=(
"基于第1步素材输入子任务手动生成第2步图片 AI 提词。"
"如果已存在旧的第2、3、4、5步,会先软删除旧步骤,再创建新的第2步。"
),
)
async def generate_image_prompt(
project_id: str = Path(..., description="总任务项目ID"),
step_id: str = Path(..., description="第1步素材输入子任务ID"),
req: HotOpeningGenerateImagePromptRequest = Body(default_factory=HotOpeningGenerateImagePromptRequest, description="图片提词生成参数,当前无需传参,额外字段会忽略"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = req
if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
try:
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id)
project_id_value = str(project.id)
step_id_value = str(step.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"图片提词任务创建失败: {exc}")
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
try:
start_image_prompt_optimize.delay(project_id_value, step_id_value)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"图片提词任务投递失败: {exc}",
)
return HotOpeningActionOut(
message="图片 AI 提词任务已提交",
project_id=project_id_value,
step_id=step_id_value,
detail=await _reload_project_detail(db, current_user, project_id_value),
)
@router.post(
"/tasks/{project_id}/steps/{step_id}/generate-image",
response_model=HotOpeningActionOut,
summary="基于图片 AI 提词生成新项目图片",
description=(
"基于第2步图片 AI 提词生成新项目图片。调用时传入图片生成引擎和图片生成参数。"
"后端会创建第3步图片生成子任务,ChatGenerationTask 幂等键由后端按任务ID自动生成,不再使用前端幂等键。"
"图片生成媒体积分在创建 ChatGenerationTask 时扣除,生成失败走媒体积分退款。"
"如果已存在旧的第3、4、5步,会先软删除旧步骤,再创建新的第3步。"
),
)
async def generate_image(
project_id: str = Path(..., description="总任务项目ID"),
step_id: str = Path(..., description="第2步图片 AI 提词子任务ID"),
req: HotOpeningGenerateImageRequest = Body(..., description="图片生成引擎和参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
try:
project, step = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req)
project_id_value = str(project.id)
step_id_value = str(step.id)
chat_task_id_value = step.chat_task_id
if not chat_task_id_value:
raise HTTPException(status_code=500, detail="图片生成任务创建失败:chat_task_id为空")
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"图片生成任务创建失败: {exc}")
from app.tasks.generation_create_tasks import chatapi_create_generation_task
try:
chatapi_create_generation_task.delay(chat_task_id_value)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"图片生成任务投递失败: {exc}",
)
return HotOpeningActionOut(
message="图片生成任务已提交",
project_id=project_id_value,
step_id=step_id_value,
detail=await _reload_project_detail(db, current_user, project_id_value),
)
@router.post(
"/tasks/{project_id}/steps/{step_id}/generate-video-prompt",
response_model=HotOpeningActionOut,
summary="基于图片结果手动生成视频 AI 提词",
description=(
"基于第3步图片生成子任务手动生成第4步视频 AI 提词 JSON schema。"
"视频时长、比例、分辨率集中在本步骤确定并写入 step.output_json.payload.params_used_for_prompt。"
"视频 AI 提词成功后按文本 token 扣积分;该文本积分不参与后续视频生成失败退款。"
"如果已存在旧的第4、5步,会先软删除旧步骤,再创建新的第4步。"
),
)
async def generate_video_prompt(
project_id: str = Path(..., description="总任务项目ID"),
step_id: str = Path(..., description="第3步图片生成子任务ID"),
req: HotOpeningGenerateVideoPromptRequest = Body(..., description="视频提词生成参数,用于读取接口配置并规划视频时长、比例、分辨率"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
try:
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
project_id_value = str(project.id)
step_id_value = str(step.id)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"视频提词任务创建失败: {exc}")
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
try:
start_video_prompt_optimize.delay(project_id_value, step_id_value)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"视频提词任务投递失败: {exc}",
)
return HotOpeningActionOut(
message="视频 AI 提词任务已提交",
project_id=project_id_value,
step_id=step_id_value,
detail=await _reload_project_detail(db, current_user, project_id_value),
)
@router.post(
"/tasks/{project_id}/steps/{step_id}/generate-video",
response_model=HotOpeningActionOut,
summary="基于视频 AI 提词生成最终视频",
description=(
"基于第4步视频 AI 提词生成最终视频。请求体只需要选择视频生成引擎 engine_id。"
"视频时长、比例、分辨率从第4步视频提词优化结果读取,不再由本接口动态传入。"
"关联 ChatGenerationTask 的 original_prompt 和 optimized_prompt 都使用第4步生成的 prompt_schema JSON 字符串。"
"ChatGenerationTask 幂等键由后端按任务ID自动生成;视频生成媒体积分失败时走退款。"
"如果已存在旧的第5步,会先软删除旧步骤,再创建新的第5步。"
),
)
async def generate_video(
project_id: str = Path(..., description="总任务项目ID"),
step_id: str = Path(..., description="第4步视频 AI 提词子任务ID"),
req: HotOpeningGenerateVideoRequest = Body(..., description="视频生成参数:只传 engine_id,其它视频参数继承第4步视频提词结果"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
try:
project, step = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req)
project_id_value = str(project.id)
step_id_value = str(step.id)
chat_task_id_value = step.chat_task_id
if not chat_task_id_value:
raise HTTPException(status_code=500, detail="视频生成任务创建失败:chat_task_id为空")
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"视频生成任务创建失败: {exc}")
from app.tasks.generation_create_tasks import chatapi_create_generation_task
try:
chatapi_create_generation_task.delay(chat_task_id_value)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"视频生成任务投递失败: {exc}",
)
return HotOpeningActionOut(
message="视频生成任务已提交",
project_id=project_id_value,
step_id=step_id_value,
detail=await _reload_project_detail(db, current_user, project_id_value),
)
@router.delete(
"/tasks/{project_id}",
response_model=HotOpeningDeleteOut,
summary="删除爆款开头复刻总任务项目",
description="软删除爆款开头复刻总任务项目,并联动软删除当前有效子任务和关联的 ChatGenerationTask。",
)
async def delete_task(
project_id: str = Path(..., description="总任务项目ID"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
result = await delete_hot_opening_project(db, current_user=current_user, project_id=project_id)
await db.commit()
return result
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"删除爆款开头复刻项目失败: {exc}")