爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -24,7 +24,6 @@ from app.models.credit_ratio import CreditRatio
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.enums.user import FrontendUserKind, UserType
|
||||
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
||||
from app.enums.generation_status import GenerationRecordPipelineStage
|
||||
from app.schemas.admin import (
|
||||
CreditAdjustRequest,
|
||||
ModelConfigCreate,
|
||||
@@ -38,17 +37,12 @@ from app.schemas.admin import (
|
||||
UpdateMenusRequest,
|
||||
ResetPasswordRequest,
|
||||
UpdateFrontendUserKindRequest,
|
||||
OperationLogOut,
|
||||
)
|
||||
from app.schemas.team import UpdateUserTeamRequest
|
||||
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
||||
from app.schemas.industry import IndustryConfigCreate
|
||||
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
||||
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
||||
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
||||
from app.services.generation.pipeline.db_lock_service import (
|
||||
DatabaseRowLockBusy,
|
||||
execute_with_lock_timeout,
|
||||
)
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.services.credit_record_meta_service import build_admin_adjust_meta
|
||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||
@@ -57,23 +51,26 @@ from app.services.auth import hash_password, verify_password
|
||||
from app.services.operation_log import log_operation
|
||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.payment import sync_pending_orders, process_refund
|
||||
from app.services.payment import process_refund
|
||||
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
||||
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||
|
||||
from app.services.generation.billing_service import (
|
||||
OWNER_GENERATION_RECORD,
|
||||
charge_generation_media_for_record,
|
||||
get_next_credit_attempt_no,
|
||||
)
|
||||
from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
|
||||
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _safe_json_object(value: str | None) -> dict | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
"""Serialize datetime as naive ISO string (UTC→CST, strip tzinfo)."""
|
||||
if dt is None:
|
||||
@@ -1917,6 +1914,8 @@ async def list_token_usage(
|
||||
async def admin_list_generation_records(
|
||||
user_id: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
engine_id: str | None = Query(None),
|
||||
include_media_references: bool | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
admin: User = Depends(get_admin_user),
|
||||
@@ -1935,13 +1934,25 @@ async def admin_list_generation_records(
|
||||
query = query.where(GenerationRecord.user_id == user_id)
|
||||
if status:
|
||||
query = query.where(GenerationRecord.status == status)
|
||||
if engine_id:
|
||||
query = query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None))
|
||||
count_query = (
|
||||
select(func.count(GenerationRecord.id))
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
|
||||
)
|
||||
if user_id:
|
||||
count_query = count_query.where(GenerationRecord.user_id == user_id)
|
||||
if status:
|
||||
count_query = count_query.where(GenerationRecord.status == status)
|
||||
if engine_id:
|
||||
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
@@ -1977,6 +1988,10 @@ async def admin_list_generation_records(
|
||||
"video_url": build_resource_signed_url(record.video_url) if record.video_url else '',
|
||||
"video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
|
||||
"references": refs,
|
||||
"engine_id": record.engine_id,
|
||||
"engine_name": (_safe_json_object(record.engine_snapshot_json) or {}).get("name"),
|
||||
"engine_snapshot": _safe_json_object(record.engine_snapshot_json),
|
||||
"include_media_references": bool(record.include_media_references),
|
||||
"credits_cost": record.credits_cost or 0,
|
||||
"text_credits_cost": record.text_credits_cost or 0,
|
||||
"text_tokens_used": record.text_tokens_used or 0,
|
||||
@@ -1997,245 +2012,6 @@ async def admin_list_generation_records(
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.put("/generation-records/{record_id}/status")
|
||||
async def admin_update_generation_status(
|
||||
record_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员只能终止正在执行或待生成的记录,禁止绕过流水线裸改生成/完成状态。"""
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(GenerationRecord).where(
|
||||
GenerationRecord.id == record_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.limit(1),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
new_status = str(body.get("status") or "").strip()
|
||||
if new_status in {"generating", "completed", "prompt_optimized"}:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="禁止直接修改为该状态;生成请调用生成接口,完成必须由下载/超分流水线落库",
|
||||
)
|
||||
if new_status != "failed":
|
||||
raise HTTPException(status_code=400, detail="该接口仅允许管理员终止任务")
|
||||
if record.status == "completed":
|
||||
raise HTTPException(status_code=409, detail="已完成记录不能直接改为失败")
|
||||
|
||||
error_message = body.get("error_message") or record.error_message or "管理员终止生成任务"
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=error_message,
|
||||
generation_attempt_no=int(record.generation_attempt_no or 1),
|
||||
)
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
record.provider_create_claim_token = None
|
||||
record.provider_create_lease_until = None
|
||||
record.poll_claim_token = None
|
||||
record.poll_lease_until = None
|
||||
record.next_poll_at = None
|
||||
record.download_claim_token = None
|
||||
record.download_lease_until = None
|
||||
record.download_next_retry_at = None
|
||||
|
||||
# 若任务已进入超分,必须同时撤销超分数据库租约;执行中的超分 Worker
|
||||
# 在回填前校验 lease_token,发现 token 被清除后会中止,不得覆盖管理员终止状态。
|
||||
from app.enums.video_upscale import VideoUpscaleStage, VideoUpscaleTaskStatus
|
||||
from app.models.video_upscale_task import VideoUpscaleTask
|
||||
|
||||
try:
|
||||
upscale_result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(VideoUpscaleTask)
|
||||
.where(VideoUpscaleTask.generation_record_id == record.id)
|
||||
.with_for_update()
|
||||
.limit(1),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
upscale = upscale_result.scalar_one_or_none()
|
||||
if upscale and upscale.status not in {
|
||||
VideoUpscaleTaskStatus.COMPLETED.value,
|
||||
VideoUpscaleTaskStatus.FAILED.value,
|
||||
}:
|
||||
upscale.status = VideoUpscaleTaskStatus.FAILED.value
|
||||
upscale.stage = VideoUpscaleStage.FAILED.value
|
||||
upscale.last_error = error_message
|
||||
upscale.failed_at = datetime.now(CST)
|
||||
upscale.next_retry_at = None
|
||||
upscale.lease_token = None
|
||||
upscale.lease_until = None
|
||||
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"管理员终止生成记录",
|
||||
"PUT",
|
||||
f"/admin/generation-records/{record_id}/status",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"record_id": record_id,
|
||||
"new_status": new_status,
|
||||
"generation_attempt_no": int(record.generation_attempt_no or 1),
|
||||
"error_message": error_message,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Redis 注册表只做调度加速;删除失败不回滚已提交的业务终止状态。
|
||||
try:
|
||||
from app.services.celery_download_recovery_service import remove_download_active
|
||||
from app.services.generation.pipeline.owner_service import redis_owner_item_id
|
||||
from app.services.redis_registry_service import redis_remove_registry_item
|
||||
from app.config import settings
|
||||
|
||||
registry_id = redis_owner_item_id(
|
||||
"generation_record",
|
||||
record_id,
|
||||
int(record.generation_attempt_no or 1),
|
||||
)
|
||||
await remove_download_active(registry_id)
|
||||
await redis_remove_registry_item(
|
||||
hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY,
|
||||
zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY,
|
||||
item_id=registry_id,
|
||||
log_context="admin_generation_record_terminate",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.post("/generation-records/{record_id}/generate")
|
||||
async def admin_generate_record_resource(
|
||||
record_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员触发 GenerationRecord 图片或视频资源生成。"""
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
prepare_generation_record_execution,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(GenerationRecord, Project.name)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(
|
||||
GenerationRecord.id == record_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update(),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
row = result.first()
|
||||
if not row:
|
||||
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=f"当前状态不允许生成{type_str}")
|
||||
if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value:
|
||||
raise HTTPException(status_code=409, detail="该任务为画质增强失败,请使用超分恢复命令处理")
|
||||
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db,
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
owner_id=record.id,
|
||||
)
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
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="不支持的分辨率")
|
||||
|
||||
from app.services.video_gen import get_active_engine
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
|
||||
engine = await get_active_engine(db)
|
||||
try:
|
||||
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
supported_provider_resolutions = []
|
||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||
db,
|
||||
target_resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
)
|
||||
record.aspect_ratio = aspect_ratio
|
||||
record.resolution = resolution
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
elif record.gen_type == GenerationType.image:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
|
||||
engine = await get_active_image_engine(db)
|
||||
record.image_size = body.get("image_size") or record.image_size or "2K"
|
||||
record.provider_generation_resolution = None
|
||||
record.video_upscale_enabled_snapshot = False
|
||||
record.video_upscale_snapshot_json = None
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的生成类型")
|
||||
|
||||
media_billing = await charge_generation_media_for_record(
|
||||
db,
|
||||
record=record,
|
||||
project_name=project_name,
|
||||
description_prefix=f"{type_str}生成(管理后台)-",
|
||||
attempt_no=attempt_no,
|
||||
engine_id=engine.id,
|
||||
)
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(media_billing.total_charged or 0), 2)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_admin_generate")
|
||||
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"管理员触发生成{type_str}: {record_id}",
|
||||
"POST",
|
||||
f"/admin/generation-records/{record_id}/generate",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"record_id": record_id,
|
||||
"gen_type": record.gen_type,
|
||||
"project_name": project_name,
|
||||
"generation_attempt_no": record.generation_attempt_no,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok", "record_id": record_id}
|
||||
|
||||
|
||||
# ── File Uploads ─────────────────────────────────────────
|
||||
|
||||
import os
|
||||
@@ -2251,7 +2027,6 @@ async def upload_pdf(
|
||||
):
|
||||
"""Upload a PDF file and save URL to system config."""
|
||||
from app.config import settings
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
@@ -2313,7 +2088,6 @@ async def upload_logo(
|
||||
):
|
||||
"""Upload a Logo image file and save URL to system config."""
|
||||
from app.config import settings
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
|
||||
@@ -55,6 +55,16 @@ from app.services.generation.billing_service import (
|
||||
get_next_credit_attempt_no,
|
||||
)
|
||||
from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
|
||||
from app.services.generation.ai.engine_service import (
|
||||
get_image_engine,
|
||||
get_video_engine,
|
||||
image_supported_sizes,
|
||||
parse_json_list,
|
||||
)
|
||||
from app.services.generation.media_reference_service import (
|
||||
calculate_media_reference_usage,
|
||||
validate_media_reference_usage_for_engine,
|
||||
)
|
||||
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
|
||||
from app.services.credit_record_meta_service import build_generation_record_prompt_meta
|
||||
from app.services.video_cover_service import async_create_video_cover_for_local_video
|
||||
@@ -70,6 +80,36 @@ router = APIRouter(prefix="/generation-records", tags=["generation"])
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
|
||||
def _engine_snapshot(record: GenerationRecord) -> dict | None:
|
||||
if not record.engine_snapshot_json:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(record.engine_snapshot_json)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _validate_video_engine_selection(engine, *, aspect_ratio: str, resolution: str, duration: int) -> None:
|
||||
ratios = [str(item) for item in parse_json_list(engine.supported_ratios, [])]
|
||||
resolutions = [str(item) for item in parse_json_list(engine.supported_resolutions, [])]
|
||||
durations = [int(item) for item in parse_json_list(engine.supported_durations, []) if str(item).isdigit()]
|
||||
if ratios and aspect_ratio not in ratios:
|
||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选画面比例")
|
||||
if resolutions and resolution not in resolutions:
|
||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选分辨率")
|
||||
if durations and duration not in durations:
|
||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选时长")
|
||||
if int(engine.max_duration or 0) > 0 and duration > int(engine.max_duration):
|
||||
raise HTTPException(status_code=400, detail="生成时长超过当前视频引擎上限")
|
||||
|
||||
|
||||
def _validate_image_engine_selection(engine, *, image_size: str) -> None:
|
||||
sizes = image_supported_sizes(engine)
|
||||
if sizes and image_size not in sizes:
|
||||
raise HTTPException(status_code=400, detail="当前图片引擎不支持所选画面分辨率")
|
||||
|
||||
|
||||
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
|
||||
refs = refs_override
|
||||
if refs is None and record.media_references:
|
||||
@@ -112,6 +152,10 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
|
||||
video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
|
||||
image_url=build_resource_signed_url(record.image_url) if record.image_url else '',
|
||||
references=refs,
|
||||
engine_id=record.engine_id,
|
||||
engine_name=(_engine_snapshot(record) or {}).get("name"),
|
||||
engine_snapshot=_engine_snapshot(record),
|
||||
include_media_references=bool(record.include_media_references),
|
||||
text_credits_cost=round(record.text_credits_cost or 0.00, 2),
|
||||
# text_tokens_used=record.text_tokens_used or 0,
|
||||
credits_cost=round(record.credits_cost or 0.00, 2),
|
||||
@@ -436,7 +480,11 @@ async def generate_record_resource(
|
||||
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
|
||||
|
||||
await assert_user_resource_capacity_available(db, current_user.id)
|
||||
attempt_no = await get_next_credit_attempt_no(db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id)
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id
|
||||
)
|
||||
selected_engine_id = req.engine_id or record.engine_id
|
||||
record.include_media_references = bool(req.include_media_references)
|
||||
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
@@ -444,56 +492,83 @@ async def generate_record_resource(
|
||||
)
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
if req.aspect_ratio not in ASPECT_RATIOS:
|
||||
aspect_ratio = req.aspect_ratio or record.aspect_ratio
|
||||
resolution = req.resolution or record.resolution
|
||||
if aspect_ratio not in ASPECT_RATIOS:
|
||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
||||
if req.resolution not in RESOLUTIONS:
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
||||
from app.services.video_gen import get_active_engine
|
||||
engine = await get_video_engine(db, selected_engine_id)
|
||||
_validate_video_engine_selection(
|
||||
engine,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
duration=int(record.duration or 5),
|
||||
)
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
|
||||
engine = await get_active_engine(db)
|
||||
try:
|
||||
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
supported_provider_resolutions = []
|
||||
supported_provider_resolutions = parse_json_list(engine.supported_resolutions, [])
|
||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||
db,
|
||||
target_resolution=req.resolution,
|
||||
aspect_ratio=req.aspect_ratio,
|
||||
target_resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
)
|
||||
billing = await charge_generation_media_by_params(
|
||||
db, user_id=current_user.id, record_id=record.id, gen_type="video",
|
||||
duration=record.duration or 5, resolution=req.resolution, engine_id=engine.id,
|
||||
project_name=project_name, description_prefix=project_name + "-",
|
||||
owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no,
|
||||
)
|
||||
record.aspect_ratio = req.aspect_ratio
|
||||
record.resolution = req.resolution
|
||||
record.aspect_ratio = aspect_ratio
|
||||
record.resolution = resolution
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
else:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
engine = await get_active_image_engine(db)
|
||||
engine = await get_image_engine(db, selected_engine_id)
|
||||
image_size = req.image_size or record.image_size or engine.default_size or "2K"
|
||||
billing = await charge_generation_media_by_params(
|
||||
db, user_id=current_user.id, record_id=record.id, gen_type="image",
|
||||
image_size=image_size, engine_id=engine.id, project_name=project_name,
|
||||
description_prefix=project_name + "-", owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no,
|
||||
)
|
||||
_validate_image_engine_selection(engine, image_size=image_size)
|
||||
record.image_size = image_size
|
||||
record.provider_generation_resolution = None
|
||||
record.video_upscale_enabled_snapshot = False
|
||||
record.video_upscale_snapshot_json = None
|
||||
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(billing.total_charged or 0), 2)
|
||||
reference_usage = calculate_media_reference_usage(
|
||||
record.media_references,
|
||||
include=bool(record.include_media_references),
|
||||
)
|
||||
validate_media_reference_usage_for_engine(
|
||||
reference_usage,
|
||||
gen_type=record.gen_type,
|
||||
engine=engine,
|
||||
)
|
||||
billing = await charge_generation_media_for_record(
|
||||
db,
|
||||
record=record,
|
||||
project_name=project_name,
|
||||
description_prefix=project_name + "-",
|
||||
attempt_no=attempt_no,
|
||||
engine_id=engine.id,
|
||||
)
|
||||
record.credits_cost = round(
|
||||
float(record.credits_cost or 0) + float(billing.total_charged or 0), 2
|
||||
)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_api_generate")
|
||||
record_id_snapshot = str(record.id)
|
||||
await commit_and_enqueue_generation_record(
|
||||
db, record, reason="generation_record_api_generate"
|
||||
)
|
||||
|
||||
refreshed = await db.execute(
|
||||
select(GenerationRecord, Project.name)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(GenerationRecord.id == record_id_snapshot)
|
||||
.limit(1)
|
||||
)
|
||||
refreshed_row = refreshed.first()
|
||||
if not refreshed_row:
|
||||
raise RecordNotFoundError()
|
||||
record, project_name = refreshed_row
|
||||
refs = await resolve_private_portrait_reference_display_urls(
|
||||
db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id
|
||||
db,
|
||||
json.loads(record.media_references) if record.media_references else None,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return _record_to_out(record, project_name, refs_override=refs)
|
||||
|
||||
@@ -529,43 +604,82 @@ async def retry_generation(
|
||||
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
|
||||
|
||||
await assert_user_resource_capacity_available(db, current_user.id)
|
||||
attempt_no = await get_next_credit_attempt_no(db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id)
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id
|
||||
)
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
prepare_generation_record_execution,
|
||||
)
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
from app.services.video_gen import get_active_engine
|
||||
engine = await get_video_engine(db, record.engine_id)
|
||||
_validate_video_engine_selection(
|
||||
engine,
|
||||
aspect_ratio=record.aspect_ratio or "16:9",
|
||||
resolution=record.resolution or "480p",
|
||||
duration=int(record.duration or 5),
|
||||
)
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
engine = await get_active_engine(db)
|
||||
try:
|
||||
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
supported_provider_resolutions = []
|
||||
|
||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||
db, target_resolution=record.resolution or "480p", aspect_ratio=record.aspect_ratio or "16:9",
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
db,
|
||||
target_resolution=record.resolution or "480p",
|
||||
aspect_ratio=record.aspect_ratio or "16:9",
|
||||
supported_provider_resolutions=parse_json_list(engine.supported_resolutions, []),
|
||||
)
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
else:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
engine = await get_active_image_engine(db)
|
||||
engine = await get_image_engine(db, record.engine_id)
|
||||
_validate_image_engine_selection(
|
||||
engine, image_size=record.image_size or engine.default_size or "2K"
|
||||
)
|
||||
|
||||
reference_usage = calculate_media_reference_usage(
|
||||
record.media_references,
|
||||
include=bool(record.include_media_references),
|
||||
)
|
||||
validate_media_reference_usage_for_engine(
|
||||
reference_usage,
|
||||
gen_type=record.gen_type,
|
||||
engine=engine,
|
||||
)
|
||||
billing = await charge_generation_media_for_record(
|
||||
db, record=record, project_name=project_name, description_prefix="资源生成重试-", attempt_no=attempt_no, engine_id=engine.id
|
||||
db,
|
||||
record=record,
|
||||
project_name=project_name,
|
||||
description_prefix="资源生成重试-",
|
||||
attempt_no=attempt_no,
|
||||
engine_id=engine.id,
|
||||
)
|
||||
record.credits_cost = round(
|
||||
float(record.credits_cost or 0) + float(billing.total_charged or 0), 2
|
||||
)
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(billing.total_charged or 0), 2)
|
||||
record.manual_retry_count = int(record.manual_retry_count or 0) + 1
|
||||
record.retry_count = int(record.manual_retry_count or 0)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_api_retry")
|
||||
record_id_snapshot = str(record.id)
|
||||
await commit_and_enqueue_generation_record(
|
||||
db, record, reason="generation_record_api_retry"
|
||||
)
|
||||
|
||||
refreshed = await db.execute(
|
||||
select(GenerationRecord, Project.name)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(GenerationRecord.id == record_id_snapshot)
|
||||
.limit(1)
|
||||
)
|
||||
refreshed_row = refreshed.first()
|
||||
if not refreshed_row:
|
||||
raise RecordNotFoundError()
|
||||
record, project_name = refreshed_row
|
||||
refs = await resolve_private_portrait_reference_display_urls(
|
||||
db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id
|
||||
db,
|
||||
json.loads(record.media_references) if record.media_references else None,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return _record_to_out(record, project_name, refs_override=refs)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.common import ModuleProjectStatusEnum
|
||||
from app.enums.common import ModuleProjectStatusEnum, ModuleEventTypeEnum
|
||||
from app.enums.generation_task import GenerationOwnerType
|
||||
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
|
||||
from app.schemas.hot_opening_replicate import (
|
||||
@@ -42,7 +42,7 @@ from app.services.hot_opening_replicate_service import (
|
||||
update_hot_opening_material_input,
|
||||
update_hot_opening_video_prompt_schema,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_error
|
||||
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
||||
from app.services.module_async_recovery_service import (
|
||||
TASK_HOT_IMAGE_PROMPT,
|
||||
TASK_HOT_VIDEO_PROMPT,
|
||||
@@ -283,29 +283,17 @@ async def create_task(
|
||||
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 bind_upload_resources(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project_id_value,
|
||||
resource_ids=[req.material_video_resource_id, req.material_image_resource_id],
|
||||
urls=[req.material_video_url, req.material_image_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"创建爆款开头复刻项目失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}")
|
||||
|
||||
return await _reload_project_detail(db, current_user, project_id_value)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
|
||||
user_id=current_user.id,
|
||||
message="拦截爆款开头复刻 V1 创建请求",
|
||||
detail={"api_version": "v1", "flow_version": "v1"},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="V1 创建流程已停止,请使用 V2 API",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.common import ModuleEventTypeEnum
|
||||
from app.enums.generation_task import GenerationOwnerType
|
||||
from app.enums.shot_replicate import (
|
||||
ModuleCodeEnum,
|
||||
@@ -862,24 +863,17 @@ async def create_replication_project_from_segment(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||||
project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req)
|
||||
project_id = project.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"创建拆镜复刻项目失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"创建拆镜复刻项目失败: {exc}")
|
||||
|
||||
return ShotReplicateActionOut(
|
||||
message="已从拆镜片段创建复刻项目,素材视频已锁定",
|
||||
project_id=project_id,
|
||||
step_id=None,
|
||||
detail=await _reload_project_detail(db, current_user, project_id),
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
|
||||
user_id=current_user.id,
|
||||
step_id=segment_id,
|
||||
message="拦截拆镜复刻 V1 创建请求",
|
||||
detail={"api_version": "v1", "flow_version": "v1", "segment_id": segment_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="V1 创建流程已停止,请使用 V2 API",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v2.hot_opening_replicate import router as hot_opening_router
|
||||
from app.api.v2.shot_replicate import router as shot_replicate_router
|
||||
|
||||
api_router_v2 = APIRouter()
|
||||
api_router_v2.include_router(hot_opening_router)
|
||||
api_router_v2.include_router(shot_replicate_router)
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.hot_opening_replicate import HotOpeningActionOut, HotOpeningDeleteOut, HotOpeningTaskDetailOut
|
||||
from app.schemas.module_generation_v2 import (
|
||||
HotOpeningTaskCreateV2,
|
||||
ModuleVideoPromptRetryV2,
|
||||
ModuleVideoPromptSchemaUpdateV2,
|
||||
)
|
||||
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
|
||||
from app.services.hot_opening_replicate_service import project_to_detail_out
|
||||
from app.services.module_generation_v2.config import HOT_OPENING_V2
|
||||
from app.services.module_generation_v2.dispatch_service import (
|
||||
dispatch_video_prompt_v2,
|
||||
ensure_v2_celery_enabled,
|
||||
)
|
||||
from app.services.module_generation_v2.flow_service import (
|
||||
create_hot_opening_project_v2,
|
||||
delete_project_v2,
|
||||
generate_video_from_prompt_v2,
|
||||
get_v2_project_for_user,
|
||||
is_project_idempotency_conflict,
|
||||
mark_video_prompt_dispatch_failed_v2,
|
||||
rebuild_video_prompt_step_v2,
|
||||
update_video_prompt_schema_v2,
|
||||
)
|
||||
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
|
||||
|
||||
router = APIRouter(prefix="/hot-opening-replications", tags=["hot-opening-replications-v2"])
|
||||
|
||||
|
||||
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> HotOpeningTaskDetailOut:
|
||||
project = await get_v2_project_for_user(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
project_id=project_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
return await project_to_detail_out(db, project)
|
||||
|
||||
|
||||
async def _dispatch_or_mark_failed(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
) -> None:
|
||||
dispatch = await dispatch_video_prompt_v2(
|
||||
config=HOT_OPENING_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
)
|
||||
if dispatch.recoverable:
|
||||
return
|
||||
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
|
||||
await mark_video_prompt_dispatch_failed_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
error_message=error_message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=error_message)
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=HotOpeningTaskDetailOut)
|
||||
async def create_task_v2(
|
||||
req: HotOpeningTaskCreateV2 = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
result = await create_hot_opening_project_v2(db, current_user=current_user, req=req)
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if not req.idempotency_key or not is_project_idempotency_conflict(exc):
|
||||
raise HTTPException(status_code=500, detail="项目创建失败") from exc
|
||||
# 同幂等键并发请求由唯一索引收敛;回查已提交项目并按幂等成功返回。
|
||||
result = await create_hot_opening_project_v2(db, current_user=current_user, req=req)
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail="创建爆款复刻 V2 项目失败") from exc
|
||||
|
||||
if created_new:
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id)
|
||||
return await _detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@router.get("/tasks/{project_id}", response_model=HotOpeningTaskDetailOut)
|
||||
async def get_task_v2(
|
||||
project_id: str = Path(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{project_id}/steps/{step_id}/retry-video-prompt",
|
||||
response_model=HotOpeningActionOut,
|
||||
)
|
||||
async def retry_video_prompt_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
req: ModuleVideoPromptRetryV2,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
project, new_step = await rebuild_video_prompt_step_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
source_prompt_step_id=step_id,
|
||||
video_config=req.video_config,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(new_step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value)
|
||||
return HotOpeningActionOut(
|
||||
message="视频提词已重新提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tasks/{project_id}/steps/{step_id}/video-prompt-schema",
|
||||
response_model=HotOpeningActionOut,
|
||||
)
|
||||
async def update_video_prompt_schema_route_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
req: ModuleVideoPromptSchemaUpdateV2,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await update_video_prompt_schema_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
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
|
||||
return HotOpeningActionOut(
|
||||
message="视频提词已保存",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{project_id}/steps/{step_id}/generate-video",
|
||||
response_model=HotOpeningActionOut,
|
||||
)
|
||||
async def generate_video_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
project, step, task = await generate_video_from_prompt_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
prompt_step_id=step_id,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
task_id = str(task.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
# commit 后重新读取,避免 ORM expire/lazy-load 风险。
|
||||
queued_task = await db.get(ChatGenerationTask, task_id)
|
||||
if queued_task is None:
|
||||
raise HTTPException(status_code=500, detail="视频生成任务提交后无法重新读取")
|
||||
try:
|
||||
await enqueue_generation_create(queued_task, reason="hot_opening_v2_generate_video")
|
||||
except Exception as exc:
|
||||
# queued 状态已持久化,周期生成恢复任务会使用确定性 task_id 补投。
|
||||
raise HTTPException(status_code=503, detail="视频生成任务暂未投递,将由恢复任务自动补投") from exc
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="视频生成任务已提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tasks/{project_id}", response_model=HotOpeningDeleteOut)
|
||||
async def delete_project_route_v2(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload = await delete_project_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
)
|
||||
pending_ids = list(payload.get("pending_delete_resource_ids") or [])
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
if pending_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return HotOpeningDeleteOut(**payload)
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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.module_generation_v2 import (
|
||||
ModuleVideoPromptRetryV2,
|
||||
ModuleVideoPromptSchemaUpdateV2,
|
||||
ShotReplicateProjectCreateV2,
|
||||
)
|
||||
from app.schemas.shot_replicate import ShotReplicateActionOut, ShotReplicateDeleteOut, ShotReplicateTaskDetailOut
|
||||
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
|
||||
from app.services.module_generation_v2.config import SHOT_REPLICATE_V2
|
||||
from app.services.module_generation_v2.dispatch_service import (
|
||||
dispatch_video_prompt_v2,
|
||||
ensure_v2_celery_enabled,
|
||||
)
|
||||
from app.services.module_generation_v2.flow_service import (
|
||||
create_shot_replicate_project_v2,
|
||||
delete_project_v2,
|
||||
generate_video_from_prompt_v2,
|
||||
get_v2_project_for_user,
|
||||
is_project_idempotency_conflict,
|
||||
mark_video_prompt_dispatch_failed_v2,
|
||||
rebuild_video_prompt_step_v2,
|
||||
update_video_prompt_schema_v2,
|
||||
)
|
||||
from app.services.shot_replicate_flow_service import project_to_detail_out
|
||||
from app.services.shot_replicate_taskset_service import get_segment_for_user
|
||||
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
|
||||
|
||||
router = APIRouter(prefix="/shot-replications", tags=["shot-replications-v2"])
|
||||
|
||||
|
||||
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
|
||||
project = await get_v2_project_for_user(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
project_id=project_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
return await project_to_detail_out(db, project)
|
||||
|
||||
|
||||
async def _dispatch_or_mark_failed(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
) -> None:
|
||||
dispatch = await dispatch_video_prompt_v2(
|
||||
config=SHOT_REPLICATE_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
)
|
||||
if dispatch.recoverable:
|
||||
return
|
||||
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
|
||||
await mark_video_prompt_dispatch_failed_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
error_message=error_message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=error_message)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/segments/{segment_id}/replication-projects",
|
||||
response_model=ShotReplicateActionOut,
|
||||
)
|
||||
async def create_project_v2(
|
||||
segment_id: str = Path(...),
|
||||
req: ShotReplicateProjectCreateV2 = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
segment = await get_segment_for_user(
|
||||
db, segment_id=segment_id, user=current_user, for_update=True
|
||||
)
|
||||
result = await create_shot_replicate_project_v2(
|
||||
db, current_user=current_user, segment=segment, req=req
|
||||
)
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if not req.idempotency_key or not is_project_idempotency_conflict(exc):
|
||||
raise HTTPException(status_code=500, detail="项目创建失败") from exc
|
||||
segment = await get_segment_for_user(
|
||||
db, segment_id=segment_id, user=current_user, for_update=True
|
||||
)
|
||||
result = await create_shot_replicate_project_v2(
|
||||
db, current_user=current_user, segment=segment, req=req
|
||||
)
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail="创建拆镜复刻 V2 项目失败") from exc
|
||||
|
||||
if created_new:
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id)
|
||||
return ShotReplicateActionOut(
|
||||
message="V2 项目已创建,视频提词已自动提交" if created_new else "已返回现有幂等项目",
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
detail=await _detail(db, current_user, project_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ShotReplicateTaskDetailOut)
|
||||
async def get_project_v2(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/steps/{step_id}/retry-video-prompt",
|
||||
response_model=ShotReplicateActionOut,
|
||||
)
|
||||
async def retry_video_prompt_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
req: ModuleVideoPromptRetryV2,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
project, new_step = await rebuild_video_prompt_step_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
source_prompt_step_id=step_id,
|
||||
video_config=req.video_config,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(new_step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value)
|
||||
return ShotReplicateActionOut(
|
||||
message="视频提词已重新提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/projects/{project_id}/steps/{step_id}/video-prompt-schema",
|
||||
response_model=ShotReplicateActionOut,
|
||||
)
|
||||
async def update_video_prompt_schema_route_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
req: ModuleVideoPromptSchemaUpdateV2,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await update_video_prompt_schema_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
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
|
||||
return ShotReplicateActionOut(
|
||||
message="视频提词已保存",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/steps/{step_id}/generate-video",
|
||||
response_model=ShotReplicateActionOut,
|
||||
)
|
||||
async def generate_video_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
project, step, task = await generate_video_from_prompt_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
prompt_step_id=step_id,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
task_id = str(task.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
queued_task = await db.get(ChatGenerationTask, task_id)
|
||||
if queued_task is None:
|
||||
raise HTTPException(status_code=500, detail="视频生成任务提交后无法重新读取")
|
||||
try:
|
||||
await enqueue_generation_create(queued_task, reason="shot_replicate_v2_generate_video")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail="视频生成任务暂未投递,将由恢复任务自动补投") from exc
|
||||
|
||||
return ShotReplicateActionOut(
|
||||
message="视频生成任务已提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}", response_model=ShotReplicateDeleteOut)
|
||||
async def delete_project_route_v2(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload = await delete_project_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
)
|
||||
pending_ids = list(payload.get("pending_delete_resource_ids") or [])
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
if pending_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return ShotReplicateDeleteOut(**payload)
|
||||
Reference in New Issue
Block a user