爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制

This commit is contained in:
2026-07-21 14:01:08 +08:00
parent 40efcf55cf
commit 79c09151ba
60 changed files with 4250 additions and 924 deletions
+32 -258
View File
@@ -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="请选择文件")
+158 -44
View File
@@ -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(
+12 -18
View File
@@ -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",
)
+8
View File
@@ -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)
+272
View File
@@ -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)