生成记录接口兼容图片类型|CHAT对话生成视频/图片任务API+celery异步模块完成
This commit is contained in:
@@ -14,6 +14,7 @@ from app.api.v1.menu_configs import router as menu_configs_router
|
||||
from app.api.v1.recharge_packages import router as recharge_packages_router
|
||||
from app.api.v1.video_engines import router as video_engines_router
|
||||
from app.api.v1.image_engines import router as image_engines_router
|
||||
from app.api.v1.generation_ai import router as generation_ai_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
@@ -30,3 +31,4 @@ api_router.include_router(menu_configs_router)
|
||||
api_router.include_router(recharge_packages_router)
|
||||
api_router.include_router(video_engines_router)
|
||||
api_router.include_router(image_engines_router)
|
||||
api_router.include_router(generation_ai_router)
|
||||
|
||||
@@ -43,6 +43,7 @@ from app.services.notification import create_notification
|
||||
from app.services.auth import hash_password, verify_password
|
||||
from app.services.operation_log import log_operation
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
|
||||
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
@@ -991,6 +992,14 @@ async def admin_list_generation_records(
|
||||
"error_message": record.error_message,
|
||||
"created_at": _iso(record.created_at),
|
||||
"generated_at": _iso(record.generated_at),
|
||||
|
||||
# append img param
|
||||
"gen_type": record.gen_type,
|
||||
"image_size": record.image_size or '',
|
||||
"image_url": record.image_url or '',
|
||||
"image_tokens_used": record.image_tokens_used or 0,
|
||||
"image_proportion": record.image_proportion or '',
|
||||
"image_px": record.image_px or '',
|
||||
})
|
||||
|
||||
return {"total": total, "items": items}
|
||||
@@ -1033,8 +1042,8 @@ async def admin_generate_video(
|
||||
):
|
||||
"""Admin trigger video generation for a record with specified params."""
|
||||
from app.models.project import Project
|
||||
from app.services.credits import calc_video_credits, deduct_credits
|
||||
from app.schemas.generation import ASPECT_RATIOS, RESOLUTIONS
|
||||
from app.services.credits import calc_video_credits, deduct_credits, calc_image_credits
|
||||
from app.services.video_queue import task_queue
|
||||
|
||||
result = await db.execute(
|
||||
select(GenerationRecord, Project.name)
|
||||
@@ -1046,43 +1055,68 @@ async def admin_generate_video(
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
record, project_name = row
|
||||
type_str = "视频" if record.gen_type == GenerationType.video else "图片"
|
||||
|
||||
if record.status not in ("prompt_optimized", "failed"):
|
||||
raise HTTPException(status_code=400, detail="当前状态不允许生成视频")
|
||||
raise HTTPException(status_code=400, detail=f"当前状态不允许生成{type_str}")
|
||||
|
||||
aspect_ratio = body.get("aspect_ratio", "16:9")
|
||||
resolution = body.get("resolution", "720p")
|
||||
if aspect_ratio not in ASPECT_RATIOS:
|
||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
||||
if record.gen_type == GenerationType.video:
|
||||
# Video Generation
|
||||
aspect_ratio = body.get("aspect_ratio", "16:9")
|
||||
resolution = body.get("resolution", "720p")
|
||||
if aspect_ratio not in ASPECT_RATIOS:
|
||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
||||
|
||||
duration = record.duration or 5
|
||||
video_credits = await calc_video_credits(db, duration, resolution)
|
||||
await deduct_credits(
|
||||
db, record.user_id, video_credits,
|
||||
f"视频生成(管理后台) - {project_name}",
|
||||
related_id=record_id,
|
||||
)
|
||||
duration = record.duration or 5
|
||||
video_credits = await calc_video_credits(db, duration, resolution)
|
||||
await deduct_credits(
|
||||
db, record.user_id, video_credits,
|
||||
f"视频生成(管理后台) - {project_name}",
|
||||
related_id=record_id,
|
||||
)
|
||||
|
||||
record.aspect_ratio = aspect_ratio
|
||||
record.resolution = resolution
|
||||
record.credits_cost = (record.credits_cost or 0) + video_credits
|
||||
record.status = "generating"
|
||||
record.error_message = None
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
from app.services.video_gen import get_active_engine, submit_video_task
|
||||
from app.services.video_queue import task_queue
|
||||
|
||||
engine = await get_active_engine(db)
|
||||
task_id = await submit_video_task(db, engine, record)
|
||||
record.seedance_task_id = task_id
|
||||
await db.flush()
|
||||
await task_queue.enqueue(record_id)
|
||||
except Exception as e:
|
||||
record.status = "failed"
|
||||
record.error_message = str(e)
|
||||
record.aspect_ratio = aspect_ratio
|
||||
record.resolution = resolution
|
||||
record.credits_cost = (record.credits_cost or 0) + video_credits
|
||||
record.status = "generating"
|
||||
record.error_message = None
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
from app.services.video_gen import get_active_engine, submit_video_task
|
||||
|
||||
engine = await get_active_engine(db)
|
||||
task_id = await submit_video_task(db, engine, record)
|
||||
record.seedance_task_id = task_id
|
||||
await db.flush()
|
||||
await task_queue.enqueue(record_id)
|
||||
except Exception as e:
|
||||
record.status = "failed"
|
||||
record.error_message = str(e)
|
||||
await db.flush()
|
||||
elif record.gen_type == GenerationType.image:
|
||||
# Image generation
|
||||
|
||||
post_image_size = body.get("image_size", "")
|
||||
image_credits = await calc_image_credits(db, post_image_size or record.image_size or "2K")
|
||||
await deduct_credits(
|
||||
db, record.user_id, image_credits,
|
||||
f"图片生成 - {project_name}",
|
||||
related_id=record_id,
|
||||
)
|
||||
|
||||
try:
|
||||
record.image_size = post_image_size or record.image_size or "2K"
|
||||
record.credits_cost = round(image_credits, 2)
|
||||
record.status = "generating"
|
||||
record.error_message = None
|
||||
await db.flush()
|
||||
await task_queue.enqueue(record_id)
|
||||
except Exception as e:
|
||||
record.status = "failed"
|
||||
record.error_message = str(e)
|
||||
await db.flush()
|
||||
|
||||
return {"message": "ok", "record_id": record_id}
|
||||
|
||||
@@ -201,7 +201,11 @@ async def optimize(
|
||||
# record.error_message = extract_error_message(e, "提示词")
|
||||
# await db.flush()
|
||||
# await db.commit()
|
||||
raise HTTPException(status_code=502, detail=f"AI模型调用失败: {extract_error_message(e, "提示词")}")
|
||||
error_message = extract_error_message(e, "提示词")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI模型调用失败: {error_message}"
|
||||
)
|
||||
|
||||
text_credits = await calc_text_credits(
|
||||
db, token_usage["input_tokens"], token_usage["output_tokens"],
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.user import User
|
||||
from app.schemas.generation_ai import (
|
||||
GenerationAIHistoryDayItemsOut,
|
||||
GenerationAIHistoryGroupedOut,
|
||||
GenerationAIRetryOut,
|
||||
GenerationAITaskCreate,
|
||||
GenerationAITaskListOut,
|
||||
GenerationAITaskOut,
|
||||
)
|
||||
from app.services.generation_ai_service import (
|
||||
create_async_generation_task,
|
||||
list_async_generation_tasks,
|
||||
list_generation_history_day_items,
|
||||
list_generation_history_grouped_days,
|
||||
record_to_out,
|
||||
)
|
||||
from app.services.generation_log_service import log_task_event
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/generation-ai",
|
||||
tags=["generation-ai"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks",
|
||||
response_model=GenerationAITaskOut,
|
||||
summary="创建AI图片/视频生成任务",
|
||||
description=(
|
||||
"创建一个项目无关的AI生成任务。"
|
||||
"该接口用于 Chat 风格的图片/视频生成,不再绑定 project_id。"
|
||||
"创建成功后会写入 chat_generation_tasks 表,并投递 Celery 异步任务。"
|
||||
"支持 image 图片生成和 video 视频生成。"
|
||||
"建议前端传入 idempotency_key,用于防止按钮连点、网络重试导致重复创建任务和重复扣费。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"description": "任务创建成功,返回任务详情",
|
||||
},
|
||||
400: {
|
||||
"description": "请求参数错误,例如 gen_type 不支持、图片尺寸不支持、视频参数不支持等",
|
||||
},
|
||||
401: {
|
||||
"description": "未登录或 Token 无效",
|
||||
},
|
||||
503: {
|
||||
"description": "Celery 未启用或消息队列未配置",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def create_task(
|
||||
req: GenerationAITaskCreate = Body(
|
||||
...,
|
||||
description="AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数",
|
||||
),
|
||||
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")
|
||||
|
||||
task = await create_async_generation_task(db, current_user, req)
|
||||
await db.commit()
|
||||
|
||||
await log_task_event(
|
||||
task,
|
||||
event_type="TASK_CREATED",
|
||||
to_status="generating",
|
||||
to_stage="queued",
|
||||
detail={"gen_type": task.gen_type},
|
||||
)
|
||||
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
chatapi_create_generation_task.delay(task.id)
|
||||
return record_to_out(task)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks",
|
||||
response_model=GenerationAITaskListOut,
|
||||
summary="获取AI生成任务列表",
|
||||
description=(
|
||||
"分页获取当前登录用户的AI生成任务列表。"
|
||||
"可按生成类型 gen_type 和任务状态 status 过滤。"
|
||||
"该接口返回的是普通任务列表,不按日期分组。"
|
||||
"如果前端需要按生成日期分组展示历史记录,请使用 /generation-ai/history 接口。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"description": "查询成功,返回任务总数和当前分页任务列表",
|
||||
},
|
||||
401: {
|
||||
"description": "未登录或 Token 无效",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def list_tasks(
|
||||
gen_type: str | None = Query(
|
||||
None,
|
||||
description="生成类型筛选:image=图片任务,video=视频任务;为空表示不过滤生成类型",
|
||||
examples=["image"],
|
||||
),
|
||||
status: str | None = Query(
|
||||
None,
|
||||
description=(
|
||||
"任务状态筛选。常见值:generating=生成中,completed=已完成,failed=失败;"
|
||||
"为空表示不过滤状态"
|
||||
),
|
||||
examples=["completed"],
|
||||
),
|
||||
page: int = Query(
|
||||
1,
|
||||
ge=1,
|
||||
description="分页页码,从1开始",
|
||||
examples=[1],
|
||||
),
|
||||
page_size: int = Query(
|
||||
20,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="每页返回数量,范围 1~100",
|
||||
examples=[20],
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
total, items = await list_async_generation_tasks(
|
||||
db,
|
||||
current_user.id,
|
||||
gen_type,
|
||||
status,
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
return GenerationAITaskListOut(total=total, items=[record_to_out(i) for i in items])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history",
|
||||
response_model=GenerationAIHistoryGroupedOut,
|
||||
summary="获取AI生成历史日期分组",
|
||||
description=(
|
||||
"按生成完成日期倒序返回当前用户的AI生成历史记录。"
|
||||
"该接口只返回生成成功的任务,即 status=completed 且 generated_at 不为空的数据。"
|
||||
"必须通过 gen_type 区分图片和视频。"
|
||||
"分页对象是生成日期,不是单条记录。"
|
||||
"每页最多返回10个生成日期分组,每个日期分组内最多返回该日期下倒序前10条生成记录。"
|
||||
"如果某一天 total 大于10,前端可调用 /generation-ai/history/{generated_date} 加载该日期下的后续分页数据。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"description": "查询成功,返回按生成日期分组的历史记录",
|
||||
},
|
||||
400: {
|
||||
"description": "参数错误,例如 gen_type 不是 image 或 video",
|
||||
},
|
||||
401: {
|
||||
"description": "未登录或 Token 无效",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def list_history_grouped_days(
|
||||
gen_type: str = Query(
|
||||
...,
|
||||
description="生成类型:image=图片历史,video=视频历史",
|
||||
examples=["image"],
|
||||
),
|
||||
page: int = Query(
|
||||
1,
|
||||
ge=1,
|
||||
description="日期分组分页页码,从1开始。注意:这里分页的是生成日期,不是单条生成记录",
|
||||
examples=[1],
|
||||
),
|
||||
page_size: int = Query(
|
||||
10,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="每页返回的生成日期数量,范围 1~10,最大只能获取10天",
|
||||
examples=[10],
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_generation_history_grouped_days(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
gen_type=gen_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history/{generated_date}",
|
||||
response_model=GenerationAIHistoryDayItemsOut,
|
||||
summary="获取指定日期下的AI生成历史分页",
|
||||
description=(
|
||||
"获取某一个生成日期下的生成成功记录分页。"
|
||||
"该接口用于前端在历史分组列表中继续加载某一天的后续记录。"
|
||||
"例如 /history 接口中某一天 total=18,但 items 只返回前10条,"
|
||||
"则前端可以调用本接口 page=2&page_size=10 获取该日期下剩余记录。"
|
||||
"该接口同样必须通过 gen_type 区分图片和视频。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"description": "查询成功,返回指定日期下的历史记录分页",
|
||||
},
|
||||
400: {
|
||||
"description": "参数错误,例如 generated_date 格式不是 YYYY-MM-DD,或 gen_type 不合法",
|
||||
},
|
||||
401: {
|
||||
"description": "未登录或 Token 无效",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def list_history_day_items(
|
||||
generated_date: str = Path(
|
||||
...,
|
||||
description="生成日期,格式:YYYY-MM-DD,例如:2026-05-27",
|
||||
examples=["2026-05-27"],
|
||||
),
|
||||
gen_type: str = Query(
|
||||
...,
|
||||
description="生成类型:image=图片历史,video=视频历史",
|
||||
examples=["image"],
|
||||
),
|
||||
page: int = Query(
|
||||
1,
|
||||
ge=1,
|
||||
description="当前日期下的记录分页页码,从1开始",
|
||||
examples=[1],
|
||||
),
|
||||
page_size: int = Query(
|
||||
10,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="当前日期下每页返回的生成记录数量,范围 1~100",
|
||||
examples=[10],
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_generation_history_day_items(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
gen_type=gen_type,
|
||||
generated_date=generated_date,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}",
|
||||
response_model=GenerationAITaskOut,
|
||||
summary="获取AI生成任务详情",
|
||||
description=(
|
||||
"根据任务ID获取当前登录用户的AI生成任务详情。"
|
||||
"只能查询当前用户自己的任务,且只查询 generation_mode=chatapi_async 的任务。"
|
||||
"如果任务不存在或不属于当前用户,返回404。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"description": "查询成功,返回任务详情",
|
||||
},
|
||||
401: {
|
||||
"description": "未登录或 Token 无效",
|
||||
},
|
||||
404: {
|
||||
"description": "任务不存在,或任务不属于当前用户",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def get_task(
|
||||
task_id: str = Path(
|
||||
...,
|
||||
description="AI生成任务ID",
|
||||
examples=["0019e0a44895b6d837d"],
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ChatGenerationTask).where(
|
||||
ChatGenerationTask.id == task_id,
|
||||
ChatGenerationTask.user_id == current_user.id,
|
||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return record_to_out(task)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{task_id}/retry",
|
||||
response_model=GenerationAIRetryOut,
|
||||
summary="重试失败的AI生成任务",
|
||||
description=(
|
||||
"重新投递一个失败的AI生成任务。"
|
||||
"只有 status=failed 的任务允许重试。"
|
||||
"重试次数超过3次后不允许继续重试。"
|
||||
"后端会根据任务当前数据自动判断应从哪个流水线阶段恢复,"
|
||||
"例如重新优化提示词、重新创建第三方任务、继续轮询远程结果或重新下载结果。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"description": "任务重新投递成功",
|
||||
},
|
||||
400: {
|
||||
"description": "任务状态不允许重试,或已超过最大重试次数",
|
||||
},
|
||||
401: {
|
||||
"description": "未登录或 Token 无效",
|
||||
},
|
||||
404: {
|
||||
"description": "任务不存在,或任务不属于当前用户",
|
||||
},
|
||||
503: {
|
||||
"description": "Celery 未启用或消息队列未配置",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def retry_task(
|
||||
task_id: str = Path(
|
||||
...,
|
||||
description="需要重试的AI生成任务ID",
|
||||
examples=["0019e0a44895b6d837d"],
|
||||
),
|
||||
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")
|
||||
|
||||
result = await db.execute(
|
||||
select(ChatGenerationTask).where(
|
||||
ChatGenerationTask.id == task_id,
|
||||
ChatGenerationTask.user_id == current_user.id,
|
||||
ChatGenerationTask.generation_mode == "chatapi_async",
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if task.status != "failed":
|
||||
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
|
||||
|
||||
task.status = "generating"
|
||||
task.error_message = None
|
||||
task.retry_count = (task.retry_count or 0) + 1
|
||||
if task.retry_count > 3:
|
||||
raise HTTPException(status_code=400, detail="任务已超过最大重试次数")
|
||||
|
||||
# Resume from the earliest missing stage.
|
||||
if not task.optimized_prompt:
|
||||
task.pipeline_stage = "queued"
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
chatapi_create_generation_task.delay(task.id)
|
||||
elif not task.seedance_task_id and not task.remote_result_url:
|
||||
task.pipeline_stage = "creating_provider_task"
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
chatapi_create_generation_task.delay(task.id)
|
||||
elif task.seedance_task_id and not task.remote_result_url:
|
||||
task.pipeline_stage = "waiting_remote"
|
||||
from app.tasks.generation_poll_tasks import poll_generation_task
|
||||
|
||||
poll_generation_task.delay(task.id)
|
||||
elif task.remote_result_url and not (task.image_url or task.video_url):
|
||||
task.pipeline_stage = "result_ready"
|
||||
from app.tasks.generation_download_tasks import download_generation_result_task
|
||||
|
||||
download_generation_result_task.delay(task.id)
|
||||
else:
|
||||
task.status = "completed"
|
||||
task.pipeline_stage = "done"
|
||||
|
||||
await db.commit()
|
||||
return GenerationAIRetryOut(
|
||||
id=task.id,
|
||||
status=task.status,
|
||||
pipeline_stage=task.pipeline_stage,
|
||||
message="任务已重新投递",
|
||||
)
|
||||
Reference in New Issue
Block a user